@rocapine/community 0.6.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// Placeholders: `{name}` (actor display name) and `{count}` (the number of
|
|
18
18
|
// OTHER people in a coalesced push). A `*.many` value is either a string or a
|
|
19
19
|
// `{ one, few, many, other }` object picked by the CLDR plural category of
|
|
20
|
-
// `{count}` for the recipient's locale (Polish needs all three;
|
|
21
|
-
// 0 like 1; everything else is one/other).
|
|
20
|
+
// `{count}` for the recipient's locale (Polish needs all three; Ukrainian and
|
|
21
|
+
// Romanian one/few/other; French treats 0 like 1; everything else is one/other).
|
|
22
22
|
//
|
|
23
23
|
// Legacy single-string secrets still apply, below the JSON and above the
|
|
24
24
|
// built-ins: COMMUNITY_FALLBACK_NAME (every locale's fallbackName) and
|
|
@@ -165,13 +165,23 @@ const BUILTIN: Record<string, LocaleCopy> = {
|
|
|
165
165
|
// Same rule set as the UI package's `pluralCategory` (packages/ui/src/i18n.ts).
|
|
166
166
|
export function pluralCategory(locale: string, n: number): keyof Forms {
|
|
167
167
|
const lang = locale.split("-")[0]?.toLowerCase();
|
|
168
|
+
const m10 = n % 10;
|
|
169
|
+
const m100 = n % 100;
|
|
168
170
|
if (lang === "pl") {
|
|
169
171
|
if (n === 1) return "one";
|
|
170
|
-
const m10 = n % 10;
|
|
171
|
-
const m100 = n % 100;
|
|
172
172
|
if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
|
|
173
173
|
return "many";
|
|
174
174
|
}
|
|
175
|
+
if (lang === "uk") {
|
|
176
|
+
if (m10 === 1 && m100 !== 11) return "one";
|
|
177
|
+
if (m10 >= 2 && m10 <= 4 && !(m100 >= 12 && m100 <= 14)) return "few";
|
|
178
|
+
return "other";
|
|
179
|
+
}
|
|
180
|
+
if (lang === "ro") {
|
|
181
|
+
if (n === 1) return "one";
|
|
182
|
+
if (n === 0 || (m100 >= 2 && m100 <= 19)) return "few";
|
|
183
|
+
return "other";
|
|
184
|
+
}
|
|
175
185
|
if (lang === "fr") return n === 0 || n === 1 ? "one" : "other";
|
|
176
186
|
return n === 1 ? "one" : "other";
|
|
177
187
|
}
|
|
@@ -28,14 +28,25 @@ export async function sendExpoPushBatch(
|
|
|
28
28
|
if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
|
|
29
29
|
const chunks: ExpoPushMessage[][] = [];
|
|
30
30
|
for (let i = 0; i < messages.length; i += 100) chunks.push(messages.slice(i, i + 100));
|
|
31
|
-
await runPool(chunks, opts.concurrency ?? 4, (chunk) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
await runPool(chunks, opts.concurrency ?? 4, async (chunk) => {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetchImpl("https://exp.host/--/api/v2/push/send", {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers,
|
|
36
|
+
body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
|
|
37
|
+
signal: AbortSignal.timeout(10_000),
|
|
38
|
+
});
|
|
39
|
+
// Best effort, but never silent: a project with push security enabled
|
|
40
|
+
// answers 401 UNAUTHORIZED without EXPO_ACCESS_TOKEN, and bad tokens
|
|
41
|
+
// come back as per-message tickets — both used to vanish here.
|
|
42
|
+
const body = await res.text();
|
|
43
|
+
if (!res.ok || /"status"\s*:\s*"error"/.test(body)) {
|
|
44
|
+
console.error(`expo push: HTTP ${res.status} ${body.slice(0, 500)}`);
|
|
45
|
+
}
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error("expo push: request failed", error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// update-profile: the ONLY write path for profiles.handle / bio / avatar_url.
|
|
1
|
+
// update-profile: the ONLY moderated write path for profiles.username / handle / bio / avatar_url.
|
|
2
2
|
// JWT-authenticated: uid comes from the caller's anon session token, never the
|
|
3
3
|
// body. Text fields run through OpenAI omni-moderation (same category logic
|
|
4
4
|
// as moderate-one); avatarPath runs image moderation on the object's public
|
|
5
5
|
// URL. Fail closed: if moderation is unreachable, nothing is written.
|
|
6
6
|
//
|
|
7
|
-
// Fields are applied sequentially (handle,
|
|
7
|
+
// Fields are applied sequentially (username, handle, bio, then avatar); the first
|
|
8
8
|
// failure aborts and reports its field. The app's editors send one field at a
|
|
9
9
|
// time, so partial application is not observable in practice.
|
|
10
10
|
|
|
@@ -24,17 +24,23 @@ Deno.serve(async (req) => {
|
|
|
24
24
|
const uid = userData.user?.id;
|
|
25
25
|
if (!uid) return json({ status: "error", error: "unauthorized" }, 401);
|
|
26
26
|
|
|
27
|
-
let body: { handle?: unknown; bio?: unknown; avatarPath?: unknown };
|
|
27
|
+
let body: { username?: unknown; handle?: unknown; bio?: unknown; avatarPath?: unknown };
|
|
28
28
|
try {
|
|
29
29
|
body = await req.json();
|
|
30
30
|
} catch {
|
|
31
31
|
return json({ status: "error", error: "bad request" }, 400);
|
|
32
32
|
}
|
|
33
|
-
const { handle, bio, avatarPath } = body;
|
|
34
|
-
if (
|
|
33
|
+
const { username, handle, bio, avatarPath } = body;
|
|
34
|
+
if (
|
|
35
|
+
username === undefined &&
|
|
36
|
+
handle === undefined &&
|
|
37
|
+
bio === undefined &&
|
|
38
|
+
avatarPath === undefined
|
|
39
|
+
) {
|
|
35
40
|
return json({ status: "error", error: "bad request" }, 400);
|
|
36
41
|
}
|
|
37
42
|
if (
|
|
43
|
+
(username !== undefined && typeof username !== "string") ||
|
|
38
44
|
(handle !== undefined && typeof handle !== "string") ||
|
|
39
45
|
(bio !== undefined && typeof bio !== "string") ||
|
|
40
46
|
(avatarPath !== undefined && typeof avatarPath !== "string")
|
|
@@ -42,6 +48,21 @@ Deno.serve(async (req) => {
|
|
|
42
48
|
return json({ status: "error", error: "bad request" }, 400);
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
// ============ USERNAME (display name) ============
|
|
52
|
+
if (username !== undefined) {
|
|
53
|
+
const u = (username as string).trim();
|
|
54
|
+
if (u.length < 2 || u.length > 24) {
|
|
55
|
+
return json({ status: "error", error: "invalid_username" }, 400);
|
|
56
|
+
}
|
|
57
|
+
const verdict = await moderateInput(u);
|
|
58
|
+
if (!verdict) return json({ status: "error", error: "moderation unavailable" }, 503);
|
|
59
|
+
if (verdict.flagged) {
|
|
60
|
+
return json({ status: "rejected", field: "username", reason: verdict.reason });
|
|
61
|
+
}
|
|
62
|
+
const { error } = await admin.from("profiles").update({ username: u }).eq("id", uid);
|
|
63
|
+
if (error) return json({ status: "error", error: error.message }, 500);
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
// ============ HANDLE ============
|
|
46
67
|
if (handle !== undefined) {
|
|
47
68
|
const h = (handle as string).trim().toLowerCase();
|
|
@@ -119,11 +140,12 @@ Deno.serve(async (req) => {
|
|
|
119
140
|
// ============ SUCCESS ============
|
|
120
141
|
const { data: profile } = await admin
|
|
121
142
|
.from("profiles")
|
|
122
|
-
.select("handle, bio, avatar_url")
|
|
143
|
+
.select("username, handle, bio, avatar_url")
|
|
123
144
|
.eq("id", uid)
|
|
124
145
|
.single();
|
|
125
146
|
return json({
|
|
126
147
|
status: "ok",
|
|
148
|
+
username: profile?.username ?? null,
|
|
127
149
|
handle: profile?.handle ?? null,
|
|
128
150
|
bio: profile?.bio ?? null,
|
|
129
151
|
avatarUrl: profile?.avatar_url ?? null,
|