@takosjp/yurucommu-core 3.3.0 → 3.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
1
  export * from "./lib/api.ts";
2
2
  export * from "./lib/transport.ts";
3
3
  export * from "./lib/rtc-client.ts";
4
+ export * from "./lib/realtime-client.ts";
4
5
  export * from "./social-server.ts";
5
6
  export * from "./types/index.ts";
@@ -313,9 +313,7 @@ export class CallClient {
313
313
  }
314
314
  }
315
315
 
316
- private onRinging(
317
- frame: Extract<HubToClientFrame, { t: "ringing" }>,
318
- ): void {
316
+ private onRinging(frame: Extract<HubToClientFrame, { t: "ringing" }>): void {
319
317
  if (this.call) {
320
318
  // Already busy — auto-decline the second ring.
321
319
  this.send({ t: "reject", callId: frame.callId, reason: "busy" });
@@ -27,13 +27,7 @@ export interface CallMediaKind {
27
27
 
28
28
  /** Cross-instance signaling message kinds (Matrix-VoIP inspired). */
29
29
  export type RtcSignalType =
30
- | "offer"
31
- | "answer"
32
- | "candidate"
33
- | "accept"
34
- | "reject"
35
- | "hangup"
36
- | "cancel";
30
+ "offer" | "answer" | "candidate" | "accept" | "reject" | "hangup" | "cancel";
37
31
 
38
32
  /**
39
33
  * Selected SFU focus for a group call. `null`/absent means pure P2P (1:1).
@@ -286,9 +280,7 @@ export function parseRtcSignalEnvelope(
286
280
  candidates: parseCandidates(input.candidates),
287
281
  sfuFocus: parseSfuFocus(input.sfuFocus),
288
282
  reason:
289
- typeof input.reason === "string"
290
- ? input.reason.slice(0, 200)
291
- : undefined,
283
+ typeof input.reason === "string" ? input.reason.slice(0, 200) : undefined,
292
284
  ts,
293
285
  ttlMs,
294
286
  };
@@ -1,6 +1,9 @@
1
1
  // Call signaling wire contract + browser<->hub frames (voice + video).
2
2
  export * from "./call.ts";
3
3
 
4
+ // Realtime stream wire contract (per-user event feed + control frames).
5
+ export * from "./realtime.ts";
6
+
4
7
  // ===== Yurucommu AP-Native Types =====
5
8
 
6
9
  // Actor represents a user (Person) in ActivityPub
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Realtime stream wire contract (browser <-> per-user RealtimeStreamDO).
3
+ *
4
+ * One authenticated WebSocket per user carries every live update the client
5
+ * used to poll for: talk messages, typing, read receipts, contact-list
6
+ * changes, new notifications, and the authoritative unread counters. The
7
+ * server pushes `RealtimeEvent` envelopes; the client sends only the small
8
+ * control frames below (writes stay on the REST API).
9
+ *
10
+ * Event ids are a per-user monotonic sequence assigned by the Durable Object.
11
+ * A reconnecting client offers its last seen id in `hello`; the DO replays the
12
+ * gap from its ring buffer, or answers `resync` when the gap is older than the
13
+ * buffer so the client re-fetches via the normal REST reads.
14
+ */
15
+
16
+ export type RealtimeEventType =
17
+ | "talk.message"
18
+ | "talk.typing"
19
+ | "talk.read"
20
+ | "talk.contacts_changed"
21
+ | "notification.new"
22
+ | "unread";
23
+
24
+ export interface RealtimeEvent {
25
+ /** Per-user monotonic sequence number (assigned by the stream DO). */
26
+ id: number;
27
+ type: RealtimeEventType;
28
+ data: Record<string, unknown>;
29
+ }
30
+
31
+ /** `talk.message` payload. `other_ap_id` is from the RECEIVING user's view. */
32
+ export interface TalkMessageEventData {
33
+ kind: "dm" | "community";
34
+ /** DM: the counterpart actor (per-recipient). */
35
+ other_ap_id?: string;
36
+ /** Community chat: the community actor. */
37
+ community_ap_id?: string;
38
+ conversation_id?: string;
39
+ message: {
40
+ id: string;
41
+ sender: {
42
+ ap_id: string;
43
+ username: string;
44
+ preferred_username: string | null;
45
+ name: string | null;
46
+ icon_url: string | null;
47
+ };
48
+ content: string | null;
49
+ attachments?: unknown[];
50
+ created_at: string | null;
51
+ };
52
+ }
53
+
54
+ export interface TalkTypingEventData {
55
+ other_ap_id: string;
56
+ is_typing: boolean;
57
+ typed_at: string;
58
+ }
59
+
60
+ export interface TalkReadEventData {
61
+ other_ap_id: string;
62
+ conversation_id: string;
63
+ last_read_at: string;
64
+ }
65
+
66
+ /** Authoritative unread counters (server-computed; never client-derived). */
67
+ export interface UnreadEventData {
68
+ dm: number;
69
+ community: number;
70
+ talk_total: number;
71
+ notifications: number;
72
+ }
73
+
74
+ // --- Client -> server frames -------------------------------------------------
75
+
76
+ export type RealtimeClientFrame =
77
+ { t: "hello"; lastEventId?: number } | { t: "ping" } | { t: "pong" };
78
+
79
+ // --- Server -> client frames -------------------------------------------------
80
+
81
+ export type RealtimeServerFrame =
82
+ | { t: "hello_ok"; lastEventId: number }
83
+ | { t: "event"; event: RealtimeEvent }
84
+ /** The requested replay gap is older than the buffer: re-fetch via REST. */
85
+ | { t: "resync" }
86
+ | { t: "ping" }
87
+ | { t: "pong" };
88
+
89
+ export function parseRealtimeClientFrame(
90
+ raw: unknown,
91
+ ): RealtimeClientFrame | null {
92
+ if (!raw || typeof raw !== "object") return null;
93
+ const frame = raw as { t?: unknown; lastEventId?: unknown };
94
+ if (frame.t === "ping" || frame.t === "pong") return { t: frame.t };
95
+ if (frame.t === "hello") {
96
+ const lastEventId =
97
+ typeof frame.lastEventId === "number" &&
98
+ Number.isFinite(frame.lastEventId) &&
99
+ frame.lastEventId >= 0
100
+ ? Math.floor(frame.lastEventId)
101
+ : undefined;
102
+ return { t: "hello", lastEventId };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ export function parseRealtimeServerFrame(
108
+ raw: unknown,
109
+ ): RealtimeServerFrame | null {
110
+ if (!raw || typeof raw !== "object") return null;
111
+ const frame = raw as { t?: unknown; event?: unknown; lastEventId?: unknown };
112
+ if (frame.t === "ping" || frame.t === "pong" || frame.t === "resync") {
113
+ return { t: frame.t };
114
+ }
115
+ if (frame.t === "hello_ok" && typeof frame.lastEventId === "number") {
116
+ return { t: "hello_ok", lastEventId: frame.lastEventId };
117
+ }
118
+ if (frame.t === "event" && frame.event && typeof frame.event === "object") {
119
+ const event = frame.event as {
120
+ id?: unknown;
121
+ type?: unknown;
122
+ data?: unknown;
123
+ };
124
+ if (typeof event.id === "number" && typeof event.type === "string") {
125
+ return {
126
+ t: "event",
127
+ event: {
128
+ id: event.id,
129
+ type: event.type as RealtimeEventType,
130
+ data:
131
+ event.data && typeof event.data === "object"
132
+ ? (event.data as Record<string, unknown>)
133
+ : {},
134
+ },
135
+ };
136
+ }
137
+ }
138
+ return null;
139
+ }
@@ -30,6 +30,8 @@ import { appsApiRoutes, appsServeRoutes } from "./routes/apps.ts";
30
30
  import mobileRoutes from "./routes/mobile.ts";
31
31
  import notificationPusherRoutes from "./routes/notification-pushers.ts";
32
32
  import rtcRoutes from "./routes/rtc/index.ts";
33
+ import realtimeRoutes from "./routes/realtime/index.ts";
34
+ import { sweepRealtimeNotifications } from "./runtime/realtime-hub.ts";
33
35
 
34
36
  import { rateLimit, RateLimitConfigs } from "./middleware/rate-limit.ts";
35
37
  import { csrfProtection } from "./middleware/csrf.ts";
@@ -600,6 +602,9 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
600
602
  error,
601
603
  });
602
604
  }
605
+ // Same choke point feeds the realtime stream: the push-jobs the inbox
606
+ // trigger wrote tell us exactly which users gained a notification.
607
+ await sweepRealtimeNotifications(c.env);
603
608
  })();
604
609
  // Prefer to run the sweep AFTER the response is sent (waitUntil) so its
605
610
  // 2-4 D1 round-trips never add latency to the request. `executionCtx`
@@ -780,6 +785,8 @@ function mountCoreRoutes(app: YurucommuApp): void {
780
785
  app.route("/api/moderation", moderationRoutes);
781
786
  app.route("/api/apps", appsApiRoutes);
782
787
  app.route("/hosted", appsServeRoutes);
788
+ // Realtime stream: capability probe + WS ticket + per-user socket upgrade.
789
+ app.route("/api/realtime", realtimeRoutes);
783
790
  // Call feature: /api/rtc/* (session) + /ap/rtc/signal (server-to-server).
784
791
  app.route("/", rtcRoutes);
785
792
  app.route("/", activitypubRoutes);
@@ -953,6 +960,10 @@ type WorkerBindings = EnvVars & {
953
960
  // spreads it through untouched (it is not DB/MEDIA/KV/ASSETS) so app code and
954
961
  // the rtc routes read it as c.env.CALL_SIGNALING.
955
962
  CALL_SIGNALING?: DurableObjectNamespace;
963
+ // Per-user realtime event stream Durable Object namespace. Same pass-through
964
+ // as CALL_SIGNALING; optional — when unbound the realtime routes answer 503
965
+ // and clients fall back to polling.
966
+ REALTIME_STREAM?: DurableObjectNamespace;
956
967
  };
957
968
 
958
969
  export default {
@@ -531,6 +531,10 @@ export async function handleDeliveryQueueBatch(
531
531
  error,
532
532
  });
533
533
  }
534
+ // Same choke point feeds the realtime stream (federated + fanout inserts).
535
+ const { sweepRealtimeNotifications } =
536
+ await import("../../runtime/realtime-hub.ts");
537
+ await sweepRealtimeNotifications(env);
534
538
  }
