634 lines
21 KiB
JavaScript
634 lines
21 KiB
JavaScript
import { readDeviceInfo } from './deviceInfo';
|
|
import { PASSWORD_MIN_LENGTH } from '@hop/shared';
|
|
import { createRefreshQueue } from './refreshQueue';
|
|
import { ref, watch } from 'vue';
|
|
import { io } from 'socket.io-client';
|
|
export const account = ref(null),
|
|
registrationOpen = ref(false),
|
|
passwordMinLength = ref(PASSWORD_MIN_LENGTH);
|
|
export const refreshAccount = createRefreshQueue(async () => {
|
|
const result = await api('/auth/me');
|
|
account.value = result.account;
|
|
registrationOpen.value = result.registrationOpen;
|
|
passwordMinLength.value = result.passwordMinLength ?? PASSWORD_MIN_LENGTH;
|
|
});
|
|
export function reconnect() {
|
|
connect();
|
|
}
|
|
export const device = ref(null),
|
|
csrfToken = ref(''),
|
|
sessionExpiresAt = ref(0),
|
|
connected = ref(false),
|
|
peers = ref([]),
|
|
requests = ref([]),
|
|
items = ref([]),
|
|
spaces = ref([]),
|
|
discoveryEnabled = ref(false),
|
|
error = ref('');
|
|
export const pendingPeers = ref([]);
|
|
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
|
|
export const transferConnecting = ref(false);
|
|
let pendingTimer;
|
|
let deviceInfo = {};
|
|
export const nextCursor = ref(null);
|
|
export const clipboardTextLimitBytes = ref(262144);
|
|
export const spaceInviteCodeLength = ref(4);
|
|
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
|
|
export async function publishLocation(position) {
|
|
if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.');
|
|
const result = await socket.timeout(5000).volatile.emitWithAck('location:update', position);
|
|
if (!result?.ok) throw Error(result?.message ?? '위치를 확인하지 못했습니다.');
|
|
}
|
|
export function clearLocation() {
|
|
if (socket?.connected) socket.emit('location:clear');
|
|
}
|
|
export function reserveLocationAcceptance(requestId) {
|
|
if (socket?.connected) socket.emit('location:accept', { requestId });
|
|
}
|
|
let socket;
|
|
let reauthorizing;
|
|
export async function waitForConnection() {
|
|
if (socket?.connected) return;
|
|
if (!socket) throw Error('서버 연결을 확인해 주세요.');
|
|
await new Promise((resolve, reject) => {
|
|
const stop = watch(connected, (ready) => {
|
|
if (!ready) return;
|
|
clearTimeout(timer);
|
|
stop();
|
|
resolve();
|
|
});
|
|
const timer = setTimeout(() => {
|
|
stop();
|
|
reject(Error('서버에 다시 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'));
|
|
}, 5000);
|
|
socket.connect();
|
|
});
|
|
}
|
|
export async function requestTransfer(path, input) {
|
|
const key = crypto.randomUUID(),
|
|
owner = device.value?.id;
|
|
try {
|
|
transferConnecting.value = !socket?.connected;
|
|
await waitForConnection();
|
|
const deadline = Date.now() + 5000;
|
|
while (true) {
|
|
if (device.value?.id !== owner)
|
|
throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.');
|
|
try {
|
|
return await api(path, 'POST', input, key);
|
|
} catch (error) {
|
|
// Only a known, still-authorized peer in its reconnect grace period is retried.
|
|
if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error;
|
|
transferConnecting.value = true;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
}
|
|
} finally {
|
|
transferConnecting.value = false;
|
|
}
|
|
}
|
|
const drafts = new Map();
|
|
const completing = new Set();
|
|
export async function api(path, method = 'GET', body, key, signal) {
|
|
const response = await fetch(`/api${path}`, {
|
|
method,
|
|
signal,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
...(method !== 'GET' ? { 'X-CSRF-Token': csrfToken.value } : {}),
|
|
...(key ? { 'Idempotency-Key': key } : {}),
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
const payload = await response.json();
|
|
if (!response.ok) {
|
|
const retryAfterSeconds = Number(
|
|
payload.retryAfterSeconds ?? response.headers.get('Retry-After'),
|
|
);
|
|
if (
|
|
response.status === 429 &&
|
|
retryAfterSeconds > 0 &&
|
|
Number.isFinite(retryAfterSeconds) &&
|
|
method === 'POST' &&
|
|
(path === '/transfer-requests' || /^\/vault\/[^/]+\/send$/.test(path))
|
|
) {
|
|
transferCooldown.value = {
|
|
until: Date.now() + Math.ceil(retryAfterSeconds) * 1000,
|
|
targetDeviceId: payload.limitScope === 'pair' ? body?.targetDeviceId : null,
|
|
};
|
|
}
|
|
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
|
|
status: response.status,
|
|
code: payload.code,
|
|
retryAfterSeconds,
|
|
});
|
|
}
|
|
return payload;
|
|
}
|
|
export async function bootstrap() {
|
|
const init = async () => {
|
|
const s = await api('/guest/session', 'POST', {});
|
|
device.value = s.device;
|
|
csrfToken.value = s.csrfToken;
|
|
sessionExpiresAt.value = s.expiresAt;
|
|
// An open site participates in discovery, including sessions that opted out in older UI.
|
|
if (!s.device.discoverable) {
|
|
await api('/device/me', 'PATCH', { discoverable: true });
|
|
device.value = { ...s.device, discoverable: true };
|
|
}
|
|
};
|
|
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
|
|
else await init();
|
|
await refreshAccount();
|
|
deviceInfo = await readDeviceInfo();
|
|
connect();
|
|
await refresh();
|
|
startReconciliation();
|
|
}
|
|
function connect() {
|
|
socket?.disconnect();
|
|
socket = io({ auth: { csrfToken: csrfToken.value, deviceInfo }, reconnection: true });
|
|
socket.on('connect', () => {
|
|
connected.value = true;
|
|
void refresh().catch(showError);
|
|
});
|
|
socket.on('disconnect', () => {
|
|
connected.value = false;
|
|
});
|
|
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
|
|
socket.on('receipt:skipped', (event) => {
|
|
if (event.accountId === account.value?.id)
|
|
window.dispatchEvent(new Event('hop-receipt-skipped'));
|
|
});
|
|
socket.on('account:revoked', () => {
|
|
clearAccountUploads(account.value?.id);
|
|
account.value = null;
|
|
window.dispatchEvent(new Event('hop-account-revoked'));
|
|
});
|
|
socket.on('connect_error', (err) => {
|
|
connected.value = false;
|
|
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
|
|
reauthorizing = (async () => {
|
|
const session = await api('/guest/session', 'POST', {});
|
|
device.value = session.device;
|
|
csrfToken.value = session.csrfToken;
|
|
sessionExpiresAt.value = session.expiresAt;
|
|
if (socket) {
|
|
socket.auth = { csrfToken: csrfToken.value, deviceInfo };
|
|
socket.connect();
|
|
}
|
|
})()
|
|
.catch(showError)
|
|
.finally(() => {
|
|
reauthorizing = undefined;
|
|
});
|
|
});
|
|
socket.on('device:updated', (event) => {
|
|
const known = new Set(peers.value.map((peer) => peer.id));
|
|
pendingPeers.value = (event?.devices ?? [])
|
|
.filter((peer) => !known.has(peer.id))
|
|
.map((peer) => peer.id);
|
|
clearTimeout(pendingTimer);
|
|
pendingTimer = setTimeout(() => {
|
|
pendingPeers.value = [];
|
|
}, 5000);
|
|
void refresh().catch(showError);
|
|
});
|
|
for (const event of [
|
|
'device:online',
|
|
'device:offline',
|
|
'space:updated',
|
|
'request:created',
|
|
'request:updated',
|
|
'transfer:updated',
|
|
'access:updated',
|
|
])
|
|
socket.on(event, () => void refresh().catch(showError));
|
|
}
|
|
let errorTimer;
|
|
export function showError(err) {
|
|
clearTimeout(errorTimer);
|
|
const raw = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
|
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
|
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
|
: raw;
|
|
const displayed =
|
|
err?.retryAfterSeconds > 0
|
|
? `${message} ${Math.ceil(err.retryAfterSeconds)}초 후 다시 시도할 수 있어요.`
|
|
: message;
|
|
error.value = displayed;
|
|
errorTimer = setTimeout(() => {
|
|
if (error.value === displayed) error.value = '';
|
|
}, 5000);
|
|
}
|
|
export const refresh = createRefreshQueue(async () => {
|
|
const snapshot = await api('/device/snapshot');
|
|
device.value = snapshot.device;
|
|
peers.value = snapshot.devices;
|
|
clipboardTextLimitBytes.value = snapshot.clipboardTextLimitBytes ?? 262144;
|
|
pendingPeers.value = pendingPeers.value.filter(
|
|
(id) => !snapshot.devices.some((peer) => peer.id === id),
|
|
);
|
|
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
|
|
spaceInviteCodeLength.value = snapshot.spaceInviteCodeLength ?? 4;
|
|
locationSettings.value = snapshot.locationDiscovery ?? {
|
|
enabled: false,
|
|
radiusMeters: 100,
|
|
refreshMs: 120000,
|
|
};
|
|
requests.value = snapshot.requests;
|
|
items.value = snapshot.items;
|
|
nextCursor.value = snapshot.nextCursor;
|
|
spaces.value = snapshot.spaces;
|
|
for (const req of requests.value) {
|
|
if (
|
|
req.kind === 'file' &&
|
|
req.status === 'accepted' &&
|
|
req.transferStatus === 'waiting' &&
|
|
fileDrafts.has(req.id) &&
|
|
!Object.keys(uploadProgress.value).some(
|
|
(k) => k.startsWith(req.id) && uploadProgress.value[k]?.status === 'failed',
|
|
)
|
|
)
|
|
void deliverFiles(req.id);
|
|
if (req.status === 'accepted' && req.transferStatus === 'waiting' && drafts.has(req.id))
|
|
void deliver(req.id);
|
|
if (
|
|
['rejected', 'expired', 'cancelled'].includes(req.status) ||
|
|
req.transferStatus === 'stored'
|
|
)
|
|
drafts.delete(req.id);
|
|
}
|
|
});
|
|
export async function send(targetDeviceId, kind, values) {
|
|
const content = kind === 'text' ? values.text : values.urls.join('\n');
|
|
const { requestId } = await requestTransfer('/transfer-requests', {
|
|
targetDeviceId,
|
|
kind,
|
|
byteSize: new TextEncoder().encode(content).length,
|
|
count: kind === 'text' ? 1 : values.urls.length,
|
|
});
|
|
drafts.set(requestId, { ...values, key: crypto.randomUUID() });
|
|
await refresh();
|
|
return requestId;
|
|
}
|
|
async function deliver(id) {
|
|
if (completing.has(id)) return;
|
|
const draft = drafts.get(id);
|
|
if (!draft) return;
|
|
completing.add(id);
|
|
try {
|
|
await api(
|
|
`/transfers/${id}/complete`,
|
|
'POST',
|
|
draft.text !== undefined ? { text: draft.text } : { urls: draft.urls },
|
|
draft.key,
|
|
);
|
|
drafts.delete(id);
|
|
await refresh();
|
|
return true;
|
|
} catch (err) {
|
|
showError(err);
|
|
return false;
|
|
} finally {
|
|
completing.delete(id);
|
|
}
|
|
}
|
|
export async function retryDelivery(id, values) {
|
|
drafts.set(id, { ...values, key: crypto.randomUUID() });
|
|
if (!(await deliver(id))) throw new Error(error.value || '전송을 완료하지 못했습니다.');
|
|
}
|
|
export async function requestAction(id, action) {
|
|
await api(
|
|
`/transfer-requests/${id}/${action}`,
|
|
'POST',
|
|
{},
|
|
action === 'accept' ? crypto.randomUUID() : undefined,
|
|
);
|
|
await refresh();
|
|
}
|
|
export async function patchDevice(input) {
|
|
await api('/device/me', 'PATCH', input);
|
|
await refresh();
|
|
}
|
|
export async function markRead(item, value = true) {
|
|
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
|
|
await refresh();
|
|
}
|
|
export async function loadMore() {
|
|
if (!nextCursor.value) return;
|
|
const result = await api(`/device/items?cursor=${encodeURIComponent(nextCursor.value)}`);
|
|
items.value.push(...result.items);
|
|
nextCursor.value = result.nextCursor;
|
|
}
|
|
export async function reset() {
|
|
const result = await api('/device/reset', 'POST', {});
|
|
drafts.clear();
|
|
fileDrafts.clear();
|
|
for (const [key, xhr] of uploadRequests) if (!uploadProgress.value[key]?.accountId) xhr.abort();
|
|
uploadProgress.value = Object.fromEntries(
|
|
Object.entries(uploadProgress.value).filter(([, p]) => p.accountId),
|
|
);
|
|
device.value = result.device;
|
|
csrfToken.value = result.csrfToken;
|
|
sessionExpiresAt.value = result.expiresAt;
|
|
connect();
|
|
await refresh();
|
|
}
|
|
export const uploadProgress = ref({});
|
|
const fileDrafts = new Map();
|
|
const fileSending = new Set();
|
|
const uploadRequests = new Map();
|
|
const cancelledTransfers = new Set();
|
|
export async function sendFiles(targetDeviceId, files) {
|
|
const input = {
|
|
targetDeviceId,
|
|
kind: 'file',
|
|
byteSize: files.reduce((n, f) => n + f.size, 0),
|
|
count: files.length,
|
|
files: files.map((f) => ({
|
|
name: f.name,
|
|
size: f.size,
|
|
type: f.type || 'application/octet-stream',
|
|
})),
|
|
};
|
|
const { requestId } = await requestTransfer('/transfer-requests', input);
|
|
fileDrafts.set(requestId, files);
|
|
await refresh();
|
|
return requestId;
|
|
}
|
|
const chunkBytes = 8 * 1024 * 1024;
|
|
async function fileFingerprint(file, signal) {
|
|
const digests = [];
|
|
for (let offset = 0; offset < file.size; offset += chunkBytes) {
|
|
signal.throwIfAborted();
|
|
const bytes = await file.slice(offset, offset + chunkBytes).arrayBuffer();
|
|
digests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
|
|
}
|
|
signal.throwIfAborted();
|
|
const combined = new Uint8Array(digests.length * 32);
|
|
digests.forEach((digest, index) => combined.set(digest, index * 32));
|
|
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', combined))]
|
|
.map((value) => value.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
export async function uploadFile(id, index, file, accountId) {
|
|
const progressKey = `${id}:${index}`,
|
|
controller = new AbortController();
|
|
uploadProgress.value[progressKey] = {
|
|
name: file.name,
|
|
index,
|
|
progress: 0,
|
|
status: 'uploading',
|
|
accountId,
|
|
};
|
|
uploadRequests.set(progressKey, controller);
|
|
const check = () => {
|
|
controller.signal.throwIfAborted();
|
|
if (cancelledTransfers.has(id)) throw Error('전송이 취소되었습니다.');
|
|
if (accountId && account.value?.id !== accountId) throw Error('계정 세션이 종료되었습니다.');
|
|
};
|
|
const sendChunk = (uploadId, offset) =>
|
|
new Promise((resolve, reject) => {
|
|
check();
|
|
const xhr = new XMLHttpRequest();
|
|
const abort = () => xhr.abort();
|
|
const finish = () => controller.signal.removeEventListener('abort', abort);
|
|
controller.signal.addEventListener('abort', abort, { once: true });
|
|
xhr.open('PUT', `/api/uploads/resumable/${uploadId}`);
|
|
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
|
|
xhr.setRequestHeader('Upload-Offset', String(offset));
|
|
xhr.upload.onprogress = (event) => {
|
|
if (uploadProgress.value[progressKey])
|
|
uploadProgress.value[progressKey].progress = Math.min(
|
|
99,
|
|
Math.round(((offset + event.loaded) / Math.max(1, file.size)) * 100),
|
|
);
|
|
};
|
|
xhr.onload = () => {
|
|
finish();
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(xhr.responseText);
|
|
} catch {
|
|
payload = {};
|
|
}
|
|
if (xhr.status >= 200 && xhr.status < 300) resolve(payload);
|
|
else
|
|
reject(
|
|
Object.assign(Error(payload.message || '파일 조각을 보내지 못했습니다.'), {
|
|
status: xhr.status,
|
|
code: payload.code,
|
|
}),
|
|
);
|
|
};
|
|
xhr.onerror = xhr.onabort = () => {
|
|
finish();
|
|
reject(Object.assign(Error('업로드 연결이 중단되었습니다.'), { status: 0 }));
|
|
};
|
|
xhr.send(file.slice(offset, offset + chunkBytes));
|
|
});
|
|
const retryable = (error) =>
|
|
error.status === 0 ||
|
|
error.status >= 500 ||
|
|
[408, 429].includes(error.status) ||
|
|
['UPLOAD_OFFSET', 'UPLOAD_ACTIVE'].includes(error.code);
|
|
try {
|
|
const fingerprint = await fileFingerprint(file, controller.signal);
|
|
check();
|
|
let state = await api(
|
|
'/uploads/resumable',
|
|
'POST',
|
|
{ requestId: id, fileIndex: index, fingerprint },
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
let failures = 0;
|
|
while (state.offset < file.size) {
|
|
check();
|
|
try {
|
|
state = await sendChunk(state.uploadId, state.offset);
|
|
failures = 0;
|
|
} catch (error) {
|
|
check();
|
|
if (!retryable(error) || ++failures > 3) throw error;
|
|
await new Promise((resolve) => setTimeout(resolve, failures * 400));
|
|
check();
|
|
state = await api(
|
|
`/uploads/resumable/${state.uploadId}`,
|
|
'GET',
|
|
undefined,
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
}
|
|
}
|
|
check();
|
|
if (state.status !== 'stored') {
|
|
try {
|
|
await api(
|
|
`/uploads/resumable/${state.uploadId}/complete`,
|
|
'POST',
|
|
{},
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
} catch (error) {
|
|
check();
|
|
const current = await api(
|
|
`/uploads/resumable/${state.uploadId}`,
|
|
'GET',
|
|
undefined,
|
|
undefined,
|
|
controller.signal,
|
|
);
|
|
if (current.status !== 'stored') throw error;
|
|
}
|
|
}
|
|
check();
|
|
uploadProgress.value[progressKey] = {
|
|
name: file.name,
|
|
index,
|
|
progress: 100,
|
|
status: 'stored',
|
|
accountId,
|
|
};
|
|
} catch (error) {
|
|
if (cancelledTransfers.has(id) || (accountId && account.value?.id !== accountId))
|
|
delete uploadProgress.value[progressKey];
|
|
else
|
|
uploadProgress.value[progressKey] = {
|
|
name: file.name,
|
|
index,
|
|
progress: 0,
|
|
status: 'failed',
|
|
accountId,
|
|
message: error.message || '업로드가 중단되었습니다.',
|
|
};
|
|
throw error;
|
|
} finally {
|
|
uploadRequests.delete(progressKey);
|
|
}
|
|
}
|
|
async function deliverFiles(id) {
|
|
if (fileSending.has(id)) return;
|
|
const files = fileDrafts.get(id),
|
|
request = requests.value.find((r) => r.id === id);
|
|
if (!files || !request) return;
|
|
fileSending.add(id);
|
|
try {
|
|
for (const [index, file] of files.entries()) {
|
|
if (cancelledTransfers.has(id)) break;
|
|
if (request.files?.[index]?.status === 'stored') continue;
|
|
try {
|
|
await uploadFile(id, index, file);
|
|
} catch (err) {
|
|
if (!cancelledTransfers.has(id)) showError(err);
|
|
}
|
|
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
|
|
}
|
|
if (
|
|
Object.entries(uploadProgress.value).some(
|
|
([key, value]) => key.startsWith(`${id}:`) && value.status === 'failed',
|
|
)
|
|
)
|
|
return;
|
|
await refresh();
|
|
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
|
|
} finally {
|
|
fileSending.delete(id);
|
|
}
|
|
}
|
|
export async function retryFiles(id, files) {
|
|
const r = requests.value.find((r) => r.id === id);
|
|
if (
|
|
!r?.files ||
|
|
r.files.length !== files.length ||
|
|
files.some((f, i) => f.name !== r.files[i].name || f.size !== r.files[i].size)
|
|
)
|
|
throw Error('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.');
|
|
fileDrafts.set(id, files);
|
|
await deliverFiles(id);
|
|
}
|
|
export function cancelUpload(key) {
|
|
uploadRequests.get(key)?.abort();
|
|
}
|
|
export async function cancelTransfer(id) {
|
|
// Cancel on the server before aborting uploads so a failed upload cannot restart delivery.
|
|
cancelledTransfers.add(id);
|
|
try {
|
|
await api(`/transfer-requests/${id}/cancel`, 'POST', {});
|
|
} catch (error) {
|
|
cancelledTransfers.delete(id);
|
|
throw error;
|
|
}
|
|
drafts.delete(id);
|
|
fileDrafts.delete(id);
|
|
for (const key of uploadRequests.keys()) {
|
|
if (key.startsWith(`${id}:`)) uploadRequests.get(key)?.abort();
|
|
}
|
|
for (const key of Object.keys(uploadProgress.value)) {
|
|
if (key.startsWith(`${id}:`)) delete uploadProgress.value[key];
|
|
}
|
|
await refresh();
|
|
}
|
|
export async function deleteItems(ids) {
|
|
const original = [...items.value];
|
|
items.value = items.value.filter((i) => !ids.includes(i.accessId));
|
|
try {
|
|
const result = await api('/access/delete-batch', 'POST', { ids });
|
|
await refresh();
|
|
return result;
|
|
} catch (err) {
|
|
items.value = original;
|
|
throw err;
|
|
}
|
|
}
|
|
export async function undoDelete(id) {
|
|
await api(`/deletion-operations/${id}/undo`, 'POST', {});
|
|
await refresh();
|
|
}
|
|
export async function download(item) {
|
|
try {
|
|
await api(`/access/${item.accessId}`);
|
|
} catch (err) {
|
|
await refresh().catch(() => {});
|
|
throw err;
|
|
}
|
|
const a = document.createElement('a');
|
|
a.href = `/api/access/${item.accessId}/download`;
|
|
a.download = item.file?.name ?? '';
|
|
document.body.append(a);
|
|
a.click();
|
|
a.remove();
|
|
await markRead(item);
|
|
}
|
|
export function clearAccountUploads(accountId) {
|
|
for (const [key, progress] of Object.entries(uploadProgress.value)) {
|
|
if (!progress.accountId || (accountId && progress.accountId !== accountId)) continue;
|
|
uploadRequests.get(key)?.abort();
|
|
delete uploadProgress.value[key];
|
|
}
|
|
}
|
|
let reconciliation;
|
|
function startReconciliation() {
|
|
if (reconciliation) return;
|
|
reconciliation = setInterval(() => {
|
|
if (
|
|
document.visibilityState !== 'visible' ||
|
|
!requests.value.some(
|
|
(r) => r.status === 'pending' || (r.status === 'accepted' && r.transferStatus !== 'stored'),
|
|
)
|
|
)
|
|
return;
|
|
void api('/transfer-requests')
|
|
.then((result) => {
|
|
const summary = (rs) => JSON.stringify(rs.map((r) => [r.id, r.status, r.transferStatus]));
|
|
if (summary(result.requests) !== summary(requests.value)) return refresh();
|
|
})
|
|
.catch(showError);
|
|
}, 5000);
|
|
}
|