cheogram-www
created pr with
cheogram-www/multi-dm.1
changed pr name to
New UI for starting chats with multiDM support
added cheogram-www/multi-dm.2
1: 8f55142 < -: ------- Add UI to create private channel
2: ea160a8 ! 1: e758cfd New UI for starting chats
3: faa9d23 = 2: eb5c50d Special case for group texts
4: 0d9f850 = 3: 2217816 Only enable non-group-text multiDM experimentally
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:multi-dm | git am -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:multi-dm [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:multi-dmadd comment to patch request:
ssh pr.pico.sh comment cheogram-www:multi-dm "lgtm!"
Patchset
cheogram-www/multi-dm.2
New UI for starting chats
Stephen Paul Weber
2026-11-10T18:39:11ZSemantic diff summary
0 added,
9 modified,
0 signature changed,
1 removed
across 4 analyzed files
+33
-29
src/components/Chats.tsx
#
| ... | ... | @@ -64,22 +63,20 @@ import { useHotkeys } from "react-hotkeys-hook"; | |
| 64 | 63 | import { AvatarWithPlaceholder } from "./AvatarWithPlaceholder"; | |
| 65 | 64 | import { AwaitOnce } from "./AwaitOnce"; | |
| 66 | 65 | import { confirmDialog } from "./ConfirmDialog"; | |
| 66 | + | import { NewChatDialog } from "./NewChatDialog"; | |
| 67 | 67 | import { Profile } from "./Profile"; | |
| 68 | 68 | import { SettingsContext } from "./Settings"; | |
| 69 | - | import { href, useEffectEvent, useChatExtras, useChats, Chat } from "../util"; | |
| 69 | + | import { | |
| 70 | + | findAvailableChats, | |
| 71 | + | href, | |
| 72 | + | useEffectEvent, | |
| 73 | + | useChatExtras, | |
| 74 | + | useChats, | |
| 75 | + | Chat, | |
| 76 | + | } from "../util"; | |
| 70 | 77 | ||
| 71 | 78 | borogove.Config.relativeHashUri = true; | |
| 72 | 79 | ||
| 73 | - | async function findAvailableChats( | |
| 74 | - | client: borogove.Client, | |
| 75 | - | search: string, | |
| 76 | - | onChat: (chat: borogove.AvailableChat | null | undefined) => void, | |
| 77 | - | ) { | |
| 78 | - | for await (const chat of client.findAvailableChats(search)) { | |
| 79 | - | onChat(chat); | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | ||
| 83 | 80 | export const Chats = () => { | |
| 84 | 81 | const params = useParams(); | |
| 85 | 82 | const navigate = useNavigate(); |
| ... | ... | @@ -200,6 +197,7 @@ export const Chats = () => { | |
| 200 | 197 | const [banMember, setBanMember] = useState<borogove.Member | null>(null); | |
| 201 | 198 | ||
| 202 | 199 | const [popupMember, setPopupMember] = useState<borogove.Member | undefined>(); | |
| 200 | + | const [newChatOpen, setNewChatOpen] = useState(false); | |
| 203 | 201 | const [scanQRCode, setScanQRCode] = useState(false); | |
| 204 | 202 | const [qrStatus, setQRStatus] = useState("Loading camera..."); | |
| 205 | 203 | const qrDecodeTimeout = useRef<number | null>(null); |
| ... | ... | @@ -579,7 +577,7 @@ export const Chats = () => { | |
| 579 | 577 | {subnav !== "apps" && subnav !== "requests" && ( | |
| 580 | 578 | <Button | |
| 581 | 579 | title="Start a chat" | |
| 582 | - | icon={<FontAwesomeIcon icon={faPencil} />} | |
| 580 | + | icon={<FontAwesomeIcon icon={faPenToSquare} />} | |
| 583 | 581 | onClick={(e) => setAddContactAnchorEl(e.currentTarget)} | |
| 584 | 582 | /> | |
| 585 | 583 | )} |
| ... | ... | @@ -588,21 +586,16 @@ export const Chats = () => { | |
| 588 | 586 | anchorEl={addContactAnchorEl} | |
| 589 | 587 | onClose={() => setAddContactAnchorEl(null)} | |
| 590 | 588 | > | |
| 591 | - | <ListSubheader>Start a chat</ListSubheader> | |
| 592 | - | <MenuItem> | |
| 593 | - | <form | |
| 594 | - | onSubmit={(e) => { | |
| 595 | - | e.preventDefault(); | |
| 596 | - | const q = e.currentTarget.querySelector("input")?.value; | |
| 597 | - | setSearchQ(q || null); | |
| 598 | - | setAddContactAnchorEl(null); | |
| 599 | - | }} | |
| 600 | - | > | |
| 601 | - | <Search | |
| 602 | - | placeholder="Start by ID or URI" | |
| 603 | - | onKeyDown={(e) => e.stopPropagation()} | |
| 604 | - | /> | |
| 605 | - | </form> | |
| 589 | + | <MenuItem | |
| 590 | + | onClick={() => { | |
| 591 | + | setNewChatOpen(true); | |
| 592 | + | setAddContactAnchorEl(null); | |
| 593 | + | }} | |
| 594 | + | > | |
| 595 | + | <ListItemIcon> | |
| 596 | + | <FontAwesomeIcon icon={faComment} /> | |
| 597 | + | </ListItemIcon> | |
| 598 | + | New Chat | |
| 606 | 599 | </MenuItem> | |
| 607 | 600 | <MenuItem | |
| 608 | 601 | onClick={() => { |
| ... | ... | @@ -1237,6 +1230,17 @@ export const Chats = () => { | |
| 1237 | 1230 | </Dialog> | |
| 1238 | 1231 | )} | |
| 1239 | 1232 | ||
| 1233 | + | <NewChatDialog | |
| 1234 | + | open={newChatOpen} | |
| 1235 | + | client={client} | |
| 1236 | + | onClose={() => setNewChatOpen(false)} | |
| 1237 | + | onStarted={(chat) => { | |
| 1238 | + | setNewChatOpen(false); | |
| 1239 | + | chat.bookmark(); | |
| 1240 | + | navigateToChat(chat); | |
| 1241 | + | }} | |
| 1242 | + | /> | |
| 1243 | + | ||
| 1240 | 1244 | {banMember && ( | |
| 1241 | 1245 | <Dialog open onClose={() => setBanMember(null)} id="ban-dialog"> | |
| 1242 | 1246 | <DialogTitle>Ban {banMember.displayName}?</DialogTitle> |
+184
-0
src/components/NewChatDialog.test.tsx
#
| ... | ... | @@ -0,0 +1,184 @@ | |
| 1 | + | // @vitest-environment jsdom | |
| 2 | + | ||
| 3 | + | import * as borogove from "borogove"; | |
| 4 | + | import { | |
| 5 | + | cleanup, | |
| 6 | + | fireEvent, | |
| 7 | + | render, | |
| 8 | + | screen, | |
| 9 | + | waitFor, | |
| 10 | + | } from "@testing-library/react"; | |
| 11 | + | import { afterEach, describe, expect, it, vi } from "vitest"; | |
| 12 | + | import { mock } from "vitest-mock-extended"; | |
| 13 | + | import { NewChatDialog } from "./NewChatDialog"; | |
| 14 | + | ||
| 15 | + | afterEach(cleanup); | |
| 16 | + | ||
| 17 | + | const availableChat = ( | |
| 18 | + | chatId: string, | |
| 19 | + | displayName: string, | |
| 20 | + | isChannel = false, | |
| 21 | + | id = chatId, | |
| 22 | + | ) => { | |
| 23 | + | const chat = mock<borogove.AvailableChat>({ | |
| 24 | + | chatId, | |
| 25 | + | displayName, | |
| 26 | + | note: "", | |
| 27 | + | isChannel: () => isChannel, | |
| 28 | + | canAudioCall: () => false, | |
| 29 | + | canVideoCall: () => false, | |
| 30 | + | }); | |
| 31 | + | Object.assign(chat, { id }); | |
| 32 | + | return chat; | |
| 33 | + | }; | |
| 34 | + | ||
| 35 | + | const iterator = (chats: borogove.AvailableChat[]) => | |
| 36 | + | (async function* () { | |
| 37 | + | yield* chats; | |
| 38 | + | })() as unknown as borogove.AvailableChatIterator; | |
| 39 | + | ||
| 40 | + | const setup = (chats: borogove.AvailableChat[] = []) => { | |
| 41 | + | const chat = mock<borogove.Chat>(); | |
| 42 | + | const multiChat = mock<borogove.DirectChat>(); | |
| 43 | + | const findAvailableChats = vi.fn<borogove.Client["findAvailableChats"]>(() => | |
| 44 | + | iterator(chats), | |
| 45 | + | ); | |
| 46 | + | const availableChatSources = vi | |
| 47 | + | .fn<borogove.Client["availableChatSources"]>() | |
| 48 | + | .mockResolvedValue(["Name", "URI"]); | |
| 49 | + | const startChat = vi.fn<borogove.Client["startChat"]>(() => chat); | |
| 50 | + | const startMultiChat = vi | |
| 51 | + | .fn<borogove.Client["startMultiChat"]>() | |
| 52 | + | .mockReturnValue(multiChat); | |
| 53 | + | const client = mock<borogove.Client>({ | |
| 54 | + | accountId: () => "me@example.com", | |
| 55 | + | availableChatSources, | |
| 56 | + | findAvailableChats, | |
| 57 | + | startChat, | |
| 58 | + | startMultiChat, | |
| 59 | + | }); | |
| 60 | + | const onClose = vi.fn(); | |
| 61 | + | const onStarted = vi.fn(); | |
| 62 | + | const view = render( | |
| 63 | + | <NewChatDialog | |
| 64 | + | open | |
| 65 | + | client={client} | |
| 66 | + | onClose={onClose} | |
| 67 | + | onStarted={onStarted} | |
| 68 | + | />, | |
| 69 | + | ); | |
| 70 | + | ||
| 71 | + | return { | |
| 72 | + | ...view, | |
| 73 | + | chat, | |
| 74 | + | multiChat, | |
| 75 | + | findAvailableChats, | |
| 76 | + | startChat, | |
| 77 | + | startMultiChat, | |
| 78 | + | onClose, | |
| 79 | + | onStarted, | |
| 80 | + | }; | |
| 81 | + | }; | |
| 82 | + | ||
| 83 | + | const search = async (q: string, result: string) => { | |
| 84 | + | fireEvent.change(screen.getByRole("textbox"), { target: { value: q } }); | |
| 85 | + | return await screen.findByText(result); | |
| 86 | + | }; | |
| 87 | + | ||
| 88 | + | describe("NewChatDialog", () => { | |
| 89 | + | it("shows the available search sources in the label", async () => { | |
| 90 | + | setup(); | |
| 91 | + | ||
| 92 | + | await waitFor(() => | |
| 93 | + | expect( | |
| 94 | + | screen.getByRole("textbox", { name: "Search by Name, URI" }), | |
| 95 | + | ).toBeTruthy(), | |
| 96 | + | ); | |
| 97 | + | }); | |
| 98 | + | ||
| 99 | + | it("starts a chat when one result is selected", async () => { | |
| 100 | + | const alice = availableChat("alice@example.com", "Alice"); | |
| 101 | + | const { startChat, onStarted, chat, findAvailableChats } = setup([alice]); | |
| 102 | + | ||
| 103 | + | fireEvent.click(await search("Ali", "Alice")); | |
| 104 | + | fireEvent.click(screen.getByRole("button", { name: "Start Chat" })); | |
| 105 | + | ||
| 106 | + | await waitFor(() => expect(onStarted).toHaveBeenCalledWith(chat)); | |
| 107 | + | expect(findAvailableChats).toHaveBeenCalledWith("Ali"); | |
| 108 | + | expect(startChat).toHaveBeenCalledWith(alice); | |
| 109 | + | }); | |
| 110 | + | ||
| 111 | + | it("starts a multiDM when more than one result is selected", async () => { | |
| 112 | + | const alice = availableChat("alice@example.com", "Alice"); | |
| 113 | + | const bob = availableChat("bob@example.com", "Bob"); | |
| 114 | + | const { startMultiChat, onStarted, multiChat } = setup([alice, bob]); | |
| 115 | + | ||
| 116 | + | fireEvent.click(await search("example.com", "Alice")); | |
| 117 | + | fireEvent.click(screen.getByText("Bob")); | |
| 118 | + | fireEvent.click(screen.getByRole("button", { name: "Start Chat" })); | |
| 119 | + | ||
| 120 | + | await waitFor(() => expect(onStarted).toHaveBeenCalledWith(multiChat)); | |
| 121 | + | expect(startMultiChat).toHaveBeenCalledWith([alice, bob]); | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | it("does not offer the current account as a counterpart", async () => { | |
| 125 | + | const me = availableChat("me@example.com", "Me"); | |
| 126 | + | const alice = availableChat("alice@example.com", "Alice"); | |
| 127 | + | const { startChat, onStarted, chat } = setup([me, alice]); | |
| 128 | + | ||
| 129 | + | const result = await search("example.com", "Alice"); | |
| 130 | + | expect(screen.queryByText("Me")).toBeNull(); | |
| 131 | + | fireEvent.click(result); | |
| 132 | + | fireEvent.click(screen.getByRole("button", { name: "Start Chat" })); | |
| 133 | + | ||
| 134 | + | await waitFor(() => expect(onStarted).toHaveBeenCalledWith(chat)); | |
| 135 | + | expect(startChat).toHaveBeenCalledWith(alice); | |
| 136 | + | }); | |
| 137 | + | ||
| 138 | + | it("treats resources of the same JID as one counterpart", async () => { | |
| 139 | + | const alice = availableChat("alice@example.com", "Alice"); | |
| 140 | + | const aliceDevice = availableChat( | |
| 141 | + | "alice@example.com/phone", | |
| 142 | + | "Alice on phone", | |
| 143 | + | false, | |
| 144 | + | "alice@example.com", | |
| 145 | + | ); | |
| 146 | + | const { startChat, onStarted, chat } = setup([alice, aliceDevice]); | |
| 147 | + | ||
| 148 | + | fireEvent.click(await search("Ali", "Alice")); | |
| 149 | + | fireEvent.click(screen.getByRole("button", { name: "Start Chat" })); | |
| 150 | + | ||
| 151 | + | await waitFor(() => expect(onStarted).toHaveBeenCalledWith(chat)); | |
| 152 | + | expect(startChat).toHaveBeenCalledWith(alice); | |
| 153 | + | }); | |
| 154 | + | ||
| 155 | + | it("does not mix channels with other selections", async () => { | |
| 156 | + | const alice = availableChat("alice@example.com", "Alice"); | |
| 157 | + | const channel = availableChat("room@example.com", "Room", true); | |
| 158 | + | const { startChat, onStarted, chat } = setup([alice, channel]); | |
| 159 | + | ||
| 160 | + | fireEvent.click(await search("example.com", "Alice")); | |
| 161 | + | fireEvent.click(screen.getByText("Room")); | |
| 162 | + | fireEvent.click(screen.getByRole("button", { name: "Start Chat" })); | |
| 163 | + | ||
| 164 | + | await waitFor(() => expect(onStarted).toHaveBeenCalledWith(chat)); | |
| 165 | + | expect(startChat).toHaveBeenCalledWith(channel); | |
| 166 | + | }); | |
| 167 | + | ||
| 168 | + | it("shows an error and keeps the dialog open when starting fails", async () => { | |
| 169 | + | const alice = availableChat("alice@example.com", "Alice"); | |
| 170 | + | const { startChat, onClose, onStarted } = setup([alice]); | |
| 171 | + | startChat.mockImplementation(() => { | |
| 172 | + | throw new Error("Cannot start chat"); | |
| 173 | + | }); | |
| 174 | + | ||
| 175 | + | fireEvent.click(await search("Ali", "Alice")); | |
| 176 | + | fireEvent.click(screen.getByRole("button", { name: "Start Chat" })); | |
| 177 | + | ||
| 178 | + | expect((await screen.findByRole("alert")).textContent).toContain( | |
| 179 | + | "Cannot start chat", | |
| 180 | + | ); | |
| 181 | + | expect(onStarted).not.toHaveBeenCalled(); | |
| 182 | + | expect(onClose).not.toHaveBeenCalled(); | |
| 183 | + | }); | |
| 184 | + | }); |
+243
-0
src/components/NewChatDialog.tsx
#
| ... | ... | @@ -0,0 +1,243 @@ | |
| 1 | + | import * as borogove from "borogove"; | |
| 2 | + | import { useEffect, useState } from "react"; | |
| 3 | + | import { | |
| 4 | + | Button, | |
| 5 | + | Chip, | |
| 6 | + | CircularProgress, | |
| 7 | + | Dialog, | |
| 8 | + | DialogActions, | |
| 9 | + | DialogContent, | |
| 10 | + | DialogContentText, | |
| 11 | + | DialogTitle, | |
| 12 | + | InputAdornment, | |
| 13 | + | List, | |
| 14 | + | ListItemButton, | |
| 15 | + | ListItemText, | |
| 16 | + | Stack, | |
| 17 | + | TextField, | |
| 18 | + | } from "@mui/material"; | |
| 19 | + | import { useDebounce } from "../hooks/use-debounce"; | |
| 20 | + | ||
| 21 | + | import { findAvailableChats } from "../util"; | |
| 22 | + | ||
| 23 | + | export const NewChatDialog = ({ | |
| 24 | + | open, | |
| 25 | + | client, | |
| 26 | + | onClose, | |
| 27 | + | onStarted, | |
| 28 | + | }: { | |
| 29 | + | open: boolean; | |
| 30 | + | client: borogove.Client; | |
| 31 | + | onClose: () => void; | |
| 32 | + | onStarted: (chat: borogove.Chat) => void; | |
| 33 | + | }) => { | |
| 34 | + | const [query, setQuery] = useState(""); | |
| 35 | + | const debouncedQuery = useDebounce(query, 300); | |
| 36 | + | const [results, setResults] = useState({ | |
| 37 | + | q: "", | |
| 38 | + | chats: [] as borogove.AvailableChat[], | |
| 39 | + | }); | |
| 40 | + | const [completedQuery, setCompletedQuery] = useState<string | null>(null); | |
| 41 | + | const [selected, setSelected] = useState<borogove.AvailableChat[]>([]); | |
| 42 | + | const [starting, setStarting] = useState(false); | |
| 43 | + | const [error, setError] = useState<string | null>(null); | |
| 44 | + | const [sources, setSources] = useState<string[]>([]); | |
| 45 | + | ||
| 46 | + | const selfId = client.accountId(); | |
| 47 | + | ||
| 48 | + | useEffect(() => { | |
| 49 | + | client | |
| 50 | + | .availableChatSources() | |
| 51 | + | .then((availableSources) => setSources(availableSources)); | |
| 52 | + | }, [client]); | |
| 53 | + | ||
| 54 | + | useEffect(() => { | |
| 55 | + | if (!open) return; | |
| 56 | + | ||
| 57 | + | const search = debouncedQuery.trim(); | |
| 58 | + | if (debouncedQuery !== query || !search) return; | |
| 59 | + | ||
| 60 | + | (async () => { | |
| 61 | + | try { | |
| 62 | + | await findAvailableChats(client, search, (availableChat) => { | |
| 63 | + | if (!open) return; | |
| 64 | + | if (!availableChat || availableChat.chatId === selfId) return; | |
| 65 | + | ||
| 66 | + | setResults((existing) => { | |
| 67 | + | const chats = existing.q === search ? existing.chats : []; | |
| 68 | + | if (chats.some((c) => c.chatId === availableChat.chatId)) { | |
| 69 | + | return existing; | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | return { q: search, chats: [...chats, availableChat] }; | |
| 73 | + | }); | |
| 74 | + | }); | |
| 75 | + | } catch (e) { | |
| 76 | + | if (open) { | |
| 77 | + | setError(e instanceof Error ? e.message : String(e)); | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | if (open) { | |
| 81 | + | setResults((existing) => | |
| 82 | + | existing.q === search ? existing : { q: search, chats: [] }, | |
| 83 | + | ); | |
| 84 | + | setCompletedQuery(search); | |
| 85 | + | } | |
| 86 | + | })(); | |
| 87 | + | }, [open, query, debouncedQuery, client, selfId]); | |
| 88 | + | ||
| 89 | + | const reset = () => { | |
| 90 | + | setQuery(""); | |
| 91 | + | setResults({ q: "", chats: [] }); | |
| 92 | + | setCompletedQuery(null); | |
| 93 | + | setSelected([]); | |
| 94 | + | setStarting(false); | |
| 95 | + | setError(null); | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | const close = () => { | |
| 99 | + | if (starting) return; | |
| 100 | + | reset(); | |
| 101 | + | onClose(); | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | const isSelected = (availableChat: borogove.AvailableChat) => { | |
| 105 | + | return selected.some((c) => c.chatId === availableChat.chatId); | |
| 106 | + | }; | |
| 107 | + | ||
| 108 | + | const toggleSelected = (availableChat: borogove.AvailableChat) => { | |
| 109 | + | setSelected((existing) => { | |
| 110 | + | if (existing.some((c) => c.chatId === availableChat.chatId)) { | |
| 111 | + | return existing.filter((c) => c.chatId !== availableChat.chatId); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // A multiDM cannot include a channel, so a channel selection | |
| 115 | + | // replaces any other selections. | |
| 116 | + | if (availableChat.isChannel() || existing.some((c) => c.isChannel())) { | |
| 117 | + | return [availableChat]; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | return [...existing, availableChat]; | |
| 121 | + | }); | |
| 122 | + | }; | |
| 123 | + | ||
| 124 | + | const startChat = (event: React.FormEvent<HTMLFormElement>) => { | |
| 125 | + | event.preventDefault(); | |
| 126 | + | if (starting || selected.length === 0) return; | |
| 127 | + | ||
| 128 | + | setStarting(true); | |
| 129 | + | setError(null); | |
| 130 | + | ||
| 131 | + | try { | |
| 132 | + | const chat = | |
| 133 | + | selected.length === 1 | |
| 134 | + | ? client.startChat(selected[0]) | |
| 135 | + | : client.startMultiChat(selected); | |
| 136 | + | reset(); | |
| 137 | + | onStarted(chat); | |
| 138 | + | } catch (e) { | |
| 139 | + | setError(e instanceof Error ? e.message : String(e)); | |
| 140 | + | } | |
| 141 | + | setStarting(false); | |
| 142 | + | }; | |
| 143 | + | ||
| 144 | + | const currentQuery = query.trim(); | |
| 145 | + | const showResults = currentQuery !== "" && results.q === currentQuery; | |
| 146 | + | const searchComplete = completedQuery === currentQuery; | |
| 147 | + | const searchDescription = | |
| 148 | + | sources.length > 0 ? `Search by ${sources.join(", ")}` : "Search"; | |
| 149 | + | ||
| 150 | + | return ( | |
| 151 | + | <Dialog open={open} onClose={close} fullWidth maxWidth="sm"> | |
| 152 | + | <DialogTitle>New Chat</DialogTitle> | |
| 153 | + | <form onSubmit={startChat}> | |
| 154 | + | <DialogContent> | |
| 155 | + | <TextField | |
| 156 | + | autoFocus | |
| 157 | + | fullWidth | |
| 158 | + | margin="dense" | |
| 159 | + | label={searchDescription} | |
| 160 | + | value={query} | |
| 161 | + | onChange={(e) => { | |
| 162 | + | setQuery(e.target.value); | |
| 163 | + | setCompletedQuery(null); | |
| 164 | + | setError(null); | |
| 165 | + | }} | |
| 166 | + | disabled={starting} | |
| 167 | + | error={!!error} | |
| 168 | + | slotProps={{ | |
| 169 | + | input: { | |
| 170 | + | endAdornment: | |
| 171 | + | currentQuery !== "" && !searchComplete && !error ? ( | |
| 172 | + | <InputAdornment position="end"> | |
| 173 | + | <CircularProgress size={20} /> | |
| 174 | + | </InputAdornment> | |
| 175 | + | ) : undefined, | |
| 176 | + | }, | |
| 177 | + | }} | |
| 178 | + | /> | |
| 179 | + | {selected.length > 0 && ( | |
| 180 | + | <Stack | |
| 181 | + | direction="row" | |
| 182 | + | spacing={1} | |
| 183 | + | useFlexGap | |
| 184 | + | flexWrap="wrap" | |
| 185 | + | sx={{ mt: 1 }} | |
| 186 | + | > | |
| 187 | + | {selected.map((availableChat) => ( | |
| 188 | + | <Chip | |
| 189 | + | key={availableChat.chatId} | |
| 190 | + | label={availableChat.displayName || availableChat.chatId} | |
| 191 | + | onDelete={() => toggleSelected(availableChat)} | |
| 192 | + | disabled={starting} | |
| 193 | + | /> | |
| 194 | + | ))} | |
| 195 | + | </Stack> | |
| 196 | + | )} | |
| 197 | + | {showResults && ( | |
| 198 | + | <List dense sx={{ maxHeight: 300, overflow: "auto", mt: 1 }}> | |
| 199 | + | {results.chats.map((availableChat) => { | |
| 200 | + | return ( | |
| 201 | + | <ListItemButton | |
| 202 | + | key={availableChat.chatId} | |
| 203 | + | selected={isSelected(availableChat)} | |
| 204 | + | onClick={() => toggleSelected(availableChat)} | |
| 205 | + | disabled={starting} | |
| 206 | + | > | |
| 207 | + | <ListItemText | |
| 208 | + | primary={ | |
| 209 | + | availableChat.displayName || availableChat.chatId | |
| 210 | + | } | |
| 211 | + | secondary={availableChat.note || undefined} | |
| 212 | + | /> | |
| 213 | + | </ListItemButton> | |
| 214 | + | ); | |
| 215 | + | })} | |
| 216 | + | </List> | |
| 217 | + | )} | |
| 218 | + | {showResults && | |
| 219 | + | searchComplete && | |
| 220 | + | results.chats.length === 0 && | |
| 221 | + | !error && <DialogContentText>No chats found.</DialogContentText>} | |
| 222 | + | {error && ( | |
| 223 | + | <DialogContentText role="alert" color="error"> | |
| 224 | + | {error} | |
| 225 | + | </DialogContentText> | |
| 226 | + | )} | |
| 227 | + | </DialogContent> | |
| 228 | + | <DialogActions> | |
| 229 | + | <Button onClick={close} disabled={starting}> | |
| 230 | + | Cancel | |
| 231 | + | </Button> | |
| 232 | + | <Button | |
| 233 | + | type="submit" | |
| 234 | + | variant="contained" | |
| 235 | + | disabled={starting || selected.length === 0} | |
| 236 | + | > | |
| 237 | + | {starting ? "Starting..." : "Start Chat"} | |
| 238 | + | </Button> | |
| 239 | + | </DialogActions> | |
| 240 | + | </form> | |
| 241 | + | </Dialog> | |
| 242 | + | ); | |
| 243 | + | }; |
+10
-0
src/util.ts
#
| ... | ... | @@ -226,3 +226,13 @@ export function shortDateTimeOnlyToday(date: Date) { | |
| 226 | 226 | timeStyle: "short", | |
| 227 | 227 | }); | |
| 228 | 228 | } | |
| 229 | + | ||
| 230 | + | export async function findAvailableChats( | |
| 231 | + | client: borogove.Client, | |
| 232 | + | search: string, | |
| 233 | + | onChat: (chat: borogove.AvailableChat | null | undefined) => void, | |
| 234 | + | ) { | |
| 235 | + | for await (const chat of client.findAvailableChats(search)) { | |
| 236 | + | onChat(chat); | |
| 237 | + | } | |
| 238 | + | } |