535
539
 
536
540
  export async function handleDeliveryDlqBatch(
@@ -17,8 +17,14 @@
17
17
  * own, after the later of the per-community read time and the join time.
18
18
  */
19
19
 
20
- import { sql } from "drizzle-orm";
21
- import type { Database } from "../../db/index.ts";
20
+ import { and, count, eq, sql } from "drizzle-orm";
21
+ import {
22
+ activities,
23
+ inbox as inboxTable,
24
+ objects,
25
+ type Database,
26
+ } from "../../db/index.ts";
27
+ import { notificationEligibilityWhere } from "./notification-eligibility.ts";
22
28
 
23
29
  export interface YurumeUnreadCounts {
24
30
  readonly dm: number;
@@ -77,3 +83,58 @@ export async function yurumeUnreadCounts(
77
83
  const community = Number(communityRow?.c ?? 0);
78
84
  return { dm, community, total: dm + community };
79
85
  }
86
+
87
+ /**
88
+ * Unread social-notification count. SAME shared eligibility builder as the
89
+ * notifications list, the badge endpoint, and push delivery (not-self,
90
+ * user-facing types, archive exclusion, direct-DM exclusion, block/mute
91
+ * suppression) so a realtime-pushed badge can never drift from the badge the
92
+ * client fetches.
93
+ */
94
+ export async function notificationUnreadCount(
95
+ db: Database,
96
+ actorApId: string,
97
+ ): Promise<number> {
98
+ const result = await db
99
+ .select({ count: count() })
100
+ .from(inboxTable)
101
+ .innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
102
+ .leftJoin(objects, eq(activities.objectApId, objects.apId))
103
+ .where(
104
+ and(
105
+ eq(inboxTable.actorApId, actorApId),
106
+ eq(inboxTable.read, 0),
107
+ ...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
108
+ ),
109
+ )
110
+ .get();
111
+ return Number(result?.count ?? 0);
112
+ }
113
+
114
+ export interface UnreadSnapshot {
115
+ readonly dm: number;
116
+ readonly community: number;
117
+ readonly talkTotal: number;
118
+ readonly notifications: number;
119
+ }
120
+
121
+ /**
122
+ * One authoritative unread snapshot (talk + notifications) for the realtime
123
+ * `unread` event. Server-computed on every emit so clients never derive or
124
+ * increment counters themselves.
125
+ */
126
+ export async function computeUnreadSnapshot(
127
+ db: Database,
128
+ actorApId: string,
129
+ ): Promise<UnreadSnapshot> {
130
+ const [talk, notifications] = await Promise.all([
131
+ yurumeUnreadCounts(db, actorApId),
132
+ notificationUnreadCount(db, actorApId),
133
+ ]);
134
+ return {
135
+ dm: talk.dm,
136
+ community: talk.community,
137
+ talkTotal: talk.total,
138
+ notifications,
139
+ };
140
+ }
@@ -16,6 +16,9 @@ export { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
16
16
  // Call feature: the signaling Durable Object class each product's generated
17
17
  // worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
18
18
  export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
19
+ // Realtime stream: the per-user fanout Durable Object class each product's
20
+ // generated worker entry must re-export so Wrangler can bind REALTIME_STREAM.
21
+ export { RealtimeStreamDO } from "./runtime/realtime-stream-do.ts";
19
22
  export type { Env, EnvVars } from "./types.ts";
20
23
  export type {
21
24
  DeliveryDlqMessageV1,
@@ -23,6 +23,12 @@ import {
23
23
  } from "../../lib/attachments.ts";
24
24
  import { communityRequiresMembership } from "../../lib/community-visibility.ts";
25
25
  import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
26
+ import {
27
+ emitRealtimeBestEffort,
28
+ emitUnreadSnapshot,
29
+ isRealtimeAvailable,
30
+ runRealtimeAfterResponse,
31
+ } from "../../runtime/realtime-hub.ts";
26
32
  import {
27
33
  deleteObjectCascade,
28
34
  purgeMediaBlobs,
@@ -408,24 +414,54 @@ messagesRouter.post(
408
414
  ...pushJobStatements,
409
415
  ]);
410
416
 
411
- return c.json(
412
- {
413
- message: {
414
- id: objectApId,
415
- sender: {
416
- ap_id: actor.ap_id,
417
- username: formatUsername(actor.ap_id),
418
- preferred_username: actor.preferred_username,
419
- name: actor.name,
420
- icon_url: actor.icon_url,
421
- },
422
- content,
423
- attachments,
424
- created_at: now,
425
- },
417
+ const messagePayload = {
418
+ id: objectApId,
419
+ sender: {
420
+ ap_id: actor.ap_id,
421
+ username: formatUsername(actor.ap_id),
422
+ preferred_username: actor.preferred_username,
423
+ name: actor.name,
424
+ icon_url: actor.icon_url,
426
425
  },
427
- 201,
428
- );
426
+ content,
427
+ attachments,
428
+ created_at: now,
429
+ };
430
+
431
+ // Realtime fanout to LOCAL members (best-effort, after the response).
432
+ // Community talk has no inbox row, so the shared notification sweep never
433
+ // sees it — this direct emit is the only realtime path. Membership rows
434
+ // exist only for local members (remote membership is a follows edge), and
435
+ // the fanout is capped so a huge community cannot stall the writer.
436
+ if (isRealtimeAvailable(c.env)) {
437
+ const communityApIdForEmit = community.apId;
438
+ await runRealtimeAfterResponse(c, async () => {
439
+ const members = await db
440
+ .select({ actorApId: communityMembers.actorApId })
441
+ .from(communityMembers)
442
+ .where(eq(communityMembers.communityApId, communityApIdForEmit))
443
+ .limit(200);
444
+ await emitRealtimeBestEffort(
445
+ c.env,
446
+ members.map(({ actorApId }) => ({
447
+ actorApId,
448
+ type: "talk.message",
449
+ data: {
450
+ kind: "community",
451
+ community_ap_id: communityApIdForEmit,
452
+ message: messagePayload,
453
+ },
454
+ })),
455
+ );
456
+ await Promise.all(
457
+ members
458
+ .filter(({ actorApId }) => actorApId !== actor.ap_id)
459
+ .map(({ actorApId }) => emitUnreadSnapshot(c.env, actorApId)),
460
+ );
461
+ });
462
+ }
463
+
464
+ return c.json({ message: messagePayload }, 201);
429
465
  },
430
466
  );
431
467
 
@@ -35,6 +35,10 @@ import {
35
35
  resolveConversationId,
36
36
  } from "./query-helpers.ts";
37
37
  import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
38
+ import {
39
+ emitRealtimeBestEffort,
40
+ runRealtimeAfterResponse,
41
+ } from "../../runtime/realtime-hub.ts";
38
42
  import { feedCursorWhere } from "../../lib/feed-cursor.ts";
39
43
  import { toApAttachments } from "../../lib/activitypub-helpers.ts";
40
44
  import { validateChatAttachments } from "../../lib/attachments.ts";
@@ -600,15 +604,51 @@ dm.post("/user/:encodedApId/messages", async (c) => {
600
604
  await enqueueDeliveryToActor(c.env, deliveryActivityId, otherApId);
601
605
  }
602
606
 
607
+ const messagePayload = {
608
+ id: apId,
609
+ sender: buildSenderFromActor(actor),
610
+ content,
611
+ attachments,
612
+ created_at: now,
613
+ };
614
+
615
+ // Realtime fanout (best-effort, after the response): the recipient's open
616
+ // thread gets the message body without polling; the sender's OTHER tabs and
617
+ // devices stay in sync too. `other_ap_id` is per-recipient (each side sees
618
+ // the counterpart). Unread counters flow via the shared post-response sweep
619
+ // (the inbox trigger wrote a push job for the local recipient).
620
+ await runRealtimeAfterResponse(c, () =>
621
+ emitRealtimeBestEffort(c.env, [
622
+ ...(isRecipientLocal
623
+ ? [
624
+ {
625
+ actorApId: otherApId,
626
+ type: "talk.message",
627
+ data: {
628
+ kind: "dm",
629
+ other_ap_id: actor.ap_id,
630
+ conversation_id: conversationId,
631
+ message: messagePayload,
632
+ },
633
+ },
634
+ ]
635
+ : []),
636
+ {
637
+ actorApId: actor.ap_id,
638
+ type: "talk.message",
639
+ data: {
640
+ kind: "dm",
641
+ other_ap_id: otherApId,
642
+ conversation_id: conversationId,
643
+ message: messagePayload,
644
+ },
645
+ },
646
+ ]),
647
+ );
648
+
603
649
  return c.json(
604
650
  {
605
- message: {
606
- id: apId,
607
- sender: buildSenderFromActor(actor),
608
- content,
609
- attachments,
610
- created_at: now,
611
- },
651
+ message: messagePayload,
612
652
  conversation_id: conversationId,
613
653
  },
614
654
  201,
@@ -11,6 +11,11 @@ import {
11
11
  objects,
12
12
  } from "../../../db/index.ts";
13
13
  import { resolveConversationId } from "./query-helpers.ts";
14
+ import {
15
+ emitRealtimeBestEffort,
16
+ emitUnreadSnapshot,
17
+ runRealtimeAfterResponse,
18
+ } from "../../runtime/realtime-hub.ts";
14
19
  import {
15
20
  buildActorInfoMap,
16
21
  byTimeDesc,
@@ -54,6 +59,23 @@ readArchive.post("/user/:encodedApId/read", async (c) => {
54
59
  set: { lastReadAt: now },
55
60
  });
56
61
 
62
+ // Live read receipt for the partner's open thread + refreshed unread badge
63
+ // for the reader's OTHER tabs/devices.
64
+ await runRealtimeAfterResponse(c, async () => {
65
+ await emitRealtimeBestEffort(c.env, [
66
+ {
67
+ actorApId: otherApId,
68
+ type: "talk.read",
69
+ data: {
70
+ other_ap_id: actor.ap_id,
71
+ conversation_id: conversationId,
72
+ last_read_at: now,
73
+ },
74
+ },
75
+ ]);
76
+ await emitUnreadSnapshot(c.env, actor.ap_id);
77
+ });
78
+
57
79
  return c.json({ success: true, last_read_at: now });
58
80
  });
59
81
 
@@ -108,6 +130,11 @@ readArchive.post("/community/:encodedApId/read", async (c) => {
108
130
  set: { lastReadAt: now },
109
131
  });
110
132
 
133
+ // Refresh the reader's unread badge on their other tabs/devices.
134
+ await runRealtimeAfterResponse(c, () =>
135
+ emitUnreadSnapshot(c.env, actor.ap_id),
136
+ );
137
+
111
138
  return c.json({ success: true, last_read_at: now });
112
139
  });
113
140
 
@@ -3,6 +3,10 @@
3
3
  import { Hono } from "hono";
4
4
  import { and, eq } from "drizzle-orm";
5
5
  import { dmTyping } from "../../../db/index.ts";
6
+ import {
7
+ emitRealtimeBestEffort,
8
+ runRealtimeAfterResponse,
9
+ } from "../../runtime/realtime-hub.ts";
6
10
  import { type HonoEnv, parseOtherApId } from "./conversations-helpers.ts";
7
11
 
8
12
  const typing = new Hono<HonoEnv>();
@@ -28,6 +32,18 @@ typing.post("/user/:encodedApId/typing", async (c) => {
28
32
  set: { lastTypedAt: now },
29
33
  });
30
34
 
35
+ // Push the indicator to the partner's live sockets; the GET endpoint stays
36
+ // as the fallback-polling read.
37
+ await runRealtimeAfterResponse(c, () =>
38
+ emitRealtimeBestEffort(c.env, [
39
+ {
40
+ actorApId: otherApId,
41
+ type: "talk.typing",
42
+ data: { other_ap_id: actor.ap_id, is_typing: true, typed_at: now },
43
+ },
44
+ ]),
45
+ );
46
+
31
47
  return c.json({ success: true, typed_at: now });
32
48
  });
