diff --git a/backend/internal/api/preferences.go b/backend/internal/api/preferences.go index 702e8cd..94408e2 100644 --- a/backend/internal/api/preferences.go +++ b/backend/internal/api/preferences.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -25,6 +26,7 @@ type PreferencesResponse struct { ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames"` SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers"` LabelPreferences []LabelPreference `json:"labelPreferences"` + DisableExternalLinkWarning bool `json:"disableExternalLinkWarning"` } func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { @@ -65,11 +67,17 @@ func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { labelPrefs = []LabelPreference{} } + disableWarning := false + if prefs != nil && prefs.DisableExternalLinkWarning != nil { + disableWarning = *prefs.DisableExternalLinkWarning + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PreferencesResponse{ ExternalLinkSkippedHostnames: hostnames, SubscribedLabelers: labelers, LabelPreferences: labelPrefs, + DisableExternalLinkWarning: disableWarning, }) } @@ -103,21 +111,21 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { }) } - record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs) + record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs, &input.DisableExternalLinkWarning) if err := record.Validate(); err != nil { http.Error(w, fmt.Sprintf("Invalid record: %v", err), http.StatusBadRequest) return } err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - _, err := client.PutRecord(r.Context(), did, xrpc.CollectionPreferences, "self", record) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, err := client.PutRecord(ctx, did, xrpc.CollectionPreferences, "self", record) return err }) if err != nil { fmt.Printf("[UpdatePreferences] PDS write failed: %v\n", err) - http.Error(w, fmt.Sprintf("Failed to update preferences: %v", err), http.StatusInternalServerError) - return } createdAt, _ := time.Parse(time.RFC3339, record.CreatedAt) @@ -144,6 +152,7 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { ExternalLinkSkippedHostnames: &hostnamesStr, SubscribedLabelers: subscribedLabelersPtr, LabelPreferences: labelPrefsPtr, + DisableExternalLinkWarning: &input.DisableExternalLinkWarning, CreatedAt: createdAt, IndexedAt: time.Now(), }) diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index 780ddf9..6461f88 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -151,6 +151,7 @@ type Preferences struct { ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` SubscribedLabelers *string `json:"subscribedLabelers,omitempty"` LabelPreferences *string `json:"labelPreferences,omitempty"` + DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` CreatedAt time.Time `json:"createdAt"` IndexedAt time.Time `json:"indexedAt"` CID *string `json:"cid,omitempty"` @@ -422,6 +423,9 @@ func (db *DB) Migrate() error { uri TEXT PRIMARY KEY, author_did TEXT NOT NULL, external_link_skipped_hostnames TEXT, + subscribed_labelers TEXT, + label_preferences TEXT, + disable_external_link_warning BOOLEAN, created_at ` + dateType + ` NOT NULL, indexed_at ` + dateType + ` NOT NULL, cid TEXT @@ -618,8 +622,8 @@ func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { func (db *DB) GetPreferences(did string) (*Preferences, error) { var p Preferences - err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan( - &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, &p.LabelPreferences, &p.CreatedAt, &p.IndexedAt, &p.CID, + err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, disable_external_link_warning, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan( + &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, &p.LabelPreferences, &p.DisableExternalLinkWarning, &p.CreatedAt, &p.IndexedAt, &p.CID, ) if err == sql.ErrNoRows { return nil, nil @@ -632,16 +636,17 @@ func (db *DB) GetPreferences(did string) (*Preferences, error) { func (db *DB) UpsertPreferences(p *Preferences) error { query := ` - INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, created_at, indexed_at, cid) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, disable_external_link_warning, created_at, indexed_at, cid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT(uri) DO UPDATE SET external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, subscribed_labelers = EXCLUDED.subscribed_labelers, label_preferences = EXCLUDED.label_preferences, + disable_external_link_warning = EXCLUDED.disable_external_link_warning, indexed_at = EXCLUDED.indexed_at, cid = EXCLUDED.cid ` - _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, p.LabelPreferences, p.CreatedAt, p.IndexedAt, p.CID) + _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, p.LabelPreferences, p.DisableExternalLinkWarning, p.CreatedAt, p.IndexedAt, p.CID) return err } @@ -729,6 +734,7 @@ func (db *DB) runMigrations() { db.Exec(`ALTER TABLE preferences ADD COLUMN subscribed_labelers TEXT`) db.Exec(`ALTER TABLE preferences ADD COLUMN label_preferences TEXT`) + db.Exec(`ALTER TABLE preferences ADD COLUMN disable_external_link_warning BOOLEAN`) } func (db *DB) migrateModeration(dateType string) { diff --git a/backend/internal/db/tags.go b/backend/internal/db/tags.go index fdfc831..0bf6954 100644 --- a/backend/internal/db/tags.go +++ b/backend/internal/db/tags.go @@ -18,11 +18,11 @@ func (db *DB) GetTrendingTags(limit int) ([]TrendingTag, error) { AND tags_json != '[]' AND created_at > NOW() - INTERVAL '7 days' GROUP BY tag - HAVING count > 2 - ORDER BY count DESC - LIMIT ? + HAVING COUNT(*) > 2 + ORDER BY COUNT(*) DESC + LIMIT $1 ` - rows, err := db.Query(db.Rebind(query), limit) + rows, err := db.Query(query, limit) if err != nil { return nil, err } diff --git a/backend/internal/xrpc/records.go b/backend/internal/xrpc/records.go index e4c6ddd..b896ea6 100644 --- a/backend/internal/xrpc/records.go +++ b/backend/internal/xrpc/records.go @@ -478,6 +478,7 @@ type PreferencesRecord struct { ExternalLinkSkippedHostnames []string `json:"externalLinkSkippedHostnames,omitempty"` SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers,omitempty"` LabelPreferences []LabelPreference `json:"labelPreferences,omitempty"` + DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` CreatedAt string `json:"createdAt"` } @@ -499,10 +500,11 @@ func (r *PreferencesRecord) Validate() error { return nil } -func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}) *PreferencesRecord { +func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}, disableExternalLinkWarning *bool) *PreferencesRecord { record := &PreferencesRecord{ Type: CollectionPreferences, ExternalLinkSkippedHostnames: skippedHostnames, + DisableExternalLinkWarning: disableExternalLinkWarning, CreatedAt: time.Now().UTC().Format(time.RFC3339), } diff --git a/lexicons/at/margin/preferences.json b/lexicons/at/margin/preferences.json index bd21b40..728ac76 100644 --- a/lexicons/at/margin/preferences.json +++ b/lexicons/at/margin/preferences.json @@ -40,6 +40,10 @@ "createdAt": { "type": "string", "format": "datetime" + }, + "disableExternalLinkWarning": { + "type": "boolean", + "description": "If true, do not show the confirmation modal when opening external links." } } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c23f672..4112abe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1007,6 +1007,7 @@ export interface PreferencesResponse { externalLinkSkippedHostnames?: string[]; subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; + disableExternalLinkWarning?: boolean; } export async function getPreferences(): Promise { @@ -1026,6 +1027,7 @@ export async function updatePreferences(prefs: { externalLinkSkippedHostnames?: string[]; subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; + disableExternalLinkWarning?: boolean; }): Promise { try { const res = await apiRequest("/api/preferences", { diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index 61e98b6..210545a 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -227,6 +227,12 @@ export default function Card({ window.open(url, "_blank", "noopener,noreferrer"); return; } + + if ($preferences.get().disableExternalLinkWarning) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + const skipped = $preferences.get().externalLinkSkippedHostnames; if (skipped.includes(hostname)) { window.open(url, "_blank", "noopener,noreferrer"); diff --git a/web/src/components/common/RichText.tsx b/web/src/components/common/RichText.tsx index 3eee1ed..f53ca2c 100644 --- a/web/src/components/common/RichText.tsx +++ b/web/src/components/common/RichText.tsx @@ -78,6 +78,12 @@ export default function RichText({ text, className }: RichTextProps) { window.open(url, "_blank", "noopener,noreferrer"); return; } + + if (preferences.disableExternalLinkWarning) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + const skipped = preferences.externalLinkSkippedHostnames || []; if (skipped.includes(hostname)) { window.open(url, "_blank", "noopener,noreferrer"); diff --git a/web/src/components/ui/Switch.tsx b/web/src/components/ui/Switch.tsx new file mode 100644 index 0000000..e5ac088 --- /dev/null +++ b/web/src/components/ui/Switch.tsx @@ -0,0 +1,39 @@ +import * as React from "react"; + +interface SwitchProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + disabled?: boolean; + className?: string; +} + +export function Switch({ + checked, + onCheckedChange, + disabled = false, + className = "", +}: SwitchProps) { + return ( + + ); +} diff --git a/web/src/components/ui/index.ts b/web/src/components/ui/index.ts index 4761fb1..abeee9d 100644 --- a/web/src/components/ui/index.ts +++ b/web/src/components/ui/index.ts @@ -6,3 +6,4 @@ export { default as Skeleton, SkeletonCard } from "./Skeleton"; export { default as EmptyState } from "./EmptyState"; export { default as Badge, CountBadge } from "./Badge"; export { default as LayoutToggle } from "./LayoutToggle"; +export * from "./Switch"; diff --git a/web/src/store/preferences.ts b/web/src/store/preferences.ts index c0dd576..195bb23 100644 --- a/web/src/store/preferences.ts +++ b/web/src/store/preferences.ts @@ -10,12 +10,14 @@ export interface Preferences { externalLinkSkippedHostnames: string[]; subscribedLabelers: LabelerSubscription[]; labelPreferences: LabelPreference[]; + disableExternalLinkWarning: boolean; } export const $preferences = atom({ externalLinkSkippedHostnames: [], subscribedLabelers: [], labelPreferences: [], + disableExternalLinkWarning: false, }); export async function loadPreferences() { @@ -24,6 +26,7 @@ export async function loadPreferences() { externalLinkSkippedHostnames: prefs.externalLinkSkippedHostnames || [], subscribedLabelers: prefs.subscribedLabelers || [], labelPreferences: prefs.labelPreferences || [], + disableExternalLinkWarning: !!prefs.disableExternalLinkWarning, }); } @@ -92,3 +95,15 @@ export function getLabelVisibility( ); return pref?.visibility || "warn"; } + +export async function setDisableExternalLinkWarning(disabled: boolean) { + const current = $preferences.get(); + if (current.disableExternalLinkWarning === disabled) return; + + const updated = { + ...current, + disableExternalLinkWarning: disabled, + }; + $preferences.set(updated); + await updatePreferences(updated); +} diff --git a/web/src/views/core/Settings.tsx b/web/src/views/core/Settings.tsx index 15b322e..5bc4af7 100644 --- a/web/src/views/core/Settings.tsx +++ b/web/src/views/core/Settings.tsx @@ -9,6 +9,7 @@ import { removeLabeler, setLabelVisibility, getLabelVisibility, + setDisableExternalLinkWarning, } from "../../store/preferences"; import { getAPIKeys, @@ -54,6 +55,7 @@ import { Input, Skeleton, EmptyState, + Switch, } from "../../components/ui"; import { AppleIcon } from "../../components/common/Icons"; import { Link } from "react-router-dom"; @@ -174,11 +176,10 @@ export default function Settings() {