SyncModeSelector.tsx

6.0 KB · raw

1// SyncModeSelector — the one place the relay choice is made.2//3// Settings → Sync renders it expanded and is the source of truth; the pairing4// surfaces render it `compact`, as a quiet "Using Public relay · change" line5// that expands in place. Both read and write the same GET/SET_SYNC_SETTINGS6// value, so the two surfaces cannot drift apart7// (docs/sync-relay-surfacing.md).89import { Component, Show, createSignal, onMount } from "solid-js";10import { sendMessage } from "../../shared/bridge";11import { DEFAULT_RELAY_URL } from "../../shared/rendezvous";1213export type SyncMode = "public" | "private" | "off";1415export interface SyncSettingsSnapshot {16  mode: SyncMode;17  relayUrl: string;18}1920/**21 * True of the same phrase everywhere: the relay is a dumb pipe under both22 * relay modes, and its absence is what makes "No sync" a visibly smaller23 * footprint rather than a different kind of trust.24 */25export const RELAY_DISCLOSURE =26  "End-to-end encrypted, through a relay that never stores or accesses any vault or user data.";2728export function modeLabel(mode: SyncMode): string {29  return mode === "public" ? "Public relay" : mode === "private" ? "Private relay" : "No sync";30}3132export interface SyncModeSelectorProps {33  /** Render as a one-line summary that expands on "change". */34  compact?: boolean;35  /** Fires on every committed change, so a host mid-pairing can react. */36  onChange?(settings: SyncSettingsSnapshot): void;37}3839export const SyncModeSelector: Component<SyncModeSelectorProps> = (props) => {40  const [mode, setMode] = createSignal<SyncMode>("public");41  const [relayUrl, setRelayUrl] = createSignal("");42  const [draftUrl, setDraftUrl] = createSignal("");43  const [saving, setSaving] = createSignal(false);44  const [status, setStatus] = createSignal<string | null>(null);45  const [expanded, setExpanded] = createSignal(false);4647  /** Private chosen, no URL yet — never silently fall back to the public relay. */48  const needsRelayUrl = () => mode() === "private" && relayUrl() === DEFAULT_RELAY_URL;4950  function apply(next: SyncSettingsSnapshot) {51    setMode(next.mode);52    setRelayUrl(next.relayUrl);53    setDraftUrl(next.relayUrl === DEFAULT_RELAY_URL ? "" : next.relayUrl);54  }5556  onMount(async () => {57    const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {58      ok: boolean;59      data?: SyncSettingsSnapshot;60    };61    if (result.ok && result.data) apply(result.data);62  });6364  /** Pass a mode to switch; pass nothing to save the typed private relay URL. */65  async function save(next?: SyncMode) {66    setSaving(true);67    setStatus(null);68    const result = (await sendMessage({69      type: "SET_SYNC_SETTINGS",70      payload: next ? { mode: next } : { relayUrl: draftUrl() },71    })) as { ok: boolean; data?: SyncSettingsSnapshot };72    if (result.ok && result.data) {73      apply(result.data);74      setStatus(next ? null : "Saved");75      props.onChange?.(result.data);76    } else {77      setStatus("Use a valid wss:// relay URL");78    }79    setSaving(false);80  }8182  const option = (value: SyncMode, title: string, sub: string) => (83    <button84      class="peach-sync-mode"85      classList={{ "is-active": mode() === value }}86      role="radio"87      aria-checked={mode() === value}88      disabled={saving()}89      onClick={() => void save(value)}90    >91      <span class="peach-sync-mode-main">92        <span class="peach-sync-mode-title">{title}</span>93        <span class="peach-sync-mode-sub">{sub}</span>94      </span>95      <span class="peach-sync-mode-pip" aria-hidden="true" />96    </button>97  );9899  return (100    <Show101      when={!props.compact || expanded()}102      fallback={103        <p class="peach-sync-mode-summary">104          Using {modeLabel(mode())}105          {needsRelayUrl() ? " — no URL set" : ""}106          {" · "}107          <button class="peach-sync-mode-change" onClick={() => setExpanded(true)}>change</button>108        </p>109      }110    >111      <div class="peach-sync-mode-group" role="radiogroup" aria-label="How this vault syncs">112        {option(113          "public",114          "Public relay",115          "Peach's own relay. Works anywhere, nothing to set up.",116        )}117        {option(118          "private",119          "Private relay",120          "Your own peach-relay, on your own domain. Same open-source code.",121        )}122123        <Show when={mode() === "private"}>124          <label class="peach-label" for="sync-relay-url">relay URL</label>125          <input126            id="sync-relay-url"127            class="peach-underline-input"128            type="url"129            inputmode="url"130            autocomplete="off"131            spellcheck={false}132            placeholder="wss://relay.example.com"133            value={draftUrl()}134            disabled={saving()}135            onInput={(event) => setDraftUrl((event.target as HTMLInputElement).value)}136            onKeyDown={(event) => {137              if (event.key === "Enter") void save();138            }}139          />140          <div class="peach-settings-row peach-settings-row--meta">141            <span142              class="peach-settings-value"143              classList={{ "peach-sync-mode-warn": needsRelayUrl() || status()?.startsWith("Use") }}144            >145              {status() ??146                (needsRelayUrl()147                  ? "Sync is paused until you enter your relay URL."148                  : "Only secure wss:// relays are accepted.")}149            </span>150            <button151              class="peach-btn peach-btn--subtle peach-btn--sm"152              disabled={saving()}153              onClick={() => void save()}154            >{saving() ? "saving…" : "save"}</button>155          </div>156        </Show>157158        {option(159          "off",160          "No sync",161          "This device keeps to itself. Move your vault by hand with Codex or CSV export and import.",162        )}163164        <p class="peach-sync-mode-disclosure">{RELAY_DISCLOSURE}</p>165166        <Show when={props.compact}>167          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => setExpanded(false)}>168            Done169          </button>170        </Show>171      </div>172    </Show>173  );174};