Skip to content

Commit 9f21679

Browse files
committed
feat: Add single note sync functionality with overwrite warnings and UI integration
1 parent 868b4ad commit 9f21679

5 files changed

Lines changed: 482 additions & 4 deletions

File tree

frontend/apps/app/components/navigation/AppDrawer.tsx

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,30 @@ import {
66
} from "@gorhom/bottom-sheet"
77
import type { Note } from "@memoneo/shared"
88
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
9-
import { useAtom } from "jotai"
9+
import { useAtom, useAtomValue } from "jotai"
1010
import type React from "react"
1111
import { useCallback, useRef, useState } from "react"
1212
import { Alert, Dimensions, StyleSheet } from "react-native"
1313
import { Drawer } from "react-native-drawer-layout"
1414

15+
import { authAtom, tokenAtom } from "@/lib/auth/state"
1516
import { loadNoteCache } from "@/lib/notes/cache"
1617
import { deleteLocalNote } from "@/lib/notes/local"
17-
import { NOTES_CACHE_QUERY_KEY, NOTES_LOCAL_QUERY_KEY } from "@/lib/notes/query"
18+
import {
19+
NOTES_CACHE_QUERY_KEY,
20+
NOTES_FOLDERS_QUERY_KEY,
21+
NOTES_LOCAL_QUERY_KEY,
22+
} from "@/lib/notes/query"
1823
import {
1924
selectedNoteIdAtom,
2025
useNotesState,
2126
} from "@/lib/notes/state"
27+
import {
28+
syncLocalNote,
29+
uploadLocalNote,
30+
type SingleNoteOverwriteWarning,
31+
type SingleNoteSyncAction,
32+
} from "@/lib/notes/sync"
2233

2334
import { AppDrawerContext } from "./appDrawerContext"
2435
import { DrawerContent } from "./DrawerContent"
@@ -31,6 +42,8 @@ const DRAWER_WIDTH = Math.min(340, WINDOW_WIDTH * 0.86)
3142

3243
export function AppDrawer({ children }: { children: React.ReactNode }) {
3344
const queryClient = useQueryClient()
45+
const auth = useAtomValue(authAtom)
46+
const token = useAtomValue(tokenAtom)
3447
const [drawerOpen, setDrawerOpen] = useState(false)
3548
const [optionsNote, setOptionsNote] = useState<Note | null>(null)
3649
const [selectedNoteId, setSelectedNoteId] = useAtom(selectedNoteIdAtom)
@@ -85,6 +98,57 @@ export function AppDrawer({ children }: { children: React.ReactNode }) {
8598
},
8699
})
87100

