diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1d7ac851e..1a2e0d826 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,9 @@ { - "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "clinyong.vscode-css-modules", + "vunguyentuan.vscode-css-variables", + "vunguyentuan.vscode-postcss" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 972153487..068482665 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,5 +19,12 @@ { "mode": "auto" } + ], + "cssVariables.lookupFiles": [ + "**/*.css", + "**/*.scss", + "**/*.sass", + "**/*.less", + "node_modules/@mantine/core/styles.css" ] } diff --git a/apps/xmtp.chat/README.md b/apps/xmtp.chat/README.md new file mode 100644 index 000000000..3180c2a3b --- /dev/null +++ b/apps/xmtp.chat/README.md @@ -0,0 +1,19 @@ +# xmtp.chat app + +Use this React app as a tool to start building an app with XMTP. + +The app is built using the [XMTP client browser SDK](/sdks/browser-sdk/README.md), [React](https://react.dev/), and [RainbowKit](https://www.rainbowkit.com/). + +To keep up with the latest React app developments, see the [Issues tab](https://github.com/xmtp/xmtp-js/issues) in this repo. + +To learn more about XMTP and get answers to frequently asked questions, see the [XMTP documentation](https://xmtp.org/docs). + +### Limitations + +This React app isn't a complete solution. For example, the list of conversations doesn't update when new messages arrive in existing conversations. + +## Useful commands + +- `yarn clean`: Removes `node_modules` and `.turbo` folders +- `yarn dev`: Runs the app in development mode +- `yarn typecheck`: Runs `tsc` diff --git a/examples/react-vite-browser-sdk/index.html b/apps/xmtp.chat/index.html similarity index 74% rename from examples/react-vite-browser-sdk/index.html rename to apps/xmtp.chat/index.html index 31cf5d729..3533b8512 100644 --- a/examples/react-vite-browser-sdk/index.html +++ b/apps/xmtp.chat/index.html @@ -3,7 +3,8 @@ - XMTP V3 Browser SDK Example + + XMTP Browser Developer Tools
diff --git a/apps/xmtp.chat/package.json b/apps/xmtp.chat/package.json new file mode 100644 index 000000000..98ad855fc --- /dev/null +++ b/apps/xmtp.chat/package.json @@ -0,0 +1,52 @@ +{ + "name": "@xmtp/xmtp.chat", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "src/index.ts", + "scripts": { + "build": "vite build", + "clean": "rm -rf .turbo && rm -rf node_modules && yarn clean:dist", + "clean:dist": "rm -rf dist", + "dev": "vite", + "typecheck": "tsc" + }, + "dependencies": { + "@mantine/core": "^7.14.3", + "@mantine/form": "^7.14.3", + "@mantine/hooks": "^7.14.3", + "@mantine/modals": "^7.14.3", + "@mantine/notifications": "^7.14.3", + "@tanstack/react-query": "^5.61.5", + "@xmtp/browser-sdk": "^0.0.13", + "@xmtp/content-type-group-updated": "^2.0.0", + "@xmtp/content-type-primitives": "^2.0.0", + "@xmtp/content-type-reaction": "^2.0.0", + "@xmtp/content-type-remote-attachment": "^2.0.0", + "@xmtp/content-type-reply": "^2.0.0", + "@xmtp/content-type-text": "^2.0.0", + "date-fns": "^4.1.0", + "react": "^18.3.1", + "react-18-blockies": "^1.0.6", + "react-confetti": "^6.1.0", + "react-dom": "^18.3.1", + "react-router": "^7.0.2", + "uint8array-extras": "^1.4.0", + "viem": "^2.21.52", + "wagmi": "^2.13.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "postcss": "^8.4.49", + "postcss-preset-mantine": "^1.17.0", + "postcss-simple-vars": "^7.0.1", + "tsconfig": "workspace:*", + "typescript": "^5.7.2", + "vite": "^6.0.2" + } +} diff --git a/apps/xmtp.chat/postcss.config.cjs b/apps/xmtp.chat/postcss.config.cjs new file mode 100644 index 000000000..e817f567b --- /dev/null +++ b/apps/xmtp.chat/postcss.config.cjs @@ -0,0 +1,14 @@ +module.exports = { + plugins: { + "postcss-preset-mantine": {}, + "postcss-simple-vars": { + variables: { + "mantine-breakpoint-xs": "36em", + "mantine-breakpoint-sm": "48em", + "mantine-breakpoint-md": "62em", + "mantine-breakpoint-lg": "75em", + "mantine-breakpoint-xl": "88em", + }, + }, + }, +}; diff --git a/apps/xmtp.chat/public/favicon.ico b/apps/xmtp.chat/public/favicon.ico new file mode 100644 index 000000000..f12d61770 Binary files /dev/null and b/apps/xmtp.chat/public/favicon.ico differ diff --git a/examples/react-vite-browser-sdk/public/xmtp-icon.png b/apps/xmtp.chat/public/xmtp-icon.png similarity index 100% rename from examples/react-vite-browser-sdk/public/xmtp-icon.png rename to apps/xmtp.chat/public/xmtp-icon.png diff --git a/apps/xmtp.chat/src/assets/xmtp-icon.png b/apps/xmtp.chat/src/assets/xmtp-icon.png new file mode 100644 index 000000000..6c610d7d8 Binary files /dev/null and b/apps/xmtp.chat/src/assets/xmtp-icon.png differ diff --git a/apps/xmtp.chat/src/components/Actions.tsx b/apps/xmtp.chat/src/components/Actions.tsx new file mode 100644 index 000000000..1bc350512 --- /dev/null +++ b/apps/xmtp.chat/src/components/Actions.tsx @@ -0,0 +1,38 @@ +import { Button, Flex, useMatches } from "@mantine/core"; +import { useEffect, useRef } from "react"; +import { useNavigate } from "react-router"; +import { useClient } from "../hooks/useClient"; +import { IconMessagePlus } from "../icons/IconMessagePlus"; +import { useRefManager } from "./RefManager"; + +export const Actions: React.FC = () => { + const { client } = useClient(); + const navigate = useNavigate(); + const { setRef } = useRefManager(); + const ref = useRef(null); + const label: React.ReactNode = useMatches({ + base: , + sm: "New conversation", + }); + const px = useMatches({ + base: "xs", + sm: "md", + }); + const handleClick = () => { + void navigate("/conversations/new"); + }; + + useEffect(() => { + setRef("new-conversation-button", ref); + }, []); + + return ( + client && ( + + + + ) + ); +}; diff --git a/apps/xmtp.chat/src/components/AddressBadge.tsx b/apps/xmtp.chat/src/components/AddressBadge.tsx new file mode 100644 index 000000000..43c762d2e --- /dev/null +++ b/apps/xmtp.chat/src/components/AddressBadge.tsx @@ -0,0 +1,68 @@ +import { Badge, Flex, Text, Tooltip } from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { shortAddress } from "../helpers/address"; + +export type AddressTooltipLabelProps = { + address: string; +}; + +export const AddressTooltipLabel: React.FC = ({ + address, +}) => { + return ( + + {address} + + click to copy + + + ); +}; + +export type AddressBadgeProps = { + address: string; +}; + +export const AddressBadge: React.FC = ({ address }) => { + const clipboard = useClipboard({ timeout: 1000 }); + + const handleCopy = () => { + clipboard.copy(address); + }; + + const handleKeyboardCopy = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleCopy(); + } + }; + + return ( + Copied! + ) : ( + + ) + } + withArrow + events={{ hover: true, focus: true, touch: true }}> + + {shortAddress(address)} + + + ); +}; diff --git a/apps/xmtp.chat/src/components/App.module.css b/apps/xmtp.chat/src/components/App.module.css new file mode 100644 index 000000000..9e50b8ada --- /dev/null +++ b/apps/xmtp.chat/src/components/App.module.css @@ -0,0 +1,13 @@ +@media (max-width: 48em) { + .main { + --app-shell-footer-offset: 0px !important; + } + + .navbar { + --app-shell-footer-offset: 0px !important; + } + + .footer { + --app-shell-footer-height: 0px; + } +} diff --git a/apps/xmtp.chat/src/components/App.tsx b/apps/xmtp.chat/src/components/App.tsx new file mode 100644 index 000000000..99101c36f --- /dev/null +++ b/apps/xmtp.chat/src/components/App.tsx @@ -0,0 +1,115 @@ +import { + AppShell, + Button, + Group, + Modal, + Stack, + Text, + Title, +} from "@mantine/core"; +import { useDisclosure } from "@mantine/hooks"; +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { useClient } from "../hooks/useClient"; +import classes from "./App.module.css"; +import { AppFooter } from "./AppFooter"; +import { AppHeader } from "./AppHeader"; +import { Main } from "./Main"; +import { Navbar } from "./Navbar"; + +export const App: React.FC = () => { + const [opened, { toggle }] = useDisclosure(); + const [collapsed, setCollapsed] = useState(true); + const [unhandledRejectionError, setUnhandledRejectionError] = useState< + string | null + >(null); + const { client } = useClient(); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (!client && location.pathname !== "/") { + void navigate("/"); + return; + } + + if ( + location.pathname.startsWith("/conversations") || + location.pathname.startsWith("/identity") + ) { + setCollapsed(false); + } else { + setCollapsed(true); + } + + if (location.pathname === "/" && client) { + void navigate("/conversations"); + } + }, [location.pathname, client, navigate]); + + useEffect(() => { + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + setUnhandledRejectionError( + (event.reason as Error).message || "Unknown error", + ); + }; + window.addEventListener("unhandledrejection", handleUnhandledRejection); + return () => { + window.removeEventListener( + "unhandledrejection", + handleUnhandledRejection, + ); + }; + }, []); + + return ( + <> + {unhandledRejectionError && ( + { + setUnhandledRejectionError(null); + }} + withCloseButton={false} + centered> + + Application error + {unhandledRejectionError} + + + + + + )} + + + + + + {client && } + + +
+ + + + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/AppFooter.tsx b/apps/xmtp.chat/src/components/AppFooter.tsx new file mode 100644 index 000000000..1c1d67b2e --- /dev/null +++ b/apps/xmtp.chat/src/components/AppFooter.tsx @@ -0,0 +1,67 @@ +import { Anchor, Box, Flex, Group, Image, Text } from "@mantine/core"; +import logo from "../assets/xmtp-icon.png"; + +export const AppFooter: React.FC = () => { + return ( + <> + + + + XMTP + + XMTP + + + + + + + Contribute + + + • + + + Report an issue + + + • + + + Documentation + + + • + + + Forums + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/AppHeader.module.css b/apps/xmtp.chat/src/components/AppHeader.module.css new file mode 100644 index 000000000..0065fe918 --- /dev/null +++ b/apps/xmtp.chat/src/components/AppHeader.module.css @@ -0,0 +1,6 @@ +.button { + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-8) + ); +} diff --git a/apps/xmtp.chat/src/components/AppHeader.tsx b/apps/xmtp.chat/src/components/AppHeader.tsx new file mode 100644 index 000000000..2a68c0fa4 --- /dev/null +++ b/apps/xmtp.chat/src/components/AppHeader.tsx @@ -0,0 +1,51 @@ +import { Burger, Button, Flex } from "@mantine/core"; +import { useNavigate } from "react-router"; +import { shortAddress } from "../helpers/address"; +import { useClient } from "../hooks/useClient"; +import { Actions } from "./Actions"; +import classes from "./AppHeader.module.css"; +import { Connection } from "./Connection"; + +export type AppHeaderProps = { + collapsed?: boolean; + opened: boolean; + toggle: () => void; +}; + +export const AppHeader: React.FC = ({ + collapsed, + opened, + toggle, +}) => { + const { client } = useClient(); + const navigate = useNavigate(); + + const handleClick = () => { + void navigate("/identity"); + }; + + return ( + + + {!collapsed && ( + + )} + + {client && ( + + )} + + + + + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/BadgeWithCopy.module.css b/apps/xmtp.chat/src/components/BadgeWithCopy.module.css new file mode 100644 index 000000000..0cf272788 --- /dev/null +++ b/apps/xmtp.chat/src/components/BadgeWithCopy.module.css @@ -0,0 +1,11 @@ +.badge { + background-color: light-dark( + var(--mantine-color-gray-1), + var(--mantine-color-dark-5) + ); + color: light-dark(var(--mantine-color-light-4), var(--mantine-color-dark-1)); +} + +.button { + color: light-dark(var(--mantine-color-light-4), var(--mantine-color-dark-1)); +} diff --git a/apps/xmtp.chat/src/components/BadgeWithCopy.tsx b/apps/xmtp.chat/src/components/BadgeWithCopy.tsx new file mode 100644 index 000000000..3ab559628 --- /dev/null +++ b/apps/xmtp.chat/src/components/BadgeWithCopy.tsx @@ -0,0 +1,71 @@ +import { Badge, Button, Text, Tooltip } from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { IconCopy } from "../icons/IconCopy"; +import classes from "./BadgeWithCopy.module.css"; + +type CopyIconProps = { + value: string; +}; + +const CopyIcon: React.FC = ({ value }) => { + const clipboard = useClipboard({ timeout: 1000 }); + + const handleCopy = () => { + clipboard.copy(value); + }; + + const handleKeyboardCopy = ( + event: React.KeyboardEvent, + ) => { + if (event.key === "Enter" || event.key === " ") { + handleCopy(); + } + }; + + return ( + Copied! + ) : ( + {value} + ) + } + withArrow + events={{ hover: true, focus: true, touch: true }}> + + + ); +}; + +export type BadgeWithCopyProps = { + value: string; +}; + +export const BadgeWithCopy: React.FC = ({ value }) => { + return ( + }> + {value} + + ); +}; diff --git a/apps/xmtp.chat/src/components/CodeWithCopy.tsx b/apps/xmtp.chat/src/components/CodeWithCopy.tsx new file mode 100644 index 000000000..524ab99bd --- /dev/null +++ b/apps/xmtp.chat/src/components/CodeWithCopy.tsx @@ -0,0 +1,21 @@ +import { Box, Code } from "@mantine/core"; +import { CopyButton } from "./CopyButton"; + +type CodeWithCopyProps = { + code: string; +}; + +export const CodeWithCopy: React.FC = ({ code }) => { + return ( + + + {code} + + + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/Composer.tsx b/apps/xmtp.chat/src/components/Composer.tsx new file mode 100644 index 000000000..f156370f3 --- /dev/null +++ b/apps/xmtp.chat/src/components/Composer.tsx @@ -0,0 +1,45 @@ +import { Button, Flex, TextInput } from "@mantine/core"; +import type { Conversation } from "@xmtp/browser-sdk"; +import { useState } from "react"; +import { useConversation } from "../hooks/useConversation"; + +export type ComposerProps = { + conversation: Conversation; +}; + +export const Composer: React.FC = ({ conversation }) => { + const { send, sending } = useConversation(conversation); + const [message, setMessage] = useState(""); + + const handleSend = async () => { + await send(message); + setMessage(""); + }; + + return ( + + { + if (event.key === "Enter") { + void handleSend(); + } + }} + onChange={(e) => { + setMessage(e.target.value); + }} + /> + + + ); +}; diff --git a/apps/xmtp.chat/src/components/Connection.tsx b/apps/xmtp.chat/src/components/Connection.tsx new file mode 100644 index 000000000..90f8f8198 --- /dev/null +++ b/apps/xmtp.chat/src/components/Connection.tsx @@ -0,0 +1,136 @@ +import { Button, Flex, Loader, Text, useMatches } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import type { ClientOptions, XmtpEnv } from "@xmtp/browser-sdk"; +import { useEffect, useRef } from "react"; +import { hexToUint8Array, uint8ArrayToHex } from "uint8array-extras"; +import { type Hex } from "viem"; +import { generatePrivateKey } from "viem/accounts"; +import { useConnect, useDisconnect, useWalletClient } from "wagmi"; +import { injected } from "wagmi/connectors"; +import { createEphemeralSigner, createSigner } from "../helpers/createSigner"; +import { useClient } from "../hooks/useClient"; +import { IconLogout } from "../icons/IconLogout"; +import { IconUser } from "../icons/IconUser"; +import { useRefManager } from "./RefManager"; +import { Settings } from "./Settings"; + +export const Disconnect: React.FC = () => { + const { disconnect } = useDisconnect(); + const { disconnect: disconnectClient } = useClient(); + const label: React.ReactNode = useMatches({ + base: , + sm: "Disconnect", + }); + const px = useMatches({ + base: "xs", + sm: "md", + }); + + const handleDisconnect = () => { + disconnect(undefined, { + onSuccess: () => { + disconnectClient(); + }, + }); + }; + return ( + + ); +}; + +export const Connect: React.FC = () => { + const { setRef } = useRefManager(); + const ref = useRef(null); + const { initialize, initializing } = useClient(); + const [encryptionKey] = useLocalStorage({ + key: "XMTP_ENCRYPTION_KEY", + defaultValue: uint8ArrayToHex(crypto.getRandomValues(new Uint8Array(32))), + }); + const [env] = useLocalStorage({ + key: "XMTP_NETWORK", + defaultValue: "dev", + }); + const [useEphemeralAccount] = useLocalStorage({ + key: "XMTP_USE_EPHEMERAL_ACCOUNT", + defaultValue: false, + }); + const [ephemeralAccountKey, setEphemeralAccountKey] = + useLocalStorage({ + key: "XMTP_EPHEMERAL_ACCOUNT_KEY", + defaultValue: null, + }); + const [loggingLevel] = useLocalStorage({ + key: "XMTP_LOGGING_LEVEL", + defaultValue: "off", + }); + const { connect } = useConnect(); + const { data } = useWalletClient(); + const label: React.ReactNode = useMatches({ + base: , + sm: "Connect", + }); + const px = useMatches({ + base: "xs", + sm: "md", + }); + + useEffect(() => { + setRef("connect-wallet-button", ref); + }, []); + + useEffect(() => { + if (data?.account) { + void initialize({ + encryptionKey: hexToUint8Array(encryptionKey), + env, + loggingLevel, + signer: createSigner(data.account.address, data), + }); + } + }, [data, env]); + + const handleConnect = () => { + const connectEphemeralAccount = async () => { + const key = ephemeralAccountKey || generatePrivateKey(); + if (!ephemeralAccountKey) { + setEphemeralAccountKey(key); + } + const signer = createEphemeralSigner(key); + await initialize({ + encryptionKey: hexToUint8Array(encryptionKey), + env, + loggingLevel, + signer, + }); + }; + if (!useEphemeralAccount) { + connect({ connector: injected() }); + } else { + void connectEphemeralAccount(); + } + }; + + return initializing ? ( + + + Connecting... + + ) : ( + + ); +}; + +export const Connection: React.FC = () => { + const { client } = useClient(); + return ( + + {!client && } + {client && } + + + ); +}; diff --git a/apps/xmtp.chat/src/components/Conversation.tsx b/apps/xmtp.chat/src/components/Conversation.tsx new file mode 100644 index 000000000..5f962d797 --- /dev/null +++ b/apps/xmtp.chat/src/components/Conversation.tsx @@ -0,0 +1,104 @@ +import { + Button, + Flex, + Group, + LoadingOverlay, + ScrollArea, + Stack, + Text, + Title, +} from "@mantine/core"; +import type { Conversation as XmtpConversation } from "@xmtp/browser-sdk"; +import { useEffect } from "react"; +import { Link, Outlet } from "react-router"; +import { useBodyClass } from "../hooks/useBodyClass"; +import { useConversation } from "../hooks/useConversation"; +import { Composer } from "./Composer"; +import { Messages } from "./Messages"; +import classes from "./ScrollFade.module.css"; + +export type ConversationProps = { + conversation?: XmtpConversation; + loading: boolean; +}; + +export const Conversation: React.FC = ({ + conversation, + loading, +}) => { + useBodyClass("main-flex-layout"); + const { + messages, + getMessages, + loading: conversationLoading, + syncing: conversationSyncing, + } = useConversation(conversation); + + useEffect(() => { + const loadMessages = async () => { + await getMessages(); + }; + void loadMessages(); + }, [conversation?.id]); + + const handleSync = async () => { + await getMessages(undefined, true); + }; + + return ( + <> + + {conversation && ( + <> + + {conversation.name ? ( + {conversation.name} + ) : ( + + Untitled + + )} + + + + + + + {loading || conversationLoading || messages.length === 0 ? ( + + {messages.length === 0 && No messages} + + ) : ( + + + + )} + + + + + )} + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/ConversationCard.module.css b/apps/xmtp.chat/src/components/ConversationCard.module.css new file mode 100644 index 000000000..faf885f92 --- /dev/null +++ b/apps/xmtp.chat/src/components/ConversationCard.module.css @@ -0,0 +1,8 @@ +.root:hover { + background-color: var(--mantine-color-default-hover); + cursor: pointer; +} + +.root:focus { + border-color: var(--mantine-primary-color-filled); +} diff --git a/apps/xmtp.chat/src/components/ConversationCard.tsx b/apps/xmtp.chat/src/components/ConversationCard.tsx new file mode 100644 index 000000000..1fd3ea5ab --- /dev/null +++ b/apps/xmtp.chat/src/components/ConversationCard.tsx @@ -0,0 +1,84 @@ +import { Card, Flex, Stack, Text } from "@mantine/core"; +import { + SortDirection, + type Conversation, + type DecodedMessage, +} from "@xmtp/browser-sdk"; +import { formatDistanceToNow } from "date-fns"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { nsToDate } from "../helpers/date"; +import { useConversation } from "../hooks/useConversation"; +import styles from "./ConversationCard.module.css"; + +export type ConversationCardProps = { + conversation: Conversation; +}; + +export const ConversationCard: React.FC = ({ + conversation, +}) => { + const [memberCount, setMemberCount] = useState(0); + const navigate = useNavigate(); + const [messageCount, setMessageCount] = useState(0); + const [lastMessage, setLastMessage] = useState(null); + const { getMessages } = useConversation(conversation); + + useEffect(() => { + const loadMessages = async () => { + const messages = await getMessages({ + direction: SortDirection.Descending, + limit: 1n, + }); + setLastMessage(messages[0] ?? null); + setMessageCount(messages.length); + }; + void loadMessages(); + }, [conversation]); + + useEffect(() => { + void conversation.members().then((members) => { + setMemberCount(members.length); + }); + }, [conversation.id]); + + return ( + { + if (e.key === "Enter") { + void navigate(`/conversations/${conversation.id}`); + } + }} + onClick={() => void navigate(`/conversations/${conversation.id}`)} + style={{ cursor: "pointer" }} + classNames={{ root: styles.root }}> + + + + {conversation.name || "Untitled"} + + + + {memberCount} member{memberCount !== 1 ? "s" : ""}, {messageCount}{" "} + message{messageCount !== 1 ? "s" : ""} + + {lastMessage && ( + + Last message sent{" "} + {formatDistanceToNow(nsToDate(lastMessage.sentAtNs), { + addSuffix: true, + })} + + )} + + + ); +}; diff --git a/apps/xmtp.chat/src/components/Conversations.tsx b/apps/xmtp.chat/src/components/Conversations.tsx new file mode 100644 index 000000000..07d530f60 --- /dev/null +++ b/apps/xmtp.chat/src/components/Conversations.tsx @@ -0,0 +1,5 @@ +import { Outlet } from "react-router"; + +export const Conversations: React.FC = () => { + return ; +}; diff --git a/apps/xmtp.chat/src/components/ConversationsNavbar.tsx b/apps/xmtp.chat/src/components/ConversationsNavbar.tsx new file mode 100644 index 000000000..fd57826c6 --- /dev/null +++ b/apps/xmtp.chat/src/components/ConversationsNavbar.tsx @@ -0,0 +1,69 @@ +import { + AppShell, + Badge, + Button, + Group, + LoadingOverlay, + ScrollArea, + Stack, + Text, +} from "@mantine/core"; +import type { Conversation } from "@xmtp/browser-sdk"; +import { useEffect, useState } from "react"; +import { useConversations } from "../hooks/useConversations"; +import { ConversationCard } from "./ConversationCard"; + +export const ConversationsNavbar: React.FC = () => { + const { list, loading, syncing } = useConversations(); + const [conversations, setConversations] = useState([]); + + useEffect(() => { + const loadConversations = async () => { + const conversations = await list(); + setConversations(conversations); + }; + void loadConversations(); + }, []); + + const handleSync = async () => { + const conversations = await list(undefined, true); + setConversations(conversations); + }; + + return ( + <> + {loading && } + + + + + + Conversations + + + {conversations.length} + + + + + + + + {conversations.length === 0 ? ( + No conversations found + ) : ( + + {conversations.map((conversation) => ( + + ))} + + )} + + + ); +}; diff --git a/apps/xmtp.chat/src/components/CopyButton.module.css b/apps/xmtp.chat/src/components/CopyButton.module.css new file mode 100644 index 000000000..1575c85f3 --- /dev/null +++ b/apps/xmtp.chat/src/components/CopyButton.module.css @@ -0,0 +1,3 @@ +.button { + color: light-dark(var(--mantine-color-light-4), var(--mantine-color-dark-1)); +} diff --git a/apps/xmtp.chat/src/components/CopyButton.tsx b/apps/xmtp.chat/src/components/CopyButton.tsx new file mode 100644 index 000000000..89b743ad0 --- /dev/null +++ b/apps/xmtp.chat/src/components/CopyButton.tsx @@ -0,0 +1,47 @@ +import { Button, Text, Tooltip } from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { IconCopy } from "../icons/IconCopy"; +import classes from "./CopyButton.module.css"; + +type CopyButtonProps = { + value: string; +}; + +export const CopyButton: React.FC = ({ value }) => { + const clipboard = useClipboard({ timeout: 1000 }); + + const handleCopy = () => { + clipboard.copy(value); + }; + + const handleKeyboardCopy = ( + event: React.KeyboardEvent, + ) => { + if (event.key === "Enter" || event.key === " ") { + handleCopy(); + } + }; + + return ( + Copied! + ) : ( + Copy + ) + } + withArrow + events={{ hover: true, focus: true, touch: true }}> + + + ); +}; diff --git a/apps/xmtp.chat/src/components/DateLabel.tsx b/apps/xmtp.chat/src/components/DateLabel.tsx new file mode 100644 index 000000000..705a21422 --- /dev/null +++ b/apps/xmtp.chat/src/components/DateLabel.tsx @@ -0,0 +1,64 @@ +import { Flex, Text, Tooltip } from "@mantine/core"; +import { useClipboard } from "@mantine/hooks"; +import { formatRFC3339, intlFormat } from "date-fns"; + +export type DateLabelTooltipProps = { + date: Date; +}; + +export const DateLabelTooltip: React.FC = ({ date }) => { + return ( + + {formatRFC3339(date, { fractionDigits: 3 })} + + click to copy + + + ); +}; + +export type DateLabelProps = { + date: Date; +}; + +export const DateLabel: React.FC = ({ date }) => { + const clipboard = useClipboard({ timeout: 1000 }); + + const handleCopy = () => { + clipboard.copy(formatRFC3339(date, { fractionDigits: 3 })); + }; + + const handleKeyboardCopy = (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + handleCopy(); + } + }; + + return ( + Copied! + ) : ( + + ) + } + withArrow + events={{ hover: true, focus: true, touch: true }}> + + {intlFormat(date, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} + + + ); +}; diff --git a/apps/xmtp.chat/src/components/Identity.tsx b/apps/xmtp.chat/src/components/Identity.tsx new file mode 100644 index 000000000..9b46b5c11 --- /dev/null +++ b/apps/xmtp.chat/src/components/Identity.tsx @@ -0,0 +1,151 @@ +import { + Button, + FocusTrap, + Group, + LoadingOverlay, + Modal, + Paper, + ScrollArea, + Stack, + Text, + Title, +} from "@mantine/core"; +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router"; +import { useBodyClass } from "../hooks/useBodyClass"; +import { useClient } from "../hooks/useClient"; +import { useIdentity } from "../hooks/useIdentity"; +import { BadgeWithCopy } from "./BadgeWithCopy"; +import { InstallationTable } from "./InstallationTable"; +import classes from "./ScrollFade.module.css"; + +export const Identity: React.FC = () => { + useBodyClass("main-flex-layout"); + const { client } = useClient(); + const navigate = useNavigate(); + const { + installations, + revokeAllOtherInstallations, + revoking, + sync, + syncing, + } = useIdentity(true); + const [revokeInstallationError, setRevokeInstallationError] = useState< + string | null + >(null); + + const handleRevokeAllOtherInstallations = async () => { + try { + await revokeAllOtherInstallations(); + await sync(); + } catch (error) { + setRevokeInstallationError((error as Error).message || "Unknown error"); + } + }; + + useEffect(() => { + if (!client) { + void navigate("/"); + } + }, [client]); + + return ( + <> + {revokeInstallationError && ( + { + setRevokeInstallationError(null); + }} + withCloseButton={false} + centered> + + Revoke installation error + {revokeInstallationError} + + + + + + )} + + + + Identity + + {client && ( + + + + + + Address + + + + + + Inbox ID + + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + + + + + Installation ID + + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + + + + + Installations + + + {installations.length === 0 && ( + No other installations found + )} + {installations.length > 0 && ( + <> + + + + + + )} + + + + )} + + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/IdentityNavbar.tsx b/apps/xmtp.chat/src/components/IdentityNavbar.tsx new file mode 100644 index 000000000..cf66a488e --- /dev/null +++ b/apps/xmtp.chat/src/components/IdentityNavbar.tsx @@ -0,0 +1,19 @@ +import { Box, Button } from "@mantine/core"; +import { useNavigate } from "react-router"; +import { IconArrowLeft } from "../icons/IconArrowLeft"; + +export const IdentityNavbar: React.FC = () => { + const navigate = useNavigate(); + + return ( + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/InstallationTable.tsx b/apps/xmtp.chat/src/components/InstallationTable.tsx new file mode 100644 index 000000000..11331e5b9 --- /dev/null +++ b/apps/xmtp.chat/src/components/InstallationTable.tsx @@ -0,0 +1,95 @@ +import { Button, Table, Text, useMatches } from "@mantine/core"; +import type { SafeInstallation } from "@xmtp/browser-sdk"; +import { formatDistanceToNow } from "date-fns"; +import { nsToDate } from "../helpers/date"; +import { useIdentity } from "../hooks/useIdentity"; +import { BadgeWithCopy } from "./BadgeWithCopy"; + +type InstallationTableRowProps = { + installation: SafeInstallation; + refreshInstallations: () => Promise; + setRevokeInstallationError: React.Dispatch< + React.SetStateAction + >; +}; + +const InstallationTableRow: React.FC = ({ + installation, + refreshInstallations, + setRevokeInstallationError, +}) => { + const { revokeInstallation, revoking } = useIdentity(); + + const handleRevokeInstallation = async (installationIdBytes: Uint8Array) => { + try { + await revokeInstallation(installationIdBytes); + await refreshInstallations(); + } catch (error) { + setRevokeInstallationError((error as Error).message || "Unknown error"); + } + }; + + const maw = useMatches({ + base: "12rem", + sm: "20rem", + }); + + return ( + + + + + + + {formatDistanceToNow(nsToDate(installation.clientTimestampNs ?? 0n), { + addSuffix: true, + })} + + + + + + + ); +}; + +type InstallationTableProps = { + installations: SafeInstallation[]; + refreshInstallations: () => Promise; + setRevokeInstallationError: React.Dispatch< + React.SetStateAction + >; +}; + +export const InstallationTable: React.FC = ({ + installations, + refreshInstallations, + setRevokeInstallationError, +}) => { + return ( + + + + Installation ID + Created + + + + + {installations.map((installation) => ( + + ))} + +
+ ); +}; diff --git a/apps/xmtp.chat/src/components/LoadConversation.tsx b/apps/xmtp.chat/src/components/LoadConversation.tsx new file mode 100644 index 000000000..0429eab2d --- /dev/null +++ b/apps/xmtp.chat/src/components/LoadConversation.tsx @@ -0,0 +1,29 @@ +import type { Conversation as XmtpConversation } from "@xmtp/browser-sdk"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router"; +import { useBodyClass } from "../hooks/useBodyClass"; +import { useConversations } from "../hooks/useConversations"; +import { Conversation } from "./Conversation"; + +export const LoadConversation: React.FC = () => { + useBodyClass("main-flex-layout"); + const { conversationId } = useParams(); + const { getConversationById, loading } = useConversations(); + const [conversation, setConversation] = useState< + XmtpConversation | undefined + >(undefined); + + useEffect(() => { + const loadConversation = async () => { + if (conversationId) { + const conversation = await getConversationById(conversationId); + if (conversation) { + setConversation(conversation); + } + } + }; + void loadConversation(); + }, [conversationId]); + + return ; +}; diff --git a/apps/xmtp.chat/src/components/LoggingSelect.tsx b/apps/xmtp.chat/src/components/LoggingSelect.tsx new file mode 100644 index 000000000..255f37e19 --- /dev/null +++ b/apps/xmtp.chat/src/components/LoggingSelect.tsx @@ -0,0 +1,37 @@ +import { Flex, NativeSelect, Text } from "@mantine/core"; +import { useLocalStorage } from "@mantine/hooks"; +import { type ClientOptions } from "@xmtp/browser-sdk"; +import { useDisconnect } from "wagmi"; +import { useClient } from "../hooks/useClient"; + +export const LoggingSelect: React.FC = () => { + const { disconnect } = useDisconnect(); + const { disconnect: disconnectClient } = useClient(); + const [logging, setLogging] = useLocalStorage({ + key: "XMTP_LOGGING_LEVEL", + defaultValue: "off", + }); + + const handleChange = (event: React.ChangeEvent) => { + setLogging(event.currentTarget.value as ClientOptions["loggingLevel"]); + disconnect(undefined, { + onSuccess: () => { + disconnectClient(); + }, + }); + }; + + return ( + + + LOGGING + + + + ); +}; diff --git a/apps/xmtp.chat/src/components/Main.css b/apps/xmtp.chat/src/components/Main.css new file mode 100644 index 000000000..7ab337938 --- /dev/null +++ b/apps/xmtp.chat/src/components/Main.css @@ -0,0 +1,13 @@ +body.main-flex-layout main { + display: flex; + flex-direction: column; + height: 100dvh; + background-color: light-dark( + var(--mantine-color-gray-0), + var(--mantine-color-dark-8) + ); +} + +#root > canvas { + z-index: 9999 !important; +} diff --git a/apps/xmtp.chat/src/components/Main.tsx b/apps/xmtp.chat/src/components/Main.tsx new file mode 100644 index 000000000..df3c64248 --- /dev/null +++ b/apps/xmtp.chat/src/components/Main.tsx @@ -0,0 +1,25 @@ +import "./Main.css"; +import { Route, Routes } from "react-router"; +import { Conversations } from "./Conversations"; +import { Identity } from "./Identity"; +import { LoadConversation } from "./LoadConversation"; +import { ManageConversation } from "./ManageConversation"; +import { MessageModal } from "./MessageModal"; +import { NewConversation } from "./NewConversation"; +import { SelectConversation } from "./SelectConversation"; +import { Welcome } from "./Welcome"; + +export const Main: React.FC = () => ( + + } /> + }> + } /> + } /> + }> + } /> + + } /> + + } /> + +); diff --git a/apps/xmtp.chat/src/components/ManageConversation.tsx b/apps/xmtp.chat/src/components/ManageConversation.tsx new file mode 100644 index 000000000..34c2018e4 --- /dev/null +++ b/apps/xmtp.chat/src/components/ManageConversation.tsx @@ -0,0 +1,830 @@ +import { + Badge, + Button, + FocusTrap, + Group, + LoadingOverlay, + Modal, + NativeSelect, + Paper, + ScrollArea, + Stack, + Text, + Textarea, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import { + GroupPermissionsOptions, + MetadataField, + PermissionPolicy, + PermissionUpdateType, + type ConsentState, + type Conversation, + type PermissionPolicySet, + type SafeGroupMember, +} from "@xmtp/browser-sdk"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router"; +import { isValidLongWalletAddress } from "../helpers/address"; +import { useBodyClass } from "../hooks/useBodyClass"; +import { useClient } from "../hooks/useClient"; +import { useConversations } from "../hooks/useConversations"; +import { BadgeWithCopy } from "./BadgeWithCopy"; +import classes from "./ScrollFade.module.css"; + +type AnyFn = (...args: unknown[]) => unknown; +type ClassProperties = { + [K in keyof C as C[K] extends AnyFn ? never : K]: C[K]; +}; +type PolicySet = ClassProperties; + +export const ManageConversation: React.FC = () => { + useBodyClass("main-flex-layout"); + const { conversationId } = useParams(); + const { client } = useClient(); + const { getConversationById, loading } = useConversations(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [address, setAddress] = useState(""); + const [addressError, setAddressError] = useState(null); + const [members, setMembers] = useState([]); + const [addedMembers, setAddedMembers] = useState([]); + const [removedMembers, setRemovedMembers] = useState([]); + const [permissionsPolicy, setPermissionsPolicy] = + useState(GroupPermissionsOptions.AllMembers); + const [policySet, setPolicySet] = useState({ + addAdminPolicy: PermissionPolicy.Admin, + addMemberPolicy: PermissionPolicy.Admin, + removeAdminPolicy: PermissionPolicy.Admin, + removeMemberPolicy: PermissionPolicy.Admin, + updateGroupDescriptionPolicy: PermissionPolicy.Allow, + updateGroupImageUrlSquarePolicy: PermissionPolicy.Allow, + updateGroupNamePolicy: PermissionPolicy.Allow, + updateGroupPinnedFrameUrlPolicy: PermissionPolicy.Allow, + }); + const [updateConversationError, setUpdateConversationError] = useState< + string | null + >(null); + const consentStateRef = useRef(0); + const [consentState, setConsentState] = useState(0); + const [conversation, setConversation] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [imageUrl, setImageUrl] = useState(""); + const [pinnedFrameUrl, setPinnedFrameUrl] = useState(""); + + const policyTooltip = useMemo(() => { + if (permissionsPolicy === GroupPermissionsOptions.AllMembers) { + return "All members of the group can perform group actions"; + } else if (permissionsPolicy === GroupPermissionsOptions.AdminOnly) { + return "Only admins can perform group actions"; + } + return "Custom policy as defined below"; + }, [permissionsPolicy]); + + useEffect(() => { + if ( + permissionsPolicy === GroupPermissionsOptions.AllMembers || + permissionsPolicy === GroupPermissionsOptions.CustomPolicy + ) { + setPolicySet({ + addAdminPolicy: PermissionPolicy.Admin, + addMemberPolicy: PermissionPolicy.Admin, + removeAdminPolicy: PermissionPolicy.Admin, + removeMemberPolicy: PermissionPolicy.Admin, + updateGroupDescriptionPolicy: PermissionPolicy.Allow, + updateGroupImageUrlSquarePolicy: PermissionPolicy.Allow, + updateGroupNamePolicy: PermissionPolicy.Allow, + updateGroupPinnedFrameUrlPolicy: PermissionPolicy.Allow, + }); + } else { + setPolicySet({ + addAdminPolicy: PermissionPolicy.Admin, + addMemberPolicy: PermissionPolicy.Admin, + removeAdminPolicy: PermissionPolicy.Admin, + removeMemberPolicy: PermissionPolicy.Admin, + updateGroupDescriptionPolicy: PermissionPolicy.Admin, + updateGroupImageUrlSquarePolicy: PermissionPolicy.Admin, + updateGroupNamePolicy: PermissionPolicy.Admin, + updateGroupPinnedFrameUrlPolicy: PermissionPolicy.Admin, + }); + } + }, [permissionsPolicy]); + + useEffect(() => { + if (members.some((member) => member.accountAddresses.includes(address))) { + setAddressError("Duplicate address"); + } else if (address && !isValidLongWalletAddress(address)) { + setAddressError("Invalid address"); + } else { + setAddressError(null); + } + }, [members, address]); + + const handleUpdate = async () => { + if (!client) { + setUpdateConversationError("Client not initialized"); + return; + } + setIsLoading(true); + try { + if (name !== conversation?.name) { + await conversation?.updateName(name); + } + if (description !== conversation?.description) { + await conversation?.updateDescription(description); + } + if (imageUrl !== conversation?.imageUrl) { + await conversation?.updateImageUrl(imageUrl); + } + if (pinnedFrameUrl !== conversation?.pinnedFrameUrl) { + await conversation?.updatePinnedFrameUrl(pinnedFrameUrl); + } + if (addedMembers.length > 0) { + await conversation?.addMembers(addedMembers); + } + if (removedMembers.length > 0) { + await conversation?.removeMembersByInboxId( + removedMembers.map((member) => member.inboxId), + ); + } + + if (consentState !== consentStateRef.current) { + await conversation?.updateConsentState(consentState); + } + + const permissions = await conversation?.permissions(); + if ( + permissions?.policyType !== permissionsPolicy && + permissionsPolicy !== GroupPermissionsOptions.CustomPolicy + ) { + switch (permissionsPolicy) { + case GroupPermissionsOptions.AllMembers: { + await conversation?.updatePermission( + PermissionUpdateType.AddMember, + PermissionPolicy.Deny, + ); + await conversation?.updatePermission( + PermissionUpdateType.RemoveMember, + PermissionPolicy.Admin, + ); + await conversation?.updatePermission( + PermissionUpdateType.AddAdmin, + PermissionPolicy.SuperAdmin, + ); + await conversation?.updatePermission( + PermissionUpdateType.RemoveAdmin, + PermissionPolicy.SuperAdmin, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Allow, + MetadataField.GroupName, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Allow, + MetadataField.Description, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Allow, + MetadataField.ImageUrlSquare, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Allow, + MetadataField.PinnedFrameUrl, + ); + break; + } + case GroupPermissionsOptions.AdminOnly: { + await conversation?.updatePermission( + PermissionUpdateType.AddMember, + PermissionPolicy.Admin, + ); + await conversation?.updatePermission( + PermissionUpdateType.RemoveMember, + PermissionPolicy.Admin, + ); + await conversation?.updatePermission( + PermissionUpdateType.AddAdmin, + PermissionPolicy.SuperAdmin, + ); + await conversation?.updatePermission( + PermissionUpdateType.RemoveAdmin, + PermissionPolicy.SuperAdmin, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Admin, + MetadataField.GroupName, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Admin, + MetadataField.Description, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Admin, + MetadataField.ImageUrlSquare, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + PermissionPolicy.Admin, + MetadataField.PinnedFrameUrl, + ); + } + } + } + if (permissionsPolicy === GroupPermissionsOptions.CustomPolicy) { + await conversation?.updatePermission( + PermissionUpdateType.AddMember, + policySet.addMemberPolicy, + ); + await conversation?.updatePermission( + PermissionUpdateType.RemoveMember, + policySet.removeMemberPolicy, + ); + await conversation?.updatePermission( + PermissionUpdateType.AddAdmin, + policySet.addAdminPolicy, + ); + await conversation?.updatePermission( + PermissionUpdateType.RemoveAdmin, + policySet.removeAdminPolicy, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + policySet.updateGroupNamePolicy, + MetadataField.GroupName, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + policySet.updateGroupDescriptionPolicy, + MetadataField.Description, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + policySet.updateGroupImageUrlSquarePolicy, + MetadataField.ImageUrlSquare, + ); + await conversation?.updatePermission( + PermissionUpdateType.UpdateMetadata, + policySet.updateGroupPinnedFrameUrlPolicy, + MetadataField.PinnedFrameUrl, + ); + } + void navigate(`/conversations/${conversationId}`); + } catch (error) { + setUpdateConversationError( + `Failed to update conversation: ${error as Error}`, + ); + } finally { + setIsLoading(false); + } + }; + + const handleAddMember = () => { + setAddedMembers([...addedMembers, address]); + setAddress(""); + setAddressError(null); + }; + + useEffect(() => { + const loadConversation = async () => { + if (client && conversationId) { + const conversation = await getConversationById(conversationId); + if (conversation) { + setConversation(conversation); + setName(conversation.name ?? ""); + setDescription(conversation.description ?? ""); + setImageUrl(conversation.imageUrl ?? ""); + setPinnedFrameUrl(conversation.pinnedFrameUrl ?? ""); + const consentState = await conversation.consentState(); + setConsentState(consentState); + consentStateRef.current = consentState; + const members = await conversation.members(); + setMembers(members); + const permissions = await conversation.permissions(); + const policyType = permissions.policyType; + switch (policyType) { + case GroupPermissionsOptions.AllMembers: + setPermissionsPolicy(GroupPermissionsOptions.AllMembers); + setPolicySet({ + addAdminPolicy: PermissionPolicy.Admin, + addMemberPolicy: PermissionPolicy.Admin, + removeAdminPolicy: PermissionPolicy.Admin, + removeMemberPolicy: PermissionPolicy.Admin, + updateGroupDescriptionPolicy: PermissionPolicy.Allow, + updateGroupImageUrlSquarePolicy: PermissionPolicy.Allow, + updateGroupNamePolicy: PermissionPolicy.Allow, + updateGroupPinnedFrameUrlPolicy: PermissionPolicy.Allow, + }); + break; + case GroupPermissionsOptions.AdminOnly: + setPermissionsPolicy(GroupPermissionsOptions.AdminOnly); + setPolicySet({ + addAdminPolicy: PermissionPolicy.Admin, + addMemberPolicy: PermissionPolicy.Admin, + removeAdminPolicy: PermissionPolicy.Admin, + removeMemberPolicy: PermissionPolicy.Admin, + updateGroupDescriptionPolicy: PermissionPolicy.Admin, + updateGroupImageUrlSquarePolicy: PermissionPolicy.Admin, + updateGroupNamePolicy: PermissionPolicy.Admin, + updateGroupPinnedFrameUrlPolicy: PermissionPolicy.Admin, + }); + break; + case GroupPermissionsOptions.CustomPolicy: + setPermissionsPolicy(GroupPermissionsOptions.CustomPolicy); + setPolicySet(permissions.policySet); + break; + } + } else { + void navigate("/conversations"); + } + } else { + void navigate("/conversations"); + } + }; + void loadConversation(); + }, [client, conversationId]); + + return ( + <> + {updateConversationError && ( + { + setUpdateConversationError(null); + }} + withCloseButton={false} + centered> + + Error + {updateConversationError} + + + + + + )} + + + + + {conversation?.name || "Untitled"} + + + + + + Properties + + Name + { + setName(event.target.value); + }} + /> + + + Description +