From 1e88549ff5b0a109db1bbf6c5b1d9e9d3dfaa568 Mon Sep 17 00:00:00 2001 From: Train Chen Date: Sat, 13 Jun 2026 13:31:23 +0200 Subject: [PATCH 1/6] =?UTF-8?q?spec(circulation):=20Circulation=20of=20Lov?= =?UTF-8?q?e=20=E2=80=94=20anonymous=20time-bound=20sharing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Love Circulation feature. What lands: - docs/specs/love-circulation.md: full design, including what the feature is NOT (locks down scope), data model, user flow, RLS rationale, moderation requirements, 5 open questions for review. - supabase/migrations/_love_circulation.sql: • love_letters table (author, content ≤500 chars, language, pseudonym, moderated_status, posted_at, expires_at, archived) • letter_holdings table (letter_id + holder_id composite PK so reactions are idempotent) • circulation_settings table (receive_letters, share_letters, ttl_days ∈ {7,14,30}) • Hot-path indexes (live current by language; author's own letters; per-letter hold count) • Full RLS: - Authors always see their own letters - Other users see only passed + live + matching language + reader opted-in - Holds enforce: not your own letter, letter live, reader opted-in - Service role full access for moderation + TTL cron Reviewer questions (in spec) before Phase 2: 1. Share via existing JournalEntry or own flow only? 2. Silent reactions to author or noisy? 3. Default TTL: 7/14/30? 4. Drift teaser for non-opted-in users? 5. Cross-language sharing allowed? Defaults applied in this PR are my best guesses; spec calls them out so the reviewer can override before edge fns are built. Token spend this turn: ~6k output (specs are dense but bounded). Cumulative: ~6k. Estimate for full feature: ~80k. Co-Authored-By: Claude Opus 4.7 --- docs/specs/love-circulation.md | 200 ++++++++++++++++++ ...8_0e009da1-76c8-47dd-922c-68733c4f5e69.sql | 160 ++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 docs/specs/love-circulation.md create mode 100644 supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql diff --git a/docs/specs/love-circulation.md b/docs/specs/love-circulation.md new file mode 100644 index 0000000..eb50178 --- /dev/null +++ b/docs/specs/love-circulation.md @@ -0,0 +1,200 @@ +# Circulation of Love — spec v1 + +> Inspired by the user's grandfather's idea — *the circulation of love*. A way to share quiet, hopeful entries with strangers who speak your language, then let them drift away on a 14-day current. + +## What it is + +An opt-in, anonymous, time-bound sharing layer on top of the existing journaling app. The user shares a single short entry (≤500 chars) in their primary language. The entry rides a 14-day current — visible to other opted-in users in the same primary language — then archives itself. + +## What it is NOT (lock these down so scope doesn't drift) + +- Not a social network (no follows, no profiles, no replies, no comments). +- Not a comment thread (single-shot reaction only). +- Not cross-lingual (same primary language; no translation in v1). +- Not public outside the app (only signed-in opted-in users see letters). +- Not viral (no share buttons, no "send to friend"). +- Not search-indexed. + +## User-visible flow + +``` +┌─ Discover ──────────────────────────────────────────────────────────┐ +│ HomeScreen → tap "Circulation" tile │ +│ → land on LettersInCirculationScreen │ +│ → drift animation if opted-in; │ +│ "Join the current" CTA if not │ +└──────────────────────────────────────────────────────────────────────┘ + +┌─ Opt in ────────────────────────────────────────────────────────────┐ +│ One-tap settings: │ +│ • Receive letters (default OFF) │ +│ • Share my letters (default OFF) │ +│ • TTL: 7 / 14 (default) / 30 days │ +└──────────────────────────────────────────────────────────────────────┘ + +┌─ Share ─────────────────────────────────────────────────────────────┐ +│ ShareALetterScreen │ +│ → compose (≤500 chars, primary language) │ +│ → see auto-pseudonym ("雪落松間" / "Soft Wind" — regenerable once) │ +│ → tap "Release into the current" │ +│ → AI moderation runs (Gemini, ~2 sec) │ +│ • pass → letter enters circulation │ +│ • soft-fail → "Could you say it a different way?" + reasoning │ +│ → confirmation: "Your letter joins the current. Returns in 14 days." │ +└──────────────────────────────────────────────────────────────────────┘ + +┌─ Read ──────────────────────────────────────────────────────────────┐ +│ LettersInCirculationScreen │ +│ → drifting envelopes (animation, see Phase 4) │ +│ → tap one → opens softly │ +│ → shows: content, pseudonym, language, days remaining │ +│ → ONE reaction: "Hold this for a moment" (single tap, idempotent) │ +│ → close, drift continues │ +└──────────────────────────────────────────────────────────────────────┘ + +┌─ Expire ────────────────────────────────────────────────────────────┐ +│ At T+14d: │ +│ • Letter disappears from feed │ +│ • Soft-archived; only original author can see │ +│ • Author sees: total holdings received (the only reaction count │ +│ they ever see) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## Data model + +```sql +love_letters + id uuid pk + author_id text -- user_anonymous_id + content text -- ≤500 chars, validated at insert + language text -- 'en' | 'fr' | 'es' | 'ja' | 'zh-Hans' | 'zh-Hant' + pseudonym text -- generated once on insert + moderated_status text -- 'pending' | 'passed' | 'softfailed' | 'blocked' + moderation_note text -- why it softfailed (shown to author only) + posted_at timestamptz -- when moderation passed + expires_at timestamptz -- posted_at + ttl_days + archived boolean -- set true at expires_at by cron + created_at timestamptz + +letter_holdings + letter_id uuid fk + holder_id text -- user_anonymous_id + held_at timestamptz + PRIMARY KEY (letter_id, holder_id) -- idempotent reactions + +circulation_settings + user_id text pk -- user_anonymous_id + receive_letters boolean -- default false + share_letters boolean -- default false + ttl_days int -- 7 | 14 | 30, default 14 + updated_at timestamptz +``` + +## RLS — non-negotiable + +``` +love_letters + SELECT: + • Author always sees their own letters (active + archived) + • Other users see ONLY: status='passed', archived=false, + language matches their primaryLang in profile, AND they have + receive_letters=true + INSERT: + • Authenticated, author_id = auth.uid() + • Content length 1..500 + • moderated_status forced to 'pending' on insert + UPDATE: + • Author can update only their own row, only soft-archive flag + • System (service_role) updates moderated_status + posted_at + DELETE: + • Author can delete their own letters at any time + +letter_holdings + SELECT: + • Holder sees their own (for "letters I've held" view, optional) + • Author of the letter sees aggregate count, NOT holder identity + INSERT: + • Authenticated, holder_id = auth.uid() + • Letter must exist, status='passed', not expired, not own letter + DELETE: + • Holder can unhold (rare; mostly for accidental taps) + +circulation_settings + SELECT/INSERT/UPDATE/DELETE: + • user_id = auth.uid() only +``` + +## Critical business rules + +1. **Author cannot react to their own letter.** Self-holding makes no sense. +2. **Holding is idempotent.** Tapping twice = still 1 hold. PK enforces. +3. **Pseudonym is regenerable ONCE before posting, never after.** Once in the current, it stays. +4. **Language filter is strict.** A `fr` user never sees `en` letters even if they're learning English. Translation = v2. +5. **Reaction count is private to author.** No public popularity-mongering. +6. **TTL is honored even if user changes settings later.** Letter posted with 14d stays 14d. +7. **Archive ≠ delete.** Honors CLAUDE.md *Object Permanence* — author can still see their old letters. + +## Moderation — non-negotiable + +Pre-publish AI moderation via Gemini 2.5 Flash. Blocks: + +- Self-harm content (with grace — show a gentle support resource link, not an error) +- Targeted harassment +- Sexually explicit content +- Personal identifying information (names, addresses, phone numbers) +- Spam / promotion +- Content that names a specific person identifiably (even non-malicious) + +Pass-list: +- Sad content (the WHOLE point is to share difficulty) +- Difficult emotions, anger, grief, loneliness +- Religious or political content if non-extremist +- Discussions of mental health symptoms (not in crisis) + +The moderation prompt is in `supabase/functions/moderate-letter/index.ts` and is the most carefully reviewed file in this feature. + +## Pseudonym generation + +Per-language pseudonym pools, lifted from natural imagery — no animals (overdone), no abstract emotions. Sample (full list in `src/lib/pseudonyms.ts`): + +- en: *Soft Wind*, *Late Light*, *Wet Grass*, *Drift Snow*, *Quiet Bell*, *Held Stone* +- fr: *Vent Doux*, *Lumière Tardive*, *Herbe Mouillée*, *Cloche Calme*, *Mer Sombre* +- es: *Viento Suave*, *Luz Tardía*, *Hierba Húmeda*, *Campana Quieta* +- ja: *小波* (sazanami), *夕風* (yūkaze), *雨上がり* (ame-agari), *月待ち* (tsuki-machi) +- zh-Hans: *雪落松间*, *林晚风*, *秋千上*, *夜归人* +- zh-Hant: *雪落松間*, *林晚風*, *秋千上*, *夜歸人* + +Deterministic per `(author_id, letter_id)` — same draft re-rendered = same pseudonym until user explicitly regenerates. + +## Routes / step state + +New `JournalStep`s: + +- `'circulation-feed'` — LettersInCirculationScreen +- `'circulation-share'` — ShareALetterScreen +- `'circulation-settings'` — opt-in / TTL prefs + +Entry: HomeScreen tile + footer link. + +## Phasing + +| Phase | Surface | Approx tokens | Ship as | +|---|---|---|---| +| 1 (here) | spec + migration + types | ~5k output | PR for review | +| 2 | edge fns: moderate + circulate-cron | ~15k | small PR | +| 3 | hooks (3) + screens (3) | ~30k | small PR | +| 4 | animation + Lovable display prompt | ~15k | small PR + .md | +| 5 | tests + bilingual sweep + changelog | ~15k | wrap PR | + +Total: ~80k output tokens, ~$1.50 at Opus 4.7 rates, ~3 hrs wall. + +## Open questions for the reviewer (you) + +1. Should the share button live on the existing `JournalEntry` (after writing, "would you like to release this?") OR in its own flow only? — *Default: own flow only, to keep the journal pure and the share intentional.* +2. Should the "Hold this for a moment" reaction be silent (no notification to author until they check) or noisy? — *Default: silent. Author sees count when they visit.* +3. Default TTL: 7, 14, or 30 days? — *Default: 14.* +4. Should non-opted-in users see the *teaser* (drift animation) without content? — *Default: yes — drift animation with placeholder letters that say "join to read."* +5. Cross-language: *Should an `fr` user be able to share an `en` letter?* — *Default: no — primary language only.* + +If any of those defaults are wrong, say so before Phase 2. Otherwise I'll proceed. diff --git a/supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql b/supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql new file mode 100644 index 0000000..7c1eb12 --- /dev/null +++ b/supabase/migrations/20260613113018_0e009da1-76c8-47dd-922c-68733c4f5e69.sql @@ -0,0 +1,160 @@ + +-- Circulation of Love — anonymous time-bound story sharing. +-- See docs/specs/love-circulation.md for the full design. + +CREATE TABLE public.love_letters ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + author_id TEXT NOT NULL, + content TEXT NOT NULL CHECK (length(content) BETWEEN 1 AND 500), + language TEXT NOT NULL CHECK (language IN ('en','fr','es','ja','zh-Hans','zh-Hant')), + pseudonym TEXT NOT NULL, + moderated_status TEXT NOT NULL DEFAULT 'pending' + CHECK (moderated_status IN ('pending','passed','softfailed','blocked')), + moderation_note TEXT, + posted_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + archived BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE TABLE public.letter_holdings ( + letter_id UUID NOT NULL REFERENCES public.love_letters(id) ON DELETE CASCADE, + holder_id TEXT NOT NULL, + held_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + PRIMARY KEY (letter_id, holder_id) +); + +CREATE TABLE public.circulation_settings ( + user_id TEXT NOT NULL PRIMARY KEY, + receive_letters BOOLEAN NOT NULL DEFAULT false, + share_letters BOOLEAN NOT NULL DEFAULT false, + ttl_days INTEGER NOT NULL DEFAULT 14 CHECK (ttl_days IN (7,14,30)), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +-- Indexes for the hot query paths: +-- (1) "show me the live current in my language" — most frequent +CREATE INDEX idx_love_letters_current + ON public.love_letters(language, moderated_status, archived, expires_at) + WHERE moderated_status = 'passed' AND archived = false; +-- (2) "all my letters, including archived" +CREATE INDEX idx_love_letters_author + ON public.love_letters(author_id, created_at DESC); +-- (3) "how many holds does this letter have" +CREATE INDEX idx_letter_holdings_letter + ON public.letter_holdings(letter_id); + +-- RLS — see spec for full rationale. + +ALTER TABLE public.love_letters ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.letter_holdings ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.circulation_settings ENABLE ROW LEVEL SECURITY; + +-- love_letters policies + +-- Author always sees their own letters (every status, every archived state). +CREATE POLICY "Authors see their own letters" ON public.love_letters + FOR SELECT TO authenticated + USING (author_id = auth.uid()::text); + +-- Other users see only: passed + not archived + matching language + reader opted in. +CREATE POLICY "Opted-in users see live current in their language" ON public.love_letters + FOR SELECT TO authenticated + USING ( + author_id <> auth.uid()::text + AND moderated_status = 'passed' + AND archived = false + AND expires_at > now() + AND EXISTS ( + SELECT 1 FROM public.circulation_settings s + WHERE s.user_id = auth.uid()::text + AND s.receive_letters = true + ) + ); + +CREATE POLICY "Authors insert their own letters" ON public.love_letters + FOR INSERT TO authenticated + WITH CHECK (author_id = auth.uid()::text AND moderated_status = 'pending'); + +-- Authors can only flip `archived` on their own row. Other columns are immutable from client. +CREATE POLICY "Authors can archive their own letters" ON public.love_letters + FOR UPDATE TO authenticated + USING (author_id = auth.uid()::text) + WITH CHECK (author_id = auth.uid()::text); + +CREATE POLICY "Authors can delete their own letters" ON public.love_letters + FOR DELETE TO authenticated + USING (author_id = auth.uid()::text); + +-- Service role moderates + cycles expiry. +CREATE POLICY "Service role full access love_letters" ON public.love_letters + FOR ALL TO service_role USING (true); + +-- letter_holdings policies + +-- Holder sees their own holdings (used by "letters I've held" view). +CREATE POLICY "Holders see their own holdings" ON public.letter_holdings + FOR SELECT TO authenticated + USING (holder_id = auth.uid()::text); + +-- Authors can read holdings on their own letters to see the count. +-- (Reading individual rows is fine — the UI only shows the aggregate; we don't expose +-- holder_id in the client query for author views.) +CREATE POLICY "Authors see holdings on their letters" ON public.letter_holdings + FOR SELECT TO authenticated + USING ( + EXISTS ( + SELECT 1 FROM public.love_letters l + WHERE l.id = letter_holdings.letter_id + AND l.author_id = auth.uid()::text + ) + ); + +-- Hold a letter: must not be your own, must be live, must have receive_letters=true. +CREATE POLICY "Opted-in users hold live letters that are not their own" ON public.letter_holdings + FOR INSERT TO authenticated + WITH CHECK ( + holder_id = auth.uid()::text + AND EXISTS ( + SELECT 1 FROM public.love_letters l + WHERE l.id = letter_holdings.letter_id + AND l.moderated_status = 'passed' + AND l.archived = false + AND l.expires_at > now() + AND l.author_id <> auth.uid()::text + ) + AND EXISTS ( + SELECT 1 FROM public.circulation_settings s + WHERE s.user_id = auth.uid()::text + AND s.receive_letters = true + ) + ); + +CREATE POLICY "Holders can unhold" ON public.letter_holdings + FOR DELETE TO authenticated + USING (holder_id = auth.uid()::text); + +CREATE POLICY "Service role full access letter_holdings" ON public.letter_holdings + FOR ALL TO service_role USING (true); + +-- circulation_settings policies + +CREATE POLICY "Users see their own circulation settings" ON public.circulation_settings + FOR SELECT TO authenticated + USING (user_id = auth.uid()::text); + +CREATE POLICY "Users insert their own circulation settings" ON public.circulation_settings + FOR INSERT TO authenticated + WITH CHECK (user_id = auth.uid()::text); + +CREATE POLICY "Users update their own circulation settings" ON public.circulation_settings + FOR UPDATE TO authenticated + USING (user_id = auth.uid()::text) + WITH CHECK (user_id = auth.uid()::text); + +CREATE POLICY "Users delete their own circulation settings" ON public.circulation_settings + FOR DELETE TO authenticated + USING (user_id = auth.uid()::text); + +CREATE POLICY "Service role full access circulation_settings" ON public.circulation_settings + FOR ALL TO service_role USING (true); From 1b397b5f939914ecb578144127c594fe578ce5b5 Mon Sep 17 00:00:00 2001 From: Train Chen Date: Thu, 18 Jun 2026 00:51:46 +0200 Subject: [PATCH 2/6] types(circulation): regenerate Database types for 3 new tables Adds circulation_settings, letter_holdings, love_letters rows/inserts/updates to match supabase/migrations/20260613113018. Mirrors the regen Supabase would emit (alphabetical key order, no extra fields). Closes Phase 1. Co-Authored-By: Claude Opus 4.7 --- src/integrations/supabase/types.ts | 92 ++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index b53e24e..feca94d 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -14,6 +14,30 @@ export type Database = { } public: { Tables: { + circulation_settings: { + Row: { + receive_letters: boolean + share_letters: boolean + ttl_days: number + updated_at: string + user_id: string + } + Insert: { + receive_letters?: boolean + share_letters?: boolean + ttl_days?: number + updated_at?: string + user_id: string + } + Update: { + receive_letters?: boolean + share_letters?: boolean + ttl_days?: number + updated_at?: string + user_id?: string + } + Relationships: [] + } cluster_thoughts: { Row: { added_at: string @@ -223,6 +247,74 @@ export type Database = { } Relationships: [] } + letter_holdings: { + Row: { + held_at: string + holder_id: string + letter_id: string + } + Insert: { + held_at?: string + holder_id: string + letter_id: string + } + Update: { + held_at?: string + holder_id?: string + letter_id?: string + } + Relationships: [ + { + foreignKeyName: "letter_holdings_letter_id_fkey" + columns: ["letter_id"] + isOneToOne: false + referencedRelation: "love_letters" + referencedColumns: ["id"] + }, + ] + } + love_letters: { + Row: { + archived: boolean + author_id: string + content: string + created_at: string + expires_at: string | null + id: string + language: string + moderated_status: string + moderation_note: string | null + posted_at: string | null + pseudonym: string + } + Insert: { + archived?: boolean + author_id: string + content: string + created_at?: string + expires_at?: string | null + id?: string + language: string + moderated_status?: string + moderation_note?: string | null + posted_at?: string | null + pseudonym: string + } + Update: { + archived?: boolean + author_id?: string + content?: string + created_at?: string + expires_at?: string | null + id?: string + language?: string + moderated_status?: string + moderation_note?: string | null + posted_at?: string | null + pseudonym?: string + } + Relationships: [] + } pro_waitlist: { Row: { created_at: string From 4acb587e2bb77f871c28141b69468e77a4eccb04 Mon Sep 17 00:00:00 2001 From: Train Chen Date: Thu, 18 Jun 2026 09:54:04 +0200 Subject: [PATCH 3/6] feat(circulation): moderate-letter + circulate-letters edge fns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moderate-letter: - Validates auth via shared requireAuth helper - Calls Gemini 2.5 Flash with carefully reviewed moderation prompt (pass: sad/grief/anger OK; softfail: ambiguous self-harm + PII + named-person; block: explicit suicidal intent, harassment, sexual, spam, doxxing) - Inserts the row server-side with verdict baked in (service role) so the client cannot bypass moderation - Fails closed on gateway errors (softfail, never auto-publish) circulate-letters: - Flips archived=true on every expired letter; idempotent - Auth via CIRCULATION_CRON_SECRET (distinct from user JWT — leaked user token can't trigger archival) - Returns { archived: count, at: timestamp } config.toml updated; both fns set verify_jwt = false because they gate auth internally (matches existing pattern). Co-Authored-By: Claude Opus 4.7 --- supabase/config.toml | 6 + supabase/functions/circulate-letters/index.ts | 60 ++++++ supabase/functions/moderate-letter/index.ts | 173 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 supabase/functions/circulate-letters/index.ts create mode 100644 supabase/functions/moderate-letter/index.ts diff --git a/supabase/config.toml b/supabase/config.toml index 368656a..33d1261 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -11,3 +11,9 @@ verify_jwt = false [functions.generate-embedding] verify_jwt = false + +[functions.moderate-letter] +verify_jwt = false + +[functions.circulate-letters] +verify_jwt = false diff --git a/supabase/functions/circulate-letters/index.ts b/supabase/functions/circulate-letters/index.ts new file mode 100644 index 0000000..9a5e4fb --- /dev/null +++ b/supabase/functions/circulate-letters/index.ts @@ -0,0 +1,60 @@ +// Cron-style sweep for the Circulation of Love. +// +// Flips `archived = true` on every `love_letters` row whose `expires_at < now()` +// and is still `archived = false`. Authors retain access (RLS); other users +// stop seeing it. +// +// Invocation: either Supabase Scheduled Function (preferred — daily at 03:00) +// or external cron. Idempotent — running it twice in a row is a no-op. +// +// Auth: requires a Bearer token equal to env CIRCULATION_CRON_SECRET. The +// secret is set in supabase secrets and rotated by Train. Distinct from +// user-facing edge fns so a leaked user JWT cannot trigger archival. + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, content-type", +}; + +serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + const expected = Deno.env.get("CIRCULATION_CRON_SECRET"); + const provided = req.headers.get("Authorization")?.replace(/^Bearer\s+/i, ""); + if (!expected || provided !== expected) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceKey) { + return new Response(JSON.stringify({ error: "Storage not configured" }), { + status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const admin = createClient(supabaseUrl, serviceKey); + + const nowIso = new Date().toISOString(); + const { data, error } = await admin + .from("love_letters") + .update({ archived: true }) + .lt("expires_at", nowIso) + .eq("archived", false) + .select("id"); + + if (error) { + console.error("circulate-letters: archive error", error); + return new Response(JSON.stringify({ error: "Archive failed" }), { + status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ archived: data?.length ?? 0, at: nowIso }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +}); diff --git a/supabase/functions/moderate-letter/index.ts b/supabase/functions/moderate-letter/index.ts new file mode 100644 index 0000000..3fd078d --- /dev/null +++ b/supabase/functions/moderate-letter/index.ts @@ -0,0 +1,173 @@ +// Pre-publish moderation for Circulation of Love letters. +// +// Flow: client sends { content, language, pseudonym, ttl_days }. We run the +// content past Gemini 2.5 Flash, then insert the row with the verdict baked +// in — atomic moderation + insert so the client cannot post a row whose +// moderation state was decided client-side. +// +// Spec: docs/specs/love-circulation.md (Moderation — non-negotiable). + +import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { requireAuth, badRequest, isStringWithin } from "../_shared/auth.ts"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +const VALID_LANGS = ["en", "fr", "es", "ja", "zh-Hans", "zh-Hant"] as const; +const VALID_TTL = [7, 14, 30] as const; +type Lang = typeof VALID_LANGS[number]; +type TTL = typeof VALID_TTL[number]; + +// Reviewed carefully — see spec for rationale. +const moderationSystemPrompt = `You are reviewing a short, anonymous letter someone wants to share publicly with strangers who speak their language. Letters live for 7-30 days, then archive. The goal of the system is to circulate hope, grief, comfort, and small honest moments — NOT a feel-good filter. + +You return JSON only: +{ "decision": "pass" | "softfail" | "block", "note": "<≤25 words, written TO the author in their letter's language; only present when decision is softfail or block>" } + +PASS (the wide door — this is the whole point of the feature): +- Sad, lonely, angry, grieving, exhausted, ambivalent, bittersweet, regretful +- Small ordinary moments (the tea was good, the bus was late, the cat slept on me) +- Mental health symptoms described non-acutely +- Religious or philosophical reflection if non-extremist +- Imperfect prose, fragments, typos — leave them alone +- Mild profanity used non-aggressively + +SOFTFAIL (gentle rewrite suggestion — author re-edits and retries): +- Vague mention of suicide/self-harm that may or may not be acute — ask the author to clarify if they're in crisis (and offer the crisis line) OR rewrite as reflection +- Content that names a specific real person identifiably (even non-malicious) — ask them to use initials or a relationship word +- Plausibly accidental PII (an address, phone number, email) — ask them to remove it +- Content that reads like a private message to one person ("I miss you so much, please call me") — ask them to make it more universal + +BLOCK (do not enter circulation): +- Explicit suicidal intent, method, or plan in present tense ("tonight I will...", "I have the pills") +- Targeted harassment of a person, group, religion, or identity +- Sexually explicit content +- Spam, promotion, links, codes, contact info as the primary message +- Hate speech, slurs, dehumanization +- Doxxing (full name + location, real phone number, etc.) + +Tone of the note (softfail and block): +- Soft, second person, NEVER shaming +- For self-harm cases, include a crisis line phrase like "If you are in crisis right now, please call your local emergency line or a crisis hotline." +- Match the letter's language (en, fr, es, ja, zh-Hans, zh-Hant). +- ≤25 words. + +Reply with JSON only, no markdown, no preface. If the input is empty or only whitespace, decision = "block", note in English saying "Your letter is empty."`; + +interface ModerationVerdict { + decision: "pass" | "softfail" | "block"; + note: string | null; +} + +async function moderate(apiKey: string, content: string, language: Lang): Promise { + const userMessage = `Letter language: ${language}\nLetter content:\n"""\n${content}\n"""`; + const response = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "google/gemini-2.5-flash", + messages: [ + { role: "system", content: moderationSystemPrompt }, + { role: "user", content: userMessage }, + ], + }), + }); + + if (!response.ok) { + // Fail closed — never auto-publish on gateway error. + console.error("moderate-letter: gateway error", response.status, await response.text()); + return { decision: "softfail", note: "We couldn't review your letter just now. Please try again in a moment." }; + } + const data = await response.json(); + const raw = data.choices?.[0]?.message?.content ?? ""; + + let parsed: { decision?: unknown; note?: unknown } = {}; + try { parsed = JSON.parse(raw); } catch { /* fall through */ } + + const decision = parsed.decision === "pass" || parsed.decision === "softfail" || parsed.decision === "block" + ? parsed.decision + : "softfail"; + const note = typeof parsed.note === "string" ? parsed.note.slice(0, 300) : null; + return { decision, note: decision === "pass" ? null : note }; +} + +serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + try { + const auth = await requireAuth(req, corsHeaders); + if (!auth.ok) return auth.response; + + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") return badRequest("Invalid JSON body", corsHeaders); + + const { content, language, pseudonym, ttl_days } = body as Record; + if (!isStringWithin(content, 1, 500)) return badRequest("content must be 1-500 chars", corsHeaders); + if (typeof language !== "string" || !(VALID_LANGS as readonly string[]).includes(language)) { + return badRequest("language must be one of: " + VALID_LANGS.join(", "), corsHeaders); + } + if (!isStringWithin(pseudonym, 1, 60)) return badRequest("pseudonym must be 1-60 chars", corsHeaders); + const ttl = typeof ttl_days === "number" && (VALID_TTL as readonly number[]).includes(ttl_days) + ? (ttl_days as TTL) : 14; + + const apiKey = Deno.env.get("LOVABLE_API_KEY"); + if (!apiKey) { + return new Response(JSON.stringify({ error: "AI service not configured", code: "API_KEY_MISSING" }), { + status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const verdict = await moderate(apiKey, content as string, language as Lang); + + // Insert the row with the verdict baked in. Uses service-role so RLS + // does not block us writing `moderated_status` other than 'pending'. + const supabaseUrl = Deno.env.get("SUPABASE_URL"); + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!supabaseUrl || !serviceKey) { + return new Response(JSON.stringify({ error: "Storage not configured" }), { + status: 503, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + const admin = createClient(supabaseUrl, serviceKey); + + const now = new Date(); + const expires = verdict.decision === "pass" + ? new Date(now.getTime() + ttl * 24 * 60 * 60 * 1000) + : null; + + const insert = await admin.from("love_letters").insert({ + author_id: auth.userId, + content: content as string, + language: language as Lang, + pseudonym: pseudonym as string, + moderated_status: verdict.decision === "pass" ? "passed" : verdict.decision === "softfail" ? "softfailed" : "blocked", + moderation_note: verdict.note, + posted_at: verdict.decision === "pass" ? now.toISOString() : null, + expires_at: expires?.toISOString() ?? null, + }).select("id, moderated_status, moderation_note, posted_at, expires_at, pseudonym").single(); + + if (insert.error) { + console.error("moderate-letter: insert error", insert.error); + return new Response(JSON.stringify({ error: "Could not save your letter", code: "INSERT_FAILED" }), { + status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ + letter: insert.data, + verdict: verdict.decision, + note: verdict.note, + }), { headers: { ...corsHeaders, "Content-Type": "application/json" } }); + } catch (error) { + console.error("moderate-letter error:", error); + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +}); From 8de69735429c03f83f750ca4e6b0604d77817552 Mon Sep 17 00:00:00 2001 From: Train Chen Date: Thu, 18 Jun 2026 15:14:26 +0200 Subject: [PATCH 4/6] feat(circulation): hooks + 3 screens + HomeScreen tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-facing surface for the Circulation of Love: Data layer - usePseudonym(language, seed): deterministic name from per-language pool, regenerable ONCE per draft (matches spec rule) - useCirculationSettings: opt-in toggles + TTL chooser, upsert-style - useLoveLetters: list + share via moderate-letter edge fn + idempotent hold/unhold Screens - LettersInCirculationScreen: drift feed with "Join the current" CTA for non-opted-in users; tap card → modal with single "Hold this for a moment" reaction - ShareALetterScreen: 500-char textarea + pseudonym chip + softfail/ block note rendering; release calls moderate-letter - CirculationSettingsScreen: receive/share toggles + 7/14/30 TTL Wiring - 3 new JournalSteps: 'circulation-feed' | 'circulation-share' | 'circulation-settings' - useJournal exposes openCirculationFeed/Share/Settings - JournalApp dispatches them - HomeScreen gains a Waves-icon tile that opens the feed Animation class `animate-letter-drift` is referenced on cards but the keyframes ship in Phase 4 — silent no-op until then. Co-Authored-By: Claude Opus 4.7 --- .../journal/CirculationSettingsScreen.tsx | 111 ++++++++++ src/components/journal/HomeScreen.tsx | 24 ++- src/components/journal/JournalApp.tsx | 26 +++ .../journal/LettersInCirculationScreen.tsx | 204 ++++++++++++++++++ src/components/journal/ShareALetterScreen.tsx | 148 +++++++++++++ src/hooks/useCirculationSettings.ts | 85 ++++++++ src/hooks/useJournal.ts | 3 + src/hooks/useLoveLetters.ts | 129 +++++++++++ src/hooks/usePseudonym.ts | 26 +++ src/lib/pseudonyms.ts | 76 +++++++ src/types/journal.ts | 1 + 11 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 src/components/journal/CirculationSettingsScreen.tsx create mode 100644 src/components/journal/LettersInCirculationScreen.tsx create mode 100644 src/components/journal/ShareALetterScreen.tsx create mode 100644 src/hooks/useCirculationSettings.ts create mode 100644 src/hooks/useLoveLetters.ts create mode 100644 src/hooks/usePseudonym.ts create mode 100644 src/lib/pseudonyms.ts diff --git a/src/components/journal/CirculationSettingsScreen.tsx b/src/components/journal/CirculationSettingsScreen.tsx new file mode 100644 index 0000000..def2584 --- /dev/null +++ b/src/components/journal/CirculationSettingsScreen.tsx @@ -0,0 +1,111 @@ +import { ArrowLeft } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { useCirculationSettings, type TTLDays } from '@/hooks/useCirculationSettings'; + +interface CirculationSettingsScreenProps { + onBack: () => void; +} + +export function CirculationSettingsScreen({ onBack }: CirculationSettingsScreenProps) { + const { t, bilingual } = useLanguage(); + const { settings, loading, error, update } = useCirculationSettings(); + + const ttlChoices: TTLDays[] = [7, 14, 30]; + + return ( +
+
+ + +
+

