cheogram-www

created pr with cheogram-www/multi-dm.1 on 2026-11-10T19:08:58Z · by 9e834e0d
changed pr name to New UI for starting chats with multiDM support on 2026-11-10T19:32:17Z · by 9e834e0d
added cheogram-www/multi-dm.2 on 2026-11-10T19:32:24Z · by 9e834e0d
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 -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:multi-dm [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:multi-dm
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:multi-dm "lgtm!"
+25 -18 src/components/Chats.tsx #
......@@ -15,7 +15,6 @@ import {
1515 Menu,
1616 MenuItem,
1717 ListItemIcon,
18- ListSubheader,
1918 TextField,
2019 useMediaQuery,
2120 } from "@mui/material";
......@@ -51,7 +50,7 @@ import ArrowForward from "@mui/icons-material/ArrowForward";
5150 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5251 import {
5352 faAt,
54- faPencil,
53+ faPenToSquare,
5554 faPlus,
5655 faXmark,
5756 faComment,
......@@ -66,6 +65,7 @@ import { AvatarWithPlaceholder } from "./AvatarWithPlaceholder";
6665 import { AwaitOnce } from "./AwaitOnce";
6766 import { confirmDialog } from "./ConfirmDialog";
6867 import { CreateChannelDialog } from "./CreateChannelDialog";
68+import { NewChatDialog } from "./NewChatDialog";
6969 import { Profile } from "./Profile";
7070 import { SettingsContext } from "./Settings";
7171 import { href, useEffectEvent, useChatExtras, useChats, Chat } from "../util";
......@@ -193,6 +193,7 @@ export const Chats = () => {
193193
194194 const [popupMember, setPopupMember] = useState<borogove.Member | undefined>();
195195 const [createChannelOpen, setCreateChannelOpen] = useState(false);
196+ const [newChatOpen, setNewChatOpen] = useState(false);
196197 const [scanQRCode, setScanQRCode] = useState(false);
197198 const [qrStatus, setQRStatus] = useState("Loading camera...");
198199 const qrDecodeTimeout = useRef<number | null>(null);
......@@ -573,7 +574,7 @@ export const Chats = () => {
573574 {subnav !== "apps" && subnav !== "requests" && (
574575 <Button
575576 title="Start a chat"
576- icon={<FontAwesomeIcon icon={faPencil} />}
577+ icon={<FontAwesomeIcon icon={faPenToSquare} />}
577578 onClick={(e) => setAddContactAnchorEl(e.currentTarget)}
578579 />
579580 )}
......@@ -582,21 +583,16 @@ export const Chats = () => {
582583 anchorEl={addContactAnchorEl}
583584 onClose={() => setAddContactAnchorEl(null)}
584585 >
585- <ListSubheader>Start a chat</ListSubheader>
586- <MenuItem>
587- <form
588- onSubmit={(e) => {
589- e.preventDefault();
590- const q = e.currentTarget.querySelector("input")?.value;
591- setSearchQ(q || null);
592- setAddContactAnchorEl(null);
593- }}
594- >
595- <Search
596- placeholder="Start by ID or URI"
597- onKeyDown={(e) => e.stopPropagation()}
598- />
599- </form>
586+ <MenuItem
587+ onClick={() => {
588+ setNewChatOpen(true);
589+ setAddContactAnchorEl(null);
590+ }}
591+ >
592+ <ListItemIcon>
593+ <FontAwesomeIcon icon={faComment} />
594+ </ListItemIcon>
595+ New Chat
600596 </MenuItem>
601597 <MenuItem
602598 onClick={() => {
......@@ -1252,6 +1248,17 @@ export const Chats = () => {
12521248 }}
12531249 />
12541250
1251+ <NewChatDialog
1252+ open={newChatOpen}
1253+ client={client}
1254+ onClose={() => setNewChatOpen(false)}
1255+ onStarted={(chat) => {
1256+ setNewChatOpen(false);
1257+ chat.bookmark();
1258+ navigateToChat(chat);
1259+ }}
1260+ />
1261+
12551262 {banMember && (
12561263 <Dialog open onClose={() => setBanMember(null)} id="ban-dialog">
12571264 <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+export const NewChatDialog = ({
22+ open,
23+ client,
24+ onClose,
25+ onStarted,
26+}: {
27+ open: boolean;
28+ client: borogove.Client;
29+ onClose: () => void;
30+ onStarted: (chat: borogove.Chat) => void;
31+}) => {
32+ const [query, setQuery] = useState("");
33+ const debouncedQuery = useDebounce(query, 300);
34+ const [results, setResults] = useState({
35+ q: "",
36+ chats: [] as borogove.AvailableChat[],
37+ });
38+ const [completedQuery, setCompletedQuery] = useState<string | null>(null);
39+ const [selected, setSelected] = useState<borogove.AvailableChat[]>([]);
40+ const [starting, setStarting] = useState(false);
41+ const [error, setError] = useState<string | null>(null);
42+ const [sources, setSources] = useState<string[]>([]);
43+
44+ const selfId = client.accountId();
45+
46+ useEffect(() => {
47+ client
48+ .availableChatSources()
49+ .then((availableSources) => setSources(availableSources));
50+ }, [client]);
51+
52+ useEffect(() => {
53+ if (!open) return;
54+
55+ const search = debouncedQuery.trim();
56+ if (debouncedQuery !== query || !search) return;
57+
58+ (async () => {
59+ try {
60+ for await (const availableChat of client.findAvailableChats(search)) {
61+ if (!open) return;
62+ if (!availableChat || availableChat.chatId === selfId) continue;
63+
64+ setResults((existing) => {
65+ const chats = existing.q === search ? existing.chats : [];
66+ if (chats.some((c) => c.chatId === availableChat.chatId)) {
67+ return existing;
68+ }
69+
70+ return { q: search, chats: [...chats, availableChat] };
71+ });
72+ }
73+ } catch (e) {
74+ if (open) {
75+ setError(e instanceof Error ? e.message : String(e));
76+ }
77+ } finally {
78+ if (open) {
79+ setResults((existing) =>
80+ existing.q === search ? existing : { q: search, chats: [] },
81+ );
82+ setCompletedQuery(search);
83+ }
84+ }
85+ })();
86+ }, [open, query, debouncedQuery, client, selfId]);
87+
88+ const reset = () => {
89+ setQuery("");
90+ setResults({ q: "", chats: [] });
91+ setCompletedQuery(null);
92+ setSelected([]);
93+ setStarting(false);
94+ setError(null);
95+ };
96+
97+ const close = () => {
98+ if (starting) return;
99+ reset();
100+ onClose();
101+ };
102+
103+ const isSelected = (availableChat: borogove.AvailableChat) => {
104+ return selected.some((c) => c.chatId === availableChat.chatId);
105+ };
106+
107+ const toggleSelected = (availableChat: borogove.AvailableChat) => {
108+ setSelected((existing) => {
109+ if (existing.some((c) => c.chatId === availableChat.chatId)) {
110+ return existing.filter((c) => c.chatId !== availableChat.chatId);
111+ }
112+
113+ // A multiDM cannot include a channel, so a channel selection
114+ // replaces any other selections.
115+ if (availableChat.isChannel() || existing.some((c) => c.isChannel())) {
116+ return [availableChat];
117+ }
118+
119+ return [...existing, availableChat];
120+ });
121+ };
122+
123+ const startChat = (event: React.FormEvent<HTMLFormElement>) => {
124+ event.preventDefault();
125+ if (starting || selected.length === 0) return;
126+
127+ setStarting(true);
128+ setError(null);
129+
130+ try {
131+ const chat =
132+ selected.length === 1
133+ ? client.startChat(selected[0])
134+ : client.startMultiChat(selected);
135+ reset();
136+ onStarted(chat);
137+ } catch (e) {
138+ setError(e instanceof Error ? e.message : String(e));
139+ } finally {
140+ setStarting(false);
141+ }
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+};
Back to top