Chat's sidebar keeps public channels in their own group (#52)
A public channel (open to anyone on g1t) sat under Channels beside the workspace's own, so #community-support read as one more internal room. The sidebar now has Channels (workspace and private, by name, with + to create one and Browse channels) and, below it, Public channels, by name, shown only when you are in some. It folds and stays folded on the device like the other sections, keeps unread and mention counts, follows All / Unread / Mentions and Jump to (Enter goes to the first match in the order shown). A public channel you pinned stays under Pinned. lib/chat.ts's sections() gives the public ones as their own list. A guest sees Channels only for channels they were asked into; Public channels is always there for them, with "You're in none of its public channels yet." when empty and Browse public channels at its end. The phone's list and the desktop app draw the same ChatSidebar, so they group the same way. The chat guide's sidebar table names the new group.
- Checked:
- the sidebar rendered in AppShell with mocked chat data (public, workspace and private channels, pinned ones, unread counts and mentions, a muted channel, DMs, another workspace's channel) at 1280 and 390 in dark and light, with Unread and Mentions, with Public channels folded (an unread one stays shown), and as a guest with an invited channel, with only public ones and with none; the site's typecheck and tests (956, with a new sections() test); npm run build (every route loads, 258); the docs build.
- G1t-Session:
- ses_01m4ynr1peevvs8zwaqakycv2e
| 209 | 209 | | Section | What's in it | | |
| 210 | 210 | | --- | --- | | |
| 211 | 211 | | **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. | | |
| 212 | − | | **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. | | |
| 212 | + | | **Channels** | The workspace's own channels you're in, workspace and private, by name, then **Browse channels** with how many more you could join. **+** creates one. | | |
| 213 | + | | **Public channels** | The [public channels](#public-channels) you're in, by name, kept apart because anyone on g1t can be in them. Only when you're in some. | | |
| 213 | 214 | | **Direct messages** | [g1t](#talk-with-g1t) first, then your direct messages, the latest first. The pencil starts a new message. Empty, it says **Message a teammate.** | | |
| 214 | 215 | ||
| 215 | 216 | Unread conversations are in bold with a count; mentions of you are counted |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | /** | |
| 46 | − | * Chat mode's sidebar: what you pinned, the channels (and a way to browse | |
| 47 | − | * the rest), then direct messages, the latest first. Sections fold, and | |
| 46 | + | * Chat mode's sidebar: what you pinned, the workspace's own channels (and a | |
| 47 | + | * way to browse the rest), its public channels, then direct messages, the | |
| 48 | + | * latest first. Sections fold, and | |
| 48 | 49 | * stay folded on this device. All, Unread and Mentions filter every section. It refreshes | |
| 49 | 50 | * itself while the tab is shown, so unread counts move without a reload. | |
| 50 | 51 | */ | |
| ⋯ | |||
| 84 | 85 | // The open conversation stays listed even when it is an old one with an agent alone, which Jump to otherwise finds. | |
| 85 | 86 | const currentId = entries.find((e) => pathname === channelPath(slug, e.channel) || pathname.startsWith(`${channelPath(slug, e.channel)}/`))?.channel.id ?? null; | |
| 86 | 87 | const shown = filterEntries(entries, filter, query, currentId); | |
| 87 | − | const { pinned, channels, dms } = sections(shown); | |
| 88 | + | const { pinned, channels, publicChannels, dms } = sections(shown); | |
| 88 | 89 | const loading = !data; | |
| 89 | 90 | ||
| 90 | 91 | // Folded sections, remembered per workspace on this device. A folded | |
| ⋯ | |||
| 141 | 142 | onChange={(event) => setQuery(event.target.value)} | |
| 142 | 143 | onKeyDown={(event) => { | |
| 143 | 144 | if (event.key === "Enter") { | |
| 144 | − | const first = [...pinned, ...channels, ...dms][0]; | |
| 145 | + | const first = [...pinned, ...channels, ...publicChannels, ...dms][0]; | |
| 145 | 146 | if (first) { | |
| 146 | 147 | navigate(channelPath(slug, first.channel)); | |
| 147 | 148 | setQuery(""); | |
| ⋯ | |||
| 191 | 192 | {pinned.map(conversation)} | |
| 192 | 193 | </Section> | |
| 193 | 194 | )} | |
| 194 | − | <Section | |
| 195 | − | id="channels" | |
| 196 | − | title="Channels" | |
| 197 | − | folded={folded} | |
| 198 | − | onFold={fold} | |
| 199 | − | action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />} | |
| 200 | − | keep={channels.filter(keepShown).map(conversation)} | |
| 201 | − | > | |
| 202 | − | {channels.map(conversation)} | |
| 203 | − | {channels.length === 0 && filter === "all" && !query && <Empty>{sidebar.guest ? "You're in none of its channels yet." : "No channels yet."}</Empty>} | |
| 204 | − | {filter === "all" && !query && ( | |
| 205 | − | <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={sidebar.browsable > 0 ? sidebar.browsable : undefined} countLabel="to join"> | |
| 206 | − | {sidebar.guest ? "Public channels" : "Browse channels"} | |
| 207 | − | </ExtraRow> | |
| 208 | − | )} | |
| 209 | − | </Section> | |
| 195 | + | {/* A guest has only the channels they were asked into here; with none, the public ones are all there is. */} | |
| 196 | + | {(!sidebar.guest || channels.length > 0) && ( | |
| 197 | + | <Section | |
| 198 | + | id="channels" | |
| 199 | + | title="Channels" | |
| 200 | + | folded={folded} | |
| 201 | + | onFold={fold} | |
| 202 | + | action={sidebar.guest ? undefined : <CreateChannelButton slug={slug} />} | |
| 203 | + | keep={channels.filter(keepShown).map(conversation)} | |
| 204 | + | > | |
| 205 | + | {channels.map(conversation)} | |
| 206 | + | {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>} | |
| 207 | + | {!sidebar.guest && filter === "all" && !query && <BrowseRow slug={slug} count={sidebar.browsable} />} | |
| 208 | + | </Section> | |
| 209 | + | )} | |
| 210 | + | {/* Open to anyone on g1t, apart from the workspace's own: only when there are some, or for a guest, to find them. */} | |
| 211 | + | {(publicChannels.length > 0 || (sidebar.guest && filter === "all" && !query)) && ( | |
| 212 | + | <Section id="public" title="Public channels" folded={folded} onFold={fold} keep={publicChannels.filter(keepShown).map(conversation)}> | |
| 213 | + | {publicChannels.map(conversation)} | |
| 214 | + | {sidebar.guest && filter === "all" && !query && ( | |
| 215 | + | <> | |
| 216 | + | {publicChannels.length === 0 && <Empty>You're in none of its public channels yet.</Empty>} | |
| 217 | + | <BrowseRow slug={slug} count={sidebar.browsable} guest /> | |
| 218 | + | </> | |
| 219 | + | )} | |
| 220 | + | </Section> | |
| 221 | + | )} | |
| 210 | 222 | {(sidebar.invitations?.length ?? 0) > 0 && filter === "all" && !query && ( | |
| 211 | 223 | <Section id="invitations" title="Invitations" folded={folded} onFold={fold}> | |
| 212 | 224 | {sidebar.invitations!.map((invitation) => ( | |
| ⋯ | |||
| 341 | 353 | ); | |
| 342 | 354 | } | |
| 343 | 355 | ||
| 356 | + | /** Browse channels, with how many more there are to join; a guest browses the public ones. */ | |
| 357 | + | function BrowseRow({ slug, count, guest = false }: { slug: string; count: number; guest?: boolean }) { | |
| 358 | + | return ( | |
| 359 | + | <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={count > 0 ? count : undefined} countLabel="to join"> | |
| 360 | + | {guest ? "Browse public channels" : "Browse channels"} | |
| 361 | + | </ExtraRow> | |
| 362 | + | ); | |
| 363 | + | } | |
| 364 | + | ||
| 344 | 365 | function Empty({ children }: { children: ReactNode }) { | |
| 345 | 366 | return <li className="px-2 py-1 text-xs text-faint">{children}</li>; | |
| 346 | 367 | } | |
| 123 | 123 | assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" }); | |
| 124 | 124 | }); | |
| 125 | 125 | ||
| 126 | − | function entry(id: string, over: Partial<ChatSidebarEntry> & { kind?: "channel" | "dm"; last?: string } = {}): ChatSidebarEntry { | |
| 126 | + | function entry(id: string, over: Partial<ChatSidebarEntry> & { kind?: "channel" | "dm"; last?: string; visibility?: "private" | "workspace" | "public" } = {}): ChatSidebarEntry { | |
| 127 | 127 | return { | |
| 128 | 128 | channel: { | |
| 129 | 129 | id, | |
| ⋯ | |||
| 131 | 131 | kind: over.kind ?? "channel", | |
| 132 | 132 | name: over.kind === "dm" ? null : id, | |
| 133 | 133 | topic: null, | |
| 134 | − | private: false, | |
| 134 | + | private: over.visibility === "private", | |
| 135 | + | visibility: over.visibility ?? (over.kind === "dm" ? "private" : "workspace"), | |
| 135 | 136 | created_by: { kind: "user", id: "u1" }, | |
| 136 | 137 | created_at: "2026-10-01T00:00:00Z", | |
| 137 | 138 | archived_at: null, | |
| ⋯ | |||
| 156 | 157 | entry("d3", { kind: "dm", title: "ana, reviewer", others: [ana, bot], last: "2026-10-03T00:00:00Z" }), | |
| 157 | 158 | entry("d4", { kind: "dm", title: "notes", others: [], last: "2026-10-01T00:00:00Z" }), | |
| 158 | 159 | ]; | |
| 159 | − | const { pinned, channels, dms } = sections(all); | |
| 160 | + | const { pinned, channels, publicChannels, dms } = sections(all); | |
| 160 | 161 | assert.deepEqual(pinned.map((e) => e.title), ["general"]); | |
| 161 | 162 | assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]); | |
| 163 | + | assert.deepEqual(publicChannels, []); | |
| 162 | 164 | assert.deepEqual(dms.map((e) => e.title), ["ana", "ana, reviewer", "reviewer", "notes"]); | |
| 163 | 165 | // An old conversation with an agent alone is left out until searched for or open. | |
| 164 | 166 | assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "noisy"]); | |
| ⋯ | |||
| 167 | 169 | assert.deepEqual(unreadTotals(all), { unread: 3, mentions: 1 }); | |
| 168 | 170 | }); | |
| 169 | 171 | ||
| 172 | + | test("the sidebar keeps public channels apart from the workspace's own", () => { | |
| 173 | + | const all = [ | |
| 174 | + | entry("general"), | |
| 175 | + | entry("leads", { visibility: "private", unread: 1 }), | |
| 176 | + | entry("support", { visibility: "public", mentions: 2, unread: 2 }), | |
| 177 | + | entry("announcements", { visibility: "public" }), | |
| 178 | + | entry("launch", { visibility: "public", starred: true }), | |
| 179 | + | entry("d1", { kind: "dm", title: "ana", others: [ana] }), | |
| 180 | + | ]; | |
| 181 | + | const { pinned, channels, publicChannels, dms } = sections(all); | |
| 182 | + | // Pinned keeps a public channel you pinned; the rest are by name in their own group. | |
| 183 | + | assert.deepEqual(pinned.map((e) => e.title), ["launch"]); | |
| 184 | + | assert.deepEqual(channels.map((e) => e.title), ["general", "leads"]); | |
| 185 | + | assert.deepEqual(publicChannels.map((e) => e.title), ["announcements", "support"]); | |
| 186 | + | assert.deepEqual(dms.map((e) => e.title), ["ana"]); | |
| 187 | + | // Filters reach both groups. | |
| 188 | + | assert.deepEqual(sections(filterEntries(all, "unread", "")).publicChannels.map((e) => e.title), ["support"]); | |
| 189 | + | assert.deepEqual(sections(filterEntries(all, "mentions", "")).channels, []); | |
| 190 | + | }); | |
| 191 | + | ||
| 170 | 192 | test("an old conversation with an agent alone opens read-only, under the name the agent had", () => { | |
| 171 | 193 | const deleted = { ...bot, id: "a9", name: "triage", display_name: "Triage", deleted: true }; | |
| 172 | 194 | const all = [ | |
| 7 | 7 | * Message bodies are never HTML: they are parsed here into a small tree | |
| 8 | 8 | * that React renders as text, so nothing anyone writes can run. | |
| 9 | 9 | */ | |
| 10 | − | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; | |
| 10 | + | import type { Channel, ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | /** Two messages from the same author this close together share one header. */ | |
| 13 | 13 | export const GROUP_GAP_MS = 5 * 60 * 1000; | |
| ⋯ | |||
| 182 | 182 | ||
| 183 | 183 | /** | |
| 184 | 184 | * The sidebar's sections, in the order they show: what is pinned (the | |
| 185 | − | * `starred` preference), channels by name, then direct messages, the | |
| 186 | − | * latest first. | |
| 185 | + | * `starred` preference), the workspace's own channels (workspace and | |
| 186 | + | * private) by name, its public channels by name, then direct messages, the | |
| 187 | + | * latest first. A pinned channel stays under Pinned, whatever it is. | |
| 187 | 188 | */ | |
| 188 | 189 | export function sections(entries: ChatSidebarEntry[]): { | |
| 189 | 190 | pinned: ChatSidebarEntry[]; | |
| 190 | 191 | channels: ChatSidebarEntry[]; | |
| 192 | + | publicChannels: ChatSidebarEntry[]; | |
| 191 | 193 | dms: ChatSidebarEntry[]; | |
| 192 | 194 | } { | |
| 193 | 195 | const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title); | |
| ⋯ | |||
| 196 | 198 | (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at); | |
| 197 | 199 | return { | |
| 198 | 200 | pinned: entries.filter((e) => e.starred).sort(byName), | |
| 199 | − | channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName), | |
| 201 | + | channels: entries.filter((e) => !e.starred && e.channel.kind === "channel" && !isPublic(e.channel)).sort(byName), | |
| 202 | + | publicChannels: entries.filter((e) => !e.starred && e.channel.kind === "channel" && isPublic(e.channel)).sort(byName), | |
| 200 | 203 | // Your direct message with @g1t heads the list, whenever you last talked. | |
| 201 | 204 | dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort((a, b) => Number(g1tEntry(b)) - Number(g1tEntry(a)) || byRecent(a, b)), | |
| 202 | 205 | }; | |
| 203 | 206 | } | |
| 204 | 207 | ||
| 208 | + | /** Open to anyone on g1t, not only the workspace's members. */ | |
| 209 | + | function isPublic(channel: Pick<Channel, "private" | "visibility">): boolean { | |
| 210 | + | return !channel.private && channel.visibility === "public"; | |
| 211 | + | } | |
| 212 | + | ||
| 205 | 213 | /** Whether a sidebar entry is the viewer's direct message with @g1t. */ | |
| 206 | 214 | export function g1tEntry(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind" | "id">[] }): boolean { | |
| 207 | 215 | return entry.channel.kind === "dm" && entry.others.length === 1 && entry.others[0]!.kind === "system" && entry.others[0]!.id === "g1t"; | |
Comments
Sign in to comment.