mirror of
https://github.com/plebbit/seedit.git
synced 2026-07-31 01:45:58 -04:00
Merge branch 'master' of github.com:bitsocialnet/seedit
This commit is contained in:
98
docs/agent-runs/directory-routes/feature-list.json
Normal file
98
docs/agent-runs/directory-routes/feature-list.json
Normal 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."
|
||||
}
|
||||
]
|
||||
}
|
||||
24
docs/agent-runs/directory-routes/progress.md
Normal file
24
docs/agent-runs/directory-routes/progress.md
Normal 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.
|
||||
56
docs/architecture/directory-routes.md
Normal file
56
docs/architecture/directory-routes.md
Normal 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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "تجاهل"
|
||||
}
|
||||
|
||||
@@ -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": "খারিজ করুন"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Κλείσιμο"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "بستن"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -391,5 +391,15 @@
|
||||
"settings_upgrade_error_saving": "Impossible d’enregistrer la mise à niveau des paramètres.",
|
||||
"settings_upgrade_review_notice": "mise à niveau des paramètres disponible",
|
||||
"settings_upgrade_review_button": "examiner",
|
||||
"settings_upgrade_never_show_again": "ne plus afficher"
|
||||
"settings_upgrade_never_show_again": "ne plus afficher",
|
||||
"directory_route_currently_recommends": "s/{{directoryCode}} recommande actuellement",
|
||||
"directory_auto_switch_label": "changer automatiquement cet abonnement lorsque s/{{directoryCode}} change",
|
||||
"directory_recommendation_changed": "s/{{directoryCode}} recommande maintenant",
|
||||
"directory_auto_switch_help": "Seedit remplacera cet abonnement par la nouvelle communauté recommandée. Vous pouvez désactiver cette option à tout moment.",
|
||||
"directory_you_still_follow": "Vous suivez toujours",
|
||||
"switch_to_community": "passer à {{communityAddress}}",
|
||||
"keep_both_communities": "garder les deux",
|
||||
"keep_community": "conserver {{communityAddress}}",
|
||||
"directory_automatic_switch_notice": "s/{{directoryCode}} a changé, Seedit a donc remplacé votre abonnement à {{fromAddress}} par {{toAddress}}.",
|
||||
"dismiss": "ignorer"
|
||||
}
|
||||
|
||||
@@ -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": "סגירה"
|
||||
}
|
||||
|
||||
@@ -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": "बंद करें"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "閉じる"
|
||||
}
|
||||
|
||||
@@ -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": "닫기"
|
||||
}
|
||||
|
||||
@@ -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": "बंद करा"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Закрыть"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "మూసివేయి"
|
||||
}
|
||||
|
||||
@@ -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": "ปิด"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Закрити"
|
||||
}
|
||||
|
||||
@@ -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": "بند کریں"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
126
scripts/sync-directories.js
Normal 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();
|
||||
30
src/app.tsx
30
src/app.tsx
@@ -27,6 +27,8 @@ import AccountBar from './components/account-bar/';
|
||||
import ChallengeModal from './components/challenge-modal';
|
||||
import Header from './components/header';
|
||||
import NotificationHandler from './components/notification-handler';
|
||||
import DirectorySubscriptionReconciler from './components/directory-subscription-reconciler';
|
||||
import ExactCommunityActionRoute from './components/exact-community-action-route';
|
||||
import StickyHeader from './components/sticky-header';
|
||||
import TopBar from './components/topbar';
|
||||
import styles from './app.module.css';
|
||||
@@ -44,6 +46,7 @@ const App = () => {
|
||||
<>
|
||||
<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 />} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
1
src/components/directory-auto-switch-control/index.ts
Normal file
1
src/components/directory-auto-switch-control/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './directory-auto-switch-control';
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './directory-subscription-reconciler';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './directory-subscription-updates-notice';
|
||||
@@ -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;
|
||||
1
src/components/exact-community-action-route/index.ts
Normal file
1
src/components/exact-community-action-route/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './exact-community-action-route';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
16
src/data/seedit-directories/seedit-askseedit-directory.json
Normal file
16
src/data/seedit-directories/seedit-askseedit-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-aww-directory.json
Normal file
16
src/data/seedit-directories/seedit-aww-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
98
src/data/seedit-directories/seedit-directories-defaults.json
Normal file
98
src/data/seedit-directories/seedit-directories-defaults.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-funny-directory.json
Normal file
16
src/data/seedit-directories/seedit-funny-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-gaming-directory.json
Normal file
16
src/data/seedit-directories/seedit-gaming-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-memes-directory.json
Normal file
16
src/data/seedit-directories/seedit-memes-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-news-directory.json
Normal file
16
src/data/seedit-directories/seedit-news-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-pics-directory.json
Normal file
16
src/data/seedit-directories/seedit-pics-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
src/data/seedit-directories/seedit-videos-directory.json
Normal file
16
src/data/seedit-directories/seedit-videos-directory.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
39
src/data/vendored-directory-lists.ts
Normal file
39
src/data/vendored-directory-lists.ts
Normal 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;
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
96
src/hooks/use-directory-list.test.ts
Normal file
96
src/hooks/use-directory-list.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
177
src/hooks/use-directory-list.ts
Normal file
177
src/hooks/use-directory-list.ts
Normal 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);
|
||||
};
|
||||
39
src/hooks/use-resolved-community-route.test.ts
Normal file
39
src/hooks/use-resolved-community-route.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
49
src/hooks/use-resolved-community-route.ts
Normal file
49
src/hooks/use-resolved-community-route.ts
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
133
src/lib/utils/directory-account-transforms.test.ts
Normal file
133
src/lib/utils/directory-account-transforms.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
154
src/lib/utils/directory-account-transforms.ts
Normal file
154
src/lib/utils/directory-account-transforms.ts
Normal 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);
|
||||
};
|
||||
17
src/lib/utils/directory-codes.test.ts
Normal file
17
src/lib/utils/directory-codes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
11
src/lib/utils/directory-codes.ts
Normal file
11
src/lib/utils/directory-codes.ts
Normal 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);
|
||||
138
src/lib/utils/directory-list-utils.test.ts
Normal file
138
src/lib/utils/directory-list-utils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
184
src/lib/utils/directory-list-utils.ts
Normal file
184
src/lib/utils/directory-list-utils.ts
Normal 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;
|
||||
};
|
||||
384
src/lib/utils/directory-subscriptions.test.ts
Normal file
384
src/lib/utils/directory-subscriptions.test.ts
Normal 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: {} });
|
||||
});
|
||||
});
|
||||
491
src/lib/utils/directory-subscriptions.ts
Normal file
491
src/lib/utils/directory-subscriptions.ts
Normal 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 },
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user