diff options
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.js | 210 |
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> + `; +} |