SyncModeSelector.tsx

6.3 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 { type 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  /** Compact only: the expanded selector is taller than the popup, so the host38   *  needs to know when to fold away whatever it was showing. */39  onExpand?(expanded: boolean): void;40}4142export const SyncModeSelector: Component<SyncModeSelectorProps> = (props) => {43  const [mode, setMode] = createSignal<SyncMode>("public");44  const [relayUrl, setRelayUrl] = createSignal("");45  const [draftUrl, setDraftUrl] = createSignal("wss://");46  const [saving, setSaving] = createSignal(false);47  const [status, setStatus] = createSignal<string | null>(null);48  const [expanded, setExpandedSignal] = createSignal(false);4950  function setExpanded(next: boolean) {51    setExpandedSignal(next);52    props.onExpand?.(next);53  }5455  /** Private chosen, no URL yet — never silently fall back to the public relay. */56  const needsRelayUrl = () => mode() === "private" && relayUrl() === DEFAULT_RELAY_URL;5758  function apply(next: SyncSettingsSnapshot) {59    setMode(next.mode);60    setRelayUrl(next.relayUrl);61    setDraftUrl(next.relayUrl === DEFAULT_RELAY_URL ? "wss://" : next.relayUrl);62  }6364  onMount(async () => {65    const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {66      ok: boolean;67      data?: SyncSettingsSnapshot;68    };69    if (result.ok && result.data) apply(result.data);70  });7172  /** Pass a mode to switch; pass nothing to save the typed private relay URL. */73  async function save(next?: SyncMode) {74    setSaving(true);75    setStatus(null);76    const result = (await sendMessage({77      type: "SET_SYNC_SETTINGS",78      payload: next ? { mode: next } : { relayUrl: draftUrl() },79    })) as { ok: boolean; data?: SyncSettingsSnapshot };80    if (result.ok && result.data) {81      apply(result.data);82      setStatus(next ? null : "Saved");83      props.onChange?.(result.data);84    } else {85      setStatus("Use a valid wss:// relay URL");86    }87    setSaving(false);88  }8990  const option = (value: SyncMode, title: string, sub: string) => (91    <button92      class="peach-sync-mode"93      classList={{ "is-active": mode() === value }}94      role="radio"95      aria-checked={mode() === value}96      disabled={saving()}97      onClick={() => void save(value)}98    >99      <span class="peach-sync-mode-main">100        <span class="peach-sync-mode-title">{title}</span>101        <span class="peach-sync-mode-sub">{sub}</span>102      </span>103      <span class="peach-sync-mode-pip" aria-hidden="true" />104    </button>105  );106107  return (108    <Show109      when={!props.compact || expanded()}110      fallback={111        <p class="peach-sync-mode-summary">112          Using {modeLabel(mode())}113          {needsRelayUrl() ? " — no URL set" : ""}114          {" · "}115          <button class="peach-sync-mode-change" onClick={() => setExpanded(true)}>change</button>116        </p>117      }118    >119      <div class="peach-sync-mode-group" role="radiogroup" aria-label="How this vault syncs">120        {option(121          "public",122          "Public relay",123          "Peach's own relay. Works anywhere, nothing to set up.",124        )}125        {option(126          "private",127          "Private relay",128          "Your own peach-relay, on your own domain. Same open-source code.",129        )}130131        <Show when={mode() === "private"}>132          <label class="peach-label" for="sync-relay-url">relay URL</label>133          <input134            id="sync-relay-url"135            class="peach-underline-input"136            type="url"137            inputmode="url"138            autocomplete="off"139            spellcheck={false}140            placeholder="wss://relay.example.com"141            value={draftUrl()}142            disabled={saving()}143            onInput={(event) => setDraftUrl((event.target as HTMLInputElement).value)}144            onKeyDown={(event) => {145              if (event.key === "Enter") void save();146            }}147          />148          <div class="peach-settings-row peach-settings-row--meta">149            <span150              class="peach-settings-value"151              classList={{ "peach-sync-mode-warn": needsRelayUrl() || status()?.startsWith("Use") }}152            >153              {status() ??154                (needsRelayUrl()155                  ? "Sync is paused until you enter your relay URL."156                  : "Only secure wss:// relays are accepted.")}157            </span>158            <button159              class="peach-btn peach-btn--subtle peach-btn--sm"160              disabled={saving()}161              onClick={() => void save()}162            >{saving() ? "saving…" : "save"}</button>163          </div>164        </Show>165166        {option(167          "off",168          "No sync",169          "This device keeps to itself. Move your vault by hand with Codex or CSV export and import.",170        )}171172        <p class="peach-sync-mode-disclosure">{RELAY_DISCLOSURE}</p>173174        <Show when={props.compact}>175          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => setExpanded(false)}>176            Done177          </button>178        </Show>179      </div>180    </Show>181  );182};