33
49
 
@@ -35,6 +35,10 @@ import {
35
35
  NOTIFICATION_ACTIVITY_TYPES,
36
36
  notificationEligibilityWhere,
37
37
  } from "../lib/notification-eligibility.ts";
38
+ import {
39
+ emitUnreadSnapshot,
40
+ runRealtimeAfterResponse,
41
+ } from "../runtime/realtime-hub.ts";
38
42
 
39
43
  const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
40
44
 
@@ -622,6 +626,11 @@ notifications.post("/read", async (c) => {
622
626
  );
623
627
  }
624
628
 
629
+ // Sync the reader's OTHER tabs/devices: push the fresh authoritative badge.
630
+ await runRealtimeAfterResponse(c, () =>
631
+ emitUnreadSnapshot(c.env, actor.ap_id),
632
+ );
633
+
625
634
  return c.json({ success: true });
626
635
  });
627
636
 
@@ -681,6 +690,11 @@ notifications.post("/archive", async (c) => {
681
690
  ARCHIVE_CREATE_BATCH_SIZE,
682
691
  );
683
692
 
693
+ // Archiving an unread notification removes it from the badge count.
694
+ await runRealtimeAfterResponse(c, () =>
695
+ emitUnreadSnapshot(c.env, actor.ap_id),
696
+ );
697
+
684
698
  return c.json({ success: true, archived_count });
685
699
  });
686
700
 
@@ -716,6 +730,11 @@ notifications.delete("/archive", async (c) => {
716
730
  ),
717
731
  );
718
732
 
733
+ // Unarchiving can resurface unread rows into the badge count.
734
+ await runRealtimeAfterResponse(c, () =>
735
+ emitUnreadSnapshot(c.env, actor.ap_id),
736
+ );
737
+
719
738
  return c.json({ success: true });
720
739
  });
721
740
 
@@ -758,6 +777,12 @@ notifications.post("/archive/all", async (c) => {
758
777
  rows,
759
778
  ARCHIVE_CREATE_BATCH_SIZE,
760
779
  );
780
+
781
+ // Archive-all clears the whole badge; sync the reader's other tabs/devices.
782
+ await runRealtimeAfterResponse(c, () =>
783
+ emitUnreadSnapshot(c.env, actor.ap_id),
784
+ );
785
+
761
786
  return c.json({ success: true, archived_count });
762
787
  });
763
788