Skip to content
69 linesCodeBlameRaw
1import type { ChatSidebar, Principal, User, WorkspaceAgent } from "@g1t/contracts";
2
3import type { Mentionable } from "./chat";
4import { chat, identity, workspaceAgents } from "./services.server";
5
6/**
7 * Who can be talked to in a workspace: its people and its agents, for
8 * mentions and for starting a direct message. Each list is empty, not an
9 * error, when its service does not answer: chat and agents may arrive a
10 * little after the site does.
11 */
12export async function workspacePeople(slug: string, viewer: User): Promise<{ people: Mentionable[]; agents: WorkspaceAgent[] }> {
13 const [members, agents] = await Promise.all([
14 identity.listMembers(slug, viewer).catch(() => null),
15 workspaceAgents.list(slug, viewer).catch(() => null),
16 ]);
17 const people: Mentionable[] = members?.ok
18 ? members.value.map((member) => ({
19 kind: "user" as const,
20 name: member.username,
21 display_name: member.name?.trim() || member.username,
22 avatar: member.avatar ?? null,
23 }))
24 : [];
25 return { people, agents: agents?.ok ? agents.value.filter((agent) => !agent.archived_at) : [] };
26}
27
28/** An agent as a mention: by its handle, with its role beside it. */
29export function agentMentionable(agent: WorkspaceAgent): Mentionable {
30 return { kind: "agent", name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role };
31}
32
33/** The sidebar, or null when chat does not answer. */
34export async function sidebarOrNull(slug: string, viewer: User): Promise<ChatSidebar | null> {
35 try {
36 const result = await chat.sidebar(slug, viewer);
37 return result.ok ? result.value : null;
38 } catch {
39 return null;
40 }
41}
42
43/**
44 * Members as a form sends them, `user:<username>` or `agent:<id>`, as the
45 * chat service names them: a person by their account id. Anyone who is not
46 * a member of the workspace is left out.
47 */
48export async function principalsFrom(slug: string, viewer: User, fields: string[]): Promise<Principal[]> {
49 const usernames = fields.filter((f) => f.startsWith("user:")).map((f) => f.slice(5).toLowerCase());
50 const agentIds = fields.filter((f) => f.startsWith("agent:")).map((f) => f.slice(6)).filter(Boolean);
51 const members = usernames.length > 0 ? await identity.listMembers(slug, viewer).catch(() => null) : null;
52 const known = new Set(members?.ok ? members.value.map((m) => m.username.toLowerCase()) : []);
53 const people = await Promise.all(
54 usernames
55 .filter((name) => known.has(name) && name !== viewer.username.toLowerCase())
56 .map((name) => identity.userByUsername(name).catch(() => null)),
57 );
58 return [
59 ...people.filter((user): user is User => user != null).map((user) => ({ kind: "user" as const, id: user.id })),
60 ...agentIds.map((id) => ({ kind: "agent" as const, id })),
61 ];
62}
63
64/** The cookie that remembers the last conversation open, so Chat opens there again. */
65export const LAST_CHAT_COOKIE = "g1t_chat";
66
67export function rememberChat(path: string, secure: boolean): string {
68 return `${LAST_CHAT_COOKIE}=${encodeURIComponent(path)}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
69}