Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

235 linesCodeBlameRaw
1/**
2 * What the notify service is told about a new message, or a read: every
3 * person in the conversation has its counts moved, and those it is for are
4 * notified (docs.g1t.sh/guides/chat/, "Notifications").
5 *
6 * - A direct message notifies everyone else in it, muted or not.
7 * - An @mention notifies the person named, muted or not.
8 * - A reply notifies the people already in its thread (who started it or
9 * replied), unless they muted the conversation.
10 * - Everyone else in it only has their counts moved.
11 * - The author is never told of their own message; their own count for
12 * the conversation goes to nothing (what you wrote, you have read).
13 * - Only people are told.
14 *
15 * `recipients` is pure, so the rules are tested apart from the service.
16 */
17import type { FeedDelivery, FeedNotification, MemberProfile, NotificationKind } from "@g1t/contracts";
18
19import { plainText } from "@g1t/contracts/chat-markdown";
20
21import { tally, type UnreadRow } from "./unread.ts";
22
23export type Person = { key: string; user_id: string; username: string; muted: boolean };
24
25export type Recipient = { user_id: string; kind: NotificationKind | null; mentioned: boolean; muted: boolean };
26
27export function recipients(input: {
28 /** `user:<id>`. */
29 author: string;
30 channelKind: "channel" | "dm";
31 /** The people in the conversation. */
32 people: Person[];
33 /** Handles the message mentions, lowercased. */
34 mentioned: string[];
35 /** For a reply: the people in its thread, by key; null for a top-level message. */
36 thread: ReadonlySet<string> | null;
37}): Recipient[] {
38 const named = new Set(input.mentioned.map((h) => h.toLowerCase()));
39 const out: Recipient[] = [];
40 for (const person of input.people) {
41 if (person.key === input.author) continue;
42 const mentioned = named.has(person.username.toLowerCase());
43 let kind: NotificationKind | null = null;
44 if (input.channelKind === "dm") kind = "dm";
45 else if (mentioned) kind = "mention";
46 else if (input.thread?.has(person.key) && !person.muted) kind = "thread_reply";
47 out.push({ user_id: person.user_id, kind, mentioned, muted: person.muted });
48 }
49 return out;
50}
51
52/** A message's text as a notification shows it: one line, the Markdown's marks gone, short. */
53export function preview(body: string, max = 140): string {
54 return plainText(String(body ?? ""), max);
55}
56
57/** Where a conversation, or a thread in it, is on the site. */
58export function conversationHref(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }, threadRoot: string | null): string {
59 const base = channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
60 return threadRoot ? `${base}?thread=${encodeURIComponent(threadRoot)}` : base;
61}
62
63/** The deliveries for one new message. */
64export function messageDeliveries(input: {
65 slug: string;
66 channel: { id: string; kind: "channel" | "dm"; name: string | null };
67 message: { id: string; author: string; body: string; thread_root: string | null; created_at: string };
68 author: MemberProfile;
69 recipients: Recipient[];
70}): FeedDelivery[] {
71 const { slug, channel, message, author } = input;
72 const where = channel.kind === "dm" ? "" : ` in #${channel.name}`;
73 // The service sets `display_name` by `memberName`'s rule (display name,
74 // else the username in its chosen case), so pushes match the chat.
75 const shown = author.display_name.trim() || author.display_username || author.name;
76 const deliveries: FeedDelivery[] = input.recipients.map((r) => {
77 const notification: FeedNotification | null = r.kind
78 ? {
79 id: message.id,
80 kind: r.kind,
81 workspace: slug,
82 title: r.kind === "thread_reply" ? `${shown} replied${where}` : `${shown}${where}`,
83 body: preview(message.body),
84 href: conversationHref(slug, channel, message.thread_root),
85 actor: {
86 kind: author.kind,
87 id: author.id,
88 name: shown,
89 avatar: author.avatar,
90 avatar_seed: author.avatar_seed ?? null,
91 },
92 channel_id: channel.id,
93 thread_root: message.thread_root,
94 created_at: message.created_at,
95 }
96 : null;
97 return {
98 user_id: r.user_id,
99 workspace: slug,
100 counts: { channel_id: channel.id, unread: 1, mentions: r.mentioned ? 1 : 0, muted: r.muted },
101 notification,
102 };
103 });
104 // The author has read the conversation by writing in it.
105 if (message.author.startsWith("user:")) {
106 deliveries.push({
107 user_id: message.author.slice("user:".length),
108 workspace: slug,
109 counts: { channel_id: channel.id, unread: 0, mentions: 0, set: true },
110 notification: null,
111 });
112 }
113 return deliveries;
114}
115
116/** A person's counts for one conversation after they read up to `lastReadId`, from the rows after it. */
117export function countsAfterRead(
118 rows: UnreadRow[],
119 channelId: string,
120 member: string,
121 username: string,
122 lastReadId: string,
123): { unread: number; mentions: number } {
124 return tally(rows, member, username, new Map([[channelId, lastReadId]])).get(channelId) ?? { unread: 0, mentions: 0 };
125}
126
127// ── Wiring, used by src/index.ts ─────────────────────────────────────────
128
129type Db = D1Database;
130type Notify = { fetch(input: string, init?: RequestInit): Promise<Response> };
131
132async function deliver(notify: Notify, items: FeedDelivery[]): Promise<void> {
133 if (!items.length) return;
134 const response = await notify.fetch("https://service/rpc/deliver", {
135 method: "POST",
136 headers: { "content-type": "application/json" },
137 body: JSON.stringify({ items }),
138 });
139 if (!response.ok) throw new Error(`deliver failed with status ${response.status}`);
140}
141
142/** Tells notify of a new message: one batched call for everyone in the conversation. */
143export async function notifyMessage(
144 db: Db,
145 notify: Notify | undefined,
146 profiles: (keys: string[]) => Promise<Map<string, MemberProfile>>,
147 input: {
148 slug: string;
149 channel: { id: string; kind: "channel" | "dm"; name: string | null };
150 row: { id: string; author: string; body: string; thread_root: string | null; created_at: string };
151 handles: string[];
152 },
153): Promise<void> {
154 if (!notify) return;
155 const { channel, row } = input;
156 const [members, thread] = await Promise.all([
157 db
158 .prepare("SELECT principal, muted FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'")
159 .bind(channel.id)
160 .all<{ principal: string; muted: number }>(),
161 row.thread_root
162 ? db
163 .prepare("SELECT DISTINCT author FROM messages WHERE channel_id = ?1 AND (id = ?2 OR thread_root = ?2) AND author LIKE 'user:%'")
164 .bind(channel.id, row.thread_root)
165 .all<{ author: string }>()
166 : Promise.resolve(null),
167 ]);
168 const keys = members.results.map((m) => m.principal);
169 const found = await profiles([...keys, row.author]);
170 const people: Person[] = members.results.map((m) => ({
171 key: m.principal,
172 user_id: m.principal.slice("user:".length),
173 username: found.get(m.principal)?.name ?? "",
174 muted: !!m.muted,
175 }));
176 const author = found.get(row.author);
177 if (!author) return;
178 await deliver(
179 notify,
180 messageDeliveries({
181 slug: input.slug,
182 channel,
183 message: { id: row.id, author: row.author, body: row.body, thread_root: row.thread_root, created_at: row.created_at },
184 author,
185 recipients: recipients({
186 author: row.author,
187 channelKind: channel.kind,
188 people,
189 mentioned: input.handles,
190 thread: thread ? new Set(thread.results.map((r) => r.author)) : null,
191 }),
192 }),
193 );
194}
195
196/** After a read: the person's counts for the conversation, as they now are, in every tab. */
197export async function notifyRead(
198 db: Db,
199 notify: Notify | undefined,
200 input: { slug: string; channel_id: string; user_id: string; username: string; last_read_id: string },
201): Promise<void> {
202 if (!notify) return;
203 const member = `user:${input.user_id}`;
204 const rows = await db
205 .prepare("SELECT channel_id, id, author, mentions FROM messages WHERE channel_id = ? AND id > ? AND deleted_at IS NULL AND author != ? LIMIT 5000")
206 .bind(input.channel_id, input.last_read_id, member)
207 .all<UnreadRow>();
208 const counts = countsAfterRead(rows.results, input.channel_id, member, input.username, input.last_read_id);
209 await deliver(notify, [
210 { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, ...counts, set: true }, notification: null },
211 ]);
212}
213
214/** After muting or unmuting: the badge counts the conversation, or leaves it out, in every tab. */
215export async function notifyMuted(notify: Notify | undefined, input: { slug: string; channel_id: string; user_id: string; muted: boolean }): Promise<void> {
216 if (!notify) return;
217 await deliver(notify, [
218 { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, unread: 0, mentions: 0, muted: input.muted }, notification: null },
219 ]);
220}
221
222/**
223 * After the person cleared their conversation with @g1t (src/clear.ts):
224 * notify forgets the notifications it kept from it (their text is a
225 * preview of messages that no longer exist), and its counts go to nothing.
226 */
227export async function notifyCleared(notify: Notify | undefined, input: { slug: string; channel_id: string; user_id: string }): Promise<void> {
228 if (!notify) return;
229 const response = await notify.fetch("https://service/rpc/forget_conversation", {
230 method: "POST",
231 headers: { "content-type": "application/json" },
232 body: JSON.stringify({ user_id: input.user_id, workspace: input.slug, channel_id: input.channel_id }),
233 });
234 if (!response.ok) throw new Error(`forget_conversation failed with status ${response.status}`);
235}