101+
const singleNoteSyncMutation = useMutation({
102+
mutationFn: async ({
103+
action,
104+
note,
105+
}: {
106+
action: SingleNoteSyncAction
107+
note: Note
108+
}) => {
109+
if (!auth.isAuthenticated || !token) {
110+
throw new Error("Sign in from Settings before syncing notes.")
111+
}
112+
113+
const syncAuth = { token, userId: auth.user.id }
114+
const options = { confirmOverwrite: confirmSingleNoteOverwrite }
115+
116+
if (action === "upload") {
117+
return uploadLocalNote(syncAuth, note, options)
118+
}
119+
120+
return syncLocalNote(syncAuth, note, action, options)
121+
},
122+
onSuccess: async () => {
123+
await Promise.all([
124+
queryClient.invalidateQueries({ queryKey: NOTES_LOCAL_QUERY_KEY }),
125+
queryClient.invalidateQueries({ queryKey: NOTES_FOLDERS_QUERY_KEY }),
126+
queryClient.invalidateQueries({ queryKey: NOTES_CACHE_QUERY_KEY }),
127+
])
128+
await Promise.all([
129+
queryClient.refetchQueries({
130+
queryKey: NOTES_LOCAL_QUERY_KEY,
131+
type: "active",
132+
}),
133+
queryClient.refetchQueries({
134+
queryKey: NOTES_FOLDERS_QUERY_KEY,
135+
type: "active",
136+
}),
137+
queryClient.refetchQueries({
138+
queryKey: NOTES_CACHE_QUERY_KEY,
139+
type: "active",
140+
}),
141+
])
142+
closeNoteOptions()
143+
},
144+
onError: error => {
145+
Alert.alert(
146+
"Sync failed",
147+
error instanceof Error ? error.message : String(error)
148+
)
149+
},
150+
})
151+
88152
const confirmDeleteNote = useCallback(
89153
(note: Note) => {
90154
Alert.alert(
@@ -103,6 +167,17 @@ export function AppDrawer({ children }: { children: React.ReactNode }) {
103167
[deleteNoteMutation]
104168
)
105169

170+
const syncSingleNote = useCallback(
171+
(note: Note, action: SingleNoteSyncAction) => {
172+
if (singleNoteSyncMutation.isPending) {
173+
return
174+
}
175+
176+
singleNoteSyncMutation.mutate({ note, action })
177+
},
178+
[singleNoteSyncMutation]
179+
)
180+
106181
const renderBackdrop = useCallback(
107182
(props: BottomSheetBackdropProps) => (
108183
<BottomSheetBackdrop
@@ -146,14 +221,16 @@ export function AppDrawer({ children }: { children: React.ReactNode }) {
146221
backgroundStyle={styles.sheetBackground}
147222
handleIndicatorStyle={styles.sheetHandle}
148223
ref={noteOptionsSheetRef}
149-
snapPoints={["46%"]}>
224+
snapPoints={["64%"]}>
150225
<BottomSheetView style={styles.flex}>
151226
{optionsNote && (
152227
<NoteOptionsSheet
153228
isDeleting={deleteNoteMutation.isPending}
229+
isSyncing={singleNoteSyncMutation.isPending}
154230
lastSync={lastSync}
155231
note={optionsNote}
156232
onDelete={confirmDeleteNote}
233+
onSync={syncSingleNote}
157234
/>
158235
)}
159236
</BottomSheetView>
@@ -162,6 +239,23 @@ export function AppDrawer({ children }: { children: React.ReactNode }) {
162239
)
163240
}
164241

242+
function confirmSingleNoteOverwrite(warning: SingleNoteOverwriteWarning) {
243+
return new Promise<boolean>(resolve => {
244+
Alert.alert(warning.title, warning.message, [
245+
{
246+
text: "Cancel",
247+
style: "cancel",
248+
onPress: () => resolve(false),
249+
},
250+
{
251+
text: warning.confirmText,
252+
style: "destructive",
253+
onPress: () => resolve(true),
254+
},
255+
])
256+
})
257+
}
258+
165259
const styles = StyleSheet.create({
166260
drawer: {
167261
backgroundColor: "hsl(240 10% 3.9%)",

frontend/apps/app/components/navigation/NoteOptionsSheet.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
11
import type { Note } from "@memoneo/shared"
2-
import { FileText, Trash2 } from "lucide-react-native"
2+
import { FileText, RefreshCw, Trash2, Upload } from "lucide-react-native"
33
import { Pressable, View } from "react-native"
44

55
import { MText } from "@/components/reusables/MText"
66
import { cn } from "@/lib/reusables/utils"
7+
import type { SingleNoteSyncAction } from "@/lib/notes/sync"
78

89
type NoteOptionsSheetProps = {
910
isDeleting: boolean
11+
isSyncing: boolean
1012
lastSync?: string
1113
note: Note
1214
onDelete: (note: Note) => void
15+
onSync: (note: Note, action: SingleNoteSyncAction) => void
1316
}
1417

1518
export function NoteOptionsSheet({
1619
isDeleting,
20+
isSyncing,
1721
lastSync,
1822
note,
1923
onDelete,
24+
onSync,
2025
}: NoteOptionsSheetProps) {
2126
const canDelete = Boolean(note.file?.title)
27+
const canSync = note.id !== "unsaved" && Boolean(note.file?.title)
2228

2329
return (
2430
<View className="flex-1 gap-2 px-5 pb-6 pt-2">
@@ -46,6 +52,36 @@ export function NoteOptionsSheet({
4652
</MText>
4753
</View>
4854

55+
<View className="gap-2">
56+
<Pressable
57+
accessibilityRole="button"
58+
disabled={!canSync || isSyncing}
59+
onPress={() => onSync(note, "upload")}
60+
className={cn(
61+
"min-h-12 flex-row items-center justify-center gap-2 rounded-md border border-zinc-700 px-3.5",
62+
(!canSync || isSyncing) && "opacity-50"
63+
)}>
64+
<Upload size={18} color="#a1a1aa" />
65+
<MText className="text-[15px] font-bold text-zinc-100">
66+
{isSyncing ? "Syncing..." : "Upload note"}
67+
</MText>
68+
</Pressable>
69+
70+
<Pressable
71+
accessibilityRole="button"
72+
disabled={!canSync || isSyncing}
73+
onPress={() => onSync(note, "sync")}
74+
className={cn(
75+
"min-h-12 flex-row items-center justify-center gap-2 rounded-md border border-zinc-700 px-3.5",
76+
(!canSync || isSyncing) && "opacity-50"
77+
)}>
78+
<RefreshCw size={18} color="#a1a1aa" />
79+
<MText className="text-[15px] font-bold text-zinc-100">
80+
{isSyncing ? "Syncing..." : "Sync note"}
81+
</MText>
82+
</Pressable>
83+
</View>
84+
4985
<Pressable
5086
accessibilityRole="button"
5187
disabled={!canDelete || isDeleting}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { MarkdownFileInfo, Note } from "@memoneo/shared"
2+
import { describe, expect, it } from "vitest"
3+
4+
import {
5+
getDownloadOverwriteWarning,
6+
getUploadOverwriteWarning,
7+
} from "./syncWarnings"
8+
9+
function note(overrides: Partial<Note> = {}): Note {
10+
return {
11+
id: "note-1",
12+
user_id: "user-1",
13+
title: "Note",
14+
body: "",
15+
body_iv: "",
16+
date: "2026-01-01T00:00:00.000Z",
17+
archived: false,
18+
version: 1,
19+
created_at: "2026-01-01T00:00:00.000Z",
20+
updated_at: "2026-01-01T00:00:00.000Z",
21+
file: {
22+
note_id: "note-1",
23+
title: "Note",
24+
path: "",
25+
},
26+
...overrides,
27+
}
28+
}
29+
30+
function file(overrides: Partial<MarkdownFileInfo> = {}): MarkdownFileInfo {
31+
return {
32+
fileName: "Note",
33+
path: "",
34+
text: "body",
35+
modifiedTime: new Date("2026-01-01T00:00:00.000Z"),
36+
createdTime: new Date("2026-01-01T00:00:00.000Z"),
37+
metadata: {
38+
id: "note-1",
39+
title: "Note",
40+
date: "2026-01-01T00:00:00.000Z",
41+
version: 1,
42+
},
43+
...overrides,
44+
}
45+
}
46+
47+
describe("single note sync overwrite warnings", () => {
48+
it("warns before uploading over a newer remote version", () => {
49+
const warning = getUploadOverwriteWarning(
50+
note({ version: 2 }),
51+
file({ metadata: { id: "note-1", version: 1 } }),
52+
{ lastSync: "2026-01-01T00:00:00.000Z" }
53+
)
54+
55+
expect(warning?.title).toBe("Overwrite newer remote note?")
56+
})
57+
58+
it("warns before uploading when remote changed after the last sync", () => {
59+
const warning = getUploadOverwriteWarning(note(), file(), {
60+
lastSync: "2025-12-31T00:00:00.000Z",
61+
})
62+
63+
expect(warning?.confirmText).toBe("Upload anyway")
64+
})
65+
66+
it("does not warn for upload when local metadata has caught up", () => {
67+
const warning = getUploadOverwriteWarning(
68+
note({ version: 2, updated_at: "2026-01-02T00:00:00.000Z" }),
69+
file({ metadata: { id: "note-1", version: 2 } }),
70+
{ lastSync: "2026-01-02T00:00:00.000Z" }
71+
)
72+
73+
expect(warning).toBeNull()
74+
})
75+
76+
it("warns before downloading over a locally modified note", () => {
77+
const warning = getDownloadOverwriteWarning(
78+
note(),
79+
file({ modifiedTime: new Date("2026-01-03T00:00:00.000Z") }),
80+
{ lastSync: "2026-01-02T00:00:00.000Z" }
81+
)
82+
83+
expect(warning?.title).toBe("Overwrite newer local note?")
84+
})
85+
86+
it("does not warn for download when local file is older than remote and cache", () => {
87+
const warning = getDownloadOverwriteWarning(
88+
note({ updated_at: "2026-01-03T00:00:00.000Z" }),
89+
file({ modifiedTime: new Date("2026-01-02T00:00:00.000Z") }),
90+
{ lastSync: "2026-01-03T00:00:00.000Z" }
91+
)
92+
93+
expect(warning).toBeNull()
94+
})
95+
})

0 commit comments

Comments
 (0)