Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions mobile-app/src/api/syncthing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, { addresses: string[] }>>('/rest/system/discovery');
}
Expand Down
48 changes: 26 additions & 22 deletions mobile-app/src/daemon/EventsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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;
Expand Down
73 changes: 54 additions & 19 deletions mobile-app/src/daemon/RecentChangesContext.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand All @@ -30,29 +37,57 @@ interface RecentChangesContextValue {
const Ctx = createContext<RecentChangesContextValue | null>(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<RecentChange[]>([]);

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]);

Expand Down
2 changes: 1 addition & 1 deletion mobile-app/src/screens/RecentChangesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function RecentChangesModal({ visible, onClose }: Props) {
{changes.map(c => {
const label = folderLabels[c.folder] || c.folder;
return (
<View key={c.id} style={styles.row}>
<View key={`${c.id}:${c.folder}:${c.item}`} style={styles.row}>
<Text style={[styles.rowIcon, c.error && styles.rowIconError]}>
{iconFor(c.action, !!c.error)}
</Text>
Expand Down
43 changes: 39 additions & 4 deletions mobile-app/src/screens/TransfersModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 => {
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -105,7 +126,7 @@ export function TransfersModal({ visible, onClose }: Props) {
<View style={styles.center}>
<Text style={styles.emptyIcon}>✓</Text>
<Text style={styles.emptyTitle}>All synced</Text>
<Text style={styles.emptyHint}>No files are waiting to download.</Text>
<Text style={styles.emptyHint}>No files are transferring right now.</Text>
</View>
) : (
<FlatList
Expand All @@ -114,6 +135,10 @@ export function TransfersModal({ visible, onClose }: Props) {
? [{ type: 'header' as const, label: `Downloading (${downloading.length})` }]
: []),
...downloading.map(i => ({ 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})` }]
: []),
Expand Down Expand Up @@ -162,7 +187,13 @@ export function TransfersModal({ visible, onClose }: Props) {
</View>
<View style={[styles.phaseBadge, phaseStyle(phase)]}>
<Text style={[styles.phaseText, phaseTextStyle(phase)]}>
{phase === 'downloading' ? '↓' : phase === 'queued' ? '⏳' : '○'}
{phase === 'downloading'
? '↓'
: phase === 'uploading'
? '↑'
: phase === 'queued'
? '⏳'
: '○'}
</Text>
</View>
</View>
Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading