Skip to content

Commit 1805083

Browse files
Programcodeclaude
andcommitted
Build the Library tab with links, notes and source details
The Library lists the Current Debate's sources with search and type filters. The plus menu pastes a link through the import-source Edge Function or writes a note, saved with the website's columns and folder. A row opens the source read-only, with its page in the in-app browser, and swiping it left deletes it after a confirmation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9de0058 commit 1805083

20 files changed

Lines changed: 1162 additions & 7 deletions

‎apps/mobile/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"expo-splash-screen": "~57.0.9",
2828
"expo-status-bar": "~57.0.1",
2929
"expo-system-ui": "~57.0.4",
30+
"expo-web-browser": "~57.0.3",
3031
"i18next": "^23.16.8",
3132
"react": "19.2.3",
3233
"react-dom": "19.2.3",
Lines changed: 258 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,260 @@
1-
import { PlaceholderScreen } from '@/components/placeholder-screen';
1+
import { useFocusEffect, useRouter } from 'expo-router';
2+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, View } from 'react-native';
5+
import ReanimatedSwipeable, { type SwipeableMethods } from 'react-native-gesture-handler/ReanimatedSwipeable';
26

3-
export default function Screen() {
4-
return <PlaceholderScreen tab="library" />;
7+
import { TextButton } from '@/components/buttons';
8+
import { Card, IconTile } from '@/components/card';
9+
import { Chip } from '@/components/chip';
10+
import { Icon } from '@/components/icon';
11+
import { LibraryHeader, type AddSourceKind } from '@/components/library-header';
12+
import { Screen } from '@/components/screen';
13+
import { SearchField } from '@/components/search-field';
14+
import { SOURCE_ICONS, sourceMeta } from '@/components/source-display';
15+
import { Text } from '@/components/text';
16+
import { useAuth } from '@/lib/auth';
17+
import { confirmDestructive } from '@/lib/confirm';
18+
import { useDebates } from '@/lib/debate-context';
19+
import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources';
20+
import { useTheme } from '@/theme/use-theme';
21+
22+
type Filter = 'all' | SourceType;
23+
24+
// In the mock's order. Files are listed read-only: the app cannot add them yet.
25+
const FILTERS: { value: Filter; label: string }[] = [
26+
{ value: 'all', label: 'mobile.library.filters.all' },
27+
{ value: 'file', label: 'mobile.library.filters.files' },
28+
{ value: 'url', label: 'mobile.library.filters.links' },
29+
{ value: 'text', label: 'mobile.library.filters.notes' },
30+
];
31+
32+
function matches(source: Source, query: string): boolean {
33+
// The website searches titles, text and origins.
34+
const needle = query.trim().toLocaleLowerCase();
35+
return !needle || [source.title, source.content, source.origin ?? ''].some((value) => value.toLocaleLowerCase().includes(needle));
36+
}
37+
38+
/** The Current Debate's sources, as in mock 04. It shows the Current Debate even in one-off mode, like the website. */
39+
export default function LibraryScreen() {
40+
const { t } = useTranslation();
41+
const router = useRouter();
42+
const { status, current } = useDebates();
43+
const debateId = current?.id ?? null;
44+
45+
const onAdd = (kind: AddSourceKind) => router.push(kind === 'link' ? '/add-link' : '/new-note');
46+
47+
return (
48+
<Screen>
49+
<LibraryHeader title={t('mobile.tabs.library')} onAdd={onAdd} disabled={!debateId} />
50+
{status === 'loading' ? <ActivityIndicator style={styles.loading} /> : null}
51+
{status !== 'loading' && !debateId ? (
52+
<Card>
53+
<Text tone="secondary" style={styles.body}>
54+
{t('debate.libraryNeedsDebate')}
55+
</Text>
56+
<TextButton label={t('debate.switch')} onPress={() => router.push('/debates')} />
57+
</Card>
58+
) : null}
59+
{debateId ? <SourceList key={debateId} debateId={debateId} /> : null}
60+
</Screen>
61+
);
62+
}
63+
64+
function SourceList({ debateId }: { debateId: string }) {
65+
const { t } = useTranslation();
66+
const router = useRouter();
67+
const { session } = useAuth();
68+
const { colors } = useTheme();
69+
const [sources, setSources] = useState<Source[] | null>(null);
70+
const [failed, setFailed] = useState(false);
71+
const [deleteFailed, setDeleteFailed] = useState(false);
72+
const [query, setQuery] = useState('');
73+
const [filter, setFilter] = useState<Filter>('all');
74+
const loads = useRef(0);
75+
const userId = session?.user.id ?? null;
76+
77+
const load = useCallback(async () => {
78+
if (!userId) return;
79+
const load = ++loads.current;
80+
try {
81+
const next = await fetchSources(userId, debateId);
82+
if (load !== loads.current) return;
83+
setSources(next);
84+
setFailed(false);
85+
} catch (cause) {
86+
if (load !== loads.current) return;
87+
console.warn('Could not load sources:', cause instanceof Error ? cause.message : cause);
88+
setFailed(true);
89+
}
90+
}, [userId, debateId]);
91+
92+
// Sources added on the website show up when the tab comes back into view.
93+
useFocusEffect(
94+
useCallback(() => {
95+
void load();
96+
}, [load]),
97+
);
98+
useEffect(() => subscribeSourcesChanged(() => void load()), [load]);
99+
100+
const visible = useMemo(
101+
() => (sources ?? []).filter((source) => (filter === 'all' || source.type === filter) && matches(source, query)),
102+
[sources, filter, query],
103+
);
104+
105+
const remove = async (source: Source) => {
106+
if (!userId) return false;
107+
const confirmed = await confirmDestructive({
108+
title: t('libraryPage.deleteSourceTitle'),
109+
message: t('libraryPage.deleteSourceDesc'),
110+
confirm: t('common.delete'),
111+
cancel: t('common.cancel'),
112+
});
113+
if (!confirmed) return false;
114+
setDeleteFailed(false);
115+
try {
116+
await removeSource(userId, source.id);
117+
setSources((previous) => previous?.filter((item) => item.id !== source.id) ?? previous);
118+
return true;
119+
} catch (cause) {
120+
console.warn('Could not delete the source:', cause instanceof Error ? cause.message : cause);
121+
setDeleteFailed(true);
122+
return false;
123+
}
124+
};
125+
126+
if (failed && !sources) {
127+
return (
128+
<Card>
129+
<Text tone="secondary" style={styles.body}>
130+
{t('mobile.library.loadFailed')}
131+
</Text>
132+
<TextButton label={t('common.retry')} onPress={() => void load()} />
133+
</Card>
134+
);
135+
}
136+
if (!sources) return <ActivityIndicator style={styles.loading} accessibilityRole="progressbar" />;
137+
138+
return (
139+
<>
140+
<SearchField value={query} onChangeText={setQuery} placeholder={t('mobile.library.search', { count: sources.length })} />
141+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips} style={styles.chipRow}>
142+
{FILTERS.map((option) => (
143+
<Chip
144+
key={option.value}
145+
label={t(option.label)}
146+
selected={filter === option.value}
147+
accessibilityRole="tab"
148+
onPress={() => setFilter(option.value)}
149+
/>
150+
))}
151+
</ScrollView>
152+
153+
{deleteFailed ? (
154+
<Text accessibilityRole="alert" weight="medium" style={[styles.body, { color: colors.destructive }]}>
155+
{t('mobile.library.deleteFailed')}
156+
</Text>
157+
) : null}
158+
159+
{sources.length === 0 ? (
160+
<Card>
161+
<Text weight="semibold" style={styles.emptyTitle}>
162+
{t('libraryPage.emptyTitle')}
163+
</Text>
164+
<Text tone="secondary" style={styles.body}>
165+
{t('libraryPage.emptyDesc')}
166+
</Text>
167+
</Card>
168+
) : visible.length === 0 ? (
169+
<Card>
170+
<Text weight="semibold" style={styles.emptyTitle}>
171+
{t('libraryPage.noMatchingSources')}
172+
</Text>
173+
<Text tone="secondary" style={styles.body}>
174+
{t('libraryPage.noMatchingSourcesDesc')}
175+
</Text>
176+
</Card>
177+
) : (
178+
<Card style={styles.list}>
179+
{visible.map((source, index) => (
180+
<SourceRow
181+
key={source.id}
182+
source={source}
183+
first={index === 0}
184+
onOpen={() => router.push({ pathname: '/source/[id]', params: { id: source.id } })}
185+
onDelete={() => remove(source)}
186+
/>
187+
))}
188+
</Card>
189+
)}
190+
</>
191+
);
5192
}
193+
194+
/** A source row; swipe left to delete, with a confirmation, as on iOS. */
195+
function SourceRow({ source, first, onOpen, onDelete }: { source: Source; first: boolean; onOpen: () => void; onDelete: () => Promise<boolean> }) {
196+
const { t } = useTranslation();
197+
const { colors } = useTheme();
198+
const swipeable = useRef<SwipeableMethods>(null);
199+
200+
const remove = async () => {
201+
const removed = await onDelete();
202+
if (!removed) swipeable.current?.close();
203+
};
204+
205+
return (
206+
<ReanimatedSwipeable
207+
ref={swipeable}
208+
friction={2}
209+
rightThreshold={40}
210+
overshootRight={false}
211+
containerStyle={!first && { borderTopWidth: 1, borderTopColor: colors.line }}
212+
renderRightActions={() => (
213+
<Pressable
214+
accessibilityRole="button"
215+
accessibilityLabel={t('libraryPage.deleteSourceLabel', { title: source.title })}
216+
onPress={() => void remove()}
217+
style={[styles.deleteAction, { backgroundColor: colors.destructive }]}>
218+
<Icon name="trash" color="#ffffff" size={20} />
219+
<Text weight="semibold" style={styles.deleteLabel}>
220+
{t('common.delete')}
221+
</Text>
222+
</Pressable>
223+
)}>
224+
<Pressable
225+
accessibilityRole="button"
226+
accessibilityActions={[{ name: 'delete', label: t('common.delete') }]}
227+
onAccessibilityAction={(event) => {
228+
if (event.nativeEvent.actionName === 'delete') void remove();
229+
}}
230+
onPress={onOpen}
231+
style={({ pressed }) => [styles.row, { backgroundColor: colors.card }, pressed && styles.pressed]}>
232+
<IconTile name={SOURCE_ICONS[source.type]} size={42} />
233+
<View style={styles.rowText}>
234+
<Text weight="semibold" style={styles.rowTitle} numberOfLines={1}>
235+
{source.title || t('libraryPage.untitledSource')}
236+
</Text>
237+
<Text tone="faint" style={styles.rowMeta} numberOfLines={1}>
238+
{sourceMeta(t, source)}
239+
</Text>
240+
</View>
241+
</Pressable>
242+
</ReanimatedSwipeable>
243+
);
244+
}
245+
246+
const styles = StyleSheet.create({
247+
loading: { marginTop: 32 },
248+
body: { fontSize: 15, lineHeight: 21 },
249+
emptyTitle: { fontSize: 17 },
250+
chipRow: { marginHorizontal: -20, flexGrow: 0 },
251+
chips: { gap: 8, paddingHorizontal: 20 },
252+
list: { padding: 0, gap: 0, overflow: 'hidden' },
253+
row: { flexDirection: 'row', alignItems: 'center', gap: 13, paddingVertical: 12, paddingHorizontal: 14 },
254+
rowText: { flex: 1, gap: 2 },
255+
rowTitle: { fontSize: 16 },
256+
rowMeta: { fontSize: 14 },
257+
pressed: { opacity: 0.7 },
258+
deleteAction: { width: 92, alignItems: 'center', justifyContent: 'center', gap: 3 },
259+
deleteLabel: { color: '#ffffff', fontSize: 13 },
260+
});

