@takosjp/yurucommu-core 3.0.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.
Files changed (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,525 @@
1
+ // GET /contacts - List DM contacts and communities
2
+
3
+ import { Hono } from "hono";
4
+ import {
5
+ and,
6
+ count,
7
+ desc,
8
+ eq,
9
+ inArray,
10
+ isNotNull,
11
+ isNull,
12
+ ne,
13
+ or,
14
+ sql,
15
+ } from "drizzle-orm";
16
+ import {
17
+ communities,
18
+ communityMembers,
19
+ dmArchivedConversations,
20
+ dmReadStatus,
21
+ objectRecipients,
22
+ objects,
23
+ } from "../../../db/index.ts";
24
+ import { formatUsername } from "../../federation-helpers.ts";
25
+ import { chunkForInClause } from "../../lib/chunk.ts";
26
+ import {
27
+ buildActorInfoMap,
28
+ byTimeDesc,
29
+ findRepliedConversations,
30
+ formatActorProfile,
31
+ groupConversations,
32
+ type HonoEnv,
33
+ parseOtherApId,
34
+ uniqueValues,
35
+ } from "./conversations-helpers.ts";
36
+
37
+ const contacts = new Hono<HonoEnv>();
38
+
39
+ contacts.get("/contacts", async (c) => {
40
+ const actor = c.get("actor");
41
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
42
+ const db = c.get("db");
43
+
44
+ // DMs where this actor is the recipient are addressed via the
45
+ // `object_recipients` index (`recipient_ap_id = <actor>`, `type = 'to'`),
46
+ // written on every inbound/outbound DM whose recipient is this local actor
47
+ // (see dm/messages.ts, takos-tools/dm.ts, inbox-content-handlers.ts). Using
48
+ // that index instead of an unindexable `to_json LIKE '%"<apId>"%'` substring
49
+ // scan keeps the same membership semantics (recipient OR author) without a
50
+ // full-table scan.
51
+ const recipientObjectIds = db
52
+ .select({ objectApId: objectRecipients.objectApId })
53
+ .from(objectRecipients)
54
+ .where(
55
+ and(
56
+ eq(objectRecipients.recipientApId, actor.ap_id),
57
+ eq(objectRecipients.type, "to"),
58
+ ),
59
+ );
60
+ const dmWhere = and(
61
+ eq(objects.visibility, "direct"),
62
+ eq(objects.type, "Note"),
63
+ isNotNull(objects.conversation),
64
+ or(
65
+ eq(objects.attributedTo, actor.ap_id),
66
+ inArray(objects.apId, recipientObjectIds),
67
+ ),
68
+ );
69
+
70
+ // GET must be side-effect-free: this handler no longer prunes orphaned
71
+ // dm_read_status rows. Read-status cleanup happens on the write paths that
72
+ // delete conversations/messages, not on this read.
73
+
74
+ // Get archived conversation IDs to exclude
75
+ const archivedConversations = await db
76
+ .select({
77
+ conversationId: dmArchivedConversations.conversationId,
78
+ })
79
+ .from(dmArchivedConversations)
80
+ .where(eq(dmArchivedConversations.actorApId, actor.ap_id));
81
+ const archivedSet = new Set(
82
+ archivedConversations.map((a) => a.conversationId),
83
+ );
84
+
85
+ // Get the latest message per conversation. SQLite's bare-column + single
86
+ // MAX() rule fills the non-aggregated columns (attributedTo, toJson, content)
87
+ // from the row holding MAX(published), so this returns exactly one row per
88
+ // conversation = its newest message. That makes the 2000-row cap bound
89
+ // CONVERSATIONS, not messages — previously a few very active threads could
90
+ // fill the 2000 most-recent message rows and silently drop older-but-active
91
+ // conversations from the contact list. We now keep the 2000 most recently
92
+ // active conversations instead.
93
+ const dmObjects = await db
94
+ .select({
95
+ conversation: objects.conversation,
96
+ attributedTo: objects.attributedTo,
97
+ toJson: objects.toJson,
98
+ content: objects.content,
99
+ published: sql<string>`MAX(${objects.published})`,
100
+ })
101
+ .from(objects)
102
+ .where(dmWhere)
103
+ .groupBy(objects.conversation)
104
+ .orderBy(desc(sql`MAX(${objects.published})`))
105
+ .limit(2000);
106
+
107
+ const conversationMap = groupConversations(
108
+ dmObjects,
109
+ actor.ap_id,
110
+ (id) => !archivedSet.has(id),
111
+ );
112
+
113
+ // Get read status for all conversations
114
+ const readStatuses = await db
115
+ .select()
116
+ .from(dmReadStatus)
117
+ .where(eq(dmReadStatus.actorApId, actor.ap_id));
118
+ const readStatusMap = new Map(
119
+ readStatuses.map((r) => [r.conversationId, r.lastReadAt]),
120
+ );
121
+
122
+ // Calculate unread counts for each conversation using batch query
123
+ const conversationIds = Array.from(conversationMap.keys());
124
+ const unreadCounts = new Map<string, number>();
125
+
126
+ if (conversationIds.length > 0) {
127
+ // Group conversations by their lastReadAt time for efficient querying
128
+ const lastReadAtMap = new Map<string, string[]>();
129
+ for (const convId of conversationIds) {
130
+ const lastReadAt = readStatusMap.get(convId) || "1970-01-01T00:00:00Z";
131
+ const convIds = lastReadAtMap.get(lastReadAt) || [];
132
+ convIds.push(convId);
133
+ lastReadAtMap.set(lastReadAt, convIds);
134
+ }
135
+
136
+ await Promise.all(
137
+ Array.from(lastReadAtMap.entries()).map(async ([lastReadAt, convIds]) => {
138
+ // Chunk the IN(...) lookup: the contact list keeps up to 2000
139
+ // conversations and a single lastReadAt bucket can hold all of them,
140
+ // while D1 caps a query at 100 bound parameters. Each conversation is in
141
+ // exactly one bucket and one chunk, so the per-chunk count is complete
142
+ // and `.set` is collision-free (no summing across chunks).
143
+ for (const chunk of chunkForInClause(convIds)) {
144
+ const unreadMessages = await db
145
+ .select({
146
+ conversation: objects.conversation,
147
+ count: count(),
148
+ })
149
+ .from(objects)
150
+ .where(
151
+ and(
152
+ inArray(objects.conversation, chunk),
153
+ eq(objects.visibility, "direct"),
154
+ ne(objects.attributedTo, actor.ap_id),
155
+ sql`${objects.published} > ${lastReadAt}`,
156
+ ),
157
+ )
158
+ .groupBy(objects.conversation);
159
+
160
+ for (const msg of unreadMessages) {
161
+ if (msg.conversation) {
162
+ unreadCounts.set(msg.conversation, msg.count);
163
+ }
164
+ }
165
+ }
166
+ }),
167
+ );
168
+ }
169
+
170
+ const otherApIds = uniqueValues(conversationMap, (c) => c.otherApId);
171
+ const actorInfoMap = await buildActorInfoMap(db, otherApIds);
172
+
173
+ const contactsResult = Array.from(conversationMap.values())
174
+ .map((conv) => ({
175
+ type: "user" as const,
176
+ ...formatActorProfile(conv.otherApId, actorInfoMap.get(conv.otherApId)),
177
+ conversation_id: conv.conversation,
178
+ last_message: conv.lastContent
179
+ ? {
180
+ content: conv.lastContent,
181
+ is_mine: conv.lastSender === actor.ap_id,
182
+ }
183
+ : null,
184
+ last_message_at: conv.lastMessageAt,
185
+ unread_count: unreadCounts.get(conv.conversation) || 0,
186
+ }))
187
+ .sort((a, b) => byTimeDesc(a.last_message_at, b.last_message_at));
188
+
189
+ // Get communities the user is a member of (for group chat)
190
+ const communityMemberships = await db
191
+ .select({
192
+ communityApId: communityMembers.communityApId,
193
+ joinedAt: communityMembers.joinedAt,
194
+ community: {
195
+ apId: communities.apId,
196
+ preferredUsername: communities.preferredUsername,
197
+ name: communities.name,
198
+ iconUrl: communities.iconUrl,
199
+ memberCount: communities.memberCount,
200
+ },
201
+ })
202
+ .from(communityMembers)
203
+ .innerJoin(
204
+ communities,
205
+ eq(communityMembers.communityApId, communities.apId),
206
+ )
207
+ .where(eq(communityMembers.actorApId, actor.ap_id));
208
+
209
+ // Batch get the last CHAT message for all communities to avoid N+1.
210
+ //
211
+ // The community contact's preview must reflect the last group-CHAT message,
212
+ // matching the (chat-vs-feed–separated) unread count below and the chat
213
+ // reader in communities/messages.ts. Chat messages are addressed via the
214
+ // object_recipients audience link and leave `communityApId` NULL, whereas
215
+ // feed posts carry `communityApId`. Querying the audience-linked,
216
+ // `communityApId IS NULL` object-set keeps the preview a chat message and not
217
+ // the last feed post. Communities with no chat message yet get no preview.
218
+ const communityApIds = communityMemberships.map((cm) => cm.community.apId);
219
+ const lastMessagesMap = new Map<
220
+ string,
221
+ { content: string; attributedTo: string; published: string }
222
+ >();
223
+
224
+ if (communityApIds.length > 0) {
225
+ const recentMessages = await db
226
+ .select({
227
+ communityApId: objectRecipients.recipientApId,
228
+ content: objects.content,
229
+ attributedTo: objects.attributedTo,
230
+ published: objects.published,
231
+ })
232
+ .from(objects)
233
+ .innerJoin(
234
+ objectRecipients,
235
+ eq(objectRecipients.objectApId, objects.apId),
236
+ )
237
+ .where(
238
+ and(
239
+ // Subquery, not `inArray(communityApIds)`: a user in >~100 communities
240
+ // would exceed D1's 100-bound-parameter limit. Same set as
241
+ // communityApIds (the viewer's joined communities).
242
+ inArray(
243
+ objectRecipients.recipientApId,
244
+ db
245
+ .select({ id: communityMembers.communityApId })
246
+ .from(communityMembers)
247
+ .where(eq(communityMembers.actorApId, actor.ap_id)),
248
+ ),
249
+ eq(objectRecipients.type, "audience"),
250
+ eq(objects.type, "Note"),
251
+ isNull(objects.communityApId),
252
+ ),
253
+ )
254
+ .orderBy(desc(objects.published))
255
+ .limit(communityApIds.length * 10);
256
+
257
+ for (const msg of recentMessages) {
258
+ if (msg.communityApId && !lastMessagesMap.has(msg.communityApId)) {
259
+ lastMessagesMap.set(msg.communityApId, {
260
+ content: msg.content,
261
+ attributedTo: msg.attributedTo,
262
+ published: msg.published,
263
+ });
264
+ }
265
+ }
266
+ }
267
+
268
+ // Count unread community CHAT messages (published after the read baseline,
269
+ // not authored by the viewer) per community via the object_recipients
270
+ // audience link.
271
+ //
272
+ // The unread badge must reflect unread group-CHAT messages only, NOT feed
273
+ // posts. A community feed post is stored with `communityApId` set (the
274
+ // community-scoped feed query filters on that column), whereas a group-chat
275
+ // message is addressed purely via object_recipients and leaves
276
+ // `communityApId` NULL. Restricting to `communityApId IS NULL` keeps feed
277
+ // posts out of the chat unread count so reading the feed does not leave a
278
+ // stuck chat badge (and posting to the feed does not bump it).
279
+ //
280
+ // ONE grouped query (mirrors GET /unread/count) instead of a COUNT per
281
+ // community: the per-community unread baseline — the later of the viewer's
282
+ // last-read time and their join time, so a freshly joined member is not
283
+ // blasted for the whole backlog — is resolved in SQL via
284
+ // COALESCE(r.last_read_at, cm.joined_at, epoch), then GROUP BY community.
285
+ const communityUnreadCounts = new Map<string, number>();
286
+ if (communityApIds.length > 0) {
287
+ const me = actor.ap_id;
288
+ const unreadRows = await db.all<{ communityApId: string; c: number }>(sql`
289
+ SELECT cm.community_ap_id AS communityApId, COUNT(*) AS c
290
+ FROM community_members cm
291
+ JOIN object_recipients orp
292
+ ON orp.recipient_ap_id = cm.community_ap_id
293
+ AND orp.type = 'audience'
294
+ JOIN objects o
295
+ ON o.ap_id = orp.object_ap_id
296
+ AND o.type = 'Note'
297
+ AND o.community_ap_id IS NULL
298
+ AND o.attributed_to != ${me}
299
+ LEFT JOIN dm_community_read_status r
300
+ ON r.community_ap_id = cm.community_ap_id
301
+ AND r.actor_ap_id = ${me}
302
+ WHERE cm.actor_ap_id = ${me}
303
+ AND o.published > COALESCE(r.last_read_at, cm.joined_at, '1970-01-01T00:00:00Z')
304
+ GROUP BY cm.community_ap_id
305
+ `);
306
+ for (const row of unreadRows) {
307
+ if (row.c) communityUnreadCounts.set(row.communityApId, Number(row.c));
308
+ }
309
+ }
310
+
311
+ const communitiesResult = communityMemberships
312
+ .map((cm) => {
313
+ const lastMessage = lastMessagesMap.get(cm.community.apId);
314
+ return {
315
+ type: "community" as const,
316
+ ap_id: cm.community.apId,
317
+ username: formatUsername(cm.community.apId),
318
+ preferred_username: cm.community.preferredUsername,
319
+ name: cm.community.name,
320
+ icon_url: cm.community.iconUrl,
321
+ member_count: cm.community.memberCount,
322
+ last_message: lastMessage?.content
323
+ ? {
324
+ content: lastMessage.content,
325
+ is_mine: lastMessage.attributedTo === actor.ap_id,
326
+ }
327
+ : null,
328
+ last_message_at: lastMessage?.published || null,
329
+ unread_count: communityUnreadCounts.get(cm.community.apId) || 0,
330
+ };
331
+ })
332
+ .sort(
333
+ (a, b) =>
334
+ byTimeDesc(a.last_message_at, b.last_message_at) ||
335
+ a.name.localeCompare(b.name),
336
+ );
337
+
338
+ // Count pending requests: DMs from people we haven't replied to.
339
+ //
340
+ // "Incoming" = direct Notes addressed TO this actor, found via the indexed
341
+ // `object_recipients` link (`recipient_ap_id = <actor>`, `type = 'to'`)
342
+ // instead of an unindexable `to_json LIKE` substring scan. This matches the
343
+ // recipient-membership semantics of the conversation list above; the
344
+ // requests are conversations among these for which the actor has not yet
345
+ // replied (see findRepliedConversations).
346
+ const incomingDMs = await db
347
+ .selectDistinct({
348
+ conversation: objects.conversation,
349
+ })
350
+ .from(objects)
351
+ .innerJoin(objectRecipients, eq(objectRecipients.objectApId, objects.apId))
352
+ .where(
353
+ and(
354
+ eq(objects.visibility, "direct"),
355
+ eq(objects.type, "Note"),
356
+ eq(objectRecipients.recipientApId, actor.ap_id),
357
+ eq(objectRecipients.type, "to"),
358
+ ),
359
+ );
360
+
361
+ const incomingConversations = incomingDMs
362
+ .map((dm) => dm.conversation)
363
+ .filter((c): c is string => c !== null);
364
+
365
+ const repliedConversations = await findRepliedConversations(
366
+ db,
367
+ incomingConversations,
368
+ actor.ap_id,
369
+ );
370
+ const requestCount = incomingConversations.filter(
371
+ (c) => !repliedConversations.has(c),
372
+ ).length;
373
+
374
+ return c.json({
375
+ mutual_followers: contactsResult,
376
+ communities: communitiesResult,
377
+ request_count: requestCount,
378
+ });
379
+ });
380
+
381
+ // GET /contact/:encodedApId - Resolve a single DM contact (user or community)
382
+ // by AP-ID. Lets a deep-link to a conversation that is not in the loaded
383
+ // contact list (e.g. a brand-new thread, or a community the viewer just
384
+ // reached) render instead of dead-ending. Returns a minimal DMContact shape.
385
+ contacts.get("/contact/:encodedApId", async (c) => {
386
+ const actor = c.get("actor");
387
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
388
+ const db = c.get("db");
389
+
390
+ const apId = parseOtherApId(c);
391
+ if (!apId) return c.json({ error: "ap_id required" }, 400);
392
+
393
+ // Community first: a Group AP-ID resolves to a community contact.
394
+ const community = await db
395
+ .select({
396
+ apId: communities.apId,
397
+ preferredUsername: communities.preferredUsername,
398
+ name: communities.name,
399
+ iconUrl: communities.iconUrl,
400
+ memberCount: communities.memberCount,
401
+ visibility: communities.visibility,
402
+ })
403
+ .from(communities)
404
+ .where(eq(communities.apId, apId))
405
+ .get();
406
+
407
+ if (community) {
408
+ // A private community's size is withheld from non-members, matching the
409
+ // canonical GET /api/communities/:identifier gate — don't disclose
410
+ // member_count to a non-member who resolves the contact by apId.
411
+ let memberCount = community.memberCount;
412
+ if (community.visibility !== "public") {
413
+ const membership = await db
414
+ .select({ actorApId: communityMembers.actorApId })
415
+ .from(communityMembers)
416
+ .where(
417
+ and(
418
+ eq(communityMembers.communityApId, community.apId),
419
+ eq(communityMembers.actorApId, actor.ap_id),
420
+ ),
421
+ )
422
+ .get();
423
+ if (!membership) memberCount = 0;
424
+ }
425
+ return c.json({
426
+ contact: {
427
+ type: "community" as const,
428
+ ap_id: community.apId,
429
+ username: formatUsername(community.apId),
430
+ preferred_username: community.preferredUsername,
431
+ name: community.name,
432
+ icon_url: community.iconUrl,
433
+ member_count: memberCount,
434
+ last_message: null,
435
+ last_message_at: null,
436
+ unread_count: 0,
437
+ },
438
+ });
439
+ }
440
+
441
+ // Otherwise treat it as a user (local actor or cached remote actor).
442
+ const infoMap = await buildActorInfoMap(db, [apId]);
443
+ const info = infoMap.get(apId);
444
+ if (!info) return c.json({ error: "Contact not found" }, 404);
445
+
446
+ const profile = formatActorProfile(apId, info);
447
+ return c.json({
448
+ contact: {
449
+ type: "user" as const,
450
+ ...profile,
451
+ conversation_id: null,
452
+ last_message: null,
453
+ last_message_at: null,
454
+ unread_count: 0,
455
+ },
456
+ });
457
+ });
458
+
459
+ // GET /unread/count - lightweight unread DM total for the nav badge.
460
+ //
461
+ // The Messages badge only needs a number, but the badge poller used to hit the
462
+ // full `GET /contacts` every 30s, which enriches every contact with actor info
463
+ // and last-message previews and sorts the list — far more work than a count.
464
+ // This computes the same DM + community-chat unread totals as `/contacts` (so
465
+ // the badge stays consistent) in two COUNT(*) joins, with no enrichment, no
466
+ // previews, and no 2000-row materialization. A parity test pins the totals to
467
+ // the sum of `unread_count` across `/contacts` so the two cannot drift.
468
+ //
469
+ // - DM unread: direct Notes addressed TO me (via the object_recipients `to`
470
+ // index), not authored by me, published after my per-conversation read time
471
+ // (epoch if never read), excluding archived conversations.
472
+ // - Community unread: group-CHAT Notes (audience-linked, communityApId IS NULL —
473
+ // NOT feed posts) in communities I belong to, not mine, after the later of my
474
+ // per-community read time and my join time.
475
+ contacts.get("/unread/count", async (c) => {
476
+ const actor = c.get("actor");
477
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
478
+ const db = c.get("db");
479
+ const me = actor.ap_id;
480
+
481
+ const dmRow = await db.get<{ c: number }>(sql`
482
+ SELECT COUNT(*) AS c
483
+ FROM objects o
484
+ JOIN object_recipients orp
485
+ ON orp.object_ap_id = o.ap_id
486
+ AND orp.recipient_ap_id = ${me}
487
+ AND orp.type = 'to'
488
+ LEFT JOIN dm_read_status r
489
+ ON r.conversation_id = o.conversation
490
+ AND r.actor_ap_id = ${me}
491
+ WHERE o.visibility = 'direct'
492
+ AND o.type = 'Note'
493
+ AND o.conversation IS NOT NULL
494
+ AND o.attributed_to != ${me}
495
+ AND o.published > COALESCE(r.last_read_at, '1970-01-01T00:00:00Z')
496
+ AND o.conversation NOT IN (
497
+ SELECT conversation_id FROM dm_archived_conversations
498
+ WHERE actor_ap_id = ${me}
499
+ )
500
+ `);
501
+
502
+ const communityRow = await db.get<{ c: number }>(sql`
503
+ SELECT COUNT(*) AS c
504
+ FROM community_members cm
505
+ JOIN object_recipients orp
506
+ ON orp.recipient_ap_id = cm.community_ap_id
507
+ AND orp.type = 'audience'
508
+ JOIN objects o
509
+ ON o.ap_id = orp.object_ap_id
510
+ AND o.type = 'Note'
511
+ AND o.community_ap_id IS NULL
512
+ AND o.attributed_to != ${me}
513
+ LEFT JOIN dm_community_read_status r
514
+ ON r.community_ap_id = cm.community_ap_id
515
+ AND r.actor_ap_id = ${me}
516
+ WHERE cm.actor_ap_id = ${me}
517
+ AND o.published > COALESCE(r.last_read_at, cm.joined_at, '1970-01-01T00:00:00Z')
518
+ `);
519
+
520
+ const dm = Number(dmRow?.c ?? 0);
521
+ const community = Number(communityRow?.c ?? 0);
522
+ return c.json({ total: dm + community, dm, community });
523
+ });
524
+
525
+ export default contacts;