| 1 | import { type ChatSidebar, type Principal, type User, type WorkspaceAgent, shownUsername } from "@g1t/contracts"; |
| 2 | |
| 3 | import type { Mentionable } from "./chat"; |
| 4 | import { 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 | */ |
| 12 | export 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_username: member.display_username ?? null, |
| 22 | // As chat names them (`memberName`): display name, else the username as they wrote it. |
| 23 | display_name: member.name?.trim() || shownUsername(member), |
| 24 | avatar: member.avatar ?? null, |
| 25 | })) |
| 26 | : []; |
| 27 | return { people, agents: agents?.ok ? agents.value.filter((agent) => !agent.archived_at) : [] }; |
| 28 | } |
| 29 | |
| 30 | /** An agent as a mention: by its handle, with its role beside it. */ |
| 31 | export function agentMentionable(agent: WorkspaceAgent): Mentionable { |
| 32 | return { kind: "agent", name: agent.handle, display_name: agent.display_name, avatar: agent.avatar, role: agent.role }; |
| 33 | } |
| 34 | |
| 35 | /** The sidebar, or null when chat does not answer. */ |
| 36 | export async function sidebarOrNull(slug: string, viewer: User): Promise<ChatSidebar | null> { |
| 37 | try { |
| 38 | const result = await chat.sidebar(slug, viewer); |
| 39 | return result.ok ? result.value : null; |
| 40 | } catch { |
| 41 | return null; |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Members as a form sends them, `user:<username>` or `agent:<id>`, as the |
| 47 | * chat service names them: a person by their account id. Anyone who is not |
| 48 | * a member of the workspace is left out. |
| 49 | */ |
| 50 | export async function principalsFrom(slug: string, viewer: User, fields: string[]): Promise<Principal[]> { |
| 51 | const usernames = fields.filter((f) => f.startsWith("user:")).map((f) => f.slice(5).toLowerCase()); |
| 52 | const agentIds = fields.filter((f) => f.startsWith("agent:")).map((f) => f.slice(6)).filter(Boolean); |
| 53 | const members = usernames.length > 0 ? await identity.listMembers(slug, viewer).catch(() => null) : null; |
| 54 | const known = new Set(members?.ok ? members.value.map((m) => m.username.toLowerCase()) : []); |
| 55 | const people = await Promise.all( |
| 56 | usernames |
| 57 | .filter((name) => known.has(name) && name !== viewer.username.toLowerCase()) |
| 58 | .map((name) => identity.userByUsername(name).catch(() => null)), |
| 59 | ); |
| 60 | return [ |
| 61 | ...people.filter((user): user is User => user != null).map((user) => ({ kind: "user" as const, id: user.id })), |
| 62 | ...agentIds.map((id) => ({ kind: "agent" as const, id })), |
| 63 | ]; |
| 64 | } |
| 65 | |
| 66 | /** The cookie that remembers the last conversation open, so Chat opens there again. */ |
| 67 | export const LAST_CHAT_COOKIE = "g1t_chat"; |
| 68 | |
| 69 | export function rememberChat(path: string, secure: boolean): string { |
| 70 | return `${LAST_CHAT_COOKIE}=${encodeURIComponent(path)}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`; |
| 71 | } |