‎apps/mobile/src/app/_layout.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import type { NativeStackNavigationOptions } from 'expo-router/native-stack';
66
import * as SplashScreen from 'expo-splash-screen';
77
import { StatusBar } from 'expo-status-bar';
88
import { useEffect } from 'react';
9-
import { StyleSheet, View } from 'react-native';
9+
import { StyleSheet } from 'react-native';
10+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
1011

1112
import { AuthProvider, useAuth } from '@/lib/auth';
1213
import { DebateProvider } from '@/lib/debate-context';
@@ -67,20 +68,23 @@ function Root() {
6768
return (
6869
<ThemeProvider value={navigationTheme}>
6970
<StatusBar style={scheme === 'dark' ? 'light' : 'dark'} />
70-
{/* The screenshot script waits for auth-settled before it checks an offline cold start. */}
71-
<View style={styles.root} testID={settled ? 'auth-settled' : 'auth-pending'}>
71+
{/* The screenshot script waits for auth-settled before it checks an offline cold start. Swipeable rows need the gesture root. */}
72+
<GestureHandlerRootView style={styles.root} testID={settled ? 'auth-settled' : 'auth-pending'}>
7273
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
7374
<Stack.Protected guard={session !== null}>
7475
<Stack.Screen name="(tabs)" />
7576
<Stack.Screen name="debates" options={sheet} />
7677
<Stack.Screen name="new-debate" options={sheet} />
7778
<Stack.Screen name="delete-account" options={sheet} />
79+
<Stack.Screen name="add-link" options={sheet} />
80+
<Stack.Screen name="new-note" options={sheet} />
81+
<Stack.Screen name="source/[id]" options={sheet} />
7882
</Stack.Protected>
7983
<Stack.Protected guard={session === null}>
8084
<Stack.Screen name="sign-in" />
8185
</Stack.Protected>
8286
</Stack>
83-
</View>
87+
</GestureHandlerRootView>
8488
</ThemeProvider>
8589
);
8690
}

0 commit comments

Comments
 (0)