@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,329 @@
|
|
|
1
|
+
import { normalizeActor } from "./normalize.ts";
|
|
2
|
+
import {
|
|
3
|
+
apiDelete,
|
|
4
|
+
ApiError,
|
|
5
|
+
apiFetch,
|
|
6
|
+
apiPatch,
|
|
7
|
+
apiPost,
|
|
8
|
+
assertOk,
|
|
9
|
+
} from "./fetch.ts";
|
|
10
|
+
|
|
11
|
+
export interface CommunityDetail {
|
|
12
|
+
ap_id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
display_name: string;
|
|
15
|
+
summary: string | null;
|
|
16
|
+
icon_url: string | null;
|
|
17
|
+
visibility: "public" | "private";
|
|
18
|
+
join_policy: "open" | "invite" | "approval";
|
|
19
|
+
post_policy: "anyone" | "members" | "mods" | "owners";
|
|
20
|
+
member_count: number;
|
|
21
|
+
post_count?: number;
|
|
22
|
+
created_by: string;
|
|
23
|
+
created_at: string;
|
|
24
|
+
is_member: boolean;
|
|
25
|
+
member_role: "owner" | "moderator" | "member" | null;
|
|
26
|
+
join_status?: "pending" | null;
|
|
27
|
+
last_message_at?: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CommunityMember {
|
|
31
|
+
ap_id: string;
|
|
32
|
+
username: string;
|
|
33
|
+
preferred_username: string;
|
|
34
|
+
name: string | null;
|
|
35
|
+
icon_url: string | null;
|
|
36
|
+
role: "owner" | "moderator" | "member";
|
|
37
|
+
joined_at: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CommunityMessage {
|
|
41
|
+
id: string;
|
|
42
|
+
sender: {
|
|
43
|
+
ap_id: string;
|
|
44
|
+
username: string;
|
|
45
|
+
preferred_username: string;
|
|
46
|
+
name: string | null;
|
|
47
|
+
icon_url: string | null;
|
|
48
|
+
};
|
|
49
|
+
content: string;
|
|
50
|
+
created_at: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface JoinCommunityResult {
|
|
54
|
+
status: "joined" | "pending" | "invite_required";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CommunityJoinRequest {
|
|
58
|
+
ap_id: string;
|
|
59
|
+
username: string;
|
|
60
|
+
preferred_username: string;
|
|
61
|
+
name: string | null;
|
|
62
|
+
icon_url: string | null;
|
|
63
|
+
created_at: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CommunityInviteResult {
|
|
67
|
+
invite_id: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CommunityInvite {
|
|
71
|
+
id: string;
|
|
72
|
+
invited_ap_id: string | null;
|
|
73
|
+
invited_by: {
|
|
74
|
+
ap_id: string;
|
|
75
|
+
username: string;
|
|
76
|
+
preferred_username: string;
|
|
77
|
+
name: string;
|
|
78
|
+
};
|
|
79
|
+
created_at: string;
|
|
80
|
+
expires_at: string | null;
|
|
81
|
+
used_at: string | null;
|
|
82
|
+
used_by_ap_id: string | null;
|
|
83
|
+
is_valid: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CommunitySettings {
|
|
87
|
+
display_name?: string;
|
|
88
|
+
summary?: string;
|
|
89
|
+
icon_url?: string;
|
|
90
|
+
visibility?: "public" | "private";
|
|
91
|
+
join_policy?: "open" | "approval" | "invite";
|
|
92
|
+
post_policy?: "anyone" | "members" | "mods" | "owners";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const normalizeCommunityMessage = (
|
|
96
|
+
message: CommunityMessage,
|
|
97
|
+
): CommunityMessage => ({
|
|
98
|
+
...message,
|
|
99
|
+
sender: normalizeActor(message.sender),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export async function fetchCommunities(): Promise<CommunityDetail[]> {
|
|
103
|
+
const res = await apiFetch("/api/communities");
|
|
104
|
+
const data = (await res.json()) as { communities?: CommunityDetail[] };
|
|
105
|
+
return data.communities || [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function fetchCommunity(
|
|
109
|
+
identifier: string,
|
|
110
|
+
): Promise<CommunityDetail> {
|
|
111
|
+
const res = await apiFetch(
|
|
112
|
+
`/api/communities/${encodeURIComponent(identifier)}`,
|
|
113
|
+
);
|
|
114
|
+
await assertOk(res, "Community not found");
|
|
115
|
+
const data = (await res.json()) as { community: CommunityDetail };
|
|
116
|
+
return data.community;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function createCommunity(data: {
|
|
120
|
+
name: string;
|
|
121
|
+
display_name?: string;
|
|
122
|
+
summary?: string;
|
|
123
|
+
}): Promise<CommunityDetail> {
|
|
124
|
+
const res = await apiPost("/api/communities", data);
|
|
125
|
+
await assertOk(res, "Failed to create community");
|
|
126
|
+
const result = (await res.json()) as { community: CommunityDetail };
|
|
127
|
+
return result.community;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function joinCommunity(
|
|
131
|
+
identifier: string,
|
|
132
|
+
options?: { inviteId?: string },
|
|
133
|
+
): Promise<JoinCommunityResult> {
|
|
134
|
+
const body = options?.inviteId ? { invite_id: options.inviteId } : undefined;
|
|
135
|
+
const res = await apiPost(
|
|
136
|
+
`/api/communities/${encodeURIComponent(identifier)}/join`,
|
|
137
|
+
body,
|
|
138
|
+
);
|
|
139
|
+
const data = (await res.json().catch(() => ({}))) as {
|
|
140
|
+
error?: string;
|
|
141
|
+
status?: string;
|
|
142
|
+
};
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
throw new ApiError(res.status, data.error || "Failed to join community");
|
|
145
|
+
}
|
|
146
|
+
const status: JoinCommunityResult["status"] =
|
|
147
|
+
data.status === "pending" || data.status === "invite_required"
|
|
148
|
+
? data.status
|
|
149
|
+
: "joined";
|
|
150
|
+
return { status };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function leaveCommunity(identifier: string): Promise<void> {
|
|
154
|
+
const res = await apiPost(
|
|
155
|
+
`/api/communities/${encodeURIComponent(identifier)}/leave`,
|
|
156
|
+
);
|
|
157
|
+
await assertOk(res, "Failed to leave community");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function fetchCommunityMessages(
|
|
161
|
+
identifier: string,
|
|
162
|
+
options?: { limit?: number; before?: string },
|
|
163
|
+
): Promise<{ messages: CommunityMessage[]; hasMore: boolean }> {
|
|
164
|
+
const params = new URLSearchParams();
|
|
165
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
166
|
+
if (options?.before) params.set("before", options.before);
|
|
167
|
+
const query = params.toString() ? `?${params}` : "";
|
|
168
|
+
const res = await apiFetch(
|
|
169
|
+
`/api/communities/${encodeURIComponent(identifier)}/messages${query}`,
|
|
170
|
+
);
|
|
171
|
+
await assertOk(res, "Failed to fetch messages");
|
|
172
|
+
const data = (await res.json()) as {
|
|
173
|
+
messages?: CommunityMessage[];
|
|
174
|
+
has_more?: boolean;
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
messages: (data.messages || []).map(normalizeCommunityMessage),
|
|
178
|
+
hasMore: data.has_more ?? false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function sendCommunityMessage(
|
|
183
|
+
identifier: string,
|
|
184
|
+
content: string,
|
|
185
|
+
): Promise<CommunityMessage> {
|
|
186
|
+
const res = await apiPost(
|
|
187
|
+
`/api/communities/${encodeURIComponent(identifier)}/messages`,
|
|
188
|
+
{ content },
|
|
189
|
+
);
|
|
190
|
+
await assertOk(res, "Failed to send message");
|
|
191
|
+
const data = (await res.json()) as { message: CommunityMessage };
|
|
192
|
+
return normalizeCommunityMessage(data.message);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function fetchCommunityMembers(
|
|
196
|
+
identifier: string,
|
|
197
|
+
): Promise<CommunityMember[]> {
|
|
198
|
+
// Request the server's max page (500) so the realistic range of communities
|
|
199
|
+
// shows its full roster without a load-more affordance — the default page
|
|
200
|
+
// (100) silently truncated larger communities. A >500-member community still
|
|
201
|
+
// needs cursor pagination (a documented follow-up); has_more/total in the
|
|
202
|
+
// response signal when that boundary is hit.
|
|
203
|
+
const res = await apiFetch(
|
|
204
|
+
`/api/communities/${encodeURIComponent(identifier)}/members?limit=500`,
|
|
205
|
+
);
|
|
206
|
+
await assertOk(res, "Failed to fetch members");
|
|
207
|
+
const data = (await res.json()) as { members?: CommunityMember[] };
|
|
208
|
+
return (data.members || []).map(normalizeActor);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function fetchCommunityJoinRequests(
|
|
212
|
+
identifier: string,
|
|
213
|
+
): Promise<CommunityJoinRequest[]> {
|
|
214
|
+
const res = await apiFetch(
|
|
215
|
+
`/api/communities/${encodeURIComponent(identifier)}/requests`,
|
|
216
|
+
);
|
|
217
|
+
await assertOk(res, "Failed to fetch join requests");
|
|
218
|
+
const data = (await res.json()) as { requests?: CommunityJoinRequest[] };
|
|
219
|
+
return (data.requests || []).map(normalizeActor);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function acceptCommunityJoinRequest(
|
|
223
|
+
identifier: string,
|
|
224
|
+
actorApId: string,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const res = await apiPost(
|
|
227
|
+
`/api/communities/${encodeURIComponent(identifier)}/requests/accept`,
|
|
228
|
+
{ actor_ap_id: actorApId },
|
|
229
|
+
);
|
|
230
|
+
await assertOk(res, "Failed to accept join request");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function rejectCommunityJoinRequest(
|
|
234
|
+
identifier: string,
|
|
235
|
+
actorApId: string,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
const res = await apiPost(
|
|
238
|
+
`/api/communities/${encodeURIComponent(identifier)}/requests/reject`,
|
|
239
|
+
{ actor_ap_id: actorApId },
|
|
240
|
+
);
|
|
241
|
+
await assertOk(res, "Failed to reject join request");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function fetchCommunityInvites(
|
|
245
|
+
identifier: string,
|
|
246
|
+
): Promise<CommunityInvite[]> {
|
|
247
|
+
const res = await apiFetch(
|
|
248
|
+
`/api/communities/${encodeURIComponent(identifier)}/invites`,
|
|
249
|
+
);
|
|
250
|
+
await assertOk(res, "Failed to fetch invites");
|
|
251
|
+
const data = (await res.json()) as { invites?: CommunityInvite[] };
|
|
252
|
+
return (data.invites || []).map((invite: CommunityInvite) => ({
|
|
253
|
+
...invite,
|
|
254
|
+
invited_by: normalizeActor(invite.invited_by),
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function createCommunityInvite(
|
|
259
|
+
identifier: string,
|
|
260
|
+
options?: { invited_ap_id?: string; expires_in_hours?: number },
|
|
261
|
+
): Promise<CommunityInviteResult & { expires_at?: string }> {
|
|
262
|
+
const res = await apiPost(
|
|
263
|
+
`/api/communities/${encodeURIComponent(identifier)}/invites`,
|
|
264
|
+
options,
|
|
265
|
+
);
|
|
266
|
+
await assertOk(res, "Failed to create invite");
|
|
267
|
+
return (await res.json()) as CommunityInviteResult & { expires_at?: string };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function revokeCommunityInvite(
|
|
271
|
+
identifier: string,
|
|
272
|
+
inviteId: string,
|
|
273
|
+
): Promise<void> {
|
|
274
|
+
const res = await apiDelete(
|
|
275
|
+
`/api/communities/${encodeURIComponent(identifier)}/invites/${encodeURIComponent(
|
|
276
|
+
inviteId,
|
|
277
|
+
)}`,
|
|
278
|
+
);
|
|
279
|
+
await assertOk(res, "Failed to revoke invite");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function updateCommunitySettings(
|
|
283
|
+
identifier: string,
|
|
284
|
+
settings: CommunitySettings,
|
|
285
|
+
): Promise<void> {
|
|
286
|
+
const res = await apiPatch(
|
|
287
|
+
`/api/communities/${encodeURIComponent(identifier)}/settings`,
|
|
288
|
+
settings,
|
|
289
|
+
);
|
|
290
|
+
await assertOk(res, "Failed to update community settings");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function removeCommunityMember(
|
|
294
|
+
identifier: string,
|
|
295
|
+
actorApId: string,
|
|
296
|
+
): Promise<void> {
|
|
297
|
+
const res = await apiDelete(
|
|
298
|
+
`/api/communities/${encodeURIComponent(identifier)}/members/${encodeURIComponent(
|
|
299
|
+
actorApId,
|
|
300
|
+
)}`,
|
|
301
|
+
);
|
|
302
|
+
await assertOk(res, "Failed to remove member");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function updateCommunityMemberRole(
|
|
306
|
+
identifier: string,
|
|
307
|
+
actorApId: string,
|
|
308
|
+
role: "owner" | "moderator" | "member",
|
|
309
|
+
): Promise<void> {
|
|
310
|
+
const res = await apiPatch(
|
|
311
|
+
`/api/communities/${encodeURIComponent(identifier)}/members/${encodeURIComponent(
|
|
312
|
+
actorApId,
|
|
313
|
+
)}`,
|
|
314
|
+
{ role },
|
|
315
|
+
);
|
|
316
|
+
await assertOk(res, "Failed to update member role");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function deleteCommunityMessage(
|
|
320
|
+
identifier: string,
|
|
321
|
+
messageId: string,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const res = await apiDelete(
|
|
324
|
+
`/api/communities/${encodeURIComponent(identifier)}/messages/${encodeURIComponent(
|
|
325
|
+
messageId,
|
|
326
|
+
)}`,
|
|
327
|
+
);
|
|
328
|
+
await assertOk(res, "Failed to delete message");
|
|
329
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { clearYurucommuApiTransport } from "../transport.ts";
|
|
3
|
+
import type { DMMessage } from "../../types/index.ts";
|
|
4
|
+
import { fetchUserDMMessages } from "./dm.ts";
|
|
5
|
+
|
|
6
|
+
function makeMessage(id: string): DMMessage {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
sender: {
|
|
10
|
+
ap_id: "https://example.com/ap/users/alice",
|
|
11
|
+
username: "alice@example.com",
|
|
12
|
+
preferred_username: "alice",
|
|
13
|
+
name: "Alice",
|
|
14
|
+
icon_url: null,
|
|
15
|
+
},
|
|
16
|
+
content: "hi",
|
|
17
|
+
attachments: [],
|
|
18
|
+
created_at: "2026-01-01T00:00:00.000Z",
|
|
19
|
+
} as unknown as DMMessage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withMockFetch<T>(
|
|
23
|
+
responseBody: unknown,
|
|
24
|
+
fn: () => Promise<T>,
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
const originalFetch = globalThis.fetch;
|
|
27
|
+
clearYurucommuApiTransport();
|
|
28
|
+
globalThis.fetch = ((_input: RequestInfo | URL) =>
|
|
29
|
+
Promise.resolve(
|
|
30
|
+
new Response(JSON.stringify(responseBody), {
|
|
31
|
+
status: 200,
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
}),
|
|
34
|
+
)) as typeof fetch;
|
|
35
|
+
try {
|
|
36
|
+
return await fn();
|
|
37
|
+
} finally {
|
|
38
|
+
globalThis.fetch = originalFetch;
|
|
39
|
+
clearYurucommuApiTransport();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Regression: the DM thread's "load older" affordance depends on the client
|
|
44
|
+
// surfacing the server's `has_more` flag (it was previously discarded).
|
|
45
|
+
test("fetchUserDMMessages surfaces has_more as hasMore and maps messages", async () => {
|
|
46
|
+
const result = await withMockFetch(
|
|
47
|
+
{
|
|
48
|
+
messages: [makeMessage("m1")],
|
|
49
|
+
conversation_id: "conv-1",
|
|
50
|
+
has_more: true,
|
|
51
|
+
},
|
|
52
|
+
() => fetchUserDMMessages("https://example.com/ap/users/alice"),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(result.messages.map((m) => m.id)).toEqual(["m1"]);
|
|
56
|
+
expect(result.conversation_id).toBe("conv-1");
|
|
57
|
+
expect(result.hasMore).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("fetchUserDMMessages defaults hasMore to false when the server omits it", async () => {
|
|
61
|
+
const result = await withMockFetch({ messages: [] }, () =>
|
|
62
|
+
fetchUserDMMessages("https://example.com/ap/users/alice"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(result.messages).toEqual([]);
|
|
66
|
+
expect(result.hasMore).toBe(false);
|
|
67
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { DMMessage } from "../../types/index.ts";
|
|
2
|
+
import { normalizeActor } from "./normalize.ts";
|
|
3
|
+
import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
|
|
4
|
+
|
|
5
|
+
// Contact types for the unified DM view
|
|
6
|
+
export interface DMContact {
|
|
7
|
+
type: "user" | "community";
|
|
8
|
+
ap_id: string;
|
|
9
|
+
username: string;
|
|
10
|
+
preferred_username: string;
|
|
11
|
+
name: string | null;
|
|
12
|
+
icon_url: string | null;
|
|
13
|
+
conversation_id?: string | null;
|
|
14
|
+
member_count?: number;
|
|
15
|
+
last_message: { content: string; is_mine: boolean } | null;
|
|
16
|
+
last_message_at: string | null;
|
|
17
|
+
unread_count?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DMContactsResponse {
|
|
21
|
+
mutual_followers: DMContact[];
|
|
22
|
+
communities: DMContact[];
|
|
23
|
+
request_count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DMRequest {
|
|
27
|
+
id: string;
|
|
28
|
+
sender: {
|
|
29
|
+
ap_id: string;
|
|
30
|
+
username: string;
|
|
31
|
+
preferred_username: string;
|
|
32
|
+
name: string | null;
|
|
33
|
+
icon_url: string | null;
|
|
34
|
+
};
|
|
35
|
+
content: string;
|
|
36
|
+
created_at: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const normalizeDmMessage = (message: DMMessage): DMMessage => ({
|
|
40
|
+
...message,
|
|
41
|
+
sender: normalizeActor(message.sender),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const normalizeDmRequest = (request: DMRequest): DMRequest => ({
|
|
45
|
+
...request,
|
|
46
|
+
sender: normalizeActor(request.sender),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Fetch contacts (followers + communities) - no room creation needed
|
|
50
|
+
export async function fetchDMContacts(): Promise<DMContactsResponse> {
|
|
51
|
+
const res = await apiFetch("/api/dm/contacts");
|
|
52
|
+
await assertOk(res, "Failed to load conversations");
|
|
53
|
+
const data = (await res.json()) as {
|
|
54
|
+
mutual_followers?: DMContact[];
|
|
55
|
+
communities?: DMContact[];
|
|
56
|
+
request_count?: number;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
mutual_followers: (data.mutual_followers || []).map(normalizeActor),
|
|
60
|
+
communities: (data.communities || []).map(normalizeActor),
|
|
61
|
+
request_count: data.request_count || 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Lightweight unread total for the Messages nav badge. The backend computes the
|
|
66
|
+
// same DM + community-chat unread totals as /contacts (a parity test pins them)
|
|
67
|
+
// but with two COUNT(*) joins and no contact enrichment / previews, so the 30s
|
|
68
|
+
// badge poll does not pay for the full contacts list.
|
|
69
|
+
export interface DMUnreadCount {
|
|
70
|
+
total: number;
|
|
71
|
+
dm: number;
|
|
72
|
+
community: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function fetchDMUnreadCount(): Promise<DMUnreadCount> {
|
|
76
|
+
const res = await apiFetch("/api/dm/unread/count");
|
|
77
|
+
const data = (await res.json()) as Partial<DMUnreadCount>;
|
|
78
|
+
return {
|
|
79
|
+
total: data.total || 0,
|
|
80
|
+
dm: data.dm || 0,
|
|
81
|
+
community: data.community || 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fetch message requests
|
|
86
|
+
export async function fetchDMRequests(): Promise<DMRequest[]> {
|
|
87
|
+
const res = await apiFetch("/api/dm/requests");
|
|
88
|
+
await assertOk(res, "Failed to load message requests");
|
|
89
|
+
const data = (await res.json()) as { requests?: DMRequest[] };
|
|
90
|
+
return (data.requests || []).map(normalizeDmRequest);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reject message request (by sender AP ID, optionally block)
|
|
94
|
+
export async function rejectDMRequest(
|
|
95
|
+
senderApId: string,
|
|
96
|
+
block?: boolean,
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
const res = await apiPost("/api/dm/requests/reject", {
|
|
99
|
+
sender_ap_id: senderApId,
|
|
100
|
+
block,
|
|
101
|
+
});
|
|
102
|
+
await assertOk(res, "Failed to reject request");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// User-based DM endpoints (no conversation creation needed)
|
|
106
|
+
export async function fetchUserDMMessages(
|
|
107
|
+
userApId: string,
|
|
108
|
+
options?: { limit?: number; before?: string },
|
|
109
|
+
): Promise<{
|
|
110
|
+
messages: DMMessage[];
|
|
111
|
+
conversation_id: string | null;
|
|
112
|
+
hasMore: boolean;
|
|
113
|
+
}> {
|
|
114
|
+
const params = new URLSearchParams();
|
|
115
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
116
|
+
if (options?.before) params.set("before", options.before);
|
|
117
|
+
const query = params.toString() ? `?${params}` : "";
|
|
118
|
+
const res = await apiFetch(
|
|
119
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/messages${query}`,
|
|
120
|
+
);
|
|
121
|
+
const data = (await res.json()) as {
|
|
122
|
+
messages?: DMMessage[];
|
|
123
|
+
conversation_id?: string | null;
|
|
124
|
+
has_more?: boolean;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
messages: (data.messages || []).map(normalizeDmMessage),
|
|
128
|
+
conversation_id: data.conversation_id ?? null,
|
|
129
|
+
hasMore: data.has_more ?? false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function sendUserDMMessage(
|
|
134
|
+
userApId: string,
|
|
135
|
+
content: string,
|
|
136
|
+
): Promise<{ message: DMMessage; conversation_id: string }> {
|
|
137
|
+
const res = await apiPost(
|
|
138
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/messages`,
|
|
139
|
+
{ content },
|
|
140
|
+
);
|
|
141
|
+
await assertOk(res, "Failed to send message");
|
|
142
|
+
const data = (await res.json()) as {
|
|
143
|
+
message: DMMessage;
|
|
144
|
+
conversation_id: string;
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
message: normalizeDmMessage(data.message),
|
|
148
|
+
conversation_id: data.conversation_id,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function sendUserDMTyping(userApId: string): Promise<void> {
|
|
153
|
+
const res = await apiPost(
|
|
154
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/typing`,
|
|
155
|
+
);
|
|
156
|
+
await assertOk(res, "Failed to send typing");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function fetchUserDMTyping(
|
|
160
|
+
userApId: string,
|
|
161
|
+
): Promise<{ is_typing: boolean; last_typed_at: string | null }> {
|
|
162
|
+
const res = await apiFetch(
|
|
163
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/typing`,
|
|
164
|
+
);
|
|
165
|
+
await assertOk(res, "Failed to fetch typing");
|
|
166
|
+
const data = (await res.json()) as {
|
|
167
|
+
is_typing?: boolean;
|
|
168
|
+
last_typed_at?: string | null;
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
is_typing: !!data.is_typing,
|
|
172
|
+
last_typed_at: data.last_typed_at ?? null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function markDMAsRead(userApId: string): Promise<void> {
|
|
177
|
+
const res = await apiPost(
|
|
178
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/read`,
|
|
179
|
+
);
|
|
180
|
+
await assertOk(res, "Failed to mark as read");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Archive a one-to-one DM conversation (by the other party's AP-ID). Archived
|
|
184
|
+
// conversations drop out of the inbox + unread count and live under the
|
|
185
|
+
// archived view until unarchived.
|
|
186
|
+
export async function archiveDMConversation(userApId: string): Promise<void> {
|
|
187
|
+
const res = await apiPost(
|
|
188
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/archive`,
|
|
189
|
+
);
|
|
190
|
+
await assertOk(res, "Failed to archive conversation");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function unarchiveDMConversation(userApId: string): Promise<void> {
|
|
194
|
+
const res = await apiDelete(
|
|
195
|
+
`/api/dm/user/${encodeURIComponent(userApId)}/archive`,
|
|
196
|
+
);
|
|
197
|
+
await assertOk(res, "Failed to unarchive conversation");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function fetchArchivedDMConversations(): Promise<DMContact[]> {
|
|
201
|
+
const res = await apiFetch("/api/dm/archived");
|
|
202
|
+
await assertOk(res, "Failed to load archived conversations");
|
|
203
|
+
const data = (await res.json()) as { archived?: Partial<DMContact>[] };
|
|
204
|
+
// The archived endpoint returns actor-profile fields + conversation_id +
|
|
205
|
+
// last_message_at (no preview / unread). Normalize to a one-to-one DMContact.
|
|
206
|
+
return (data.archived || []).map((c) => {
|
|
207
|
+
const a = normalizeActor(c as DMContact);
|
|
208
|
+
return {
|
|
209
|
+
...a,
|
|
210
|
+
type: "user" as const,
|
|
211
|
+
last_message: null,
|
|
212
|
+
unread_count: 0,
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Mark a community (group chat) as read so its unread badge clears.
|
|
218
|
+
export async function markCommunityAsRead(
|
|
219
|
+
communityApId: string,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const res = await apiPost(
|
|
222
|
+
`/api/dm/community/${encodeURIComponent(communityApId)}/read`,
|
|
223
|
+
);
|
|
224
|
+
await assertOk(res, "Failed to mark as read");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Resolve a single DM contact (user or community) by AP-ID. Used to render a
|
|
228
|
+
// deep-linked conversation that is not present in the loaded contact list.
|
|
229
|
+
// Returns null when no such actor/community is known.
|
|
230
|
+
export async function fetchDMContact(apId: string): Promise<DMContact | null> {
|
|
231
|
+
const res = await apiFetch(`/api/dm/contact/${encodeURIComponent(apId)}`);
|
|
232
|
+
if (res.status === 404) return null;
|
|
233
|
+
await assertOk(res, "Failed to resolve contact");
|
|
234
|
+
const data = (await res.json()) as { contact?: DMContact };
|
|
235
|
+
return data.contact ? normalizeActor(data.contact) : null;
|
|
236
|
+
}
|