cheogram-www

created pr with cheogram-www/create-private-channel.1 on 2026-11-09T15:50:15Z · by 9e834e0d
added cheogram-www/create-private-channel.2 on 2026-11-10T19:22:00Z · by 9e834e0d
1: b7201b7 = 1: b796dc1 Add UI to create private channel
added cheogram-www/create-private-channel.3 on 2026-11-10T19:22:27Z · by 9e834e0d
1: b796dc1 ! 1: 65e966b Add UI to create private channel
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:create-private-channel | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:create-private-channel [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:create-private-channel
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:create-private-channel "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";
......@@ -200,6 +202,7 @@ export const Chats = () => {
200202 const [banMember, setBanMember] = useState<borogove.Member | null>(null);
201203
202204 const [popupMember, setPopupMember] = useState<borogove.Member | undefined>();
205+ const [createChannelOpen, setCreateChannelOpen] = useState(false);
203206 const [scanQRCode, setScanQRCode] = useState(false);
204207 const [qrStatus, setQRStatus] = useState("Loading camera...");
205208 const qrDecodeTimeout = useRef<number | null>(null);
......@@ -615,6 +618,17 @@ export const Chats = () => {
615618 </ListItemIcon>
616619 Scan QR Code
617620 </MenuItem>
621+ <MenuItem
622+ onClick={() => {
623+ setCreateChannelOpen(true);
624+ setAddContactAnchorEl(null);
625+ }}
626+ >
627+ <ListItemIcon>
628+ <FontAwesomeIcon icon={faUserGroup} />
629+ </ListItemIcon>
630+ New Private Channel
631+ </MenuItem>
618632 </Menu>
619633 </ConversationHeader.Actions>
620634 </ConversationHeader>
......@@ -1237,6 +1251,16 @@ export const Chats = () => {
12371251 </Dialog>
12381252 )}
12391253
1254+ <CreateChannelDialog
1255+ open={createChannelOpen}
1256+ client={client}
1257+ onClose={() => setCreateChannelOpen(false)}
1258+ onCreated={(channel) => {
1259+ setCreateChannelOpen(false);
1260+ navigateToChat(channel);
1261+ }}
1262+ />
1263+
12401264 {banMember && (
12411265 <Dialog open onClose={() => setBanMember(null)} id="ban-dialog">
12421266 <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