@rocapine/community 0.8.1 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocapine/community",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "CLI to install, upgrade and adopt the Rocapine Community SDK's Supabase backend (migrations, Edge Functions) into a project.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,11 +8,36 @@ export interface ExpoPushMessage {
8
8
  title: string;
9
9
  body?: string;
10
10
  data: Record<string, string>;
11
- // badge: 1 is a "something new" dot, not a real unread count — the app
12
- // clears it on every foreground.
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
@@ -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 ? `App voice: ${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: 1,
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: 1,
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: 1,
83
+ badge: await unreadBadge(supabase, post.author_id),
84
84
  });
85
85
  }
86
86
 
@@ -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());
@@ -0,0 +1,8 @@
1
+ -- community-sdk reaction/003_retract.sql
2
+ -- A reaction can be withdrawn, like a like: the client deletes its own row.
3
+ -- Until now only select/insert existed, so a delete() was a silent no-op.
4
+ -- The coalesced push only reads rows still present, so a reaction withdrawn
5
+ -- before the digest runs is never announced.
6
+ drop policy if exists "unreact own" on public.post_reactions;
7
+ create policy "unreact own" on public.post_reactions for delete to authenticated
8
+ using (user_id = auth.uid());