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.1
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"; |
| ... | ... | @@ -190,6 +192,7 @@ export const Chats = () => { | |
| 190 | 192 | const [banMember, setBanMember] = useState<borogove.Member | null>(null); | |
| 191 | 193 | ||
| 192 | 194 | const [popupMember, setPopupMember] = useState<borogove.Member | undefined>(); | |
| 195 | + | const [createChannelOpen, setCreateChannelOpen] = useState(false); | |
| 193 | 196 | const [scanQRCode, setScanQRCode] = useState(false); | |
| 194 | 197 | const [qrStatus, setQRStatus] = useState("Loading camera..."); | |
| 195 | 198 | const qrDecodeTimeout = useRef<number | null>(null); |
| ... | ... | @@ -606,6 +609,17 @@ export const Chats = () => { | |
| 606 | 609 | </ListItemIcon> | |
| 607 | 610 | Scan QR Code | |
| 608 | 611 | </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> | |
| 609 | 623 | </Menu> | |
| 610 | 624 | </ConversationHeader.Actions> | |
| 611 | 625 | </ConversationHeader> |
| ... | ... | @@ -1228,6 +1242,16 @@ export const Chats = () => { | |
| 1228 | 1242 | </Dialog> | |
| 1229 | 1243 | )} | |
| 1230 | 1244 | ||
| 1245 | + | <CreateChannelDialog | |
| 1246 | + | open={createChannelOpen} | |
| 1247 | + | client={client} | |
| 1248 | + | onClose={() => setCreateChannelOpen(false)} | |
| 1249 | + | onCreated={(channel) => { | |
| 1250 | + | setCreateChannelOpen(false); | |
| 1251 | + | navigateToChat(channel); | |
| 1252 | + | }} | |
| 1253 | + | /> | |
| 1254 | + | ||
| 1231 | 1255 | {banMember && ( | |
| 1232 | 1256 | <Dialog open onClose={() => setBanMember(null)} id="ban-dialog"> | |
| 1233 | 1257 | <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 | + | }; |