cheogram-www
created pr with
cheogram-www/create-private-channel.1
added cheogram-www/create-private-channel.2
1: b7201b7 = 1: b796dc1 Add UI to create private channel
added cheogram-www/create-private-channel.3
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 -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:create-private-channel [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:create-private-channeladd comment to patch request:
ssh pr.pico.sh comment cheogram-www:create-private-channel "lgtm!"
Patchset
cheogram-www/create-private-channel.2
Add UI to create private channel
Stephen Paul Weber
2026-11-09T15:49:33ZSemantic diff summary
0 added,
7 modified,
0 signature changed,
0 removed
across 3 analyzed files
+24
-0
src/components/Chats.tsx
#
| ... | ... | @@ -64,6 +65,7 @@ import { useHotkeys } from "react-hotkeys-hook"; | |
| 64 | 65 | import { AvatarWithPlaceholder } from "./AvatarWithPlaceholder"; | |
| 65 | 66 | import { AwaitOnce } from "./AwaitOnce"; | |
| 66 | 67 | import { confirmDialog } from "./ConfirmDialog"; | |
| 68 | + | import { CreateChannelDialog } from "./CreateChannelDialog"; | |
| 67 | 69 | import { Profile } from "./Profile"; | |
| 68 | 70 | import { SettingsContext } from "./Settings"; | |
| 69 | 71 | import { href, useEffectEvent, useChatExtras, useChats, Chat } from "../util"; |
| ... | ... | @@ -200,6 +202,7 @@ export const Chats = () => { | |
| 200 | 202 | const [banMember, setBanMember] = useState<borogove.Member | null>(null); | |
| 201 | 203 | ||
| 202 | 204 | const [popupMember, setPopupMember] = useState<borogove.Member | undefined>(); | |
| 205 | + | const [createChannelOpen, setCreateChannelOpen] = useState(false); | |
| 203 | 206 | const [scanQRCode, setScanQRCode] = useState(false); | |
| 204 | 207 | const [qrStatus, setQRStatus] = useState("Loading camera..."); | |
| 205 | 208 | const qrDecodeTimeout = useRef<number | null>(null); |
| ... | ... | @@ -615,6 +618,17 @@ export const Chats = () => { | |
| 615 | 618 | </ListItemIcon> | |
| 616 | 619 | Scan QR Code | |
| 617 | 620 | </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> | |
| 618 | 632 | </Menu> | |
| 619 | 633 | </ConversationHeader.Actions> | |
| 620 | 634 | </ConversationHeader> |
| ... | ... | @@ -1237,6 +1251,16 @@ export const Chats = () => { | |
| 1237 | 1251 | </Dialog> | |
| 1238 | 1252 | )} | |
| 1239 | 1253 | ||
| 1254 | + | <CreateChannelDialog | |
| 1255 | + | open={createChannelOpen} | |
| 1256 | + | client={client} | |
| 1257 | + | onClose={() => setCreateChannelOpen(false)} | |
| 1258 | + | onCreated={(channel) => { | |
| 1259 | + | setCreateChannelOpen(false); | |
| 1260 | + | navigateToChat(channel); | |
| 1261 | + | }} | |
| 1262 | + | /> | |
| 1263 | + | ||
| 1240 | 1264 | {banMember && ( | |
| 1241 | 1265 | <Dialog open onClose={() => setBanMember(null)} id="ban-dialog"> | |
| 1242 | 1266 | <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 | + | }; |