aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-10 12:56:26 +0200
commita0793dd37030f7213ecb07a018b11e6a8adf9238 (patch)
tree5de1046eae9b3747ac97a81652590fb6af99622b /packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
parent044c585f73cfd548e300859e89eaf5dcb40d3d3c (diff)
downloadmeshbay-a0793dd37030f7213ecb07a018b11e6a8adf9238.tar.gz
feat(android): back the phone's contacts up to a group of one's own
A Contacts backup section on the Android Sync page sends a dated .vcf into <folder>/<account>-contacts once a day when the address book changed, only to a group the account is alone in, checked again at every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js210
1 files changed, 210 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
new file mode 100644
index 0000000..7df83b9
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/phone-sync-settings.js
@@ -0,0 +1,210 @@
+// The contacts section of the Android Sync page, on the Android application
+// only (docs/MESHBAY_DESIGN.md §9.13). Loaded on demand: no other client has it.
+
+import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { ask } from './ask.js';
+import {
+ KINDS, subscribe, runDocSync, privateGroups, probeWritable, failureText,
+} from './phone-sync.js';
+
+// What each kind says in its own words; everything else is shared.
+const TEXT = {
+ contacts: {
+ intro: 'contactsync.intro',
+ permission: 'contactsync.permission_needed',
+ confirm: 'contactsync.confirm',
+ },
+};
+
+function stateLine(st, status) {
+ switch (st.phase) {
+ case 'connecting': return t('photosync.state_connecting');
+ case 'sending': return t('phonesync.state_sending');
+ case 'offline': return t('photosync.state_offline');
+ case 'interrupted': return t('photosync.state_interrupted');
+ case 'up_to_date': return t('photosync.state_up_to_date');
+ default: return status && status.lastCompleted ? t('photosync.state_up_to_date') : '';
+ }
+}
+
+export function DocSyncSection({ kind, user, groups }) {
+ const { sync, heading } = KINDS[kind];
+ const [status, setStatus] = useState(null);
+ const [run, setRun] = useState({ phase: 'idle' });
+ const [editing, setEditing] = useState(false);
+ const [error, setError] = useState('');
+
+ const refresh = useCallback(() => sync.status().then(setStatus).catch(() => {}), [sync]);
+ useEffect(() => { refresh(); }, [refresh]);
+ useEffect(() => subscribe(kind, (st) => {
+ setRun(st);
+ if (st.phase !== 'sending' && st.phase !== 'connecting') refresh();
+ }), [kind, refresh]);
+
+ if (!status) return null;
+ const cfg = status.config;
+ const busy = run.phase === 'sending' || run.phase === 'connecting';
+
+ const backUpNow = async () => {
+ setError('');
+ try { await runDocSync(kind, { force: true }); } catch (e) { setError(e.message); }
+ };
+
+ const turnOff = async () => {
+ setError('');
+ try { setStatus(await sync.configure(null)); } catch (e) { setError(e.message); }
+ };
+
+ return html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t(heading)}</h3>
+ ${!cfg && !editing && html`
+ <p class="settings-hint">${t(TEXT[kind].intro)}</p>
+ <button class="admin-btn" onClick=${() => setEditing(true)}>${t('photosync.setup')}</button>
+ `}
+ ${cfg && !editing && html`
+ <div class="settings-row">
+ <span class="settings-label settings-name">
+ ${t('phonesync.summary', { group: cfg.groupName, dir: status.dir })}
+ </span>
+ </div>
+ ${cfg.account !== user.username && html`
+ <p class="settings-hint">${t('photosync.other_account', { account: cfg.account })}</p>`}
+ <p class="settings-hint">
+ ${t('phonesync.copies_sent', { n: status.sent })}
+ ${' · '}
+ ${status.lastCompleted
+ ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() })
+ : t('photosync.never')}
+ </p>
+ ${status.lastSent && html`
+ <p class="settings-hint settings-name">${t('phonesync.last_file', { name: status.lastSent })}</p>`}
+ ${status.failure
+ ? html`<p class="error-msg">${failureText(status.failure)}</p>`
+ : stateLine(run, status) && html`<p class="settings-hint">${stateLine(run, status)}</p>`}
+ <div class="settings-row" style="gap:8px; justify-content:flex-start; flex-wrap:wrap">
+ <button class="admin-btn" disabled=${busy} onClick=${backUpNow}>${t('photosync.now')}</button>
+ <button class="btn-secondary" onClick=${() => setEditing(true)}>${t('photosync.change')}</button>
+ <button class="btn-secondary" onClick=${turnOff}>${t('photosync.turn_off')}</button>
+ </div>
+ `}
+ ${editing && html`
+ <${Setup} kind=${kind} user=${user} groups=${groups} status=${status}
+ onPermission=${setStatus}
+ onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runDocSync(kind); } }} />`}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}
+
+function Setup({ kind, user, groups, status, onPermission, onDone }) {
+ const { sync, suffix } = KINDS[kind];
+ const cfg = status.config && status.config.account === user.username ? status.config : null;
+ const [candidates, setCandidates] = useState(null); // null while the hub is asked
+ const [groupId, setGroupId] = useState(cfg ? cfg.groupId : '');
+ const [folders, setFolders] = useState(null);
+ const [probe, setProbe] = useState(''); // '', 'checking', 'offline'
+ const [folder, setFolder] = useState(cfg ? cfg.folder : '');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState('');
+ const permitted = status.permission === 'granted';
+
+ // Only groups this account is alone in: what is sent is readable by every
+ // member, and an address book or a conversation is not the group's.
+ useEffect(() => {
+ let gone = false;
+ privateGroups(groups).then((list) => {
+ if (gone) return;
+ setCandidates(list);
+ setGroupId((g) => (list.some((x) => x.id === g) ? g : ''));
+ });
+ return () => { gone = true; };
+ }, [groups]);
+
+ useEffect(() => {
+ setFolders(null);
+ if (!groupId) return undefined;
+ let gone = false;
+ setProbe('checking');
+ probeWritable(groupId).then((r) => {
+ if (gone) return;
+ setProbe('');
+ setFolders(r.folders);
+ setFolder((f) => (r.folders.includes(f) ? f : (r.folders[0] || '')));
+ }).catch(() => { if (!gone) setProbe('offline'); });
+ return () => { gone = true; };
+ }, [groupId]);
+
+ const allow = async () => {
+ setError('');
+ try { onPermission(await sync.permit()); } catch (e) { setError(e.message); }
+ };
+
+ const dir = folder ? `${folder}/${user.username}-${suffix}` : '';
+
+ const start = async () => {
+ const group = (candidates || []).find((g) => g.id === groupId);
+ if (!group || !folder) return;
+ setBusy(true);
+ setError('');
+ try {
+ if (!cfg || cfg.groupId !== groupId || cfg.folder !== folder) {
+ const yes = await ask(t(TEXT[kind].confirm, { group: group.name, dir }));
+ if (!yes) { setBusy(false); return; }
+ }
+ onDone(await sync.configure({
+ account: user.username, groupId, groupName: group.name,
+ owner: group.owner_username || '', folder,
+ }));
+ } catch (e) {
+ setError(e.message);
+ setBusy(false);
+ }
+ };
+
+ return html`
+ <div>
+ ${!permitted && html`
+ <p class="settings-hint">${t(TEXT[kind].permission)}</p>
+ <div class="settings-row" style="gap:8px; justify-content:flex-start">
+ <button class="admin-btn" onClick=${allow}>${t('photosync.permission_allow')}</button>
+ <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>
+ </div>
+ `}
+ ${permitted && html`
+ ${candidates === null && html`<p class="settings-hint">${t('phonesync.checking_groups')}</p>`}
+ ${candidates && !candidates.length && html`
+ <p class="settings-hint">${t('phonesync.no_private_group')}</p>`}
+ ${candidates && candidates.length > 0 && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('photosync.group')}</span>
+ <select class="settings-select" value=${groupId}
+ onChange=${(e) => setGroupId(e.target.value)}>
+ <option value="">${t('photosync.choose_group')}</option>
+ ${candidates.map((g) => html`<option key=${g.id} value=${g.id}>${g.name}</option>`)}
+ </select>
+ </div>
+ `}
+ ${probe === 'checking' && html`<p class="settings-hint">${t('photosync.checking_group')}</p>`}
+ ${probe === 'offline' && html`<p class="settings-hint">${t('photosync.group_offline')}</p>`}
+ ${folders && !folders.length && html`<p class="settings-hint">${t('phonesync.no_folder')}</p>`}
+ ${folders && folders.length > 0 && html`
+ <div class="settings-row">
+ <span class="settings-label">${t('photosync.folder')}</span>
+ <select class="settings-select" value=${folder} onChange=${(e) => setFolder(e.target.value)}>
+ ${folders.map((f) => html`<option key=${f} value=${f}>${f}</option>`)}
+ </select>
+ </div>
+ <p class="settings-hint settings-name">${t('phonesync.destination', { dir })}</p>
+ `}
+ <div class="settings-row" style="gap:8px; justify-content:flex-start">
+ <button class="admin-btn" onClick=${start}
+ disabled=${busy || !groupId || !folder}>${t('photosync.start')}</button>
+ <button class="btn-secondary" onClick=${() => onDone(null)}>${t('dialog.cancel')}</button>
+ </div>
+ `}
+ ${error && html`<p class="error-msg">${error}</p>`}
+ </div>
+ `;
+}