Merge branch 'master' of github.com:bitsocialnet/seedit

This commit is contained in:
Tommaso Casaburi
2026-07-20 18:09:55 +07:00
104 changed files with 3601 additions and 137 deletions

View File

@@ -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/<slug> 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/<cid> 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."
}
]
}

View File

@@ -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.

View File

@@ -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/<cid>`.
- 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.

View File

@@ -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",

View File

@@ -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": "تجاهل"
}

View File

@@ -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": "খারিজ করুন"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "Κλείσιμο"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "بستن"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -391,5 +391,15 @@
"settings_upgrade_error_saving": "Impossible denregistrer 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"
}

View File

@@ -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": "סגירה"
}

View File

@@ -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": "बंद करें"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "閉じる"
}

View File

@@ -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": "닫기"
}

View File

@@ -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": "बंद करा"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "Закрыть"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "మూసివేయి"
}

View File

@@ -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": "ปิด"
}

View File

@@ -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"
}

View File

@@ -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": "Закрити"
}

View File

@@ -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": "بند کریں"
}

View File

@@ -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"
}

View File

@@ -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": "关闭"
}

126
scripts/sync-directories.js Normal file
View File

@@ -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();

View File

@@ -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 = () => {
<>
<ChallengeModal />
<NotificationHandler />
<DirectorySubscriptionReconciler />
<Suspense fallback={null}>
<SettingsUpgradeModal />
</Suspense>
@@ -103,12 +106,33 @@ const App = () => {
<Route path='/s/:communityAddress/comments/:commentCid' element={<PostPage />} />
<Route path='/s/:communityAddress/comments/:commentCid/about' element={<AboutView />} />
<Route path='/s/:communityAddress/submit' element={<SubmitPage />} />
<Route
path='/s/:communityAddress/submit'
element={
<ExactCommunityActionRoute>
<SubmitPage />
</ExactCommunityActionRoute>
}
/>
<Route path='/s/:communityAddress/about' element={<AboutView />} />
<Route path='/settings' element={<Settings />} />
<Route path='/s/:communityAddress/settings' element={<CommunitySettings />} />
<Route path='/s/:communityAddress/settings/editor' element={<CommunityDataEditor />} />
<Route
path='/s/:communityAddress/settings'
element={
<ExactCommunityActionRoute>
<CommunitySettings />
</ExactCommunityActionRoute>
}
/>
<Route
path='/s/:communityAddress/settings/editor'
element={
<ExactCommunityActionRoute>
<CommunityDataEditor />
</ExactCommunityActionRoute>
}
/>
<Route path='/settings/advanced' element={<Settings />} />
<Route path='/settings/p2p-stats' element={<Settings />} />
<Route path='/settings/content-options' element={<Settings />} />

View File

@@ -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;
}

View File

@@ -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 (
<label className={styles.control} title={t('directory_auto_switch_help')}>
<input type='checkbox' checked={enabled} onChange={(event) => void handleChange(event.target.checked)} />
{t('directory_auto_switch_label', { directoryCode })}
</label>
);
};
export default DirectoryAutoSwitchControl;

View File

@@ -0,0 +1 @@
export { default } from './directory-auto-switch-control';

View File

@@ -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) => <DirectorySlotReconciler key={directoryCode} directoryCode={directoryCode} />);
};
export default DirectorySubscriptionReconciler;

View File

@@ -0,0 +1 @@
export { default } from './directory-subscription-reconciler';

View File

@@ -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;
}
}

View File

@@ -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 (
<section className={styles.notice} aria-live='polite'>
<strong>{t('directory_recommendation_changed', { directoryCode })}</strong>{' '}
<Link to={getCommunityPath(pending.winnerAddress)}>{getDisplayAddress(pending.winnerAddress)}</Link>. {t('directory_you_still_follow')}{' '}
<Link to={getCommunityPath(pending.currentAddress)}>{getDisplayAddress(pending.currentAddress)}</Link>.
<span className={styles.actions}>
<button type='button' onClick={() => runAction('switch')}>
{t('switch_to_community', { communityAddress: getDisplayAddress(pending.winnerAddress) })}
</button>
<button type='button' onClick={() => runAction('keepBoth')}>
{t('keep_both_communities')}
</button>
<button type='button' onClick={() => runAction('keep')}>
{t('keep_community', { communityAddress: getDisplayAddress(pending.currentAddress) })}
</button>
</span>
</section>
);
};
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 (
<section className={styles.notice} aria-live='polite'>
<strong>{t('directory_automatic_switch_notice', { directoryCode, fromAddress: notice.fromAddress, toAddress: notice.toAddress })}</strong>
<span className={styles.actions}>
<button type='button' onClick={() => runAction('undo')}>
{t('undo')}
</button>
<button type='button' onClick={() => runAction('dismiss')}>
{t('dismiss')}
</button>
</span>
</section>
);
};
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) => [
<AutomaticDirectoryChangeNotice key={`${directoryCode}-automatic`} directoryCode={directoryCode} />,
<ManualDirectoryChangeNotice key={`${directoryCode}-manual`} directoryCode={directoryCode} />,
]);
};
export default DirectorySubscriptionUpdatesNotice;

View File

@@ -0,0 +1 @@
export { default } from './directory-subscription-updates-notice';

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { default } from './exact-community-action-route';

View File

@@ -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;
}

View File

@@ -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 (
<li className={(isInPostPageView || isInPendingPostView) && !isInHomeAboutView && !isInPostPageAboutView ? styles.selected : styles.choice}>
@@ -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 = () => {
</ul>
)}
</div>
{isMobile && directoryCode && communityAddress && isInCommunityView && !isInPostPageView && !isInCommunitySubmitView && !isInCommunitySettingsView && (
<div className={styles.mobileDirectoryDisclosure}>
{t('directory_route_currently_recommends', { directoryCode })} <Link to={getCommunityPath(communityAddress)}>{getDisplayAddress(communityAddress)}</Link>
</div>
)}
{isMobile && !isInCommunitySubmitView && !isHiddenNsfwCommunity && (
<ul className={`${styles.tabMenu} ${isInProfileView ? styles.horizontalScroll : ''}`}>
<HeaderTabs />

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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 && (
<div className={styles.titleBox}>
<Link className={styles.title} to={address ? getCommunityPath(address) : '/communities'}>
{getDisplayAddress(community?.address || '')}
<Link className={styles.title} to={directoryCode ? `/s/${directoryCode}` : address ? getCommunityPath(address) : '/communities'}>
{directoryCode ? `s/${directoryCode}` : getDisplayAddress(community?.address || '')}
</Link>
{directoryCode && address && (
<div className={styles.directoryRouteDisclosure}>
{t('directory_route_currently_recommends', { directoryCode })} <Link to={getCommunityPath(address)}>{getDisplayAddress(address)}</Link>
</div>
)}
<div className={styles.subscribeContainer}>
<span className={styles.subscribeButton}>
<SubscribeButton address={address} />
<SubscribeButton address={address} directoryCode={directoryCode} directoryRevision={directoryRevision} />
</span>
<span className={styles.subscribers}>{t('members_count', { count: allActiveUserCount })}</span>
</div>
{directoryCode && directoryRevision && address && (
<DirectoryAutoSwitchControl address={address} directoryCode={directoryCode} directoryRevision={directoryRevision} />
)}
<div className={styles.onlineLine}>
<span className={`${styles.onlineIndicator} ${!isOffline ? styles.online : styles.offline}`} title={!isOffline ? t('online') : t('offline')} />
<span>{isSubCreatedButNotYetPublished ? subCreatedButNotYetPublishedStatus : onlineStatus}</span>

View File

@@ -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<typeof notice> => 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 (
<output className={styles.notice}>
<strong>{t('starter_subscriptions_updated')}</strong>{' '}
{t('starter_subscriptions_change_summary', {
added: delta.addedAddresses.length,
removed: delta.removedAddresses.length,
added: uncoveredAddedAddresses.length,
removed: uncoveredRemovedAddresses.length,
})}{' '}
<Link to='/communities/defaults'>{t('review_changes')}</Link>
</output>

View File

@@ -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();

View File

@@ -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 (
<div className={styles.headerArea}>

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
]
}
}
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -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",

View File

@@ -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<SeeditDirectoryCode, unknown>;
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<SeeditDirectoryCode, DirectoryList>;
export const getVendoredDirectoryList = (directoryCode: string | undefined): DirectoryList | null =>
directoryCode && isDirectoryCode(directoryCode) ? vendoredDirectoryLists[directoryCode] : null;

View File

@@ -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'],
},

View File

@@ -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');
});
});

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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<void> | null;
revalidationTimer: ReturnType<typeof globalThis.setTimeout> | 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<SeeditDirectoryCode, DirectoryListStore>();
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<unknown> => {
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<void> | 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);
};

View File

@@ -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 });
});
});

View File

@@ -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;

View File

@@ -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<Params<string>>
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;
};

View File

@@ -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');
});
});

View File

@@ -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;
};

View File

@@ -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'],
});
});
});

View File

@@ -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);
};

View File

@@ -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);
});
});

View File

@@ -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<string> = new Set(SEEDIT_DIRECTORY_CODES);
export const isDirectoryCode = (value: string | undefined): value is SeeditDirectoryCode => typeof value === 'string' && DIRECTORY_CODES_SET.has(value);

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import {
normalizeDirectoryDefaultsData,
normalizeDirectoryList,
normalizeRemoteDirectoryList,
pickDirectoryWinner,
sortDirectoryCommunitiesByRank,
} from './directory-list-utils';
const directoryPayload = (overrides: Record<string, unknown> = {}) => ({
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');
});
});

View File

@@ -0,0 +1,184 @@
// Directory lists are route-discovery snapshots. They select the community shown at a
// reserved /s/<code> 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<string, DirectoryDefaultsEntry>;
}
const isRecord = (value: unknown): value is Record<string, unknown> => 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;
};

View File

@@ -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<Record<SeeditDirectoryCode, { address: string; revision?: number; acknowledgedAddress?: string; autoSwitch?: boolean }>>,
): 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: {} });
});
});

View File

@@ -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<Record<SeeditDirectoryCode, SeeditDirectoryPreference>>;
automaticChangeNotices: Partial<Record<SeeditDirectoryCode, DirectoryAutomaticChangeNotice>>;
}
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<string>();
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<SeeditStarterSubscriptions>;
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<Record<SeeditDirectoryCode, AuthoritativeDirectoryWinnerSnapshot>> => {
const latest: Partial<Record<SeeditDirectoryCode, AuthoritativeDirectoryWinnerSnapshot>> = {};
const conflicts = new Set<SeeditDirectoryCode>();
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 },
}),
};
};

View File

@@ -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);

View File

@@ -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

View File

@@ -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),
};
};

View File

@@ -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'] });

View File

@@ -39,6 +39,8 @@ interface ManualLeaveStarterSubscriptionResult {
provenance?: SeeditStarterSubscriptions;
}
export type StarterDirectoryChangeChoice = 'keep' | 'trackWinner';
const uniqueAddresses = (addresses: readonly string[]): string[] => {
const seen = new Set<string>();
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 };
};

View File

@@ -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', () => {

View File

@@ -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}`;
}

View File

@@ -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 (
<div className={styles.content}>

View File

@@ -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;

View File

@@ -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 (
<div className={`${styles.box}`}>
@@ -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;

Some files were not shown because too many files have changed in this diff Show More