@rocapine/community 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/functions/_shared/copy.ts +14 -4
- package/templates/functions/_shared/push.ts +46 -10
- package/templates/functions/_shared/translation.ts +23 -1
- package/templates/functions/notify-comment/index.ts +2 -2
- package/templates/functions/notify-like/index.ts +2 -2
- package/templates/functions/notify-reaction/index.ts +2 -2
- package/templates/functions/update-profile/index.ts +28 -6
- package/templates/migrations/push/004_delete_own_push_row.sql +5 -0
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
|
}
|
|
@@ -8,11 +8,36 @@ export interface ExpoPushMessage {
|
|
|
8
8
|
title: string;
|
|
9
9
|
body?: string;
|
|
10
10
|
data: Record<string, string>;
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// The recipient's unread inbox count at send time (see `unreadBadge`); the
|
|
12
|
+
// app re-syncs the icon badge from the SDK's own count while it runs.
|
|
13
13
|
badge?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Unread inbox rows for `uid` (newer than the seen marker), at least 1 since a push
|
|
17
|
+
* is being sent. Falls back to 1 when the inbox module is not installed. */
|
|
18
|
+
export async function unreadBadge(
|
|
19
|
+
supabase: { from: (table: string) => any },
|
|
20
|
+
uid: string,
|
|
21
|
+
): Promise<number> {
|
|
22
|
+
try {
|
|
23
|
+
const { data: seen } = await supabase
|
|
24
|
+
.from("notification_seen")
|
|
25
|
+
.select("seen_at")
|
|
26
|
+
.eq("user_id", uid)
|
|
27
|
+
.maybeSingle();
|
|
28
|
+
let query = supabase
|
|
29
|
+
.from("notifications")
|
|
30
|
+
.select("*", { count: "exact", head: true })
|
|
31
|
+
.eq("user_id", uid);
|
|
32
|
+
if (seen?.seen_at) query = query.gt("created_at", seen.seen_at);
|
|
33
|
+
const { count, error } = await query;
|
|
34
|
+
if (error) return 1;
|
|
35
|
+
return Math.max(1, count ?? 0);
|
|
36
|
+
} catch {
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
// Sends in chunks of 100 (Expo API limit), 4 requests in flight at a time, each
|
|
17
42
|
// aborted after 10 s so one hung request can't stall a large broadcast past the
|
|
18
43
|
// function's wall-clock limit. Best-effort: push failures must never fail the
|
|
@@ -28,14 +53,25 @@ export async function sendExpoPushBatch(
|
|
|
28
53
|
if (accessToken) headers["Authorization"] = `Bearer ${accessToken}`;
|
|
29
54
|
const chunks: ExpoPushMessage[][] = [];
|
|
30
55
|
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
|
-
|
|
56
|
+
await runPool(chunks, opts.concurrency ?? 4, async (chunk) => {
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetchImpl("https://exp.host/--/api/v2/push/send", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers,
|
|
61
|
+
body: JSON.stringify(chunk.length === 1 ? chunk[0] : chunk),
|
|
62
|
+
signal: AbortSignal.timeout(10_000),
|
|
63
|
+
});
|
|
64
|
+
// Best effort, but never silent: a project with push security enabled
|
|
65
|
+
// answers 401 UNAUTHORIZED without EXPO_ACCESS_TOKEN, and bad tokens
|
|
66
|
+
// come back as per-message tickets — both used to vanish here.
|
|
67
|
+
const body = await res.text();
|
|
68
|
+
if (!res.ok || /"status"\s*:\s*"error"/.test(body)) {
|
|
69
|
+
console.error(`expo push: HTTP ${res.status} ${body.slice(0, 500)}`);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error("expo push: request failed", error);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
export async function sendExpoPush(message: ExpoPushMessage): Promise<void> {
|
|
@@ -18,7 +18,29 @@ export const TARGET_LOCALES: string[] = (Deno.env.get("COMMUNITY_TRANSLATION_LOC
|
|
|
18
18
|
.map((l) => l.trim())
|
|
19
19
|
.filter(Boolean);
|
|
20
20
|
export const TRANSLATION_MODEL = Deno.env.get("COMMUNITY_TRANSLATION_MODEL") ?? "gpt-5-mini";
|
|
21
|
+
// COMMUNITY_TRANSLATION_STYLE: a sentence (same voice for every target) or a JSON
|
|
22
|
+
// object { "<locale>": "<hint>" } for per-locale register hints — e.g.
|
|
23
|
+
// { "fr": "address the reader as vous", "de": "address the reader as Sie" }.
|
|
21
24
|
export const TRANSLATION_STYLE = Deno.env.get("COMMUNITY_TRANSLATION_STYLE") ?? "";
|
|
25
|
+
|
|
26
|
+
/** The "App voice" instruction lines for the given targets; empty when unset. */
|
|
27
|
+
export function translationStyleLines(style: string, targets: string[]): string[] {
|
|
28
|
+
const raw = style.trim();
|
|
29
|
+
if (!raw) return [];
|
|
30
|
+
if (!raw.startsWith("{")) return [`App voice: ${raw}`];
|
|
31
|
+
let byLocale: Record<string, unknown>;
|
|
32
|
+
try {
|
|
33
|
+
byLocale = JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
return [`App voice: ${raw}`];
|
|
36
|
+
}
|
|
37
|
+
return targets.flatMap((locale) => {
|
|
38
|
+
const hint = byLocale[locale] ?? byLocale[locale.split("-")[0]];
|
|
39
|
+
return typeof hint === "string" && hint.trim()
|
|
40
|
+
? [`App voice for ${locale}: ${hint.trim()}`]
|
|
41
|
+
: [];
|
|
42
|
+
});
|
|
43
|
+
}
|
|
22
44
|
export const ENGINE = `openai:${TRANSLATION_MODEL}`;
|
|
23
45
|
|
|
24
46
|
export type TranslationRow = {
|
|
@@ -140,7 +162,7 @@ export async function translateItem(input: {
|
|
|
140
162
|
input.options.length > 0
|
|
141
163
|
? `The text is a poll: also translate the ${input.options.length} option labels, in the same order and count, into "options". Return an empty options array when there are none.`
|
|
142
164
|
: "Return an empty options array for every locale.",
|
|
143
|
-
TRANSLATION_STYLE
|
|
165
|
+
...translationStyleLines(TRANSLATION_STYLE, input.targets),
|
|
144
166
|
]
|
|
145
167
|
.filter(Boolean)
|
|
146
168
|
.join("\n");
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// push comes from the request itself.
|
|
9
9
|
|
|
10
10
|
import { adminClient } from "../_shared/client.ts";
|
|
11
|
-
import { sendExpoPush } from "../_shared/push.ts";
|
|
11
|
+
import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
|
|
12
12
|
import { pushCopy } from "../_shared/copy.ts";
|
|
13
13
|
import { ensureTranslations, resolveTargetLocale, languageOf } from "../_shared/translation.ts";
|
|
14
14
|
|
|
@@ -84,7 +84,7 @@ Deno.serve(async (req) => {
|
|
|
84
84
|
title: copy.text("comment.title"),
|
|
85
85
|
body,
|
|
86
86
|
data: { route: "/community", kind: "community_comment" },
|
|
87
|
-
badge:
|
|
87
|
+
badge: await unreadBadge(supabase, post.author_id),
|
|
88
88
|
});
|
|
89
89
|
return new Response("ok");
|
|
90
90
|
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// notifications early.
|
|
12
12
|
|
|
13
13
|
import { adminClient } from "../_shared/client.ts";
|
|
14
|
-
import { sendExpoPush } from "../_shared/push.ts";
|
|
14
|
+
import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
|
|
15
15
|
import { pushCopy } from "../_shared/copy.ts";
|
|
16
16
|
|
|
17
17
|
const supabase = adminClient();
|
|
@@ -81,7 +81,7 @@ async function flushPost(postId: string) {
|
|
|
81
81
|
to: pref.expo_push_token,
|
|
82
82
|
title,
|
|
83
83
|
data: { route: "/community", kind: "community_like" },
|
|
84
|
-
badge:
|
|
84
|
+
badge: await unreadBadge(supabase, post.author_id),
|
|
85
85
|
});
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// untrusted body; everything else comes from post_reactions.
|
|
16
16
|
|
|
17
17
|
import { adminClient } from "../_shared/client.ts";
|
|
18
|
-
import { sendExpoPush } from "../_shared/push.ts";
|
|
18
|
+
import { sendExpoPush, unreadBadge } from "../_shared/push.ts";
|
|
19
19
|
import { pushCopy } from "../_shared/copy.ts";
|
|
20
20
|
|
|
21
21
|
const supabase = adminClient();
|
|
@@ -80,7 +80,7 @@ async function flushPost(postId: string) {
|
|
|
80
80
|
to: pref.expo_push_token,
|
|
81
81
|
title,
|
|
82
82
|
data: { route: "/community", kind: "community_reaction" },
|
|
83
|
-
badge:
|
|
83
|
+
badge: await unreadBadge(supabase, post.author_id),
|
|
84
84
|
});
|
|
85
85
|
}
|
|
86
86
|
|
|
@@ -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,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Lets a user delete their own push row (account deletion). Until now only
|
|
2
|
+
-- select/insert/update existed, so a client delete() was a silent no-op.
|
|
3
|
+
drop policy if exists "delete own push row" on public.push_tokens;
|
|
4
|
+
create policy "delete own push row" on public.push_tokens for delete to authenticated
|
|
5
|
+
using (user_id = auth.uid());
|