feat: per-entity federation privacy toggles for reviews and watchlist

- add federate_reviews + federate_watchlist to UserSettings (default true)
- new UserFederationSettingsQuery port with FederationFlags struct
- remove get_user_federate_goals from LocalApContentQuery
- gate ReviewLogged, ReviewUpdated, WatchlistEntryAdded, on_poster_synced on flags
- goals gating migrated to UserFederationSettingsQuery
- ReviewDeleted and WatchlistEntryRemoved ungated (tombstones always fire)
- sqlite + postgres migrations and adapter impls
- settings API and SPA toggles
This commit is contained in:
2026-06-12 02:26:01 +02:00
parent 33aa5bdab3
commit ca7ca51949
25 changed files with 372 additions and 113 deletions

View File

@@ -18,11 +18,15 @@ export type UpdateGoalRequest = {
export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(),
federate_reviews: z.boolean(),
federate_watchlist: z.boolean(),
})
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = {
federate_goals: boolean
federate_reviews: boolean
federate_watchlist: boolean
}
export function getGoals() {

View File

@@ -178,6 +178,10 @@
"privacy": "Privacy",
"federateGoals": "Share goals on Fediverse",
"federateGoalsDesc": "Broadcast goal progress to followers",
"federateReviews": "Share reviews on Fediverse",
"federateReviewsDesc": "Broadcast diary entries to followers",
"federateWatchlist": "Share watchlist on Fediverse",
"federateWatchlistDesc": "Broadcast watchlist additions to followers",
"export": "Export",
"exportDesc": "Download your diary",
"exportCsv": "CSV",

View File

@@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next"
import { useMutation } from "@tanstack/react-query"
import {
ArrowLeft,
BookOpen,
ChevronRight,
Download,
Key,
List,
LogOut,
RefreshCw,
ShieldBan,
@@ -19,6 +21,7 @@ import { Switch } from "@/components/ui/switch"
import { useAuth, useIsAdmin } from "@/components/auth-provider"
import { reindexSearch } from "@/lib/api/users"
import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
import { UpdateUserSettingsRequest } from "@/lib/api/goals"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/")({
@@ -128,6 +131,18 @@ function PrivacySection() {
const { data: settings } = useSettings()
const updateMutation = useUpdateSettings()
const disabled = updateMutation.isPending
const toggle = (patch: Partial<UpdateUserSettingsRequest>) => {
if (!settings) return
updateMutation.mutate({
federate_goals: settings.federate_goals,
federate_reviews: settings.federate_reviews,
federate_watchlist: settings.federate_watchlist,
...patch,
})
}
return (
<div>
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
@@ -145,11 +160,41 @@ function PrivacySection() {
</p>
</div>
<Switch
checked={settings?.federate_goals ?? false}
onCheckedChange={(checked) =>
updateMutation.mutate({ federate_goals: checked })
}
disabled={updateMutation.isPending}
checked={settings?.federate_goals ?? true}
onCheckedChange={(checked) => toggle({ federate_goals: checked })}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<BookOpen className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateReviews")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateReviewsDesc")}
</p>
</div>
<Switch
checked={settings?.federate_reviews ?? true}
onCheckedChange={(checked) => toggle({ federate_reviews: checked })}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<List className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateWatchlist")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateWatchlistDesc")}
</p>
</div>
<Switch
checked={settings?.federate_watchlist ?? true}
onCheckedChange={(checked) => toggle({ federate_watchlist: checked })}
disabled={disabled}
/>
</div>
</div>