@takosjp/yurucommu-core 3.0.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,534 @@
1
+ import type {
2
+ NotificationPusherProduct,
3
+ NotificationPusherRegistration,
4
+ } from "../../types/index.ts";
5
+ import {
6
+ registerNotificationPusher,
7
+ unregisterNotificationPusher,
8
+ } from "./notifications.ts";
9
+
10
+ export type BrowserNotificationPushState =
11
+ "unsupported" | "unconfigured" | "denied" | "disabled" | "enabled";
12
+
13
+ export interface BrowserNotificationPushConfig {
14
+ readonly product: NotificationPusherProduct;
15
+ readonly appId: string;
16
+ readonly appDisplayName: string;
17
+ /** Origin of the yurucommu-compatible API that owns this registration. */
18
+ readonly serverOrigin: string;
19
+ /** Public stateless gateway notify endpoint. */
20
+ readonly gatewayUrl: string;
21
+ /** Public uncompressed P-256 VAPID key, base64url encoded. */
22
+ readonly vapidPublicKey: string;
23
+ readonly serviceWorkerPath: string;
24
+ readonly scope?: string;
25
+ readonly lang?: string;
26
+ }
27
+
28
+ interface BrowserPushSubscriptionLike {
29
+ readonly endpoint: string;
30
+ readonly options?: {
31
+ readonly applicationServerKey: ArrayBuffer | null;
32
+ };
33
+ unsubscribe(): Promise<boolean>;
34
+ }
35
+
36
+ interface BrowserPushManagerLike {
37
+ getSubscription(): Promise<BrowserPushSubscriptionLike | null>;
38
+ subscribe(options: {
39
+ readonly userVisibleOnly: true;
40
+ readonly applicationServerKey: BufferSource;
41
+ }): Promise<BrowserPushSubscriptionLike>;
42
+ }
43
+
44
+ interface BrowserServiceWorkerRegistrationLike {
45
+ readonly pushManager: BrowserPushManagerLike;
46
+ }
47
+
48
+ interface BrowserServiceWorkerContainerLike {
49
+ register(
50
+ scriptURL: string,
51
+ options?: RegistrationOptions,
52
+ ): Promise<BrowserServiceWorkerRegistrationLike>;
53
+ getRegistration(
54
+ clientURL?: string,
55
+ ): Promise<BrowserServiceWorkerRegistrationLike | undefined>;
56
+ }
57
+
58
+ interface BrowserNotificationApiLike {
59
+ readonly permission: NotificationPermission;
60
+ requestPermission(): Promise<NotificationPermission>;
61
+ }
62
+
63
+ interface BrowserPushStorageLike {
64
+ getItem(key: string): string | null;
65
+ setItem(key: string, value: string): void;
66
+ removeItem(key: string): void;
67
+ }
68
+
69
+ export interface BrowserNotificationPushRuntime {
70
+ readonly serviceWorker?: BrowserServiceWorkerContainerLike;
71
+ readonly notification?: BrowserNotificationApiLike;
72
+ readonly storage?: BrowserPushStorageLike;
73
+ }
74
+
75
+ export async function getBrowserNotificationPushState(
76
+ config: BrowserNotificationPushConfig | null | undefined,
77
+ runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
78
+ ): Promise<BrowserNotificationPushState> {
79
+ if (!runtime.serviceWorker || !runtime.notification) return "unsupported";
80
+ const normalized = normalizeBrowserPushConfig(config);
81
+ if (!normalized) return "unconfigured";
82
+ if (runtime.notification.permission === "denied") return "denied";
83
+ const registration = await runtime.serviceWorker.getRegistration();
84
+ const subscription = await registration?.pushManager.getSubscription();
85
+ return subscription &&
86
+ subscriptionMatchesConfig(normalized, subscription, runtime)
87
+ ? "enabled"
88
+ : "disabled";
89
+ }
90
+
91
+ export async function enableBrowserNotificationPush(
92
+ config: BrowserNotificationPushConfig,
93
+ runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
94
+ ): Promise<{
95
+ readonly state: BrowserNotificationPushState;
96
+ readonly registration?: NotificationPusherRegistration;
97
+ }> {
98
+ if (!runtime.serviceWorker || !runtime.notification) {
99
+ return { state: "unsupported" };
100
+ }
101
+ const normalized = requireBrowserPushConfig(config);
102
+ const permission =
103
+ runtime.notification.permission === "default"
104
+ ? await runtime.notification.requestPermission()
105
+ : runtime.notification.permission;
106
+ if (permission !== "granted") {
107
+ return { state: permission === "denied" ? "denied" : "disabled" };
108
+ }
109
+
110
+ const serviceWorker = await runtime.serviceWorker.register(
111
+ normalized.serviceWorkerPath,
112
+ { scope: "/" },
113
+ );
114
+ let existing = await serviceWorker.pushManager.getSubscription();
115
+ if (existing && !subscriptionMatchesConfig(normalized, existing, runtime)) {
116
+ await retireBrowserSubscription(normalized, existing, runtime);
117
+ existing = null;
118
+ }
119
+ const subscription =
120
+ existing ??
121
+ (await serviceWorker.pushManager.subscribe({
122
+ userVisibleOnly: true,
123
+ applicationServerKey: normalized.applicationServerKey,
124
+ }));
125
+ const registration = await registerBrowserSubscription(
126
+ normalized,
127
+ subscription,
128
+ );
129
+ storeBrowserPushBinding(normalized, subscription, runtime);
130
+ return { state: "enabled", registration };
131
+ }
132
+
133
+ /**
134
+ * Rebind an existing browser subscription to the current signed-in actor.
135
+ * This never requests permission and never creates a new subscription.
136
+ */
137
+ export async function refreshBrowserNotificationPush(
138
+ config: BrowserNotificationPushConfig | null | undefined,
139
+ runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
140
+ ): Promise<BrowserNotificationPushState> {
141
+ if (!runtime.serviceWorker || !runtime.notification) return "unsupported";
142
+ const normalized = normalizeBrowserPushConfig(config);
143
+ if (!normalized) return "unconfigured";
144
+ if (runtime.notification.permission !== "granted") {
145
+ return runtime.notification.permission === "denied" ? "denied" : "disabled";
146
+ }
147
+ const serviceWorker = await runtime.serviceWorker.register(
148
+ normalized.serviceWorkerPath,
149
+ { scope: "/" },
150
+ );
151
+ const subscription = await serviceWorker.pushManager.getSubscription();
152
+ if (!subscription) return "disabled";
153
+ if (!subscriptionMatchesConfig(normalized, subscription, runtime)) {
154
+ await retireBrowserSubscription(normalized, subscription, runtime);
155
+ return "disabled";
156
+ }
157
+ await registerBrowserSubscription(normalized, subscription);
158
+ storeBrowserPushBinding(normalized, subscription, runtime);
159
+ return "enabled";
160
+ }
161
+
162
+ export async function disableBrowserNotificationPush(
163
+ config: BrowserNotificationPushConfig,
164
+ runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
165
+ ): Promise<BrowserNotificationPushState> {
166
+ if (!runtime.serviceWorker || !runtime.notification) return "unsupported";
167
+ const normalized = requireBrowserPushConfig(config);
168
+ const serviceWorker = await runtime.serviceWorker.getRegistration();
169
+ const subscription = await serviceWorker?.pushManager.getSubscription();
170
+ if (!subscription) {
171
+ clearBrowserPushBinding(normalized, runtime);
172
+ return "disabled";
173
+ }
174
+
175
+ // Explicit disable is privacy-first: always invalidate the local endpoint,
176
+ // even when the host cannot remove its row. The next rejected delivery lets
177
+ // the host clean that stale row.
178
+ await retireBrowserSubscription(normalized, subscription, runtime, true);
179
+ return "disabled";
180
+ }
181
+
182
+ /**
183
+ * Invalidate this product's browser endpoint without requiring runtime push
184
+ * configuration. Use this during logout/account teardown so a removed or
185
+ * temporarily unavailable server cannot keep waking a signed-out device.
186
+ */
187
+ export async function clearBrowserNotificationPush(
188
+ identity: Pick<
189
+ BrowserNotificationPushConfig,
190
+ "product" | "appId" | "serviceWorkerPath"
191
+ >,
192
+ runtime: BrowserNotificationPushRuntime = browserPushRuntime(),
193
+ ): Promise<BrowserNotificationPushState> {
194
+ if (!runtime.serviceWorker) return "unsupported";
195
+ const registration = await runtime.serviceWorker.getRegistration();
196
+ const subscription = await registration?.pushManager.getSubscription();
197
+ if (subscription) await subscription.unsubscribe();
198
+ clearBrowserPushBindingForIdentity(identity, runtime);
199
+ return "disabled";
200
+ }
201
+
202
+ function browserPushRuntime(): BrowserNotificationPushRuntime {
203
+ const navigatorValue = globalThis.navigator as Navigator | undefined;
204
+ const notificationValue = globalThis.Notification;
205
+ let storage: BrowserPushStorageLike | undefined;
206
+ try {
207
+ storage = globalThis.localStorage;
208
+ } catch {
209
+ storage = undefined;
210
+ }
211
+ return {
212
+ ...(navigatorValue?.serviceWorker
213
+ ? {
214
+ serviceWorker:
215
+ navigatorValue.serviceWorker as unknown as BrowserServiceWorkerContainerLike,
216
+ }
217
+ : {}),
218
+ ...(notificationValue
219
+ ? {
220
+ notification:
221
+ notificationValue as unknown as BrowserNotificationApiLike,
222
+ }
223
+ : {}),
224
+ ...(storage ? { storage } : {}),
225
+ };
226
+ }
227
+
228
+ type NormalizedBrowserPushConfig = BrowserNotificationPushConfig & {
229
+ readonly applicationServerKey: BufferSource;
230
+ };
231
+
232
+ function requireBrowserPushConfig(
233
+ config: BrowserNotificationPushConfig,
234
+ ): NormalizedBrowserPushConfig {
235
+ const normalized = normalizeBrowserPushConfig(config);
236
+ if (!normalized) {
237
+ throw new Error(
238
+ "Browser notification push requires a valid HTTPS gateway URL, VAPID public key, app id, and root-relative service worker path.",
239
+ );
240
+ }
241
+ return normalized;
242
+ }
243
+
244
+ function normalizeBrowserPushConfig(
245
+ config: BrowserNotificationPushConfig | null | undefined,
246
+ ): NormalizedBrowserPushConfig | null {
247
+ if (!config) return null;
248
+ const appId = config.appId.trim();
249
+ const appDisplayName = config.appDisplayName.trim();
250
+ const serverOrigin = normalizeServerOrigin(config.serverOrigin);
251
+ const gatewayUrl = normalizeGatewayUrl(config.gatewayUrl);
252
+ const vapidPublicKey = config.vapidPublicKey.trim();
253
+ const applicationServerKey = decodeVapidPublicKey(vapidPublicKey);
254
+ const serviceWorkerPath = config.serviceWorkerPath.trim();
255
+ if (
256
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(appId) ||
257
+ !appDisplayName ||
258
+ appDisplayName.length > 255 ||
259
+ !serverOrigin ||
260
+ !gatewayUrl ||
261
+ !applicationServerKey ||
262
+ !serviceWorkerPath.startsWith("/") ||
263
+ serviceWorkerPath.startsWith("//") ||
264
+ serviceWorkerPath.length > 512
265
+ ) {
266
+ return null;
267
+ }
268
+ return {
269
+ ...config,
270
+ appId,
271
+ appDisplayName,
272
+ serverOrigin,
273
+ gatewayUrl,
274
+ vapidPublicKey,
275
+ serviceWorkerPath,
276
+ applicationServerKey,
277
+ };
278
+ }
279
+
280
+ function normalizeServerOrigin(value: string): string | null {
281
+ try {
282
+ const url = new URL(value.trim());
283
+ if (url.username || url.password || url.hash) return null;
284
+ const loopback =
285
+ url.hostname === "localhost" ||
286
+ url.hostname === "127.0.0.1" ||
287
+ url.hostname === "[::1]";
288
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
289
+ return null;
290
+ }
291
+ return url.origin;
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ function normalizeGatewayUrl(value: string): string | null {
298
+ try {
299
+ const url = new URL(value.trim());
300
+ if (url.username || url.password || url.hash) return null;
301
+ if (url.protocol === "https:") {
302
+ if (url.port && url.port !== "443") return null;
303
+ if (!isPublicHttpsHostname(url.hostname)) return null;
304
+ return url.toString();
305
+ }
306
+ if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname)) {
307
+ return null;
308
+ }
309
+ return url.toString();
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ function isLoopbackHostname(hostname: string): boolean {
316
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
317
+ if (
318
+ normalized === "localhost" ||
319
+ normalized.endsWith(".localhost") ||
320
+ normalized === "::1"
321
+ ) {
322
+ return true;
323
+ }
324
+ const octets = normalized.split(".").map(Number);
325
+ return (
326
+ octets.length === 4 &&
327
+ octets.every(
328
+ (part) => Number.isInteger(part) && part >= 0 && part <= 255,
329
+ ) &&
330
+ octets[0] === 127
331
+ );
332
+ }
333
+
334
+ function isPublicHttpsHostname(hostname: string): boolean {
335
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
336
+ if (
337
+ !normalized.includes(".") ||
338
+ normalized.endsWith(".localhost") ||
339
+ normalized.endsWith(".local") ||
340
+ normalized.endsWith(".internal") ||
341
+ normalized.endsWith(".home") ||
342
+ normalized.endsWith(".lan")
343
+ ) {
344
+ return false;
345
+ }
346
+ const ipv4 = normalized.split(".").map(Number);
347
+ if (
348
+ ipv4.length === 4 &&
349
+ ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
350
+ ) {
351
+ return false;
352
+ }
353
+ return !normalized.includes(":");
354
+ }
355
+
356
+ function decodeVapidPublicKey(value: string): BufferSource | null {
357
+ const normalized = value.trim().replace(/-/g, "+").replace(/_/g, "/");
358
+ if (!normalized || normalized.length > 256) return null;
359
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
360
+ try {
361
+ const binary = globalThis.atob(padded);
362
+ const bytes = Uint8Array.from(binary, (character) =>
363
+ character.charCodeAt(0),
364
+ );
365
+ // Web Push uses an uncompressed P-256 public key.
366
+ return bytes.byteLength === 65 && bytes[0] === 0x04 ? bytes : null;
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+
372
+ async function registerBrowserSubscription(
373
+ config: NormalizedBrowserPushConfig,
374
+ subscription: BrowserPushSubscriptionLike,
375
+ ): Promise<NotificationPusherRegistration> {
376
+ const registration = await registerNotificationPusher({
377
+ product: config.product,
378
+ ...(config.scope ? { scope: config.scope } : {}),
379
+ pusher: {
380
+ kind: "http",
381
+ app_id: config.appId,
382
+ app_display_name: config.appDisplayName,
383
+ pushkey: subscription.endpoint,
384
+ ...(config.lang ? { lang: config.lang } : {}),
385
+ data: {
386
+ url: config.gatewayUrl,
387
+ format: "event_id_only",
388
+ provider: "webpush",
389
+ ttl: 60,
390
+ urgency: "normal",
391
+ },
392
+ },
393
+ });
394
+ return registration;
395
+ }
396
+
397
+ type StoredBrowserPushBinding = {
398
+ readonly endpoint: string;
399
+ readonly serverOrigin: string;
400
+ readonly vapidPublicKey: string;
401
+ };
402
+
403
+ function browserPushBindingKey(
404
+ identity: Pick<BrowserNotificationPushConfig, "product" | "appId">,
405
+ ): string {
406
+ return `yurucommu.browser-push.v1.${identity.product}.${identity.appId}`;
407
+ }
408
+
409
+ function readBrowserPushBinding(
410
+ config: NormalizedBrowserPushConfig,
411
+ runtime: BrowserNotificationPushRuntime,
412
+ ): StoredBrowserPushBinding | null {
413
+ try {
414
+ const value = runtime.storage?.getItem(browserPushBindingKey(config));
415
+ if (!value || value.length > 4096) return null;
416
+ const parsed = JSON.parse(value) as Partial<StoredBrowserPushBinding>;
417
+ return typeof parsed.endpoint === "string" &&
418
+ typeof parsed.serverOrigin === "string" &&
419
+ typeof parsed.vapidPublicKey === "string"
420
+ ? {
421
+ endpoint: parsed.endpoint,
422
+ serverOrigin: parsed.serverOrigin,
423
+ vapidPublicKey: parsed.vapidPublicKey,
424
+ }
425
+ : null;
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
431
+ function storeBrowserPushBinding(
432
+ config: NormalizedBrowserPushConfig,
433
+ subscription: BrowserPushSubscriptionLike,
434
+ runtime: BrowserNotificationPushRuntime,
435
+ ): void {
436
+ try {
437
+ runtime.storage?.setItem(
438
+ browserPushBindingKey(config),
439
+ JSON.stringify({
440
+ endpoint: subscription.endpoint,
441
+ serverOrigin: config.serverOrigin,
442
+ vapidPublicKey: config.vapidPublicKey,
443
+ } satisfies StoredBrowserPushBinding),
444
+ );
445
+ } catch {
446
+ // Storage can be disabled independently of Push. The subscription's own
447
+ // applicationServerKey still protects key rotation in that case.
448
+ }
449
+ }
450
+
451
+ function clearBrowserPushBinding(
452
+ config: NormalizedBrowserPushConfig,
453
+ runtime: BrowserNotificationPushRuntime,
454
+ ): void {
455
+ clearBrowserPushBindingForIdentity(config, runtime);
456
+ }
457
+
458
+ function clearBrowserPushBindingForIdentity(
459
+ identity: Pick<BrowserNotificationPushConfig, "product" | "appId">,
460
+ runtime: BrowserNotificationPushRuntime,
461
+ ): void {
462
+ try {
463
+ runtime.storage?.removeItem(browserPushBindingKey(identity));
464
+ } catch {
465
+ // Best-effort metadata cleanup; the Push endpoint itself is authoritative.
466
+ }
467
+ }
468
+
469
+ function subscriptionMatchesConfig(
470
+ config: NormalizedBrowserPushConfig,
471
+ subscription: BrowserPushSubscriptionLike,
472
+ runtime: BrowserNotificationPushRuntime,
473
+ ): boolean {
474
+ const binding = readBrowserPushBinding(config, runtime);
475
+ if (
476
+ binding &&
477
+ (binding.endpoint !== subscription.endpoint ||
478
+ binding.serverOrigin !== config.serverOrigin ||
479
+ binding.vapidPublicKey !== config.vapidPublicKey)
480
+ ) {
481
+ return false;
482
+ }
483
+
484
+ const existingKey = subscription.options?.applicationServerKey;
485
+ if (!existingKey) return binding !== null;
486
+ return equalBufferSources(existingKey, config.applicationServerKey);
487
+ }
488
+
489
+ function equalBufferSources(left: BufferSource, right: BufferSource): boolean {
490
+ const leftBytes = bufferSourceBytes(left);
491
+ const rightBytes = bufferSourceBytes(right);
492
+ if (leftBytes.byteLength !== rightBytes.byteLength) return false;
493
+ for (let index = 0; index < leftBytes.byteLength; index += 1) {
494
+ if (leftBytes[index] !== rightBytes[index]) return false;
495
+ }
496
+ return true;
497
+ }
498
+
499
+ function bufferSourceBytes(value: BufferSource): Uint8Array {
500
+ return ArrayBuffer.isView(value)
501
+ ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
502
+ : new Uint8Array(value);
503
+ }
504
+
505
+ async function retireBrowserSubscription(
506
+ config: NormalizedBrowserPushConfig,
507
+ subscription: BrowserPushSubscriptionLike,
508
+ runtime: BrowserNotificationPushRuntime,
509
+ unregisterCurrentServer = false,
510
+ ): Promise<void> {
511
+ const binding = readBrowserPushBinding(config, runtime);
512
+ const canUnregisterCurrentServer =
513
+ unregisterCurrentServer ||
514
+ !binding ||
515
+ binding.serverOrigin === config.serverOrigin;
516
+ if (canUnregisterCurrentServer) {
517
+ try {
518
+ await unregisterNotificationPusher({
519
+ product: config.product,
520
+ ...(config.scope ? { scope: config.scope } : {}),
521
+ app_id: config.appId,
522
+ pushkey: subscription.endpoint,
523
+ });
524
+ } catch {
525
+ // Local invalidation below is the privacy boundary. A rejected delivery
526
+ // lets the old server remove a stale row when it becomes reachable.
527
+ }
528
+ }
529
+ try {
530
+ await subscription.unsubscribe();
531
+ } finally {
532
+ clearBrowserPushBinding(config, runtime);
533
+ }
534
+ }
@@ -1,3 +1,4 @@
1
+ import type { MediaAttachment } from "../../types/index.ts";
1
2
  import { normalizeActor } from "./normalize.ts";
2
3
  import {
3
4
  apiDelete,
@@ -47,9 +48,20 @@ export interface CommunityMessage {
47
48
  icon_url: string | null;
48
49
  };
49
50
  content: string;
51
+ /** Media attachments (image/video), same shape as post attachments. */
52
+ attachments?: MediaAttachment[];
50
53
  created_at: string;
51
54
  }
52
55
 
56
+ /**
57
+ * A member's chat read position (LOCAL-ONLY read receipt). Only local members
58
+ * that opened the chat appear; remote members never report read state.
59
+ */
60
+ export interface CommunityReadState {
61
+ actor_ap_id: string;
62
+ last_read_at: string;
63
+ }
64
+
53
65
  export interface JoinCommunityResult {
54
66
  status: "joined" | "pending" | "invite_required";
55
67
  }
@@ -160,7 +172,12 @@ export async function leaveCommunity(identifier: string): Promise<void> {
160
172
  export async function fetchCommunityMessages(
161
173
  identifier: string,
162
174
  options?: { limit?: number; before?: string },
163
- ): Promise<{ messages: CommunityMessage[]; hasMore: boolean }> {
175
+ ): Promise<{
176
+ messages: CommunityMessage[];
177
+ hasMore: boolean;
178
+ /** Per-member read positions (local members only; see CommunityReadState). */
179
+ readStates: CommunityReadState[];
180
+ }> {
164
181
  const params = new URLSearchParams();
165
182
  if (options?.limit) params.set("limit", String(options.limit));
166
183
  if (options?.before) params.set("before", options.before);
@@ -172,20 +189,26 @@ export async function fetchCommunityMessages(
172
189
  const data = (await res.json()) as {
173
190
  messages?: CommunityMessage[];
174
191
  has_more?: boolean;
192
+ read_states?: CommunityReadState[];
175
193
  };
176
194
  return {
177
195
  messages: (data.messages || []).map(normalizeCommunityMessage),
178
196
  hasMore: data.has_more ?? false,
197
+ readStates: data.read_states ?? [],
179
198
  };
180
199
  }
181
200
 
182
201
  export async function sendCommunityMessage(
183
202
  identifier: string,
184
203
  content: string,
204
+ attachments?: MediaAttachment[],
185
205
  ): Promise<CommunityMessage> {
186
206
  const res = await apiPost(
187
207
  `/api/communities/${encodeURIComponent(identifier)}/messages`,
188
- { content },
208
+ {
209
+ content,
210
+ ...(attachments && attachments.length > 0 ? { attachments } : {}),
211
+ },
189
212
  );
190
213
  await assertOk(res, "Failed to send message");
191
214
  const data = (await res.json()) as { message: CommunityMessage };
@@ -1,4 +1,4 @@
1
- import type { DMMessage } from "../../types/index.ts";
1
+ import type { DMMessage, MediaAttachment } from "../../types/index.ts";
2
2
  import { normalizeActor } from "./normalize.ts";
3
3
  import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
4
4
 
@@ -110,6 +110,12 @@ export async function fetchUserDMMessages(
110
110
  messages: DMMessage[];
111
111
  conversation_id: string | null;
112
112
  hasMore: boolean;
113
+ /**
114
+ * The partner's last-read time (LOCAL-ONLY read receipt), or null when
115
+ * unknown — a remote partner never reports read state, so null must render
116
+ * as "no receipt", not "unread".
117
+ */
118
+ partnerLastReadAt: string | null;
113
119
  }> {
114
120
  const params = new URLSearchParams();
115
121
  if (options?.limit) params.set("limit", String(options.limit));
@@ -122,21 +128,27 @@ export async function fetchUserDMMessages(
122
128
  messages?: DMMessage[];
123
129
  conversation_id?: string | null;
124
130
  has_more?: boolean;
131
+ partner_last_read_at?: string | null;
125
132
  };
126
133
  return {
127
134
  messages: (data.messages || []).map(normalizeDmMessage),
128
135
  conversation_id: data.conversation_id ?? null,
129
136
  hasMore: data.has_more ?? false,
137
+ partnerLastReadAt: data.partner_last_read_at ?? null,
130
138
  };
131
139
  }
132
140
 
133
141
  export async function sendUserDMMessage(
134
142
  userApId: string,
135
143
  content: string,
144
+ attachments?: MediaAttachment[],
136
145
  ): Promise<{ message: DMMessage; conversation_id: string }> {
137
146
  const res = await apiPost(
138
147
  `/api/dm/user/${encodeURIComponent(userApId)}/messages`,
139
- { content },
148
+ {
149
+ content,
150
+ ...(attachments && attachments.length > 0 ? { attachments } : {}),
151
+ },
140
152
  );
141
153
  await assertOk(res, "Failed to send message");
142
154
  const data = (await res.json()) as {
@@ -1,4 +1,9 @@
1
- import type { Notification } from "../../types/index.ts";
1
+ import type {
2
+ Notification,
3
+ NotificationPusherInput,
4
+ NotificationPusherProduct,
5
+ NotificationPusherRegistration,
6
+ } from "../../types/index.ts";
2
7
  import { normalizeNotification } from "./normalize.ts";
3
8
  import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
4
9
 
@@ -59,3 +64,50 @@ export async function archiveAllNotifications(): Promise<number> {
59
64
  const data = (await res.json()) as { archived_count?: number };
60
65
  return data.archived_count ?? 0;
61
66
  }
67
+
68
+ export async function registerNotificationPusher(input: {
69
+ product: NotificationPusherProduct;
70
+ scope?: string;
71
+ pusher: NotificationPusherInput;
72
+ }): Promise<NotificationPusherRegistration> {
73
+ const res = await apiPost("/api/notifications/pushers", input);
74
+ await assertOk(res, "Failed to register notification pusher");
75
+ const data = (await res.json()) as {
76
+ pusher: NotificationPusherRegistration;
77
+ };
78
+ return data.pusher;
79
+ }
80
+
81
+ export async function unregisterNotificationPusher(input: {
82
+ product: NotificationPusherProduct;
83
+ scope?: string;
84
+ app_id: string;
85
+ pushkey: string;
86
+ }): Promise<void> {
87
+ const res = await apiDelete("/api/notifications/pushers", input);
88
+ await assertOk(res, "Failed to unregister notification pusher");
89
+ }
90
+
91
+ export interface NotificationPusherPublicConfig {
92
+ readonly enabled: boolean;
93
+ readonly gateway_url: string | null;
94
+ readonly web_push_public_key: string | null;
95
+ }
96
+
97
+ /** Non-secret runtime configuration used by browser/PWA clients. */
98
+ export async function fetchNotificationPusherPublicConfig(): Promise<NotificationPusherPublicConfig> {
99
+ const res = await apiFetch("/api/notifications/pushers/config");
100
+ await assertOk(res, "Failed to load notification pusher configuration");
101
+ const value = (await res.json()) as Partial<NotificationPusherPublicConfig>;
102
+ const gatewayUrl =
103
+ typeof value.gateway_url === "string" ? value.gateway_url : null;
104
+ const publicKey =
105
+ typeof value.web_push_public_key === "string"
106
+ ? value.web_push_public_key
107
+ : null;
108
+ return {
109
+ enabled: Boolean(gatewayUrl && publicKey),
110
+ gateway_url: gatewayUrl,
111
+ web_push_public_key: publicKey,
112
+ };
113
+ }