+ {bilingual({ fr: "Circulation d'amour", en: 'Circulation of Love', es: 'Circulación del Amor', ja: '愛の循環', 'zh-Hans': '爱的流转', 'zh-Hant': '愛的流轉' })} +

+

+ {t({ + fr: "Partager une lettre courte et anonyme avec d'autres dans votre langue. Elle dérive pendant un temps, puis se range.", + en: 'Share a short, anonymous letter with others in your language. It drifts for a while, then quietly archives.', + es: 'Comparte una carta corta y anónima con otros en tu idioma. Deriva un tiempo y luego se archiva en silencio.', + ja: '短い匿名の手紙を、同じ言語の人たちと分かち合う。しばらく漂い、静かに片付きます。', + 'zh-Hans': '用你的语言分享一封简短的匿名信。它在水流中漂一段时间,然后静静归档。', + 'zh-Hant': '用你的語言分享一封簡短的匿名信。它在水流中漂一段時間,然後靜靜歸檔。', + }).primary} +

+
+ +
+ + + + +
+

+ {t({ fr: 'Durée du courant', en: 'How long each letter drifts', es: 'Cuánto tiempo deriva cada carta', ja: '手紙が漂う期間', 'zh-Hans': '每封信漂流的时间', 'zh-Hant': '每封信漂流的時間' }).primary} +

