| 1 | import { |
| 2 | Archive, |
| 3 | ArchiveRestore, |
| 4 | ChevronLeft, |
| 5 | Copy, |
| 6 | Ellipsis, |
| 7 | FileText, |
| 8 | Link2, |
| 9 | Pencil, |
| 10 | Trash2, |
| 11 | BellOff, |
| 12 | Hash, |
| 13 | Lock, |
| 14 | MessageSquareText, |
| 15 | PanelRight, |
| 16 | Star, |
| 17 | UserPlus, |
| 18 | Users, |
| 19 | X, |
| 20 | } from "lucide-react"; |
| 21 | import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 22 | import { Link, useNavigate, useRouteLoaderData, useSearchParams } from "react-router"; |
| 23 | |
| 24 | import { |
| 25 | type Channel, |
| 26 | type ChannelChange, |
| 27 | type ChannelMember, |
| 28 | type ChatLiveEvent, |
| 29 | type ChatMessage, |
| 30 | type MemberProfile, |
| 31 | type Result, |
| 32 | hasCodeAccess, |
| 33 | shownUsername, |
| 34 | } from "@g1t/contracts"; |
| 35 | |
| 36 | import { useChatData, useChatSend, useChatSidebar } from "./actions"; |
| 37 | import { CardBox, CardToasts, showToast } from "./card"; |
| 38 | import { Composer } from "./composer"; |
| 39 | import { type LiveState, useChatLive } from "./live"; |
| 40 | import { AgentPill, MemberAvatar } from "./marks"; |
| 41 | import { PersonStatusEmoji, PersonStatusLine, PresenceSummary, WithPresence } from "../presence"; |
| 42 | import { CardContext, MemberCard, type PersonCard, personCard } from "./profile-card"; |
| 43 | import { Avatar } from "../ui"; |
| 44 | import { localTime } from "../../lib/time-zone"; |
| 45 | import { BottomSheet, SheetRow, useBack, useSwipeBack } from "../mobile"; |
| 46 | import { MessageText, type TextContext } from "./text"; |
| 47 | import { WriteUpDialog } from "./write-up"; |
| 48 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../ui/dropdown-menu"; |
| 49 | import { threadLink, writeUpAgents } from "../../lib/write-up"; |
| 50 | import { |
| 51 | AlertDialog, |
| 52 | AlertDialogAction, |
| 53 | AlertDialogCancel, |
| 54 | AlertDialogContent, |
| 55 | AlertDialogDescription, |
| 56 | AlertDialogFooter, |
| 57 | AlertDialogHeader, |
| 58 | AlertDialogTitle, |
| 59 | } from "../ui/alert-dialog"; |
| 60 | import { Badge } from "../ui/badge"; |
| 61 | import { Hint } from "../ui/hint"; |
| 62 | import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; |
| 63 | import { type Mentionable, type ShownMessage, channelPath, mentionNames, mergeMessages, shownHandle, shownName, timeline } from "../../lib/chat"; |
| 64 | import { codeAccessPath } from "../../lib/workspace-nav"; |
| 65 | import { conversationCache } from "./conversation-cache"; |
| 66 | // Reactions and the workspace's own emoji (components/emoji). |
| 67 | import { EmojiProvider, useCustomEmoji } from "../emoji/context"; |
| 68 | import { AddReaction, QuickReactions, ReactionBar } from "../emoji/reaction-bar"; |
| 69 | import { useAddresses } from "../../lib/addresses"; |
| 70 | import { applyReactionEvent, keepMine, toggleReaction } from "../../lib/emoji"; |
| 71 | import type { ChannelData } from "../../routes/workspace/chat/channel"; |
| 72 | |
| 73 | type Loaded = Extract<ChannelData, { unavailable: false }>; |
| 74 | |
| 75 | /** True once the page has hydrated: times and days are the reader's own from then on. */ |
| 76 | function useHydrated(): boolean { |
| 77 | const [hydrated, setHydrated] = useState(false); |
| 78 | useEffect(() => setHydrated(true), []); |
| 79 | return hydrated; |
| 80 | } |
| 81 | |
| 82 | function clock(at: string, zone: string | undefined): string { |
| 83 | return new Date(at).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone: zone }); |
| 84 | } |
| 85 | |
| 86 | /** Whether the viewer uses Code in this workspace (docs/WORKSPACE.md, "Members without Code"). */ |
| 87 | export function useCodeAccess(slug: string): boolean { |
| 88 | const root = useRouteLoaderData("root") as { user?: { workspaces?: { slug: string; code_access?: boolean }[] } | null } | undefined; |
| 89 | return hasCodeAccess(root?.user?.workspaces?.find((m) => m.slug === slug) ?? null); |
| 90 | } |
| 91 | |
| 92 | /** The project a channel of the same name is about, for a bare `#123`. */ |
| 93 | function useProjectNamed(name: string | null): string | null { |
| 94 | const root = useRouteLoaderData("root") as { shell?: { repos?: { name: string }[] } } | undefined; |
| 95 | if (!name) return null; |
| 96 | return root?.shell?.repos?.some((repo) => repo.name === name) ? name : null; |
| 97 | } |
| 98 | |
| 99 | function newClientId(): string { |
| 100 | return `tmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; |
| 101 | } |
| 102 | |
| 103 | /** A conversation: its header, its messages, the box to write in, and its thread and details beside it. */ |
| 104 | export function ChannelView({ data }: { data: Loaded }) { |
| 105 | const chatData = useChatData(); |
| 106 | const slug = chatData?.slug ?? ""; |
| 107 | const me = chatData?.me ?? null; |
| 108 | const send = useChatSend(slug); |
| 109 | const { sidebar, refresh } = useChatSidebar(); |
| 110 | const hydrated = useHydrated(); |
| 111 | const zone = hydrated ? undefined : "UTC"; |
| 112 | const code = useCodeAccess(slug); |
| 113 | const project = useProjectNamed(data.channel.kind === "channel" ? data.channel.name : null); |
| 114 | const [params, setParams] = useSearchParams(); |
| 115 | const threadId = params.get("thread"); |
| 116 | |
| 117 | const [messages, setMessages] = useState<ShownMessage[]>(() => mergeMessages([], data.messages)); |
| 118 | const [older, setOlder] = useState<string | null>(data.older); |
| 119 | const [loadingOlder, setLoadingOlder] = useState(false); |
| 120 | const [thread, setThread] = useState<ShownMessage[] | null>(null); |
| 121 | // The message the open thread is under, as its page says, for when it is |
| 122 | // further back than the conversation has loaded (a long-running session's card). |
| 123 | const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null); |
| 124 | const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map()); |
| 125 | const [joined, setJoined] = useState(data.joined); |
| 126 | const [members, setMembers] = useState<ChannelMember[]>(data.members); |
| 127 | // The channel as it is now: renamed, its topic changed, archived, live. |
| 128 | const [channel, setChannel] = useState<Channel>(data.channel); |
| 129 | const [canManage, setCanManage] = useState(data.can_manage === true); |
| 130 | const view = useMemo<Loaded>(() => ({ ...data, channel, title: channel.kind === "channel" ? `#${channel.name}` : data.title }), [data, channel]); |
| 131 | // The server's answer behind a cached draw: merged in by id. |
| 132 | useEffect(() => { |
| 133 | const onFresh = (event: Event) => { |
| 134 | const { key, value } = (event as CustomEvent<{ key: string; value: ChannelData }>).detail; |
| 135 | if (key !== window.location.pathname || value.unavailable || value.channel.id !== data.channel.id) return; |
| 136 | setMessages((now) => mergeMessages(now, value.messages)); |
| 137 | setMembers(value.members); |
| 138 | setJoined(value.joined); |
| 139 | setChannel(value.channel); |
| 140 | setCanManage(value.can_manage === true); |
| 141 | }; |
| 142 | window.addEventListener("g1t:chat-fresh", onFresh); |
| 143 | return () => window.removeEventListener("g1t:chat-fresh", onFresh); |
| 144 | }, [data.channel.id]); |
| 145 | // The cache follows what is on screen, so coming back shows the latest. |
| 146 | useEffect(() => { |
| 147 | const path = window.location.pathname; |
| 148 | const timer = setTimeout( |
| 149 | () => conversationCache.set(path, { ...view, messages: messages.filter((m) => !m.pending).slice(-80), members, joined, can_manage: canManage }), |
| 150 | 400, |
| 151 | ); |
| 152 | return () => clearTimeout(timer); |
| 153 | }, [view, messages, members, joined, canManage]); |
| 154 | // Renamed while open, here or by someone else: the address follows the name. |
| 155 | const navigate = useNavigate(); |
| 156 | const nameShown = useRef(data.channel.name); |
| 157 | useEffect(() => { |
| 158 | if (channel.kind !== "channel" || channel.name === nameShown.current || !slug) return; |
| 159 | nameShown.current = channel.name; |
| 160 | navigate(`${channelPath(slug, channel)}${window.location.search}`, { replace: true, preventScrollReset: true }); |
| 161 | }, [channel, slug, navigate]); |
| 162 | const [info, setInfo] = useState(false); |
| 163 | // A phone: back to the list, by the chevron or a swipe from the edge; |
| 164 | // and a long-pressed message's actions. |
| 165 | const back = useBack(`/${chatData?.slug ?? ""}/-/chat`); |
| 166 | const swipe = useSwipeBack(back); |
| 167 | const [actions, setActions] = useState<ShownMessage | null>(null); |
| 168 | // Someone's profile, opened from their card, beside the conversation. |
| 169 | const [profile, setProfile] = useState<string | null>(null); |
| 170 | // A message being edited: in its place on a computer (`inline`), in a sheet on a phone. |
| 171 | const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null); |
| 172 | const scroller = useRef<HTMLDivElement>(null); |
| 173 | const stick = useRef(true); |
| 174 | |
| 175 | const entry = sidebar?.entries.find((e) => e.channel.id === data.channel.id); |
| 176 | const starred = entry?.starred ?? members.find((m) => m.member.kind === "user" && m.member.id === me?.id)?.starred ?? false; |
| 177 | const muted = entry?.muted ?? false; |
| 178 | const others = members.filter((m) => !(m.member.kind === "user" && m.member.id === me?.id)).map((m) => m.member); |
| 179 | const isDm = data.channel.kind === "dm"; |
| 180 | |
| 181 | // The details panel: open by default on a wide screen, as last left. |
| 182 | useEffect(() => { |
| 183 | try { |
| 184 | const saved = localStorage.getItem("chat-info"); |
| 185 | setInfo(saved ? saved === "1" : window.matchMedia("(min-width: 1280px)").matches && !isDm); |
| 186 | } catch { |
| 187 | setInfo(false); |
| 188 | } |
| 189 | }, [isDm]); |
| 190 | const toggleInfo = () => |
| 191 | setInfo((now) => { |
| 192 | try { |
| 193 | localStorage.setItem("chat-info", now ? "0" : "1"); |
| 194 | } catch { |
| 195 | // Not kept: fine. |
| 196 | } |
| 197 | return !now; |
| 198 | }); |
| 199 | |
| 200 | const people = useMemo<Mentionable[]>( |
| 201 | () => [ |
| 202 | ...(chatData?.agents ?? []).map((agent) => ({ kind: "agent" as const, name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role })), |
| 203 | ...(chatData?.people ?? []).filter((p) => p.name !== me?.username), |
| 204 | ], |
| 205 | [chatData, me?.username], |
| 206 | ); |
| 207 | const context = useMemo<TextContext>( |
| 208 | () => ({ |
| 209 | slug, |
| 210 | me: me?.username ?? null, |
| 211 | agents: new Set((chatData?.agents ?? []).map((a) => a.handle.toLowerCase())), |
| 212 | // `@ana` reads as the name people know: everyone in the workspace, the viewer too. |
| 213 | names: mentionNames([...people, ...(me ? [{ kind: "user" as const, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar }] : [])]), |
| 214 | channels: new Set((sidebar?.entries ?? []).filter((e) => e.channel.kind === "channel").map((e) => e.channel.name ?? "")), |
| 215 | project, |
| 216 | codeLink: code ? undefined : (href: string) => (/^\/[^/]+\/(?!-\/)[^/]+/.test(href) ? codeAccessPath(slug, href) : href), |
| 217 | }), |
| 218 | [slug, me, people, chatData?.agents, sidebar?.entries, project, code], |
| 219 | ); |
| 220 | |
| 221 | // Keeps the newest message in view while the reader is at the bottom. |
| 222 | const toBottom = useCallback((smooth = false) => { |
| 223 | const element = scroller.current; |
| 224 | if (element) element.scrollTo({ top: element.scrollHeight, behavior: smooth ? "smooth" : "auto" }); |
| 225 | }, []); |
| 226 | useEffect(() => toBottom(), [toBottom]); |
| 227 | useEffect(() => { |
| 228 | if (stick.current) toBottom(true); |
| 229 | }, [messages.length, typing.size, toBottom]); |
| 230 | |
| 231 | // Read up to the newest message whenever it is in view. |
| 232 | const lastRead = useRef<string | null>(null); |
| 233 | const markRead = useCallback(() => { |
| 234 | if (!joined || document.visibilityState !== "visible") return; |
| 235 | const newest = [...messages].reverse().find((m) => !m.pending); |
| 236 | if (!newest || newest.id === lastRead.current) return; |
| 237 | lastRead.current = newest.id; |
| 238 | void send({ intent: "read", channel_id: data.channel.id, id: newest.id }); |
| 239 | window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: data.channel.id })); |
| 240 | }, [messages, joined, send, data.channel.id]); |
| 241 | useEffect(() => { |
| 242 | const timer = setTimeout(markRead, 400); |
| 243 | document.addEventListener("visibilitychange", markRead); |
| 244 | window.addEventListener("focus", markRead); |
| 245 | return () => { |
| 246 | clearTimeout(timer); |
| 247 | document.removeEventListener("visibilitychange", markRead); |
| 248 | window.removeEventListener("focus", markRead); |
| 249 | }; |
| 250 | }, [markRead]); |
| 251 | |
| 252 | // The thread open beside the conversation, from `?thread=`. |
| 253 | const loadThread = useCallback(async () => { |
| 254 | if (!threadId) return setThread(null); |
| 255 | try { |
| 256 | const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&thread=${encodeURIComponent(threadId)}`); |
| 257 | const result = (await response.json()) as Result<{ messages: ChatMessage[]; root?: ChatMessage | null }>; |
| 258 | // The page reaches back to the message the thread is under: shown above it, not among the replies. |
| 259 | setThread(result.ok ? mergeMessages([], result.value.messages.filter((m) => m.id !== threadId && !m.deleted_at)) : []); |
| 260 | const root = result.ok ? (result.value.root ?? result.value.messages.find((m) => m.id === threadId) ?? null) : null; |
| 261 | setThreadRoot(root); |
| 262 | } catch { |
| 263 | setThread([]); |
| 264 | } |
| 265 | }, [threadId, slug, data.channel.id]); |
| 266 | useEffect(() => { |
| 267 | setThread(null); |
| 268 | setThreadRoot(null); |
| 269 | void loadThread(); |
| 270 | }, [loadThread]); |
| 271 | |
| 272 | // Reply counts: counted here only until the service's own count for that |
| 273 | // message arrives (a `message.updated` of it), which can come before or |
| 274 | // after the reply itself; never twice for one reply. |
| 275 | const serverCounted = useRef(new Set<string>()); |
| 276 | const countedReplies = useRef(new Set<string>()); |
| 277 | const countReply = useCallback((root: string, reply: string, at: string) => { |
| 278 | if (serverCounted.current.has(root) || countedReplies.current.has(reply)) return; |
| 279 | countedReplies.current.add(reply); |
| 280 | setMessages((now) => now.map((m) => (m.id === root ? { ...m, reply_count: m.reply_count + 1, last_reply_at: at } : m))); |
| 281 | }, []); |
| 282 | const latest = useRef(messages); |
| 283 | latest.current = messages; |
| 284 | |
| 285 | // Everything that happens while the page is open. |
| 286 | const onEvent = useCallback( |
| 287 | (event: ChatLiveEvent) => { |
| 288 | if (event.type === "message.created" || event.type === "message.updated") { |
| 289 | const message = event.message; |
| 290 | if (message.channel_id !== data.channel.id) return; |
| 291 | if (message.thread_root) { |
| 292 | if (message.thread_root === threadId) setThread((now) => (now ? mergeMessages(now, [keepMine(now, message)]) : now)); |
| 293 | if (event.type === "message.created") countReply(message.thread_root, message.id, message.created_at); |
| 294 | } else { |
| 295 | if (event.type === "message.updated") serverCounted.current.add(message.id); |
| 296 | setMessages((now) => mergeMessages(now, [keepMine(now, message)])); |
| 297 | // The open thread's root, a session's card say, changes in place at its top too. |
| 298 | if (message.id === threadId) setThreadRoot((now) => (now ? keepMine([now], message) : now)); |
| 299 | } |
| 300 | // Whoever just spoke is no longer typing. |
| 301 | setTyping((now) => { |
| 302 | const key = `${message.author.kind}:${message.author.id}`; |
| 303 | if (!now.has(key)) return now; |
| 304 | const next = new Map(now); |
| 305 | next.delete(key); |
| 306 | return next; |
| 307 | }); |
| 308 | } else if (event.type === "message.deleted") { |
| 309 | if (event.channel_id !== data.channel.id) return; |
| 310 | const gone = (m: ShownMessage) => (m.id === event.id ? { ...m, deleted_at: new Date().toISOString() } : m); |
| 311 | setMessages((now) => now.map(gone)); |
| 312 | setThread((now) => now?.map(gone) ?? now); |
| 313 | setThreadRoot((now) => (now ? gone(now) : now)); |
| 314 | } else if (event.type === "reaction.added" || event.type === "reaction.removed") { |
| 315 | if (event.channel_id !== data.channel.id) return; |
| 316 | setMessages((now) => applyReactionEvent(now, event, me?.id ?? null)); |
| 317 | setThread((now) => (now ? applyReactionEvent(now, event, me?.id ?? null) : now)); |
| 318 | } else if (event.type === "typing") { |
| 319 | if (event.channel_id !== data.channel.id) return; |
| 320 | if (event.member.kind === "user" && event.member.id === me?.id) return; |
| 321 | setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() })); |
| 322 | } else if (event.type === "channel.updated") { |
| 323 | // Renamed, a new topic, archived or back: everyone looking sees it at once. |
| 324 | if (event.channel.id !== data.channel.id) return; |
| 325 | setChannel(event.channel); |
| 326 | refresh(); |
| 327 | } |
| 328 | }, |
| 329 | [data.channel.id, threadId, me?.id, countReply, refresh], |
| 330 | ); |
| 331 | // Back after a drop: what was missed. |
| 332 | const onReconnect = useCallback(async () => { |
| 333 | const base = `/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}`; |
| 334 | try { |
| 335 | // Everything after the newest message held, page by page, oldest first; |
| 336 | // what was deleted meanwhile comes back empty and is dropped. |
| 337 | let after = [...latest.current].reverse().find((m) => !m.pending)?.id ?? null; |
| 338 | for (let pages = 0; after && pages < 20; pages++) { |
| 339 | const response = await fetch(`${base}&after=${encodeURIComponent(after)}`); |
| 340 | const result = (await response.json()) as Result<{ messages: ChatMessage[]; newer?: string | null }>; |
| 341 | if (!result.ok) break; |
| 342 | const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id)); |
| 343 | const kept = result.value.messages.filter((m) => !m.deleted_at && !m.thread_root); |
| 344 | setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), kept)); |
| 345 | after = result.value.newer ?? null; |
| 346 | } |
| 347 | // Edits and deletions to what was already on screen: the latest page again. |
| 348 | const response = await fetch(base); |
| 349 | const result = (await response.json()) as Result<{ messages: ChatMessage[] }>; |
| 350 | if (result.ok) { |
| 351 | const gone = new Set(result.value.messages.filter((m) => m.deleted_at).map((m) => m.id)); |
| 352 | setMessages((now) => mergeMessages(now.filter((m) => !gone.has(m.id)), result.value.messages.filter((m) => !m.deleted_at))); |
| 353 | } |
| 354 | } catch { |
| 355 | // The next event, or the next return, catches up. |
| 356 | } |
| 357 | void loadThread(); |
| 358 | refresh(); |
| 359 | }, [slug, data.channel.id, loadThread, refresh]); |
| 360 | const live = useChatLive(slug, data.channel.id, onEvent, onReconnect); |
| 361 | |
| 362 | // Typing shows until its time runs out. |
| 363 | useEffect(() => { |
| 364 | if (typing.size === 0) return; |
| 365 | const timer = setInterval(() => { |
| 366 | const now = Date.now(); |
| 367 | setTyping((current) => { |
| 368 | const next = new Map([...current].filter(([, value]) => value.until > now)); |
| 369 | return next.size === current.size ? current : next; |
| 370 | }); |
| 371 | }, 1000); |
| 372 | return () => clearInterval(timer); |
| 373 | }, [typing.size]); |
| 374 | |
| 375 | // Says "is typing" to the others, at most every few seconds. |
| 376 | const lastTyping = useRef(0); |
| 377 | const onTyping = useCallback(() => { |
| 378 | const now = Date.now(); |
| 379 | if (now - lastTyping.current < 3000) return; |
| 380 | lastTyping.current = now; |
| 381 | live.send({ type: "typing", channel_id: data.channel.id }); |
| 382 | }, [live, data.channel.id]); |
| 383 | |
| 384 | const post = useCallback( |
| 385 | async (body: string, root: string | null) => { |
| 386 | if (!me) return; |
| 387 | const clientId = newClientId(); |
| 388 | const author: MemberProfile = { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null }; |
| 389 | const pending: ShownMessage = { |
| 390 | id: clientId, |
| 391 | client_id: clientId, |
| 392 | pending: true, |
| 393 | channel_id: data.channel.id, |
| 394 | author, |
| 395 | kind: "text", |
| 396 | body, |
| 397 | card: null, |
| 398 | thread_root: root, |
| 399 | reply_count: 0, |
| 400 | last_reply_at: null, |
| 401 | created_at: new Date().toISOString(), |
| 402 | edited_at: null, |
| 403 | deleted_at: null, |
| 404 | }; |
| 405 | const put = (list: ShownMessage[]) => mergeMessages(list, [pending]); |
| 406 | if (root) setThread((now) => put(now ?? [])); |
| 407 | else { |
| 408 | stick.current = true; |
| 409 | setMessages(put); |
| 410 | } |
| 411 | const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root }); |
| 412 | const settle = (list: ShownMessage[]) => |
| 413 | saved.ok |
| 414 | ? mergeMessages(list, [{ ...saved.value, client_id: clientId }]) |
| 415 | : list.map((m) => (m.client_id === clientId ? { ...m, failed: true, pending: true } : m)); |
| 416 | if (root) { |
| 417 | setThread((now) => (now ? settle(now) : now)); |
| 418 | if (saved.ok) countReply(root, saved.value.id, saved.value.created_at); |
| 419 | } else setMessages(settle); |
| 420 | }, |
| 421 | [me, send, data.channel.id, countReply], |
| 422 | ); |
| 423 | // ── Reactions (components/emoji): shown at once, then as the service counts them. |
| 424 | const customs = useCustomEmoji(slug); |
| 425 | const { usercontent } = useAddresses(); |
| 426 | const meProfile = useMemo<MemberProfile | null>( |
| 427 | () => (me ? { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null } : null), |
| 428 | [me], |
| 429 | ); |
| 430 | const react = useCallback( |
| 431 | async (messageId: string, emoji: string, on: boolean) => { |
| 432 | if (!meProfile) return; |
| 433 | const change = (to: boolean) => (list: ShownMessage[]) => |
| 434 | list.map((m) => (m.id === messageId ? { ...m, reactions: toggleReaction(m.reactions ?? [], emoji, to, meProfile) } : m)); |
| 435 | setMessages(change(on)); |
| 436 | setThread((now) => (now ? change(on)(now) : now)); |
| 437 | try { |
| 438 | const response = await fetch(`/${slug}/-/chat/api`, { |
| 439 | method: "POST", |
| 440 | headers: { "content-type": "application/json" }, |
| 441 | body: JSON.stringify({ intent: on ? "react" : "unreact", channel_id: data.channel.id, message_id: messageId, emoji }), |
| 442 | }); |
| 443 | const result = (await response.json()) as Result<ChatMessage["reactions"]>; |
| 444 | if (!result.ok) throw new Error(result.error.message); |
| 445 | const settle = (list: ShownMessage[]) => list.map((m) => (m.id === messageId ? { ...m, reactions: result.value } : m)); |
| 446 | setMessages(settle); |
| 447 | setThread((now) => (now ? settle(now) : now)); |
| 448 | } catch { |
| 449 | // Not saved: back as it was. |
| 450 | setMessages(change(!on)); |
| 451 | setThread((now) => (now ? change(!on)(now) : now)); |
| 452 | } |
| 453 | }, |
| 454 | [meProfile, slug, data.channel.id], |
| 455 | ); |
| 456 | const emojiContext = useMemo( |
| 457 | () => ({ customs, usercontent, me: meProfile, react: (id: string, emoji: string, on: boolean) => void react(id, emoji, on), manageHref: `/${slug}/-/emoji` }), |
| 458 | [customs, usercontent, meProfile, react, slug], |
| 459 | ); |
| 460 | |
| 461 | const retry = (message: ShownMessage) => { |
| 462 | const drop = (list: ShownMessage[]) => list.filter((m) => m.client_id !== message.client_id); |
| 463 | if (message.thread_root) setThread((now) => (now ? drop(now) : now)); |
| 464 | else setMessages(drop); |
| 465 | void post(message.body, message.thread_root); |
| 466 | }; |
| 467 | |
| 468 | const loadOlder = async () => { |
| 469 | if (!older || loadingOlder) return; |
| 470 | setLoadingOlder(true); |
| 471 | const element = scroller.current; |
| 472 | const fromBottom = element ? element.scrollHeight - element.scrollTop : 0; |
| 473 | try { |
| 474 | const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(data.channel.id)}&before=${encodeURIComponent(older)}`); |
| 475 | const result = (await response.json()) as Result<{ messages: ChatMessage[]; older: string | null }>; |
| 476 | if (result.ok) { |
| 477 | stick.current = false; |
| 478 | setMessages((now) => mergeMessages(now, result.value.messages)); |
| 479 | setOlder(result.value.older); |
| 480 | requestAnimationFrame(() => { |
| 481 | if (element) element.scrollTop = element.scrollHeight - fromBottom; |
| 482 | }); |
| 483 | } |
| 484 | } finally { |
| 485 | setLoadingOlder(false); |
| 486 | } |
| 487 | }; |
| 488 | |
| 489 | const openThread = (id: string | null) => { |
| 490 | const next = new URLSearchParams(params); |
| 491 | if (id) next.set("thread", id); |
| 492 | else next.delete("thread"); |
| 493 | setParams(next, { preventScrollReset: true, replace: !!threadId && !!id }); |
| 494 | }; |
| 495 | |
| 496 | // A thread's link, to copy and to cite; and "Write this up in Docs", which |
| 497 | // asks an agent, in the thread and as the person, for a page about it. |
| 498 | const [writeUp, setWriteUp] = useState<string | null>(null); |
| 499 | const linkToThread = (root: string) => threadLink(window.location.origin, channelPath(slug, channel), root); |
| 500 | const copyThreadLink = (root: string) => { |
| 501 | const done = navigator.clipboard?.writeText(linkToThread(root)); |
| 502 | if (!done) return showToast(false, "Couldn't copy the link here."); |
| 503 | done.then( |
| 504 | () => showToast(true, "Link to the thread copied."), |
| 505 | () => showToast(false, "Couldn't copy the link here."), |
| 506 | ); |
| 507 | }; |
| 508 | const writers = useMemo(() => writeUpAgents(members.map((m) => m.member)), [members]); |
| 509 | const askForWriteUp = async (body: string): Promise<string | null> => { |
| 510 | const root = writeUp; |
| 511 | if (!root) return null; |
| 512 | // The thread open already: posted as any reply is, shown at once. |
| 513 | if (root === threadId) { |
| 514 | void post(body, root); |
| 515 | return null; |
| 516 | } |
| 517 | const saved = await send<ChatMessage>({ intent: "post", channel_id: data.channel.id, body, thread_root: root }); |
| 518 | if (!saved.ok) return saved.error.message; |
| 519 | openThread(root); |
| 520 | return null; |
| 521 | }; |
| 522 | |
| 523 | const updateChannel = useCallback( |
| 524 | async (change: ChannelChange): Promise<Result<unknown>> => { |
| 525 | const done = await send<{ channel: Channel }>({ intent: "update_channel", channel_id: data.channel.id, ...change }); |
| 526 | if (done.ok) setChannel(done.value.channel); |
| 527 | return done; |
| 528 | }, |
| 529 | [send, data.channel.id], |
| 530 | ); |
| 531 | |
| 532 | const toggleStar = async () => { |
| 533 | await send({ intent: "preferences", channel_id: data.channel.id, starred: !starred }); |
| 534 | refresh(); |
| 535 | }; |
| 536 | |
| 537 | const join = async () => { |
| 538 | const done = await send({ intent: "join", channel_id: data.channel.id }); |
| 539 | if (done.ok) { |
| 540 | setJoined(true); |
| 541 | if (me) { |
| 542 | setMembers((now) => [ |
| 543 | ...now, |
| 544 | { |
| 545 | channel_id: data.channel.id, |
| 546 | member: { kind: "user", id: me.id, name: me.username, display_username: me.display_username, display_name: me.display_name, avatar: me.avatar, role: null }, |
| 547 | role: "member", |
| 548 | starred: false, |
| 549 | muted: false, |
| 550 | last_read_id: null, |
| 551 | joined_at: new Date().toISOString(), |
| 552 | }, |
| 553 | ]); |
| 554 | } |
| 555 | } |
| 556 | }; |
| 557 | |
| 558 | const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]); |
| 559 | const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at; |
| 560 | // Editing uses the composer, started from the message's Markdown. |
| 561 | const editor = (target: { id: string; body: string }) => ( |
| 562 | <Composer |
| 563 | initial={target.body} |
| 564 | placeholder="Edit message" |
| 565 | people={people} |
| 566 | autoFocus |
| 567 | compact |
| 568 | onCancel={() => setEditing(null)} |
| 569 | onSend={async (body) => { |
| 570 | setEditing(null); |
| 571 | if (body === target.body) return; |
| 572 | // Shown edited at once; put back if the service refuses. |
| 573 | const before = messages.find((m) => m.id === target.id); |
| 574 | setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m))); |
| 575 | const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body }); |
| 576 | if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value])); |
| 577 | else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m))); |
| 578 | }} |
| 579 | /> |
| 580 | ); |
| 581 | const typers = [...typing.values()].map((t) => t.member); |
| 582 | const name = channel.name ?? ""; |
| 583 | const archived = channel.kind === "channel" && !!channel.archived_at; |
| 584 | const placeholder = isDm |
| 585 | ? `Message ${others.map(shownName).join(", ") || "yourself"}` |
| 586 | : `Message #${name}. @ a teammate or an agent`; |
| 587 | const rootMessage = threadId ? (messages.find((m) => m.id === threadId) ?? (threadRoot?.id === threadId ? threadRoot : null)) : null; |
| 588 | const cardContext = useMemo( |
| 589 | () => ({ |
| 590 | slug, |
| 591 | agents: chatData?.agents ?? [], |
| 592 | onViewProfile: (username: string) => { |
| 593 | if (threadId) openThread(null); |
| 594 | setProfile(username); |
| 595 | }, |
| 596 | }), |
| 597 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 598 | [slug, chatData?.agents, threadId], |
| 599 | ); |
| 600 | |
| 601 | return ( |
| 602 | // On a phone the conversation is the whole screen, as tall as what the |
| 603 | // keyboard leaves (components/mobile.tsx), so the composer sits on it. |
| 604 | <EmojiProvider value={emojiContext}> |
| 605 | <CardContext.Provider value={cardContext}> |
| 606 | <div |
| 607 | {...swipe} |
| 608 | className="flex h-[calc(100dvh-3.5rem)] min-h-0 lg:h-dvh max-md:fixed max-md:inset-x-0 max-md:top-(--vv-top,0px) max-md:z-30 max-md:h-(--vv-height,100dvh) max-md:bg-bg" |
| 609 | > |
| 610 | {/* What pressing a card's action did (components/chat/card.tsx). */} |
| 611 | <CardToasts /> |
| 612 | <section aria-label={data.title} className="flex min-w-0 grow flex-col"> |
| 613 | <ChannelHeader |
| 614 | onBack={back} |
| 615 | data={view} |
| 616 | others={others} |
| 617 | starred={starred} |
| 618 | muted={muted} |
| 619 | memberCount={members.length} |
| 620 | live={live.state} |
| 621 | info={info} |
| 622 | onStar={() => void toggleStar()} |
| 623 | onInfo={toggleInfo} |
| 624 | /> |
| 625 | <div |
| 626 | ref={scroller} |
| 627 | onScroll={(event) => { |
| 628 | const element = event.currentTarget; |
| 629 | stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 120; |
| 630 | }} |
| 631 | className="min-h-0 grow overflow-y-auto overscroll-contain [scrollbar-gutter:stable] [scrollbar-width:thin]" |
| 632 | > |
| 633 | <div className="mx-auto flex min-h-full max-w-[56rem] flex-col justify-end px-2 pt-6 pb-3 sm:px-4"> |
| 634 | {older ? ( |
| 635 | <div className="flex justify-center pb-4"> |
| 636 | <button |
| 637 | type="button" |
| 638 | onClick={() => void loadOlder()} |
| 639 | className="rounded-full border border-line px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" |
| 640 | > |
| 641 | {loadingOlder ? "Loading…" : "Load earlier messages"} |
| 642 | </button> |
| 643 | </div> |
| 644 | ) : ( |
| 645 | <ConversationStart data={view} others={others} /> |
| 646 | )} |
| 647 | {rows.map((row) => |
| 648 | row.kind === "day" ? ( |
| 649 | <DayRule key={row.key} label={row.label} /> |
| 650 | ) : ( |
| 651 | <MessageRow |
| 652 | key={row.key} |
| 653 | message={row.message} |
| 654 | head={row.head} |
| 655 | zone={zone} |
| 656 | context={context} |
| 657 | code={code} |
| 658 | slug={slug} |
| 659 | onThread={() => openThread(row.message.id)} |
| 660 | onRetry={() => retry(row.message)} |
| 661 | onLongPress={() => setActions(row.message)} |
| 662 | onCopyLink={() => copyThreadLink(row.message.id)} |
| 663 | onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined} |
| 664 | editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined} |
| 665 | onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined} |
| 666 | threadOpen={row.message.id === threadId} |
| 667 | /> |
| 668 | ), |
| 669 | )} |
| 670 | </div> |
| 671 | </div> |
| 672 | <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2"> |
| 673 | <div className="mx-auto max-w-[56rem]"> |
| 674 | <TypingLine members={typers} /> |
| 675 | {archived ? ( |
| 676 | <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3"> |
| 677 | <p className="flex items-center gap-2 text-sm text-muted"> |
| 678 | <Archive size={15} className="shrink-0 text-faint" /> |
| 679 | <span> |
| 680 | <span className="font-medium text-fg">#{name}</span> is archived. Its history stays here to read; nobody can post in it. |
| 681 | </span> |
| 682 | </p> |
| 683 | {canManage && ( |
| 684 | <button |
| 685 | type="button" |
| 686 | onClick={() => void updateChannel({ archived: false })} |
| 687 | className="inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised" |
| 688 | > |
| 689 | <ArchiveRestore size={15} /> |
| 690 | Unarchive |
| 691 | </button> |
| 692 | )} |
| 693 | </div> |
| 694 | ) : joined ? ( |
| 695 | <Composer draftKey={data.channel.id} placeholder={placeholder} people={people} onSend={(body) => void post(body, null)} onTyping={onTyping} autoFocus /> |
| 696 | ) : ( |
| 697 | <div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-line bg-surface px-4 py-3"> |
| 698 | <p className="text-sm text-muted"> |
| 699 | You're reading <span className="font-medium text-fg">#{name}</span>. Join to post and get its notifications. |
| 700 | </p> |
| 701 | <button |
| 702 | type="button" |
| 703 | onClick={() => void join()} |
| 704 | className="inline-flex h-9 items-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg hover:bg-accent-hover" |
| 705 | > |
| 706 | Join channel |
| 707 | </button> |
| 708 | </div> |
| 709 | )} |
| 710 | </div> |
| 711 | </div> |
| 712 | </section> |
| 713 | {threadId ? ( |
| 714 | <SidePanel |
| 715 | title="Thread" |
| 716 | subtitle={isDm ? undefined : `#${name}`} |
| 717 | onClose={() => openThread(null)} |
| 718 | menu={<ThreadMenu onCopyLink={() => copyThreadLink(threadId)} onWriteUp={joined && !archived ? () => setWriteUp(threadId) : undefined} />} |
| 719 | > |
| 720 | <ThreadPanel |
| 721 | root={rootMessage} |
| 722 | replies={thread} |
| 723 | zone={zone} |
| 724 | context={context} |
| 725 | code={code} |
| 726 | slug={slug} |
| 727 | people={people} |
| 728 | joined={joined && !archived} |
| 729 | onSend={(body) => void post(body, threadId)} |
| 730 | onRetry={retry} |
| 731 | onTyping={onTyping} |
| 732 | draftKey={`${data.channel.id}:${threadId}`} |
| 733 | /> |
| 734 | </SidePanel> |
| 735 | ) : profile ? ( |
| 736 | <SidePanel title="Profile" onClose={() => setProfile(null)}> |
| 737 | <ProfilePanel slug={slug} username={profile} messages={messages} zone={zone} context={context} /> |
| 738 | </SidePanel> |
| 739 | ) : ( |
| 740 | info && ( |
| 741 | <SidePanel title={isDm ? "Details" : "About this channel"} onClose={toggleInfo}> |
| 742 | <InfoPanel |
| 743 | data={view} |
| 744 | members={members} |
| 745 | slug={slug} |
| 746 | people={people} |
| 747 | onAdded={(m) => setMembers((now) => [...now, m])} |
| 748 | joined={joined} |
| 749 | canManage={canManage} |
| 750 | onUpdate={updateChannel} |
| 751 | /> |
| 752 | </SidePanel> |
| 753 | ) |
| 754 | )} |
| 755 | <MessageActions |
| 756 | message={actions} |
| 757 | mine={actions != null && actions.author.kind === "user" && actions.author.id === me?.id} |
| 758 | onClose={() => setActions(null)} |
| 759 | onThread={(id) => openThread(id)} |
| 760 | onCopyLink={(message) => copyThreadLink(message.thread_root ?? message.id)} |
| 761 | onWriteUp={joined && !archived ? (message) => setWriteUp(message.thread_root ?? message.id) : undefined} |
| 762 | onEdit={(message) => setEditing({ id: message.id, body: message.body })} |
| 763 | onDelete={async (message) => { |
| 764 | // Gone at once; back if the service refuses. |
| 765 | setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: new Date().toISOString() } : m))); |
| 766 | const done = await send({ intent: "remove", channel_id: data.channel.id, id: message.id }); |
| 767 | if (!done.ok) setMessages((now) => now.map((m) => (m.id === message.id ? { ...m, deleted_at: null } : m))); |
| 768 | }} |
| 769 | /> |
| 770 | <WriteUpDialog |
| 771 | open={writeUp != null} |
| 772 | onOpenChange={(open) => !open && setWriteUp(null)} |
| 773 | slug={slug} |
| 774 | agents={writers} |
| 775 | link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""} |
| 776 | onSend={askForWriteUp} |
| 777 | /> |
| 778 | <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message"> |
| 779 | {editing && !editing.inline && ( |
| 780 | <div className="space-y-3 px-1 pt-1"> |
| 781 | <h2 className="px-2 text-base font-semibold">Edit message</h2> |
| 782 | {editor(editing)} |
| 783 | </div> |
| 784 | )} |
| 785 | </BottomSheet> |
| 786 | </div> |
| 787 | </CardContext.Provider> |
| 788 | </EmojiProvider> |
| 789 | ); |
| 790 | } |
| 791 | |
| 792 | /** The top of a conversation: what it is, and its controls. */ |
| 793 | function ChannelHeader({ |
| 794 | onBack, |
| 795 | data, |
| 796 | others, |
| 797 | starred, |
| 798 | muted, |
| 799 | memberCount, |
| 800 | live, |
| 801 | info, |
| 802 | onStar, |
| 803 | onInfo, |
| 804 | }: { |
| 805 | /** A phone: back to the list of conversations. */ |
| 806 | onBack: () => void; |
| 807 | data: Loaded; |
| 808 | others: MemberProfile[]; |
| 809 | starred: boolean; |
| 810 | muted: boolean; |
| 811 | memberCount: number; |
| 812 | live: LiveState; |
| 813 | info: boolean; |
| 814 | onStar: () => void; |
| 815 | onInfo: () => void; |
| 816 | }) { |
| 817 | const channel = data.channel; |
| 818 | const agentDm = channel.kind === "dm" && others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; |
| 819 | return ( |
| 820 | <header className="flex h-14 shrink-0 items-center gap-3 border-b border-line px-4 sm:px-5 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:gap-2 max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5"> |
| 821 | <button |
| 822 | type="button" |
| 823 | onClick={onBack} |
| 824 | aria-label="Back to conversations" |
| 825 | className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted active:bg-raised md:hidden" |
| 826 | > |
| 827 | <ChevronLeft size={22} /> |
| 828 | </button> |
| 829 | <div className="flex min-w-0 grow items-center gap-2.5"> |
| 830 | {channel.kind === "channel" ? ( |
| 831 | <> |
| 832 | <h1 className="flex min-w-0 shrink-0 items-center gap-1 text-[0.9375rem] font-semibold"> |
| 833 | {channel.private ? ( |
| 834 | <Hint label="Private channel: only its members can find and read it"> |
| 835 | <span className="flex"> |
| 836 | <Lock size={15} className="text-faint" aria-label="Private channel" /> |
| 837 | </span> |
| 838 | </Hint> |
| 839 | ) : ( |
| 840 | <Hash size={16} className="text-faint" aria-label="Public channel" /> |
| 841 | )} |
| 842 | <span className="truncate">{channel.name}</span> |
| 843 | </h1> |
| 844 | <StarButton starred={starred} onClick={onStar} /> |
| 845 | {muted && ( |
| 846 | <Hint label="Muted: no unread count or notifications"> |
| 847 | <span className="text-faint"> |
| 848 | <BellOff size={14} aria-label="Muted" /> |
| 849 | </span> |
| 850 | </Hint> |
| 851 | )} |
| 852 | {channel.archived_at && ( |
| 853 | <Badge tone="neutral" className="shrink-0"> |
| 854 | <Archive size={11} /> |
| 855 | Archived |
| 856 | </Badge> |
| 857 | )} |
| 858 | {channel.topic && ( |
| 859 | <> |
| 860 | <span aria-hidden="true" className="hidden h-4 w-px shrink-0 bg-line sm:block" /> |
| 861 | <p className="hidden min-w-0 truncate text-sm text-muted sm:block">{channel.topic}</p> |
| 862 | </> |
| 863 | )} |
| 864 | </> |
| 865 | ) : ( |
| 866 | <> |
| 867 | <span className="flex shrink-0 -space-x-1.5"> |
| 868 | {others.slice(0, 3).map((member) => ( |
| 869 | <span key={`${member.kind}:${member.id}`} className="rounded-full ring-2 ring-bg"> |
| 870 | <MemberAvatar member={member} size={26} presence={others.length === 1} /> |
| 871 | </span> |
| 872 | ))} |
| 873 | </span> |
| 874 | <div className="min-w-0"> |
| 875 | <h1 className="flex min-w-0 items-center gap-1.5 text-[0.9375rem] leading-tight font-semibold"> |
| 876 | <span className="truncate">{others.map(shownName).join(", ") || "Just you"}</span> |
| 877 | {agentDm && <AgentPill />} |
| 878 | </h1> |
| 879 | {agentDm && (titleOf(agentDm) ?? agentDm.role) && ( |
| 880 | <p className="truncate text-xs leading-tight text-muted">{titleOf(agentDm) ?? agentDm.role}</p> |
| 881 | )} |
| 882 | {/* One person: their status under their name, as their card has it. */} |
| 883 | {!agentDm && others.length === 1 && others[0]!.kind === "user" && <PersonStatusLine person={{ id: others[0]!.id, username: others[0]!.name }} className="truncate text-xs leading-tight text-muted" />} |
| 884 | </div> |
| 885 | <StarButton starred={starred} onClick={onStar} /> |
| 886 | </> |
| 887 | )} |
| 888 | </div> |
| 889 | {live !== "open" && ( |
| 890 | <span role="status" className="hidden items-center gap-1.5 text-xs text-faint sm:flex"> |
| 891 | <span className="size-1.5 animate-pulse rounded-full bg-warn" /> |
| 892 | {live === "reconnecting" ? "Reconnecting…" : "Connecting…"} |
| 893 | </span> |
| 894 | )} |
| 895 | {channel.kind === "channel" && ( |
| 896 | <Hint label={info ? "Hide details" : "Members and details"}> |
| 897 | <button |
| 898 | type="button" |
| 899 | onClick={onInfo} |
| 900 | aria-pressed={info} |
| 901 | aria-label="Members and details" |
| 902 | className={`flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs tabular-nums transition-colors ${ |
| 903 | info ? "border-line-strong bg-raised text-fg" : "border-line text-muted hover:border-line-strong hover:text-fg" |
| 904 | }`} |
| 905 | > |
| 906 | <UsersStack count={memberCount} /> |
| 907 | </button> |
| 908 | </Hint> |
| 909 | )} |
| 910 | {channel.kind === "dm" && ( |
| 911 | <Hint label={info ? "Hide details" : "Details"}> |
| 912 | <button |
| 913 | type="button" |
| 914 | onClick={onInfo} |
| 915 | aria-pressed={info} |
| 916 | aria-label="Details" |
| 917 | className={`flex size-8 items-center justify-center rounded-md transition-colors ${info ? "bg-raised text-fg" : "text-muted hover:bg-raised hover:text-fg"}`} |
| 918 | > |
| 919 | <PanelRight size={16} /> |
| 920 | </button> |
| 921 | </Hint> |
| 922 | )} |
| 923 | </header> |
| 924 | ); |
| 925 | } |
| 926 | |
| 927 | function UsersStack({ count }: { count: number }) { |
| 928 | return ( |
| 929 | <> |
| 930 | <Users size={14} /> |
| 931 | {count} |
| 932 | </> |
| 933 | ); |
| 934 | } |
| 935 | |
| 936 | function StarButton({ starred, onClick }: { starred: boolean; onClick: () => void }) { |
| 937 | return ( |
| 938 | <Hint label={starred ? "Unstar" : "Star: keep it at the top of the sidebar"}> |
| 939 | <button |
| 940 | type="button" |
| 941 | aria-label={starred ? "Unstar" : "Star"} |
| 942 | aria-pressed={starred} |
| 943 | onClick={onClick} |
| 944 | className={`flex size-7 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised ${starred ? "text-warn" : "text-faint hover:text-fg"}`} |
| 945 | > |
| 946 | <Star size={15} fill={starred ? "currentColor" : "none"} /> |
| 947 | </button> |
| 948 | </Hint> |
| 949 | ); |
| 950 | } |
| 951 | |
| 952 | /** How a conversation begins, above its first message. */ |
| 953 | function ConversationStart({ data, others }: { data: Loaded; others: MemberProfile[] }) { |
| 954 | const channel = data.channel; |
| 955 | if (channel.kind === "dm") { |
| 956 | const agent = others.length === 1 && others[0]!.kind === "agent" ? others[0]! : null; |
| 957 | return ( |
| 958 | <div className="px-2 pb-6 sm:px-3"> |
| 959 | <div className="flex -space-x-2"> |
| 960 | {others.slice(0, 4).map((m) => ( |
| 961 | <span key={m.id} className="rounded-full ring-4 ring-bg"> |
| 962 | <MemberAvatar member={m} size={56} /> |
| 963 | </span> |
| 964 | ))} |
| 965 | </div> |
| 966 | <h2 className="mt-3 flex items-center gap-2 text-xl font-semibold tracking-tight"> |
| 967 | {others.map(shownName).join(", ") || "Just you"} |
| 968 | {agent && <AgentPill />} |
| 969 | </h2> |
| 970 | <p className="mt-1 max-w-xl text-sm text-muted"> |
| 971 | {agent |
| 972 | ? `${agent.role ? `${agent.role}. ` : ""}Ask a question or give it a job: it answers here, and opens a task when the work needs one.` |
| 973 | : "This is the start of your conversation."} |
| 974 | </p> |
| 975 | </div> |
| 976 | ); |
| 977 | } |
| 978 | return ( |
| 979 | <div className="px-2 pb-6 sm:px-3"> |
| 980 | <span className="flex size-12 items-center justify-center rounded-xl bg-raised text-muted"> |
| 981 | {channel.private ? <Lock size={22} /> : <Hash size={24} />} |
| 982 | </span> |
| 983 | <h2 className="mt-3 text-xl font-semibold tracking-tight">Welcome to #{channel.name}</h2> |
| 984 | <p className="mt-1 max-w-xl text-sm text-muted">{channel.topic ?? "This is the very beginning of the channel."}</p> |
| 985 | </div> |
| 986 | ); |
| 987 | } |
| 988 | |
| 989 | function DayRule({ label }: { label: string }) { |
| 990 | return ( |
| 991 | <div role="separator" aria-label={label} className="relative my-3 flex items-center justify-center"> |
| 992 | <span aria-hidden="true" className="absolute inset-x-2 top-1/2 h-px bg-line" /> |
| 993 | <span className="relative rounded-full border border-line bg-bg px-3 py-0.5 text-[0.6875rem] font-medium text-muted" suppressHydrationWarning> |
| 994 | {label} |
| 995 | </span> |
| 996 | </div> |
| 997 | ); |
| 998 | } |
| 999 | |
| 1000 | /** One message: with its author's name and avatar when it starts a group. */ |
| 1001 | /** |
| 1002 | * Someone's profile beside the conversation, opened from their card: who |
| 1003 | * they are, their teams and local time, and what they said here lately. |
| 1004 | * The conversation stays where it was. |
| 1005 | */ |
| 1006 | function ProfilePanel({ |
| 1007 | slug, |
| 1008 | username, |
| 1009 | messages, |
| 1010 | zone, |
| 1011 | context, |
| 1012 | }: { |
| 1013 | slug: string; |
| 1014 | username: string; |
| 1015 | messages: ShownMessage[]; |
| 1016 | zone: string | undefined; |
| 1017 | context: TextContext; |
| 1018 | }) { |
| 1019 | const [card, setCard] = useState<PersonCard | null | undefined>(undefined); |
| 1020 | useEffect(() => { |
| 1021 | let live = true; |
| 1022 | setCard(undefined); |
| 1023 | void personCard(slug, username).then((value) => { |
| 1024 | if (live) setCard(value); |
| 1025 | }); |
| 1026 | return () => { |
| 1027 | live = false; |
| 1028 | }; |
| 1029 | }, [slug, username]); |
| 1030 | const theirs = messages |
| 1031 | .filter((m) => m.author.kind === "user" && m.author.name === username && !m.deleted_at && m.body) |
| 1032 | .slice(-5) |
| 1033 | .reverse(); |
| 1034 | const time = card ? localTime(card.timezone, Date.now()) : null; |
| 1035 | return ( |
| 1036 | <div className="min-h-0 grow overflow-y-auto p-5 [scrollbar-width:thin]"> |
| 1037 | <WithPresence person={{ username }} size={88}> |
| 1038 | <Avatar name={username} image={card?.avatar ?? null} size={88} /> |
| 1039 | </WithPresence> |
| 1040 | <h3 className="mt-3 text-xl font-semibold tracking-tight">{card?.name?.trim() || (card ? shownUsername(card) : username)}</h3> |
| 1041 | <p className="font-mono text-sm text-muted"> |
| 1042 | @{card ? shownUsername(card) : username} |
| 1043 | {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null} |
| 1044 | </p> |
| 1045 | <PresenceSummary person={{ username }} className="mt-3 text-sm text-muted" /> |
| 1046 | {card?.bio && <p className="mt-3 text-sm leading-relaxed text-fg-soft">{card.bio}</p>} |
| 1047 | <dl className="mt-4 space-y-2 text-sm"> |
| 1048 | {card && card.teams.length > 0 && ( |
| 1049 | <div className="flex gap-3"> |
| 1050 | <dt className="w-20 shrink-0 text-faint">Teams</dt> |
| 1051 | <dd className="text-fg-soft">{card.teams.map((team) => team.name).join(", ")}</dd> |
| 1052 | </div> |
| 1053 | )} |
| 1054 | {time && ( |
| 1055 | <div className="flex gap-3"> |
| 1056 | <dt className="w-20 shrink-0 text-faint">Local time</dt> |
| 1057 | <dd className="text-fg-soft" suppressHydrationWarning> |
| 1058 | {time} |
| 1059 | </dd> |
| 1060 | </div> |
| 1061 | )} |
| 1062 | {card?.location && ( |
| 1063 | <div className="flex gap-3"> |
| 1064 | <dt className="w-20 shrink-0 text-faint">Location</dt> |
| 1065 | <dd className="text-fg-soft">{card.location}</dd> |
| 1066 | </div> |
| 1067 | )} |
| 1068 | {card && card.workspaces.length > 0 && ( |
| 1069 | <div className="flex gap-3"> |
| 1070 | <dt className="w-20 shrink-0 text-faint">Workspaces</dt> |
| 1071 | <dd className="text-fg-soft">{card.workspaces.map((w) => w.name).join(", ")}</dd> |
| 1072 | </div> |
| 1073 | )} |
| 1074 | </dl> |
| 1075 | <h4 className="mt-6 mb-2 text-xs font-medium text-faint">Lately in this conversation</h4> |
| 1076 | {theirs.length === 0 ? ( |
| 1077 | <p className="text-sm text-muted">Nothing here yet.</p> |
| 1078 | ) : ( |
| 1079 | <ul className="space-y-3"> |
| 1080 | {theirs.map((m) => ( |
| 1081 | <li key={m.id} className="rounded-lg border border-line bg-surface px-3 py-2.5"> |
| 1082 | <p className="mb-1 text-[0.6875rem] text-faint" suppressHydrationWarning> |
| 1083 | {clock(m.created_at, zone)} |
| 1084 | </p> |
| 1085 | <div className="line-clamp-4 text-sm"> |
| 1086 | <MessageText body={m.body} context={context} /> |
| 1087 | </div> |
| 1088 | </li> |
| 1089 | ))} |
| 1090 | </ul> |
| 1091 | )} |
| 1092 | <Link to={`/u/${username}`} className="mt-6 inline-block text-sm text-accent hover:underline"> |
| 1093 | Open their full profile |
| 1094 | </Link> |
| 1095 | </div> |
| 1096 | ); |
| 1097 | } |
| 1098 | |
| 1099 | /** An agent's title ("QA Engineer"), when the chat service sends it with the member. */ |
| 1100 | function titleOf(member: MemberProfile): string | null { |
| 1101 | return (member as MemberProfile & { title?: string | null }).title ?? null; |
| 1102 | } |
| 1103 | |
| 1104 | /** A long press (half a second, without moving) on a touch screen. */ |
| 1105 | function useLongPress(onLongPress?: () => void) { |
| 1106 | const timer = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 1107 | const cancel = () => { |
| 1108 | if (timer.current) clearTimeout(timer.current); |
| 1109 | timer.current = null; |
| 1110 | }; |
| 1111 | if (!onLongPress) return {}; |
| 1112 | return { |
| 1113 | onTouchStart: () => { |
| 1114 | cancel(); |
| 1115 | timer.current = setTimeout(() => { |
| 1116 | timer.current = null; |
| 1117 | if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(8); |
| 1118 | onLongPress(); |
| 1119 | }, 480); |
| 1120 | }, |
| 1121 | onTouchMove: cancel, |
| 1122 | onTouchEnd: cancel, |
| 1123 | onTouchCancel: cancel, |
| 1124 | onContextMenu: (event: React.MouseEvent) => { |
| 1125 | // A phone's own long-press menu (copy, look up) would cover ours. |
| 1126 | if (window.matchMedia("(pointer: coarse)").matches) event.preventDefault(); |
| 1127 | }, |
| 1128 | }; |
| 1129 | } |
| 1130 | |
| 1131 | /** A long-pressed message's actions, in a sheet from the bottom: reply, copy, and for your own, edit and delete. */ |
| 1132 | function MessageActions({ |
| 1133 | message, |
| 1134 | mine, |
| 1135 | onClose, |
| 1136 | onThread, |
| 1137 | onCopyLink, |
| 1138 | onWriteUp, |
| 1139 | onEdit, |
| 1140 | onDelete, |
| 1141 | }: { |
| 1142 | message: ShownMessage | null; |
| 1143 | mine: boolean; |
| 1144 | onClose: () => void; |
| 1145 | onThread: (id: string) => void; |
| 1146 | onCopyLink: (message: ShownMessage) => void; |
| 1147 | /** Absent where the person cannot post (not joined, archived). */ |
| 1148 | onWriteUp?: (message: ShownMessage) => void; |
| 1149 | onEdit: (message: ShownMessage) => void; |
| 1150 | onDelete: (message: ShownMessage) => void; |
| 1151 | }) { |
| 1152 | const [confirm, setConfirm] = useState(false); |
| 1153 | useEffect(() => setConfirm(false), [message]); |
| 1154 | const act = (run: () => void) => () => { |
| 1155 | run(); |
| 1156 | onClose(); |
| 1157 | }; |
| 1158 | return ( |
| 1159 | <BottomSheet open={message != null} onOpenChange={(open) => !open && onClose()} title="Message actions"> |
| 1160 | {message && ( |
| 1161 | <> |
| 1162 | <p className="mt-1 mb-2 line-clamp-2 px-3 text-sm text-muted"> |
| 1163 | <span className="font-medium text-fg-soft">{shownName(message.author)}:</span> {message.body || message.card?.title} |
| 1164 | </p> |
| 1165 | {!message.pending && <QuickReactions messageId={message.id} onDone={onClose} />} |
| 1166 | {!message.thread_root && ( |
| 1167 | <SheetRow icon={<MessageSquareText />} onClick={act(() => onThread(message.id))}> |
| 1168 | Reply in thread |
| 1169 | </SheetRow> |
| 1170 | )} |
| 1171 | <SheetRow icon={<Copy />} onClick={act(() => void navigator.clipboard?.writeText(message.body || message.card?.title || ""))}> |
| 1172 | Copy text |
| 1173 | </SheetRow> |
| 1174 | {!message.pending && ( |
| 1175 | <SheetRow icon={<Link2 />} onClick={act(() => onCopyLink(message))}> |
| 1176 | Copy link to thread |
| 1177 | </SheetRow> |
| 1178 | )} |
| 1179 | {!message.pending && onWriteUp && ( |
| 1180 | <SheetRow icon={<FileText />} onClick={act(() => onWriteUp(message))}> |
| 1181 | Write this up in Docs |
| 1182 | </SheetRow> |
| 1183 | )} |
| 1184 | {mine && message.kind === "text" && ( |
| 1185 | <SheetRow icon={<Pencil />} onClick={act(() => onEdit(message))}> |
| 1186 | Edit |
| 1187 | </SheetRow> |
| 1188 | )} |
| 1189 | {mine && |
| 1190 | (confirm ? ( |
| 1191 | <SheetRow icon={<Trash2 className="text-danger" />} onClick={act(() => onDelete(message))}> |
| 1192 | <span className="text-danger">Delete for everyone</span> |
| 1193 | </SheetRow> |
| 1194 | ) : ( |
| 1195 | <SheetRow icon={<Trash2 />} onClick={() => setConfirm(true)}> |
| 1196 | Delete |
| 1197 | </SheetRow> |
| 1198 | ))} |
| 1199 | </> |
| 1200 | )} |
| 1201 | </BottomSheet> |
| 1202 | ); |
| 1203 | } |
| 1204 | |
| 1205 | function MessageRow({ |
| 1206 | message, |
| 1207 | head, |
| 1208 | zone, |
| 1209 | context, |
| 1210 | code, |
| 1211 | slug, |
| 1212 | onThread, |
| 1213 | onRetry, |
| 1214 | onLongPress, |
| 1215 | onCopyLink, |
| 1216 | onWriteUp, |
| 1217 | threadOpen, |
| 1218 | inThread, |
| 1219 | onEdit, |
| 1220 | editor, |
| 1221 | }: { |
| 1222 | /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */ |
| 1223 | onEdit?: () => void; |
| 1224 | editor?: ReactNode; |
| 1225 | /** A phone: a long press opens the message's actions. */ |
| 1226 | onLongPress?: () => void; |
| 1227 | /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */ |
| 1228 | onCopyLink?: () => void; |
| 1229 | onWriteUp?: () => void; |
| 1230 | message: ShownMessage; |
| 1231 | head: boolean; |
| 1232 | zone: string | undefined; |
| 1233 | context: TextContext; |
| 1234 | code: boolean; |
| 1235 | slug: string; |
| 1236 | onThread?: () => void; |
| 1237 | onRetry: () => void; |
| 1238 | threadOpen?: boolean; |
| 1239 | inThread?: boolean; |
| 1240 | }) { |
| 1241 | const author = message.author; |
| 1242 | const time = clock(message.created_at, zone); |
| 1243 | const press = useLongPress(onLongPress); |
| 1244 | return ( |
| 1245 | <article |
| 1246 | {...press} |
| 1247 | aria-label={`${shownName(author)}, ${time}`} |
| 1248 | className={`group/message relative flex gap-3 rounded-lg px-2 transition-colors hover:bg-surface/70 sm:px-3 max-md:min-h-11 max-md:[-webkit-touch-callout:none] max-md:active:bg-surface/70 ${head ? "mt-3 pt-1 pb-1" : "py-0.5"} ${ |
| 1249 | threadOpen ? "bg-accent/[0.06] hover:bg-accent/[0.08]" : "" |
| 1250 | }`} |
| 1251 | > |
| 1252 | <div className="w-9 shrink-0"> |
| 1253 | {head ? ( |
| 1254 | <MemberCard member={author} className="mt-0.5 block rounded-full" label={`${shownName(author)}'s card`}> |
| 1255 | <MemberAvatar member={author} size={36} /> |
| 1256 | </MemberCard> |
| 1257 | ) : ( |
| 1258 | <time |
| 1259 | dateTime={message.created_at} |
| 1260 | suppressHydrationWarning |
| 1261 | className="mt-1 block text-right text-[0.625rem] leading-5 text-faint tabular-nums opacity-0 group-hover/message:opacity-100" |
| 1262 | > |
| 1263 | {time.replace(/\s?[AP]M$/, "")} |
| 1264 | </time> |
| 1265 | )} |
| 1266 | </div> |
| 1267 | <div className="min-w-0 grow"> |
| 1268 | {head && ( |
| 1269 | <div className="flex items-baseline gap-2"> |
| 1270 | <MemberCard member={author} className="truncate text-[0.9375rem] font-semibold text-fg hover:underline"> |
| 1271 | {shownName(author)} |
| 1272 | </MemberCard> |
| 1273 | {author.kind === "user" && <PersonStatusEmoji person={{ id: author.id, username: author.name }} size={14} className="self-center" />} |
| 1274 | {author.kind === "agent" && <AgentPill className="self-center" />} |
| 1275 | <time dateTime={message.created_at} suppressHydrationWarning className="shrink-0 text-xs text-faint tabular-nums"> |
| 1276 | {time} |
| 1277 | </time> |
| 1278 | </div> |
| 1279 | )} |
| 1280 | <div className={message.pending && !message.failed ? "opacity-60" : undefined}> |
| 1281 | {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />} |
| 1282 | {message.card && ( |
| 1283 | <CardBox |
| 1284 | card={message.card} |
| 1285 | slug={slug} |
| 1286 | code={code} |
| 1287 | channelId={message.channel_id} |
| 1288 | messageId={message.id} |
| 1289 | inert={!!message.pending || !!message.deleted_at} |
| 1290 | /> |
| 1291 | )} |
| 1292 | {message.edited_at && <span className="text-[0.6875rem] text-faint"> (edited)</span>} |
| 1293 | </div> |
| 1294 | {!message.pending && !message.deleted_at && <ReactionBar messageId={message.id} reactions={message.reactions} />} |
| 1295 | {message.failed && ( |
| 1296 | <p className="mt-0.5 text-xs text-danger"> |
| 1297 | Not sent.{" "} |
| 1298 | <button type="button" onClick={onRetry} className="font-medium underline underline-offset-2 hover:text-fg"> |
| 1299 | Try again |
| 1300 | </button> |
| 1301 | </p> |
| 1302 | )} |
| 1303 | {!inThread && message.reply_count > 0 && onThread && ( |
| 1304 | <button |
| 1305 | type="button" |
| 1306 | onClick={onThread} |
| 1307 | className="mt-1 -ml-1 flex items-center gap-2 rounded-md px-1 py-0.5 text-[0.8125rem] font-medium text-accent transition-colors hover:bg-raised" |
| 1308 | > |
| 1309 | <MessageSquareText size={14} /> |
| 1310 | {message.reply_count} {message.reply_count === 1 ? "reply" : "replies"} |
| 1311 | {message.last_reply_at && ( |
| 1312 | <span className="font-normal text-faint" suppressHydrationWarning> |
| 1313 | Last reply {clock(message.last_reply_at, zone)} |
| 1314 | </span> |
| 1315 | )} |
| 1316 | </button> |
| 1317 | )} |
| 1318 | </div> |
| 1319 | {!inThread && onThread && !message.pending && ( |
| 1320 | <div className="absolute -top-3 right-3 hidden rounded-lg border border-line bg-surface p-0.5 shadow-lg shadow-black/30 group-hover/message:flex group-focus-within/message:flex has-[[data-state=open]]:flex"> |
| 1321 | <AddReaction messageId={message.id} /> |
| 1322 | <Hint label="Reply in thread"> |
| 1323 | <button |
| 1324 | type="button" |
| 1325 | aria-label="Reply in thread" |
| 1326 | onClick={onThread} |
| 1327 | className="flex size-7 items-center justify-center rounded-md text-muted hover:bg-raised hover:text-fg" |
| 1328 | > |
| 1329 | <MessageSquareText size={15} /> |
| 1330 | </button> |
| 1331 | </Hint> |
| 1332 | {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />} |
| 1333 | </div> |
| 1334 | )} |
| 1335 | </article> |
| 1336 | ); |
| 1337 | } |
| 1338 | |
| 1339 | /** A message's "⋯" in its hover toolbar. */ |
| 1340 | function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) { |
| 1341 | return ( |
| 1342 | <DropdownMenu> |
| 1343 | <Hint label="More actions"> |
| 1344 | <DropdownMenuTrigger |
| 1345 | aria-label="More actions" |
| 1346 | className="flex size-7 items-center justify-center rounded-md text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50" |
| 1347 | > |
| 1348 | <Ellipsis size={15} /> |
| 1349 | </DropdownMenuTrigger> |
| 1350 | </Hint> |
| 1351 | <DropdownMenuContent align="end"> |
| 1352 | {onEdit && ( |
| 1353 | <DropdownMenuItem onSelect={onEdit}> |
| 1354 | <Pencil /> |
| 1355 | Edit message |
| 1356 | </DropdownMenuItem> |
| 1357 | )} |
| 1358 | <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} /> |
| 1359 | </DropdownMenuContent> |
| 1360 | </DropdownMenu> |
| 1361 | ); |
| 1362 | } |
| 1363 | |
| 1364 | function ThreadMenuItems({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) { |
| 1365 | return ( |
| 1366 | <> |
| 1367 | <DropdownMenuItem onSelect={onCopyLink}> |
| 1368 | <Link2 /> |
| 1369 | Copy link to thread |
| 1370 | </DropdownMenuItem> |
| 1371 | {onWriteUp && ( |
| 1372 | <DropdownMenuItem onSelect={onWriteUp}> |
| 1373 | <FileText /> |
| 1374 | Write this up in Docs |
| 1375 | </DropdownMenuItem> |
| 1376 | )} |
| 1377 | </> |
| 1378 | ); |
| 1379 | } |
| 1380 | |
| 1381 | /** The thread panel's "⋯", in its header. */ |
| 1382 | function ThreadMenu({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) { |
| 1383 | return ( |
| 1384 | <DropdownMenu> |
| 1385 | <Hint label="Thread actions"> |
| 1386 | <DropdownMenuTrigger |
| 1387 | aria-label="Thread actions" |
| 1388 | className="flex size-10 items-center justify-center rounded-full text-muted outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50 active:bg-raised lg:size-7 lg:rounded-md" |
| 1389 | > |
| 1390 | <Ellipsis size={16} /> |
| 1391 | </DropdownMenuTrigger> |
| 1392 | </Hint> |
| 1393 | <DropdownMenuContent align="end"> |
| 1394 | <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} /> |
| 1395 | </DropdownMenuContent> |
| 1396 | </DropdownMenu> |
| 1397 | ); |
| 1398 | } |
| 1399 | |
| 1400 | /** "reviewer is typing…", for whoever is. */ |
| 1401 | /** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */ |
| 1402 | const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"]; |
| 1403 | |
| 1404 | function agentDoing(id: string): string { |
| 1405 | let hash = 0; |
| 1406 | for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0; |
| 1407 | return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!; |
| 1408 | } |
| 1409 | |
| 1410 | function TypingLine({ members }: { members: MemberProfile[] }) { |
| 1411 | const names = members.map(shownName); |
| 1412 | const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : ""; |
| 1413 | return ( |
| 1414 | <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted"> |
| 1415 | {who && ( |
| 1416 | <> |
| 1417 | <span aria-hidden="true" className="flex gap-0.5"> |
| 1418 | {[0, 1, 2].map((i) => ( |
| 1419 | <span key={i} className="size-1 animate-bounce rounded-full bg-accent" style={{ animationDelay: `${i * 140}ms` }} /> |
| 1420 | ))} |
| 1421 | </span> |
| 1422 | <span> |
| 1423 | <span className="font-medium text-fg-soft">{who}</span>{" "} |
| 1424 | {names.length === 1 ? (members[0]!.kind === "agent" ? agentDoing(members[0]!.id) : "is typing") : "are typing"}… |
| 1425 | </span> |
| 1426 | </> |
| 1427 | )} |
| 1428 | </div> |
| 1429 | ); |
| 1430 | } |
| 1431 | |
| 1432 | /** |
| 1433 | * A panel beside the conversation: a thread, or the details. On a phone it |
| 1434 | * is a screen of its own, pushed over the conversation, as tall as what |
| 1435 | * the keyboard leaves. |
| 1436 | */ |
| 1437 | function SidePanel({ title, subtitle, onClose, menu, children }: { title: string; subtitle?: string; onClose: () => void; menu?: ReactNode; children: ReactNode }) { |
| 1438 | const swipe = useSwipeBack(onClose); |
| 1439 | return ( |
| 1440 | <aside |
| 1441 | {...swipe} |
| 1442 | aria-label={title} |
| 1443 | className="fixed inset-0 z-40 flex flex-col bg-bg lg:static lg:z-auto lg:w-[22rem] lg:shrink-0 lg:border-l lg:border-line xl:w-[24rem] max-md:top-(--vv-top,0px) max-md:bottom-auto max-md:h-(--vv-height,100dvh) max-md:pb-[env(safe-area-inset-bottom)]" |
| 1444 | > |
| 1445 | <div className="flex h-14 shrink-0 items-center gap-2 border-b border-line px-4 max-md:h-[calc(3.5rem+env(safe-area-inset-top))] max-md:pt-[env(safe-area-inset-top)] max-md:pl-1.5"> |
| 1446 | <button type="button" onClick={onClose} aria-label="Back" className="flex size-10 items-center justify-center rounded-full text-muted active:bg-raised lg:hidden"> |
| 1447 | <ChevronLeft size={22} /> |
| 1448 | </button> |
| 1449 | <h2 className="text-[0.9375rem] font-semibold">{title}</h2> |
| 1450 | {subtitle && <span className="truncate text-sm text-faint">{subtitle}</span>} |
| 1451 | <div className="ml-auto flex shrink-0 items-center gap-1"> |
| 1452 | {menu} |
| 1453 | <button type="button" onClick={onClose} aria-label="Close" className="hidden rounded-md p-1 text-faint hover:bg-raised hover:text-fg lg:block"> |
| 1454 | <X size={16} /> |
| 1455 | </button> |
| 1456 | </div> |
| 1457 | </div> |
| 1458 | <div className="flex min-h-0 grow flex-col">{children}</div> |
| 1459 | </aside> |
| 1460 | ); |
| 1461 | } |
| 1462 | |
| 1463 | function ThreadPanel({ |
| 1464 | root, |
| 1465 | replies, |
| 1466 | zone, |
| 1467 | context, |
| 1468 | code, |
| 1469 | slug, |
| 1470 | people, |
| 1471 | joined, |
| 1472 | onSend, |
| 1473 | onRetry, |
| 1474 | onTyping, |
| 1475 | draftKey, |
| 1476 | }: { |
| 1477 | root: ShownMessage | null; |
| 1478 | replies: ShownMessage[] | null; |
| 1479 | zone: string | undefined; |
| 1480 | context: TextContext; |
| 1481 | code: boolean; |
| 1482 | slug: string; |
| 1483 | people: Mentionable[]; |
| 1484 | joined: boolean; |
| 1485 | onSend: (body: string) => void; |
| 1486 | onRetry: (message: ShownMessage) => void; |
| 1487 | /** Says "is typing" to the others in the channel, as the main composer does. */ |
| 1488 | onTyping: () => void; |
| 1489 | draftKey: string; |
| 1490 | }) { |
| 1491 | const rows = useMemo(() => timeline(replies ?? [], new Date(), zone).filter((row) => row.kind === "message"), [replies, zone]); |
| 1492 | return ( |
| 1493 | <> |
| 1494 | <div className="min-h-0 grow overflow-y-auto px-1 py-3 [scrollbar-width:thin]"> |
| 1495 | {root ? ( |
| 1496 | <MessageRow message={root} head zone={zone} context={context} code={code} slug={slug} onRetry={() => onRetry(root)} inThread /> |
| 1497 | ) : ( |
| 1498 | replies != null && <p className="px-4 py-2 text-sm text-faint">The message this thread is under is further back.</p> |
| 1499 | )} |
| 1500 | <div className="my-2 flex items-center gap-3 px-4 text-xs text-faint"> |
| 1501 | {replies == null ? "Loading replies…" : `${replies.filter((r) => !r.deleted_at).length} ${replies.length === 1 ? "reply" : "replies"}`} |
| 1502 | <span className="h-px grow bg-line" /> |
| 1503 | </div> |
| 1504 | {rows.map((row) => |
| 1505 | row.kind === "message" ? ( |
| 1506 | <MessageRow |
| 1507 | key={row.key} |
| 1508 | message={row.message} |
| 1509 | head={row.head} |
| 1510 | zone={zone} |
| 1511 | context={context} |
| 1512 | code={code} |
| 1513 | slug={slug} |
| 1514 | onRetry={() => onRetry(row.message)} |
| 1515 | inThread |
| 1516 | /> |
| 1517 | ) : null, |
| 1518 | )} |
| 1519 | </div> |
| 1520 | {joined && ( |
| 1521 | <div className="shrink-0 p-3"> |
| 1522 | <Composer draftKey={draftKey} placeholder="Reply…" people={people} onSend={onSend} onTyping={onTyping} compact autoFocus /> |
| 1523 | </div> |
| 1524 | )} |
| 1525 | </> |
| 1526 | ); |
| 1527 | } |
| 1528 | |
| 1529 | /** |
| 1530 | * A channel's name, topic and visibility, and changing them: any member |
| 1531 | * edits the topic; renaming and archiving are for whoever the workspace's |
| 1532 | * chat settings allow (`canManage`). #general keeps its name and stays. |
| 1533 | */ |
| 1534 | function ChannelAbout({ |
| 1535 | channel, |
| 1536 | joined, |
| 1537 | canManage, |
| 1538 | onUpdate, |
| 1539 | }: { |
| 1540 | channel: Channel; |
| 1541 | joined: boolean; |
| 1542 | canManage: boolean; |
| 1543 | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; |
| 1544 | }) { |
| 1545 | const [editing, setEditing] = useState<"name" | "topic" | null>(null); |
| 1546 | const [value, setValue] = useState(""); |
| 1547 | const [error, setError] = useState<string | null>(null); |
| 1548 | const [busy, setBusy] = useState(false); |
| 1549 | const [confirm, setConfirm] = useState(false); |
| 1550 | const general = channel.name === "general"; |
| 1551 | const archived = !!channel.archived_at; |
| 1552 | const mayRename = canManage && !general && !archived; |
| 1553 | const mayTopic = joined && !archived; |
| 1554 | const save = async (change: ChannelChange) => { |
| 1555 | setBusy(true); |
| 1556 | setError(null); |
| 1557 | const done = await onUpdate(change); |
| 1558 | setBusy(false); |
| 1559 | if (!done.ok) return setError(done.error.message); |
| 1560 | setEditing(null); |
| 1561 | setConfirm(false); |
| 1562 | }; |
| 1563 | const start = (field: "name" | "topic") => { |
| 1564 | setEditing(field); |
| 1565 | setValue(field === "name" ? (channel.name ?? "") : (channel.topic ?? "")); |
| 1566 | setError(null); |
| 1567 | }; |
| 1568 | const edit = (field: "name" | "topic") => ( |
| 1569 | <form |
| 1570 | className="mt-1.5 space-y-2" |
| 1571 | onSubmit={(event) => { |
| 1572 | event.preventDefault(); |
| 1573 | void save(field === "name" ? { name: value } : { topic: value }); |
| 1574 | }} |
| 1575 | > |
| 1576 | <span className="flex h-9 items-center gap-1.5 rounded-md border border-line bg-bg px-2.5 focus-within:border-accent-dim"> |
| 1577 | {field === "name" && (channel.private ? <Lock size={13} className="text-faint" /> : <Hash size={13} className="text-faint" />)} |
| 1578 | <input |
| 1579 | autoFocus |
| 1580 | value={value} |
| 1581 | onChange={(event) => setValue(event.target.value)} |
| 1582 | onKeyDown={(event) => event.key === "Escape" && setEditing(null)} |
| 1583 | maxLength={field === "name" ? 80 : 250} |
| 1584 | placeholder={field === "name" ? "channel-name" : "What this channel is for"} |
| 1585 | aria-label={field === "name" ? "Channel name" : "Topic"} |
| 1586 | autoComplete="off" |
| 1587 | data-1p-ignore |
| 1588 | className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint" |
| 1589 | /> |
| 1590 | </span> |
| 1591 | <div className="flex justify-end gap-1.5"> |
| 1592 | <button type="button" onClick={() => setEditing(null)} className="h-7 rounded-md px-2.5 text-xs text-muted hover:bg-raised hover:text-fg"> |
| 1593 | Cancel |
| 1594 | </button> |
| 1595 | <button type="submit" disabled={busy} className="h-7 rounded-md bg-accent px-2.5 text-xs font-medium text-bg hover:bg-accent-hover disabled:opacity-50"> |
| 1596 | {busy ? "Saving…" : "Save"} |
| 1597 | </button> |
| 1598 | </div> |
| 1599 | </form> |
| 1600 | ); |
| 1601 | const editButton = (field: "name" | "topic", label: string) => ( |
| 1602 | <button |
| 1603 | type="button" |
| 1604 | onClick={() => start(field)} |
| 1605 | aria-label={label} |
| 1606 | className="rounded px-1.5 py-0.5 text-xs text-muted opacity-0 transition-opacity group-hover/about:opacity-100 hover:bg-raised hover:text-fg focus-visible:opacity-100 [@media(hover:none)]:opacity-100" |
| 1607 | > |
| 1608 | Edit |
| 1609 | </button> |
| 1610 | ); |
| 1611 | return ( |
| 1612 | <> |
| 1613 | <section className="group/about rounded-xl border border-line bg-surface p-3.5"> |
| 1614 | <div className="flex items-center justify-between"> |
| 1615 | <h3 className="text-xs font-medium text-faint">Name</h3> |
| 1616 | {mayRename && editing !== "name" && editButton("name", "Rename channel")} |
| 1617 | </div> |
| 1618 | {editing === "name" ? edit("name") : <p className="mt-1 text-sm text-fg-soft">#{channel.name}</p>} |
| 1619 | <div className="mt-3 flex items-center justify-between"> |
| 1620 | <h3 className="text-xs font-medium text-faint">Topic</h3> |
| 1621 | {mayTopic && editing !== "topic" && editButton("topic", "Edit topic")} |
| 1622 | </div> |
| 1623 | {editing === "topic" ? edit("topic") : <p className="mt-1 text-sm text-fg-soft">{channel.topic || "No topic yet."}</p>} |
| 1624 | <h3 className="mt-3 text-xs font-medium text-faint">Visibility</h3> |
| 1625 | <p className="mt-1 flex items-center gap-1.5 text-sm text-fg-soft"> |
| 1626 | {channel.private ? <Lock size={13} /> : <Hash size={13} />} |
| 1627 | {channel.private ? "Private: only its members can find and read it" : "Public: anyone in the workspace can read and join"} |
| 1628 | </p> |
| 1629 | {error && <p className="mt-2 text-xs text-danger">{error}</p>} |
| 1630 | </section> |
| 1631 | {canManage && !general && ( |
| 1632 | <button |
| 1633 | type="button" |
| 1634 | onClick={() => (archived ? void save({ archived: false }) : setConfirm(true))} |
| 1635 | disabled={busy} |
| 1636 | className="mt-2 flex h-8 w-full items-center justify-center gap-2 rounded-md text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" |
| 1637 | > |
| 1638 | {archived ? <ArchiveRestore size={14} /> : <Archive size={14} />} |
| 1639 | {archived ? "Unarchive channel" : "Archive channel"} |
| 1640 | </button> |
| 1641 | )} |
| 1642 | <AlertDialog open={confirm} onOpenChange={setConfirm}> |
| 1643 | <AlertDialogContent> |
| 1644 | <AlertDialogHeader> |
| 1645 | <AlertDialogTitle>Archive #{channel.name}?</AlertDialogTitle> |
| 1646 | <AlertDialogDescription> |
| 1647 | It leaves everyone's sidebar and nobody can post in it. Its history stays readable, and it can be unarchived from Browse |
| 1648 | channels, Archived. |
| 1649 | </AlertDialogDescription> |
| 1650 | </AlertDialogHeader> |
| 1651 | <AlertDialogFooter> |
| 1652 | <AlertDialogCancel>Cancel</AlertDialogCancel> |
| 1653 | <AlertDialogAction |
| 1654 | onClick={(event) => { |
| 1655 | event.preventDefault(); |
| 1656 | void save({ archived: true }); |
| 1657 | }} |
| 1658 | disabled={busy} |
| 1659 | > |
| 1660 | {busy ? "Archiving…" : "Archive"} |
| 1661 | </AlertDialogAction> |
| 1662 | </AlertDialogFooter> |
| 1663 | </AlertDialogContent> |
| 1664 | </AlertDialog> |
| 1665 | </> |
| 1666 | ); |
| 1667 | } |
| 1668 | |
| 1669 | function InfoPanel({ |
| 1670 | data, |
| 1671 | members, |
| 1672 | slug, |
| 1673 | people, |
| 1674 | onAdded, |
| 1675 | joined, |
| 1676 | canManage, |
| 1677 | onUpdate, |
| 1678 | }: { |
| 1679 | data: Loaded; |
| 1680 | members: ChannelMember[]; |
| 1681 | slug: string; |
| 1682 | people: Mentionable[]; |
| 1683 | onAdded: (member: ChannelMember) => void; |
| 1684 | joined: boolean; |
| 1685 | /** Whether the viewer may rename, archive and unarchive it. */ |
| 1686 | canManage: boolean; |
| 1687 | onUpdate: (change: ChannelChange) => Promise<Result<unknown>>; |
| 1688 | }) { |
| 1689 | const channel = data.channel; |
| 1690 | const agents = members.filter((m) => m.member.kind === "agent"); |
| 1691 | const humans = members.filter((m) => m.member.kind === "user"); |
| 1692 | return ( |
| 1693 | <div className="min-h-0 grow overflow-y-auto p-4 [scrollbar-width:thin]"> |
| 1694 | {channel.kind === "channel" && <ChannelAbout channel={channel} joined={joined} canManage={canManage} onUpdate={onUpdate} />} |
| 1695 | {agents.length > 0 && ( |
| 1696 | <section className="mt-5"> |
| 1697 | <h3 className="mb-2 px-1 text-xs font-medium text-faint">Agents · {agents.length}</h3> |
| 1698 | <div className="flex flex-wrap gap-1.5"> |
| 1699 | {agents.map(({ member }) => ( |
| 1700 | <Hint key={member.id} label={member.role ?? undefined}> |
| 1701 | <MemberCard |
| 1702 | member={member} |
| 1703 | className="flex h-7 items-center gap-1.5 rounded-full border border-accent/25 bg-accent/10 pr-2.5 pl-1 text-[0.8125rem] font-medium text-accent transition-colors hover:bg-accent/15" |
| 1704 | > |
| 1705 | <MemberAvatar member={member} size={20} /> |
| 1706 | {shownName(member)} |
| 1707 | </MemberCard> |
| 1708 | </Hint> |
| 1709 | ))} |
| 1710 | </div> |
| 1711 | </section> |
| 1712 | )} |
| 1713 | <section className="mt-5"> |
| 1714 | <div className="mb-1 flex items-center justify-between px-1"> |
| 1715 | <h3 className="text-xs font-medium text-faint">People · {humans.length}</h3> |
| 1716 | {joined && channel.kind === "channel" && <AddMember slug={slug} channelId={channel.id} people={people} members={members} onAdded={onAdded} />} |
| 1717 | </div> |
| 1718 | <ul className="space-y-px"> |
| 1719 | {humans.map(({ member, role }) => ( |
| 1720 | <li key={member.id}> |
| 1721 | <MemberCard member={member} className="flex w-full items-center gap-2.5 rounded-md px-1 py-1.5 transition-colors hover:bg-raised/60 max-md:min-h-11"> |
| 1722 | <MemberAvatar member={member} size={26} presence /> |
| 1723 | <span className="min-w-0 grow truncate text-sm"> |
| 1724 | {shownName(member)} |
| 1725 | {shownName(member) !== shownHandle(member) && <span className="ml-1.5 text-faint">@{shownHandle(member)}</span>} |
| 1726 | <PersonStatusEmoji person={{ id: member.id, username: member.name }} size={13} className="ml-1.5 align-[-2px]" inert /> |
| 1727 | </span> |
| 1728 | {role === "owner" && <span className="text-[0.6875rem] text-faint">Owner</span>} |
| 1729 | </MemberCard> |
| 1730 | </li> |
| 1731 | ))} |
| 1732 | </ul> |
| 1733 | </section> |
| 1734 | </div> |
| 1735 | ); |
| 1736 | } |
| 1737 | |
| 1738 | /** Adding a person or an agent to the channel. An invite lets them read; it never grants write. */ |
| 1739 | function AddMember({ |
| 1740 | slug, |
| 1741 | channelId, |
| 1742 | people, |
| 1743 | members, |
| 1744 | onAdded, |
| 1745 | }: { |
| 1746 | slug: string; |
| 1747 | channelId: string; |
| 1748 | people: Mentionable[]; |
| 1749 | members: ChannelMember[]; |
| 1750 | onAdded: (member: ChannelMember) => void; |
| 1751 | }) { |
| 1752 | const [open, setOpen] = useState(false); |
| 1753 | const [query, setQuery] = useState(""); |
| 1754 | const [error, setError] = useState<string | null>(null); |
| 1755 | const send = useChatSend(slug); |
| 1756 | const chatData = useChatData(); |
| 1757 | const inside = new Set(members.map((m) => `${m.member.kind}:${m.member.name.toLowerCase()}`)); |
| 1758 | const q = query.trim().toLowerCase().replace(/^@/, ""); |
| 1759 | const options = people |
| 1760 | .filter((p) => !inside.has(`${p.kind}:${p.name.toLowerCase()}`)) |
| 1761 | .filter((p) => !q || p.name.toLowerCase().includes(q) || p.display_name.toLowerCase().includes(q)) |
| 1762 | .slice(0, 8); |
| 1763 | const add = async (person: Mentionable) => { |
| 1764 | const agent = person.kind === "agent" ? chatData?.agents.find((a) => a.handle === person.name) : null; |
| 1765 | const key = agent ? `agent:${agent.id}` : `user:${person.name}`; |
| 1766 | setError(null); |
| 1767 | const done = await send({ intent: "invite", channel_id: channelId, member: key }); |
| 1768 | if (!done.ok) return setError(done.error.message); |
| 1769 | onAdded({ |
| 1770 | channel_id: channelId, |
| 1771 | member: { kind: person.kind, id: agent?.id ?? person.name, name: person.name, display_username: person.display_username ?? null, display_name: person.display_name, avatar: person.avatar, role: person.role ?? null }, |
| 1772 | role: "member", |
| 1773 | starred: false, |
| 1774 | muted: false, |
| 1775 | last_read_id: null, |
| 1776 | joined_at: new Date().toISOString(), |
| 1777 | }); |
| 1778 | setOpen(false); |
| 1779 | }; |
| 1780 | return ( |
| 1781 | <Popover open={open} onOpenChange={setOpen}> |
| 1782 | <PopoverTrigger asChild> |
| 1783 | <button type="button" className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-muted hover:bg-raised hover:text-fg"> |
| 1784 | <UserPlus size={13} /> |
| 1785 | Add |
| 1786 | </button> |
| 1787 | </PopoverTrigger> |
| 1788 | <PopoverContent align="end" className="w-72 p-1.5"> |
| 1789 | <input |
| 1790 | autoFocus |
| 1791 | value={query} |
| 1792 | onChange={(event) => setQuery(event.target.value)} |
| 1793 | placeholder="Add a person or an agent" |
| 1794 | aria-label="Add a person or an agent" |
| 1795 | autoComplete="off" |
| 1796 | data-1p-ignore |
| 1797 | className="mb-1 h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm outline-none placeholder:text-faint focus:border-accent-dim" |
| 1798 | /> |
| 1799 | {options.length === 0 && <p className="px-2 py-3 text-xs text-faint">Everyone who matches is already here.</p>} |
| 1800 | {options.map((person) => ( |
| 1801 | <button |
| 1802 | key={`${person.kind}:${person.name}`} |
| 1803 | type="button" |
| 1804 | onClick={() => void add(person)} |
| 1805 | className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-line" |
| 1806 | > |
| 1807 | <MemberAvatar member={person} size={20} /> |
| 1808 | <span className="min-w-0 grow truncate">{person.display_name}</span> |
| 1809 | {person.kind === "agent" && <AgentPill />} |
| 1810 | </button> |
| 1811 | ))} |
| 1812 | {error && <p className="px-2 pt-1 text-xs text-danger">{error}</p>} |
| 1813 | <p className="border-t border-line px-2 pt-1.5 pb-0.5 text-[0.6875rem] leading-snug text-faint"> |
| 1814 | Adding an agent lets it read this channel. It never grants it write access. |
| 1815 | </p> |
| 1816 | </PopoverContent> |
| 1817 | </Popover> |
| 1818 | ); |
| 1819 | } |