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!"
+24 -0 src/components/Chats.tsx #
......@@ -55,6 +55,7 @@ import {
5555 faPlus,
5656 faXmark,
5757 faComment,
58+ faUserGroup,
5859 faMagnifyingGlassArrowRight,
5960 faThumbtack,
6061 } from "@fortawesome/free-solid-svg-icons";
......@@ -64,6 +65,7 @@ import { useHotkeys } from "react-hotkeys-hook";
6465 import { AvatarWithPlaceholder } from "./AvatarWithPlaceholder";
6566 import { AwaitOnce } from "./AwaitOnce";
6667 import { confirmDialog } from "./ConfirmDialog";
68+import { CreateChannelDialog } from "./CreateChannelDialog";
6769 import { Profile } from "./Profile";
6870 import { SettingsContext } from "./Settings";
6971 import { href, useEffectEvent, useChatExtras, useChats, Chat } from "../util";
......@@ -190,6 +192,7 @@ export const Chats = () => {
190192 const [banMember, setBanMember] = useState<borogove.Member | null>(null);
191193
192194 const [popupMember, setPopupMember] = useState<borogove.Member | undefined>();
195+ const [createChannelOpen, setCreateChannelOpen] = useState(false);
193196 const [scanQRCode, setScanQRCode] = useState(false);
194197 const [qrStatus, setQRStatus] = useState("Loading camera...");
195198 const qrDecodeTimeout = useRef<number | null>(null);
......@@ -606,6 +609,17 @@ export const Chats = () => {
606609 </ListItemIcon>
607610 Scan QR Code
608611 </MenuItem>
612+ <MenuItem
613+ onClick={() => {
614+ setCreateChannelOpen(true);
615+ setAddContactAnchorEl(null);
616+ }}
617+ >
618+ <ListItemIcon>
619+ <FontAwesomeIcon icon={faUserGroup} />
620+ </ListItemIcon>
621+ New Private Channel
622+ </MenuItem>
609623 </Menu>
610624 </ConversationHeader.Actions>
611625 </ConversationHeader>
......@@ -1228,6 +1242,16 @@ export const Chats = () => {
12281242 </Dialog>
12291243 )}
12301244
1245+ <CreateChannelDialog
1246+ open={createChannelOpen}
1247+ client={client}
1248+ onClose={() => setCreateChannelOpen(false)}
1249+ onCreated={(channel) => {
1250+ setCreateChannelOpen(false);
1251+ navigateToChat(channel);
1252+ }}
1253+ />
1254+
12311255 {banMember && (
12321256 <Dialog open onClose={() => setBanMember(null)} id="ban-dialog">
12331257 <DialogTitle>Ban {banMember.displayName}?</DialogTitle>
+73 -0 src/components/CreateChannelDialog.test.tsx #
......@@ -0,0 +1,73 @@
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 { CreateChannelDialog } from "./CreateChannelDialog";
14+
15+afterEach(cleanup);
16+
17+const setup = () => {
18+ const channel = mock<borogove.Channel>();
19+ const createChannelInstant = vi
20+ .fn<borogove.Client["createChannelInstant"]>()
21+ .mockResolvedValue(channel);
22+ const client = mock<borogove.Client>({ createChannelInstant });
23+ const onClose = vi.fn();
24+ const onCreated = vi.fn();
25+ const view = render(
26+ <CreateChannelDialog
27+ open
28+ client={client}
29+ onClose={onClose}
30+ onCreated={onCreated}
31+ />,
32+ );
33+
34+ return { ...view, channel, createChannelInstant, onClose, onCreated };
35+};
36+
37+describe("CreateChannelDialog", () => {
38+ it("creates a private channel with a trimmed, optional name", async () => {
39+ const { createChannelInstant, onCreated, channel } = setup();
40+
41+ fireEvent.change(screen.getByLabelText("Channel name"), {
42+ target: { value: " Team Chat " },
43+ });
44+ fireEvent.click(screen.getByRole("button", { name: "Create Channel" }));
45+
46+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith(channel));
47+ expect(createChannelInstant).toHaveBeenCalledWith("Team Chat");
48+ });
49+
50+ it("leaves the name unset when the field is blank", async () => {
51+ const { createChannelInstant, onCreated, channel } = setup();
52+
53+ fireEvent.click(screen.getByRole("button", { name: "Create Channel" }));
54+
55+ await waitFor(() => expect(onCreated).toHaveBeenCalledWith(channel));
56+ expect(createChannelInstant).toHaveBeenCalledWith(null);
57+ });
58+
59+ it("shows an error and keeps the dialog open when creation fails", async () => {
60+ const { createChannelInstant, onClose, onCreated } = setup();
61+ createChannelInstant.mockRejectedValue(
62+ new Error("No default service on which to make a channel"),
63+ );
64+
65+ fireEvent.click(screen.getByRole("button", { name: "Create Channel" }));
66+
67+ expect((await screen.findByRole("alert")).textContent).toContain(
68+ "No default service on which to make a channel",
69+ );
70+ expect(onCreated).not.toHaveBeenCalled();
71+ expect(onClose).not.toHaveBeenCalled();
72+ });
73+});
+87 -0 src/components/CreateChannelDialog.tsx #
......@@ -0,0 +1,87 @@
1+import * as borogove from "borogove";
2+import { useState } from "react";
3+import {
4+ Button,
5+ Dialog,
6+ DialogActions,
7+ DialogContent,
8+ DialogContentText,
9+ DialogTitle,
10+ TextField,
11+} from "@mui/material";
12+
13+export const CreateChannelDialog = ({
14+ open,
15+ client,
16+ onClose,
17+ onCreated,
18+}: {
19+ open: boolean;
20+ client: borogove.Client;
21+ onClose: () => void;
22+ onCreated: (channel: borogove.Channel) => void;
23+}) => {
24+ const [name, setName] = useState("");
25+ const [creating, setCreating] = useState(false);
26+ const [error, setError] = useState<string | null>(null);
27+
28+ const close = () => {
29+ if (creating) return;
30+ setName("");
31+ setError(null);
32+ onClose();
33+ };
34+
35+ const createChannel = async (event: React.FormEvent<HTMLFormElement>) => {
36+ event.preventDefault();
37+ if (creating) return;
38+
39+ setCreating(true);
40+ setError(null);
41+
42+ try {
43+ const channel = await client.createChannelInstant(name.trim() || null);
44+ setName("");
45+ onCreated(channel);
46+ } catch (e) {
47+ setError(e instanceof Error ? e.message : String(e));
48+ } finally {
49+ setCreating(false);
50+ }
51+ };
52+
53+ return (
54+ <Dialog open={open} onClose={close} fullWidth maxWidth="sm">
55+ <DialogTitle>New Private Channel</DialogTitle>
56+ <form onSubmit={createChannel}>
57+ <DialogContent>
58+ <DialogContentText>Create an invite-only channel.</DialogContentText>
59+ <TextField
60+ autoFocus
61+ fullWidth
62+ margin="dense"
63+ label="Channel name"
64+ helperText="Optional. Leave blank to generate one automatically."
65+ value={name}
66+ onChange={(e) => setName(e.target.value)}
67+ disabled={creating}
68+ error={!!error}
69+ />
70+ {error && (
71+ <DialogContentText role="alert" color="error">
72+ {error}
73+ </DialogContentText>
74+ )}
75+ </DialogContent>
76+ <DialogActions>
77+ <Button onClick={close} disabled={creating}>
78+ Cancel
79+ </Button>
80+ <Button type="submit" variant="contained" disabled={creating}>
81+ {creating ? "Creating..." : "Create Channel"}
82+ </Button>
83+ </DialogActions>
84+ </form>
85+ </Dialog>
86+ );
87+};
Back to top