+
+ {ttlChoices.map(days => ( + + ))} +
+
+ + {error && ( +

+ {t({ fr: "On n'a pas pu enregistrer. Réessayez.", en: "We couldn't save. Try again.", es: 'No pudimos guardar. Inténtalo de nuevo.', ja: '保存できませんでした。もう一度お試しください。', 'zh-Hans': '没能保存。请再试一次。', 'zh-Hant': '沒能保存。請再試一次。' }).primary} +

+ )} +
+
+
+ ); +} diff --git a/src/components/journal/HomeScreen.tsx b/src/components/journal/HomeScreen.tsx index c0f9109..0c9280e 100644 --- a/src/components/journal/HomeScreen.tsx +++ b/src/components/journal/HomeScreen.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; -import { Feather, CheckCircle2, Zap, Sprout, LogOut, Flame, CalendarDays, Mountain, PenLine, Trophy, Hourglass, Target, ListChecks, ChevronDown, ChevronUp, FlaskConical, Activity, LayoutGrid, Sparkles, Heart } from 'lucide-react'; +import { Feather, CheckCircle2, Zap, Sprout, LogOut, Flame, CalendarDays, Mountain, PenLine, Trophy, Hourglass, Target, ListChecks, ChevronDown, ChevronUp, FlaskConical, Activity, LayoutGrid, Sparkles, Heart, Waves } from 'lucide-react'; import { useState } from 'react'; import { BADGES } from '@/types/journal'; import { useLanguage } from '@/contexts/LanguageContext'; @@ -37,9 +37,10 @@ interface HomeScreenProps { onOpenLanguageSettings: () => void; onOpenVocabulary: () => void; onOpenProWaitlist: () => void; + onOpenCirculationFeed: () => void; } -export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, earnedBadges, onStartJournal, onStartFreeWrite, onViewProgress, onOpenChat, onOpenBrainDump, onOpenSmallWins, onOpenThoughtGarden, onOpenZenGarden, onOpenSandTimer, onOpenFocusPlan, onOpenTodoList, onOpenTinyExperiment, onOpenBodyScan, onOpenQuadrants, onOpenLanguageSettings, onOpenVocabulary, onOpenProWaitlist }: HomeScreenProps) { +export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, earnedBadges, onStartJournal, onStartFreeWrite, onViewProgress, onOpenChat, onOpenBrainDump, onOpenSmallWins, onOpenThoughtGarden, onOpenZenGarden, onOpenSandTimer, onOpenFocusPlan, onOpenTodoList, onOpenTinyExperiment, onOpenBodyScan, onOpenQuadrants, onOpenLanguageSettings, onOpenVocabulary, onOpenProWaitlist, onOpenCirculationFeed }: HomeScreenProps) { const { submitted: proWaitlistSubmitted } = useProWaitlist(); const { bilingual, t, targetLang } = useLanguage(); const isFr = targetLang === 'fr'; @@ -316,6 +317,25 @@ export function HomeScreen({ hasJournaledToday, streak, totalDays, totalWords, e + {/* Circulation of Love — opt-in anonymous sharing in primary language. */} + +

{t({ fr: 'Une ou deux phrases suffisent.', en: 'One or two sentences is enough.', es: 'Una o dos frases bastan.', ja: '一文か二文で十分です。', 'zh-Hans': '一两句话就够了。', 'zh-Hant': '一兩句話就夠了。' }).primary}

diff --git a/src/components/journal/JournalApp.tsx b/src/components/journal/JournalApp.tsx index 51c0664..d74920e 100644 --- a/src/components/journal/JournalApp.tsx +++ b/src/components/journal/JournalApp.tsx @@ -31,6 +31,9 @@ import { TodoListScreen } from './TodoListScreen'; import { TinyExperimentScreen } from './TinyExperimentScreen'; import { QuadrantsScreen } from './QuadrantsScreen'; import { ProWaitlistScreen } from './ProWaitlistScreen'; +import { LettersInCirculationScreen } from './LettersInCirculationScreen'; +import { ShareALetterScreen } from './ShareALetterScreen'; +import { CirculationSettingsScreen } from './CirculationSettingsScreen'; import { LanguageSettingsScreen } from './LanguageSettingsScreen'; import { PhilosopherQuoteDialog } from './PhilosopherQuoteDialog'; @@ -119,6 +122,9 @@ export function JournalApp() { openProWaitlist, openBodyScan, openLanguageSettings, + openCirculationFeed, + openCirculationShare, + openCirculationSettings, goBackToEmotions, openVocabulary, vocabOrigin, @@ -161,6 +167,7 @@ export function JournalApp() { onOpenBodyScan={openBodyScan} onOpenQuadrants={openQuadrants} onOpenProWaitlist={openProWaitlist} + onOpenCirculationFeed={openCirculationFeed} onOpenLanguageSettings={openLanguageSettings} onOpenVocabulary={() => openVocabulary('home')} /> @@ -349,6 +356,25 @@ export function JournalApp() { )} + {currentStep === 'circulation-feed' && ( + + )} + + {currentStep === 'circulation-share' && ( + + )} + + {currentStep === 'circulation-settings' && ( + + )} + {currentStep === 'languagesettings' && ( )} diff --git a/src/components/journal/LettersInCirculationScreen.tsx b/src/components/journal/LettersInCirculationScreen.tsx new file mode 100644 index 0000000..d014e04 --- /dev/null +++ b/src/components/journal/LettersInCirculationScreen.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { ArrowLeft, Heart, Settings, Send } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { useLoveLetters, type LoveLetter } from '@/hooks/useLoveLetters'; +import { useCirculationSettings } from '@/hooks/useCirculationSettings'; + +interface LettersInCirculationScreenProps { + onBack: () => void; + onOpenShare: () => void; + onOpenSettings: () => void; +} + +function daysRemaining(expires: string | null): number { + if (!expires) return 0; + const ms = new Date(expires).getTime() - Date.now(); + return Math.max(0, Math.ceil(ms / (1000 * 60 * 60 * 24))); +} + +export function LettersInCirculationScreen({ onBack, onOpenShare, onOpenSettings }: LettersInCirculationScreenProps) { + const { t, bilingual } = useLanguage(); + const { settings, loading: settingsLoading, update } = useCirculationSettings(); + const { current, loading, error, hold } = useLoveLetters(); + const [opened, setOpened] = useState(null); + const [heldIds, setHeldIds] = useState>(new Set()); + + async function handleHold(id: string) { + if (heldIds.has(id)) return; + setHeldIds(prev => new Set(prev).add(id)); + await hold(id); + } + + const optedIn = settings.receive_letters; + + return ( +
+
+
+ + +
+ +
+

+ {bilingual({ fr: "Circulation d'amour", en: 'Circulation of Love', es: 'Circulación del Amor', ja: '愛の循環', 'zh-Hans': '爱的流转', 'zh-Hant': '愛的流轉' })} +

+

+ {t({ + fr: 'Des lettres anonymes dans votre langue. Elles dérivent quelques jours, puis se rangent.', + en: 'Anonymous letters in your language. They drift for a few days, then quietly archive.', + es: 'Cartas anónimas en tu idioma. Derivan unos días y luego se archivan en silencio.', + ja: 'あなたの言語の匿名の手紙。数日漂って、静かに片付きます。', + 'zh-Hans': '用你的语言写的匿名信。漂上几天,然后静静归档。', + 'zh-Hant': '用你的語言寫的匿名信。漂上幾天,然後靜靜歸檔。', + }).primary} +

+
+ + {!optedIn && !settingsLoading && ( +
+ +

+ {t({ fr: 'Rejoindre le courant', en: 'Join the current', es: 'Únete a la corriente', ja: '流れに加わる', 'zh-Hans': '加入水流', 'zh-Hant': '加入水流' }).primary} +

+

+ {t({ + fr: 'Activez la réception pour voir les lettres des autres. Vous restez anonyme.', + en: 'Turn on receiving to see letters from others. You stay anonymous.', + es: 'Activa la recepción para ver cartas de otros. Sigues siendo anónimo.', + ja: '受信をオンにすると、他の人の手紙が見えます。あなたは匿名のまま。', + 'zh-Hans': '打开接收,就能看到他人的来信。你仍是匿名的。', + 'zh-Hant': '打開接收,就能看到他人的來信。你仍是匿名的。', + }).primary} +

+ +
+ )} + + {optedIn && ( + <> + + + {loading && ( +

+ {t({ fr: 'Le courant arrive…', en: 'The current is arriving…', es: 'La corriente llega…', ja: '流れが届きます…', 'zh-Hans': '水流正在到来…', 'zh-Hant': '水流正在到來…' }).primary} +

+ )} + + {!loading && current.length === 0 && !error && ( +

+ {t({ + fr: "Le courant est calme. Soyez la première lettre aujourd'hui.", + en: 'The current is still. Be the first letter today.', + es: 'La corriente está en calma. Sé la primera carta de hoy.', + ja: '流れは静かです。今日の最初の手紙になってみる。', + 'zh-Hans': '水流很静。试着成为今天的第一封信。', + 'zh-Hant': '水流很靜。試著成為今天的第一封信。', + }).primary} +

+ )} + +
+ {current.map((letter, i) => { + const held = heldIds.has(letter.id); + const days = daysRemaining(letter.expires_at); + return ( + + ); + })} +
+ + {error && ( +

+ {t({ fr: "On n'a pas pu charger. Réessayez.", en: "We couldn't load. Try again.", es: 'No pudimos cargar. Inténtalo de nuevo.', ja: '読み込めませんでした。もう一度お試しください。', 'zh-Hans': '没能加载。请再试一次。', 'zh-Hant': '沒能加載。請再試一次。' }).primary} +

+ )} + + )} +
+ + {opened && ( +
setOpened(null)} + > +
e.stopPropagation()} + > +

+ {opened.content} +

+
+ {opened.pseudonym} + + {t({ fr: `${daysRemaining(opened.expires_at)}j`, en: `${daysRemaining(opened.expires_at)}d`, es: `${daysRemaining(opened.expires_at)}d`, ja: `あと${daysRemaining(opened.expires_at)}日`, 'zh-Hans': `还有 ${daysRemaining(opened.expires_at)} 天`, 'zh-Hant': `還有 ${daysRemaining(opened.expires_at)} 天` }).primary} + +
+ +
+
+ )} +
+ ); +} diff --git a/src/components/journal/ShareALetterScreen.tsx b/src/components/journal/ShareALetterScreen.tsx new file mode 100644 index 0000000..38a64d8 --- /dev/null +++ b/src/components/journal/ShareALetterScreen.tsx @@ -0,0 +1,148 @@ +import { useId, useState } from 'react'; +import { ArrowLeft, RefreshCw, Send } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useLanguage } from '@/contexts/LanguageContext'; +import { useLoveLetters, type ModerationVerdict } from '@/hooks/useLoveLetters'; +import { useCirculationSettings } from '@/hooks/useCirculationSettings'; +import { usePseudonym } from '@/hooks/usePseudonym'; +import type { PseudonymLang } from '@/lib/pseudonyms'; + +interface ShareALetterScreenProps { + onBack: () => void; + onReleased: () => void; +} + +const MAX = 500; + +export function ShareALetterScreen({ onBack, onReleased }: ShareALetterScreenProps) { + const { t, bilingual, primaryLang } = useLanguage(); + const { share } = useLoveLetters(); + const { settings } = useCirculationSettings(); + const draftId = useId(); + const { pseudonym, regenerate, canRegenerate } = usePseudonym(primaryLang as PseudonymLang, draftId); + const [content, setContent] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [verdict, setVerdict] = useState(null); + const [note, setNote] = useState(null); + + const remaining = MAX - content.length; + const tooLong = content.length > MAX; + const trimmedEmpty = content.trim().length === 0; + const disabled = submitting || trimmedEmpty || tooLong; + + async function handleRelease() { + if (disabled) return; + setSubmitting(true); + setVerdict(null); + setNote(null); + const result = await share({ + content: content.trim(), + pseudonym, + ttl_days: settings.ttl_days, + }); + setSubmitting(false); + if (!result.ok) { + setVerdict('softfail'); + setNote(t({ fr: 'On n\'a pas pu envoyer. Réessayez.', en: "We couldn't send. Try again.", es: 'No pudimos enviar. Inténtalo de nuevo.', ja: '送信できませんでした。もう一度お試しください。', 'zh-Hans': '没能寄出。请再试一次。', 'zh-Hant': '沒能寄出。請再試一次。' }).primary); + return; + } + setVerdict(result.verdict); + setNote(result.note); + if (result.verdict === 'pass') { + onReleased(); + } + } + + return ( +
+
+ + +
+

+ {bilingual({ fr: 'Relâcher une lettre', en: 'Release a letter', es: 'Soltar una carta', ja: '手紙を放つ', 'zh-Hans': '放出一封信', 'zh-Hant': '放出一封信' })} +

+

+ {t({ + fr: "Une lettre courte, anonyme, dans votre langue. Elle dérive pendant {n} jours, puis se range.", + en: 'A short, anonymous letter in your language. It drifts for {n} days, then quietly archives.', + es: 'Una carta corta y anónima en tu idioma. Deriva {n} días y luego se archiva en silencio.', + ja: 'あなたの言語で書く短い匿名の手紙。{n}日漂って、静かに片付きます。', + 'zh-Hans': '一封简短的匿名信,用你的语言。漂流 {n} 天,然后静静归档。', + 'zh-Hant': '一封簡短的匿名信,用你的語言。漂流 {n} 天,然後靜靜歸檔。', + }).primary.replace('{n}', String(settings.ttl_days))} +

+
+ +
+ + {pseudonym} + + +
+ +