diff --git a/docs/agent-runs/directory-routes/feature-list.json b/docs/agent-runs/directory-routes/feature-list.json new file mode 100644 index 00000000..b74d498a --- /dev/null +++ b/docs/agent-runs/directory-routes/feature-list.json @@ -0,0 +1,98 @@ +{ + "task": "directory-routes", + "last_updated": "2026-07-19", + "items": [ + { + "id": "F001", + "priority": 1, + "status": "completed", + "description": "Load and validate the small Seedit directory registry without allowing directory slugs into account subscriptions.", + "verification": [ + "Focused directory-list normalization and winner-selection tests pass." + ], + "files": [ + "src/data/seedit-directories", + "src/data/vendored-directory-lists.ts", + "src/hooks/use-directory-list.ts", + "src/lib/utils/directory-list-utils.ts" + ], + "notes": "Directory data is allowed only for short-route discovery and consented update offers." + }, + { + "id": "F002", + "priority": 2, + "status": "completed", + "description": "Resolve reserved /s/ routes to the current winning community while keeping explicit address and post routes canonical.", + "verification": [ + "/s/funny renders the current winner, /s/funny.bso remains exact, and all /comments/ paths contain an exact address." + ], + "files": [ + "src/hooks/use-resolved-community-route.ts", + "src/lib/utils/community-route-utils.ts", + "src/views/community/community.tsx", + "src/views/post-page/post-page.tsx", + "src/components/exact-community-action-route" + ], + "notes": "Home and subscription navigation must never resolve directory slugs at read time." + }, + { + "id": "F003", + "priority": 3, + "status": "completed", + "description": "Disclose the exact winner on directory routes and make the existing Join action subscribe only to that exact address.", + "verification": [ + "Desktop and mobile browser checks confirm one Join meaning and a keyboard-accessible exact-address link." + ], + "files": [ + "src/components/sidebar/sidebar.tsx", + "src/components/header/header.tsx", + "src/components/subscribe-button/subscribe-button.tsx", + "src/components/directory-auto-switch-control" + ], + "notes": "Preserve Seedit's compact old.reddit visual language." + }, + { + "id": "F004", + "priority": 4, + "status": "completed", + "description": "Offer consented add, keep, keep-both, or per-directory auto-follow behavior when a directory winner changes, while persisting exact addresses only.", + "verification": [ + "Focused transition tests prove manual subscriptions survive and auto-follow rewrites only subscriptions attributable to the changed slot." + ], + "files": [ + "src/components/directory-subscription-reconciler", + "src/components/directory-subscription-updates-notice", + "src/lib/utils/directory-subscriptions.ts", + "src/lib/utils/directory-account-transforms.ts", + "src/lib/utils/starter-account.ts" + ], + "notes": "No silent changes without a prior explicit opt-in, and every automatic change receives an informational notice." + }, + { + "id": "F005", + "priority": 5, + "status": "completed", + "description": "Document the route-only directory boundary and update user-facing copy and generated AI context where required.", + "verification": [ + "Documentation states that directories govern entry routes, while subscriptions and permalinks remain exact." + ], + "files": [ + "docs/architecture/directory-routes.md", + "public/translations" + ], + "notes": "Run yarn llms:generate for public-facing documentation changes." + }, + { + "id": "F006", + "priority": 6, + "status": "completed", + "description": "Complete static, unit, React quality, multi-engine browser, responsive, theme, and throttled navigation verification.", + "verification": [ + "168 tests, production build, lint, type-check, Knip, AI workflow parity, changed-scope React Doctor, generated LLM context, and git diff --check pass.", + "Chrome, Firefox, and WebKit pass at 1440x1000 and 390x844; Chromium also passes warm navigation under the repository's mid-throttle profile." + ], + "files": [], + "notes": "Verified with Node 22.12.0 and fresh isolated browser sessions. Full-repository React Doctor retains pre-existing findings outside this change; changed-scope React Doctor reports no issues." + } + ] +} diff --git a/docs/agent-runs/directory-routes/progress.md b/docs/agent-runs/directory-routes/progress.md new file mode 100644 index 00000000..29dcb312 --- /dev/null +++ b/docs/agent-runs/directory-routes/progress.md @@ -0,0 +1,24 @@ +# Progress Log + +## 2026-07-19 + +- Item: F001 +- Summary: Created the isolated worktree, loaded product and design context, started three read-only audits, and confirmed the supported agent-init command starts the branch-scoped development server. +- Files: `docs/agent-runs/directory-routes/feature-list.json`, `docs/agent-runs/directory-routes/progress.md` +- Verification: `./scripts/agent-init.sh` +- Blockers: none +- Next: Finish source archaeology and implement the directory registry as a route-only input. + +- Item: F001-F005 +- Summary: Implemented versioned deterministic directory snapshots, route-only resolution, exact-address Join and permalink behavior, atomic subscription provenance, consented winner-change actions, per-slot automatic switching with safe undo, exact action-route redirects, localized disclosure/update UI, and the architecture decision record. +- Files: `src/data/seedit-directories/`, `src/hooks/use-directory-list.ts`, `src/hooks/use-resolved-community-route.ts`, `src/lib/utils/directory-subscriptions.ts`, `src/lib/utils/directory-account-transforms.ts`, `src/components/directory-*`, `src/components/exact-community-action-route/`, `docs/architecture/directory-routes.md` +- Verification: 25 test files / 168 tests; build, lint, type-check, Knip, AI workflow parity, generated LLM context, changed-scope React Doctor, translation coverage, and `git diff --check` pass. +- Blockers: Full-repository React Doctor still exits on pre-existing findings outside this change; changed-scope React Doctor reports no issues. +- Next: Finish the multi-engine desktop/mobile and throttled Chromium browser pass, then split the Seedit diff into focused commits without pushing it. + +- Item: F006 +- Summary: Completed the final quality gate and browser matrix. Directory aliases disclose and join the exact winner, exact-address routes remain exact, submit aliases canonicalize before rendering, and no directory slug enters account subscriptions. +- Files: `docs/agent-runs/directory-routes/feature-list.json`, `docs/agent-runs/directory-routes/progress.md` +- Verification: 25 test files / 168 tests; build, lint, type-check, Knip, AI workflow parity, changed-scope React Doctor, generated LLM context, and `git diff --check`; Chrome, Firefox, and WebKit at 1440x1000 and 390x844; Chromium mid-throttle warm navigation; light and dark themes; no horizontal overflow. +- Blockers: Full-repository React Doctor retains pre-existing findings outside this change; external P2P router CORS/404/521 failures were observed during browser verification but produced no directory-feature exceptions. +- Next: Review the four focused local commits; keep the Seedit branch unpushed until explicitly approved. diff --git a/docs/architecture/directory-routes.md b/docs/architecture/directory-routes.md new file mode 100644 index 00000000..586350b9 --- /dev/null +++ b/docs/architecture/directory-routes.md @@ -0,0 +1,56 @@ +# Seedit directory routes + +## Status + +Accepted. + +## Decision + +Seedit directory codes are mutable discovery routes, not subscription identities. + +- `/s/funny` resolves the finalized `funny` directory snapshot and displays its deterministic winner. +- `/s/funny.bso` and `/s/funny-posting.bso` identify exact communities. +- `account.subscriptions` contains only exact community addresses or public keys. A directory code is never stored there. +- The home feed reads the exact subscription array directly and never resolves directory codes. +- Joining from `/s/funny` atomically subscribes to the exact current winner and records Seedit-only directory provenance. +- Generated post permalinks always use the post owner's exact community address and `/comments/`. +- A winner change does not alter a subscription by default. Seedit presents an inline choice to switch, keep both, or keep the current community. +- Automatic switching is an explicit per-directory preference. An automatic change produces a persistent notice with undo. + +The short route is therefore a contested front door. The user's feed and durable links remain pinned to identities they can inspect and carry to another Bitsocial client. + +## Snapshot rules + +Directory payloads use `schemaVersion: 1` and a monotonically increasing `revision`. The revision is the atomic change boundary: candidate membership or winner selection must not change at the same revision. + +Candidates are ordered deterministically by: + +1. score, descending; +2. `addedAt`, ascending; +3. exact address, ascending. + +Clients do not rotate to another candidate based on local availability. Every client using the same finalized snapshot must resolve the same winner. + +The current `bitsocialnet/lists` publication is a bootstrap transport for these snapshots. It does not define the eventual decentralized voting or finalization protocol. + +## Subscription provenance + +Seedit stores directory provenance separately from the portable exact subscription array. Each tracked slot records: + +- its exact attributable subscription address; +- the last acknowledged winner address and revision; +- whether the user explicitly enabled automatic switching. + +This metadata lets Seedit offer winner-change actions without inventing a second kind of subscription. Other Bitsocial clients can ignore it and still read the user's exact subscriptions correctly. + +## Defaults + +Seedit's default-subscription list remains a list of exact addresses. A default entry may also include `directoryCode` and `directoryRevision` to record which finalized directory snapshot selected it. New accounts receive exact subscriptions plus the matching Seedit provenance; existing accounts retain their exact choices until they act on an update or previously enabled automatic switching. + +## Rejected alternatives + +- Directory codes in `account.subscriptions`: this makes feeds change at read time and is not portable across clients. +- Directory-coded post permalinks: a winner change can make an old post URL resolve through the wrong community. +- Client-local offline failover: two clients could resolve the same short route to different communities. +- Permanent Seedit-owned community subdomains: community identity should remain portable between Bitsocial clients. +- Administrator allocation of short names: this recreates the centralized veto power the directory mechanism is meant to replace. diff --git a/package.json b/package.json index bbcd2da9..88baa9db 100755 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "start:preview": "node scripts/start-preview.js", "ai-workflow:check": "node scripts/validate-ai-workflow.mjs", "llms:generate": "node scripts/generate-llms-files.mjs", + "sync:directories": "node scripts/sync-directories.js", "build": "cross-env NODE_ENV=production PUBLIC_URL=./ GENERATE_SOURCEMAP=false vite build", "build:preload": "cross-env NODE_ENV=production vite build --config electron/vite.preload.config.js", "build-netlify": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" NODE_ENV=production PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build", diff --git a/public/translations/ar/default.json b/public/translations/ar/default.json index 2f2152ef..8b10cf9b 100644 --- a/public/translations/ar/default.json +++ b/public/translations/ar/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "تعذّر حفظ ترقية الإعدادات.", "settings_upgrade_review_notice": "تتوفر ترقية للإعدادات", "settings_upgrade_review_button": "مراجعة", - "settings_upgrade_never_show_again": "عدم العرض مرة أخرى" + "settings_upgrade_never_show_again": "عدم العرض مرة أخرى", + "directory_route_currently_recommends": "يوصي s/{{directoryCode}} حاليًا بـ", + "directory_auto_switch_label": "تبديل هذا الاشتراك تلقائيًا عندما يتغير s/{{directoryCode}}", + "directory_recommendation_changed": "يوصي s/{{directoryCode}} الآن بـ", + "directory_auto_switch_help": "سيستبدل Seedit هذا الاشتراك بالمجتمع الموصى به حديثًا. يمكنك إيقاف هذا الخيار في أي وقت.", + "directory_you_still_follow": "لا تزال تتابع", + "switch_to_community": "التبديل إلى {{communityAddress}}", + "keep_both_communities": "الاحتفاظ بكليهما", + "keep_community": "الاحتفاظ بـ {{communityAddress}}", + "directory_automatic_switch_notice": "تغيّر s/{{directoryCode}}، لذا حوّل Seedit اشتراكك من {{fromAddress}} إلى {{toAddress}}.", + "dismiss": "تجاهل" } diff --git a/public/translations/bn/default.json b/public/translations/bn/default.json index 37f918f5..60f42b5a 100644 --- a/public/translations/bn/default.json +++ b/public/translations/bn/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "সেটিংস আপগ্রেড সংরক্ষণ করা যায়নি।", "settings_upgrade_review_notice": "সেটিংস আপগ্রেড উপলভ্য", "settings_upgrade_review_button": "পর্যালোচনা করুন", - "settings_upgrade_never_show_again": "আর কখনও দেখাবেন না" + "settings_upgrade_never_show_again": "আর কখনও দেখাবেন না", + "directory_route_currently_recommends": "s/{{directoryCode}}-এর বর্তমান সুপারিশ:", + "directory_auto_switch_label": "s/{{directoryCode}} পরিবর্তন হলে স্বয়ংক্রিয়ভাবে এই সাবস্ক্রিপশনটি পরিবর্তন করুন", + "directory_recommendation_changed": "s/{{directoryCode}} এখন সুপারিশ করছে", + "directory_auto_switch_help": "Seedit এই সাবস্ক্রিপশনটি নতুনভাবে সুপারিশ করা কমিউনিটি দিয়ে প্রতিস্থাপন করবে। আপনি যেকোনো সময় এটি বন্ধ করতে পারেন।", + "directory_you_still_follow": "আপনি এখনও অনুসরণ করছেন", + "switch_to_community": "{{communityAddress}}-এ স্যুইচ করুন", + "keep_both_communities": "দুটিই রাখুন", + "keep_community": "{{communityAddress}} রাখুন", + "directory_automatic_switch_notice": "s/{{directoryCode}} পরিবর্তিত হয়েছে, তাই Seedit আপনার সাবস্ক্রিপশন {{fromAddress}} থেকে {{toAddress}}-এ পরিবর্তন করেছে।", + "dismiss": "খারিজ করুন" } diff --git a/public/translations/cs/default.json b/public/translations/cs/default.json index 0fc072b3..13ef786b 100644 --- a/public/translations/cs/default.json +++ b/public/translations/cs/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Upgrade nastavení se nepodařilo uložit.", "settings_upgrade_review_notice": "je k dispozici upgrade nastavení", "settings_upgrade_review_button": "zkontrolovat", - "settings_upgrade_never_show_again": "již nikdy nezobrazovat" + "settings_upgrade_never_show_again": "již nikdy nezobrazovat", + "directory_route_currently_recommends": "s/{{directoryCode}} aktuálně doporučuje", + "directory_auto_switch_label": "automaticky přepnout toto předplatné, když se s/{{directoryCode}} změní", + "directory_recommendation_changed": "s/{{directoryCode}} nyní doporučuje", + "directory_auto_switch_help": "Seedit nahradí toto předplatné nově doporučenou komunitou. Tuto možnost můžete kdykoli vypnout.", + "directory_you_still_follow": "Stále sledujete", + "switch_to_community": "přejít na {{communityAddress}}", + "keep_both_communities": "ponechat obě", + "keep_community": "ponechat {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} se změnilo, takže Seedit převedl vaše předplatné z {{fromAddress}} na {{toAddress}}.", + "dismiss": "Zavřít" } diff --git a/public/translations/da/default.json b/public/translations/da/default.json index ca36d778..48e24dfb 100644 --- a/public/translations/da/default.json +++ b/public/translations/da/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Indstillingsopgraderingen kunne ikke gemmes.", "settings_upgrade_review_notice": "opgradering af indstillinger er tilgængelig", "settings_upgrade_review_button": "gennemgå", - "settings_upgrade_never_show_again": "vis aldrig igen" + "settings_upgrade_never_show_again": "vis aldrig igen", + "directory_route_currently_recommends": "s/{{directoryCode}} anbefaler i øjeblikket", + "directory_auto_switch_label": "skift automatisk dette abonnement, når s/{{directoryCode}} ændres", + "directory_recommendation_changed": "s/{{directoryCode}} anbefaler nu", + "directory_auto_switch_help": "Seedit erstatter dette abonnement med det nyligt anbefalede fællesskab. Du kan til enhver tid slå dette fra.", + "directory_you_still_follow": "Du følger stadig", + "switch_to_community": "skift til {{communityAddress}}", + "keep_both_communities": "behold begge", + "keep_community": "behold {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} er ændret, så Seedit skiftede dit abonnement fra {{fromAddress}} til {{toAddress}}.", + "dismiss": "Luk" } diff --git a/public/translations/de/default.json b/public/translations/de/default.json index 72e98ae2..bcb825c5 100644 --- a/public/translations/de/default.json +++ b/public/translations/de/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Das Einstellungs-Upgrade konnte nicht gespeichert werden.", "settings_upgrade_review_notice": "Upgrade für Einstellungen verfügbar", "settings_upgrade_review_button": "prüfen", - "settings_upgrade_never_show_again": "nie wieder anzeigen" + "settings_upgrade_never_show_again": "nie wieder anzeigen", + "directory_route_currently_recommends": "s/{{directoryCode}} empfiehlt derzeit", + "directory_auto_switch_label": "dieses Abonnement automatisch wechseln, wenn sich s/{{directoryCode}} ändert", + "directory_recommendation_changed": "s/{{directoryCode}} empfiehlt jetzt", + "directory_auto_switch_help": "Seedit ersetzt dieses Abonnement durch die neu empfohlene Community. Du kannst dies jederzeit deaktivieren.", + "directory_you_still_follow": "Du folgst weiterhin", + "switch_to_community": "zu {{communityAddress}} wechseln", + "keep_both_communities": "beide behalten", + "keep_community": "{{communityAddress}} behalten", + "directory_automatic_switch_notice": "s/{{directoryCode}} hat sich geändert, daher hat Seedit dein Abonnement von {{fromAddress}} zu {{toAddress}} gewechselt.", + "dismiss": "Ausblenden" } diff --git a/public/translations/el/default.json b/public/translations/el/default.json index d0157951..3f02fa8f 100644 --- a/public/translations/el/default.json +++ b/public/translations/el/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Δεν ήταν δυνατή η αποθήκευση της αναβάθμισης ρυθμίσεων.", "settings_upgrade_review_notice": "διατίθεται αναβάθμιση ρυθμίσεων", "settings_upgrade_review_button": "έλεγχος", - "settings_upgrade_never_show_again": "να μην εμφανιστεί ξανά" + "settings_upgrade_never_show_again": "να μην εμφανιστεί ξανά", + "directory_route_currently_recommends": "Το s/{{directoryCode}} προτείνει αυτή τη στιγμή", + "directory_auto_switch_label": "αυτόματη αλλαγή αυτής της συνδρομής όταν αλλάζει το s/{{directoryCode}}", + "directory_recommendation_changed": "Το s/{{directoryCode}} προτείνει τώρα", + "directory_auto_switch_help": "Το Seedit θα αντικαταστήσει αυτήν τη συνδρομή με τη νέα προτεινόμενη κοινότητα. Μπορείτε να απενεργοποιήσετε αυτήν την επιλογή ανά πάσα στιγμή.", + "directory_you_still_follow": "Εξακολουθείτε να ακολουθείτε", + "switch_to_community": "μετάβαση στο {{communityAddress}}", + "keep_both_communities": "διατήρηση και των δύο", + "keep_community": "διατήρηση του {{communityAddress}}", + "directory_automatic_switch_notice": "Το s/{{directoryCode}} άλλαξε, οπότε το Seedit μετέφερε τη συνδρομή σας από το {{fromAddress}} στο {{toAddress}}.", + "dismiss": "Κλείσιμο" } diff --git a/public/translations/en/default.json b/public/translations/en/default.json index 5719a4b5..3048f3f3 100644 --- a/public/translations/en/default.json +++ b/public/translations/en/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Could not save the settings upgrade.", "settings_upgrade_review_notice": "settings upgrade available", "settings_upgrade_review_button": "review", - "settings_upgrade_never_show_again": "never show again" + "settings_upgrade_never_show_again": "never show again", + "directory_route_currently_recommends": "s/{{directoryCode}} currently recommends", + "directory_auto_switch_label": "automatically switch this subscription when s/{{directoryCode}} changes", + "directory_recommendation_changed": "s/{{directoryCode}} now recommends", + "directory_auto_switch_help": "Seedit will replace this subscription with the newly recommended community. You can turn this off anytime.", + "directory_you_still_follow": "You still follow", + "switch_to_community": "switch to {{communityAddress}}", + "keep_both_communities": "keep both", + "keep_community": "keep {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} changed, so Seedit switched your subscription from {{fromAddress}} to {{toAddress}}.", + "dismiss": "dismiss" } diff --git a/public/translations/es/default.json b/public/translations/es/default.json index ec4c0bee..1bf3ce3c 100644 --- a/public/translations/es/default.json +++ b/public/translations/es/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "No se pudo guardar la actualización de la configuración.", "settings_upgrade_review_notice": "actualización de ajustes disponible", "settings_upgrade_review_button": "revisar", - "settings_upgrade_never_show_again": "no volver a mostrar" + "settings_upgrade_never_show_again": "no volver a mostrar", + "directory_route_currently_recommends": "s/{{directoryCode}} recomienda actualmente", + "directory_auto_switch_label": "cambiar automáticamente esta suscripción cuando cambie s/{{directoryCode}}", + "directory_recommendation_changed": "s/{{directoryCode}} ahora recomienda", + "directory_auto_switch_help": "Seedit reemplazará esta suscripción por la nueva comunidad recomendada. Puedes desactivar esta opción en cualquier momento.", + "directory_you_still_follow": "Sigues siguiendo", + "switch_to_community": "cambiar a {{communityAddress}}", + "keep_both_communities": "conservar ambas", + "keep_community": "conservar {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} cambió, así que Seedit cambió tu suscripción de {{fromAddress}} a {{toAddress}}.", + "dismiss": "descartar" } diff --git a/public/translations/fa/default.json b/public/translations/fa/default.json index f53c30cd..5970c882 100644 --- a/public/translations/fa/default.json +++ b/public/translations/fa/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "ذخیره ارتقای تنظیمات ممکن نشد.", "settings_upgrade_review_notice": "ارتقای تنظیمات در دسترس است", "settings_upgrade_review_button": "بررسی", - "settings_upgrade_never_show_again": "دیگر نشان نده" + "settings_upgrade_never_show_again": "دیگر نشان نده", + "directory_route_currently_recommends": "پیشنهاد فعلی s/{{directoryCode}}:", + "directory_auto_switch_label": "وقتی s/{{directoryCode}} تغییر می‌کند، این اشتراک به‌طور خودکار تغییر کند", + "directory_recommendation_changed": "s/{{directoryCode}} اکنون پیشنهاد می‌کند", + "directory_auto_switch_help": "Seedit این اشتراک را با جامعهٔ تازه پیشنهادی جایگزین می‌کند. هر زمان بخواهید می‌توانید این گزینه را غیرفعال کنید.", + "directory_you_still_follow": "شما همچنان دنبال می‌کنید", + "switch_to_community": "تغییر به {{communityAddress}}", + "keep_both_communities": "هر دو را نگه دارید", + "keep_community": "حفظ {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} تغییر کرد، بنابراین Seedit اشتراک شما را از {{fromAddress}} به {{toAddress}} منتقل کرد.", + "dismiss": "بستن" } diff --git a/public/translations/fi/default.json b/public/translations/fi/default.json index 6145c6d0..83414f72 100644 --- a/public/translations/fi/default.json +++ b/public/translations/fi/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Asetusten päivitystä ei voitu tallentaa.", "settings_upgrade_review_notice": "asetusten päivitys saatavilla", "settings_upgrade_review_button": "tarkista", - "settings_upgrade_never_show_again": "älä näytä enää" + "settings_upgrade_never_show_again": "älä näytä enää", + "directory_route_currently_recommends": "s/{{directoryCode}} suosittelee tällä hetkellä", + "directory_auto_switch_label": "vaihda tämä tilaus automaattisesti, kun s/{{directoryCode}} muuttuu", + "directory_recommendation_changed": "s/{{directoryCode}} suosittelee nyt", + "directory_auto_switch_help": "Seedit korvaa tämän tilauksen uudella suositellulla yhteisöllä. Voit poistaa tämän käytöstä milloin tahansa.", + "directory_you_still_follow": "Seuraat edelleen", + "switch_to_community": "vaihda yhteisöön {{communityAddress}}", + "keep_both_communities": "pidä molemmat", + "keep_community": "säilytä {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} muuttui, joten Seedit vaihtoi tilauksesi yhteisöstä {{fromAddress}} yhteisöön {{toAddress}}.", + "dismiss": "Sulje" } diff --git a/public/translations/fil/default.json b/public/translations/fil/default.json index 20517f3f..6a9124da 100644 --- a/public/translations/fil/default.json +++ b/public/translations/fil/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Hindi ma-save ang pag-upgrade ng mga setting.", "settings_upgrade_review_notice": "magagamit na ang pag-upgrade ng mga setting", "settings_upgrade_review_button": "suriin", - "settings_upgrade_never_show_again": "huwag nang ipakita muli" + "settings_upgrade_never_show_again": "huwag nang ipakita muli", + "directory_route_currently_recommends": "Kasalukuyang inirerekomenda ng s/{{directoryCode}} ang", + "directory_auto_switch_label": "awtomatikong ilipat ang subscription na ito kapag nagbago ang s/{{directoryCode}}", + "directory_recommendation_changed": "Inirerekomenda na ngayon ng s/{{directoryCode}} ang", + "directory_auto_switch_help": "Papalitan ng Seedit ang subscription na ito ng bagong inirerekomendang komunidad. Maaari mo itong i-off anumang oras.", + "directory_you_still_follow": "Sinusubaybayan mo pa rin", + "switch_to_community": "lumipat sa {{communityAddress}}", + "keep_both_communities": "panatilihin pareho", + "keep_community": "panatilihin ang {{communityAddress}}", + "directory_automatic_switch_notice": "Nagbago ang s/{{directoryCode}}, kaya inilipat ng Seedit ang subscription mo mula {{fromAddress}} papuntang {{toAddress}}.", + "dismiss": "Isara" } diff --git a/public/translations/fr/default.json b/public/translations/fr/default.json index c57f7fb7..b2a0368f 100644 --- a/public/translations/fr/default.json +++ b/public/translations/fr/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Impossible d’enregistrer la mise à niveau des paramètres.", "settings_upgrade_review_notice": "mise à niveau des paramètres disponible", "settings_upgrade_review_button": "examiner", - "settings_upgrade_never_show_again": "ne plus afficher" + "settings_upgrade_never_show_again": "ne plus afficher", + "directory_route_currently_recommends": "s/{{directoryCode}} recommande actuellement", + "directory_auto_switch_label": "changer automatiquement cet abonnement lorsque s/{{directoryCode}} change", + "directory_recommendation_changed": "s/{{directoryCode}} recommande maintenant", + "directory_auto_switch_help": "Seedit remplacera cet abonnement par la nouvelle communauté recommandée. Vous pouvez désactiver cette option à tout moment.", + "directory_you_still_follow": "Vous suivez toujours", + "switch_to_community": "passer à {{communityAddress}}", + "keep_both_communities": "garder les deux", + "keep_community": "conserver {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} a changé, Seedit a donc remplacé votre abonnement à {{fromAddress}} par {{toAddress}}.", + "dismiss": "ignorer" } diff --git a/public/translations/he/default.json b/public/translations/he/default.json index 968b317f..8607d672 100644 --- a/public/translations/he/default.json +++ b/public/translations/he/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "לא ניתן היה לשמור את שדרוג ההגדרות.", "settings_upgrade_review_notice": "שדרוג הגדרות זמין", "settings_upgrade_review_button": "סקירה", - "settings_upgrade_never_show_again": "לא להציג שוב" + "settings_upgrade_never_show_again": "לא להציג שוב", + "directory_route_currently_recommends": "s/{{directoryCode}} ממליץ כרגע על", + "directory_auto_switch_label": "להחליף את המינוי הזה אוטומטית כאשר s/{{directoryCode}} משתנה", + "directory_recommendation_changed": "s/{{directoryCode}} ממליץ עכשיו על", + "directory_auto_switch_help": "Seedit יחליף את המינוי הזה בקהילה החדשה שהומלצה. אפשר לכבות את האפשרות הזו בכל עת.", + "directory_you_still_follow": "עדיין במעקב אחר", + "switch_to_community": "מעבר אל {{communityAddress}}", + "keep_both_communities": "להשאיר את שניהם", + "keep_community": "להשאיר את {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} השתנה, ולכן Seedit החליף את המינוי שלך מ־{{fromAddress}} ל־{{toAddress}}.", + "dismiss": "סגירה" } diff --git a/public/translations/hi/default.json b/public/translations/hi/default.json index 9b257562..97f5b15b 100644 --- a/public/translations/hi/default.json +++ b/public/translations/hi/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "सेटिंग अपग्रेड को सहेजा नहीं जा सका।", "settings_upgrade_review_notice": "सेटिंग्स अपग्रेड उपलब्ध है", "settings_upgrade_review_button": "समीक्षा करें", - "settings_upgrade_never_show_again": "फिर कभी न दिखाएँ" + "settings_upgrade_never_show_again": "फिर कभी न दिखाएँ", + "directory_route_currently_recommends": "s/{{directoryCode}} की मौजूदा सिफ़ारिश:", + "directory_auto_switch_label": "s/{{directoryCode}} बदलने पर इस सदस्यता को अपने-आप बदलें", + "directory_recommendation_changed": "s/{{directoryCode}} अब सुझाता है", + "directory_auto_switch_help": "Seedit इस सदस्यता को नई सुझाई गई कम्युनिटी से बदल देगा। आप इसे कभी भी बंद कर सकते हैं।", + "directory_you_still_follow": "आप अभी भी फ़ॉलो करते हैं", + "switch_to_community": "{{communityAddress}} पर स्विच करें", + "keep_both_communities": "दोनों रखें", + "keep_community": "{{communityAddress}} को रखें", + "directory_automatic_switch_notice": "s/{{directoryCode}} बदल गया, इसलिए Seedit ने आपकी सदस्यता {{fromAddress}} से {{toAddress}} पर बदल दी।", + "dismiss": "बंद करें" } diff --git a/public/translations/hu/default.json b/public/translations/hu/default.json index edee0844..c361c243 100644 --- a/public/translations/hu/default.json +++ b/public/translations/hu/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Nem sikerült menteni a beállítások frissítését.", "settings_upgrade_review_notice": "beállításfrissítés érhető el", "settings_upgrade_review_button": "áttekintés", - "settings_upgrade_never_show_again": "soha ne jelenjen meg újra" + "settings_upgrade_never_show_again": "soha ne jelenjen meg újra", + "directory_route_currently_recommends": "Az s/{{directoryCode}} jelenleg ezt ajánlja:", + "directory_auto_switch_label": "automatikusan váltsa át ezt a feliratkozást, amikor az s/{{directoryCode}} megváltozik", + "directory_recommendation_changed": "Az s/{{directoryCode}} most ezt ajánlja:", + "directory_auto_switch_help": "A Seedit ezt a feliratkozást az újonnan ajánlott közösségre cseréli. Ezt bármikor kikapcsolhatod.", + "directory_you_still_follow": "Továbbra is követed", + "switch_to_community": "váltás erre: {{communityAddress}}", + "keep_both_communities": "mindkettő megtartása", + "keep_community": "{{communityAddress}} megtartása", + "directory_automatic_switch_notice": "Az s/{{directoryCode}} megváltozott, ezért a Seedit a feliratkozásodat {{fromAddress}} címről {{toAddress}} címre váltotta.", + "dismiss": "Bezárás" } diff --git a/public/translations/id/default.json b/public/translations/id/default.json index 8440624d..4509770f 100644 --- a/public/translations/id/default.json +++ b/public/translations/id/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Tidak dapat menyimpan peningkatan pengaturan.", "settings_upgrade_review_notice": "peningkatan pengaturan tersedia", "settings_upgrade_review_button": "tinjau", - "settings_upgrade_never_show_again": "jangan tampilkan lagi" + "settings_upgrade_never_show_again": "jangan tampilkan lagi", + "directory_route_currently_recommends": "s/{{directoryCode}} saat ini merekomendasikan", + "directory_auto_switch_label": "alihkan langganan ini secara otomatis saat s/{{directoryCode}} berubah", + "directory_recommendation_changed": "s/{{directoryCode}} sekarang merekomendasikan", + "directory_auto_switch_help": "Seedit akan mengganti langganan ini dengan komunitas yang baru direkomendasikan. Anda dapat menonaktifkannya kapan saja.", + "directory_you_still_follow": "Anda masih mengikuti", + "switch_to_community": "beralih ke {{communityAddress}}", + "keep_both_communities": "simpan keduanya", + "keep_community": "pertahankan {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} berubah, jadi Seedit mengalihkan langganan Anda dari {{fromAddress}} ke {{toAddress}}.", + "dismiss": "Tutup" } diff --git a/public/translations/it/default.json b/public/translations/it/default.json index 617d3ba6..b9c16378 100644 --- a/public/translations/it/default.json +++ b/public/translations/it/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Impossibile salvare l'aggiornamento delle impostazioni.", "settings_upgrade_review_notice": "aggiornamento delle impostazioni disponibile", "settings_upgrade_review_button": "rivedi", - "settings_upgrade_never_show_again": "non mostrare più" + "settings_upgrade_never_show_again": "non mostrare più", + "directory_route_currently_recommends": "s/{{directoryCode}} attualmente consiglia", + "directory_auto_switch_label": "cambia automaticamente questo abbonamento quando cambia s/{{directoryCode}}", + "directory_recommendation_changed": "s/{{directoryCode}} ora consiglia", + "directory_auto_switch_help": "Seedit sostituirà questa iscrizione con la nuova community consigliata. Puoi disattivare questa opzione in qualsiasi momento.", + "directory_you_still_follow": "Segui ancora", + "switch_to_community": "passa a {{communityAddress}}", + "keep_both_communities": "mantieni entrambe", + "keep_community": "mantieni {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} è cambiato, quindi Seedit ha spostato la tua iscrizione da {{fromAddress}} a {{toAddress}}.", + "dismiss": "ignora" } diff --git a/public/translations/ja/default.json b/public/translations/ja/default.json index f0b3d3b7..2d0d69d0 100644 --- a/public/translations/ja/default.json +++ b/public/translations/ja/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "設定のアップグレードを保存できませんでした。", "settings_upgrade_review_notice": "設定のアップグレードが利用可能です", "settings_upgrade_review_button": "確認", - "settings_upgrade_never_show_again": "今後表示しない" + "settings_upgrade_never_show_again": "今後表示しない", + "directory_route_currently_recommends": "s/{{directoryCode}}の現在のおすすめ:", + "directory_auto_switch_label": "s/{{directoryCode}} が変更されたときに、この購読を自動的に切り替える", + "directory_recommendation_changed": "s/{{directoryCode}} が現在おすすめしているコミュニティ:", + "directory_auto_switch_help": "Seedit はこの購読を新たにおすすめされたコミュニティに置き換えます。この設定はいつでもオフにできます。", + "directory_you_still_follow": "引き続きフォロー中", + "switch_to_community": "{{communityAddress}}に切り替える", + "keep_both_communities": "両方を維持", + "keep_community": "{{communityAddress}}を維持する", + "directory_automatic_switch_notice": "s/{{directoryCode}} が変更されたため、Seedit がサブスクリプションを {{fromAddress}} から {{toAddress}} に切り替えました。", + "dismiss": "閉じる" } diff --git a/public/translations/ko/default.json b/public/translations/ko/default.json index 9a639760..53219c13 100644 --- a/public/translations/ko/default.json +++ b/public/translations/ko/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "설정 업그레이드를 저장하지 못했습니다.", "settings_upgrade_review_notice": "설정 업그레이드 사용 가능", "settings_upgrade_review_button": "검토", - "settings_upgrade_never_show_again": "다시 표시하지 않기" + "settings_upgrade_never_show_again": "다시 표시하지 않기", + "directory_route_currently_recommends": "s/{{directoryCode}}의 현재 추천:", + "directory_auto_switch_label": "s/{{directoryCode}} 변경 시 이 구독을 자동으로 전환", + "directory_recommendation_changed": "s/{{directoryCode}}에서 현재 추천하는 커뮤니티:", + "directory_auto_switch_help": "Seedit이 이 구독을 새로 추천된 커뮤니티로 교체합니다. 언제든지 이 기능을 끌 수 있습니다.", + "directory_you_still_follow": "아직 팔로우 중", + "switch_to_community": "{{communityAddress}} 커뮤니티로 전환", + "keep_both_communities": "둘 다 유지", + "keep_community": "{{communityAddress}} 유지", + "directory_automatic_switch_notice": "s/{{directoryCode}}의 추천이 변경되어 Seedit에서 구독 대상을 {{fromAddress}}에서 {{toAddress}}로 전환했습니다.", + "dismiss": "닫기" } diff --git a/public/translations/mr/default.json b/public/translations/mr/default.json index 87ba0343..bd121bc2 100644 --- a/public/translations/mr/default.json +++ b/public/translations/mr/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "सेटिंग्ज अपग्रेड सेव्ह करता आले नाही.", "settings_upgrade_review_notice": "सेटिंग्ज अपग्रेड उपलब्ध आहे", "settings_upgrade_review_button": "पुनरावलोकन करा", - "settings_upgrade_never_show_again": "पुन्हा कधीही दाखवू नका" + "settings_upgrade_never_show_again": "पुन्हा कधीही दाखवू नका", + "directory_route_currently_recommends": "s/{{directoryCode}} ची सध्याची शिफारस:", + "directory_auto_switch_label": "s/{{directoryCode}} बदलल्यावर ही सदस्यता आपोआप बदला", + "directory_recommendation_changed": "s/{{directoryCode}} आता शिफारस करते:", + "directory_auto_switch_help": "Seedit ही सदस्यता नव्याने शिफारस केलेल्या समुदायाने बदलेल. तुम्ही हे कधीही बंद करू शकता.", + "directory_you_still_follow": "तुम्ही अजूनही फॉलो करता", + "switch_to_community": "{{communityAddress}} वर स्विच करा", + "keep_both_communities": "दोन्ही ठेवा", + "keep_community": "{{communityAddress}} ठेवा", + "directory_automatic_switch_notice": "s/{{directoryCode}} बदलले, त्यामुळे Seedit ने तुमचे सदस्यत्व {{fromAddress}} वरून {{toAddress}} वर बदलले.", + "dismiss": "बंद करा" } diff --git a/public/translations/nl/default.json b/public/translations/nl/default.json index 4d7465e0..9a8a0a40 100644 --- a/public/translations/nl/default.json +++ b/public/translations/nl/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "De instellingenupgrade kon niet worden opgeslagen.", "settings_upgrade_review_notice": "upgrade voor instellingen beschikbaar", "settings_upgrade_review_button": "bekijken", - "settings_upgrade_never_show_again": "nooit meer tonen" + "settings_upgrade_never_show_again": "nooit meer tonen", + "directory_route_currently_recommends": "Huidige aanbeveling van s/{{directoryCode}}:", + "directory_auto_switch_label": "schakel dit abonnement automatisch om wanneer s/{{directoryCode}} verandert", + "directory_recommendation_changed": "s/{{directoryCode}} beveelt nu aan", + "directory_auto_switch_help": "Seedit vervangt dit abonnement door de nieuw aanbevolen gemeenschap. Je kunt dit op elk moment uitschakelen.", + "directory_you_still_follow": "Je volgt nog steeds", + "switch_to_community": "overschakelen naar {{communityAddress}}", + "keep_both_communities": "beide behouden", + "keep_community": "{{communityAddress}} behouden", + "directory_automatic_switch_notice": "s/{{directoryCode}} is gewijzigd, dus Seedit heeft je abonnement van {{fromAddress}} naar {{toAddress}} overgezet.", + "dismiss": "Sluiten" } diff --git a/public/translations/no/default.json b/public/translations/no/default.json index 5fbf1dde..3579c224 100644 --- a/public/translations/no/default.json +++ b/public/translations/no/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Kunne ikke lagre innstillingsoppgraderingen.", "settings_upgrade_review_notice": "oppgradering av innstillinger tilgjengelig", "settings_upgrade_review_button": "gjennomgå", - "settings_upgrade_never_show_again": "vis aldri igjen" + "settings_upgrade_never_show_again": "vis aldri igjen", + "directory_route_currently_recommends": "s/{{directoryCode}} anbefaler for øyeblikket", + "directory_auto_switch_label": "bytt dette abonnementet automatisk når s/{{directoryCode}} endres", + "directory_recommendation_changed": "s/{{directoryCode}} anbefaler nå", + "directory_auto_switch_help": "Seedit erstatter dette abonnementet med det nylig anbefalte fellesskapet. Du kan slå av dette når som helst.", + "directory_you_still_follow": "Du følger fortsatt", + "switch_to_community": "bytt til {{communityAddress}}", + "keep_both_communities": "behold begge", + "keep_community": "behold {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} ble endret, så Seedit byttet abonnementet ditt fra {{fromAddress}} til {{toAddress}}.", + "dismiss": "Lukk" } diff --git a/public/translations/pl/default.json b/public/translations/pl/default.json index f9589d21..6a2e1dec 100644 --- a/public/translations/pl/default.json +++ b/public/translations/pl/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Nie udało się zapisać uaktualnienia ustawień.", "settings_upgrade_review_notice": "dostępna aktualizacja ustawień", "settings_upgrade_review_button": "przejrzyj", - "settings_upgrade_never_show_again": "nie pokazuj ponownie" + "settings_upgrade_never_show_again": "nie pokazuj ponownie", + "directory_route_currently_recommends": "s/{{directoryCode}} obecnie poleca", + "directory_auto_switch_label": "automatycznie przełącz tę subskrypcję, gdy s/{{directoryCode}} się zmieni", + "directory_recommendation_changed": "s/{{directoryCode}} poleca teraz", + "directory_auto_switch_help": "Seedit zastąpi tę subskrypcję nowo polecaną społecznością. Możesz to wyłączyć w dowolnym momencie.", + "directory_you_still_follow": "Nadal obserwujesz", + "switch_to_community": "przełącz na {{communityAddress}}", + "keep_both_communities": "zachowaj obie", + "keep_community": "zachowaj {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} zmieniło się, więc Seedit zmienił Twoją subskrypcję z {{fromAddress}} na {{toAddress}}.", + "dismiss": "Zamknij" } diff --git a/public/translations/pt/default.json b/public/translations/pt/default.json index fa45d2a1..41f5e0e4 100644 --- a/public/translations/pt/default.json +++ b/public/translations/pt/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Não foi possível salvar a atualização das configurações.", "settings_upgrade_review_notice": "atualização das configurações disponível", "settings_upgrade_review_button": "rever", - "settings_upgrade_never_show_again": "não mostrar novamente" + "settings_upgrade_never_show_again": "não mostrar novamente", + "directory_route_currently_recommends": "s/{{directoryCode}} recomenda atualmente", + "directory_auto_switch_label": "mudar automaticamente esta assinatura quando s/{{directoryCode}} mudar", + "directory_recommendation_changed": "s/{{directoryCode}} agora recomenda", + "directory_auto_switch_help": "O Seedit substituirá esta assinatura pela nova comunidade recomendada. Você pode desativar esta opção a qualquer momento.", + "directory_you_still_follow": "Você ainda segue", + "switch_to_community": "mudar para {{communityAddress}}", + "keep_both_communities": "manter ambas", + "keep_community": "manter {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} mudou, então o Seedit alterou sua assinatura de {{fromAddress}} para {{toAddress}}.", + "dismiss": "dispensar" } diff --git a/public/translations/ro/default.json b/public/translations/ro/default.json index c76073b1..78ef1a09 100644 --- a/public/translations/ro/default.json +++ b/public/translations/ro/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Nu s-a putut salva actualizarea setărilor.", "settings_upgrade_review_notice": "upgrade pentru setări disponibil", "settings_upgrade_review_button": "revizuiește", - "settings_upgrade_never_show_again": "nu mai afișa" + "settings_upgrade_never_show_again": "nu mai afișa", + "directory_route_currently_recommends": "s/{{directoryCode}} recomandă în prezent", + "directory_auto_switch_label": "schimbă automat acest abonament când se modifică s/{{directoryCode}}", + "directory_recommendation_changed": "s/{{directoryCode}} recomandă acum", + "directory_auto_switch_help": "Seedit va înlocui acest abonament cu noua comunitate recomandată. Poți dezactiva oricând această opțiune.", + "directory_you_still_follow": "Urmărești în continuare", + "switch_to_community": "comută la {{communityAddress}}", + "keep_both_communities": "păstrează-le pe ambele", + "keep_community": "păstrează {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} s-a schimbat, așa că Seedit ți-a mutat abonamentul de la {{fromAddress}} la {{toAddress}}.", + "dismiss": "Închide" } diff --git a/public/translations/ru/default.json b/public/translations/ru/default.json index f9ca4b1d..cca127d6 100644 --- a/public/translations/ru/default.json +++ b/public/translations/ru/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Не удалось сохранить обновление настроек.", "settings_upgrade_review_notice": "доступно обновление настроек", "settings_upgrade_review_button": "просмотреть", - "settings_upgrade_never_show_again": "больше не показывать" + "settings_upgrade_never_show_again": "больше не показывать", + "directory_route_currently_recommends": "s/{{directoryCode}} сейчас рекомендует", + "directory_auto_switch_label": "автоматически переключать эту подписку при изменении s/{{directoryCode}}", + "directory_recommendation_changed": "s/{{directoryCode}} теперь рекомендует", + "directory_auto_switch_help": "Seedit заменит эту подписку на новое рекомендованное сообщество. Вы можете отключить это в любое время.", + "directory_you_still_follow": "Вы всё ещё подписаны на", + "switch_to_community": "переключиться на {{communityAddress}}", + "keep_both_communities": "оставить оба", + "keep_community": "оставить {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} изменился, поэтому Seedit переключил вашу подписку с {{fromAddress}} на {{toAddress}}.", + "dismiss": "Закрыть" } diff --git a/public/translations/sq/default.json b/public/translations/sq/default.json index e5799b79..7af13779 100644 --- a/public/translations/sq/default.json +++ b/public/translations/sq/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Përmirësimi i cilësimeve nuk mund të ruhej.", "settings_upgrade_review_notice": "disponohet përmirësimi i rregullimeve", "settings_upgrade_review_button": "rishiko", - "settings_upgrade_never_show_again": "mos e shfaq më" + "settings_upgrade_never_show_again": "mos e shfaq më", + "directory_route_currently_recommends": "s/{{directoryCode}} rekomandon aktualisht", + "directory_auto_switch_label": "kalo automatikisht këtë abonim kur ndryshon s/{{directoryCode}}", + "directory_recommendation_changed": "s/{{directoryCode}} tani rekomandon", + "directory_auto_switch_help": "Seedit do ta zëvendësojë këtë abonim me komunitetin e rekomanduar rishtazi. Mund ta çaktivizoni këtë në çdo kohë.", + "directory_you_still_follow": "Vazhdon ta ndjekësh", + "switch_to_community": "kalo te {{communityAddress}}", + "keep_both_communities": "mbaji të dyja", + "keep_community": "mbaj {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} ndryshoi, ndaj Seedit e kaloi abonimin tënd nga {{fromAddress}} te {{toAddress}}.", + "dismiss": "Mbyll" } diff --git a/public/translations/sv/default.json b/public/translations/sv/default.json index f2dd68c1..58697e2b 100644 --- a/public/translations/sv/default.json +++ b/public/translations/sv/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Det gick inte att spara inställningsuppgraderingen.", "settings_upgrade_review_notice": "uppgradering av inställningar tillgänglig", "settings_upgrade_review_button": "granska", - "settings_upgrade_never_show_again": "visa aldrig igen" + "settings_upgrade_never_show_again": "visa aldrig igen", + "directory_route_currently_recommends": "s/{{directoryCode}} rekommenderar för närvarande", + "directory_auto_switch_label": "byt automatiskt den här prenumerationen när s/{{directoryCode}} ändras", + "directory_recommendation_changed": "s/{{directoryCode}} rekommenderar nu", + "directory_auto_switch_help": "Seedit ersätter den här prenumerationen med den nya rekommenderade communityn. Du kan stänga av detta när som helst.", + "directory_you_still_follow": "Du följer fortfarande", + "switch_to_community": "byt till {{communityAddress}}", + "keep_both_communities": "behåll båda", + "keep_community": "behåll {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} ändrades, så Seedit bytte din prenumeration från {{fromAddress}} till {{toAddress}}.", + "dismiss": "Stäng" } diff --git a/public/translations/te/default.json b/public/translations/te/default.json index d1c71547..7c4d17fd 100644 --- a/public/translations/te/default.json +++ b/public/translations/te/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "సెట్టింగ్‌ల అప్‌గ్రేడ్‌ను సేవ్ చేయడం సాధ్యపడలేదు.", "settings_upgrade_review_notice": "సెట్టింగ్‌ల అప్‌గ్రేడ్ అందుబాటులో ఉంది", "settings_upgrade_review_button": "సమీక్షించండి", - "settings_upgrade_never_show_again": "మళ్లీ ఎప్పుడూ చూపవద్దు" + "settings_upgrade_never_show_again": "మళ్లీ ఎప్పుడూ చూపవద్దు", + "directory_route_currently_recommends": "s/{{directoryCode}} యొక్క ప్రస్తుత సిఫార్సు:", + "directory_auto_switch_label": "s/{{directoryCode}} మారినప్పుడు ఈ సబ్‌స్క్రిప్షన్‌ను ఆటోమేటిక్‌గా మార్చండి", + "directory_recommendation_changed": "s/{{directoryCode}} ఇప్పుడు సిఫార్సు చేస్తోంది", + "directory_auto_switch_help": "Seedit ఈ సబ్‌స్క్రిప్షన్‌ను కొత్తగా సిఫార్సు చేసిన కమ్యూనిటీతో భర్తీ చేస్తుంది. మీరు దీన్ని ఎప్పుడైనా ఆఫ్ చేయవచ్చు.", + "directory_you_still_follow": "మీరు ఇప్పటికీ ఫాలో అవుతున్నారు", + "switch_to_community": "{{communityAddress}}కి మారండి", + "keep_both_communities": "రెండింటినీ ఉంచు", + "keep_community": "{{communityAddress}}ను అలాగే ఉంచండి", + "directory_automatic_switch_notice": "s/{{directoryCode}} మారింది, కాబట్టి Seedit మీ సబ్‌స్క్రిప్షన్‌ను {{fromAddress}} నుండి {{toAddress}}కి మార్చింది.", + "dismiss": "మూసివేయి" } diff --git a/public/translations/th/default.json b/public/translations/th/default.json index 8a383c95..7c7a1646 100644 --- a/public/translations/th/default.json +++ b/public/translations/th/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "ไม่สามารถบันทึกการอัปเกรดการตั้งค่าได้", "settings_upgrade_review_notice": "มีการอัปเกรดการตั้งค่า", "settings_upgrade_review_button": "ตรวจสอบ", - "settings_upgrade_never_show_again": "ไม่ต้องแสดงอีก" + "settings_upgrade_never_show_again": "ไม่ต้องแสดงอีก", + "directory_route_currently_recommends": "คำแนะนำปัจจุบันจาก s/{{directoryCode}}:", + "directory_auto_switch_label": "สลับการสมัครรับข้อมูลนี้โดยอัตโนมัติเมื่อ s/{{directoryCode}} เปลี่ยนแปลง", + "directory_recommendation_changed": "ตอนนี้ s/{{directoryCode}} แนะนำ", + "directory_auto_switch_help": "Seedit จะแทนที่การสมัครสมาชิกนี้ด้วยชุมชนใหม่ที่ได้รับการแนะนำ คุณสามารถปิดตัวเลือกนี้ได้ทุกเมื่อ", + "directory_you_still_follow": "คุณยังคงติดตาม", + "switch_to_community": "เปลี่ยนไปใช้ {{communityAddress}}", + "keep_both_communities": "เก็บไว้ทั้งคู่", + "keep_community": "เก็บ {{communityAddress}} ไว้", + "directory_automatic_switch_notice": "s/{{directoryCode}} มีการเปลี่ยนแปลง ดังนั้น Seedit จึงเปลี่ยนการสมัครของคุณจาก {{fromAddress}} เป็น {{toAddress}}", + "dismiss": "ปิด" } diff --git a/public/translations/tr/default.json b/public/translations/tr/default.json index 6c8c5b6e..a721c0ff 100644 --- a/public/translations/tr/default.json +++ b/public/translations/tr/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Ayar yükseltmesi kaydedilemedi.", "settings_upgrade_review_notice": "ayar yükseltmesi mevcut", "settings_upgrade_review_button": "incele", - "settings_upgrade_never_show_again": "bir daha gösterme" + "settings_upgrade_never_show_again": "bir daha gösterme", + "directory_route_currently_recommends": "s/{{directoryCode}} şu anda şunu öneriyor:", + "directory_auto_switch_label": "s/{{directoryCode}} değiştiğinde bu aboneliği otomatik olarak değiştir", + "directory_recommendation_changed": "s/{{directoryCode}} şimdi şunu öneriyor:", + "directory_auto_switch_help": "Seedit bu aboneliği yeni önerilen toplulukla değiştirecek. Bunu istediğiniz zaman kapatabilirsiniz.", + "directory_you_still_follow": "Hâlâ takip ediyorsunuz", + "switch_to_community": "{{communityAddress}} topluluğuna geç", + "keep_both_communities": "ikisini de tut", + "keep_community": "{{communityAddress}} topluluğunu koru", + "directory_automatic_switch_notice": "s/{{directoryCode}} değişti, bu nedenle Seedit aboneliğinizi {{fromAddress}} adresinden {{toAddress}} adresine geçirdi.", + "dismiss": "Kapat" } diff --git a/public/translations/uk/default.json b/public/translations/uk/default.json index 6990a684..fb27d241 100644 --- a/public/translations/uk/default.json +++ b/public/translations/uk/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Не вдалося зберегти оновлення налаштувань.", "settings_upgrade_review_notice": "доступне оновлення налаштувань", "settings_upgrade_review_button": "переглянути", - "settings_upgrade_never_show_again": "більше не показувати" + "settings_upgrade_never_show_again": "більше не показувати", + "directory_route_currently_recommends": "s/{{directoryCode}} наразі рекомендує", + "directory_auto_switch_label": "автоматично перемикати цю підписку, коли змінюється s/{{directoryCode}}", + "directory_recommendation_changed": "s/{{directoryCode}} тепер рекомендує", + "directory_auto_switch_help": "Seedit замінить цю підписку на щойно рекомендовану спільноту. Ви можете вимкнути це будь-коли.", + "directory_you_still_follow": "Ви все ще підписані на", + "switch_to_community": "перемкнутися на {{communityAddress}}", + "keep_both_communities": "залишити обидві", + "keep_community": "залишити {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} змінився, тому Seedit перемкнув вашу підписку з {{fromAddress}} на {{toAddress}}.", + "dismiss": "Закрити" } diff --git a/public/translations/ur/default.json b/public/translations/ur/default.json index be2be7f2..c9ee5d5a 100644 --- a/public/translations/ur/default.json +++ b/public/translations/ur/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "ترتیبات کی اپ گریڈ محفوظ نہیں کی جا سکی۔", "settings_upgrade_review_notice": "ترتیبات کا اپ گریڈ دستیاب ہے", "settings_upgrade_review_button": "جائزہ لیں", - "settings_upgrade_never_show_again": "دوبارہ کبھی نہ دکھائیں" + "settings_upgrade_never_show_again": "دوبارہ کبھی نہ دکھائیں", + "directory_route_currently_recommends": "s/{{directoryCode}} کی موجودہ تجویز:", + "directory_auto_switch_label": "s/{{directoryCode}} تبدیل ہونے پر اس سبسکرپشن کو خودکار طور پر تبدیل کریں", + "directory_recommendation_changed": "s/{{directoryCode}} اب تجویز کرتا ہے:", + "directory_auto_switch_help": "Seedit اس سبسکرپشن کو نئی تجویز کردہ کمیونٹی سے بدل دے گا۔ آپ اسے کسی بھی وقت بند کر سکتے ہیں۔", + "directory_you_still_follow": "آپ اب بھی فالو کر رہے ہیں", + "switch_to_community": "{{communityAddress}} پر سوئچ کریں", + "keep_both_communities": "دونوں رکھیں", + "keep_community": "{{communityAddress}} برقرار رکھیں", + "directory_automatic_switch_notice": "s/{{directoryCode}} تبدیل ہو گیا، اس لیے Seedit نے آپ کی سبسکرپشن {{fromAddress}} سے {{toAddress}} پر منتقل کر دی۔", + "dismiss": "بند کریں" } diff --git a/public/translations/vi/default.json b/public/translations/vi/default.json index 39d650ff..75033bdd 100644 --- a/public/translations/vi/default.json +++ b/public/translations/vi/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "Không thể lưu bản nâng cấp cài đặt.", "settings_upgrade_review_notice": "có bản nâng cấp cài đặt", "settings_upgrade_review_button": "xem lại", - "settings_upgrade_never_show_again": "không hiển thị lại" + "settings_upgrade_never_show_again": "không hiển thị lại", + "directory_route_currently_recommends": "s/{{directoryCode}} hiện đề xuất", + "directory_auto_switch_label": "tự động chuyển đăng ký này khi s/{{directoryCode}} thay đổi", + "directory_recommendation_changed": "s/{{directoryCode}} giờ đề xuất", + "directory_auto_switch_help": "Seedit sẽ thay thế đăng ký này bằng cộng đồng mới được đề xuất. Bạn có thể tắt tính năng này bất cứ lúc nào.", + "directory_you_still_follow": "Bạn vẫn theo dõi", + "switch_to_community": "chuyển sang {{communityAddress}}", + "keep_both_communities": "giữ cả hai", + "keep_community": "giữ {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} đã thay đổi, vì vậy Seedit đã chuyển đăng ký của bạn từ {{fromAddress}} sang {{toAddress}}.", + "dismiss": "Đóng" } diff --git a/public/translations/zh/default.json b/public/translations/zh/default.json index dc1b1fb1..0c6fd8f0 100644 --- a/public/translations/zh/default.json +++ b/public/translations/zh/default.json @@ -391,5 +391,15 @@ "settings_upgrade_error_saving": "无法保存设置升级。", "settings_upgrade_review_notice": "设置升级可用", "settings_upgrade_review_button": "查看", - "settings_upgrade_never_show_again": "不再显示" + "settings_upgrade_never_show_again": "不再显示", + "directory_route_currently_recommends": "s/{{directoryCode}} 当前推荐:", + "directory_auto_switch_label": "当 s/{{directoryCode}} 更改时自动切换此订阅", + "directory_recommendation_changed": "s/{{directoryCode}} 现推荐:", + "directory_auto_switch_help": "Seedit 会将此订阅替换为新推荐的社区。你可以随时关闭此功能。", + "directory_you_still_follow": "你仍在关注", + "switch_to_community": "切换到 {{communityAddress}}", + "keep_both_communities": "两者都保留", + "keep_community": "保留 {{communityAddress}}", + "directory_automatic_switch_notice": "s/{{directoryCode}} 已更改,因此 Seedit 已将你的订阅从 {{fromAddress}} 切换到 {{toAddress}}。", + "dismiss": "关闭" } diff --git a/scripts/sync-directories.js b/scripts/sync-directories.js new file mode 100644 index 00000000..6dbb8c4c --- /dev/null +++ b/scripts/sync-directories.js @@ -0,0 +1,126 @@ +// Best-effort mirror of the Seedit directory snapshots from bitsocialnet/lists. +// The vendored copy is a bootstrap fallback when the remote source is unavailable; clients +// still select the published snapshot's deterministic winner without local failover. +// Set DIRECTORIES_SOURCE_PATH to a local lists checkout for an offline, byte-for-byte sync. + +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'; +import { dirname, isAbsolute, join, resolve } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const GITHUB_REPOSITORY_API_URL = 'https://api.github.com/repos/bitsocialnet/lists'; +const GITHUB_RAW_BASE_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists'; +const DIRECTORIES_SOURCE_PATH = process.env.DIRECTORIES_SOURCE_PATH; +const OUTPUT_DIR = join(__dirname, '..', 'src', 'data', 'seedit-directories'); +const TIMEOUT_MS = 5000; + +// Remote filenames are joined into OUTPUT_DIR, so accept only flat JSON basenames. +const isJsonFile = (fileName) => + typeof fileName === 'string' && fileName.endsWith('.json') && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..'); +const isRecord = (value) => typeof value === 'object' && value !== null; +const getErrorMessage = (error) => (error instanceof Error ? error.message : String(error)); +const getSourceLabel = () => { + if (!DIRECTORIES_SOURCE_PATH) return `GitHub repository: ${GITHUB_REPOSITORY_API_URL}`; + const sourcePath = isAbsolute(DIRECTORIES_SOURCE_PATH) ? DIRECTORIES_SOURCE_PATH : resolve(process.cwd(), DIRECTORIES_SOURCE_PATH); + return `local directory: ${sourcePath}`; +}; + +const fetchWithTimeout = async (url, asJson) => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return asJson ? response.json() : response.text(); + } finally { + clearTimeout(timeout); + } +}; + +const loadFromLocalDirectory = (directoryPath) => { + console.log(`Mirroring directories from local directory: ${directoryPath}`); + const files = {}; + for (const fileName of readdirSync(directoryPath).filter(isJsonFile)) { + files[fileName] = readFileSync(join(directoryPath, fileName), 'utf8'); + } + return files; +}; + +const loadFromGitHub = async () => { + const commit = await fetchWithTimeout(`${GITHUB_REPOSITORY_API_URL}/commits/master`, true); + if (!isRecord(commit) || typeof commit.sha !== 'string' || !/^[0-9a-f]{40}$/.test(commit.sha)) { + throw new Error('Invalid GitHub master commit response'); + } + + const snapshotRef = commit.sha; + const contentsUrl = `${GITHUB_REPOSITORY_API_URL}/contents/seedit-directories?ref=${snapshotRef}`; + console.log(`Mirroring directories from GitHub commit: ${snapshotRef}`); + const contents = await fetchWithTimeout(contentsUrl, true); + if (!Array.isArray(contents)) throw new Error('Invalid GitHub directory listing'); + + const fileNames = contents + .filter((entry) => isRecord(entry) && entry.type === 'file' && isJsonFile(entry.name)) + .map((entry) => entry.name) + .sort(); + const files = {}; + await Promise.all( + fileNames.map(async (fileName) => { + files[fileName] = await fetchWithTimeout(`${GITHUB_RAW_BASE_URL}/${snapshotRef}/seedit-directories/${fileName}`, false); + }), + ); + return files; +}; + +const loadSourceFiles = () => { + if (!DIRECTORIES_SOURCE_PATH) return loadFromGitHub(); + const sourcePath = isAbsolute(DIRECTORIES_SOURCE_PATH) ? DIRECTORIES_SOURCE_PATH : resolve(process.cwd(), DIRECTORIES_SOURCE_PATH); + if (!existsSync(sourcePath) || !statSync(sourcePath).isDirectory()) throw new Error(`Local directories source folder not found: ${sourcePath}`); + return loadFromLocalDirectory(sourcePath); +}; + +const sync = async () => { + try { + const files = await loadSourceFiles(); + const fileNames = Object.keys(files); + if (fileNames.length === 0) throw new Error('No directory files found in source'); + + // Validate the complete snapshot before touching the existing fallback. + for (const [fileName, text] of Object.entries(files)) { + try { + JSON.parse(text); + } catch { + throw new Error(`Invalid JSON for ${fileName}`); + } + } + + mkdirSync(OUTPUT_DIR, { recursive: true }); + let written = 0; + for (const [fileName, text] of Object.entries(files)) { + const outputPath = join(OUTPUT_DIR, fileName); + const existing = existsSync(outputPath) ? readFileSync(outputPath, 'utf8') : null; + if (existing === text) continue; + writeFileSync(outputPath, text, 'utf8'); + written += 1; + } + + const sourceNames = new Set(fileNames); + let removed = 0; + for (const fileName of readdirSync(OUTPUT_DIR).filter(isJsonFile)) { + if (sourceNames.has(fileName)) continue; + rmSync(join(OUTPUT_DIR, fileName)); + removed += 1; + } + + if (written === 0 && removed === 0) { + console.log(`Vendored directories already up to date (${fileNames.length} files)`); + return; + } + console.log(`Mirrored directories (${fileNames.length} files, ${written} updated, ${removed} removed)`); + } catch (error) { + console.warn(`Could not mirror directories from ${getSourceLabel()} (keeping existing files): ${getErrorMessage(error)}`); + } +}; + +void sync(); diff --git a/src/app.tsx b/src/app.tsx index 6db3ca81..5ea11e69 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -27,6 +27,8 @@ import AccountBar from './components/account-bar/'; import ChallengeModal from './components/challenge-modal'; import Header from './components/header'; import NotificationHandler from './components/notification-handler'; +import DirectorySubscriptionReconciler from './components/directory-subscription-reconciler'; +import ExactCommunityActionRoute from './components/exact-community-action-route'; import StickyHeader from './components/sticky-header'; import TopBar from './components/topbar'; import styles from './app.module.css'; @@ -44,6 +46,7 @@ const App = () => { <> + @@ -103,12 +106,33 @@ const App = () => { } /> } /> - } /> + + + + } + /> } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> } /> } /> } /> diff --git a/src/components/directory-auto-switch-control/directory-auto-switch-control.module.css b/src/components/directory-auto-switch-control/directory-auto-switch-control.module.css new file mode 100644 index 00000000..4ba50ce0 --- /dev/null +++ b/src/components/directory-auto-switch-control/directory-auto-switch-control.module.css @@ -0,0 +1,13 @@ +.control { + display: block; + margin-top: 4px; + color: var(--text-info); + font-size: 10px; + line-height: 1.35; + cursor: pointer; +} + +.control input { + margin: 1px 5px 0 0; + vertical-align: top; +} diff --git a/src/components/directory-auto-switch-control/directory-auto-switch-control.tsx b/src/components/directory-auto-switch-control/directory-auto-switch-control.tsx new file mode 100644 index 00000000..bccb8c94 --- /dev/null +++ b/src/components/directory-auto-switch-control/directory-auto-switch-control.tsx @@ -0,0 +1,43 @@ +import { useAccount, type Account } from '@bitsocial/bitsocial-react-hooks'; +import { useTranslation } from 'react-i18next'; +import type { SeeditDirectoryCode } from '../../lib/utils/directory-codes'; +import type { SeeditDirectoryPreferences } from '../../lib/utils/directory-subscriptions'; +import { persistStarterAccountUpdate } from '../../lib/utils/starter-account-persistence'; +import { setDirectoryWinnerAutoSwitchAccount } from '../../lib/utils/directory-account-transforms'; +import styles from './directory-auto-switch-control.module.css'; + +interface DirectoryAutoSwitchControlProps { + address: string; + directoryCode: SeeditDirectoryCode; + directoryRevision: number; +} + +const DirectoryAutoSwitchControl = ({ address, directoryCode, directoryRevision }: DirectoryAutoSwitchControlProps) => { + const { t } = useTranslation(); + const account = useAccount() as (Account & { seeditDirectoryPreferences?: SeeditDirectoryPreferences }) | undefined; + const isSubscribed = account?.subscriptions?.includes(address) ?? false; + const slot = account?.seeditDirectoryPreferences?.slots?.[directoryCode]; + const enabled = slot?.subscriptionAddress === address && slot.autoSwitch; + + if (!account || !isSubscribed) return null; + + const handleChange = async (nextEnabled: boolean) => { + try { + await persistStarterAccountUpdate(account.id, (currentAccount) => { + return setDirectoryWinnerAutoSwitchAccount(currentAccount, { directoryCode, address, revision: directoryRevision, authoritative: true }, nextEnabled); + }); + } catch (error) { + console.error('Directory automatic switching update error:', error); + alert(error instanceof Error ? error.message : t('failed')); + } + }; + + return ( + + ); +}; + +export default DirectoryAutoSwitchControl; diff --git a/src/components/directory-auto-switch-control/index.ts b/src/components/directory-auto-switch-control/index.ts new file mode 100644 index 00000000..f6adc38d --- /dev/null +++ b/src/components/directory-auto-switch-control/index.ts @@ -0,0 +1 @@ +export { default } from './directory-auto-switch-control'; diff --git a/src/components/directory-subscription-reconciler/directory-subscription-reconciler.tsx b/src/components/directory-subscription-reconciler/directory-subscription-reconciler.tsx new file mode 100644 index 00000000..4f34b9b5 --- /dev/null +++ b/src/components/directory-subscription-reconciler/directory-subscription-reconciler.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { SEEDIT_DIRECTORY_CODES, type SeeditDirectoryCode } from '../../lib/utils/directory-codes'; +import type { AuthoritativeDirectoryWinnerSnapshot } from '../../lib/utils/directory-subscriptions'; +import { pickDirectoryWinner } from '../../lib/utils/directory-list-utils'; +import { persistStarterAccountUpdate } from '../../lib/utils/starter-account-persistence'; +import type { StarterAccount } from '../../lib/utils/starter-account'; +import { reconcileDirectoryWinnerAccount } from '../../lib/utils/directory-account-transforms'; +import { useDirectoryList } from '../../hooks/use-directory-list'; +import useContentOptionsStore from '../../stores/use-content-options-store'; + +const DirectorySlotReconciler = ({ directoryCode }: { directoryCode: SeeditDirectoryCode }) => { + const account = useAccount() as StarterAccount | undefined; + const { list } = useDirectoryList(directoryCode); + const hideNsfwCommunities = useContentOptionsStore((state) => state.hideNsfwCommunities); + const winner = list ? pickDirectoryWinner(list.communities) : undefined; + const winnerIsHidden = Boolean(hideNsfwCommunities && winner?.nsfw); + + useEffect(() => { + if (!account || !list || !winner || winnerIsHidden) return; + + const winnerSnapshot: AuthoritativeDirectoryWinnerSnapshot = { + directoryCode, + address: winner.address, + revision: list.revision, + authoritative: true, + }; + + void persistStarterAccountUpdate(account.id, (currentAccount) => reconcileDirectoryWinnerAccount(currentAccount, winnerSnapshot)).catch((error) => + console.error(`Directory ${directoryCode} subscription reconciliation error:`, error), + ); + }, [account, directoryCode, list, winner, winnerIsHidden]); + + return null; +}; + +const DirectorySubscriptionReconciler = () => { + const account = useAccount() as StarterAccount | undefined; + const trackedCodes = SEEDIT_DIRECTORY_CODES.filter((directoryCode) => account?.seeditDirectoryPreferences?.slots?.[directoryCode]); + + return trackedCodes.map((directoryCode) => ); +}; + +export default DirectorySubscriptionReconciler; diff --git a/src/components/directory-subscription-reconciler/index.ts b/src/components/directory-subscription-reconciler/index.ts new file mode 100644 index 00000000..4052967a --- /dev/null +++ b/src/components/directory-subscription-reconciler/index.ts @@ -0,0 +1 @@ +export { default } from './directory-subscription-reconciler'; diff --git a/src/components/directory-subscription-updates-notice/directory-subscription-updates-notice.module.css b/src/components/directory-subscription-updates-notice/directory-subscription-updates-notice.module.css new file mode 100644 index 00000000..7d0bc9d4 --- /dev/null +++ b/src/components/directory-subscription-updates-notice/directory-subscription-updates-notice.module.css @@ -0,0 +1,45 @@ +.notice { + display: block; + margin: 0 315px 7px 0; + padding: 6px 10px; + border: 1px solid var(--border-orange); + background: var(--background-orange); + color: var(--text); + font-size: 12px; + line-height: 1.35; +} + +.notice a, +.notice button { + color: var(--link-primary); + font-weight: 700; +} + +.actions { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; + margin-left: 7px; +} + +.actions button { + padding: 0; + border: 0; + background: transparent; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +.actions button:not(:last-child)::after { + content: ' |'; + color: var(--text-info); + font-weight: 400; + text-decoration: none; +} + +@media (max-width: 639px) { + .notice { + margin: 0 2px 7px; + } +} diff --git a/src/components/directory-subscription-updates-notice/directory-subscription-updates-notice.tsx b/src/components/directory-subscription-updates-notice/directory-subscription-updates-notice.tsx new file mode 100644 index 00000000..f8f13aa6 --- /dev/null +++ b/src/components/directory-subscription-updates-notice/directory-subscription-updates-notice.tsx @@ -0,0 +1,110 @@ +import { Link } from 'react-router-dom'; +import { useAccount } from '@bitsocial/bitsocial-react-hooks'; +import { useTranslation } from 'react-i18next'; +import { SEEDIT_DIRECTORY_CODES, type SeeditDirectoryCode } from '../../lib/utils/directory-codes'; +import { observeDirectoryWinnerChanges, type AuthoritativeDirectoryWinnerSnapshot } from '../../lib/utils/directory-subscriptions'; +import { pickDirectoryWinner } from '../../lib/utils/directory-list-utils'; +import { getCommunityPath } from '../../lib/utils/community-route-utils'; +import { getDisplayAddress } from '../../lib/utils/address-utils'; +import { persistStarterAccountUpdate } from '../../lib/utils/starter-account-persistence'; +import type { StarterAccount } from '../../lib/utils/starter-account'; +import { + applyManualDirectoryWinnerAccount, + resolveAutomaticDirectoryNoticeAccount, + type ManualDirectoryWinnerAction, +} from '../../lib/utils/directory-account-transforms'; +import { useDirectoryList } from '../../hooks/use-directory-list'; +import useContentOptionsStore from '../../stores/use-content-options-store'; +import styles from './directory-subscription-updates-notice.module.css'; + +const persistManualAction = (accountId: string, winner: AuthoritativeDirectoryWinnerSnapshot, action: ManualDirectoryWinnerAction) => + persistStarterAccountUpdate(accountId, (currentAccount) => applyManualDirectoryWinnerAccount(currentAccount, winner, action)); + +const ManualDirectoryChangeNotice = ({ directoryCode }: { directoryCode: SeeditDirectoryCode }) => { + const { t } = useTranslation(); + const account = useAccount() as StarterAccount | undefined; + const { list } = useDirectoryList(directoryCode); + const hideNsfwCommunities = useContentOptionsStore((state) => state.hideNsfwCommunities); + const winner = list ? pickDirectoryWinner(list.communities) : undefined; + + if (!account || !list || !winner || (hideNsfwCommunities && winner.nsfw)) return null; + + const winnerSnapshot: AuthoritativeDirectoryWinnerSnapshot = { + directoryCode, + address: winner.address, + revision: list.revision, + authoritative: true, + }; + const pending = observeDirectoryWinnerChanges({ + subscriptions: account.subscriptions ?? [], + preferences: account.seeditDirectoryPreferences, + winners: [winnerSnapshot], + }).pendingChanges[0]; + + if (!pending) return null; + + const runAction = (action: ManualDirectoryWinnerAction) => { + void persistManualAction(account.id, winnerSnapshot, action).catch((error) => console.error(`Directory ${directoryCode} update error:`, error)); + }; + + return ( +
+ {t('directory_recommendation_changed', { directoryCode })}{' '} + {getDisplayAddress(pending.winnerAddress)}. {t('directory_you_still_follow')}{' '} + {getDisplayAddress(pending.currentAddress)}. + + + + + +
+ ); +}; + +const AutomaticDirectoryChangeNotice = ({ directoryCode }: { directoryCode: SeeditDirectoryCode }) => { + const { t } = useTranslation(); + const account = useAccount() as StarterAccount | undefined; + const notice = account?.seeditDirectoryPreferences?.automaticChangeNotices?.[directoryCode]; + + if (!account || !notice) return null; + + const runAction = (action: 'dismiss' | 'undo') => { + void persistStarterAccountUpdate(account.id, (currentAccount) => { + return resolveAutomaticDirectoryNoticeAccount(currentAccount, directoryCode, action); + }).catch((error) => console.error(`Directory ${directoryCode} automatic switch notice error:`, error)); + }; + + return ( +
+ {t('directory_automatic_switch_notice', { directoryCode, fromAddress: notice.fromAddress, toAddress: notice.toAddress })} + + + + +
+ ); +}; + +const DirectorySubscriptionUpdatesNotice = () => { + const account = useAccount() as StarterAccount | undefined; + const activeCodes = SEEDIT_DIRECTORY_CODES.filter( + (directoryCode) => account?.seeditDirectoryPreferences?.slots?.[directoryCode] || account?.seeditDirectoryPreferences?.automaticChangeNotices?.[directoryCode], + ); + + return activeCodes.flatMap((directoryCode) => [ + , + , + ]); +}; + +export default DirectorySubscriptionUpdatesNotice; diff --git a/src/components/directory-subscription-updates-notice/index.ts b/src/components/directory-subscription-updates-notice/index.ts new file mode 100644 index 00000000..91b2ef6f --- /dev/null +++ b/src/components/directory-subscription-updates-notice/index.ts @@ -0,0 +1 @@ +export { default } from './directory-subscription-updates-notice'; diff --git a/src/components/exact-community-action-route/exact-community-action-route.tsx b/src/components/exact-community-action-route/exact-community-action-route.tsx new file mode 100644 index 00000000..b1afedaa --- /dev/null +++ b/src/components/exact-community-action-route/exact-community-action-route.tsx @@ -0,0 +1,25 @@ +import { useEffect, type ReactNode } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; +import { getExactCommunityActionRedirectPath } from '../../lib/utils/community-route-utils'; + +interface ExactCommunityActionRouteProps { + children: ReactNode; +} + +/** Mutable directory aliases are read-only entry routes; action routes must expose an exact target. */ +const ExactCommunityActionRoute = ({ children }: ExactCommunityActionRouteProps) => { + const { directoryCode, communityAddress } = useResolvedCommunityRoute(); + const { pathname, search, hash } = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (!directoryCode || !communityAddress) return; + const redirectPath = getExactCommunityActionRedirectPath(pathname, directoryCode, communityAddress, search, hash); + if (redirectPath) navigate(redirectPath, { replace: true }); + }, [communityAddress, directoryCode, hash, navigate, pathname, search]); + + return directoryCode ? null : children; +}; + +export default ExactCommunityActionRoute; diff --git a/src/components/exact-community-action-route/index.ts b/src/components/exact-community-action-route/index.ts new file mode 100644 index 00000000..ef7fee8a --- /dev/null +++ b/src/components/exact-community-action-route/index.ts @@ -0,0 +1 @@ +export { default } from './exact-community-action-route'; diff --git a/src/components/header/header.module.css b/src/components/header/header.module.css index c131579a..0310e1a3 100644 --- a/src/components/header/header.module.css +++ b/src/components/header/header.module.css @@ -180,3 +180,17 @@ .lowercase { text-transform: lowercase; } + +.mobileDirectoryDisclosure { + padding: 2px 5px; + color: var(--text-info); + font-size: 10px; + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; +} + +.mobileDirectoryDisclosure a { + color: var(--link-primary); + text-decoration: underline; +} diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx index ebd1bd25..722abaae 100644 --- a/src/components/header/header.tsx +++ b/src/components/header/header.tsx @@ -43,7 +43,7 @@ import { isSettingsAccountDataView, } from '../../lib/utils/view-utils'; import { getDisplayAddress, getShortDisplayAddress } from '../../lib/utils/address-utils'; -import { getCommunityPath, getCommunityPostPath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import { getCommunityPath, getCommunityPostPath } from '../../lib/utils/community-route-utils'; import useContentOptionsStore from '../../stores/use-content-options-store'; import useNotFoundStore from '../../stores/use-not-found-store'; import { useIsNsfwCommunity } from '../../hooks/use-is-nsfw-community'; @@ -51,6 +51,7 @@ import useTheme from '../../hooks/use-theme'; import useWindowWidth from '../../hooks/use-window-width'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; import useOptionalAccountComment from '../../hooks/use-account-comment'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import styles from './header.module.css'; @@ -58,7 +59,7 @@ const AboutButton = () => { const { t } = useTranslation(); const params = useParams(); const location = useLocation(); - const routeCommunityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress: routeCommunityAddress } = useResolvedCommunityRoute(); const aboutLink = routeCommunityAddress ? params.commentCid ? `${getCommunityPostPath(routeCommunityAddress, params.commentCid)}/about` @@ -83,7 +84,7 @@ const CommentsButton = () => { const isInPendingPostView = isPendingPostView(location.pathname, params); const isInHomeAboutView = isHomeAboutView(location.pathname); const isInPostPageAboutView = isPostPageAboutView(location.pathname, params); - const communityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress } = useResolvedCommunityRoute(); return (
  • @@ -108,7 +109,7 @@ const SortItems = () => { const isInModView = isModView(location.pathname); const isInDomainView = isDomainView(location.pathname); const isInCommunityView = isCommunityView(location.pathname, params); - const communityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress } = useResolvedCommunityRoute(); // Derive selection directly from route instead of syncing via an effect const selectedSortType = isInHomeAboutView || isInCommunityAboutView || isInPostPageAboutView ? '' : params.sortType || 'hot'; const timeFilterName = params.timeFilterName; @@ -343,7 +344,7 @@ const HeaderTitle = ({ title, pendingPostCommunityAddress }: { title: string; pe const isInCreateCommunityView = isCreateCommunityView(location.pathname); const isInNotFoundView = useNotFoundStore((state) => state.isNotFound); - const communityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress } = useResolvedCommunityRoute(); const titleCommunityAddress = isInPendingPostView ? pendingPostCommunityAddress : communityAddress; const { hideNsfwCommunities } = useContentOptionsStore(); @@ -406,7 +407,7 @@ const Header = () => { const [theme] = useTheme(); const location = useLocation(); const params = useParams(); - const communityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress, directoryCode } = useResolvedCommunityRoute(); const community = useCommunity(communityAddress ? { community: getCommunityIdentifier(communityAddress), onlyIfCached: true } : undefined); const { title } = community || {}; @@ -495,6 +496,11 @@ const Header = () => { )} + {isMobile && directoryCode && communityAddress && isInCommunityView && !isInPostPageView && !isInCommunitySubmitView && !isInCommunitySettingsView && ( +
    + {t('directory_route_currently_recommends', { directoryCode })} {getDisplayAddress(communityAddress)} +
    + )} {isMobile && !isInCommunitySubmitView && !isHiddenNsfwCommunity && (
      diff --git a/src/components/search-bar/search-bar.tsx b/src/components/search-bar/search-bar.tsx index 0a3235b0..803012b6 100644 --- a/src/components/search-bar/search-bar.tsx +++ b/src/components/search-bar/search-bar.tsx @@ -16,9 +16,10 @@ import { import { getShortDisplayAddress } from '../../lib/utils/address-utils'; import useFeedFiltersStore from '../../stores/use-feed-filters-store'; import { useDefaultSubscriptionAddresses } from '../../hooks/use-default-subscriptions'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import styles from './search-bar.module.css'; import _ from 'lodash'; -import { getCommunityPath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import { getCommunityPath, getCommunityReferencePath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; interface SearchBarProps { isFocused?: boolean; @@ -30,6 +31,7 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { const location = useLocation(); const navigate = useNavigate(); const params = useParams(); + const { communityAddress: currentCommunityAddress } = useResolvedCommunityRoute(); const [searchParams, setSearchParams] = useSearchParams(); const isInHomeAboutView = isHomeAboutView(location.pathname); @@ -150,13 +152,13 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { if (searchInput) { const communityAddress = resolveCommunityRouteAddress(searchInput); if (!communityAddress) return; - const currentCommunityAddress = resolveCommunityRouteAddress(params.communityAddress); - if (communityAddress?.toLowerCase() === currentCommunityAddress?.toLowerCase()) { + const isCurrentDirectoryRoute = params.communityAddress?.toLowerCase() === searchInput.toLowerCase(); + if (isCurrentDirectoryRoute || communityAddress.toLowerCase() === currentCommunityAddress?.toLowerCase()) { alert(t('already_in_community')); return; } setInputValue(''); - navigate(getCommunityPath(communityAddress)); + navigate(getCommunityReferencePath(searchInput)); } }; @@ -193,7 +195,6 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { const handleCommunitySelect = useCallback( (address: string) => { - const currentCommunityAddress = resolveCommunityRouteAddress(params.communityAddress); if (address.toLowerCase() === currentCommunityAddress?.toLowerCase()) { alert(t('already_in_community')); return; @@ -205,7 +206,7 @@ const SearchBar = ({ isFocused = false, onExpandoChange }: SearchBarProps) => { searchInputRef.current?.blur(); navigate(getCommunityPath(address)); }, - [navigate, setInputValue, setIsInputFocused, setActiveDropdownIndex, params.communityAddress, t], + [currentCommunityAddress, navigate, setInputValue, setIsInputFocused, setActiveDropdownIndex, t], ); const handleKeyDown = useCallback( diff --git a/src/components/sidebar/sidebar.module.css b/src/components/sidebar/sidebar.module.css index 526d683d..03659d59 100644 --- a/src/components/sidebar/sidebar.module.css +++ b/src/components/sidebar/sidebar.module.css @@ -44,6 +44,19 @@ a { text-decoration: underline !important; } +.directoryRouteDisclosure { + margin-top: 2px; + color: var(--text-info); + font-size: 10px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.directoryRouteDisclosure a { + color: var(--link-primary); + text-decoration: underline; +} + .subscribeContainer { margin-top: 5px; } diff --git a/src/components/sidebar/sidebar.tsx b/src/components/sidebar/sidebar.tsx index bca1297a..4f622a55 100644 --- a/src/components/sidebar/sidebar.tsx +++ b/src/components/sidebar/sidebar.tsx @@ -26,12 +26,14 @@ import useIsCommunityOffline from '../../hooks/use-is-community-offline'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; import useOptionalAccountComment from '../../hooks/use-account-comment'; import { getCommunityPath, getCommunityPostUrl } from '../../lib/utils/community-route-utils'; +import type { SeeditDirectoryCode } from '../../lib/utils/directory-codes'; import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { FAQ } from '../../views/about/about'; import LoadingEllipsis from '../loading-ellipsis'; import Markdown from '../markdown'; import SearchBar from '../search-bar'; import SubscribeButton from '../subscribe-button'; +import DirectoryAutoSwitchControl from '../directory-auto-switch-control'; import { Version } from '../version'; import styles from './sidebar.module.css'; @@ -129,6 +131,9 @@ const ModerationTools = ({ address }: { address?: string }) => { interface SidebarProps { comment?: Comment; + communityAddress?: string; + directoryCode?: SeeditDirectoryCode; + directoryRevision?: number; isSubCreatedButNotYetPublished?: boolean; settings?: any; community?: Community; @@ -184,9 +189,10 @@ export const Footer = () => { ); }; -const Sidebar = ({ comment, isSubCreatedButNotYetPublished, settings, community, reset }: SidebarProps) => { +const Sidebar = ({ comment, communityAddress, directoryCode, directoryRevision, isSubCreatedButNotYetPublished, settings, community, reset }: SidebarProps) => { const { t } = useTranslation(); - const { address, createdAt, description, roles, rules, title, updatedAt } = community || {}; + const { address: loadedCommunityAddress, createdAt, description, roles, rules, title, updatedAt } = community || {}; + const address = loadedCommunityAddress || communityAddress; const { allActiveUserCount, hourActiveUserCount } = useCommunityStats(address ? { community: getCommunityIdentifier(address) } : undefined); const { isOffline, offlineTitle } = useIsCommunityOffline(community || {}); const onlineNotice = t('users_online', { count: hourActiveUserCount || 0 }); @@ -307,15 +313,23 @@ const Sidebar = ({ comment, isSubCreatedButNotYetPublished, settings, community, !isInDomainView && !isInPostPageAboutView && (
      - - {getDisplayAddress(community?.address || '')} + + {directoryCode ? `s/${directoryCode}` : getDisplayAddress(community?.address || '')} + {directoryCode && address && ( +
      + {t('directory_route_currently_recommends', { directoryCode })} {getDisplayAddress(address)} +
      + )}
      - + {t('members_count', { count: allActiveUserCount })}
      + {directoryCode && directoryRevision && address && ( + + )}
      {isSubCreatedButNotYetPublished ? subCreatedButNotYetPublishedStatus : onlineStatus} diff --git a/src/components/starter-subscriptions-notice/starter-subscriptions-notice.tsx b/src/components/starter-subscriptions-notice/starter-subscriptions-notice.tsx index fc0b21cf..61916071 100644 --- a/src/components/starter-subscriptions-notice/starter-subscriptions-notice.tsx +++ b/src/components/starter-subscriptions-notice/starter-subscriptions-notice.tsx @@ -1,20 +1,36 @@ import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import { useAccount } from '@bitsocial/bitsocial-react-hooks'; import { useStarterSubscriptions } from '../../hooks/use-starter-subscriptions'; +import type { StarterAccount } from '../../lib/utils/starter-account'; import styles from './starter-subscriptions-notice.module.css'; const StarterSubscriptionsNotice = () => { const { t } = useTranslation(); + const account = useAccount() as StarterAccount | undefined; const { hasUpdate, delta } = useStarterSubscriptions(); if (!hasUpdate) return null; + const directoryPreferences = account?.seeditDirectoryPreferences; + // Only a persisted automatic transition proves that the independently fetched directory + // snapshot covers this default-list delta. Manual directory prompts are derived at render + // time, so the generic notice remains as a safe fallback until the user acts. + const automaticNotices = Object.values(directoryPreferences?.automaticChangeNotices ?? {}).filter((notice): notice is NonNullable => Boolean(notice)); + const coveredAddedAddresses = new Set(automaticNotices.map(({ toAddress }) => toAddress)); + const coveredRemovedAddresses = new Set(automaticNotices.map(({ fromAddress }) => fromAddress)); + const uncoveredAddedAddresses = delta.addedAddresses.filter((address) => !coveredAddedAddresses.has(address)); + const uncoveredRemovedAddresses = delta.removedAddresses.filter((address) => !coveredRemovedAddresses.has(address)); + const hasUncoveredChange = uncoveredAddedAddresses.length > 0 || uncoveredRemovedAddresses.length > 0; + + if (!hasUncoveredChange) return null; + return ( {t('starter_subscriptions_updated')}{' '} {t('starter_subscriptions_change_summary', { - added: delta.addedAddresses.length, - removed: delta.removedAddresses.length, + added: uncoveredAddedAddresses.length, + removed: uncoveredRemovedAddresses.length, })}{' '} {t('review_changes')} diff --git a/src/components/subscribe-button/subscribe-button.tsx b/src/components/subscribe-button/subscribe-button.tsx index fd5fbbdf..4e04b7af 100644 --- a/src/components/subscribe-button/subscribe-button.tsx +++ b/src/components/subscribe-button/subscribe-button.tsx @@ -5,15 +5,22 @@ import styles from './subscribe-button.module.css'; import { isAuthorView, isProfileView, isPendingPostView } from '../../lib/utils/view-utils'; import { persistStarterAccountUpdate } from '../../lib/utils/starter-account-persistence'; import { leaveStarterSubscription, type SeeditStarterSubscriptions } from '../../lib/utils/starter-subscriptions'; +import { leaveDirectorySubscription, type SeeditDirectoryPreferences } from '../../lib/utils/directory-subscriptions'; +import { joinDirectoryWinnerAccount } from '../../lib/utils/directory-account-transforms'; +import type { SeeditDirectoryCode } from '../../lib/utils/directory-codes'; interface subscribeButtonProps { address: string | undefined; + directoryCode?: SeeditDirectoryCode; + directoryRevision?: number; onUnsubscribe?: (address: string) => void; } -const SubscribeButton = ({ address, onUnsubscribe }: subscribeButtonProps) => { +const SubscribeButton = ({ address, directoryCode, directoryRevision, onUnsubscribe }: subscribeButtonProps) => { const { subscribe, subscribed, unsubscribe } = useSubscribe({ communityAddress: address }); - const account = useAccount() as (Account & { seeditStarterSubscriptions?: SeeditStarterSubscriptions }) | undefined; + const account = useAccount() as + | (Account & { seeditDirectoryPreferences?: SeeditDirectoryPreferences; seeditStarterSubscriptions?: SeeditStarterSubscriptions }) + | undefined; const { t } = useTranslation(); const location = useLocation(); const params = useParams(); @@ -28,16 +35,37 @@ const SubscribeButton = ({ address, onUnsubscribe }: subscribeButtonProps) => { try { if (subscribed === false) { - await subscribe(); + if (address && account && directoryCode && directoryRevision) { + await persistStarterAccountUpdate(account.id, (currentAccount) => { + return joinDirectoryWinnerAccount(currentAccount, { + directoryCode, + address, + revision: directoryRevision, + authoritative: true, + }); + }); + } else { + await subscribe(); + } } else if (subscribed === true) { if (address && account) { await persistStarterAccountUpdate(account.id, (currentAccount) => { - const { subscriptions, provenance } = leaveStarterSubscription({ + const starterResult = leaveStarterSubscription({ subscriptions: currentAccount.subscriptions ?? [], provenance: currentAccount.seeditStarterSubscriptions, address, }); - return provenance ? { ...currentAccount, subscriptions, seeditStarterSubscriptions: provenance } : { ...currentAccount, subscriptions }; + const directoryResult = leaveDirectorySubscription({ + subscriptions: starterResult.subscriptions, + preferences: currentAccount.seeditDirectoryPreferences, + address, + }); + return { + ...currentAccount, + subscriptions: directoryResult.subscriptions, + seeditDirectoryPreferences: directoryResult.preferences, + ...(starterResult.provenance ? { seeditStarterSubscriptions: starterResult.provenance } : {}), + }; }); } else { await unsubscribe(); diff --git a/src/components/topbar/topbar.tsx b/src/components/topbar/topbar.tsx index 08d9ee97..771b9e7e 100644 --- a/src/components/topbar/topbar.tsx +++ b/src/components/topbar/topbar.tsx @@ -6,8 +6,9 @@ import { isAllView, isDomainView, isHomeView, isModView, isCommunityView } from import { getCompactCommunityDisplayName } from '../../lib/utils/address-utils'; import useContentOptionsStore from '../../stores/use-content-options-store'; import { useFilteredDefaultSubscriptions } from '../../hooks/use-default-subscriptions'; -import { getCommunityPath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import { getCommunityPath } from '../../lib/utils/community-route-utils'; import useTimeFilter, { setSessionTimeFilterPreference } from '../../hooks/use-time-filter'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import { sortTypes } from '../../constants/sort-types'; import { sortLabels } from '../../constants/sort-labels'; import styles from './topbar.module.css'; @@ -70,7 +71,7 @@ const SortTypesDropdown = () => { const { timeFilterName } = useTimeFilter(); const selectedSortType = params.sortType || 'hot'; - const communityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress } = useResolvedCommunityRoute(); const getSelectedSortLabel = () => { const index = sortTypes.indexOf(selectedSortType); @@ -133,7 +134,7 @@ const TimeFilterDropdown = () => { const timeFilterDropdownClass = isTimeFilterDropdownOpen ? styles.visible : styles.hidden; const selectedSortType = params.sortType || 'hot'; - const communityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress } = useResolvedCommunityRoute(); const getTimeFilterLink = (timeFilterName: string) => { return isInCommunityView @@ -182,7 +183,6 @@ const TimeFilterDropdown = () => { const TopBar = memo(() => { const { t } = useTranslation(); const location = useLocation(); - const params = useParams(); const isinAllView = isAllView(location.pathname); const isInHomeView = isHomeView(location.pathname); @@ -203,7 +203,7 @@ const TopBar = memo(() => { for (const { address } of defaultCommunities) { if (!subscriptionSet.has(address)) filteredCommunityAddresses.push(address); } - const activeCommunityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress: activeCommunityAddress } = useResolvedCommunityRoute(); return (
      diff --git a/src/data/seedit-directories/seedit-askseedit-directory.json b/src/data/seedit-directories/seedit-askseedit-directory.json new file mode 100644 index 00000000..8ea4142d --- /dev/null +++ b/src/data/seedit-directories/seedit-askseedit-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "askseedit", + "description": "Communities competing for Seedit's /s/askseedit route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-askseedit-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "askseedit.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWNv5HzmkcKZkxMn1SrfMyozTdTJSr7kA1bzEKKa7PTS5N", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-aww-directory.json b/src/data/seedit-directories/seedit-aww-directory.json new file mode 100644 index 00000000..297b0512 --- /dev/null +++ b/src/data/seedit-directories/seedit-aww-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "aww", + "description": "Communities competing for Seedit's /s/aww route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-aww-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "aww-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWDniAeB2eenHaA25vxk4wt2v8H39dAi42L5bfrMSxtpEs", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-directories-defaults.json b/src/data/seedit-directories/seedit-directories-defaults.json new file mode 100644 index 00000000..a1da9dec --- /dev/null +++ b/src/data/seedit-directories/seedit-directories-defaults.json @@ -0,0 +1,98 @@ +{ + "schemaVersion": 1, + "revision": 1, + "title": "Seedit Directory Defaults", + "description": "Display metadata for Seedit's contested short routes. These routes recommend exact communities but are never stored as subscriptions or used in post permalinks.", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "directories": { + "askseedit": { + "directoryCode": "askseedit", + "title": "AskSeedit", + "description": "Ask the Seedit community anything: thought-provoking and open-ended questions.", + "tags": [ + "questions", + "discussion" + ] + }, + "memes": { + "directoryCode": "memes", + "title": "Memes", + "description": "Memes of all kinds. A way of describing cultural information being shared.", + "tags": [ + "memes", + "humor" + ] + }, + "news": { + "directoryCode": "news", + "title": "News", + "description": "News from around the world: current events, politics and everything in between.", + "tags": [ + "news", + "world" + ] + }, + "pics": { + "directoryCode": "pics", + "title": "Pics", + "description": "A place for photographs, pictures, and other images.", + "tags": [ + "pictures", + "photography" + ] + }, + "todayilearned": { + "directoryCode": "todayilearned", + "title": "Today I Learned", + "description": "You learn something new every day; what did you learn today?", + "tags": [ + "learning", + "facts" + ] + }, + "interestingasfuck": { + "directoryCode": "interestingasfuck", + "title": "Interesting As Fuck", + "description": "For anything that is InterestingAsFuck.", + "tags": [ + "interesting" + ] + }, + "gaming": { + "directoryCode": "gaming", + "title": "Gaming", + "description": "A community for (almost) anything related to games: video games, board games, card games, and so on.", + "tags": [ + "gaming", + "videogames" + ] + }, + "videos": { + "directoryCode": "videos", + "title": "Videos", + "description": "The best place for video content of all kinds.", + "tags": [ + "videos" + ] + }, + "funny": { + "directoryCode": "funny", + "title": "Funny", + "description": "Seedit's largest humour depository.", + "tags": [ + "humor", + "funny" + ] + }, + "aww": { + "directoryCode": "aww", + "title": "Aww", + "description": "Things that make you go AWW! Like puppies, bunnies, babies, and so on.", + "tags": [ + "animals", + "cute" + ] + } + } +} diff --git a/src/data/seedit-directories/seedit-funny-directory.json b/src/data/seedit-directories/seedit-funny-directory.json new file mode 100644 index 00000000..8fdba598 --- /dev/null +++ b/src/data/seedit-directories/seedit-funny-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "funny", + "description": "Communities competing for Seedit's /s/funny route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-funny-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "funny-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWFL5oGc9p9CYs25eeK5cCpPXXhibECsnqmuxinq2jbtjP", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-gaming-directory.json b/src/data/seedit-directories/seedit-gaming-directory.json new file mode 100644 index 00000000..9f2404c9 --- /dev/null +++ b/src/data/seedit-directories/seedit-gaming-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "gaming", + "description": "Communities competing for Seedit's /s/gaming route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-gaming-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "gaming-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWRHbY2AS9oComoTVpMF4iF33d3GPzKCW7MSfHEJT8qeZc", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-interestingasfuck-directory.json b/src/data/seedit-directories/seedit-interestingasfuck-directory.json new file mode 100644 index 00000000..055ee510 --- /dev/null +++ b/src/data/seedit-directories/seedit-interestingasfuck-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "interestingasfuck", + "description": "Communities competing for Seedit's /s/interestingasfuck route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-interestingasfuck-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "interestingasfuck.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWDexsEFuP8wt4vPKjLn3ZaxEyQJv4EWMU3abnLk7NxEhd", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-memes-directory.json b/src/data/seedit-directories/seedit-memes-directory.json new file mode 100644 index 00000000..15a7d47d --- /dev/null +++ b/src/data/seedit-directories/seedit-memes-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "memes", + "description": "Communities competing for Seedit's /s/memes route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-memes-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "memes-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWFkZorC92RxQAwMBYMido8xBM88ghpLWA26mrE9W9Xmxd", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-news-directory.json b/src/data/seedit-directories/seedit-news-directory.json new file mode 100644 index 00000000..e5e3335e --- /dev/null +++ b/src/data/seedit-directories/seedit-news-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "news", + "description": "Communities competing for Seedit's /s/news route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-news-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "news-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWMxE8DtkGLL5LBm2ENdPJJX9DnimfHDh1uvDgKF8nrwX5", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-pics-directory.json b/src/data/seedit-directories/seedit-pics-directory.json new file mode 100644 index 00000000..b9e5533c --- /dev/null +++ b/src/data/seedit-directories/seedit-pics-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "pics", + "description": "Communities competing for Seedit's /s/pics route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-pics-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "pics-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWR39v9RaLHXXsJC9NSqdmWgrw5FMjJJAECEAsras4AztU", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-todayilearned-directory.json b/src/data/seedit-directories/seedit-todayilearned-directory.json new file mode 100644 index 00000000..8591733c --- /dev/null +++ b/src/data/seedit-directories/seedit-todayilearned-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "todayilearned", + "description": "Communities competing for Seedit's /s/todayilearned route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-todayilearned-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "til-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWEXNQaBi1xWXW9ksm8Fa5LpAP46YsmV44oGaHrsCJ41Jt", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-directories/seedit-videos-directory.json b/src/data/seedit-directories/seedit-videos-directory.json new file mode 100644 index 00000000..aa28f75c --- /dev/null +++ b/src/data/seedit-directories/seedit-videos-directory.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "revision": 1, + "directoryCode": "videos", + "description": "Communities competing for Seedit's /s/videos route. The highest-rated finalized candidate resolves the route. Subscriptions and post links continue to use exact community addresses.\n\nhttps://github.com/bitsocialnet/lists/blob/master/seedit-directories/seedit-videos-directory.json", + "createdAt": 1783123200, + "updatedAt": 1783123200, + "communities": [ + { + "address": "videos-posting.bso", + "addedAt": 1783123200, + "publicKey": "12D3KooWQt3UJ6A3AeZxJSFJnspVmYQK1Epj8VeSzrFG1tHiZC9o", + "owner": "bitsocialist.bso" + } + ] +} diff --git a/src/data/seedit-starter-communities.json b/src/data/seedit-starter-communities.json index 3ca0ca65..3e2ac8a7 100644 --- a/src/data/seedit-starter-communities.json +++ b/src/data/seedit-starter-communities.json @@ -1,12 +1,14 @@ { - "schemaVersion": 1, - "revision": 1, + "schemaVersion": 2, + "revision": 2, "title": "Seedit Default Communities", "description": "Communities new Seedit accounts subscribe to by default.", "createdAt": 1745707200, - "updatedAt": 1784049342, + "updatedAt": 1784467859, "communities": [ { + "directoryCode": "askseedit", + "directoryRevision": 1, "address": "askseedit.bso", "publicKey": "12D3KooWNv5HzmkcKZkxMn1SrfMyozTdTJSr7kA1bzEKKa7PTS5N", "title": "AskSeedit", @@ -17,6 +19,8 @@ ] }, { + "directoryCode": "memes", + "directoryRevision": 1, "address": "memes-posting.bso", "publicKey": "12D3KooWFkZorC92RxQAwMBYMido8xBM88ghpLWA26mrE9W9Xmxd", "title": "Memes", @@ -27,6 +31,8 @@ ] }, { + "directoryCode": "news", + "directoryRevision": 1, "address": "news-posting.bso", "publicKey": "12D3KooWMxE8DtkGLL5LBm2ENdPJJX9DnimfHDh1uvDgKF8nrwX5", "title": "News", @@ -37,6 +43,8 @@ ] }, { + "directoryCode": "pics", + "directoryRevision": 1, "address": "pics-posting.bso", "publicKey": "12D3KooWR39v9RaLHXXsJC9NSqdmWgrw5FMjJJAECEAsras4AztU", "title": "Pics", @@ -47,6 +55,8 @@ ] }, { + "directoryCode": "todayilearned", + "directoryRevision": 1, "address": "til-posting.bso", "publicKey": "12D3KooWEXNQaBi1xWXW9ksm8Fa5LpAP46YsmV44oGaHrsCJ41Jt", "title": "Today I Learned", @@ -57,6 +67,8 @@ ] }, { + "directoryCode": "interestingasfuck", + "directoryRevision": 1, "address": "interestingasfuck.bso", "publicKey": "12D3KooWDexsEFuP8wt4vPKjLn3ZaxEyQJv4EWMU3abnLk7NxEhd", "title": "Interesting As Fuck", @@ -66,6 +78,8 @@ ] }, { + "directoryCode": "gaming", + "directoryRevision": 1, "address": "gaming-posting.bso", "publicKey": "12D3KooWRHbY2AS9oComoTVpMF4iF33d3GPzKCW7MSfHEJT8qeZc", "title": "Gaming", @@ -76,6 +90,8 @@ ] }, { + "directoryCode": "videos", + "directoryRevision": 1, "address": "videos-posting.bso", "publicKey": "12D3KooWQt3UJ6A3AeZxJSFJnspVmYQK1Epj8VeSzrFG1tHiZC9o", "title": "Videos", @@ -85,6 +101,8 @@ ] }, { + "directoryCode": "funny", + "directoryRevision": 1, "address": "funny-posting.bso", "publicKey": "12D3KooWFL5oGc9p9CYs25eeK5cCpPXXhibECsnqmuxinq2jbtjP", "title": "Funny", @@ -95,6 +113,8 @@ ] }, { + "directoryCode": "aww", + "directoryRevision": 1, "address": "aww-posting.bso", "publicKey": "12D3KooWDniAeB2eenHaA25vxk4wt2v8H39dAi42L5bfrMSxtpEs", "title": "Aww", diff --git a/src/data/vendored-directory-lists.ts b/src/data/vendored-directory-lists.ts new file mode 100644 index 00000000..f2459642 --- /dev/null +++ b/src/data/vendored-directory-lists.ts @@ -0,0 +1,39 @@ +import askseeditDirectory from './seedit-directories/seedit-askseedit-directory.json'; +import awwDirectory from './seedit-directories/seedit-aww-directory.json'; +import directoryDefaults from './seedit-directories/seedit-directories-defaults.json'; +import funnyDirectory from './seedit-directories/seedit-funny-directory.json'; +import gamingDirectory from './seedit-directories/seedit-gaming-directory.json'; +import interestingasfuckDirectory from './seedit-directories/seedit-interestingasfuck-directory.json'; +import memesDirectory from './seedit-directories/seedit-memes-directory.json'; +import newsDirectory from './seedit-directories/seedit-news-directory.json'; +import picsDirectory from './seedit-directories/seedit-pics-directory.json'; +import todayilearnedDirectory from './seedit-directories/seedit-todayilearned-directory.json'; +import videosDirectory from './seedit-directories/seedit-videos-directory.json'; +import { isDirectoryCode, type SeeditDirectoryCode } from '../lib/utils/directory-codes'; +import { normalizeDirectoryDefaultsData, normalizeDirectoryList, type DirectoryList } from '../lib/utils/directory-list-utils'; + +const rawDirectoryLists = { + askseedit: askseeditDirectory, + memes: memesDirectory, + news: newsDirectory, + pics: picsDirectory, + todayilearned: todayilearnedDirectory, + interestingasfuck: interestingasfuckDirectory, + gaming: gamingDirectory, + videos: videosDirectory, + funny: funnyDirectory, + aww: awwDirectory, +} satisfies Record; + +export const vendoredDirectoryDefaults = normalizeDirectoryDefaultsData(directoryDefaults); + +export const vendoredDirectoryLists = Object.fromEntries( + Object.entries(rawDirectoryLists).map(([directoryCode, rawList]) => { + const list = normalizeDirectoryList(rawList, directoryCode, vendoredDirectoryDefaults); + if (!list) throw new Error(`Invalid vendored Seedit directory list: ${directoryCode}`); + return [directoryCode, list]; + }), +) as Record; + +export const getVendoredDirectoryList = (directoryCode: string | undefined): DirectoryList | null => + directoryCode && isDirectoryCode(directoryCode) ? vendoredDirectoryLists[directoryCode] : null; diff --git a/src/hooks/use-auto-subscribe.test.ts b/src/hooks/use-auto-subscribe.test.ts index 2f32dec4..c8201768 100644 --- a/src/hooks/use-auto-subscribe.test.ts +++ b/src/hooks/use-auto-subscribe.test.ts @@ -21,6 +21,23 @@ describe('computeStarterAccount', () => { }); }); + it('bootstraps exact subscriptions with route provenance but never stores directory slugs', () => { + const account = { subscriptions: [] } as StarterAccount; + + const result = computeStarterAccount(account, false, { + revision: 2, + communities: [{ address: 'funny-posting.bso', directoryCode: 'funny', directoryRevision: 3 }, { address: 'manual-default.bso' }], + }); + + expect(result.subscriptions).toEqual(['funny-posting.bso', 'manual-default.bso']); + expect(result.subscriptions).not.toContain('funny'); + expect(result.seeditDirectoryPreferences?.slots.funny).toEqual({ + subscriptionAddress: 'funny-posting.bso', + autoSwitch: false, + acknowledgedWinner: { address: 'funny-posting.bso', revision: 3 }, + }); + }); + it('merges migrated directory ownership into existing provenance', () => { const account = { subscriptions: ['aww', 'manual.bso'], @@ -48,7 +65,7 @@ describe('computeStarterAccount', () => { expect(computeStarterAccount(account, true, { revision: 2, communities: [{ address: 'new-default.bso' }] })).toMatchObject({ subscriptions: ['aww-posting.bso', 'manual.bso'], seeditStarterSubscriptions: { - acknowledgedRevision: 1, + acknowledgedRevision: 2, knownAddresses: STARTER_COMMUNITY_ADDRESSES, managedAddresses: ['aww-posting.bso'], }, diff --git a/src/hooks/use-default-subscriptions.test.ts b/src/hooks/use-default-subscriptions.test.ts index 978dbb2e..8c91b5a5 100644 --- a/src/hooks/use-default-subscriptions.test.ts +++ b/src/hooks/use-default-subscriptions.test.ts @@ -30,35 +30,40 @@ describe('normalizeStarterCommunityList', () => { it('accepts a versioned starter list and deduplicates addresses', () => { expect( normalizeStarterCommunityList({ - schemaVersion: 1, + schemaVersion: 2, revision: 2, title: 'Starter communities', communities: [ - { address: 'aww.bso', title: 'Aww', tags: ['cute', 'cute'] }, + { directoryCode: 'aww', directoryRevision: 1, address: 'aww.bso', title: 'Aww', tags: ['cute', 'cute'] }, { address: 'aww.bso', title: 'Duplicate' }, + { directoryCode: 'unknown', address: 'unknown.bso' }, + { address: 'funny' }, { address: 'news.bso', nsfw: false }, ], }), ).toMatchObject({ - schemaVersion: 1, + schemaVersion: 2, revision: 2, communities: [ - { address: 'aww.bso', title: 'Aww', tags: ['cute'] }, + { directoryCode: 'aww', directoryRevision: 1, address: 'aww.bso', title: 'Aww', tags: ['cute'] }, + { address: 'unknown.bso' }, { address: 'news.bso', nsfw: false }, ], }); }); it('rejects unversioned, empty, and malformed remote payloads', () => { - expect(normalizeStarterCommunityList({ schemaVersion: 1, revision: 1 })).toBeNull(); - expect(normalizeStarterCommunityList({ schemaVersion: 1, revision: 1, communities: [] })).toBeNull(); - expect(normalizeStarterCommunityList({ schemaVersion: 1, revision: 0, communities: [{ address: 'aww.bso' }] })).toBeNull(); + expect(normalizeStarterCommunityList({ schemaVersion: 2, revision: 1 })).toBeNull(); + expect(normalizeStarterCommunityList({ schemaVersion: 2, revision: 1, communities: [] })).toBeNull(); + expect(normalizeStarterCommunityList({ schemaVersion: 2, revision: 0, communities: [{ address: 'aww.bso' }] })).toBeNull(); + expect(normalizeStarterCommunityList({ schemaVersion: 2, revision: Number.MAX_SAFE_INTEGER + 1, communities: [{ address: 'aww.bso' }] })).toBeNull(); + expect(normalizeStarterCommunityList({ schemaVersion: 1, revision: 1, communities: [{ address: 'aww.bso' }] })).toBeNull(); }); }); describe('normalizeRemoteStarterCommunityList', () => { const payload = (revision: number) => ({ - schemaVersion: 1, + schemaVersion: 2, revision, communities: [{ address: `revision-${revision}.bso` }], }); @@ -79,6 +84,6 @@ describe('normalizeRemoteStarterCommunityList', () => { }); it('throws for a malformed successful response so the caller enters its fallback state', () => { - expect(() => normalizeRemoteStarterCommunityList({ schemaVersion: 1, revision: 2, communities: [] }, currentList)).toThrow('Invalid default communities response'); + expect(() => normalizeRemoteStarterCommunityList({ schemaVersion: 2, revision: 2, communities: [] }, currentList)).toThrow('Invalid default communities response'); }); }); diff --git a/src/hooks/use-default-subscriptions.ts b/src/hooks/use-default-subscriptions.ts index 5a2a5d0e..0649fb53 100644 --- a/src/hooks/use-default-subscriptions.ts +++ b/src/hooks/use-default-subscriptions.ts @@ -1,6 +1,8 @@ import { useMemo, useSyncExternalStore } from 'react'; import useContentOptionsStore from '../stores/use-content-options-store'; import vendoredStarterCommunities from '../data/seedit-starter-communities.json'; +import { isDirectoryCode, type SeeditDirectoryCode } from '../lib/utils/directory-codes'; +import { isExactCommunitySubscriptionAddress } from '../lib/utils/directory-subscriptions'; const STARTER_COMMUNITIES_URL = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/seedit-default-subscriptions.json'; const REVALIDATE_INTERVAL_MS = 60 * 60 * 1000; @@ -9,6 +11,8 @@ const FETCH_TIMEOUT_MS = 10 * 1000; export interface DefaultSubscription { title?: string; description?: string; + directoryCode?: SeeditDirectoryCode; + directoryRevision?: number; address: string; publicKey?: string; nsfw?: boolean; @@ -16,7 +20,7 @@ export interface DefaultSubscription { } export interface StarterCommunityList { - schemaVersion: 1; + schemaVersion: 2; revision: number; title: string; description: string; @@ -40,11 +44,14 @@ const normalizeStringArray = (value: unknown): string[] | undefined => { }; const normalizeCommunity = (value: unknown): DefaultSubscription | null => { - if (!isRecord(value) || typeof value.address !== 'string' || value.address.length === 0) return null; + if (!isRecord(value) || !isExactCommunitySubscriptionAddress(value.address)) return null; const tags = normalizeStringArray(value.tags); + const directoryCode = typeof value.directoryCode === 'string' && isDirectoryCode(value.directoryCode) ? value.directoryCode : undefined; + const directoryRevision = Number.isSafeInteger(value.directoryRevision) && (value.directoryRevision as number) >= 1 ? (value.directoryRevision as number) : undefined; return { address: value.address, + ...(directoryCode && directoryRevision ? { directoryCode, directoryRevision } : {}), ...(typeof value.title === 'string' && value.title ? { title: value.title } : {}), ...(typeof value.description === 'string' && value.description ? { description: value.description } : {}), ...(typeof value.publicKey === 'string' && value.publicKey ? { publicKey: value.publicKey } : {}), @@ -54,7 +61,7 @@ const normalizeCommunity = (value: unknown): DefaultSubscription | null => { }; export const normalizeStarterCommunityList = (value: unknown): StarterCommunityList | null => { - if (!isRecord(value) || value.schemaVersion !== 1 || !Number.isInteger(value.revision) || (value.revision as number) < 1) return null; + if (!isRecord(value) || value.schemaVersion !== 2 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 1) return null; const rawCommunities = Array.isArray(value.communities) ? value.communities : null; if (!rawCommunities) return null; @@ -63,7 +70,7 @@ export const normalizeStarterCommunityList = (value: unknown): StarterCommunityL if (uniqueCommunities.length === 0) return null; return { - schemaVersion: 1, + schemaVersion: 2, revision: value.revision as number, title: typeof value.title === 'string' ? value.title : 'Seedit Default Communities', description: typeof value.description === 'string' ? value.description : '', @@ -73,17 +80,15 @@ export const normalizeStarterCommunityList = (value: unknown): StarterCommunityL }; }; -const hasSameCommunityAddresses = (first: StarterCommunityList, second: StarterCommunityList): boolean => { - if (first.communities.length !== second.communities.length) return false; - const firstAddresses = new Set(first.communities.map(({ address }) => address)); - return second.communities.every(({ address }) => firstAddresses.has(address)); +const hasSameCommunities = (first: StarterCommunityList, second: StarterCommunityList): boolean => { + return JSON.stringify(first.communities) === JSON.stringify(second.communities); }; export const normalizeRemoteStarterCommunityList = (value: unknown, currentList: StarterCommunityList): StarterCommunityList | null => { const remoteList = normalizeStarterCommunityList(value); if (!remoteList) throw new Error('Invalid default communities response'); if (remoteList.revision < currentList.revision) return null; - if (remoteList.revision === currentList.revision && !hasSameCommunityAddresses(remoteList, currentList)) { + if (remoteList.revision === currentList.revision && !hasSameCommunities(remoteList, currentList)) { throw new Error('Default community membership changed without a new revision'); } return remoteList; diff --git a/src/hooks/use-directory-list.test.ts b/src/hooks/use-directory-list.test.ts new file mode 100644 index 00000000..42af5204 --- /dev/null +++ b/src/hooks/use-directory-list.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchDirectoryListPayload, getDirectoryListSnapshot, revalidateDirectoryList, subscribeToDirectoryList } from './use-directory-list'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('fetchDirectoryListPayload', () => { + it('requests the matching GitHub list without using a cached response', async () => { + const payload = { schemaVersion: 1, revision: 1, communities: [{ address: 'funny-posting.bso' }] }; + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve(payload) }); + vi.stubGlobal('fetch', fetchMock); + + await expect(fetchDirectoryListPayload('funny')).resolves.toEqual(payload); + expect(fetchMock).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/bitsocialnet/lists/master/seedit-directories/seedit-funny-directory.json', + expect.objectContaining({ cache: 'no-cache', signal: expect.any(AbortSignal) }), + ); + }); + + it('rejects HTTP failures', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 })); + await expect(fetchDirectoryListPayload('funny')).rejects.toThrow('Directory list request failed with 503'); + }); +}); + +describe('directory list revalidation', () => { + it('retains the vendored snapshot when remote data is invalid', async () => { + const fallback = getDirectoryListSnapshot('aww').list; + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve({ communities: [] }) })); + + await revalidateDirectoryList('aww'); + + const snapshot = getDirectoryListSnapshot('aww'); + expect(snapshot.list).toBe(fallback); + expect(snapshot.list?.communities[0]?.address).toBe('aww-posting.bso'); + expect(snapshot.error?.message).toMatch(/Invalid directory list response/); + }); + + it('deduplicates in-flight requests and throttles a successful snapshot for one hour', async () => { + let resolveFetch: ((response: unknown) => void) | undefined; + const fetchMock = vi.fn().mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const first = revalidateDirectoryList('videos'); + const second = revalidateDirectoryList('videos'); + expect(second).toBe(first); + + resolveFetch?.({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + schemaVersion: 1, + revision: 1, + directoryCode: 'videos', + communities: [{ address: 'videos-posting.bso', addedAt: 1783123200 }], + }), + }); + await first; + + expect(getDirectoryListSnapshot('videos')).toMatchObject({ loading: false, error: null, list: { revision: 1 } }); + expect(revalidateDirectoryList('videos')).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('revalidates hourly while a visible subscriber remains mounted', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-19T00:00:00Z')); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + schemaVersion: 1, + revision: 1, + directoryCode: 'gaming', + communities: [{ address: 'gaming-posting.bso', addedAt: 1783123200 }], + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const unsubscribe = subscribeToDirectoryList('gaming', () => undefined); + await revalidateDirectoryList('gaming'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(fetchMock).toHaveBeenCalledTimes(2); + unsubscribe(); + }); +}); diff --git a/src/hooks/use-directory-list.ts b/src/hooks/use-directory-list.ts new file mode 100644 index 00000000..b07de810 --- /dev/null +++ b/src/hooks/use-directory-list.ts @@ -0,0 +1,177 @@ +import { useSyncExternalStore } from 'react'; +import { getVendoredDirectoryList, vendoredDirectoryDefaults } from '../data/vendored-directory-lists'; +import { isDirectoryCode, type SeeditDirectoryCode } from '../lib/utils/directory-codes'; +import { normalizeRemoteDirectoryList, type DirectoryList } from '../lib/utils/directory-list-utils'; + +const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/seedit-directories/seedit-{code}-directory.json'; +const REVALIDATE_INTERVAL_MS = 60 * 60 * 1000; +const FETCH_RETRY_DELAY_MS = 60 * 1000; +const FETCH_TIMEOUT_MS = 10 * 1000; + +export interface DirectoryListState { + list: DirectoryList | null; + loading: boolean; + error: Error | null; +} + +interface DirectoryListStore { + directoryCode: SeeditDirectoryCode; + snapshot: DirectoryListState; + listeners: Set<() => void>; + inFlightFetch: Promise | null; + revalidationTimer: ReturnType | null; + lastFetchSuccessAt: number; + lastFetchAttemptAt: number; + handleVisibilityChange: () => void; + subscribe: (listener: () => void) => () => void; + getSnapshot: () => DirectoryListState; +} + +const EMPTY_SNAPSHOT: DirectoryListState = { list: null, loading: false, error: null }; +const emptySubscribe = () => () => undefined; +const getEmptySnapshot = () => EMPTY_SNAPSHOT; +const stores = new Map(); + +const emit = (store: DirectoryListStore) => store.listeners.forEach((listener) => listener()); + +const setSnapshot = (store: DirectoryListStore, snapshot: DirectoryListState) => { + store.snapshot = snapshot; + emit(store); +}; + +const getNextRevalidationDelay = (store: DirectoryListStore): number => { + const now = Date.now(); + if (store.lastFetchSuccessAt > 0 && now - store.lastFetchSuccessAt < REVALIDATE_INTERVAL_MS) { + return REVALIDATE_INTERVAL_MS - (now - store.lastFetchSuccessAt); + } + if (store.lastFetchAttemptAt > 0 && now - store.lastFetchAttemptAt < FETCH_RETRY_DELAY_MS) { + return FETCH_RETRY_DELAY_MS - (now - store.lastFetchAttemptAt); + } + return 0; +}; + +const scheduleRevalidation = (store: DirectoryListStore) => { + if (store.revalidationTimer) globalThis.clearTimeout(store.revalidationTimer); + store.revalidationTimer = null; + if (store.listeners.size === 0) return; + + store.revalidationTimer = globalThis.setTimeout(() => { + store.revalidationTimer = null; + const request = revalidateDirectoryList(store.directoryCode); + if (!request) scheduleRevalidation(store); + }, getNextRevalidationDelay(store)); +}; + +export const fetchDirectoryListPayload = (directoryCode: SeeditDirectoryCode, timeoutMs = FETCH_TIMEOUT_MS): Promise => { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), timeoutMs); + const url = GITHUB_URL_TEMPLATE.replace('{code}', directoryCode); + + return fetch(url, { cache: 'no-cache', signal: controller.signal }) + .then((response) => { + if (!response.ok) throw new Error(`Directory list request failed with ${response.status}`); + return response.json(); + }) + .finally(() => globalThis.clearTimeout(timeout)); +}; + +const shouldRevalidate = (store: DirectoryListStore): boolean => { + const now = Date.now(); + if (store.lastFetchSuccessAt > 0 && now - store.lastFetchSuccessAt < REVALIDATE_INTERVAL_MS) return false; + return store.lastFetchAttemptAt === 0 || now - store.lastFetchAttemptAt >= FETCH_RETRY_DELAY_MS; +}; + +export const revalidateDirectoryList = (directoryCode: SeeditDirectoryCode): Promise | null => { + const store = getDirectoryListStore(directoryCode); + if (store.inFlightFetch) return store.inFlightFetch; + if (!shouldRevalidate(store)) return null; + + store.lastFetchAttemptAt = Date.now(); + setSnapshot(store, { ...store.snapshot, loading: true, error: null }); + + const request = fetchDirectoryListPayload(directoryCode) + .then((payload) => { + const currentList = store.snapshot.list ?? getVendoredDirectoryList(directoryCode); + if (!currentList) throw new Error(`Missing vendored Seedit directory list: ${directoryCode}`); + + const remoteList = normalizeRemoteDirectoryList(payload, directoryCode, currentList, vendoredDirectoryDefaults); + store.lastFetchSuccessAt = Date.now(); + setSnapshot(store, { list: remoteList ?? currentList, loading: false, error: null }); + }) + .catch((error) => { + setSnapshot(store, { + ...store.snapshot, + loading: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + }) + .finally(() => { + store.inFlightFetch = null; + scheduleRevalidation(store); + }); + + store.inFlightFetch = request; + return request; +}; + +const createDirectoryListStore = (directoryCode: SeeditDirectoryCode): DirectoryListStore => { + const fallback = getVendoredDirectoryList(directoryCode); + if (!fallback) throw new Error(`Missing vendored Seedit directory list: ${directoryCode}`); + + const store = { + directoryCode, + snapshot: { list: fallback, loading: true, error: null }, + listeners: new Set<() => void>(), + inFlightFetch: null, + revalidationTimer: null, + lastFetchSuccessAt: 0, + lastFetchAttemptAt: 0, + } as DirectoryListStore; + + store.getSnapshot = () => store.snapshot; + store.handleVisibilityChange = () => { + if (typeof document !== 'undefined' && document.visibilityState === 'visible') void revalidateDirectoryList(directoryCode); + }; + store.subscribe = (listener) => { + store.listeners.add(listener); + if (store.listeners.size === 1 && typeof document !== 'undefined') { + document.addEventListener('visibilitychange', store.handleVisibilityChange); + } + const request = revalidateDirectoryList(directoryCode); + if (!request) scheduleRevalidation(store); + + return () => { + store.listeners.delete(listener); + if (store.listeners.size === 0 && typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', store.handleVisibilityChange); + } + if (store.listeners.size === 0 && store.revalidationTimer) { + globalThis.clearTimeout(store.revalidationTimer); + store.revalidationTimer = null; + } + }; + }; + + return store; +}; + +const getDirectoryListStore = (directoryCode: SeeditDirectoryCode): DirectoryListStore => { + const existing = stores.get(directoryCode); + if (existing) return existing; + + const store = createDirectoryListStore(directoryCode); + stores.set(directoryCode, store); + return store; +}; + +export const getDirectoryListSnapshot = (directoryCode: string | undefined): DirectoryListState => + directoryCode && isDirectoryCode(directoryCode) ? getDirectoryListStore(directoryCode).getSnapshot() : EMPTY_SNAPSHOT; + +export const subscribeToDirectoryList = (directoryCode: SeeditDirectoryCode, listener: () => void): (() => void) => + getDirectoryListStore(directoryCode).subscribe(listener); + +/** Read a route-only directory list. This hook never reads or writes account state. */ +export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => { + const store = directoryCode && isDirectoryCode(directoryCode) ? getDirectoryListStore(directoryCode) : null; + return useSyncExternalStore(store?.subscribe ?? emptySubscribe, store?.getSnapshot ?? getEmptySnapshot, store?.getSnapshot ?? getEmptySnapshot); +}; diff --git a/src/hooks/use-resolved-community-route.test.ts b/src/hooks/use-resolved-community-route.test.ts new file mode 100644 index 00000000..6a0b1b76 --- /dev/null +++ b/src/hooks/use-resolved-community-route.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { DirectoryList } from '../lib/utils/directory-list-utils'; +import { resolveCommunityRouteSnapshot } from './use-resolved-community-route'; + +const funnyList: DirectoryList = { + schemaVersion: 1, + revision: 2, + directoryCode: 'funny', + communities: [ + { address: 'first-funny.bso', score: 1, addedAt: 1 }, + { address: 'best-funny.bso', score: 8, addedAt: 2 }, + ], +}; + +describe('resolveCommunityRouteSnapshot', () => { + it('resolves a known directory route to its deterministic exact winner', () => { + expect(resolveCommunityRouteSnapshot('funny', funnyList, false, null)).toMatchObject({ + communityAddress: 'best-funny.bso', + directoryCode: 'funny', + isDirectory: true, + isResolving: false, + winner: { address: 'best-funny.bso' }, + }); + }); + + it('keeps explicit and unreserved community references independent from directory data', () => { + expect(resolveCommunityRouteSnapshot('funny.bso', funnyList, false, null)).toMatchObject({ + communityAddress: 'funny.bso', + directoryCode: undefined, + directoryList: null, + isDirectory: false, + }); + expect(resolveCommunityRouteSnapshot('unreserved-name', null, false, null).communityAddress).toBe('unreserved-name.bso'); + }); + + it('reports resolution only when a directory has no usable fallback winner', () => { + expect(resolveCommunityRouteSnapshot('funny', null, true, null)).toMatchObject({ communityAddress: undefined, isResolving: true }); + }); +}); diff --git a/src/hooks/use-resolved-community-route.ts b/src/hooks/use-resolved-community-route.ts new file mode 100644 index 00000000..beefb4e3 --- /dev/null +++ b/src/hooks/use-resolved-community-route.ts @@ -0,0 +1,49 @@ +import { useParams } from 'react-router-dom'; +import { isDirectoryCode, type SeeditDirectoryCode } from '../lib/utils/directory-codes'; +import { pickDirectoryWinner, type DirectoryList, type DirectoryListCommunity } from '../lib/utils/directory-list-utils'; +import { resolveCommunityRouteAddress } from '../lib/utils/community-route-utils'; +import { useDirectoryList } from './use-directory-list'; + +export interface ResolvedCommunityRoute { + routeIdentifier: string | undefined; + communityAddress: string | undefined; + directoryCode: SeeditDirectoryCode | undefined; + directoryList: DirectoryList | null; + winner: DirectoryListCommunity | undefined; + isDirectory: boolean; + isResolving: boolean; + error: Error | null; +} + +export const resolveCommunityRouteSnapshot = ( + routeIdentifier: string | undefined, + directoryList: DirectoryList | null, + loading: boolean, + error: Error | null, +): ResolvedCommunityRoute => { + const directoryCode = isDirectoryCode(routeIdentifier) ? routeIdentifier : undefined; + const winner = directoryCode && directoryList ? pickDirectoryWinner(directoryList.communities) : undefined; + + return { + routeIdentifier, + communityAddress: directoryCode ? winner?.address : resolveCommunityRouteAddress(routeIdentifier), + directoryCode, + directoryList: directoryCode ? directoryList : null, + winner, + isDirectory: Boolean(directoryCode), + isResolving: Boolean(directoryCode && loading && !winner), + error: directoryCode ? error : null, + }; +}; + +/** Resolve only the current route. Account subscriptions and home feeds must not use this hook. */ +const useResolvedCommunityRoute = (routeIdentifierOverride?: string): ResolvedCommunityRoute => { + const params = useParams<{ communityAddress?: string }>(); + const routeIdentifier = routeIdentifierOverride ?? params.communityAddress; + const directoryCode = isDirectoryCode(routeIdentifier) ? routeIdentifier : undefined; + const { list, loading, error } = useDirectoryList(directoryCode); + + return resolveCommunityRouteSnapshot(routeIdentifier, list, loading, error); +}; + +export default useResolvedCommunityRoute; diff --git a/src/hooks/use-time-filter.ts b/src/hooks/use-time-filter.ts index a7f63c19..fcf2f535 100644 --- a/src/hooks/use-time-filter.ts +++ b/src/hooks/use-time-filter.ts @@ -2,7 +2,6 @@ import assert from 'assert'; import { useEffect } from 'react'; import { useLocation, useParams, Params } from 'react-router-dom'; import { isCommunityView, isAllView, isModView, isHomeView, isDomainView } from '../lib/utils/view-utils'; -import { resolveCommunityRouteAddress } from '../lib/utils/community-route-utils'; // the timestamp the last time the user visited const lastVisitTimestamp = localStorage.getItem('seeditLastVisitTimestamp'); @@ -86,7 +85,7 @@ const getSessionKeyForView = (pathname: string, params: Readonly> if (isAllView(pathname)) return 'sessionTimeFilter-all'; if (isModView(pathname)) return 'sessionTimeFilter-mod'; if (isDomainView(pathname)) return `sessionTimeFilter-domain-${params.domain}`; - if (isCommunityView(pathname, params)) return `sessionTimeFilter-community-${resolveCommunityRouteAddress(params.communityAddress)}`; + if (isCommunityView(pathname, params)) return `sessionTimeFilter-community-${params.communityAddress}`; return null; }; diff --git a/src/lib/utils/community-route-utils.test.ts b/src/lib/utils/community-route-utils.test.ts index 563efae2..667dd3a5 100644 --- a/src/lib/utils/community-route-utils.test.ts +++ b/src/lib/utils/community-route-utils.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from 'vitest'; import { getCanonicalCommunityPostRedirectPath, + getCanonicalCommunityPostAboutRedirectPath, getCanonicalCommunityRoutePathname, getCommunityPath, getCommunityPostPath, getCommunityPostUrl, + getCommunityReferencePath, + getCommunityReferencePostPath, getCommunityRouteSegment, + getDirectoryPath, + getExactCommunityActionRedirectPath, resolveCommunityRouteAddress, } from './community-route-utils'; @@ -70,18 +75,42 @@ describe('community route builders', () => { expect(getCommunityPostUrl('aww.bso', 'bafy-post-cid')).toBe('https://seedit.app/s/aww.bso/comments/bafy-post-cid'); }); + it('keeps explicit directory paths separate from exact community paths', () => { + expect(getDirectoryPath('funny')).toBe('/s/funny'); + expect(getCommunityPath('funny')).toBe('/s/funny.bso'); + expect(getCommunityReferencePath('funny')).toBe('/s/funny'); + expect(getCommunityReferencePath('funny.bso')).toBe('/s/funny.bso'); + expect(getCommunityReferencePath('unreserved-name')).toBe('/s/unreserved-name.bso'); + expect(getCommunityReferencePostPath('funny', 'bafy-post-cid')).toBe('/s/funny/comments/bafy-post-cid'); + expect(getCommunityReferencePostPath('funny.bso', 'bafy-post-cid')).toBe('/s/funny.bso/comments/bafy-post-cid'); + }); + it('round-trips every canonical address', () => { for (const address of ['aww.bso', 'aww-posting.bso']) { expect(resolveCommunityRouteAddress(getCommunityRouteSegment(address))).toBe(address); } }); + + it('canonicalizes mutable directory action routes to the exact winner', () => { + expect(getExactCommunityActionRedirectPath('/s/funny/submit', 'funny', 'funny-posting.bso')).toBe('/s/funny-posting.bso/submit'); + expect(getExactCommunityActionRedirectPath('/s/funny/settings/editor', 'funny', 'funny-posting.bso', '?mode=raw', '#roles')).toBe( + '/s/funny-posting.bso/settings/editor?mode=raw#roles', + ); + expect(getExactCommunityActionRedirectPath('/s/news/submit', 'funny', 'funny-posting.bso')).toBeUndefined(); + }); }); describe('getCanonicalCommunityRoutePathname', () => { it('redirects bare community routes and preserves nested route suffixes', () => { - expect(getCanonicalCommunityRoutePathname('/s/aww')).toBe('/s/aww.bso'); - expect(getCanonicalCommunityRoutePathname('/s/aww/comments/bafy-post-cid')).toBe('/s/aww.bso/comments/bafy-post-cid'); - expect(getCanonicalCommunityRoutePathname('/s/aww/settings')).toBe('/s/aww.bso/settings'); + expect(getCanonicalCommunityRoutePathname('/s/unreserved-name')).toBe('/s/unreserved-name.bso'); + expect(getCanonicalCommunityRoutePathname('/s/unreserved-name/comments/bafy-post-cid')).toBe('/s/unreserved-name.bso/comments/bafy-post-cid'); + expect(getCanonicalCommunityRoutePathname('/s/unreserved-name/settings')).toBe('/s/unreserved-name.bso/settings'); + }); + + it('preserves known directory routes for route-only winner resolution', () => { + expect(getCanonicalCommunityRoutePathname('/s/aww')).toBeUndefined(); + expect(getCanonicalCommunityRoutePathname('/s/aww/comments/bafy-post-cid')).toBeUndefined(); + expect(getCanonicalCommunityRoutePathname('/s/aww/settings')).toBeUndefined(); }); it('does not redirect canonical, reserved, public-key or unrelated routes', () => { @@ -95,11 +124,22 @@ describe('getCanonicalCommunityRoutePathname', () => { }); describe('getCanonicalCommunityPostRedirectPath', () => { - it('does not redirect when a bare route and bare post address resolve to the same community', () => { - expect(getCanonicalCommunityPostRedirectPath('aww', 'aww', 'bafy-post-cid')).toBeUndefined(); + it('does not redirect when an unreserved bare route resolves to the post community', () => { + expect(getCanonicalCommunityPostRedirectPath('unreserved-name', 'unreserved-name', 'bafy-post-cid')).toBeUndefined(); + }); + + it('always redirects directory post routes to the post owner exact address', () => { + expect(getCanonicalCommunityPostRedirectPath('aww', 'aww.bso', 'bafy-post-cid')).toBe('/s/aww.bso/comments/bafy-post-cid'); + expect(getCanonicalCommunityPostRedirectPath('funny', 'funny-posting.bso', 'bafy-post-cid')).toBe('/s/funny-posting.bso/comments/bafy-post-cid'); }); it('redirects a mismatched route to the canonical post community path', () => { expect(getCanonicalCommunityPostRedirectPath('wrong', 'aww.bso', 'bafy-post-cid')).toBe('/s/aww.bso/comments/bafy-post-cid'); }); }); + +describe('getCanonicalCommunityPostAboutRedirectPath', () => { + it('canonicalizes a directory-coded mobile about route to the post owner', () => { + expect(getCanonicalCommunityPostAboutRedirectPath('funny', 'archived-funny.bso', 'bafy-post-cid')).toBe('/s/archived-funny.bso/comments/bafy-post-cid/about'); + }); +}); diff --git a/src/lib/utils/community-route-utils.ts b/src/lib/utils/community-route-utils.ts index bdcd22f5..f644e25a 100644 --- a/src/lib/utils/community-route-utils.ts +++ b/src/lib/utils/community-route-utils.ts @@ -1,3 +1,5 @@ +import { isDirectoryCode, type SeeditDirectoryCode } from './directory-codes'; + const DEFAULT_COMMUNITY_TLD = '.bso'; const RESERVED_COMMUNITY_ROUTE_SEGMENTS = new Set(['all', 'mod']); const BASE58_PUBLIC_KEY_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{30,}$/; @@ -25,10 +27,32 @@ export const getCommunityRouteSegment = (address: string): string => resolveComm export const getCommunityPath = (communityAddress: string): string => `/s/${encodeURIComponent(getCommunityRouteSegment(communityAddress))}`; +export const getDirectoryPath = (directoryCode: SeeditDirectoryCode): string => `/s/${encodeURIComponent(directoryCode)}`; + +/** Use for typed or linked references that may intentionally name a directory route. */ +export const getCommunityReferencePath = (communityReference: string): string => + isDirectoryCode(communityReference) ? getDirectoryPath(communityReference) : getCommunityPath(communityReference); + export const getCommunityPostPath = (communityAddress: string, cid: string): string => `${getCommunityPath(communityAddress)}/comments/${encodeURIComponent(cid)}`; +/** Preserve an explicitly typed directory reference; generated post permalinks use getCommunityPostPath instead. */ +export const getCommunityReferencePostPath = (communityReference: string, cid: string): string => + `${getCommunityReferencePath(communityReference)}/comments/${encodeURIComponent(cid)}`; + export const getCommunityPostUrl = (communityAddress: string, cid: string): string => `https://seedit.app${getCommunityPostPath(communityAddress, cid)}`; +export const getExactCommunityActionRedirectPath = ( + pathname: string, + directoryCode: SeeditDirectoryCode, + communityAddress: string, + search = '', + hash = '', +): string | undefined => { + const directoryPath = getDirectoryPath(directoryCode); + if (!pathname.startsWith(`${directoryPath}/`)) return undefined; + return `${getCommunityPath(communityAddress)}${pathname.slice(directoryPath.length)}${search}${hash}`; +}; + export const getCanonicalCommunityRoutePathname = (pathname: string): string | undefined => { const routeMatch = pathname.match(/^\/s\/([^/?#]+)(.*)$/); if (!routeMatch) return undefined; @@ -40,6 +64,8 @@ export const getCanonicalCommunityRoutePathname = (pathname: string): string | u return undefined; } + if (isDirectoryCode(routeSegment)) return undefined; + const canonicalRouteSegment = getCommunityRouteSegment(routeSegment); if (canonicalRouteSegment === routeSegment) return undefined; @@ -53,6 +79,16 @@ export const getCanonicalCommunityPostRedirectPath = ( ): string | undefined => { const routeCommunityAddress = resolveCommunityRouteAddress(routeCommunitySegment); const resolvedPostCommunityAddress = resolveCommunityRouteAddress(postCommunityAddress); - if (!cid || !resolvedPostCommunityAddress || routeCommunityAddress === resolvedPostCommunityAddress) return undefined; + if (!cid || !resolvedPostCommunityAddress) return undefined; + if (!isDirectoryCode(routeCommunitySegment) && routeCommunityAddress === resolvedPostCommunityAddress) return undefined; return getCommunityPostPath(resolvedPostCommunityAddress, cid); }; + +export const getCanonicalCommunityPostAboutRedirectPath = ( + routeCommunitySegment: string | undefined, + postCommunityAddress: string | undefined, + cid: string | undefined, +): string | undefined => { + const postRedirectPath = getCanonicalCommunityPostRedirectPath(routeCommunitySegment, postCommunityAddress, cid); + return postRedirectPath ? `${postRedirectPath}/about` : undefined; +}; diff --git a/src/lib/utils/directory-account-transforms.test.ts b/src/lib/utils/directory-account-transforms.test.ts new file mode 100644 index 00000000..6c5f5d6f --- /dev/null +++ b/src/lib/utils/directory-account-transforms.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import type { StarterAccount } from './starter-account'; +import { + applyManualDirectoryWinnerAccount, + joinDirectoryWinnerAccount, + reconcileDirectoryWinnerAccount, + resolveAutomaticDirectoryNoticeAccount, + setDirectoryWinnerAutoSwitchAccount, +} from './directory-account-transforms'; +import type { AuthoritativeDirectoryWinnerSnapshot, SeeditDirectoryPreferences } from './directory-subscriptions'; + +const winner = (address: string, revision: number): AuthoritativeDirectoryWinnerSnapshot => ({ + directoryCode: 'funny', + address, + revision, + authoritative: true, +}); + +const account = (autoSwitch = false): StarterAccount => + ({ + subscriptions: ['funny-a.bso'], + seeditDirectoryPreferences: { + schemaVersion: 1, + slots: { + funny: { + subscriptionAddress: 'funny-a.bso', + autoSwitch, + acknowledgedWinner: { address: 'funny-a.bso', revision: 1 }, + }, + }, + automaticChangeNotices: {}, + } satisfies SeeditDirectoryPreferences, + seeditStarterSubscriptions: { + schemaVersion: 1, + acknowledgedRevision: 2, + knownAddresses: ['funny-a.bso'], + managedAddresses: ['funny-a.bso'], + }, + }) as StarterAccount; + +describe('directory account transforms', () => { + it('joins a newer exact winner while advancing both provenance models', () => { + const result = joinDirectoryWinnerAccount(account(), winner('funny-b.bso', 2)); + + expect(result.subscriptions).toEqual(['funny-a.bso', 'funny-b.bso']); + expect(result.seeditDirectoryPreferences?.slots.funny?.subscriptionAddress).toBe('funny-b.bso'); + expect(result.seeditStarterSubscriptions).toMatchObject({ + knownAddresses: ['funny-b.bso'], + managedAddresses: ['funny-b.bso'], + }); + }); + + it('does not mutate starter provenance when a stale manual action is rejected', () => { + const newer = joinDirectoryWinnerAccount(account(), winner('funny-c.bso', 3)); + const result = applyManualDirectoryWinnerAccount(newer, winner('funny-b.bso', 2), 'switch'); + + expect(result.subscriptions).toEqual(newer.subscriptions); + expect(result.seeditDirectoryPreferences).toEqual(newer.seeditDirectoryPreferences); + expect(result.seeditStarterSubscriptions).toEqual(newer.seeditStarterSubscriptions); + }); + + it('adopts the current winner when auto-switch is enabled after a previously acknowledged change', () => { + const keptPrevious = account(); + keptPrevious.subscriptions = ['funny-a.bso', 'funny-b.bso']; + keptPrevious.seeditDirectoryPreferences!.slots.funny!.acknowledgedWinner = { address: 'funny-b.bso', revision: 2 }; + + const result = setDirectoryWinnerAutoSwitchAccount(keptPrevious, winner('funny-b.bso', 2), true); + + expect(result.subscriptions).toEqual(['funny-b.bso']); + expect(result.seeditDirectoryPreferences?.slots.funny).toEqual({ + subscriptionAddress: 'funny-b.bso', + autoSwitch: true, + acknowledgedWinner: { address: 'funny-b.bso', revision: 2 }, + }); + expect(result.seeditStarterSubscriptions).toMatchObject({ + knownAddresses: ['funny-b.bso'], + managedAddresses: ['funny-b.bso'], + }); + }); + + it('restores starter ownership when an automatic switch is undone', () => { + const switched = reconcileDirectoryWinnerAccount(account(true), winner('funny-b.bso', 2)); + expect(switched.subscriptions).toEqual(['funny-b.bso']); + expect(switched.seeditStarterSubscriptions?.managedAddresses).toEqual(['funny-b.bso']); + + const undone = resolveAutomaticDirectoryNoticeAccount(switched, 'funny', 'undo'); + expect(undone.subscriptions).toEqual(['funny-a.bso']); + expect(undone.seeditStarterSubscriptions).toMatchObject({ + knownAddresses: ['funny-a.bso'], + managedAddresses: ['funny-a.bso'], + }); + expect(undone.seeditDirectoryPreferences?.slots.funny?.autoSwitch).toBe(false); + }); + + it('does not steal unrelated starter ownership when a manual directory subscription is undone', () => { + const manualDirectoryAccount = account(true); + manualDirectoryAccount.subscriptions = ['funny-a.bso', 'funny-b.bso']; + manualDirectoryAccount.seeditStarterSubscriptions = { + schemaVersion: 1, + acknowledgedRevision: 2, + knownAddresses: ['funny-b.bso'], + managedAddresses: ['funny-b.bso'], + }; + + const switched = reconcileDirectoryWinnerAccount(manualDirectoryAccount, winner('funny-b.bso', 2)); + expect(switched.seeditDirectoryPreferences?.automaticChangeNotices.funny?.starterProvenanceTransition).toBeUndefined(); + + const undone = resolveAutomaticDirectoryNoticeAccount(switched, 'funny', 'undo'); + expect(undone.subscriptions).toEqual(['funny-b.bso', 'funny-a.bso']); + expect(undone.seeditStarterSubscriptions).toEqual(manualDirectoryAccount.seeditStarterSubscriptions); + }); + + it('preserves later starter-list changes when undoing an automatic switch', () => { + const switched = reconcileDirectoryWinnerAccount(account(true), winner('funny-b.bso', 2)); + const withLaterStarterChange = { + ...switched, + seeditStarterSubscriptions: { + ...switched.seeditStarterSubscriptions!, + acknowledgedRevision: 3, + knownAddresses: [...switched.seeditStarterSubscriptions!.knownAddresses, 'later-default.bso'], + managedAddresses: [...switched.seeditStarterSubscriptions!.managedAddresses, 'later-default.bso'], + }, + } as StarterAccount; + + const undone = resolveAutomaticDirectoryNoticeAccount(withLaterStarterChange, 'funny', 'undo'); + expect(undone.seeditStarterSubscriptions).toEqual({ + schemaVersion: 1, + acknowledgedRevision: 3, + knownAddresses: ['later-default.bso', 'funny-a.bso'], + managedAddresses: ['later-default.bso', 'funny-a.bso'], + }); + }); +}); diff --git a/src/lib/utils/directory-account-transforms.ts b/src/lib/utils/directory-account-transforms.ts new file mode 100644 index 00000000..a32cb59d --- /dev/null +++ b/src/lib/utils/directory-account-transforms.ts @@ -0,0 +1,154 @@ +import type { StarterAccount } from './starter-account'; +import { + dismissAutomaticDirectoryChange, + joinDirectorySubscription, + keepBothDirectorySubscriptions, + keepCurrentDirectorySubscription, + observeDirectoryWinnerChanges, + reconcileAutomaticDirectorySwitches, + setDirectoryAutoSwitch, + switchDirectorySubscription, + undoAutomaticDirectorySwitch, + type AuthoritativeDirectoryWinnerSnapshot, + type DirectoryAutomaticChangeNotice, + type DirectorySubscriptionsState, +} from './directory-subscriptions'; +import type { SeeditDirectoryCode } from './directory-codes'; +import { acknowledgeStarterDirectoryChange } from './starter-subscriptions'; + +export type ManualDirectoryWinnerAction = 'keep' | 'keepBoth' | 'switch'; + +const applyDirectoryState = (account: StarterAccount, result: DirectorySubscriptionsState, starterProvenance = account.seeditStarterSubscriptions): StarterAccount => ({ + ...account, + subscriptions: result.subscriptions, + seeditDirectoryPreferences: result.preferences, + ...(starterProvenance ? { seeditStarterSubscriptions: starterProvenance } : {}), +}); + +const acceptedWinner = (result: DirectorySubscriptionsState, winner: AuthoritativeDirectoryWinnerSnapshot): boolean => { + const slot = result.preferences.slots[winner.directoryCode]; + return slot?.acknowledgedWinner.address === winner.address && slot.acknowledgedWinner.revision === winner.revision; +}; + +const advanceStarterProvenance = ( + account: StarterAccount, + previousAddress: string | undefined, + winner: AuthoritativeDirectoryWinnerSnapshot, + result: DirectorySubscriptionsState, + choice: 'keep' | 'trackWinner', +) => + previousAddress && acceptedWinner(result, winner) + ? acknowledgeStarterDirectoryChange(account.seeditStarterSubscriptions, previousAddress, winner.address, choice) + : account.seeditStarterSubscriptions; + +const restoreStarterProvenanceTransition = ( + current: StarterAccount['seeditStarterSubscriptions'], + transition: DirectoryAutomaticChangeNotice['starterProvenanceTransition'], + fromAddress: string, + toAddress: string, +) => { + if (!current || !transition) return current; + + const restoreEndpointMembership = (currentAddresses: string[], beforeAddresses: string[], afterAddresses: string[]): string[] => { + const endpoints = [fromAddress, toAddress]; + const currentMatchesAfter = endpoints.every((address) => currentAddresses.includes(address) === afterAddresses.includes(address)); + if (!currentMatchesAfter) return currentAddresses; + + const restored = currentAddresses.filter((address) => !endpoints.includes(address)); + for (const address of endpoints) { + if (beforeAddresses.includes(address)) restored.push(address); + } + return restored; + }; + + return { + ...current, + knownAddresses: restoreEndpointMembership(current.knownAddresses, transition.before.knownAddresses, transition.after.knownAddresses), + managedAddresses: restoreEndpointMembership(current.managedAddresses, transition.before.managedAddresses, transition.after.managedAddresses), + }; +}; + +/** Join the exact winner, advance its slot, and keep starter ownership aligned atomically. */ +export const joinDirectoryWinnerAccount = (account: StarterAccount, winner: AuthoritativeDirectoryWinnerSnapshot): StarterAccount => { + const previousAddress = account.seeditDirectoryPreferences?.slots?.[winner.directoryCode]?.subscriptionAddress; + const result = joinDirectorySubscription({ + subscriptions: account.subscriptions ?? [], + preferences: account.seeditDirectoryPreferences, + winner, + }); + const provenance = + previousAddress && previousAddress !== winner.address + ? advanceStarterProvenance(account, previousAddress, winner, result, 'trackWinner') + : account.seeditStarterSubscriptions; + return applyDirectoryState(account, result, provenance); +}; + +export const setDirectoryWinnerAutoSwitchAccount = (account: StarterAccount, winner: AuthoritativeDirectoryWinnerSnapshot, enabled: boolean): StarterAccount => { + const previousAddress = account.seeditDirectoryPreferences?.slots?.[winner.directoryCode]?.subscriptionAddress; + const joined = enabled + ? previousAddress && previousAddress !== winner.address + ? switchDirectorySubscription({ subscriptions: account.subscriptions ?? [], preferences: account.seeditDirectoryPreferences, winner }) + : joinDirectorySubscription({ subscriptions: account.subscriptions ?? [], preferences: account.seeditDirectoryPreferences, winner }) + : { subscriptions: account.subscriptions ?? [], preferences: account.seeditDirectoryPreferences }; + const result = setDirectoryAutoSwitch({ ...joined, directoryCode: winner.directoryCode, enabled }); + const provenance = + enabled && previousAddress && previousAddress !== winner.address + ? advanceStarterProvenance(account, previousAddress, winner, result, 'trackWinner') + : account.seeditStarterSubscriptions; + return applyDirectoryState(account, result, provenance); +}; + +export const applyManualDirectoryWinnerAccount = ( + account: StarterAccount, + winner: AuthoritativeDirectoryWinnerSnapshot, + action: ManualDirectoryWinnerAction, +): StarterAccount => { + const previousAddress = account.seeditDirectoryPreferences?.slots?.[winner.directoryCode]?.subscriptionAddress; + const input = { subscriptions: account.subscriptions ?? [], preferences: account.seeditDirectoryPreferences, winner }; + const result = + action === 'switch' ? switchDirectorySubscription(input) : action === 'keepBoth' ? keepBothDirectorySubscriptions(input) : keepCurrentDirectorySubscription(input); + const provenance = advanceStarterProvenance(account, previousAddress, winner, result, action === 'keep' ? 'keep' : 'trackWinner'); + return applyDirectoryState(account, result, provenance); +}; + +export const reconcileDirectoryWinnerAccount = (account: StarterAccount, winner: AuthoritativeDirectoryWinnerSnapshot): StarterAccount => { + const automaticState = reconcileAutomaticDirectorySwitches({ + subscriptions: account.subscriptions ?? [], + preferences: account.seeditDirectoryPreferences, + winners: [winner], + }); + const observedState = observeDirectoryWinnerChanges({ ...automaticState, winners: [winner] }); + const notice = observedState.preferences.automaticChangeNotices[winner.directoryCode]; + const provenance = + notice?.toAddress === winner.address && notice.winnerRevision === winner.revision + ? acknowledgeStarterDirectoryChange(account.seeditStarterSubscriptions, notice.fromAddress, notice.toAddress, 'trackWinner') + : account.seeditStarterSubscriptions; + const provenanceChanged = JSON.stringify(provenance) !== JSON.stringify(account.seeditStarterSubscriptions); + const preferences = + notice && provenanceChanged && account.seeditStarterSubscriptions && provenance && !notice.starterProvenanceTransition + ? { + ...observedState.preferences, + automaticChangeNotices: { + ...observedState.preferences.automaticChangeNotices, + [winner.directoryCode]: { + ...notice, + starterProvenanceTransition: { before: account.seeditStarterSubscriptions, after: provenance }, + }, + }, + } + : observedState.preferences; + return applyDirectoryState(account, { ...observedState, preferences }, provenance); +}; + +export const resolveAutomaticDirectoryNoticeAccount = (account: StarterAccount, directoryCode: SeeditDirectoryCode, action: 'dismiss' | 'undo'): StarterAccount => { + const notice = account.seeditDirectoryPreferences?.automaticChangeNotices?.[directoryCode]; + const input = { subscriptions: account.subscriptions ?? [], preferences: account.seeditDirectoryPreferences, directoryCode }; + const result = action === 'undo' ? undoAutomaticDirectorySwitch(input) : dismissAutomaticDirectoryChange(input); + const resultingSlot = result.preferences.slots[directoryCode]; + const undoApplied = + action === 'undo' && notice && resultingSlot?.subscriptionAddress === notice.fromAddress && !result.preferences.automaticChangeNotices[directoryCode]; + const provenance = undoApplied + ? restoreStarterProvenanceTransition(account.seeditStarterSubscriptions, notice.starterProvenanceTransition, notice.fromAddress, notice.toAddress) + : account.seeditStarterSubscriptions; + return applyDirectoryState(account, result, provenance); +}; diff --git a/src/lib/utils/directory-codes.test.ts b/src/lib/utils/directory-codes.test.ts new file mode 100644 index 00000000..7119594e --- /dev/null +++ b/src/lib/utils/directory-codes.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { isDirectoryCode, SEEDIT_DIRECTORY_CODES } from './directory-codes'; + +describe('Seedit directory codes', () => { + it('contains the ten reserved lowercase route slugs', () => { + expect(SEEDIT_DIRECTORY_CODES).toEqual(['askseedit', 'memes', 'news', 'pics', 'todayilearned', 'interestingasfuck', 'gaming', 'videos', 'funny', 'aww']); + }); + + it('recognizes only exact directory slugs', () => { + expect(isDirectoryCode('funny')).toBe(true); + expect(isDirectoryCode('FUNNY')).toBe(false); + expect(isDirectoryCode('funny.bso')).toBe(false); + expect(isDirectoryCode('all')).toBe(false); + expect(isDirectoryCode('mod')).toBe(false); + expect(isDirectoryCode(undefined)).toBe(false); + }); +}); diff --git a/src/lib/utils/directory-codes.ts b/src/lib/utils/directory-codes.ts new file mode 100644 index 00000000..84a89576 --- /dev/null +++ b/src/lib/utils/directory-codes.ts @@ -0,0 +1,11 @@ +/** + * Seedit's reserved short community routes. These slugs are route-only discovery + * identifiers and must never be persisted in account subscriptions. + */ +export const SEEDIT_DIRECTORY_CODES = ['askseedit', 'memes', 'news', 'pics', 'todayilearned', 'interestingasfuck', 'gaming', 'videos', 'funny', 'aww'] as const; + +export type SeeditDirectoryCode = (typeof SEEDIT_DIRECTORY_CODES)[number]; + +const DIRECTORY_CODES_SET: ReadonlySet = new Set(SEEDIT_DIRECTORY_CODES); + +export const isDirectoryCode = (value: string | undefined): value is SeeditDirectoryCode => typeof value === 'string' && DIRECTORY_CODES_SET.has(value); diff --git a/src/lib/utils/directory-list-utils.test.ts b/src/lib/utils/directory-list-utils.test.ts new file mode 100644 index 00000000..f00f2455 --- /dev/null +++ b/src/lib/utils/directory-list-utils.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeDirectoryDefaultsData, + normalizeDirectoryList, + normalizeRemoteDirectoryList, + pickDirectoryWinner, + sortDirectoryCommunitiesByRank, +} from './directory-list-utils'; + +const directoryPayload = (overrides: Record = {}) => ({ + schemaVersion: 1, + revision: 1, + directoryCode: 'funny', + createdAt: 100, + updatedAt: 100, + communities: [{ address: 'funny-posting.bso', owner: 'owner.bso', addedAt: 10 }], + ...overrides, +}); + +describe('directory list normalization', () => { + it('normalizes the current schema and applies shared display defaults', () => { + const defaults = normalizeDirectoryDefaultsData({ + directories: { + funny: { title: 'Funny', description: 'Humour.', tags: ['humor', 'humor'] }, + }, + }); + + expect(normalizeDirectoryList(directoryPayload(), 'funny', defaults)).toEqual({ + schemaVersion: 1, + revision: 1, + directoryCode: 'funny', + title: 'Funny', + description: 'Humour.', + tags: ['humor'], + createdAt: 100, + updatedAt: 100, + communities: [{ address: 'funny-posting.bso', owner: 'owner.bso', addedAt: 10 }], + }); + }); + + it('rejects missing or invalid schema revisions, mismatched codes, and empty candidate lists', () => { + expect(normalizeDirectoryList(directoryPayload({ schemaVersion: undefined }), 'funny')).toBeNull(); + expect(normalizeDirectoryList(directoryPayload({ revision: 0 }), 'funny')).toBeNull(); + expect(normalizeDirectoryList(directoryPayload({ revision: 1.5 }), 'funny')).toBeNull(); + expect(normalizeDirectoryList(directoryPayload({ revision: Number.MAX_SAFE_INTEGER + 1 }), 'funny')).toBeNull(); + expect(normalizeDirectoryList(directoryPayload({ directoryCode: undefined }), 'funny')).toBeNull(); + expect(normalizeDirectoryList(directoryPayload({ directoryCode: 'news' }), 'funny')).toBeNull(); + expect(normalizeDirectoryList(directoryPayload({ communities: [] }), 'funny')).toBeNull(); + }); + + it('drops malformed and duplicate candidate entries', () => { + const list = normalizeDirectoryList( + directoryPayload({ + communities: [{ address: 'one.bso' }, { address: '' }, { address: 'funny' }, null, { address: 'one.bso', score: 100 }, { address: 'two.bso' }], + }), + 'funny', + ); + + expect(list?.communities).toEqual([{ address: 'one.bso' }, { address: 'two.bso' }]); + }); +}); + +describe('directory winner selection', () => { + it('ranks by score descending, addedAt ascending, then address', () => { + const candidates = [ + { address: 'z.bso', score: 5, addedAt: 20, owner: 'first-owner.bso' }, + { address: 'b.bso', score: 8, addedAt: 20, owner: 'second-owner.bso' }, + { address: 'a.bso', score: 8, addedAt: 20, owner: 'third-owner.bso' }, + { address: 'old.bso', score: 8, addedAt: 10, owner: 'unknown.bso' }, + ]; + + expect(sortDirectoryCommunitiesByRank(candidates).map(({ address }) => address)).toEqual(['old.bso', 'a.bso', 'b.bso', 'z.bso']); + expect(pickDirectoryWinner(candidates)?.address).toBe('old.bso'); + }); + + it('does not privilege an owner or accept client-local availability input', () => { + expect( + pickDirectoryWinner([ + { address: 'ranked-second.bso', score: 1, owner: 'bitsocialist.bso' }, + { address: 'ranked-first.bso', score: 2, owner: 'unknown.bso' }, + ])?.address, + ).toBe('ranked-first.bso'); + }); +}); + +describe('remote directory revisions', () => { + const current = normalizeDirectoryList( + directoryPayload({ + revision: 2, + communities: [ + { address: 'winner.bso', score: 2 }, + { address: 'other.bso', score: 1 }, + ], + }), + 'funny', + )!; + + it('ignores older snapshots', () => { + expect(normalizeRemoteDirectoryList(directoryPayload({ revision: 1 }), 'funny', current)).toBeNull(); + }); + + it('rejects candidate or winner changes at the same revision', () => { + expect(() => + normalizeRemoteDirectoryList( + directoryPayload({ + revision: 2, + communities: [ + { address: 'winner.bso', score: 2 }, + { address: 'new.bso', score: 1 }, + ], + }), + 'funny', + current, + ), + ).toThrow(/without a new revision/); + + expect(() => + normalizeRemoteDirectoryList( + directoryPayload({ + revision: 2, + communities: [ + { address: 'winner.bso', score: 1 }, + { address: 'other.bso', score: 2 }, + ], + }), + 'funny', + current, + ), + ).toThrow(/without a new revision/); + }); + + it('accepts a candidate and winner change at a higher revision', () => { + const remote = normalizeRemoteDirectoryList(directoryPayload({ revision: 3, communities: [{ address: 'new-winner.bso', score: 10 }] }), 'funny', current); + + expect(remote?.revision).toBe(3); + expect(pickDirectoryWinner(remote?.communities ?? [])?.address).toBe('new-winner.bso'); + }); +}); diff --git a/src/lib/utils/directory-list-utils.ts b/src/lib/utils/directory-list-utils.ts new file mode 100644 index 00000000..a90dd3d5 --- /dev/null +++ b/src/lib/utils/directory-list-utils.ts @@ -0,0 +1,184 @@ +// Directory lists are route-discovery snapshots. They select the community shown at a +// reserved /s/ entry route, but their slugs are never account subscription values. + +import { isResolvableCommunityAddress } from './community-route-utils'; + +export interface DirectoryListCommunity { + address: string; + publicKey?: string; + owner?: string; + score?: number; + addedAt?: number; + nsfw?: boolean; + tags?: string[]; +} + +export interface DirectoryList { + schemaVersion: 1; + revision: number; + directoryCode: string; + title?: string; + description?: string; + tags?: string[]; + createdAt?: number; + updatedAt?: number; + communities: DirectoryListCommunity[]; +} + +export interface DirectoryDefaultsEntry { + directoryCode?: string; + title?: string; + description?: string; + tags?: string[]; +} + +export interface DirectoryDefaultsData { + title?: string; + description?: string; + createdAt?: number; + updatedAt?: number; + directories: Record; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined); + +const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined); + +const toTags = (value: unknown): string[] | undefined => { + if (!Array.isArray(value)) return undefined; + const tags = [...new Set(value.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0))]; + return tags.length > 0 ? tags : undefined; +}; + +const normalizeDirectoryDefaultsEntry = (code: string, raw: unknown): DirectoryDefaultsEntry => { + if (!isRecord(raw)) return { directoryCode: code }; + const directoryCode = toString(raw.directoryCode) ?? code; + const title = toString(raw.title); + const description = toString(raw.description); + const tags = toTags(raw.tags); + + return { + directoryCode, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(tags ? { tags } : {}), + }; +}; + +export const normalizeDirectoryDefaultsData = (raw: unknown): DirectoryDefaultsData => { + const directoriesRaw = isRecord(raw) && isRecord(raw.directories) ? raw.directories : {}; + const directories = Object.fromEntries(Object.entries(directoriesRaw).map(([code, value]) => [code, normalizeDirectoryDefaultsEntry(code, value)])); + const title = isRecord(raw) ? toString(raw.title) : undefined; + const description = isRecord(raw) ? toString(raw.description) : undefined; + const createdAt = isRecord(raw) ? toNumber(raw.createdAt) : undefined; + const updatedAt = isRecord(raw) ? toNumber(raw.updatedAt) : undefined; + + return { + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + directories, + }; +}; + +const normalizeDirectoryListCommunity = (raw: unknown): DirectoryListCommunity | null => { + if (!isRecord(raw)) return null; + const address = toString(raw.address); + if (!isResolvableCommunityAddress(address)) return null; + + const publicKey = toString(raw.publicKey); + const owner = toString(raw.owner); + const score = toNumber(raw.score); + const addedAt = toNumber(raw.addedAt); + const tags = toTags(raw.tags); + + return { + address, + ...(publicKey ? { publicKey } : {}), + ...(owner ? { owner } : {}), + ...(score !== undefined ? { score } : {}), + ...(addedAt !== undefined ? { addedAt } : {}), + ...(typeof raw.nsfw === 'boolean' ? { nsfw: raw.nsfw } : {}), + ...(tags ? { tags } : {}), + }; +}; + +export const normalizeDirectoryList = (raw: unknown, fallbackCode: string, defaults?: DirectoryDefaultsData): DirectoryList | null => { + if (!isRecord(raw) || raw.schemaVersion !== 1 || !Number.isSafeInteger(raw.revision) || (raw.revision as number) < 1) return null; + if (!Array.isArray(raw.communities)) return null; + + const rawCode = toString(raw.directoryCode); + if (rawCode !== fallbackCode) return null; + + const communities = raw.communities + .map(normalizeDirectoryListCommunity) + .filter((community): community is DirectoryListCommunity => community !== null) + .filter((community, index, entries) => entries.findIndex(({ address }) => address === community.address) === index); + if (communities.length === 0) return null; + + const defaultEntry = defaults?.directories[fallbackCode]; + const directoryCode = toString(defaultEntry?.directoryCode) ?? fallbackCode; + if (directoryCode !== fallbackCode) return null; + + const title = toString(defaultEntry?.title) ?? toString(raw.title); + const description = toString(defaultEntry?.description) ?? toString(raw.description); + const tags = toTags(defaultEntry?.tags) ?? toTags(raw.tags); + const createdAt = toNumber(raw.createdAt); + const updatedAt = toNumber(raw.updatedAt); + + return { + schemaVersion: 1, + revision: raw.revision as number, + directoryCode, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(tags ? { tags } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + communities, + }; +}; + +/** Score descending, then oldest candidate first, then address for a total stable order. */ +export const sortDirectoryCommunitiesByRank = (communities: readonly DirectoryListCommunity[]): DirectoryListCommunity[] => + [...communities].sort((first, second) => { + const scoreDifference = (second.score ?? 0) - (first.score ?? 0); + if (scoreDifference !== 0) return scoreDifference; + + const addedAtDifference = (first.addedAt ?? Number.MAX_SAFE_INTEGER) - (second.addedAt ?? Number.MAX_SAFE_INTEGER); + if (addedAtDifference !== 0) return addedAtDifference; + + return first.address < second.address ? -1 : first.address > second.address ? 1 : 0; + }); + +/** Select the published snapshot's deterministic winner without client-local failover. */ +export const pickDirectoryWinner = (communities: readonly DirectoryListCommunity[]): DirectoryListCommunity | undefined => sortDirectoryCommunitiesByRank(communities)[0]; + +const hasSameCandidateAddresses = (first: DirectoryList, second: DirectoryList): boolean => { + if (first.communities.length !== second.communities.length) return false; + const firstAddresses = new Set(first.communities.map(({ address }) => address)); + return second.communities.every(({ address }) => firstAddresses.has(address)); +}; + +/** + * Validate a remote snapshot against the active one. A revision is the atomic winner-change + * boundary: candidate membership or the winner cannot change without incrementing it. + */ +export const normalizeRemoteDirectoryList = (raw: unknown, directoryCode: string, currentList: DirectoryList, defaults?: DirectoryDefaultsData): DirectoryList | null => { + const remoteList = normalizeDirectoryList(raw, directoryCode, defaults); + if (!remoteList) throw new Error(`Invalid directory list response for ${directoryCode}`); + if (remoteList.revision < currentList.revision) return null; + + if (remoteList.revision === currentList.revision) { + const sameCandidates = hasSameCandidateAddresses(remoteList, currentList); + const sameWinner = pickDirectoryWinner(remoteList.communities)?.address === pickDirectoryWinner(currentList.communities)?.address; + if (!sameCandidates || !sameWinner) { + throw new Error(`Directory ${directoryCode} changed winner or candidates without a new revision`); + } + } + + return remoteList; +}; diff --git a/src/lib/utils/directory-subscriptions.test.ts b/src/lib/utils/directory-subscriptions.test.ts new file mode 100644 index 00000000..aae10349 --- /dev/null +++ b/src/lib/utils/directory-subscriptions.test.ts @@ -0,0 +1,384 @@ +import { describe, expect, it } from 'vitest'; +import type { SeeditDirectoryCode } from './directory-codes'; +import { + createEmptyDirectoryPreferences, + dismissAutomaticDirectoryChange, + joinDirectorySubscription, + keepBothDirectorySubscriptions, + keepCurrentDirectorySubscription, + leaveDirectorySubscription, + observeDirectoryWinnerChanges, + reconcileAutomaticDirectorySwitches, + setDirectoryAutoSwitch, + switchDirectorySubscription, + undoAutomaticDirectorySwitch, + type AuthoritativeDirectoryWinnerSnapshot, + type SeeditDirectoryPreferences, +} from './directory-subscriptions'; + +const winner = (directoryCode: SeeditDirectoryCode, address: string, revision: number): AuthoritativeDirectoryWinnerSnapshot => ({ + directoryCode, + address, + revision, + authoritative: true, +}); + +const preferences = ( + entries: Partial>, +): SeeditDirectoryPreferences => ({ + schemaVersion: 1, + slots: Object.fromEntries( + Object.entries(entries).map(([code, entry]) => [ + code, + { + subscriptionAddress: entry.address, + autoSwitch: entry.autoSwitch ?? false, + acknowledgedWinner: { address: entry.acknowledgedAddress ?? entry.address, revision: entry.revision ?? 1 }, + }, + ]), + ), + automaticChangeNotices: {}, +}); + +describe('directory subscription joins and leaves', () => { + it('atomically joins only the exact winner and records route provenance', () => { + const result = joinDirectorySubscription({ + subscriptions: ['manual.bso', 'funny', 'manual.bso'], + winner: winner('funny', 'funny-posting.bso', 2), + }); + + expect(result.subscriptions).toEqual(['manual.bso', 'funny-posting.bso']); + expect(result.preferences.slots.funny).toEqual({ + subscriptionAddress: 'funny-posting.bso', + autoSwitch: false, + acknowledgedWinner: { address: 'funny-posting.bso', revision: 2 }, + }); + expect(result.subscriptions).not.toContain('funny'); + }); + + it('is idempotent and preserves an existing per-slot auto-switch choice', () => { + const first = joinDirectorySubscription({ subscriptions: [], winner: winner('funny', 'funny-posting.bso', 1) }); + const enabled = setDirectoryAutoSwitch({ ...first, directoryCode: 'funny', enabled: true }); + const second = joinDirectorySubscription({ ...enabled, winner: winner('funny', 'funny-posting.bso', 1) }); + + expect(second.subscriptions).toEqual(['funny-posting.bso']); + expect(second.preferences.slots.funny?.autoSwitch).toBe(true); + }); + + it('does not join a stale or conflicting same-revision winner over existing slot state', () => { + const initial = { + subscriptions: ['funny-current.bso'], + preferences: preferences({ funny: { address: 'funny-current.bso', revision: 3 } }), + }; + + expect(joinDirectorySubscription({ ...initial, winner: winner('funny', 'funny-stale.bso', 2) })).toEqual(initial); + expect(joinDirectorySubscription({ ...initial, winner: winner('funny', 'funny-conflict.bso', 3) })).toEqual(initial); + }); + + it('manual leave removes the exact address and every slot and notice tracking it', () => { + const initial = preferences({ + funny: { address: 'shared.bso', autoSwitch: true }, + memes: { address: 'shared.bso' }, + news: { address: 'news.bso' }, + }); + initial.automaticChangeNotices.funny = { + directoryCode: 'funny', + fromAddress: 'old-funny.bso', + toAddress: 'shared.bso', + winnerRevision: 2, + addedWinnerSubscription: true, + removedPreviousSubscription: true, + }; + + const result = leaveDirectorySubscription({ + subscriptions: ['shared.bso', 'news.bso'], + preferences: initial, + address: 'shared.bso', + }); + + expect(result.subscriptions).toEqual(['news.bso']); + expect(result.preferences.slots).toEqual({ news: initial.slots.news }); + expect(result.preferences.automaticChangeNotices).toEqual({}); + }); +}); + +describe('observing authoritative directory winners', () => { + it('reports only newer authoritative winner changes for manually controlled slots', () => { + const state = { + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso', revision: 2 } }), + }; + + expect(observeDirectoryWinnerChanges({ ...state, winners: [winner('funny', 'funny-b.bso', 2)] }).pendingChanges).toEqual([]); + expect(observeDirectoryWinnerChanges({ ...state, winners: [winner('funny', 'funny-b.bso', 1)] }).pendingChanges).toEqual([]); + expect(observeDirectoryWinnerChanges({ ...state, winners: [winner('funny', 'funny-b.bso', 3)] }).pendingChanges).toEqual([ + { + directoryCode: 'funny', + currentAddress: 'funny-a.bso', + winnerAddress: 'funny-b.bso', + winnerRevision: 3, + }, + ]); + + const nonAuthoritative = { ...winner('funny', 'funny-c.bso', 4), authoritative: false } as unknown as AuthoritativeDirectoryWinnerSnapshot; + expect(observeDirectoryWinnerChanges({ ...state, winners: [nonAuthoritative] }).pendingChanges).toEqual([]); + }); + + it('rejects conflicting winners published at the same revision', () => { + const state = { + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso' } }), + }; + + expect( + observeDirectoryWinnerChanges({ + ...state, + winners: [winner('funny', 'funny-b.bso', 2), winner('funny', 'funny-c.bso', 2)], + }).pendingChanges, + ).toEqual([]); + }); + + it('advances acknowledgement when the winner returns, so A to B to A to B prompts twice', () => { + const initial = { + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso' } }), + }; + const firstB = observeDirectoryWinnerChanges({ ...initial, winners: [winner('funny', 'funny-b.bso', 2)] }); + expect(firstB.pendingChanges).toHaveLength(1); + + const keptA = keepCurrentDirectorySubscription({ ...firstB, winner: winner('funny', 'funny-b.bso', 2) }); + const returnedA = observeDirectoryWinnerChanges({ ...keptA, winners: [winner('funny', 'funny-a.bso', 3)] }); + expect(returnedA.pendingChanges).toEqual([]); + expect(returnedA.preferences.slots.funny?.acknowledgedWinner).toEqual({ address: 'funny-a.bso', revision: 3 }); + + const secondB = observeDirectoryWinnerChanges({ ...returnedA, winners: [winner('funny', 'funny-b.bso', 4)] }); + expect(secondB.pendingChanges).toEqual([ + { + directoryCode: 'funny', + currentAddress: 'funny-a.bso', + winnerAddress: 'funny-b.bso', + winnerRevision: 4, + }, + ]); + }); + + it('drops stale slot tracking when its exact address was removed outside Seedit', () => { + const result = observeDirectoryWinnerChanges({ + subscriptions: ['manual.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso' } }), + winners: [winner('funny', 'funny-b.bso', 2)], + }); + + expect(result.preferences.slots).toEqual({}); + expect(result.pendingChanges).toEqual([]); + expect(result.subscriptions).toEqual(['manual.bso']); + }); +}); + +describe('manual winner-change choices', () => { + const initial = { + subscriptions: ['manual.bso', 'funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso' } }), + }; + + it('keeps the current exact subscription while acknowledging the winner', () => { + const result = keepCurrentDirectorySubscription({ ...initial, winner: winner('funny', 'funny-b.bso', 2) }); + + expect(result.subscriptions).toEqual(initial.subscriptions); + expect(result.preferences.slots.funny).toMatchObject({ + subscriptionAddress: 'funny-a.bso', + acknowledgedWinner: { address: 'funny-b.bso', revision: 2 }, + }); + }); + + it('keeps both exact addresses but tracks the new winner from then on', () => { + const result = keepBothDirectorySubscriptions({ ...initial, winner: winner('funny', 'funny-b.bso', 2) }); + + expect(result.subscriptions).toEqual(['manual.bso', 'funny-a.bso', 'funny-b.bso']); + expect(result.preferences.slots.funny).toMatchObject({ + subscriptionAddress: 'funny-b.bso', + acknowledgedWinner: { address: 'funny-b.bso', revision: 2 }, + }); + }); + + it('switches the attributable address and deduplicates an already-subscribed winner', () => { + const result = switchDirectorySubscription({ + ...initial, + subscriptions: [...initial.subscriptions, 'funny-b.bso', 'funny-b.bso'], + winner: winner('funny', 'funny-b.bso', 2), + }); + + expect(result.subscriptions).toEqual(['manual.bso', 'funny-b.bso']); + expect(result.preferences.slots.funny?.subscriptionAddress).toBe('funny-b.bso'); + }); + + it('preserves an old exact address while another directory slot still tracks it', () => { + const sharedPreferences = preferences({ + funny: { address: 'shared.bso' }, + memes: { address: 'shared.bso' }, + }); + const first = switchDirectorySubscription({ + subscriptions: ['shared.bso'], + preferences: sharedPreferences, + winner: winner('funny', 'funny-b.bso', 2), + }); + expect(first.subscriptions).toEqual(['shared.bso', 'funny-b.bso']); + + const second = switchDirectorySubscription({ ...first, winner: winner('memes', 'memes-b.bso', 2) }); + expect(second.subscriptions).toEqual(['funny-b.bso', 'memes-b.bso']); + }); + + it('ignores same and stale revisions for every manual choice', () => { + const stale = winner('funny', 'funny-b.bso', 1); + expect(keepCurrentDirectorySubscription({ ...initial, winner: stale }).subscriptions).toEqual(initial.subscriptions); + expect(keepBothDirectorySubscriptions({ ...initial, winner: stale }).subscriptions).toEqual(initial.subscriptions); + expect(switchDirectorySubscription({ ...initial, winner: stale }).subscriptions).toEqual(initial.subscriptions); + }); +}); + +describe('per-directory automatic switching', () => { + it('switches only opted-in slots and persists an undoable informational notice', () => { + const initial = { + subscriptions: ['funny-a.bso', 'news-a.bso'], + preferences: preferences({ + funny: { address: 'funny-a.bso', autoSwitch: true }, + news: { address: 'news-a.bso' }, + }), + }; + const result = reconcileAutomaticDirectorySwitches({ + ...initial, + winners: [winner('news', 'news-b.bso', 2), winner('funny', 'funny-b.bso', 2)], + }); + + expect(initial).toEqual({ + subscriptions: ['funny-a.bso', 'news-a.bso'], + preferences: preferences({ + funny: { address: 'funny-a.bso', autoSwitch: true }, + news: { address: 'news-a.bso' }, + }), + }); + expect(result.subscriptions).toEqual(['news-a.bso', 'funny-b.bso']); + expect(result.preferences.slots.funny?.subscriptionAddress).toBe('funny-b.bso'); + expect(result.preferences.slots.news?.subscriptionAddress).toBe('news-a.bso'); + expect(result.preferences.automaticChangeNotices.funny).toEqual({ + directoryCode: 'funny', + fromAddress: 'funny-a.bso', + toAddress: 'funny-b.bso', + winnerRevision: 2, + addedWinnerSubscription: true, + removedPreviousSubscription: true, + }); + }); + + it('does not claim or later remove a winner that was already subscribed', () => { + const switched = reconcileAutomaticDirectorySwitches({ + subscriptions: ['funny-a.bso', 'funny-b.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso', autoSwitch: true } }), + winners: [winner('funny', 'funny-b.bso', 2)], + }); + + expect(switched.subscriptions).toEqual(['funny-b.bso']); + expect(switched.preferences.automaticChangeNotices.funny?.addedWinnerSubscription).toBe(false); + + const undone = undoAutomaticDirectorySwitch({ ...switched, directoryCode: 'funny' }); + expect(undone.subscriptions).toEqual(['funny-b.bso', 'funny-a.bso']); + expect(undone.preferences.slots.funny?.autoSwitch).toBe(false); + }); + + it('preserves a shared previous address and removes it only after the final slot switches', () => { + const initialPreferences = preferences({ + funny: { address: 'shared.bso', autoSwitch: true }, + memes: { address: 'shared.bso', autoSwitch: true }, + }); + const result = reconcileAutomaticDirectorySwitches({ + subscriptions: ['shared.bso'], + preferences: initialPreferences, + winners: [winner('memes', 'memes-b.bso', 2), winner('funny', 'funny-b.bso', 2)], + }); + + expect(result.subscriptions).toEqual(['funny-b.bso', 'memes-b.bso']); + expect(result.preferences.automaticChangeNotices.funny?.removedPreviousSubscription).toBe(false); + expect(result.preferences.automaticChangeNotices.memes?.removedPreviousSubscription).toBe(true); + }); + + it('undo restores the old exact address, removes an automatically added winner, and disables auto-switch', () => { + const switched = reconcileAutomaticDirectorySwitches({ + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso', autoSwitch: true } }), + winners: [winner('funny', 'funny-b.bso', 2)], + }); + const undone = undoAutomaticDirectorySwitch({ ...switched, directoryCode: 'funny' }); + + expect(undone.subscriptions).toEqual(['funny-a.bso']); + expect(undone.preferences.slots.funny).toEqual({ + subscriptionAddress: 'funny-a.bso', + autoSwitch: false, + acknowledgedWinner: { address: 'funny-b.bso', revision: 2 }, + }); + expect(undone.preferences.automaticChangeNotices).toEqual({}); + expect(reconcileAutomaticDirectorySwitches({ ...undone, winners: [winner('funny', 'funny-b.bso', 2)] }).subscriptions).toEqual(['funny-a.bso']); + }); + + it('can dismiss an automatic notice without changing subscriptions', () => { + const switched = reconcileAutomaticDirectorySwitches({ + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso', autoSwitch: true } }), + winners: [winner('funny', 'funny-b.bso', 2)], + }); + const dismissed = dismissAutomaticDirectoryChange({ ...switched, directoryCode: 'funny' }); + + expect(dismissed.subscriptions).toEqual(switched.subscriptions); + expect(dismissed.preferences.automaticChangeNotices).toEqual({}); + }); + + it('keeps an automatic notice available for undo when auto-switch is turned off separately', () => { + const switched = reconcileAutomaticDirectorySwitches({ + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso', autoSwitch: true } }), + winners: [winner('funny', 'funny-b.bso', 2)], + }); + const disabled = setDirectoryAutoSwitch({ ...switched, directoryCode: 'funny', enabled: false }); + + expect(disabled.preferences.automaticChangeNotices.funny).toEqual(switched.preferences.automaticChangeNotices.funny); + expect(undoAutomaticDirectorySwitch({ ...disabled, directoryCode: 'funny' }).subscriptions).toEqual(['funny-a.bso']); + }); + + it('advances acknowledgement without a notice when an enabled slot winner returns to its subscribed address', () => { + const result = reconcileAutomaticDirectorySwitches({ + subscriptions: ['funny-a.bso'], + preferences: preferences({ + funny: { address: 'funny-a.bso', acknowledgedAddress: 'funny-b.bso', revision: 2, autoSwitch: true }, + }), + winners: [winner('funny', 'funny-a.bso', 3)], + }); + + expect(result.subscriptions).toEqual(['funny-a.bso']); + expect(result.preferences.slots.funny?.acknowledgedWinner).toEqual({ address: 'funny-a.bso', revision: 3 }); + expect(result.preferences.automaticChangeNotices).toEqual({}); + }); + + it('does not enable automatic switching after the tracked exact address disappeared', () => { + const result = setDirectoryAutoSwitch({ + subscriptions: [], + preferences: preferences({ funny: { address: 'funny-a.bso' } }), + directoryCode: 'funny', + enabled: true, + }); + + expect(result.preferences.slots).toEqual({}); + }); + + it('ignores same and stale revisions for automatic switching', () => { + const initial = { + subscriptions: ['funny-a.bso'], + preferences: preferences({ funny: { address: 'funny-a.bso', revision: 2, autoSwitch: true } }), + }; + + expect(reconcileAutomaticDirectorySwitches({ ...initial, winners: [winner('funny', 'funny-b.bso', 2)] }).subscriptions).toEqual(['funny-a.bso']); + expect(reconcileAutomaticDirectorySwitches({ ...initial, winners: [winner('funny', 'funny-b.bso', 1)] }).subscriptions).toEqual(['funny-a.bso']); + }); + + it('keeps empty schema-v1 preferences deterministic', () => { + expect(createEmptyDirectoryPreferences()).toEqual({ schemaVersion: 1, slots: {}, automaticChangeNotices: {} }); + }); +}); diff --git a/src/lib/utils/directory-subscriptions.ts b/src/lib/utils/directory-subscriptions.ts new file mode 100644 index 00000000..8b8924ae --- /dev/null +++ b/src/lib/utils/directory-subscriptions.ts @@ -0,0 +1,491 @@ +import { isDirectoryCode, type SeeditDirectoryCode } from './directory-codes'; +import type { SeeditStarterSubscriptions } from './starter-subscriptions'; + +export const SEEDIT_DIRECTORY_PREFERENCES_SCHEMA_VERSION = 1 as const; + +export interface AcknowledgedDirectoryWinner { + address: string; + revision: number; +} + +export interface SeeditDirectoryPreference { + /** The exact address currently associated with this directory. */ + subscriptionAddress: string; + /** Explicit per-directory consent to replace the associated exact subscription. */ + autoSwitch: boolean; + acknowledgedWinner: AcknowledgedDirectoryWinner; +} + +export interface DirectoryAutomaticChangeNotice { + directoryCode: SeeditDirectoryCode; + fromAddress: string; + toAddress: string; + winnerRevision: number; + /** Undo removes the winner only when the automatic transition added it. */ + addedWinnerSubscription: boolean; + /** Undo restores the previous address only when the automatic transition removed it. */ + removedPreviousSubscription: boolean; + /** Endpoint delta used to undo this switch without clobbering later starter-list changes. */ + starterProvenanceTransition?: { + before: SeeditStarterSubscriptions; + after: SeeditStarterSubscriptions; + }; +} + +export interface SeeditDirectoryPreferences { + schemaVersion: typeof SEEDIT_DIRECTORY_PREFERENCES_SCHEMA_VERSION; + slots: Partial>; + automaticChangeNotices: Partial>; +} + +export interface AuthoritativeDirectoryWinnerSnapshot { + directoryCode: SeeditDirectoryCode; + address: string; + revision: number; + authoritative: true; +} + +export interface DirectorySubscriptionsState { + subscriptions: string[]; + preferences: SeeditDirectoryPreferences; +} + +export interface PendingDirectoryWinnerChange { + directoryCode: SeeditDirectoryCode; + currentAddress: string; + winnerAddress: string; + winnerRevision: number; +} + +export interface ObservedDirectoryWinnerChanges extends DirectorySubscriptionsState { + pendingChanges: PendingDirectoryWinnerChange[]; +} + +export interface DirectoryStateInput { + subscriptions: readonly string[]; + preferences?: SeeditDirectoryPreferences; +} + +export interface DirectoryWinnerActionInput extends DirectoryStateInput { + winner: AuthoritativeDirectoryWinnerSnapshot; +} + +export interface DirectoryCodeActionInput extends DirectoryStateInput { + directoryCode: SeeditDirectoryCode; +} + +export interface ObserveDirectoryWinnersInput extends DirectoryStateInput { + winners: readonly AuthoritativeDirectoryWinnerSnapshot[]; +} + +const BASE58_PUBLIC_KEY_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{30,}$/; + +/** Directory slugs are never valid subscription values; names and public keys are. */ +export const isExactCommunitySubscriptionAddress = (address: unknown): address is string => + typeof address === 'string' && address.length > 0 && !isDirectoryCode(address) && (address.includes('.') || BASE58_PUBLIC_KEY_PATTERN.test(address)); + +const uniqueExactAddresses = (addresses: readonly string[]): string[] => { + const seen = new Set(); + const unique: string[] = []; + + for (const address of addresses) { + if (!isExactCommunitySubscriptionAddress(address) || seen.has(address)) continue; + seen.add(address); + unique.push(address); + } + + return unique; +}; + +const isRevision = (revision: unknown): revision is number => Number.isSafeInteger(revision) && (revision as number) >= 0; + +const normalizeStarterProvenanceSnapshot = (value: unknown): SeeditStarterSubscriptions | undefined => { + if (!value || typeof value !== 'object') return undefined; + const snapshot = value as Partial; + if ( + snapshot.schemaVersion !== 1 || + !isRevision(snapshot.acknowledgedRevision) || + !Array.isArray(snapshot.knownAddresses) || + !Array.isArray(snapshot.managedAddresses) || + !snapshot.knownAddresses.every(isExactCommunitySubscriptionAddress) || + !snapshot.managedAddresses.every(isExactCommunitySubscriptionAddress) + ) { + return undefined; + } + return { + schemaVersion: 1, + acknowledgedRevision: snapshot.acknowledgedRevision, + knownAddresses: uniqueExactAddresses(snapshot.knownAddresses), + managedAddresses: uniqueExactAddresses(snapshot.managedAddresses), + }; +}; + +export const createEmptyDirectoryPreferences = (): SeeditDirectoryPreferences => ({ + schemaVersion: SEEDIT_DIRECTORY_PREFERENCES_SCHEMA_VERSION, + slots: {}, + automaticChangeNotices: {}, +}); + +export const normalizeDirectoryPreferences = (preferences: SeeditDirectoryPreferences | undefined): SeeditDirectoryPreferences => { + if (!preferences || preferences.schemaVersion !== SEEDIT_DIRECTORY_PREFERENCES_SCHEMA_VERSION) return createEmptyDirectoryPreferences(); + + const slots: SeeditDirectoryPreferences['slots'] = {}; + for (const [code, value] of Object.entries(preferences.slots ?? {})) { + if (!isDirectoryCode(code) || !value || !isExactCommunitySubscriptionAddress(value.subscriptionAddress)) continue; + const acknowledgedAddress = isExactCommunitySubscriptionAddress(value.acknowledgedWinner?.address) ? value.acknowledgedWinner.address : value.subscriptionAddress; + slots[code] = { + subscriptionAddress: value.subscriptionAddress, + autoSwitch: value.autoSwitch === true, + acknowledgedWinner: { + address: acknowledgedAddress, + revision: isRevision(value.acknowledgedWinner?.revision) ? value.acknowledgedWinner.revision : 0, + }, + }; + } + + const automaticChangeNotices: SeeditDirectoryPreferences['automaticChangeNotices'] = {}; + for (const [code, value] of Object.entries(preferences.automaticChangeNotices ?? {})) { + if ( + !isDirectoryCode(code) || + !slots[code] || + !value || + value.directoryCode !== code || + !isExactCommunitySubscriptionAddress(value.fromAddress) || + !isExactCommunitySubscriptionAddress(value.toAddress) || + !isRevision(value.winnerRevision) || + value.winnerRevision < 1 || + slots[code].subscriptionAddress !== value.toAddress || + slots[code].acknowledgedWinner.revision !== value.winnerRevision + ) { + continue; + } + const transitionBefore = normalizeStarterProvenanceSnapshot(value.starterProvenanceTransition?.before); + const transitionAfter = normalizeStarterProvenanceSnapshot(value.starterProvenanceTransition?.after); + automaticChangeNotices[code] = { + directoryCode: code, + fromAddress: value.fromAddress, + toAddress: value.toAddress, + winnerRevision: value.winnerRevision, + addedWinnerSubscription: value.addedWinnerSubscription === true, + removedPreviousSubscription: value.removedPreviousSubscription === true, + ...(transitionBefore && transitionAfter ? { starterProvenanceTransition: { before: transitionBefore, after: transitionAfter } } : {}), + }; + } + + return { + schemaVersion: SEEDIT_DIRECTORY_PREFERENCES_SCHEMA_VERSION, + slots, + automaticChangeNotices, + }; +}; + +const removeMissingSubscriptionSlots = (subscriptions: readonly string[], preferences: SeeditDirectoryPreferences): SeeditDirectoryPreferences => { + const subscribed = new Set(subscriptions); + const slots: SeeditDirectoryPreferences['slots'] = {}; + const automaticChangeNotices: SeeditDirectoryPreferences['automaticChangeNotices'] = {}; + + for (const code of Object.keys(preferences.slots).sort()) { + if (!isDirectoryCode(code)) continue; + const slot = preferences.slots[code]; + if (!slot || !subscribed.has(slot.subscriptionAddress)) continue; + slots[code] = slot; + if (preferences.automaticChangeNotices[code]) automaticChangeNotices[code] = preferences.automaticChangeNotices[code]; + } + + return { ...preferences, slots, automaticChangeNotices }; +}; + +const prepareState = ({ subscriptions, preferences }: DirectoryStateInput): DirectorySubscriptionsState => { + const exactSubscriptions = uniqueExactAddresses(subscriptions); + return { + subscriptions: exactSubscriptions, + preferences: removeMissingSubscriptionSlots(exactSubscriptions, normalizeDirectoryPreferences(preferences)), + }; +}; + +const isUsableWinner = (winner: AuthoritativeDirectoryWinnerSnapshot | undefined): winner is AuthoritativeDirectoryWinnerSnapshot => + Boolean( + winner && + winner.authoritative === true && + isDirectoryCode(winner.directoryCode) && + isExactCommunitySubscriptionAddress(winner.address) && + Number.isSafeInteger(winner.revision) && + winner.revision >= 1, + ); + +const getLatestAuthoritativeWinners = ( + winners: readonly AuthoritativeDirectoryWinnerSnapshot[], +): Partial> => { + const latest: Partial> = {}; + const conflicts = new Set(); + + for (const winner of winners) { + if (!isUsableWinner(winner)) continue; + const current = latest[winner.directoryCode]; + if (!current || winner.revision > current.revision) { + latest[winner.directoryCode] = winner; + conflicts.delete(winner.directoryCode); + } else if (winner.revision === current.revision && winner.address !== current.address) { + conflicts.add(winner.directoryCode); + } + } + + for (const code of conflicts) delete latest[code]; + return latest; +}; + +const updateSlot = ( + preferences: SeeditDirectoryPreferences, + directoryCode: SeeditDirectoryCode, + slot: SeeditDirectoryPreference, + automaticChangeNotice?: DirectoryAutomaticChangeNotice, + preserveAutomaticChangeNotice = false, +): SeeditDirectoryPreferences => { + const automaticChangeNotices = { ...preferences.automaticChangeNotices }; + if (automaticChangeNotice) automaticChangeNotices[directoryCode] = automaticChangeNotice; + else if (!preserveAutomaticChangeNotice) delete automaticChangeNotices[directoryCode]; + + return { + ...preferences, + slots: { ...preferences.slots, [directoryCode]: slot }, + automaticChangeNotices, + }; +}; + +const isTrackedByAnotherSlot = (preferences: SeeditDirectoryPreferences, excludedCode: SeeditDirectoryCode, address: string): boolean => + Object.entries(preferences.slots).some(([code, slot]) => code !== excludedCode && slot?.subscriptionAddress === address); + +const isNewerWinner = (slot: SeeditDirectoryPreference, winner: AuthoritativeDirectoryWinnerSnapshot): boolean => winner.revision > slot.acknowledgedWinner.revision; + +/** Join the exact current winner and remember only Seedit-specific route provenance. */ +export const joinDirectorySubscription = (input: DirectoryWinnerActionInput): DirectorySubscriptionsState => { + const state = prepareState(input); + if (!isUsableWinner(input.winner)) return state; + + const subscriptions = uniqueExactAddresses([...state.subscriptions, input.winner.address]); + const existingSlot = state.preferences.slots[input.winner.directoryCode]; + if ( + existingSlot && + (input.winner.revision < existingSlot.acknowledgedWinner.revision || + (input.winner.revision === existingSlot.acknowledgedWinner.revision && input.winner.address !== existingSlot.acknowledgedWinner.address)) + ) { + return state; + } + const slot: SeeditDirectoryPreference = { + subscriptionAddress: input.winner.address, + autoSwitch: existingSlot?.autoSwitch ?? false, + acknowledgedWinner: { address: input.winner.address, revision: input.winner.revision }, + }; + + return { + subscriptions, + preferences: updateSlot(state.preferences, input.winner.directoryCode, slot), + }; +}; + +/** A direct/manual leave also relinquishes any directory slot tracking that exact address. */ +export const leaveDirectorySubscription = (input: DirectoryStateInput & { address: string }): DirectorySubscriptionsState => { + const state = prepareState(input); + const subscriptions = state.subscriptions.filter((address) => address !== input.address); + const slots = { ...state.preferences.slots }; + const automaticChangeNotices = { ...state.preferences.automaticChangeNotices }; + + for (const [code, slot] of Object.entries(slots)) { + if (!isDirectoryCode(code) || slot?.subscriptionAddress !== input.address) continue; + delete slots[code]; + delete automaticChangeNotices[code]; + } + + return { + subscriptions, + preferences: { ...state.preferences, slots, automaticChangeNotices }, + }; +}; + +/** + * Return manual review changes and silently acknowledge a newer snapshot when its winner + * returns to the exact address the user kept. This makes A -> B -> A -> B observable twice. + */ +export const observeDirectoryWinnerChanges = (input: ObserveDirectoryWinnersInput): ObservedDirectoryWinnerChanges => { + const state = prepareState(input); + const latest = getLatestAuthoritativeWinners(input.winners); + let preferences = state.preferences; + const pendingChanges: PendingDirectoryWinnerChange[] = []; + + for (const code of Object.keys(preferences.slots).sort()) { + if (!isDirectoryCode(code)) continue; + const slot = preferences.slots[code]; + const winner = latest[code]; + if (!slot || !winner || !isNewerWinner(slot, winner)) continue; + + if (winner.address === slot.subscriptionAddress) { + preferences = updateSlot(preferences, code, { + ...slot, + acknowledgedWinner: { address: winner.address, revision: winner.revision }, + }); + } else if (!slot.autoSwitch) { + pendingChanges.push({ + directoryCode: code, + currentAddress: slot.subscriptionAddress, + winnerAddress: winner.address, + winnerRevision: winner.revision, + }); + } + } + + return { ...state, preferences, pendingChanges }; +}; + +/** Acknowledge a new winner while keeping the currently associated exact subscription. */ +export const keepCurrentDirectorySubscription = (input: DirectoryWinnerActionInput): DirectorySubscriptionsState => { + const state = prepareState(input); + if (!isUsableWinner(input.winner)) return state; + const slot = state.preferences.slots[input.winner.directoryCode]; + if (!slot || !isNewerWinner(slot, input.winner)) return state; + + return { + ...state, + preferences: updateSlot(state.preferences, input.winner.directoryCode, { + ...slot, + acknowledgedWinner: { address: input.winner.address, revision: input.winner.revision }, + }), + }; +}; + +/** Keep the previous address as manual, add the winner, and track the winner from now on. */ +export const keepBothDirectorySubscriptions = (input: DirectoryWinnerActionInput): DirectorySubscriptionsState => { + const state = prepareState(input); + if (!isUsableWinner(input.winner)) return state; + const slot = state.preferences.slots[input.winner.directoryCode]; + if (!slot || !isNewerWinner(slot, input.winner)) return state; + + return { + subscriptions: uniqueExactAddresses([...state.subscriptions, input.winner.address]), + preferences: updateSlot(state.preferences, input.winner.directoryCode, { + ...slot, + subscriptionAddress: input.winner.address, + acknowledgedWinner: { address: input.winner.address, revision: input.winner.revision }, + }), + }; +}; + +/** Replace only the exact address attributable to this directory slot. */ +export const switchDirectorySubscription = (input: DirectoryWinnerActionInput): DirectorySubscriptionsState => { + const state = prepareState(input); + if (!isUsableWinner(input.winner)) return state; + const slot = state.preferences.slots[input.winner.directoryCode]; + if ( + !slot || + input.winner.revision < slot.acknowledgedWinner.revision || + (input.winner.revision === slot.acknowledgedWinner.revision && input.winner.address !== slot.acknowledgedWinner.address) + ) { + return state; + } + + const preservePrevious = isTrackedByAnotherSlot(state.preferences, input.winner.directoryCode, slot.subscriptionAddress); + const kept = preservePrevious ? state.subscriptions : state.subscriptions.filter((address) => address !== slot.subscriptionAddress); + + return { + subscriptions: uniqueExactAddresses([...kept, input.winner.address]), + preferences: updateSlot(state.preferences, input.winner.directoryCode, { + ...slot, + subscriptionAddress: input.winner.address, + acknowledgedWinner: { address: input.winner.address, revision: input.winner.revision }, + }), + }; +}; + +export const setDirectoryAutoSwitch = (input: DirectoryCodeActionInput & { enabled: boolean }): DirectorySubscriptionsState => { + const state = prepareState(input); + const slot = state.preferences.slots[input.directoryCode]; + if (!slot || slot.autoSwitch === input.enabled) return state; + + return { + ...state, + preferences: updateSlot(state.preferences, input.directoryCode, { ...slot, autoSwitch: input.enabled }, undefined, true), + }; +}; + +/** Apply all consented switches from finalized authoritative snapshots in code order. */ +export const reconcileAutomaticDirectorySwitches = (input: ObserveDirectoryWinnersInput): DirectorySubscriptionsState => { + const state = prepareState(input); + const latest = getLatestAuthoritativeWinners(input.winners); + let subscriptions = state.subscriptions; + let preferences = state.preferences; + + for (const code of Object.keys(preferences.slots).sort()) { + if (!isDirectoryCode(code)) continue; + const slot = preferences.slots[code]; + const winner = latest[code]; + if (!slot || !winner || !isNewerWinner(slot, winner)) continue; + + if (winner.address === slot.subscriptionAddress) { + preferences = updateSlot(preferences, code, { + ...slot, + acknowledgedWinner: { address: winner.address, revision: winner.revision }, + }); + continue; + } + if (!slot.autoSwitch) continue; + + const winnerAlreadySubscribed = subscriptions.includes(winner.address); + const preservePrevious = isTrackedByAnotherSlot(preferences, code, slot.subscriptionAddress); + const previousWasSubscribed = subscriptions.includes(slot.subscriptionAddress); + const kept = preservePrevious ? subscriptions : subscriptions.filter((address) => address !== slot.subscriptionAddress); + subscriptions = uniqueExactAddresses([...kept, winner.address]); + + const notice: DirectoryAutomaticChangeNotice = { + directoryCode: code, + fromAddress: slot.subscriptionAddress, + toAddress: winner.address, + winnerRevision: winner.revision, + addedWinnerSubscription: !winnerAlreadySubscribed, + removedPreviousSubscription: previousWasSubscribed && !preservePrevious, + }; + preferences = updateSlot( + preferences, + code, + { + ...slot, + subscriptionAddress: winner.address, + acknowledgedWinner: { address: winner.address, revision: winner.revision }, + }, + notice, + ); + } + + return { subscriptions, preferences }; +}; + +export const dismissAutomaticDirectoryChange = (input: DirectoryCodeActionInput): DirectorySubscriptionsState => { + const state = prepareState(input); + if (!state.preferences.automaticChangeNotices[input.directoryCode]) return state; + const automaticChangeNotices = { ...state.preferences.automaticChangeNotices }; + delete automaticChangeNotices[input.directoryCode]; + return { ...state, preferences: { ...state.preferences, automaticChangeNotices } }; +}; + +/** Restore the previous exact subscription and disable switching so reconciliation cannot repeat it. */ +export const undoAutomaticDirectorySwitch = (input: DirectoryCodeActionInput): DirectorySubscriptionsState => { + const state = prepareState(input); + const slot = state.preferences.slots[input.directoryCode]; + const notice = state.preferences.automaticChangeNotices[input.directoryCode]; + if (!slot || !notice || slot.subscriptionAddress !== notice.toAddress || slot.acknowledgedWinner.revision !== notice.winnerRevision) return state; + + let subscriptions = state.subscriptions; + if (notice.removedPreviousSubscription) subscriptions = uniqueExactAddresses([...subscriptions, notice.fromAddress]); + if (notice.addedWinnerSubscription && !isTrackedByAnotherSlot(state.preferences, input.directoryCode, notice.toAddress)) { + subscriptions = subscriptions.filter((address) => address !== notice.toAddress); + } + + return { + subscriptions, + preferences: updateSlot(state.preferences, input.directoryCode, { + ...slot, + subscriptionAddress: notice.fromAddress, + autoSwitch: false, + // Acknowledge the winner that was undone so the disabled slot does not immediately prompt. + acknowledgedWinner: { address: notice.toAddress, revision: notice.winnerRevision }, + }), + }; +}; diff --git a/src/lib/utils/legacy-default-subscriptions.test.ts b/src/lib/utils/legacy-default-subscriptions.test.ts index d755f82b..66d25e9f 100644 --- a/src/lib/utils/legacy-default-subscriptions.test.ts +++ b/src/lib/utils/legacy-default-subscriptions.test.ts @@ -15,7 +15,7 @@ describe('seedit starter community snapshot', () => { expect(starterCommunities.schemaVersion).toBe(STARTER_COMMUNITIES_SCHEMA_VERSION); expect(starterCommunities.revision).toBe(STARTER_COMMUNITIES_REVISION); expect(starterCommunities.createdAt).toBe(1745707200); - expect(starterCommunities.updatedAt).toBe(1784049342); + expect(starterCommunities.updatedAt).toBe(1784467859); expect(starterCommunities.communities).toHaveLength(10); expect(starterCommunities.communities.map(({ address }) => address)).toEqual(STARTER_COMMUNITY_ADDRESSES); expect(new Set(starterCommunities.communities.map(({ publicKey }) => publicKey))).toHaveLength(10); diff --git a/src/lib/utils/legacy-default-subscriptions.ts b/src/lib/utils/legacy-default-subscriptions.ts index b53c324f..822ca537 100644 --- a/src/lib/utils/legacy-default-subscriptions.ts +++ b/src/lib/utils/legacy-default-subscriptions.ts @@ -15,8 +15,8 @@ export const LEGACY_DIRECTORY_ADDRESS_BY_CODE = { export type LegacyDirectoryCode = keyof typeof LEGACY_DIRECTORY_ADDRESS_BY_CODE; -export const STARTER_COMMUNITIES_SCHEMA_VERSION = 1; -export const STARTER_COMMUNITIES_REVISION = 1; +export const STARTER_COMMUNITIES_SCHEMA_VERSION = 2; +export const STARTER_COMMUNITIES_REVISION = 2; export const STARTER_COMMUNITY_ADDRESSES = Object.values(LEGACY_DIRECTORY_ADDRESS_BY_CODE); // Dead defaults from the multisub that preceded directory subscriptions. Their presence diff --git a/src/lib/utils/starter-account.ts b/src/lib/utils/starter-account.ts index 860ebe40..18c09ec6 100644 --- a/src/lib/utils/starter-account.ts +++ b/src/lib/utils/starter-account.ts @@ -1,8 +1,11 @@ import type { Account } from '@bitsocial/bitsocial-react-hooks'; import { computeAddressCanonicalSubscriptionMigration, STARTER_COMMUNITIES_REVISION, STARTER_COMMUNITY_ADDRESSES } from './legacy-default-subscriptions'; +import { joinDirectorySubscription, normalizeDirectoryPreferences, type SeeditDirectoryPreferences } from './directory-subscriptions'; +import type { SeeditDirectoryCode } from './directory-codes'; import { bootstrapStarterSubscriptions, initializeStarterSubscriptions, type SeeditStarterSubscriptions } from './starter-subscriptions'; export type StarterAccount = Account & { + seeditDirectoryPreferences?: SeeditDirectoryPreferences; seeditStarterSubscriptions?: SeeditStarterSubscriptions; }; @@ -15,7 +18,7 @@ interface RebasedStarterAccountPersistence { interface StarterListInput { revision: number; - communities: readonly { address: string }[]; + communities: readonly { address: string; directoryCode?: SeeditDirectoryCode; directoryRevision?: number }[]; } const OLD_AUTO_SUBSCRIBE_KEY_PREFIX = 'seedit-auto-subscribe-done-'; @@ -35,6 +38,10 @@ const sameAddresses = (first: readonly string[] | undefined, second: readonly st export const hasStarterAccountStateChanged = (previous: StarterAccount, next: StarterAccount): boolean => { if (!sameAddresses(previous.subscriptions, next.subscriptions)) return true; + if ( + JSON.stringify(normalizeDirectoryPreferences(previous.seeditDirectoryPreferences)) !== JSON.stringify(normalizeDirectoryPreferences(next.seeditDirectoryPreferences)) + ) + return true; const previousProvenance = previous.seeditStarterSubscriptions; const nextProvenance = next.seeditStarterSubscriptions; if (!previousProvenance || !nextProvenance) return previousProvenance !== nextProvenance; @@ -46,6 +53,31 @@ export const hasStarterAccountStateChanged = (previous: StarterAccount, next: St ); }; +const addManagedDirectoryPreferences = ( + subscriptions: readonly string[], + preferences: SeeditDirectoryPreferences | undefined, + managedAddresses: readonly string[], + starterList: StarterListInput, +): SeeditDirectoryPreferences => { + const managedSet = new Set(managedAddresses); + let state = { subscriptions: [...subscriptions], preferences: normalizeDirectoryPreferences(preferences) }; + + for (const community of starterList.communities) { + if (!community.directoryCode || !community.directoryRevision || !managedSet.has(community.address)) continue; + state = joinDirectorySubscription({ + ...state, + winner: { + directoryCode: community.directoryCode, + address: community.address, + revision: community.directoryRevision, + authoritative: true, + }, + }); + } + + return state.preferences; +}; + /** Persist a starter-state transform and rebase it until the same store snapshot survives the async write. */ export const persistRebasedStarterAccount = async ({ getCurrentAccount, @@ -82,16 +114,17 @@ export const computeStarterAccount = (account: StarterAccount, isKnownExistingAc const currentProvenance = account.seeditStarterSubscriptions; if (currentProvenance) { - if (!migration.changed) return account; const managedAddresses = [...new Set([...currentProvenance.managedAddresses, ...migratedManagedAddresses])]; - return { + const nextAccount = { ...account, subscriptions: migratedSubscriptions, seeditStarterSubscriptions: { ...currentProvenance, managedAddresses, }, + seeditDirectoryPreferences: addManagedDirectoryPreferences(migratedSubscriptions, account.seeditDirectoryPreferences, managedAddresses, starterList), }; + return hasStarterAccountStateChanged(account, nextAccount) ? nextAccount : account; } if (migration.changed) { @@ -100,14 +133,16 @@ export const computeStarterAccount = (account: StarterAccount, isKnownExistingAc revision: STARTER_COMMUNITIES_REVISION, starterAddresses: STARTER_COMMUNITY_ADDRESSES, }); - return { + const nextAccount = { ...account, subscriptions: initialized.subscriptions, seeditStarterSubscriptions: { ...initialized.provenance, managedAddresses: migratedManagedAddresses, }, + seeditDirectoryPreferences: addManagedDirectoryPreferences(initialized.subscriptions, account.seeditDirectoryPreferences, migratedManagedAddresses, starterList), }; + return nextAccount; } const result = @@ -119,5 +154,6 @@ export const computeStarterAccount = (account: StarterAccount, isKnownExistingAc ...account, subscriptions: result.subscriptions, seeditStarterSubscriptions: result.provenance, + seeditDirectoryPreferences: addManagedDirectoryPreferences(result.subscriptions, account.seeditDirectoryPreferences, result.provenance.managedAddresses, starterList), }; }; diff --git a/src/lib/utils/starter-subscriptions.test.ts b/src/lib/utils/starter-subscriptions.test.ts index 3beccc70..546a5a49 100644 --- a/src/lib/utils/starter-subscriptions.test.ts +++ b/src/lib/utils/starter-subscriptions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { SEEDIT_STARTER_SUBSCRIPTIONS_SCHEMA_VERSION, addSelectedStarterSubscriptions, + acknowledgeStarterDirectoryChange, bootstrapStarterSubscriptions, getStarterSetDelta, initializeStarterSubscriptions, @@ -256,6 +257,22 @@ describe('manual subscription changes', () => { }); }); +describe('directory winner provenance', () => { + it('moves managed ownership to the winner when the user switches or keeps both', () => { + expect(acknowledgeStarterDirectoryChange(provenance(), 'old.bso', 'new.bso', 'trackWinner')).toMatchObject({ + knownAddresses: ['new.bso', 'stays.bso'], + managedAddresses: ['stays.bso', 'new.bso'], + }); + }); + + it('relinquishes managed ownership when the user keeps the previous community', () => { + expect(acknowledgeStarterDirectoryChange(provenance(), 'old.bso', 'new.bso', 'keep')).toMatchObject({ + knownAddresses: ['new.bso', 'stays.bso'], + managedAddresses: ['stays.bso'], + }); + }); +}); + describe('idempotency', () => { it('keeps initialize, bootstrap, add-selected, keep-current, and leave stable when repeated', () => { const initialized = initializeStarterSubscriptions({ subscriptions: ['manual.bso'], revision: 1, starterAddresses: ['starter.bso'] }); diff --git a/src/lib/utils/starter-subscriptions.ts b/src/lib/utils/starter-subscriptions.ts index 0195bc3f..2b717e62 100644 --- a/src/lib/utils/starter-subscriptions.ts +++ b/src/lib/utils/starter-subscriptions.ts @@ -39,6 +39,8 @@ interface ManualLeaveStarterSubscriptionResult { provenance?: SeeditStarterSubscriptions; } +export type StarterDirectoryChangeChoice = 'keep' | 'trackWinner'; + const uniqueAddresses = (addresses: readonly string[]): string[] => { const seen = new Set(); const unique: string[] = []; @@ -204,3 +206,20 @@ export const leaveStarterSubscription = ({ subscriptions, provenance, address }: : undefined, }; }; + +/** Keep the default-list provenance aligned when a directory handles an address replacement. */ +export const acknowledgeStarterDirectoryChange = ( + provenance: SeeditStarterSubscriptions | undefined, + fromAddress: string, + toAddress: string, + choice: StarterDirectoryChangeChoice, +): SeeditStarterSubscriptions | undefined => { + if (!provenance) return undefined; + const current = normalizeProvenance(provenance); + const knownAddresses = uniqueAddresses(current.knownAddresses.map((address) => (address === fromAddress ? toAddress : address))); + const previousWasManaged = current.managedAddresses.includes(fromAddress); + const managedWithoutPrevious = current.managedAddresses.filter((address) => address !== fromAddress); + const managedAddresses = choice === 'trackWinner' && previousWasManaged ? uniqueAddresses([...managedWithoutPrevious, toAddress]) : managedWithoutPrevious; + + return { ...current, knownAddresses, managedAddresses }; +}; diff --git a/src/lib/utils/url-utils.test.ts b/src/lib/utils/url-utils.test.ts index e4581468..2a00a6b8 100644 --- a/src/lib/utils/url-utils.test.ts +++ b/src/lib/utils/url-utils.test.ts @@ -11,8 +11,8 @@ describe('Seedit comments permalinks', () => { }); it('transforms external comments URLs to the matching internal route', () => { - expect(transformSeeditLinkToInternal(`https://seedit.app/s/aww/comments/${CID}`)).toBe(`/s/aww.bso/comments/${CID}`); - expect(transformSeeditLinkToInternal(`https://seedit.app/#/s/aww/comments/${CID}`)).toBe(`/s/aww.bso/comments/${CID}`); + expect(transformSeeditLinkToInternal(`https://seedit.app/s/aww/comments/${CID}`)).toBe(`/s/aww/comments/${CID}`); + expect(transformSeeditLinkToInternal(`https://seedit.app/#/s/aww/comments/${CID}`)).toBe(`/s/aww/comments/${CID}`); }); it('recognizes and links plain-text comments permalinks', () => { @@ -23,11 +23,15 @@ describe('Seedit comments permalinks', () => { it('recognizes and links default-TLD shorthand references', () => { expect(isValidCommunityPattern('s/aww')).toBe(true); - expect(preprocessSeeditPatterns('See s/aww.')).toBe('See [s/aww](/s/aww.bso).'); + expect(preprocessSeeditPatterns('See s/aww.')).toBe('See [s/aww](/s/aww).'); const pattern = `s/aww/comments/${CID}`; expect(isValidCommunityPattern(pattern)).toBe(true); - expect(preprocessSeeditPatterns(pattern)).toBe(`[${pattern}](/s/aww.bso/comments/${CID})`); + expect(preprocessSeeditPatterns(pattern)).toBe(`[${pattern}](/s/aww/comments/${CID})`); + }); + + it('continues expanding unreserved default-TLD shorthand references', () => { + expect(preprocessSeeditPatterns('See s/unreserved-name.')).toBe('See [s/unreserved-name](/s/unreserved-name.bso).'); }); it('does not interpret reserved feed routes as default-TLD community shorthand', () => { diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts index 58edbd34..ddab33b5 100644 --- a/src/lib/utils/url-utils.ts +++ b/src/lib/utils/url-utils.ts @@ -1,5 +1,11 @@ import { copyToClipboard } from './clipboard-utils'; -import { getCanonicalCommunityRoutePathname, getCommunityPath, getCommunityPostPath, getCommunityPostUrl, resolveCommunityRouteAddress } from './community-route-utils'; +import { + getCanonicalCommunityRoutePathname, + getCommunityPostUrl, + getCommunityReferencePath, + getCommunityReferencePostPath, + resolveCommunityRouteAddress, +} from './community-route-utils'; export const getHostname = (url: string) => { try { @@ -138,7 +144,7 @@ export const preprocessSeeditPatterns = (content: string): string => { if (isValidCommunityPattern(fullPattern)) { const postMatch = capturedPath.match(/^([^/]+)\/comments\/([^/]+)$/); - const internalPath = postMatch ? getCommunityPostPath(postMatch[1], postMatch[2]) : getCommunityPath(capturedPath); + const internalPath = postMatch ? getCommunityReferencePostPath(postMatch[1], postMatch[2]) : getCommunityReferencePath(capturedPath); // Convert to markdown link format and preserve trailing punctuation return `[${fullPattern}](${internalPath})${trailingPunctuation}`; } diff --git a/src/views/about/about.tsx b/src/views/about/about.tsx index ed0b8d81..2c2bba96 100644 --- a/src/views/about/about.tsx +++ b/src/views/about/about.tsx @@ -8,8 +8,15 @@ import { Capacitor } from '@capacitor/core'; import { isHomeAboutView } from '../../lib/utils/view-utils'; import { useEffect } from 'react'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; -import { getCommunityPath, getCommunityPostPath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; +import { + getCanonicalCommunityPostAboutRedirectPath, + getCanonicalCommunityPostRedirectPath, + getCommunityPath, + getCommunityPostPath, +} from '../../lib/utils/community-route-utils'; import { getDisplayAddress } from '../../lib/utils/address-utils'; +import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; const isAndroid = Capacitor.getPlatform() === 'android'; @@ -153,22 +160,32 @@ const About = () => { const navigate = useNavigate(); const { pathname, search, hash } = location; const isInHomeAboutView = isHomeAboutView(pathname); - const { commentCid, communityAddress: routeCommunityAddress } = useParams(); - const communityAddress = resolveCommunityRouteAddress(routeCommunityAddress); + const { commentCid, communityAddress: routeCommunitySegment } = useParams(); + const { communityAddress } = useResolvedCommunityRoute(); const community = useCommunity(communityAddress ? { community: getCommunityIdentifier(communityAddress) } : undefined); - const comment = useComment({ commentCid: commentCid as string, onlyIfCached: true }); + const comment = useComment({ commentCid: commentCid as string }); + const postCommunityAddress = getCommentCommunityAddress(comment); useEffect(() => { + if (commentCid && postCommunityAddress) { + const canonicalPostPath = isMobile + ? getCanonicalCommunityPostAboutRedirectPath(routeCommunitySegment, postCommunityAddress, commentCid) + : getCanonicalCommunityPostRedirectPath(routeCommunitySegment, postCommunityAddress, commentCid); + if (canonicalPostPath) { + navigate(`${canonicalPostPath}${search}${hash}`, { replace: true }); + return; + } + } if (!isMobile && pathname.endsWith('/about') && !isInHomeAboutView) { const newPath = communityAddress ? commentCid ? getCommunityPostPath(communityAddress, commentCid) : getCommunityPath(communityAddress) : pathname.replace(/\/about$/, '') || '/'; - navigate(`${newPath}${search}${hash}`); + navigate(`${newPath}${search}${hash}`, { replace: true }); } - }, [commentCid, communityAddress, hash, isMobile, isInHomeAboutView, navigate, pathname, search]); + }, [commentCid, communityAddress, hash, isMobile, isInHomeAboutView, navigate, pathname, postCommunityAddress, routeCommunitySegment, search]); return (
      diff --git a/src/views/community-settings/community-data-editor/community-data-editor.tsx b/src/views/community-settings/community-data-editor/community-data-editor.tsx index a3928b27..0f469080 100644 --- a/src/views/community-settings/community-data-editor/community-data-editor.tsx +++ b/src/views/community-settings/community-data-editor/community-data-editor.tsx @@ -6,11 +6,12 @@ import styles from '../../settings/account-data-editor/account-data-editor.modul import useIsMobile from '../../../hooks/use-is-mobile'; import LoadingEllipsis from '../../../components/loading-ellipsis'; import useCommunitySettingsStore from '../../../stores/use-community-settings-store'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import ErrorDisplay from '../../../components/error-display'; import useStateString from '../../../hooks/use-state-string'; import { getCommunityIdentifier } from '../../../hooks/use-community-identifier'; -import { getCommunityPath, resolveCommunityRouteAddress } from '../../../lib/utils/community-route-utils'; +import useResolvedCommunityRoute from '../../../hooks/use-resolved-community-route'; +import { getCommunityPath } from '../../../lib/utils/community-route-utils'; import { removeSuggestedAvatarUrl } from './community-data-editor-utils'; class EditorErrorBoundary extends Component<{ children: React.ReactNode; fallback: React.ReactNode }> { @@ -61,8 +62,7 @@ const CommunityDataEditor = () => { const theme = useTheme((state) => state.theme); const [text, setText] = useState(''); - const { communityAddress: routeCommunityAddress } = useParams<{ communityAddress: string }>(); - const communityAddress = resolveCommunityRouteAddress(routeCommunityAddress); + const { communityAddress } = useResolvedCommunityRoute(); const community = useCommunity(communityAddress ? { community: getCommunityIdentifier(communityAddress) } : undefined); const { address, createdAt, description, error, rules, settings, suggested, roles, title } = community || {}; const hasLoaded = !!createdAt; diff --git a/src/views/community-settings/community-settings.tsx b/src/views/community-settings/community-settings.tsx index fcc8c12e..d8d93479 100644 --- a/src/views/community-settings/community-settings.tsx +++ b/src/views/community-settings/community-settings.tsx @@ -12,10 +12,11 @@ import { useSubscribe, } from '@bitsocial/bitsocial-react-hooks'; import { isUserOwnerOrAdmin, Roles } from '../../lib/utils/user-utils'; -import { getCommunityPath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import { getCommunityPath } from '../../lib/utils/community-route-utils'; import { isCreateCommunityView, isCommunitySettingsView } from '../../lib/utils/view-utils'; import useCommunitySettingsStore from '../../stores/use-community-settings-store'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import useIsCommunityOffline from '../../hooks/use-is-community-offline'; import useStateString from '../../hooks/use-state-string'; import ErrorDisplay from '../../components/error-display'; @@ -287,8 +288,7 @@ const Moderators = ({ isReadOnly = false }: { isReadOnly?: boolean }) => { const JSONSettings = ({ isReadOnly: _isReadOnly = false }: { isReadOnly?: boolean }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const { communityAddress: routeCommunityAddress } = useParams<{ communityAddress: string }>(); - const communityAddress = resolveCommunityRouteAddress(routeCommunityAddress); + const { communityAddress } = useResolvedCommunityRoute(); return (
      @@ -305,8 +305,7 @@ const JSONSettings = ({ isReadOnly: _isReadOnly = false }: { isReadOnly?: boolea const CommunitySettings = () => { const { t } = useTranslation(); - const { communityAddress: routeCommunityAddress } = useParams<{ communityAddress: string }>(); - const communityAddress = resolveCommunityRouteAddress(routeCommunityAddress); + const { communityAddress } = useResolvedCommunityRoute(); const community = useCommunity(communityAddress ? { community: getCommunityIdentifier(communityAddress) } : undefined); const { address, challenges, createdAt, description, error, rules, shortAddress, settings, suggested, roles, title } = community || {}; const hasLoaded = !!createdAt; diff --git a/src/views/community/community.tsx b/src/views/community/community.tsx index a51e5eae..5f87ed10 100644 --- a/src/views/community/community.tsx +++ b/src/views/community/community.tsx @@ -13,7 +13,8 @@ import useFeedResetStore from '../../stores/use-feed-reset-store'; import { usePinnedPostsStore } from '../../stores/use-pinned-posts-store'; import { useIsNsfwCommunity } from '../../hooks/use-is-nsfw-community'; import useIsCommunityOffline from '../../hooks/use-is-community-offline'; -import { getCommunityPath, isResolvableCommunityAddress, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; +import { getCommunityPath, isResolvableCommunityAddress } from '../../lib/utils/community-route-utils'; import useTimeFilter, { isValidTimeFilterName } from '../../hooks/use-time-filter'; import { getCommunityIdentifier, getCommunityIdentifiers } from '../../hooks/use-community-identifier'; import ErrorDisplay from '../../components/error-display'; @@ -227,7 +228,8 @@ const CommunityView = () => { const searchQuery = searchParams.get('q') || ''; const rawCommunityIdentifier = params?.communityAddress || ''; - const communityAddress = resolveCommunityRouteAddress(rawCommunityIdentifier) || ''; + const { communityAddress: resolvedCommunityAddress, directoryCode, directoryList } = useResolvedCommunityRoute(rawCommunityIdentifier); + const communityAddress = resolvedCommunityAddress || ''; const canLoadCommunity = !!communityAddress && isResolvableCommunityAddress(communityAddress); const community = useCommunity(canLoadCommunity ? { community: getCommunityIdentifier(communityAddress) } : undefined); const { createdAt, error, shortAddress, started, title, updatedAt, settings } = community || {}; @@ -359,8 +361,8 @@ const CommunityView = () => { // page title useEffect(() => { - document.title = title ? title : getDisplayAddress(shortAddress || rawCommunityIdentifier || communityAddress); - }, [title, shortAddress, rawCommunityIdentifier, communityAddress]); + document.title = directoryList?.title || title || getDisplayAddress(shortAddress || rawCommunityIdentifier || communityAddress); + }, [title, shortAddress, rawCommunityIdentifier, communityAddress, directoryList?.title]); // Derive whether to show error directly from current feed state const shouldShowErrorToUser = Boolean(error?.message && feed.length === 0); @@ -370,7 +372,15 @@ const CommunityView = () => { ) : (
      - +
      {shouldShowErrorToUser && (
      diff --git a/src/views/home/home.tsx b/src/views/home/home.tsx index 07c8fcbf..243d2e24 100644 --- a/src/views/home/home.tsx +++ b/src/views/home/home.tsx @@ -15,6 +15,7 @@ import LoadingEllipsis from '../../components/loading-ellipsis'; import Post from '../../components/post'; import Sidebar from '../../components/sidebar'; import StarterSubscriptionsNotice from '../../components/starter-subscriptions-notice/starter-subscriptions-notice'; +import DirectorySubscriptionUpdatesNotice from '../../components/directory-subscription-updates-notice'; import { sortTypes } from '../../constants/sort-types'; import { getHomeSubscriptionState } from './subscription-state'; import styles from './home.module.css'; @@ -252,6 +253,7 @@ const Home = () => { return (
      +
      diff --git a/src/views/post-page/post-page.tsx b/src/views/post-page/post-page.tsx index c49a07ff..16ba1a0f 100644 --- a/src/views/post-page/post-page.tsx +++ b/src/views/post-page/post-page.tsx @@ -13,6 +13,7 @@ import { useIsNsfwCommunity } from '../../hooks/use-is-nsfw-community'; import useReplies from '../../hooks/use-replies'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; import useOptionalAccountComment from '../../hooks/use-account-comment'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import useStateString from '../../hooks/use-state-string'; import ErrorDisplay from '../../components/error-display'; import LoadingEllipsis from '../../components/loading-ellipsis'; @@ -244,6 +245,7 @@ const PostWithContext = ({ post }: { post: Comment }) => { }; const PostPage = () => { + const { t } = useTranslation(); const params = useParams(); const location = useLocation(); const locationSearch = location.search; @@ -284,7 +286,7 @@ const PostPage = () => { }, [pendingPost?.cid, pendingPostCommunityAddress, navigate, resetFeed]); const { commentCid } = params; - const routeCommunityAddress = resolveCommunityRouteAddress(params.communityAddress); + const { communityAddress: routeCommunityAddress, directoryCode } = useResolvedCommunityRoute(); let post = useComment({ commentCid }) as any; if (isInPendingPostView) { post = pendingPost; @@ -328,7 +330,11 @@ const PostPage = () => { ) : (
      - + {directoryCode && !isInPendingPostView ? ( + + ) : ( + + )}
      {isInPendingPostView && params?.accountCommentIndex ? : isInPostContextView ? : } {shouldShowErrorToUser && ( diff --git a/src/views/submit-page/submit-page.tsx b/src/views/submit-page/submit-page.tsx index 7ac153b7..074dcec4 100644 --- a/src/views/submit-page/submit-page.tsx +++ b/src/views/submit-page/submit-page.tsx @@ -1,17 +1,18 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { Trans, useTranslation } from 'react-i18next'; -import { Link, useNavigate, useParams } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { useAccount, usePublishComment, useCommunity } from '@bitsocial/bitsocial-react-hooks'; import { Capacitor } from '@capacitor/core'; import FileUploader from '../../plugins/file-uploader'; import { getLinkMediaInfo } from '../../lib/utils/media-utils'; import { getDisplayAddress, getShortDisplayAddress } from '../../lib/utils/address-utils'; import { isValidURL } from '../../lib/utils/url-utils'; -import { getCommunityPath, resolveCommunityRouteAddress } from '../../lib/utils/community-route-utils'; +import { getCommunityPath } from '../../lib/utils/community-route-utils'; import usePublishPostStore from '../../stores/use-publish-post-store'; import { useDefaultSubscriptionAddresses } from '../../hooks/use-default-subscriptions'; import useIsCommunityOffline from '../../hooks/use-is-community-offline'; +import useResolvedCommunityRoute from '../../hooks/use-resolved-community-route'; import { getCommunityIdentifier } from '../../hooks/use-community-identifier'; import LoadingEllipsis from '../../components/loading-ellipsis'; import Markdown from '../../components/markdown'; @@ -466,14 +467,14 @@ const SubmitOptions = () => { const SubmitPage = () => { const { t } = useTranslation(); - const params = useParams(); const navigate = useNavigate(); const { link, title, communityAddress, publishCommentOptions, setPublishPostStore, resetPublishPostStore } = usePublishPostStore(); + const { communityAddress: routeCommunityAddress } = useResolvedCommunityRoute(); useEffect(() => { - setPublishPostStore({ communityAddress: resolveCommunityRouteAddress(params.communityAddress) || '' } as any); - }, [params.communityAddress, setPublishPostStore]); + setPublishPostStore({ communityAddress: routeCommunityAddress || '' } as any); + }, [routeCommunityAddress, setPublishPostStore]); const selectedCommunityData = useCommunity(communityAddress ? { community: getCommunityIdentifier(communityAddress) } : undefined); const { rules, title: communityTitle } = selectedCommunityData;