From 74cf6100136b1596300ebb63a6fa5efdb7dd2340 Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 14:01:25 -0600 Subject: [PATCH 1/9] add XMTP v3 direct messaging integration --- bun.lock | 68 +++++- next.config.mjs | 6 + package.json | 1 + src/app/(messages)/layout.tsx | 9 + .../messages/[conversationId]/page.tsx | 16 ++ src/app/(messages)/messages/page.tsx | 11 + src/app/layout.tsx | 23 +- src/atoms/messaging.ts | 25 ++ src/components/menu/Menu.tsx | 23 +- src/components/messaging/ConversationItem.tsx | 71 ++++++ src/components/messaging/ConversationList.tsx | 136 +++++++++++ src/components/messaging/ConversationView.tsx | 220 ++++++++++++++++++ src/components/messaging/MessageInput.tsx | 73 ++++++ src/components/messaging/MessageItem.tsx | 40 ++++ .../messaging/NewConversationDialog.tsx | 202 ++++++++++++++++ src/components/messaging/XmtpContext.tsx | 136 +++++++++++ .../messaging/XmtpOnboardingDialog.tsx | 91 ++++++++ src/hooks/useConversations.ts | 56 +++++ src/hooks/useMessages.ts | 18 ++ src/hooks/useXmtpCanMessage.ts | 21 ++ src/lib/xmtp/signer.ts | 27 +++ 21 files changed, 1259 insertions(+), 14 deletions(-) create mode 100644 src/app/(messages)/layout.tsx create mode 100644 src/app/(messages)/messages/[conversationId]/page.tsx create mode 100644 src/app/(messages)/messages/page.tsx create mode 100644 src/atoms/messaging.ts create mode 100644 src/components/messaging/ConversationItem.tsx create mode 100644 src/components/messaging/ConversationList.tsx create mode 100644 src/components/messaging/ConversationView.tsx create mode 100644 src/components/messaging/MessageInput.tsx create mode 100644 src/components/messaging/MessageItem.tsx create mode 100644 src/components/messaging/NewConversationDialog.tsx create mode 100644 src/components/messaging/XmtpContext.tsx create mode 100644 src/components/messaging/XmtpOnboardingDialog.tsx create mode 100644 src/hooks/useConversations.ts create mode 100644 src/hooks/useMessages.ts create mode 100644 src/hooks/useXmtpCanMessage.ts create mode 100644 src/lib/xmtp/signer.ts diff --git a/bun.lock b/bun.lock index 259a8d4c..b47f99e1 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "paper", @@ -43,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", @@ -314,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=="], @@ -546,6 +548,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=="], @@ -868,6 +890,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=="], @@ -1530,6 +1564,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=="], @@ -1806,6 +1842,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=="], @@ -1910,6 +1948,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=="], @@ -2092,6 +2132,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=="], @@ -2132,7 +2174,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=="], @@ -2224,6 +2266,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=="], @@ -2264,6 +2308,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=="], @@ -2466,6 +2512,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=="], + "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], @@ -2498,12 +2546,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=="], @@ -2572,6 +2626,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=="], @@ -2582,10 +2638,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=="], @@ -2630,6 +2692,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 8eaf6473..25c7b432 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -35,6 +35,12 @@ const config = { webpack: (config) => { config.externals.push("lokijs", "encoding"); + // XMTP WASM support + config.experiments = { + ...config.experiments, + asyncWebAssembly: true, + }; + return config; }, }; diff --git a/package.json b/package.json index bc95b44c..0322d3ab 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..e2a3bbb8 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/"); @@ -144,6 +147,22 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) { isActive: isActivity, } as const; + const messagesDockItem = { + customIcon: ( +
+ + {messagesUnreadCount > 0 && ( + + {messagesUnreadCount > 9 ? "9+" : messagesUnreadCount} + + )} +
+ ), + label: "Messages", + onClick: () => router.push("/messages"), + isActive: isMessages, + } as const; + const postDockItem = { customIcon: , label: "Post", @@ -260,7 +279,7 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) { } as const; const dockItems = isAuthenticated - ? [homeDockItem, activityDockItem, postDockItem, profileDockItem, bookmarksDockItem] + ? [homeDockItem, activityDockItem, messagesDockItem, postDockItem, profileDockItem, bookmarksDockItem] : [homeDockItem, loginDockItem]; return ( diff --git a/src/components/messaging/ConversationItem.tsx b/src/components/messaging/ConversationItem.tsx new file mode 100644 index 00000000..eb034d0e --- /dev/null +++ b/src/components/messaging/ConversationItem.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { ConsentState } from "@xmtp/browser-sdk"; +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceToNow } from "date-fns"; +import Link from "next/link"; +import type { ConversationWithMeta } from "~/hooks/useConversations"; +import { fetchEnsUser } from "~/utils/ens/converters/userConverter"; +import { UserAvatar } from "../user/UserAvatar"; + +interface ConversationItemProps { + conversation: ConversationWithMeta; +} + +export function ConversationItem({ conversation }: ConversationItemProps) { + const lastMessageTime = conversation.lastMessage?.sentAtNs + ? new Date(Number(conversation.lastMessage.sentAtNs) / 1_000_000) + : null; + + // Fetch peer user data + const { data: peerUser } = useQuery({ + queryKey: ["ens-user", conversation.peerInboxId], + queryFn: async () => { + return fetchEnsUser(conversation.peerInboxId); + }, + staleTime: 5 * 60 * 1000, + }); + + const displayName = + peerUser?.username || + (conversation.peerInboxId + ? `${conversation.peerInboxId.slice(0, 6)}...${conversation.peerInboxId.slice(-4)}` + : "Unknown"); + + return ( + +
+ {peerUser ? ( + + ) : ( +
+ )} +
+ +
+
+ {displayName} + {lastMessageTime && ( + + {formatDistanceToNow(lastMessageTime, { addSuffix: true })} + + )} +
+ {conversation.lastMessage?.content && ( +

+ {conversation.lastMessage.content} +

+ )} +
+ + {conversation.consentState === ConsentState.Unknown && ( + + Request + + )} + + ); +} diff --git a/src/components/messaging/ConversationList.tsx b/src/components/messaging/ConversationList.tsx new file mode 100644 index 00000000..e08627df --- /dev/null +++ b/src/components/messaging/ConversationList.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { ConsentState } from "@xmtp/browser-sdk"; +import { useAtom } from "jotai"; +import { Loader2, MessageSquarePlus } from "lucide-react"; +import { useState } from "react"; +import { consentFilterAtom } from "~/atoms/messaging"; +import { useConversations } from "~/hooks/useConversations"; +import { Button } from "../ui/button"; +import { Tabs, TabsList, TabsTrigger } from "../ui/tabs"; +import { ConversationItem } from "./ConversationItem"; +import { NewConversationDialog } from "./NewConversationDialog"; +import { XmtpOnboardingDialog } from "./XmtpOnboardingDialog"; +import { useXmtp } from "./XmtpContext"; + +export function ConversationList() { + const { client, isReady, isInitializing } = useXmtp(); + const { data: conversations, isLoading } = useConversations(client); + const [filter, setFilter] = useAtom(consentFilterAtom); + const [showOnboarding, setShowOnboarding] = useState(false); + const [showNewConversation, setShowNewConversation] = useState(false); + + // Show onboarding prompt if not initialized + if (!isReady && !isInitializing) { + return ( +
+
+ +
+
+

Enable Direct Messages

+

+ Start encrypted conversations with anyone on Flow Talk. +

+
+ + +
+ ); + } + + // 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..f14d5fe7 --- /dev/null +++ b/src/components/messaging/NewConversationDialog.tsx @@ -0,0 +1,202 @@ +"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..f6b5bd52 --- /dev/null +++ b/src/components/messaging/XmtpContext.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Client, type Identifier } from "@xmtp/browser-sdk"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { useAccount, useSignMessage } 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 { signMessageAsync } = useSignMessage(); + 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 isReady = !!client && !isInitializing; + + const initialize = useCallback(async () => { + if (!address || !isConnected || client || isInitializing) return; + + setIsInitializing(true); + setError(null); + + try { + const signer = createXmtpSigner(address, signMessageAsync); + const xmtpClient = await Client.create(signer, { + env: "production", + appVersion: "flow-talk/1.0.0", + }); + setClient(xmtpClient); + } catch (err) { + console.error("Failed to initialize XMTP client:", err); + setError(err instanceof Error ? err : new Error("Failed to initialize XMTP")); + } finally { + setIsInitializing(false); + } + }, [address, isConnected, signMessageAsync, client, isInitializing]); + + const disconnect = useCallback(() => { + if (streamCleanupRef.current) { + streamCleanupRef.current(); + streamCleanupRef.current = null; + } + setClient(null); + setUnreadCount(0); + }, []); + + // Cleanup on unmount or wallet disconnect + useEffect(() => { + if (!isConnected && client) { + disconnect(); + } + }, [isConnected, 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..a81c0ac8 --- /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 () => { + await initialize(); + if (!error) { + 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..1c0c7568 --- /dev/null +++ b/src/lib/xmtp/signer.ts @@ -0,0 +1,27 @@ +import type { Identifier } from "@xmtp/browser-sdk"; +import { hexToBytes } from "viem"; + +type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; + +export type XmtpSigner = { + type: "EOA"; + getIdentifier: () => Identifier; + signMessage: (message: string) => Promise; +}; + +export function createXmtpSigner( + address: `0x${string}`, + signMessageAsync: SignMessageAsync, +): XmtpSigner { + return { + type: "EOA", + getIdentifier: () => ({ + identifier: address.toLowerCase(), + identifierKind: "Ethereum" as const, + }), + signMessage: async (message: string) => { + const signature = await signMessageAsync({ message }); + return hexToBytes(signature); + }, + }; +} From 0baca29102e9a9c099afb288b20a160d12a0ba42 Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 14:28:00 -0600 Subject: [PATCH 2/9] fix: reset xmtp client on account change, handle init failures properly --- src/components/messaging/XmtpContext.tsx | 21 ++++++++++++++----- .../messaging/XmtpOnboardingDialog.tsx | 4 ++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index f6b5bd52..c91073c9 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -18,7 +18,7 @@ interface XmtpContextValue { isInitializing: boolean; isReady: boolean; error: Error | null; - initialize: () => Promise; + initialize: () => Promise; disconnect: () => void; unreadCount: number; } @@ -33,11 +33,12 @@ export function XmtpProvider({ children }: { children: ReactNode }) { 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 () => { - if (!address || !isConnected || client || isInitializing) return; + const initialize = useCallback(async (): Promise => { + if (!address || !isConnected || client || isInitializing) return false; setIsInitializing(true); setError(null); @@ -49,9 +50,12 @@ export function XmtpProvider({ children }: { children: ReactNode }) { appVersion: "flow-talk/1.0.0", }); setClient(xmtpClient); + initializedAddressRef.current = address; + return true; } catch (err) { console.error("Failed to initialize XMTP client:", err); setError(err instanceof Error ? err : new Error("Failed to initialize XMTP")); + return false; } finally { setIsInitializing(false); } @@ -64,14 +68,21 @@ export function XmtpProvider({ children }: { children: ReactNode }) { } setClient(null); setUnreadCount(0); + initializedAddressRef.current = null; }, []); - // Cleanup on unmount or wallet disconnect + // Cleanup on unmount, wallet disconnect, or account change useEffect(() => { if (!isConnected && client) { disconnect(); + return; } - }, [isConnected, client, disconnect]); + + // 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(() => { diff --git a/src/components/messaging/XmtpOnboardingDialog.tsx b/src/components/messaging/XmtpOnboardingDialog.tsx index a81c0ac8..56e58d46 100644 --- a/src/components/messaging/XmtpOnboardingDialog.tsx +++ b/src/components/messaging/XmtpOnboardingDialog.tsx @@ -23,8 +23,8 @@ export function XmtpOnboardingDialog({ const { initialize, isInitializing, error } = useXmtp(); const handleEnable = async () => { - await initialize(); - if (!error) { + const success = await initialize(); + if (success) { onOpenChange(false); } }; From 2f0b5db88b80bb3da181057b9e3ccfa5ee19d9a3 Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 17:51:36 -0600 Subject: [PATCH 3/9] fix: use wallet client for xmtp signing, use toBytes for conversion --- src/components/messaging/XmtpContext.tsx | 16 +++++++++++----- src/lib/xmtp/signer.ts | 4 ++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index c91073c9..825f0022 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -10,7 +10,7 @@ import { useRef, useState, } from "react"; -import { useAccount, useSignMessage } from "wagmi"; +import { useAccount, useWalletClient } from "wagmi"; import { createXmtpSigner } from "~/lib/xmtp/signer"; interface XmtpContextValue { @@ -27,7 +27,7 @@ const XmtpContext = createContext(undefined); export function XmtpProvider({ children }: { children: ReactNode }) { const { address, isConnected } = useAccount(); - const { signMessageAsync } = useSignMessage(); + const { data: walletClient } = useWalletClient(); const [client, setClient] = useState(null); const [isInitializing, setIsInitializing] = useState(false); const [error, setError] = useState(null); @@ -38,13 +38,19 @@ export function XmtpProvider({ children }: { children: ReactNode }) { const isReady = !!client && !isInitializing; const initialize = useCallback(async (): Promise => { - if (!address || !isConnected || client || isInitializing) return false; + if (!address || !isConnected || !walletClient || client || isInitializing) return false; setIsInitializing(true); setError(null); try { - const signer = createXmtpSigner(address, signMessageAsync); + const signMessage = async (args: { message: string }) => { + return walletClient.signMessage({ + account: address, + message: args.message, + }); + }; + const signer = createXmtpSigner(address, signMessage); const xmtpClient = await Client.create(signer, { env: "production", appVersion: "flow-talk/1.0.0", @@ -59,7 +65,7 @@ export function XmtpProvider({ children }: { children: ReactNode }) { } finally { setIsInitializing(false); } - }, [address, isConnected, signMessageAsync, client, isInitializing]); + }, [address, isConnected, walletClient, client, isInitializing]); const disconnect = useCallback(() => { if (streamCleanupRef.current) { diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts index 1c0c7568..3d5d2168 100644 --- a/src/lib/xmtp/signer.ts +++ b/src/lib/xmtp/signer.ts @@ -1,5 +1,5 @@ import type { Identifier } from "@xmtp/browser-sdk"; -import { hexToBytes } from "viem"; +import { toBytes } from "viem"; type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; @@ -21,7 +21,7 @@ export function createXmtpSigner( }), signMessage: async (message: string) => { const signature = await signMessageAsync({ message }); - return hexToBytes(signature); + return toBytes(signature); }, }; } From d35ab63ec0045c19d1e3357fceb82236c0cbdfae Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 17:59:44 -0600 Subject: [PATCH 4/9] add xmtp signing debug logging, switch to dev env for testing --- src/components/messaging/XmtpContext.tsx | 41 +++++++++++++++++++++--- src/lib/xmtp/signer.ts | 39 ++++++++++++++++++---- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index 825f0022..45ecf8de 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -38,28 +38,59 @@ export function XmtpProvider({ children }: { children: ReactNode }) { const isReady = !!client && !isInitializing; const initialize = useCallback(async (): Promise => { - if (!address || !isConnected || !walletClient || client || isInitializing) return false; + 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); + console.log("[XMTP Context] Wallet client chain:", walletClient.chain); + console.log("[XMTP Context] Wallet client account:", walletClient.account); + const signMessage = async (args: { message: string }) => { - return walletClient.signMessage({ - account: address, + console.log("[XMTP Context] signMessage wrapper called"); + console.log("[XMTP Context] Message type:", typeof args.message); + console.log("[XMTP Context] Message preview:", args.message.substring(0, 200)); + + // Sign without explicit account - wallet client uses its connected account + const result = await walletClient.signMessage({ message: args.message, }); + console.log("[XMTP Context] signMessage result:", result); + console.log("[XMTP Context] Result type:", typeof result); + console.log("[XMTP Context] Result length:", result.length); + return result; }; + const signer = createXmtpSigner(address, signMessage); + console.log("[XMTP Context] Signer created, calling Client.create..."); + console.log("[XMTP Context] Using environment: dev"); + const xmtpClient = await Client.create(signer, { - env: "production", + 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("Failed to initialize XMTP client:", 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, + }); setError(err instanceof Error ? err : new Error("Failed to initialize XMTP")); return false; } finally { diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts index 3d5d2168..dca9e220 100644 --- a/src/lib/xmtp/signer.ts +++ b/src/lib/xmtp/signer.ts @@ -1,5 +1,5 @@ import type { Identifier } from "@xmtp/browser-sdk"; -import { toBytes } from "viem"; +import { toBytes, bytesToHex } from "viem"; type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; @@ -13,15 +13,40 @@ export function createXmtpSigner( address: `0x${string}`, signMessageAsync: SignMessageAsync, ): XmtpSigner { + const identifier = { + identifier: address.toLowerCase(), + identifierKind: "Ethereum" as const, + }; + + console.log("[XMTP Signer] Creating signer for address:", address); + console.log("[XMTP Signer] Identifier:", identifier); + return { type: "EOA", - getIdentifier: () => ({ - identifier: address.toLowerCase(), - identifierKind: "Ethereum" as const, - }), + getIdentifier: () => { + console.log("[XMTP Signer] getIdentifier called, returning:", identifier); + return identifier; + }, signMessage: async (message: string) => { - const signature = await signMessageAsync({ message }); - return toBytes(signature); + console.log("[XMTP Signer] signMessage called"); + console.log("[XMTP Signer] Message to sign:", message); + console.log("[XMTP Signer] Message length:", message.length); + + try { + const signature = await signMessageAsync({ message }); + console.log("[XMTP Signer] Raw signature (hex):", signature); + console.log("[XMTP Signer] Signature length:", signature.length); + + const signatureBytes = toBytes(signature); + console.log("[XMTP Signer] Signature bytes length:", signatureBytes.length); + console.log("[XMTP Signer] Signature bytes (first 10):", Array.from(signatureBytes.slice(0, 10))); + console.log("[XMTP Signer] Converted back to hex:", bytesToHex(signatureBytes)); + + return signatureBytes; + } catch (err) { + console.error("[XMTP Signer] Error signing message:", err); + throw err; + } }, }; } From 23ae944ad9652c0d13ba993b4edf45c3f8ed63f7 Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 18:45:00 -0600 Subject: [PATCH 5/9] fix: support smart contract wallets (SCW) for xmtp signing --- src/components/messaging/XmtpContext.tsx | 15 ++--- src/lib/xmtp/signer.ts | 70 ++++++++++++------------ 2 files changed, 41 insertions(+), 44 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index 45ecf8de..ce5cf1e5 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -54,23 +54,20 @@ export function XmtpProvider({ children }: { children: ReactNode }) { console.log("[XMTP Context] Wallet client chain:", walletClient.chain); console.log("[XMTP Context] Wallet client account:", walletClient.account); + const chainId = BigInt(walletClient.chain?.id ?? 1); + console.log("[XMTP Context] Chain ID:", chainId.toString()); + const signMessage = async (args: { message: string }) => { console.log("[XMTP Context] signMessage wrapper called"); - console.log("[XMTP Context] Message type:", typeof args.message); - console.log("[XMTP Context] Message preview:", args.message.substring(0, 200)); - - // Sign without explicit account - wallet client uses its connected account const result = await walletClient.signMessage({ message: args.message, }); - console.log("[XMTP Context] signMessage result:", result); - console.log("[XMTP Context] Result type:", typeof result); - console.log("[XMTP Context] Result length:", result.length); + console.log("[XMTP Context] Signature length:", result.length); return result; }; - const signer = createXmtpSigner(address, signMessage); - console.log("[XMTP Context] Signer created, calling Client.create..."); + const signer = createXmtpSigner(address, signMessage, chainId); + console.log("[XMTP Context] Signer created (SCW type for compatibility)"); console.log("[XMTP Context] Using environment: dev"); const xmtpClient = await Client.create(signer, { diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts index dca9e220..75b6b986 100644 --- a/src/lib/xmtp/signer.ts +++ b/src/lib/xmtp/signer.ts @@ -1,52 +1,52 @@ -import type { Identifier } from "@xmtp/browser-sdk"; -import { toBytes, bytesToHex } from "viem"; +import type { Identifier, Signer } from "@xmtp/browser-sdk"; +import { toBytes } from "viem"; type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; -export type XmtpSigner = { - type: "EOA"; - getIdentifier: () => Identifier; - signMessage: (message: string) => Promise; -}; +// Normal EOA signature is 65 bytes (130 hex chars + 0x prefix = 132 chars) +const EOA_SIGNATURE_LENGTH = 132; export function createXmtpSigner( address: `0x${string}`, signMessageAsync: SignMessageAsync, -): XmtpSigner { + chainId: bigint, +): Signer { const identifier = { identifier: address.toLowerCase(), identifierKind: "Ethereum" as const, }; console.log("[XMTP Signer] Creating signer for address:", address); - console.log("[XMTP Signer] Identifier:", identifier); + console.log("[XMTP Signer] Chain ID:", chainId.toString()); + // We'll determine the signer type based on the first signature + let detectedSignerType: "EOA" | "SCW" | null = null; + + 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); + + // Detect if this is an SCW signature (much longer than EOA) + if (detectedSignerType === null) { + detectedSignerType = signature.length > EOA_SIGNATURE_LENGTH ? "SCW" : "EOA"; + console.log("[XMTP Signer] Detected signer type:", detectedSignerType); + } + + const signatureBytes = toBytes(signature); + console.log("[XMTP Signer] Signature bytes length:", signatureBytes.length); + + return signatureBytes; + }; + + // Return SCW signer - it works for both EOA and SCW wallets + // SCW signer has additional getChainId method that EOA doesn't have return { - type: "EOA", - getIdentifier: () => { - console.log("[XMTP Signer] getIdentifier called, returning:", identifier); - return identifier; - }, - signMessage: async (message: string) => { - console.log("[XMTP Signer] signMessage called"); - console.log("[XMTP Signer] Message to sign:", message); - console.log("[XMTP Signer] Message length:", message.length); - - try { - const signature = await signMessageAsync({ message }); - console.log("[XMTP Signer] Raw signature (hex):", signature); - console.log("[XMTP Signer] Signature length:", signature.length); - - const signatureBytes = toBytes(signature); - console.log("[XMTP Signer] Signature bytes length:", signatureBytes.length); - console.log("[XMTP Signer] Signature bytes (first 10):", Array.from(signatureBytes.slice(0, 10))); - console.log("[XMTP Signer] Converted back to hex:", bytesToHex(signatureBytes)); - - return signatureBytes; - } catch (err) { - console.error("[XMTP Signer] Error signing message:", err); - throw err; - } - }, + type: "SCW", + getIdentifier: () => identifier, + signMessage, + getChainId: () => chainId, }; } From cacd3ef6c9e2877de861b7d2b8789cab361c7ce5 Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 19:41:13 -0600 Subject: [PATCH 6/9] fix: use base chain for scw signature verification, add block number --- src/components/messaging/XmtpContext.tsx | 19 ++++++++++++-- src/lib/xmtp/signer.ts | 33 ++++++++++++++++-------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index ce5cf1e5..612bdb4c 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -10,9 +10,17 @@ import { useRef, useState, } from "react"; +import { createPublicClient, http } from "viem"; +import { base } from "viem/chains"; import { useAccount, useWalletClient } from "wagmi"; import { createXmtpSigner } from "~/lib/xmtp/signer"; +// Public client for Base chain - used for SCW block number verification +const basePublicClient = createPublicClient({ + chain: base, + transport: http(), +}); + interface XmtpContextValue { client: Client | null; isInitializing: boolean; @@ -66,8 +74,15 @@ export function XmtpProvider({ children }: { children: ReactNode }) { return result; }; - const signer = createXmtpSigner(address, signMessage, chainId); - console.log("[XMTP Context] Signer created (SCW type for compatibility)"); + // Get block number from Base chain for ERC-6492 verification + const getBlockNumber = async () => { + const blockNumber = await basePublicClient.getBlockNumber(); + console.log("[XMTP Context] Base block number:", blockNumber.toString()); + return blockNumber; + }; + + const signer = createXmtpSigner(address, signMessage, chainId, getBlockNumber); + console.log("[XMTP Context] Signer created (SCW type with Base chain verification)"); console.log("[XMTP Context] Using environment: dev"); const xmtpClient = await Client.create(signer, { diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts index 75b6b986..02cfad59 100644 --- a/src/lib/xmtp/signer.ts +++ b/src/lib/xmtp/signer.ts @@ -2,25 +2,32 @@ import type { Identifier, Signer } from "@xmtp/browser-sdk"; import { toBytes } from "viem"; type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; +type GetBlockNumber = () => Promise; // Normal EOA signature is 65 bytes (130 hex chars + 0x prefix = 132 chars) const EOA_SIGNATURE_LENGTH = 132; +// Base chain ID - where Porto/Coinbase Smart Wallets are deployed +const BASE_CHAIN_ID = 8453n; + export function createXmtpSigner( address: `0x${string}`, signMessageAsync: SignMessageAsync, chainId: bigint, + getBlockNumber?: GetBlockNumber, ): Signer { const identifier = { identifier: address.toLowerCase(), identifierKind: "Ethereum" as const, }; - console.log("[XMTP Signer] Creating signer for address:", address); - console.log("[XMTP Signer] Chain ID:", chainId.toString()); + // For SCW verification, use the chain where the wallet is deployed + // Porto/Coinbase Smart Wallets are on Base + const scwChainId = chainId === 1n ? BASE_CHAIN_ID : chainId; - // We'll determine the signer type based on the first signature - let detectedSignerType: "EOA" | "SCW" | null = null; + console.log("[XMTP Signer] Creating signer for address:", address); + console.log("[XMTP Signer] Wallet chain ID:", chainId.toString()); + console.log("[XMTP Signer] SCW verification chain ID:", scwChainId.toString()); const signMessage = async (message: string): Promise => { console.log("[XMTP Signer] signMessage called"); @@ -29,11 +36,8 @@ export function createXmtpSigner( const signature = await signMessageAsync({ message }); console.log("[XMTP Signer] Signature length:", signature.length); - // Detect if this is an SCW signature (much longer than EOA) - if (detectedSignerType === null) { - detectedSignerType = signature.length > EOA_SIGNATURE_LENGTH ? "SCW" : "EOA"; - console.log("[XMTP Signer] Detected signer type:", detectedSignerType); - } + const isScwSignature = signature.length > EOA_SIGNATURE_LENGTH; + console.log("[XMTP Signer] Detected signature type:", isScwSignature ? "SCW" : "EOA"); const signatureBytes = toBytes(signature); console.log("[XMTP Signer] Signature bytes length:", signatureBytes.length); @@ -43,10 +47,17 @@ export function createXmtpSigner( // Return SCW signer - it works for both EOA and SCW wallets // SCW signer has additional getChainId method that EOA doesn't have - return { + const signer: Signer = { type: "SCW", getIdentifier: () => identifier, signMessage, - getChainId: () => chainId, + getChainId: () => scwChainId, }; + + // Add optional getBlockNumber for ERC-6492 verification + if (getBlockNumber) { + (signer as Signer & { getBlockNumber: GetBlockNumber }).getBlockNumber = getBlockNumber; + } + + return signer; } From 7a087564d33748badaee6d026d0a64f4e1fdbf0c Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 19:48:40 -0600 Subject: [PATCH 7/9] fix: remove getBlockNumber to avoid web worker serialization error --- src/components/messaging/XmtpContext.tsx | 17 +---------------- src/lib/xmtp/signer.ts | 13 +++---------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index 612bdb4c..69bd701b 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -10,17 +10,9 @@ import { useRef, useState, } from "react"; -import { createPublicClient, http } from "viem"; -import { base } from "viem/chains"; import { useAccount, useWalletClient } from "wagmi"; import { createXmtpSigner } from "~/lib/xmtp/signer"; -// Public client for Base chain - used for SCW block number verification -const basePublicClient = createPublicClient({ - chain: base, - transport: http(), -}); - interface XmtpContextValue { client: Client | null; isInitializing: boolean; @@ -74,14 +66,7 @@ export function XmtpProvider({ children }: { children: ReactNode }) { return result; }; - // Get block number from Base chain for ERC-6492 verification - const getBlockNumber = async () => { - const blockNumber = await basePublicClient.getBlockNumber(); - console.log("[XMTP Context] Base block number:", blockNumber.toString()); - return blockNumber; - }; - - const signer = createXmtpSigner(address, signMessage, chainId, getBlockNumber); + const signer = createXmtpSigner(address, signMessage, chainId); console.log("[XMTP Context] Signer created (SCW type with Base chain verification)"); console.log("[XMTP Context] Using environment: dev"); diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts index 02cfad59..ece7bdc5 100644 --- a/src/lib/xmtp/signer.ts +++ b/src/lib/xmtp/signer.ts @@ -2,7 +2,6 @@ import type { Identifier, Signer } from "@xmtp/browser-sdk"; import { toBytes } from "viem"; type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; -type GetBlockNumber = () => Promise; // Normal EOA signature is 65 bytes (130 hex chars + 0x prefix = 132 chars) const EOA_SIGNATURE_LENGTH = 132; @@ -14,7 +13,6 @@ export function createXmtpSigner( address: `0x${string}`, signMessageAsync: SignMessageAsync, chainId: bigint, - getBlockNumber?: GetBlockNumber, ): Signer { const identifier = { identifier: address.toLowerCase(), @@ -47,17 +45,12 @@ export function createXmtpSigner( // Return SCW signer - it works for both EOA and SCW wallets // SCW signer has additional getChainId method that EOA doesn't have - const signer: Signer = { + // Note: getBlockNumber is not included because it can't be serialized for Web Workers + // XMTP will use the latest block number by default + return { type: "SCW", getIdentifier: () => identifier, signMessage, getChainId: () => scwChainId, }; - - // Add optional getBlockNumber for ERC-6492 verification - if (getBlockNumber) { - (signer as Signer & { getBlockNumber: GetBlockNumber }).getBlockNumber = getBlockNumber; - } - - return signer; } From 5980ac58a817346587cea5dbc6f595753a997463 Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 19:54:33 -0600 Subject: [PATCH 8/9] fix: revert to eoa signer, add helpful error for unsupported smart wallets --- src/components/messaging/XmtpContext.tsx | 20 +++++++++------- src/lib/xmtp/signer.ts | 29 ++++-------------------- 2 files changed, 17 insertions(+), 32 deletions(-) diff --git a/src/components/messaging/XmtpContext.tsx b/src/components/messaging/XmtpContext.tsx index 69bd701b..3a0e01bc 100644 --- a/src/components/messaging/XmtpContext.tsx +++ b/src/components/messaging/XmtpContext.tsx @@ -51,11 +51,6 @@ export function XmtpProvider({ children }: { children: ReactNode }) { try { console.log("[XMTP Context] Creating signer for address:", address); - console.log("[XMTP Context] Wallet client chain:", walletClient.chain); - console.log("[XMTP Context] Wallet client account:", walletClient.account); - - const chainId = BigInt(walletClient.chain?.id ?? 1); - console.log("[XMTP Context] Chain ID:", chainId.toString()); const signMessage = async (args: { message: string }) => { console.log("[XMTP Context] signMessage wrapper called"); @@ -66,8 +61,8 @@ export function XmtpProvider({ children }: { children: ReactNode }) { return result; }; - const signer = createXmtpSigner(address, signMessage, chainId); - console.log("[XMTP Context] Signer created (SCW type with Base chain verification)"); + 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, { @@ -88,7 +83,16 @@ export function XmtpProvider({ children }: { children: ReactNode }) { message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, }); - setError(err instanceof Error ? err : new Error("Failed to initialize XMTP")); + + // 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); diff --git a/src/lib/xmtp/signer.ts b/src/lib/xmtp/signer.ts index ece7bdc5..6b1e6dfa 100644 --- a/src/lib/xmtp/signer.ts +++ b/src/lib/xmtp/signer.ts @@ -1,31 +1,18 @@ -import type { Identifier, Signer } from "@xmtp/browser-sdk"; +import type { Signer } from "@xmtp/browser-sdk"; import { toBytes } from "viem"; type SignMessageAsync = (args: { message: string }) => Promise<`0x${string}`>; -// Normal EOA signature is 65 bytes (130 hex chars + 0x prefix = 132 chars) -const EOA_SIGNATURE_LENGTH = 132; - -// Base chain ID - where Porto/Coinbase Smart Wallets are deployed -const BASE_CHAIN_ID = 8453n; - export function createXmtpSigner( address: `0x${string}`, signMessageAsync: SignMessageAsync, - chainId: bigint, ): Signer { const identifier = { identifier: address.toLowerCase(), identifierKind: "Ethereum" as const, }; - // For SCW verification, use the chain where the wallet is deployed - // Porto/Coinbase Smart Wallets are on Base - const scwChainId = chainId === 1n ? BASE_CHAIN_ID : chainId; - - console.log("[XMTP Signer] Creating signer for address:", address); - console.log("[XMTP Signer] Wallet chain ID:", chainId.toString()); - console.log("[XMTP Signer] SCW verification chain ID:", scwChainId.toString()); + console.log("[XMTP Signer] Creating EOA signer for address:", address); const signMessage = async (message: string): Promise => { console.log("[XMTP Signer] signMessage called"); @@ -34,23 +21,17 @@ export function createXmtpSigner( const signature = await signMessageAsync({ message }); console.log("[XMTP Signer] Signature length:", signature.length); - const isScwSignature = signature.length > EOA_SIGNATURE_LENGTH; - console.log("[XMTP Signer] Detected signature type:", isScwSignature ? "SCW" : "EOA"); - const signatureBytes = toBytes(signature); console.log("[XMTP Signer] Signature bytes length:", signatureBytes.length); return signatureBytes; }; - // Return SCW signer - it works for both EOA and SCW wallets - // SCW signer has additional getChainId method that EOA doesn't have - // Note: getBlockNumber is not included because it can't be serialized for Web Workers - // XMTP will use the latest block number by default + // Use EOA signer type - this works for standard wallets + // Smart contract wallets with passkeys are not yet supported by XMTP return { - type: "SCW", + type: "EOA", getIdentifier: () => identifier, signMessage, - getChainId: () => scwChainId, }; } From 8cc2775b6127b33e5e181927c7e7452698704ccd Mon Sep 17 00:00:00 2001 From: kualta Date: Fri, 9 Jan 2026 21:23:00 -0600 Subject: [PATCH 9/9] move bookmarks to user menu, reorder dock, fix avatar sizing --- src/components/menu/Menu.tsx | 25 ++++++++++--------- src/components/messaging/ConversationItem.tsx | 2 +- .../messaging/NewConversationDialog.tsx | 12 +++++---- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index e2a3bbb8..6fed9c76 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -86,10 +86,6 @@ export function Menu({ isAuthenticated, user }: MenuClientProps) {
); - const bookmarkIcon = ( - - ); - const homeExtra = (
+