diff --git a/mobile-app/src/api/syncthing.ts b/mobile-app/src/api/syncthing.ts index 105419c..228315a 100644 --- a/mobile-app/src/api/syncthing.ts +++ b/mobile-app/src/api/syncthing.ts @@ -294,6 +294,16 @@ export class SyncthingClient { }>(`/rest/db/need?folder=${encodeURIComponent(folderId)}&page=${page}&perpage=${perpage}`); } + async dbRemoteNeed(folderId: string, deviceId: string, page = 1, perpage = 100) { + return this.request<{ + files: NeedFile[]; + page: number; + perpage: number; + }>( + `/rest/db/remoteneed?folder=${encodeURIComponent(folderId)}&device=${encodeURIComponent(deviceId)}&page=${page}&perpage=${perpage}`, + ); + } + systemDiscovery() { return this.request>('/rest/system/discovery'); } diff --git a/mobile-app/src/daemon/EventsContext.tsx b/mobile-app/src/daemon/EventsContext.tsx index ebc24ec..db7706e 100644 --- a/mobile-app/src/daemon/EventsContext.tsx +++ b/mobile-app/src/daemon/EventsContext.tsx @@ -59,12 +59,28 @@ export function EventsProvider({ children }: { children: React.ReactNode }) { const abort = new AbortController(); let cancelled = false; - let since = 0; const baseUrl = `http://${info.guiAddress}`; - const loop = async () => { + const dispatch = (events: SyncthingEvent[]) => { + // snapshot so a callback can unsubscribe itself mid-iteration + const snapshot = Array.from(subscribersRef.current); + for (const event of events) { + for (const sub of snapshot) { + if (sub.types.has(event.type)) { + try { + sub.callback(event); + } catch { + // one bad subscriber shouldn't kill the loop + } + } + } + } + }; + + const poll = async (path: string, isDisk: boolean) => { + let since = 0; while (!cancelled) { - const url = `${baseUrl}/rest/events?since=${since}&timeout=60`; + const url = `${baseUrl}${path}?since=${since}&timeout=60`; try { const res = await fetch(url, { headers: { 'X-API-Key': info.apiKey }, @@ -75,35 +91,23 @@ export function EventsProvider({ children }: { children: React.ReactNode }) { } const events = (await res.json()) as SyncthingEvent[]; if (cancelled) return; - if (!connected) setConnected(true); + if (!isDisk && !connected) setConnected(true); if (events.length > 0) { - const latest = events[events.length - 1].id; - since = latest; - setLastEventId(latest); - // snapshot so a callback can unsubscribe itself mid-iteration - const snapshot = Array.from(subscribersRef.current); - for (const event of events) { - for (const sub of snapshot) { - if (sub.types.has(event.type)) { - try { - sub.callback(event); - } catch { - // one bad subscriber shouldn't kill the loop - } - } - } - } + since = events[events.length - 1].id; + if (!isDisk) setLastEventId(since); + dispatch(events); } } catch (e) { if (cancelled) return; if (e instanceof Error && e.name === 'AbortError') return; - setConnected(false); + if (!isDisk) setConnected(false); await new Promise(resolve => setTimeout(resolve, 2000)); } } }; - loop(); + poll('/rest/events', false); + poll('/rest/events/disk', true); return () => { cancelled = true; diff --git a/mobile-app/src/daemon/RecentChangesContext.tsx b/mobile-app/src/daemon/RecentChangesContext.tsx index d28e644..c5ca07d 100644 --- a/mobile-app/src/daemon/RecentChangesContext.tsx +++ b/mobile-app/src/daemon/RecentChangesContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { useEvents } from './EventsContext'; -// ring buffer of ItemFinished events; subscribed at app root so we don't miss +// ring buffer of recent file changes; subscribed at app root so we don't miss // changes that happen while another screen is mounted. cap at 100. export interface RecentChange { @@ -14,6 +14,13 @@ export interface RecentChange { error: string | null; } +interface DiskChangeData { + folder?: string; + path?: string; + type?: string; + action?: string; // modified | deleted +} + interface ItemFinishedData { folder?: string; item?: string; @@ -30,29 +37,57 @@ interface RecentChangesContextValue { const Ctx = createContext(null); const MAX_ENTRIES = 100; +// disk events report "modified"/"deleted"; normalize to the update/delete +// vocabulary the rest of the UI (icons, labels) already speaks. +function normalizeAction(action: string | undefined): string { + if (action === 'deleted' || action === 'delete') return 'delete'; + if (action === 'metadata') return 'metadata'; + return 'update'; +} + export function RecentChangesProvider({ children }: { children: React.ReactNode }) { const { subscribe } = useEvents(); const [changes, setChanges] = useState([]); - useEffect(() => { - const unsubscribe = subscribe(['ItemFinished'], evt => { - const d = (evt.data ?? {}) as ItemFinishedData; - if (!d.folder || !d.item) return; - const next: RecentChange = { - id: evt.id, - time: evt.time, - folder: d.folder, - item: d.item, - type: d.type ?? 'file', - action: d.action ?? 'update', - error: d.error ? String(d.error) : null, - }; - setChanges(prev => { - const out = [next, ...prev]; - if (out.length > MAX_ENTRIES) out.length = MAX_ENTRIES; - return out; - }); + const push = (next: RecentChange) => + setChanges(prev => { + const out = [next, ...prev]; + if (out.length > MAX_ENTRIES) out.length = MAX_ENTRIES; + return out; }); + + useEffect(() => { + const unsubscribe = subscribe( + ['LocalChangeDetected', 'RemoteChangeDetected', 'ItemFinished'], + evt => { + if (evt.type === 'ItemFinished') { + const d = (evt.data ?? {}) as ItemFinishedData; + if (!d.folder || !d.item || !d.error) return; + push({ + id: evt.id, + time: evt.time, + folder: d.folder, + item: d.item, + type: d.type ?? 'file', + action: normalizeAction(d.action), + error: String(d.error), + }); + return; + } + + const d = (evt.data ?? {}) as DiskChangeData; + if (!d.folder || !d.path) return; + push({ + id: evt.id, + time: evt.time, + folder: d.folder, + item: d.path, + type: d.type ?? 'file', + action: normalizeAction(d.action), + error: null, + }); + }, + ); return unsubscribe; }, [subscribe]); diff --git a/mobile-app/src/screens/RecentChangesModal.tsx b/mobile-app/src/screens/RecentChangesModal.tsx index b91762d..5689fec 100644 --- a/mobile-app/src/screens/RecentChangesModal.tsx +++ b/mobile-app/src/screens/RecentChangesModal.tsx @@ -112,7 +112,7 @@ export function RecentChangesModal({ visible, onClose }: Props) { {changes.map(c => { const label = folderLabels[c.folder] || c.folder; return ( - + {iconFor(c.action, !!c.error)} diff --git a/mobile-app/src/screens/TransfersModal.tsx b/mobile-app/src/screens/TransfersModal.tsx index a69a995..0c05116 100644 --- a/mobile-app/src/screens/TransfersModal.tsx +++ b/mobile-app/src/screens/TransfersModal.tsx @@ -24,7 +24,7 @@ interface Props { interface TransferItem { folder: FolderConfig; file: NeedFile; - phase: 'downloading' | 'queued' | 'pending'; + phase: 'downloading' | 'queued' | 'pending' | 'uploading'; } export function TransfersModal({ visible, onClose }: Props) { @@ -38,7 +38,11 @@ export function TransfersModal({ visible, onClose }: Props) { if (isRefresh) setRefreshing(true); else setLoading(true); try { - const folders = await client.folders(); + const [folders, conns] = await Promise.all([ + client.folders(), + client.connections().catch(() => null), + ]); + const connected = conns?.connections ?? {}; const allItems: TransferItem[] = []; await Promise.all( folders.map(async folder => { @@ -56,6 +60,22 @@ export function TransfersModal({ visible, onClose }: Props) { } catch { // folder may be paused } + // Outgoing: files connected peers still need from us. db/need only + // covers what *we* need, so without this the view reads "all synced" + // whenever this device is the source of a transfer. + await Promise.all( + (folder.devices ?? []).map(async d => { + if (!connected[d.deviceID]?.connected) return; + try { + const remote = await client.dbRemoteNeed(folder.id, d.deviceID, 1, 50); + for (const f of (remote.files ?? []).slice(0, 20)) { + allItems.push({ folder, file: f, phase: 'uploading' }); + } + } catch { + // peer disconnected mid-poll, or folder not shared with it + } + }), + ); }), ); setItems(allItems); @@ -77,6 +97,7 @@ export function TransfersModal({ visible, onClose }: Props) { }, [visible, load]); const downloading = items.filter(i => i.phase === 'downloading'); + const uploading = items.filter(i => i.phase === 'uploading'); const queued = items.filter(i => i.phase === 'queued'); const pending = items.filter(i => i.phase === 'pending'); const totalNeeded = items.reduce((s, i) => s + (i.file.size ?? 0), 0); @@ -105,7 +126,7 @@ export function TransfersModal({ visible, onClose }: Props) { All synced - No files are waiting to download. + No files are transferring right now. ) : ( ({ type: 'item' as const, item: i })), + ...(uploading.length > 0 + ? [{ type: 'header' as const, label: `Uploading (${uploading.length})` }] + : []), + ...uploading.map(i => ({ type: 'item' as const, item: i })), ...(queued.length > 0 ? [{ type: 'header' as const, label: `Queued (${queued.length})` }] : []), @@ -162,7 +187,13 @@ export function TransfersModal({ visible, onClose }: Props) { - {phase === 'downloading' ? '↓' : phase === 'queued' ? '⏳' : '○'} + {phase === 'downloading' + ? '↓' + : phase === 'uploading' + ? '↑' + : phase === 'queued' + ? '⏳' + : '○'} @@ -179,6 +210,8 @@ function phaseStyle(phase: string) { switch (phase) { case 'downloading': return { backgroundColor: '#1a3a2a', borderColor: '#2a7a4a' }; + case 'uploading': + return { backgroundColor: '#16263f', borderColor: '#2a557a' }; case 'queued': return { backgroundColor: '#3a3020', borderColor: '#7a6830' }; default: @@ -190,6 +223,8 @@ function phaseTextStyle(phase: string) { switch (phase) { case 'downloading': return { color: '#4ade80' }; + case 'uploading': + return { color: '#60a5fa' }; case 'queued': return { color: '#e5a94b' }; default: