ChirpSync.tsx

15.1 KB · raw

1// ChirpSync — the v3 Settings → Sync surface.2//3// Three modes the user can be in:4//5//   1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6//      to the License section.7//   2. Idle — list of paired devices + "Pair a device" hero button. Stale8//      devices (>7 days since last sync) show a soft warning indicator;9//      vouched devices (received via device manifest, v2 brief §4.2) show10//      an "Added automatically" badge.11//   3. Pairing — ChirpPairingFlow takes over the full surface and drives12//      the three-act bird animation in lockstep with the audio protocol.13//14// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15// the binding between that orchestrator, the background-message16// CHIRP_* handlers, and the Settings navigation surrounding it.1718import {19  Component,20  Show,21  createMemo,22  createSignal,23  For,24  onMount,25} from "solid-js";26import { sendMessage } from "../../shared/bridge";27import type {28  DeviceType,29  PairedDevice,30  PairingProgress,31} from "../../shared/sync-types";32import { ChirpPairingFlow, DesktopBird, MobileBird } from "../components/ChirpPairingFlow";33import {34  ChirpPairingFailure,35  runChirpPairing,36  type ChirpPairingDeps,37  type PairingRole,38} from "../chirp-pair";39import { getLocalSubnet } from "../../shared/local-network";40import { isNativePlatform } from "../../shared/native";41import { openCapacitorPairingServer } from "../chirp-pair-capacitor";42import { PeachMark } from "../components/PeachMark";4344export interface ChirpSyncViewProps {45  isPro: boolean;46  onBack(): void;47  onLearnPro(): void;48}4950const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;5152type Mode = "rest" | "pairing";5354export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {55  const [devices, setDevices] = createSignal<PairedDevice[]>([]);56  const [mode, setMode] = createSignal<Mode>("rest");57  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });58  const [error, setError] = createSignal<string | null>(null);59  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);6061  onMount(() => {62    void refresh();63  });6465  async function refresh(): Promise<void> {66    if (!props.isPro) return;67    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });68    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;69    setDevices(data?.devices ?? []);70  }7172  async function handleStartPairing(): Promise<void> {73    setError(null);74    setPairing({ phase: "idle" });75    setMode("pairing");7677    const deps: ChirpPairingDeps = {78      onProgress: (p) => setPairing(p),79      fetchLocalIdentity: async () => {80        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });81        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };82        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");83        // The handler returns a LocalIdentity; we trust the background's shape.84        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;85      },86      completePairing: async (paired) => {87        const res = await sendMessage({88          type: "CHIRP_COMPLETE_PAIRING",89          payload: { paired },90        });91        const r = res as { ok: boolean; error?: string };92        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");93      },94      getLocalSubnet,95      openPairingTransport: async (endpoint) => {96        // Native WebSocket transport for the pairing-mode peer scan. The97        // chirp-pair orchestrator only uses send/receive/close, so a thin98        // adapter over the platform WebSocket suffices.99        return new Promise((resolve, reject) => {100          let ws: WebSocket;101          try {102            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);103          } catch (err) {104            reject(err);105            return;106          }107          const inbox: Uint8Array[] = [];108          const waiters: Array<(b: Uint8Array) => void> = [];109          let closed = false;110          ws.binaryType = "arraybuffer";111          const timer = setTimeout(() => {112            try { ws.close(); } catch { /* ignore */ }113            reject(new Error("pairing_transport_timeout"));114          }, 2_000);115          ws.onopen = () => {116            clearTimeout(timer);117            resolve({118              async send(data: Uint8Array) {119                ws.send(data as Uint8Array<ArrayBuffer>);120              },121              receive(): Promise<Uint8Array> {122                if (closed) return Promise.reject(new Error("closed"));123                const v = inbox.shift();124                if (v) return Promise.resolve(v);125                return new Promise<Uint8Array>((res) => waiters.push(res));126              },127              async close() {128                try { ws.close(); } catch { /* ignore */ }129              },130            });131          };132          ws.onmessage = (ev) => {133            if (!(ev.data instanceof ArrayBuffer)) return;134            const bytes = new Uint8Array(ev.data);135            const w = waiters.shift();136            if (w) w(bytes); else inbox.push(bytes);137          };138          ws.onerror = () => {139            clearTimeout(timer);140            reject(new Error("pairing_transport_error"));141          };142          ws.onclose = () => {143            closed = true;144            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));145          };146        });147      },148    };149150    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";151    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;152153    try {154      await runChirpPairing(deps, role);155      await refresh();156    } catch (err) {157      if (err instanceof ChirpPairingFailure) {158        // The orchestrator already pushed the terminal phase into159        // `pairing()`; the inline error copy will render. No further160        // action needed — the user can tap "Try again" or "Cancel".161        return;162      }163      setError((err as Error).message);164      setMode("rest");165    }166  }167168  async function handleUnpair(deviceId: string): Promise<void> {169    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });170    setConfirmUnpair(null);171    await refresh();172  }173174  function relativeTime(when: number | undefined): string {175    if (!when) return "Not synced yet";176    const elapsed = Date.now() - when;177    if (elapsed < 60_000) return "Just now";178    const minutes = Math.floor(elapsed / 60_000);179    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;180    const hours = Math.floor(minutes / 60);181    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;182    const days = Math.floor(hours / 24);183    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;184    return "Not seen recently";185  }186187  function isStale(d: PairedDevice): boolean {188    if (d.lastSyncedAt === undefined) return false;189    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;190  }191192  // --- Sub-views --------------------------------------------------193194  const proGateView = () => (195    <div class="peach-chirp-settings peach-chirp-settings--gated">196      <header class="peach-chirp-settings-header">197        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>198          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">199            <path d="M11 4 L5 9 L11 14" />200          </svg>201        </button>202        <h1 class="peach-chirp-settings-title">Sync</h1>203      </header>204      <div class="peach-chirp-pro-card">205        <div class="peach-chirp-pro-illustration">206          <PeachMark class="peach-chirp-pro-mark" />207          <svg class="peach-chirp-pro-waves" width="200" height="80" viewBox="0 0 200 80" fill="none" stroke="currentColor" stroke-width="1.4">208            <path d="M 30 40 Q 100 10 170 40" />209            <path d="M 30 50 Q 100 20 170 50" opacity="0.6" />210            <path d="M 30 60 Q 100 30 170 60" opacity="0.3" />211          </svg>212        </div>213        <h2 class="peach-chirp-pro-headline">Chirp Sync is a Peach Pro feature</h2>214        <p class="peach-chirp-pro-body">215          Pair your phone with your laptop using a quick inaudible chirp —216          then your vault stays in sync across devices on your local network.217          No QR codes, no relay servers, no cloud accounts.218        </p>219        <button class="peach-btn peach-btn-primary" onClick={() => props.onLearnPro()}>220          Learn more about Peach Pro221        </button>222      </div>223    </div>224  );225226  const restView = () => (227    <div class="peach-chirp-pair" data-phase="idle" role="region" aria-label="Sync">228      <header class="peach-chirp-head">229        <button class="peach-chirp-back" aria-label="Back" onClick={() => props.onBack()}>230          <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">231            <path d="M10 3 L5 8 L10 13" />232          </svg>233        </button>234        <div class="peach-chirp-title">Sync</div>235        <div class="peach-chirp-head-spacer" />236      </header>237238      <div class="peach-chirp-chip-row">239        <div class="peach-chirp-chip" data-phase="idle">240          <span class="peach-chirp-chip-dot" />241          <span class="peach-chirp-chip-text">ready</span>242        </div>243      </div>244245      <Show when={error()}>246        <div class="peach-chirp-settings-error" role="alert">{error()}</div>247      </Show>248249      <div class="peach-chirp-stage act-idle" aria-hidden="true">250        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-desktop">251          <DesktopBird pose="idle" />252        </div>253        <div class="peach-chirp-bird-slot peach-chirp-bird-slot-mobile">254          <MobileBird pose="hidden" />255        </div>256      </div>257258      <div class="peach-chirp-copy-host">259        <div class="peach-chirp-copy peach-chirp-copy-rest">260          <div class="peach-chirp-headline">261            {devices().length === 0 ? "Bring your phone close" : "Sync is ready"}262          </div>263          <div class="peach-chirp-sub">264            {devices().length === 0265              ? "Peach pairs over a short, inaudible chirp — no codes, no servers."266              : "Pair another device, or manage the ones already connected below."}267          </div>268        </div>269      </div>270271      <div class="peach-chirp-action">272        <button class="peach-chirp-pair-start" onClick={() => void handleStartPairing()}>273          <svg width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">274            <path d="M3 9 L9 3 L15 9" />275            <path d="M3 13 L9 7 L15 13" opacity="0.5" />276          </svg>277          <span>{devices().length === 0 ? "Pair a device" : "Pair another device"}</span>278        </button>279      </div>280281      <Show when={devices().length > 0}>282        <section class="peach-chirp-paired-section">283          <h2 class="peach-chirp-paired-heading">Paired devices</h2>284          <ul class="peach-chirp-device-list">285            <For each={devices()}>286              {(d) => (287                <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>288                  <DeviceTypeIcon type={d.deviceType ?? "extension"} />289                  <div class="peach-chirp-device-info">290                    <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>291                    <span class="peach-chirp-device-meta">292                      {relativeTime(d.lastSyncedAt)}293                      <Show when={d.trustSource === "vouched"}>294                        <span class="peach-chirp-device-badge">Added automatically</span>295                      </Show>296                      <Show when={isStale(d)}>297                        <span class="peach-chirp-device-stale">Not seen recently</span>298                      </Show>299                    </span>300                  </div>301                  <Show302                    when={confirmUnpair() !== d.deviceId}303                    fallback={304                      <div class="peach-chirp-unpair-confirm">305                        <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>306                          Cancel307                        </button>308                        <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>309                          Remove310                        </button>311                      </div>312                    }313                  >314                    <button315                      class="peach-icon-btn peach-chirp-device-remove"316                      aria-label={`Unpair ${d.deviceName}`}317                      onClick={() => setConfirmUnpair(d.deviceId)}318                    >319                      <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">320                        <path d="M3 3 L11 11 M11 3 L3 11" />321                      </svg>322                    </button>323                  </Show>324                </li>325              )}326            </For>327          </ul>328        </section>329      </Show>330331      <footer class="peach-chirp-foot">332        <div class="peach-chirp-foot-divider" />333        <div class="peach-chirp-foot-text">334          Chirp Sync stays on your local network. No relay servers, no cloud335          accounts — just your devices in the same room.336        </div>337      </footer>338    </div>339  );340341  const pairingView = () => (342    <ChirpPairingFlow343      progress={pairing()}344      onCancel={() => setMode("rest")}345      onRetry={() => void handleStartPairing()}346      onDismiss={() => {347        setMode("rest");348        void refresh();349      }}350    />351  );352353  const currentMode = createMemo(() => {354    if (!props.isPro) return "gated";355    if (mode() === "pairing") return "pairing";356    return "rest";357  });358359  return (360    <Show361      when={currentMode() === "gated"}362      fallback={363        <Show when={currentMode() === "pairing"} fallback={restView()}>364          {pairingView()}365        </Show>366      }367    >368      {proGateView()}369    </Show>370  );371};372373const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {374  return (375    <div class="peach-chirp-device-icon" data-type={props.type}>376      <Show when={props.type === "android" || props.type === "ios"}>377        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">378          <rect x="6" y="2" width="8" height="16" rx="1.5" />379          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />380        </svg>381      </Show>382      <Show when={props.type === "extension" || props.type === "desktop"}>383        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">384          <rect x="2" y="4" width="16" height="10" rx="1.4" />385          <path d="M7 17 L13 17" />386          <path d="M9 14 L9 17 M11 14 L11 17" />387        </svg>388      </Show>389    </div>390  );391};392