@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 +1 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +1 -0
- package/packages/api/src/lib/rtc-client.ts +1 -3
- package/packages/api/src/types/call.ts +2 -10
- package/packages/api/src/types/index.ts +3 -0
- package/packages/api/src/types/realtime.ts +139 -0
- package/src/backend/index.ts +11 -0
- package/src/backend/lib/delivery/queue.ts +4 -0
- package/src/backend/lib/unread-counts.ts +63 -2
- package/src/backend/public.ts +3 -0
- package/src/backend/routes/communities/messages.ts +53 -17
- package/src/backend/routes/dm/messages.ts +47 -7
- package/src/backend/routes/dm/read-archive.ts +27 -0
- package/src/backend/routes/dm/typing.ts +16 -0
- package/src/backend/routes/notifications.ts +25 -0
- package/src/backend/routes/realtime/index.ts +67 -0
- package/src/backend/routes/rtc/index.ts +5 -1
- package/src/backend/runtime/call-hub-core.ts +13 -3
- package/src/backend/runtime/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/types.ts +4 -0
package/package.json
CHANGED
|
@@ -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
|
-
| "
|
|
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
|
+
}
|
package/src/backend/index.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/backend/public.ts
CHANGED
|
@@ -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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
|