// The "Photo backup" section of Settings, on the Android application only // (docs/MESHBAY_DESIGN.md §9.12). Loaded on demand: no other client has it. import { html, useState, useEffect, useCallback } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import * as platform from './platform.js'; import { hubFetch } from './hub-client.js'; import { formatSize } from './file-utils.js'; import { ask } from './ask.js'; import { subscribe, runPhotoSync, probeGroup } from './photo-sync.js'; function stateLine(st, status) { switch (st.phase) { case 'waiting_wifi': return t('photosync.state_waiting_wifi'); case 'connecting': return t('photosync.state_connecting'); case 'sending': return t('photosync.state_sending', { done: st.done, total: st.total }); 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') : ''; } } const FAILURES = { disk_full: 'photosync.fail_disk_full', root_read_only: 'photosync.fail_read_only', no_writable_root: 'photosync.fail_read_only', no_such_root: 'photosync.fail_no_folder', no_such_directory: 'photosync.fail_no_folder', root_unavailable: 'photosync.fail_unavailable', not_a_member: 'photosync.fail_not_member', permission: 'photosync.fail_permission', }; export function PhotoSyncSection({ user, groups }) { const [status, setStatus] = useState(null); const [run, setRun] = useState({ phase: 'idle' }); const [editing, setEditing] = useState(false); const [error, setError] = useState(''); const refresh = useCallback(() => platform.photoSync.status().then(setStatus).catch(() => {}), []); useEffect(() => { refresh(); }, [refresh]); // A run that ends changes what the phone says (sent, last backup, a // failure); read it again then rather than on a timer. useEffect(() => subscribe((st) => { setRun(st); if (st.phase !== 'sending' && st.phase !== 'connecting') refresh(); }), [refresh]); if (!status) return null; const cfg = status.config; const backUpNow = async () => { setError(''); try { if (!status.unmetered) { const est = await platform.photoSync.estimate(cfg); if (est.count === 0) { await runPhotoSync({ force: true, metered: true }); return; } const yes = await ask(t('photosync.metered_confirm', { count: est.count, size: formatSize(est.bytes) })); if (!yes) return; await runPhotoSync({ force: true, metered: true }); } else { await runPhotoSync({ force: true }); } } catch (e) { setError(e.message); } }; const turnOff = async () => { setError(''); try { setStatus(await platform.photoSync.configure(null)); } catch (e) { setError(e.message); } }; return html`

${t('photosync.heading')}

${!cfg && !editing && html`

${t('photosync.intro')}

`} ${cfg && !editing && html`
${t('photosync.summary', { group: cfg.groupName, owner: cfg.owner, folder: cfg.folder })}
${cfg.account !== user.username && html`

${t('photosync.other_account', { account: cfg.account })}

`}

${t('photosync.sent_total', { n: status.sent })} ${' · '} ${status.lastCompleted ? t('photosync.last_run', { when: new Date(status.lastCompleted).toLocaleString() }) : t('photosync.never')}

${status.permission === 'partial' && html`

${t('photosync.permission_partial')}

`} ${status.failure ? html`

${t(FAILURES[status.failure] || 'photosync.fail_other')}

` : stateLine(run, status) && html`

${stateLine(run, status)}

`}
`} ${editing && html` <${Setup} user=${user} groups=${groups} status=${status} onPermission=${setStatus} onDone=${(next) => { setEditing(false); if (next) { setStatus(next); runPhotoSync(); } }} />`} ${error && html`

${error}

`}
`; } function Setup({ user, groups, status, onPermission, onDone }) { const cfg = status.config && status.config.account === user.username ? status.config : null; const [groupId, setGroupId] = useState(cfg ? cfg.groupId : ''); const [folders, setFolders] = useState(null); // null while unknown const [probe, setProbe] = useState(''); // '', 'checking', 'offline' const [folder, setFolder] = useState(cfg ? cfg.folder : ''); const [albums, setAlbums] = useState(null); const [chosen, setChosen] = useState(() => new Set(cfg ? cfg.albums : [])); const [existing, setExisting] = useState(cfg ? cfg.includeExisting : true); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const permitted = status.permission !== 'denied'; useEffect(() => { if (!permitted) return; platform.photoSync.albums().then((list) => { setAlbums(list); // The camera, and only the camera, unless the person chose before: // screenshots and saved images are where photos nobody meant to share live. if (!cfg) setChosen(new Set(list.filter((a) => a.camera).map((a) => a.id))); }).catch((e) => setError(e.message)); }, [permitted]); useEffect(() => { setFolders(null); if (!groupId) return; let gone = false; setProbe('checking'); probeGroup(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 platform.photoSync.permit()); } catch (e) { setError(e.message); } }; const toggle = (id) => setChosen((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const start = async () => { const group = groups.find((g) => g.id === groupId); if (!group || !folder || !chosen.size) return; setBusy(true); setError(''); try { const settings = { account: user.username, groupId, groupName: group.name, owner: group.owner_username || '', folder, albums: [...chosen], includeExisting: existing, }; // Asked when the photos would go somewhere new, or from somewhere new in // time — never for a change of albums alone, which the person just made. const same = cfg && cfg.groupId === groupId && cfg.folder === folder && cfg.includeExisting === existing; if (!same) { const [est, members] = await Promise.all([ platform.photoSync.estimate(settings), hubFetch(`/v1/groups/${groupId}/members`, { token: user.token }) .then((r) => (r.members || []).length).catch(() => null), ]); const params = { group: group.name, owner: group.owner_username || '', folder, members: members == null ? '?' : members, count: est.count, size: formatSize(est.bytes), }; const yes = await ask(t(existing && est.count > 0 ? 'photosync.confirm' : 'photosync.confirm_new', params)); if (!yes) { setBusy(false); return; } } onDone(await platform.photoSync.configure(settings)); } catch (e) { setError(e.message); setBusy(false); } }; return html`
${!permitted && html`

${t('photosync.permission_needed')}

`} ${status.permission === 'partial' && html`

${t('photosync.permission_partial')}

`} ${permitted && html`
${t('photosync.group')}
${probe === 'checking' && html`

${t('photosync.checking_group')}

`} ${probe === 'offline' && html`

${t('photosync.group_offline')}

`} ${folders && !folders.length && html`

${t('photosync.no_folder')}

`} ${folders && folders.length > 0 && html`
${t('photosync.folder')}
`}

${t('photosync.albums')}

${(albums || []).map((a) => html` `)}
`} ${!permitted && html` `} ${error && html`

${error}

`}
`; }