@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.
- package/LICENSE +16 -0
- package/README.md +82 -0
- package/migrations/0001_init.sql +495 -0
- package/migrations/0002_social_remote_actor_edges.sql +92 -0
- package/migrations/0003_activity_remote_object_edges.sql +68 -0
- package/migrations/0004_blocklist.sql +26 -0
- package/migrations/0005_story_community_scope.sql +13 -0
- package/migrations/0006_dm_community_read_status.sql +19 -0
- package/migrations/0007_moderation_reports.sql +22 -0
- package/migrations/0008_actor_fields_aka.sql +18 -0
- package/migrations/0009_object_tags.sql +13 -0
- package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
- package/migrations/0011_drop_remote_actor_fks.sql +205 -0
- package/migrations/0012_objects_content_fts.sql +39 -0
- package/migrations/0013_efficiency_indexes.sql +13 -0
- package/migrations/0014_inbox_actor_created_idx.sql +15 -0
- package/migrations/0015_community_bans.sql +16 -0
- package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
- package/migrations/0017_mobile_push_registrations.sql +22 -0
- package/migrations/README.md +122 -0
- package/package.json +75 -0
- package/packages/api/LICENSE +16 -0
- package/packages/api/package.json +30 -0
- package/packages/api/src/index.ts +4 -0
- package/packages/api/src/lib/api/account.ts +20 -0
- package/packages/api/src/lib/api/actors.ts +149 -0
- package/packages/api/src/lib/api/auth.ts +46 -0
- package/packages/api/src/lib/api/communities.ts +329 -0
- package/packages/api/src/lib/api/dm.test.ts +67 -0
- package/packages/api/src/lib/api/dm.ts +236 -0
- package/packages/api/src/lib/api/fetch.ts +111 -0
- package/packages/api/src/lib/api/follow.ts +30 -0
- package/packages/api/src/lib/api/media.ts +100 -0
- package/packages/api/src/lib/api/moderation.ts +98 -0
- package/packages/api/src/lib/api/normalize.ts +71 -0
- package/packages/api/src/lib/api/notifications.test.ts +63 -0
- package/packages/api/src/lib/api/notifications.ts +61 -0
- package/packages/api/src/lib/api/posts.test.ts +110 -0
- package/packages/api/src/lib/api/posts.ts +181 -0
- package/packages/api/src/lib/api/recommendations.ts +22 -0
- package/packages/api/src/lib/api/search.ts +88 -0
- package/packages/api/src/lib/api/stories.ts +80 -0
- package/packages/api/src/lib/api.ts +15 -0
- package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
- package/packages/api/src/lib/transport.ts +40 -0
- package/packages/api/src/social-server.ts +47 -0
- package/packages/api/src/types/index.ts +185 -0
- package/scripts/apply-takosumi-migrations.ts +621 -0
- package/src/backend/federation-helpers.ts +36 -0
- package/src/backend/index.ts +872 -0
- package/src/backend/lib/account-migration.ts +106 -0
- package/src/backend/lib/activitypub-actor-cache.ts +238 -0
- package/src/backend/lib/activitypub-helpers.ts +131 -0
- package/src/backend/lib/activitypub-validators.ts +323 -0
- package/src/backend/lib/ap-context.ts +16 -0
- package/src/backend/lib/ap-ids.ts +101 -0
- package/src/backend/lib/ap-response.ts +30 -0
- package/src/backend/lib/ap-signing.ts +87 -0
- package/src/backend/lib/ap-verify.ts +670 -0
- package/src/backend/lib/auth-lockout.ts +230 -0
- package/src/backend/lib/backend-paths.ts +34 -0
- package/src/backend/lib/base64.ts +30 -0
- package/src/backend/lib/blocklist-purge.ts +109 -0
- package/src/backend/lib/blocklist.ts +279 -0
- package/src/backend/lib/chunk.ts +33 -0
- package/src/backend/lib/client-ip.ts +169 -0
- package/src/backend/lib/community-visibility.ts +230 -0
- package/src/backend/lib/crypto.ts +424 -0
- package/src/backend/lib/delivery/circuit.ts +265 -0
- package/src/backend/lib/delivery/metrics.ts +30 -0
- package/src/backend/lib/delivery/planner.ts +190 -0
- package/src/backend/lib/delivery/queue-batching.ts +626 -0
- package/src/backend/lib/delivery/queue-delivery.ts +641 -0
- package/src/backend/lib/delivery/queue.ts +576 -0
- package/src/backend/lib/delivery/transformers.ts +56 -0
- package/src/backend/lib/delivery/types.ts +139 -0
- package/src/backend/lib/errors.ts +114 -0
- package/src/backend/lib/federation-fetch.ts +296 -0
- package/src/backend/lib/feed-cursor.ts +57 -0
- package/src/backend/lib/feed-exclude.ts +48 -0
- package/src/backend/lib/hex.ts +8 -0
- package/src/backend/lib/log-mask.ts +213 -0
- package/src/backend/lib/logger.ts +285 -0
- package/src/backend/lib/mobile-contract.ts +137 -0
- package/src/backend/lib/oauth-providers.ts +324 -0
- package/src/backend/lib/oauth-utils.ts +148 -0
- package/src/backend/lib/oidc-id-token.ts +151 -0
- package/src/backend/lib/parse-helpers.ts +31 -0
- package/src/backend/lib/post-visibility.ts +190 -0
- package/src/backend/lib/session-actor.ts +61 -0
- package/src/backend/lib/ssrf.ts +428 -0
- package/src/backend/lib/strip-image-metadata.ts +191 -0
- package/src/backend/middleware/bearer-auth.ts +70 -0
- package/src/backend/middleware/body-limit.ts +212 -0
- package/src/backend/middleware/cache.ts +429 -0
- package/src/backend/middleware/csrf.ts +130 -0
- package/src/backend/middleware/error-handler.ts +77 -0
- package/src/backend/middleware/rate-limit.ts +308 -0
- package/src/backend/public.ts +21 -0
- package/src/backend/routes/account-teardown.ts +430 -0
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
- package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
- package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
- package/src/backend/routes/activitypub/inbox-types.ts +74 -0
- package/src/backend/routes/activitypub/inbox.ts +1191 -0
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub/query-helpers.ts +227 -0
- package/src/backend/routes/activitypub.ts +616 -0
- package/src/backend/routes/actors-helpers.ts +487 -0
- package/src/backend/routes/actors.ts +1311 -0
- package/src/backend/routes/apps.ts +313 -0
- package/src/backend/routes/auth-helpers.ts +566 -0
- package/src/backend/routes/auth.ts +615 -0
- package/src/backend/routes/communities/membership-invites.ts +208 -0
- package/src/backend/routes/communities/membership-join.ts +335 -0
- package/src/backend/routes/communities/membership-members.ts +539 -0
- package/src/backend/routes/communities/membership-requests.ts +296 -0
- package/src/backend/routes/communities/membership-shared.ts +364 -0
- package/src/backend/routes/communities/messages.ts +479 -0
- package/src/backend/routes/communities/routes.ts +624 -0
- package/src/backend/routes/communities.ts +21 -0
- package/src/backend/routes/dm/contacts.ts +525 -0
- package/src/backend/routes/dm/conversations-helpers.ts +197 -0
- package/src/backend/routes/dm/conversations.ts +25 -0
- package/src/backend/routes/dm/messages.ts +658 -0
- package/src/backend/routes/dm/query-helpers.ts +85 -0
- package/src/backend/routes/dm/read-archive.ts +228 -0
- package/src/backend/routes/dm/requests.ts +222 -0
- package/src/backend/routes/dm/typing.ts +81 -0
- package/src/backend/routes/dm.ts +15 -0
- package/src/backend/routes/follow-helpers.ts +370 -0
- package/src/backend/routes/follow.ts +588 -0
- package/src/backend/routes/media.ts +692 -0
- package/src/backend/routes/mobile.ts +159 -0
- package/src/backend/routes/moderation.ts +373 -0
- package/src/backend/routes/notifications.ts +757 -0
- package/src/backend/routes/posts/delete-cascade.ts +330 -0
- package/src/backend/routes/posts/interactions.ts +795 -0
- package/src/backend/routes/posts/post-helpers.ts +847 -0
- package/src/backend/routes/posts/queries.ts +537 -0
- package/src/backend/routes/posts/routes.ts +865 -0
- package/src/backend/routes/posts/transformers.ts +161 -0
- package/src/backend/routes/posts.ts +17 -0
- package/src/backend/routes/recommendations.ts +88 -0
- package/src/backend/routes/search.ts +730 -0
- package/src/backend/routes/stories/interactions.ts +576 -0
- package/src/backend/routes/stories/query-helpers.ts +482 -0
- package/src/backend/routes/stories/routes.ts +906 -0
- package/src/backend/routes/stories.ts +13 -0
- package/src/backend/routes/takos-tools/dm.ts +249 -0
- package/src/backend/routes/takos-tools/follows.ts +225 -0
- package/src/backend/routes/takos-tools/posts.ts +292 -0
- package/src/backend/routes/takos-tools/search.ts +228 -0
- package/src/backend/routes/takos-tools/timeline.ts +132 -0
- package/src/backend/routes/takos-tools/types.ts +10 -0
- package/src/backend/routes/takos-tools-response.ts +178 -0
- package/src/backend/routes/takos-tools.ts +153 -0
- package/src/backend/routes/timeline.ts +755 -0
- package/src/backend/runtime/bun.ts +620 -0
- package/src/backend/runtime/cloudflare.ts +202 -0
- package/src/backend/runtime/compat-bun/types.ts +44 -0
- package/src/backend/runtime/memory-kv.ts +104 -0
- package/src/backend/runtime/shared.ts +142 -0
- package/src/backend/runtime/types.ts +205 -0
- package/src/backend/server.ts +636 -0
- package/src/backend/types.ts +143 -0
- package/src/db/index.ts +97 -0
- package/src/db/schema/actors.ts +129 -0
- package/src/db/schema/communities.ts +133 -0
- package/src/db/schema/date-utils.ts +17 -0
- package/src/db/schema/index.ts +17 -0
- package/src/db/schema/messaging.ts +241 -0
- package/src/db/schema/mobile.ts +37 -0
- package/src/db/schema/posts.ts +150 -0
- package/src/db/schema/relations.ts +266 -0
- package/src/db/schema/reports.ts +33 -0
- package/src/db/schema/social.ts +106 -0
- package/src/db/schema/stories.ts +70 -0
- package/src/db/schema.ts +15 -0
- package/src/plugin/public.ts +7 -0
- 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;
|