ChirpSync.tsx

15.5 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 } from "../components/ChirpPairingFlow";33import {34  ChirpPairingFailure,35  runChirpPairing,36  type ChirpPairingDeps,37} from "../chirp-pair";38import { getLocalSubnet } from "../../shared/local-network";39import { PeachMark } from "../components/PeachMark";4041export interface ChirpSyncViewProps {42  isPro: boolean;43  onBack(): void;44  onLearnPro(): void;45}4647const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;4849type Mode = "idle" | "pairing";5051export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {52  const [devices, setDevices] = createSignal<PairedDevice[]>([]);53  const [mode, setMode] = createSignal<Mode>("idle");54  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });55  const [error, setError] = createSignal<string | null>(null);56  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);5758  onMount(() => {59    void refresh();60  });6162  async function refresh(): Promise<void> {63    if (!props.isPro) return;64    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });65    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;66    setDevices(data?.devices ?? []);67  }6869  async function handleStartPairing(): Promise<void> {70    setError(null);71    setPairing({ phase: "idle" });72    setMode("pairing");7374    const deps: ChirpPairingDeps = {75      onProgress: (p) => setPairing(p),76      fetchLocalIdentity: async () => {77        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });78        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };79        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");80        // The handler returns a LocalIdentity; we trust the background's shape.81        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;82      },83      completePairing: async (paired) => {84        const res = await sendMessage({85          type: "CHIRP_COMPLETE_PAIRING",86          payload: { paired },87        });88        const r = res as { ok: boolean; error?: string };89        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");90      },91      getLocalSubnet,92      openPairingTransport: async (endpoint) => {93        // Native WebSocket transport for the pairing-mode peer scan. The94        // chirp-pair orchestrator only uses send/receive/close, so a thin95        // adapter over the platform WebSocket suffices.96        return new Promise((resolve, reject) => {97          let ws: WebSocket;98          try {99            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);100          } catch (err) {101            reject(err);102            return;103          }104          const inbox: Uint8Array[] = [];105          const waiters: Array<(b: Uint8Array) => void> = [];106          let closed = false;107          ws.binaryType = "arraybuffer";108          const timer = setTimeout(() => {109            try { ws.close(); } catch { /* ignore */ }110            reject(new Error("pairing_transport_timeout"));111          }, 2_000);112          ws.onopen = () => {113            clearTimeout(timer);114            resolve({115              async send(data: Uint8Array) {116                ws.send(data as Uint8Array<ArrayBuffer>);117              },118              receive(): Promise<Uint8Array> {119                if (closed) return Promise.reject(new Error("closed"));120                const v = inbox.shift();121                if (v) return Promise.resolve(v);122                return new Promise<Uint8Array>((res) => waiters.push(res));123              },124              async close() {125                try { ws.close(); } catch { /* ignore */ }126              },127            });128          };129          ws.onmessage = (ev) => {130            if (!(ev.data instanceof ArrayBuffer)) return;131            const bytes = new Uint8Array(ev.data);132            const w = waiters.shift();133            if (w) w(bytes); else inbox.push(bytes);134          };135          ws.onerror = () => {136            clearTimeout(timer);137            reject(new Error("pairing_transport_error"));138          };139          ws.onclose = () => {140            closed = true;141            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));142          };143        });144      },145    };146147    try {148      await runChirpPairing(deps);149      await refresh();150    } catch (err) {151      if (err instanceof ChirpPairingFailure) {152        // The orchestrator already pushed the terminal phase into153        // `pairing()`; the inline error copy will render. No further154        // action needed — the user can tap "Try again" or "Cancel".155        return;156      }157      setError((err as Error).message);158      setMode("idle");159    }160  }161162  async function handleUnpair(deviceId: string): Promise<void> {163    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });164    setConfirmUnpair(null);165    await refresh();166  }167168  function relativeTime(when: number | undefined): string {169    if (!when) return "Not synced yet";170    const elapsed = Date.now() - when;171    if (elapsed < 60_000) return "Just now";172    const minutes = Math.floor(elapsed / 60_000);173    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;174    const hours = Math.floor(minutes / 60);175    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;176    const days = Math.floor(hours / 24);177    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;178    return "Not seen recently";179  }180181  function isStale(d: PairedDevice): boolean {182    if (d.lastSyncedAt === undefined) return false;183    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;184  }185186  // --- Sub-views --------------------------------------------------187188  const proGateView = () => (189    <div class="peach-chirp-settings peach-chirp-settings--gated">190      <header class="peach-chirp-settings-header">191        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>192          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">193            <path d="M11 4 L5 9 L11 14" />194          </svg>195        </button>196        <h1 class="peach-chirp-settings-title">Sync</h1>197      </header>198      <div class="peach-chirp-pro-card">199        <div class="peach-chirp-pro-illustration">200          <PeachMark class="peach-chirp-pro-mark" />201          <svg class="peach-chirp-pro-waves" width="200" height="80" viewBox="0 0 200 80" fill="none" stroke="currentColor" stroke-width="1.4">202            <path d="M 30 40 Q 100 10 170 40" />203            <path d="M 30 50 Q 100 20 170 50" opacity="0.6" />204            <path d="M 30 60 Q 100 30 170 60" opacity="0.3" />205          </svg>206        </div>207        <h2 class="peach-chirp-pro-headline">Chirp Sync is a Peach Pro feature</h2>208        <p class="peach-chirp-pro-body">209          Pair your phone with your laptop using a quick inaudible chirp —210          then your vault stays in sync across devices on your local network.211          No QR codes, no relay servers, no cloud accounts.212        </p>213        <button class="peach-btn peach-btn-primary" onClick={() => props.onLearnPro()}>214          Learn more about Peach Pro215        </button>216      </div>217    </div>218  );219220  const idleView = () => (221    <div class="peach-chirp-settings">222      <header class="peach-chirp-settings-header">223        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>224          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">225            <path d="M11 4 L5 9 L11 14" />226          </svg>227        </button>228        <h1 class="peach-chirp-settings-title">Sync</h1>229      </header>230231      <Show when={error()}>232        <div class="peach-chirp-settings-error" role="alert">{error()}</div>233      </Show>234235      <Show when={devices().length === 0}>236        <div class="peach-chirp-hero">237          <ChirpHeroBirds />238          <h2 class="peach-chirp-hero-headline">Pair your phone with this Peach</h2>239          <p class="peach-chirp-hero-sub">240            Hold your phone near your laptop. Peach will chirp once,241            inaudibly, and pair automatically. No codes, no servers.242          </p>243        </div>244      </Show>245246      <div class="peach-chirp-pair-cta">247        <button class="peach-btn peach-btn-primary peach-chirp-pair-button" onClick={() => void handleStartPairing()}>248          <svg class="peach-chirp-pair-button-icon" width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">249            <path d="M3 9 L9 3 L15 9" />250            <path d="M3 13 L9 7 L15 13" opacity="0.5" />251          </svg>252          Pair a device253        </button>254      </div>255256      <Show when={devices().length > 0}>257        <h2 class="peach-chirp-settings-section">Paired devices</h2>258        <ul class="peach-chirp-device-list">259          <For each={devices()}>260            {(d) => (261              <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>262                <DeviceTypeIcon type={d.deviceType ?? "extension"} />263                <div class="peach-chirp-device-info">264                  <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>265                  <span class="peach-chirp-device-meta">266                    {relativeTime(d.lastSyncedAt)}267                    <Show when={d.trustSource === "vouched"}>268                      <span class="peach-chirp-device-badge">Added automatically</span>269                    </Show>270                    <Show when={isStale(d)}>271                      <span class="peach-chirp-device-stale">Not seen recently</span>272                    </Show>273                  </span>274                </div>275                <Show276                  when={confirmUnpair() !== d.deviceId}277                  fallback={278                    <div class="peach-chirp-unpair-confirm">279                      <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>280                        Cancel281                      </button>282                      <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>283                        Remove284                      </button>285                    </div>286                  }287                >288                  <button289                    class="peach-icon-btn peach-chirp-device-remove"290                    aria-label={`Unpair ${d.deviceName}`}291                    onClick={() => setConfirmUnpair(d.deviceId)}292                  >293                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">294                      <path d="M3 3 L11 11 M11 3 L3 11" />295                    </svg>296                  </button>297                </Show>298              </li>299            )}300          </For>301        </ul>302      </Show>303304      <footer class="peach-chirp-settings-footer">305        <p>306          Chirp Sync stays entirely on your local network. No relay servers,307          no cloud accounts — just your devices in the same room.308        </p>309      </footer>310    </div>311  );312313  const pairingView = () => (314    <div class="peach-chirp-settings peach-chirp-settings--pairing">315      <ChirpPairingFlow316        progress={pairing()}317        onCancel={() => setMode("idle")}318        onRetry={() => void handleStartPairing()}319        onDismiss={() => {320          setMode("idle");321          void refresh();322        }}323      />324    </div>325  );326327  const currentMode = createMemo(() => {328    if (!props.isPro) return "gated";329    if (mode() === "pairing") return "pairing";330    return "idle";331  });332333  return (334    <Show335      when={currentMode() === "gated"}336      fallback={337        <Show when={currentMode() === "pairing"} fallback={idleView()}>338          {pairingView()}339        </Show>340      }341    >342      {proGateView()}343    </Show>344  );345};346347const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {348  return (349    <div class="peach-chirp-device-icon" data-type={props.type}>350      <Show when={props.type === "android" || props.type === "ios"}>351        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">352          <rect x="6" y="2" width="8" height="16" rx="1.5" />353          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />354        </svg>355      </Show>356      <Show when={props.type === "extension" || props.type === "desktop"}>357        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">358          <rect x="2" y="4" width="16" height="10" rx="1.4" />359          <path d="M7 17 L13 17" />360          <path d="M9 14 L9 17 M11 14 L11 17" />361        </svg>362      </Show>363    </div>364  );365};366367// Schematic two-bird illustration shown when no devices are paired yet.368// The desktop bird stands ready, the mobile bird hops in from the right —369// frozen mid-greeting. The live pairing animation drives the same SVG370// shapes from ChirpPairingFlow once the user taps "Pair a device".371const ChirpHeroBirds: Component = () => {372  return (373    <svg class="peach-chirp-hero-svg" width="240" height="120" viewBox="0 0 240 120" fill="none">374      <ellipse cx="80" cy="78" rx="32" ry="24" class="peach-chirp-bird-body" />375      <circle cx="98" cy="56" r="16" class="peach-chirp-bird-head" />376      <circle cx="103" cy="54" r="2.4" class="peach-chirp-bird-eye" />377      <path d="M 113 56 L 122 56 L 116 62 Z" class="peach-chirp-bird-beak" />378      <path d="M 60 64 Q 50 76 60 86" class="peach-chirp-bird-wing" fill="none" stroke-width="2.4" stroke-linecap="round" />379380      <path d="M 130 60 Q 160 36 180 60" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.45" />381      <path d="M 130 70 Q 160 46 180 70" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.25" />382383      <ellipse cx="184" cy="80" rx="22" ry="17" class="peach-chirp-bird-body" />384      <circle cx="170" cy="64" r="11" class="peach-chirp-bird-head" />385      <circle cx="166" cy="62" r="1.8" class="peach-chirp-bird-eye" />386      <path d="M 158 64 L 151 64 L 156 69 Z" class="peach-chirp-bird-beak" />387      <path d="M 200 72 Q 210 82 200 92" class="peach-chirp-bird-wing" fill="none" stroke-width="2.2" stroke-linecap="round" />388    </svg>389  );390};