diff --git a/bun.lock b/bun.lock index bfd0090e..1ccd3f88 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ "@tanstack/react-query-devtools": "^5.84.1", "@types/mdx": "^2.0.13", "@vercel/og": "^0.6.8", + "@xmtp/browser-sdk": "^5.3.0", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -313,6 +314,8 @@ "@ethersproject/wordlists": ["@ethersproject/wordlists@5.8.0", "", { "dependencies": { "@ethersproject/bytes": "^5.8.0", "@ethersproject/hash": "^5.8.0", "@ethersproject/logger": "^5.8.0", "@ethersproject/properties": "^5.8.0", "@ethersproject/strings": "^5.8.0" } }, "sha512-2df9bbXicZws2Sb5S6ET493uJ0Z84Fjr3pC4tu/qlnZERibZCeUVuqdtt+7Tv9xxhUxHoIekIA7avrKUWHrezg=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], @@ -555,6 +558,26 @@ "@paulmillr/qr": ["@paulmillr/qr@0.2.1", "", {}, "sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3-rc.1755205432459", "", {}, "sha512-n9TqjBO+uW+FJDVKSLXVCm6XGTXRdJERDLsqx/6nydlchOROot70AJUX+JFXcpLYvM7t90Mh9UB6sx2YBiPPbQ=="], @@ -877,6 +900,18 @@ "@webassemblyjs/wast-printer": ["@webassemblyjs/wast-printer@1.14.1", "", { "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw=="], + "@xmtp/browser-sdk": ["@xmtp/browser-sdk@5.3.0", "", { "dependencies": { "@xmtp/content-type-group-updated": "^2.0.2", "@xmtp/content-type-primitives": "^2.0.2", "@xmtp/content-type-text": "^2.0.2", "@xmtp/wasm-bindings": "1.6.8", "uuid": "^11.1.0" } }, "sha512-yWD/wgCwsRHo7knrdPQmqT87DQ2+t7Bm1PYDQx5ntyKskZ9dUI9NA9V2bQV5ro5tsbvLZiSR1/3U0tqd+ky4Yw=="], + + "@xmtp/content-type-group-updated": ["@xmtp/content-type-group-updated@2.0.3", "", { "dependencies": { "@xmtp/content-type-primitives": "^2.0.2", "@xmtp/proto": "3.78.0" } }, "sha512-X4h8KBZftFUGA0Zr/im22E52oDdI/krO8Ulh+PcZVvSBhr7Unj4zaDKWkNKQKylwiIcuCi6/tqf3iqfuptw9nw=="], + + "@xmtp/content-type-primitives": ["@xmtp/content-type-primitives@2.0.3", "", { "dependencies": { "@xmtp/proto": "3.78.0" } }, "sha512-JOTaNHhcqNO1uAZV9wE8TLEtEgMApuzuoAKMuMRXsKXfAb86G7Pe84moVsr20r7VaIqDjhDlzPw1GYYs7DMODw=="], + + "@xmtp/content-type-text": ["@xmtp/content-type-text@2.0.2", "", { "dependencies": { "@xmtp/content-type-primitives": "^2.0.2" } }, "sha512-nlRufOYPrG5sNbruNPCsb6Qk9/jmJ5lMooPsZEo4Dbwda/2S4bSvrrIncT6if7M2SCha3hxtlgJlFGeTtHy4gQ=="], + + "@xmtp/proto": ["@xmtp/proto@3.78.0", "", { "dependencies": { "long": "^5.2.0", "protobufjs": "^7.0.0", "rxjs": "^7.8.0", "undici": "^5.8.1" } }, "sha512-JHkkvbdyqpWo1YOg88uOrc2GAsPnfXhIWaUKyIjXQdt4LltR4iAMgXcr1CFN+xjUmOBkAwwJ2JywUYv/uA4yZA=="], + + "@xmtp/wasm-bindings": ["@xmtp/wasm-bindings@1.6.8", "", {}, "sha512-WyKGmD6PMcNTw3PYRYl1w2w4XQH2x8P7FQSrxT8JxTkKx+RnF7JZJylcH386dbKx3QW3J+3j/xfnrwN/X8dnzw=="], + "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], @@ -1533,6 +1568,8 @@ "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], @@ -1811,6 +1848,8 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + "proxy-compare": ["proxy-compare@2.6.0", "", {}, "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw=="], "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], @@ -1915,6 +1954,8 @@ "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -2095,6 +2136,8 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="], @@ -2137,7 +2180,7 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "valid-url": ["valid-url@1.0.9", "", {}, "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA=="], @@ -2233,6 +2276,8 @@ "@lens-protocol/client/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], + "@lens-protocol/metadata/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@lens-protocol/storage/zod": ["zod@3.22.4", "", {}, "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg=="], "@lit-protocol/access-control-conditions/ethers": ["ethers@5.8.0", "", { "dependencies": { "@ethersproject/abi": "5.8.0", "@ethersproject/abstract-provider": "5.8.0", "@ethersproject/abstract-signer": "5.8.0", "@ethersproject/address": "5.8.0", "@ethersproject/base64": "5.8.0", "@ethersproject/basex": "5.8.0", "@ethersproject/bignumber": "5.8.0", "@ethersproject/bytes": "5.8.0", "@ethersproject/constants": "5.8.0", "@ethersproject/contracts": "5.8.0", "@ethersproject/hash": "5.8.0", "@ethersproject/hdnode": "5.8.0", "@ethersproject/json-wallets": "5.8.0", "@ethersproject/keccak256": "5.8.0", "@ethersproject/logger": "5.8.0", "@ethersproject/networks": "5.8.0", "@ethersproject/pbkdf2": "5.8.0", "@ethersproject/properties": "5.8.0", "@ethersproject/providers": "5.8.0", "@ethersproject/random": "5.8.0", "@ethersproject/rlp": "5.8.0", "@ethersproject/sha2": "5.8.0", "@ethersproject/signing-key": "5.8.0", "@ethersproject/solidity": "5.8.0", "@ethersproject/strings": "5.8.0", "@ethersproject/transactions": "5.8.0", "@ethersproject/units": "5.8.0", "@ethersproject/wallet": "5.8.0", "@ethersproject/web": "5.8.0", "@ethersproject/wordlists": "5.8.0" } }, "sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg=="], @@ -2273,6 +2318,8 @@ "@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3-rc.1747678980544", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3-rc.1747678980544" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/3ir02/x/KP0i0t+G2sF3adNzqoaxrx8vDIR/Cz0wktOJib06f/R5A2kZSAl7YywzGeHcbXl5H0k6RyT3sSUEA=="], @@ -2477,6 +2524,8 @@ "porto/ox": ["ox@0.9.6", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.0.9", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-8SuCbHPvv2eZLYXrNmC0EC12rdzXQLdhnOMlHDW2wiCPLxBrOOJwX5L5E61by+UjTPOryqQiRSnjIKCI+GykKg=="], + "protobufjs/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -2513,12 +2562,18 @@ "@metamask/json-rpc-engine/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/json-rpc-engine/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@metamask/json-rpc-middleware-stream/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/json-rpc-middleware-stream/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@metamask/providers/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@9.3.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "debug": "^4.3.4", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-w8CVbdkDrVXFJbfBSlDfafDR6BAkpDmv1bC1UJVCoVny5tW2RKAdn9i68Xf7asYT4TnUhl/hN4zfUiKQq9II4g=="], "@metamask/providers/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/providers/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client": ["@walletconnect/sign-client@2.21.0", "", { "dependencies": { "@walletconnect/core": "2.21.0", "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/logger": "2.1.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "events": "3.3.0" } }, "sha512-z7h+PeLa5Au2R591d/8ZlziE0stJvdzP9jNFzFolf2RG/OiXulgFKum8PrIyXy+Rg2q95U9nRVUF9fWcn78yBA=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/types": ["@walletconnect/types@2.21.0", "", { "dependencies": { "@walletconnect/events": "1.0.1", "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "events": "3.3.0" } }, "sha512-ll+9upzqt95ZBWcfkOszXZkfnpbJJ2CmxMfGgE5GmhdxxxCcO5bGhXkI+x8OpiS555RJ/v/sXJYMSOLkmu4fFw=="], @@ -2587,6 +2642,8 @@ "porto/ox/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "protobufjs/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + "styled-components/@emotion/is-prop-valid/@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], "styled-components/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], @@ -2599,10 +2656,16 @@ "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@metamask/providers/@metamask/rpc-errors/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/providers/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/sign-client/@walletconnect/core": ["@walletconnect/core@2.21.0", "", { "dependencies": { "@walletconnect/heartbeat": "1.2.2", "@walletconnect/jsonrpc-provider": "1.0.14", "@walletconnect/jsonrpc-types": "1.0.4", "@walletconnect/jsonrpc-utils": "1.0.8", "@walletconnect/jsonrpc-ws-connection": "1.0.16", "@walletconnect/keyvaluestorage": "1.1.1", "@walletconnect/logger": "2.1.2", "@walletconnect/relay-api": "1.0.11", "@walletconnect/relay-auth": "1.1.0", "@walletconnect/safe-json": "1.0.2", "@walletconnect/time": "1.0.2", "@walletconnect/types": "2.21.0", "@walletconnect/utils": "2.21.0", "@walletconnect/window-getters": "1.0.1", "es-toolkit": "1.33.0", "events": "3.3.0", "uint8arrays": "3.1.0" } }, "sha512-o6R7Ua4myxR8aRUAJ1z3gT9nM+jd2B2mfamu6arzy1Cc6vi10fIwFWb6vg3bC8xJ6o9H3n/cN5TOW3aA9Y1XVw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/@noble/ciphers": ["@noble/ciphers@1.2.1", "", {}, "sha512-rONPWMC7PeExE077uLE4oqWrZ1IvAfz3oH9LibVAcVCopJiA9R62uavnbEzdkVmJYI6M6Zgkbeb07+tWjlq2XA=="], @@ -2647,6 +2710,8 @@ "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@metamask/eth-json-rpc-provider/@metamask/json-rpc-engine/@metamask/rpc-errors/@metamask/utils/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/@scure/bip32": ["@scure/bip32@1.6.2", "", { "dependencies": { "@noble/curves": "~1.8.1", "@noble/hashes": "~1.7.1", "@scure/base": "~1.2.2" } }, "sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw=="], "@reown/appkit-controllers/@walletconnect/universal-provider/@walletconnect/utils/viem/@scure/bip39": ["@scure/bip39@1.5.4", "", { "dependencies": { "@noble/hashes": "~1.7.1", "@scure/base": "~1.2.4" } }, "sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA=="], diff --git a/next.config.mjs b/next.config.mjs index 41220e4a..4caf08f0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -33,6 +33,19 @@ const config = { }, ], }, + + // XMTP WASM support + experimental: { + webpackBuildWorker: true, + }, + + webpack: (config) => { + config.experiments = { + ...config.experiments, + asyncWebAssembly: true, + }; + return config; + }, }; export default withMDX(config); diff --git a/package.json b/package.json index 71e84e85..8a58d7c6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@tanstack/react-query-devtools": "^5.84.1", "@types/mdx": "^2.0.13", "@vercel/og": "^0.6.8", + "@xmtp/browser-sdk": "^5.3.0", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/(messages)/layout.tsx b/src/app/(messages)/layout.tsx new file mode 100644 index 00000000..5d3ff05c --- /dev/null +++ b/src/app/(messages)/layout.tsx @@ -0,0 +1,9 @@ +import type { PropsWithChildren } from "react"; + +export default function MessagesLayout({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/(messages)/messages/[conversationId]/page.tsx b/src/app/(messages)/messages/[conversationId]/page.tsx new file mode 100644 index 00000000..fbf3022f --- /dev/null +++ b/src/app/(messages)/messages/[conversationId]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { ConversationView } from "~/components/messaging/ConversationView"; + +export const metadata: Metadata = { + title: "Conversation | Flow Talk", + description: "Direct message conversation", +}; + +interface PageProps { + params: Promise<{ conversationId: string }>; +} + +export default async function ConversationPage({ params }: PageProps) { + const { conversationId } = await params; + return ; +} diff --git a/src/app/(messages)/messages/page.tsx b/src/app/(messages)/messages/page.tsx new file mode 100644 index 00000000..77501deb --- /dev/null +++ b/src/app/(messages)/messages/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { ConversationList } from "~/components/messaging/ConversationList"; + +export const metadata: Metadata = { + title: "Messages | Flow Talk", + description: "Your direct messages", +}; + +export default function MessagesPage() { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3459d4f4..7de5e379 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { DeletedPostsProvider } from "~/components/DeletedPostsContext"; import { FilteredUsersProvider } from "~/components/FilteredUsersContext"; import { FloatingAudioPlayer } from "~/components/FloatingAudioPlayer"; +import { XmtpProvider } from "~/components/messaging/XmtpContext"; import { NotificationsProvider } from "~/components/notifications/NotificationsContext"; import { Providers } from "~/components/Providers"; import { UserProvider } from "~/components/user/UserContext"; @@ -37,18 +38,20 @@ export default async function RootLayout({ children }: { children: React.ReactNo - {/* */} - - - - - + + {/* */} + + + + + - {/* */} -
{children}
- {/*
*/} + {/* */} +
{children}
+ {/*
*/} - + + diff --git a/src/atoms/messaging.ts b/src/atoms/messaging.ts new file mode 100644 index 00000000..af15326b --- /dev/null +++ b/src/atoms/messaging.ts @@ -0,0 +1,25 @@ +import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; + +// Active conversation tracking +export const activeConversationIdAtom = atom(null); + +// Message draft persistence (per conversation) +export const messageDraftsAtom = atomWithStorage>( + "xmtp_message_drafts", + {}, +); + +// Consent filter state +export const consentFilterAtom = atom<"allowed" | "requests" | "all">( + "allowed", +); + +// Unread tracking (conversation ID -> last read timestamp) +export const lastReadTimestampsAtom = atomWithStorage>( + "xmtp_last_read", + {}, +); + +// XMTP client initialized state +export const xmtpInitializedAtom = atom(false); diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index 5d7517a3..6fed9c76 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -1,7 +1,7 @@ "use client"; import type { User } from "@cartel-sh/ui"; -import { Bookmark, BookOpenIcon, Heart, LogInIcon } from "lucide-react"; +import { Bookmark, BookOpenIcon, Heart, LogInIcon, MessageSquare } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useTheme } from "next-themes"; import { useEffect, useRef, useState } from "react"; @@ -17,6 +17,7 @@ import { UsersIcon, type UsersIconHandle } from "~/components/icons/UsersIcon"; import { Dock } from "~/components/ui/dock"; import { useAuth } from "~/hooks/useSiweAuth"; import { cn } from "~/utils"; +import { useXmtp } from "../messaging/XmtpContext"; import { useNotifications } from "../notifications/NotificationsContext"; import PostComposer, { type PostComposerHandle } from "../post/PostComposer"; import { Dialog, DialogContent } from "../ui/dialog"; @@ -32,6 +33,7 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) { const router = useRouter(); const pathname = usePathname(); const { newCount } = useNotifications(); + const { unreadCount: messagesUnreadCount } = useXmtp(); const { theme, setTheme } = useTheme(); const { signOut } = useAuth(); const composerRef = useRef(null); @@ -57,6 +59,7 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) { const isHome = pathname === "/home"; const isCommunities = pathname === "/communities"; const isActivity = pathname === "/activity"; + const isMessages = pathname === "/messages" || pathname.startsWith("/messages/"); const isBookmarks = pathname === "/bookmarks"; const isSettings = pathname === "/settings"; const isDocs = pathname === "/docs" || pathname.startsWith("/docs/"); @@ -83,10 +86,6 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) { ); - const bookmarkIcon = ( - - ); - const homeExtra = (
+ ), + label: "Messages", + onClick: () => router.push("/messages"), + isActive: isMessages, + } as const; + const postDockItem = { customIcon: , label: "Post", @@ -216,6 +231,18 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) { {theme === "light" ? : } Theme + + + + ); + } + + // Show loading state + if (isInitializing || isLoading) { + return ( +
+ +
+ ); + } + + // Filter conversations by consent state + const allowedConversations = + conversations?.filter((c) => c.consentState === ConsentState.Allowed) || []; + const requestConversations = + conversations?.filter((c) => c.consentState === ConsentState.Unknown) || []; + + return ( +
+
+ setFilter(v as typeof filter)} + > + + + Inbox + {allowedConversations.length > 0 && ( + + ({allowedConversations.length}) + + )} + + + Requests + {requestConversations.length > 0 && ( + + ({requestConversations.length}) + + )} + + + + + +
+ +
+ {filter === "allowed" && ( + <> + {allowedConversations.length === 0 ? ( +
+

No conversations yet

+

+ Start a new conversation to get started +

+
+ ) : ( + allowedConversations.map((conv) => ( + + )) + )} + + )} + + {filter === "requests" && ( + <> + {requestConversations.length === 0 ? ( +
+

No message requests

+
+ ) : ( + requestConversations.map((conv) => ( + + )) + )} + + )} +
+ + +
+ ); +} diff --git a/src/components/messaging/ConversationView.tsx b/src/components/messaging/ConversationView.tsx new file mode 100644 index 00000000..27a786a9 --- /dev/null +++ b/src/components/messaging/ConversationView.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { ConsentState, type Dm, type Group } from "@xmtp/browser-sdk"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowLeft, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import { useMessages } from "~/hooks/useMessages"; +import { Button } from "../ui/button"; +import { ScrollArea } from "../ui/scroll-area"; +import { MessageInput } from "./MessageInput"; +import { MessageItem } from "./MessageItem"; +import { useXmtp } from "./XmtpContext"; + +interface ConversationViewProps { + conversationId: string; +} + +type ConversationMeta = { + conversation: Dm | Group; + peerInboxId: string; + consentState: ConsentState; +}; + +export function ConversationView({ conversationId }: ConversationViewProps) { + const { client, isReady } = useXmtp(); + const queryClient = useQueryClient(); + + // Fetch the conversation asynchronously + const { data: conversationMeta, isLoading: isLoadingConversation } = useQuery( + { + queryKey: ["xmtp-conversation", conversationId], + queryFn: async (): Promise => { + if (!client) return null; + const conv = await client.conversations.getConversationById( + conversationId, + ); + if (!conv) return null; + + const [consentState] = await Promise.all([conv.consentState()]); + + // Get peer inbox ID (only for DMs) + let peerInboxId = ""; + if ("peerInboxId" in conv) { + peerInboxId = await (conv as Dm).peerInboxId(); + } + + return { + conversation: conv, + peerInboxId, + consentState, + }; + }, + enabled: !!client, + staleTime: 30 * 1000, + }, + ); + + const { data: messages, isLoading: isLoadingMessages } = useMessages( + conversationMeta?.conversation, + ); + + const handleSend = useCallback( + async (content: string) => { + if (!conversationMeta?.conversation) return; + + try { + await conversationMeta.conversation.send(content); + // Invalidate messages cache to refresh + queryClient.invalidateQueries({ + queryKey: ["xmtp-messages", conversationId], + }); + } catch (err) { + console.error("Failed to send message:", err); + throw err; + } + }, + [conversationMeta?.conversation, conversationId, queryClient], + ); + + const handleAccept = useCallback(async () => { + if (!conversationMeta?.conversation) return; + try { + await conversationMeta.conversation.updateConsentState(ConsentState.Allowed); + queryClient.invalidateQueries({ + queryKey: ["xmtp-conversations"], + }); + queryClient.invalidateQueries({ + queryKey: ["xmtp-conversation", conversationId], + }); + toast.success("Conversation accepted"); + } catch (err) { + console.error("Failed to accept conversation:", err); + toast.error("Failed to accept conversation"); + } + }, [conversationMeta?.conversation, conversationId, queryClient]); + + const handleBlock = useCallback(async () => { + if (!conversationMeta?.conversation) return; + try { + await conversationMeta.conversation.updateConsentState(ConsentState.Denied); + queryClient.invalidateQueries({ + queryKey: ["xmtp-conversations"], + }); + queryClient.invalidateQueries({ + queryKey: ["xmtp-conversation", conversationId], + }); + toast.success("Conversation blocked"); + } catch (err) { + console.error("Failed to block conversation:", err); + toast.error("Failed to block conversation"); + } + }, [conversationMeta?.conversation, conversationId, queryClient]); + + if (!isReady || isLoadingConversation) { + return ( +
+ +
+ ); + } + + if (!conversationMeta) { + return ( +
+

Conversation not found

+ +
+ ); + } + + const { peerInboxId, consentState } = conversationMeta; + const isRequest = consentState === ConsentState.Unknown; + const isBlocked = consentState === ConsentState.Denied; + + const displayName = peerInboxId + ? `${peerInboxId.slice(0, 6)}...${peerInboxId.slice(-4)}` + : "Conversation"; + + return ( +
+ {/* Header */} +
+ +
+

{displayName}

+
+
+ + {/* Request banner */} + {isRequest && ( +
+

+ This is a message request. Accept to start chatting. +

+
+ + +
+
+ )} + + {/* Messages */} + +
+ {isLoadingMessages ? ( +
+ +
+ ) : messages?.length === 0 ? ( +
+

No messages yet

+

+ Send a message to start the conversation +

+
+ ) : ( + messages?.map((msg) => ( + + )) + )} +
+
+ + {/* Input */} + {!isBlocked && ( + + )} + + {isBlocked && ( +
+ This conversation is blocked +
+ )} +
+ ); +} diff --git a/src/components/messaging/MessageInput.tsx b/src/components/messaging/MessageInput.tsx new file mode 100644 index 00000000..b5be33fb --- /dev/null +++ b/src/components/messaging/MessageInput.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Loader2, Send } from "lucide-react"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; + +interface MessageInputProps { + onSend: (content: string) => Promise; + disabled?: boolean; + placeholder?: string; +} + +export function MessageInput({ + onSend, + disabled = false, + placeholder = "Type a message...", +}: MessageInputProps) { + const [message, setMessage] = useState(""); + const [isSending, setIsSending] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = message.trim(); + if (!trimmed || isSending || disabled) return; + + setIsSending(true); + try { + await onSend(trimmed); + setMessage(""); + } catch (err) { + console.error("Failed to send message:", err); + toast.error("Failed to send message"); + } finally { + setIsSending(false); + } + }, + [message, isSending, disabled, onSend], + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + + return ( +
+ setMessage(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled || isSending} + className="flex-1" + /> + +
+ ); +} diff --git a/src/components/messaging/MessageItem.tsx b/src/components/messaging/MessageItem.tsx new file mode 100644 index 00000000..e0c4194b --- /dev/null +++ b/src/components/messaging/MessageItem.tsx @@ -0,0 +1,40 @@ +"use client"; + +import type { DecodedMessage } from "@xmtp/browser-sdk"; +import { format } from "date-fns"; +import { cn } from "~/utils"; + +interface MessageItemProps { + message: DecodedMessage; + isOwnMessage: boolean; +} + +export function MessageItem({ message, isOwnMessage }: MessageItemProps) { + const content = + typeof message.content === "string" ? message.content : String(message.content ?? ""); + + const sentAt = new Date(Number(message.sentAtNs) / 1_000_000); + + return ( +
+
+

{content}

+
+ + {format(sentAt, "HH:mm")} + +
+ ); +} diff --git a/src/components/messaging/NewConversationDialog.tsx b/src/components/messaging/NewConversationDialog.tsx new file mode 100644 index 00000000..626ed5af --- /dev/null +++ b/src/components/messaging/NewConversationDialog.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { isAddress } from "viem"; +import { useXmtpCanMessage } from "~/hooks/useXmtpCanMessage"; +import { fetchEnsUser } from "~/utils/ens/converters/userConverter"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { UserAvatar } from "../user/UserAvatar"; +import { useXmtp } from "./XmtpContext"; + +interface NewConversationDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function NewConversationDialog({ + open, + onOpenChange, +}: NewConversationDialogProps) { + const router = useRouter(); + const { client } = useXmtp(); + const queryClient = useQueryClient(); + const [input, setInput] = useState(""); + const [isResolving, setIsResolving] = useState(false); + const [resolvedAddress, setResolvedAddress] = useState(null); + const [resolvedUser, setResolvedUser] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const { data: canMessage, isLoading: isCheckingCanMessage } = + useXmtpCanMessage(resolvedAddress || undefined); + + const handleResolve = useCallback(async () => { + const trimmed = input.trim(); + if (!trimmed) return; + + setIsResolving(true); + setResolvedAddress(null); + setResolvedUser(null); + + try { + // Check if it's already an address + if (isAddress(trimmed)) { + setResolvedAddress(trimmed); + // Try to fetch ENS data for the address + const user = await fetchEnsUser(trimmed); + setResolvedUser(user); + } else { + // Try to resolve as ENS name + const user = await fetchEnsUser(trimmed); + if (user?.address) { + setResolvedAddress(user.address); + setResolvedUser(user); + } else { + toast.error("Could not resolve ENS name"); + } + } + } catch (err) { + console.error("Failed to resolve address:", err); + toast.error("Failed to resolve address"); + } finally { + setIsResolving(false); + } + }, [input]); + + const handleCreate = useCallback(async () => { + if (!client || !resolvedAddress) return; + + setIsCreating(true); + try { + const conversation = await client.conversations.newDmWithIdentifier({ + identifier: resolvedAddress.toLowerCase(), + identifierKind: "Ethereum" as const, + }); + + // Invalidate conversations cache + queryClient.invalidateQueries({ + queryKey: ["xmtp-conversations"], + }); + + onOpenChange(false); + router.push(`/messages/${conversation.id}`); + } catch (err) { + console.error("Failed to create conversation:", err); + toast.error("Failed to create conversation"); + } finally { + setIsCreating(false); + } + }, [client, resolvedAddress, queryClient, onOpenChange, router]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleResolve(); + } + }; + + return ( + + + + New Conversation + + Enter an ENS name or Ethereum address to start a conversation. + + + +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="vitalik.eth or 0x..." + disabled={isResolving} + /> + +
+ + {resolvedAddress && ( +
+
+
+ {resolvedUser ? ( + + ) : ( +
+ )} +
+
+

+ {resolvedUser?.username || "Unknown"} +

+

+ {resolvedAddress} +

+
+
+ + {isCheckingCanMessage ? ( +

+ + Checking XMTP availability... +

+ ) : canMessage ? ( +

+ This user can receive messages +

+ ) : ( +

+ This user hasn't enabled XMTP messaging +

+ )} +
+ )} +
+ +
+ + +
+ +
+ ); +} diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx new file mode 100644 index 00000000..3a0e01bc --- /dev/null +++ b/src/components/messaging/XmtpContext.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { Client, type Identifier } from "@xmtp/browser-sdk"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useAccount, useWalletClient } from "wagmi"; +import { createXmtpSigner } from "~/lib/xmtp/signer"; + +interface XmtpContextValue { + client: Client | null; + isInitializing: boolean; + isReady: boolean; + error: Error | null; + initialize: () => Promise; + disconnect: () => void; + unreadCount: number; +} + +const XmtpContext = createContext(undefined); + +export function XmtpProvider({ children }: { children: ReactNode }) { + const { address, isConnected } = useAccount(); + const { data: walletClient } = useWalletClient(); + const [client, setClient] = useState(null); + const [isInitializing, setIsInitializing] = useState(false); + const [error, setError] = useState(null); + const [unreadCount, setUnreadCount] = useState(0); + const streamCleanupRef = useRef<(() => void) | null>(null); + const initializedAddressRef = useRef(null); + + const isReady = !!client && !isInitializing; + + const initialize = useCallback(async (): Promise => { + console.log("[XMTP Context] Initialize called"); + console.log("[XMTP Context] State:", { address, isConnected, hasWalletClient: !!walletClient, hasClient: !!client, isInitializing }); + + if (!address || !isConnected || !walletClient || client || isInitializing) { + console.log("[XMTP Context] Skipping initialization - preconditions not met"); + return false; + } + + setIsInitializing(true); + setError(null); + + try { + console.log("[XMTP Context] Creating signer for address:", address); + + const signMessage = async (args: { message: string }) => { + console.log("[XMTP Context] signMessage wrapper called"); + const result = await walletClient.signMessage({ + message: args.message, + }); + console.log("[XMTP Context] Signature length:", result.length); + return result; + }; + + const signer = createXmtpSigner(address, signMessage); + console.log("[XMTP Context] Signer created (EOA type)"); + console.log("[XMTP Context] Using environment: dev"); + + const xmtpClient = await Client.create(signer, { + env: "dev", + appVersion: "flow-talk/1.0.0", + }); + + console.log("[XMTP Context] XMTP client created successfully"); + console.log("[XMTP Context] Client inbox ID:", xmtpClient.inboxId); + + setClient(xmtpClient); + initializedAddressRef.current = address; + return true; + } catch (err) { + console.error("[XMTP Context] Failed to initialize XMTP client:", err); + console.error("[XMTP Context] Error details:", { + name: err instanceof Error ? err.name : "Unknown", + message: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + + // Check if this is a Smart Contract Wallet signature validation error + const errorMessage = err instanceof Error ? err.message : String(err); + if (errorMessage.includes("Signature") && errorMessage.includes("validation failed")) { + setError(new Error( + "Smart wallet signatures (like Coinbase Smart Wallet with passkeys) are not yet supported by XMTP. Please use a regular wallet (MetaMask, Rabby, etc.) for messaging." + )); + } else { + setError(err instanceof Error ? err : new Error("Failed to initialize XMTP")); + } + return false; + } finally { + setIsInitializing(false); + } + }, [address, isConnected, walletClient, client, isInitializing]); + + const disconnect = useCallback(() => { + if (streamCleanupRef.current) { + streamCleanupRef.current(); + streamCleanupRef.current = null; + } + setClient(null); + setUnreadCount(0); + initializedAddressRef.current = null; + }, []); + + // Cleanup on unmount, wallet disconnect, or account change + useEffect(() => { + if (!isConnected && client) { + disconnect(); + return; + } + + // Disconnect if account changed while client is active + if (client && initializedAddressRef.current && address !== initializedAddressRef.current) { + disconnect(); + } + }, [isConnected, address, client, disconnect]); + + // Stream messages for unread count + useEffect(() => { + if (!client) return; + + let cancelled = false; + + const streamMessages = async () => { + try { + const stream = await client.conversations.streamAllMessages({ + onValue: () => { + if (!cancelled) { + setUnreadCount((prev) => prev + 1); + } + }, + onError: (err) => { + console.error("XMTP stream error:", err); + }, + }); + + streamCleanupRef.current = () => { + cancelled = true; + stream.return(); + }; + } catch (err) { + console.error("Failed to start message stream:", err); + } + }; + + streamMessages(); + + return () => { + cancelled = true; + if (streamCleanupRef.current) { + streamCleanupRef.current(); + streamCleanupRef.current = null; + } + }; + }, [client]); + + return ( + + {children} + + ); +} + +export function useXmtp() { + const ctx = useContext(XmtpContext); + if (!ctx) throw new Error("useXmtp must be used within XmtpProvider"); + return ctx; +} diff --git a/src/components/messaging/XmtpOnboardingDialog.tsx b/src/components/messaging/XmtpOnboardingDialog.tsx new file mode 100644 index 00000000..56e58d46 --- /dev/null +++ b/src/components/messaging/XmtpOnboardingDialog.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Loader2, MessageSquare, Shield } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { useXmtp } from "./XmtpContext"; + +interface XmtpOnboardingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function XmtpOnboardingDialog({ + open, + onOpenChange, +}: XmtpOnboardingDialogProps) { + const { initialize, isInitializing, error } = useXmtp(); + + const handleEnable = async () => { + const success = await initialize(); + if (success) { + onOpenChange(false); + } + }; + + return ( + + + + + + Enable Direct Messages + + + Flow Talk uses XMTP for secure, encrypted messaging between wallets. + + + +
+
+ +
+

End-to-End Encrypted

+

+ Your messages are encrypted and can only be read by you and the + recipient. +

+
+
+ +

+ To enable messaging, you'll need to sign a message with your wallet. + This signature authenticates your inbox and does not cost any gas. +

+ + {error && ( +

+ {error.message || "Failed to enable messaging. Please try again."} +

+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/hooks/useConversations.ts b/src/hooks/useConversations.ts new file mode 100644 index 00000000..c5340045 --- /dev/null +++ b/src/hooks/useConversations.ts @@ -0,0 +1,56 @@ +import { type Client, type Dm, ConsentState } from "@xmtp/browser-sdk"; +import { useQuery } from "@tanstack/react-query"; + +export type ConversationWithMeta = { + id: string; + dm: Dm; + peerInboxId: string; + consentState: ConsentState; + lastMessage?: { + content: string; + sentAtNs: bigint; + }; +}; + +export function useConversations(client: Client | null) { + return useQuery({ + queryKey: ["xmtp-conversations", client?.inboxId], + queryFn: async (): Promise => { + if (!client) return []; + await client.conversations.sync(); + const dms = await client.conversations.listDms(); + + // Fetch metadata for each conversation + const conversationsWithMeta = await Promise.all( + dms.map(async (dm) => { + const [peerInboxId, consentState, lastMessage] = await Promise.all([ + dm.peerInboxId(), + dm.consentState(), + dm.lastMessage(), + ]); + + return { + id: dm.id, + dm, + peerInboxId, + consentState, + lastMessage: lastMessage + ? { + content: + typeof lastMessage.content === "string" + ? lastMessage.content + : "", + sentAtNs: lastMessage.sentAtNs, + } + : undefined, + }; + }), + ); + + return conversationsWithMeta; + }, + enabled: !!client, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }); +} diff --git a/src/hooks/useMessages.ts b/src/hooks/useMessages.ts new file mode 100644 index 00000000..72b9d42a --- /dev/null +++ b/src/hooks/useMessages.ts @@ -0,0 +1,18 @@ +import type { Dm, Group, DecodedMessage } from "@xmtp/browser-sdk"; +import { useQuery } from "@tanstack/react-query"; + +type Conversation = Dm | Group; + +export function useMessages(conversation: Conversation | null | undefined) { + return useQuery({ + queryKey: ["xmtp-messages", conversation?.id], + queryFn: async (): Promise => { + if (!conversation) return []; + await conversation.sync(); + return conversation.messages(); + }, + enabled: !!conversation, + staleTime: 10 * 1000, + refetchInterval: 30 * 1000, + }); +} diff --git a/src/hooks/useXmtpCanMessage.ts b/src/hooks/useXmtpCanMessage.ts new file mode 100644 index 00000000..02f263f3 --- /dev/null +++ b/src/hooks/useXmtpCanMessage.ts @@ -0,0 +1,21 @@ +import { Client } from "@xmtp/browser-sdk"; +import { useQuery } from "@tanstack/react-query"; + +export function useXmtpCanMessage(address: string | undefined) { + return useQuery({ + queryKey: ["xmtp-can-message", address], + queryFn: async () => { + if (!address) return false; + const result = await Client.canMessage([ + { + identifier: address.toLowerCase(), + identifierKind: "Ethereum" as const, + }, + ]); + // Result is a Map of identifier -> boolean + return result.get(address.toLowerCase()) ?? false; + }, + enabled: !!address, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts new file mode 100644 index 00000000..6b1e6dfa --- /dev/null +++ b/src/lib/xmtp/signer.ts @@ -0,0 +1,37 @@ +import type { Signer } from "@xmtp/browser-sdk"; +import { toBytes } from "viem"; + +type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; + +export function createXmtpSigner( + address: `0x${string}`, + signMessageAsync: SignMessageAsync, +): Signer { + const identifier = { + identifier: address.toLowerCase(), + identifierKind: "Ethereum" as const, + }; + + console.log("[XMTP Signer] Creating EOA signer for address:", address); + + const signMessage = async (message: string): Promise => { + console.log("[XMTP Signer] signMessage called"); + console.log("[XMTP Signer] Message to sign (first 100 chars):", message.substring(0, 100)); + + const signature = await signMessageAsync({ message }); + console.log("[XMTP Signer] Signature length:", signature.length); + + const signatureBytes = toBytes(signature); + console.log("[XMTP Signer] Signature bytes length:", signatureBytes.length); + + return signatureBytes; + }; + + // Use EOA signer type - this works for standard wallets + // Smart contract wallets with passkeys are not yet supported by XMTP + return { + type: "EOA", + getIdentifier: () => identifier, + signMessage, + }; +}