// The "Photo backup" section of the Android Sync page, 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}
`}
`;
}