Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

NotificationsScreen.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

NotificationsScreen.tsxBlame342 lines · 1 contributor
9a8fbedClaude1import React, { useCallback, useEffect, useState } from 'react';
2import {
3 FlatList,
4 StyleSheet,
5 Text,
6 TouchableOpacity,
7 View,
8} from 'react-native';
9import { SafeAreaView } from 'react-native-safe-area-context';
10import {
11 listNotifications,
12 markNotificationRead,
13 markAllRead,
14} from '../api/notifications';
15import type { Notification } from '../api/notifications';
16import { LoadingSpinner } from '../components/LoadingSpinner';
17import { ErrorBanner } from '../components/ErrorBanner';
18import { colors } from '../theme/colors';
19
20function typeIcon(type: Notification['type']): string {
21 switch (type) {
22 case 'issue_opened': return '○';
23 case 'issue_closed': return '●';
24 case 'issue_comment': return '💬';
25 case 'pr_opened': return '⑂';
26 case 'pr_merged': return '✓';
27 case 'pr_closed': return '✗';
28 case 'pr_comment': return '💬';
29 case 'pr_review': return '✦';
30 case 'push': return '↑';
31 case 'star': return '★';
32 case 'fork': return '⑂';
33 case 'gate_failed': return '✗';
34 case 'gate_passed': return '✓';
35 case 'mention': return '@';
36 default: return '•';
37 }
38}
39
40function typeColor(type: Notification['type']): string {
41 switch (type) {
42 case 'gate_failed':
43 case 'issue_closed':
44 case 'pr_closed': return colors.accentRed;
45 case 'gate_passed':
46 case 'pr_merged': return colors.accentPurple;
47 case 'pr_review': return colors.accentPurple;
48 case 'star': return colors.accentYellow;
49 default: return colors.accentBlue;
50 }
51}
52
53function formatRelative(dateStr: string): string {
54 const diff = Date.now() - new Date(dateStr).getTime();
55 const mins = Math.floor(diff / 60000);
56 if (mins < 1) return 'just now';
57 if (mins < 60) return `${mins}m ago`;
58 const hrs = Math.floor(mins / 60);
59 if (hrs < 24) return `${hrs}h ago`;
60 const days = Math.floor(hrs / 24);
61 if (days < 30) return `${days}d ago`;
62 return `${Math.floor(days / 30)}mo ago`;
63}
64
65interface NotifRowProps {
66 notif: Notification;
67 onRead: (id: string) => void;
68}
69
70function NotifRow({ notif, onRead }: NotifRowProps): React.ReactElement {
71 const handlePress = useCallback(() => {
72 if (!notif.isRead) onRead(notif.id);
73 }, [notif.id, notif.isRead, onRead]);
74
75 const iconColor = typeColor(notif.type);
76
77 return (
78 <TouchableOpacity
79 style={[styles.row, notif.isRead && styles.rowRead]}
80 onPress={handlePress}
81 activeOpacity={0.75}
82 >
83 <View style={[styles.iconContainer, { backgroundColor: iconColor + '22' }]}>
84 <Text style={[styles.icon, { color: iconColor }]}>{typeIcon(notif.type)}</Text>
85 </View>
86 <View style={styles.content}>
87 <Text style={styles.title} numberOfLines={2}>{notif.title}</Text>
88 {notif.body !== null && (
89 <Text style={styles.body} numberOfLines={1}>{notif.body}</Text>
90 )}
91 <View style={styles.meta}>
92 {notif.repoOwner !== null && notif.repoName !== null && (
93 <Text style={styles.repoText}>{notif.repoOwner}/{notif.repoName}</Text>
94 )}
95 <Text style={styles.time}>{formatRelative(notif.createdAt)}</Text>
96 </View>
97 </View>
98 {!notif.isRead && <View style={styles.unreadDot} />}
99 </TouchableOpacity>
100 );
101}
102
103type FilterState = 'unread' | 'all';
104
105export function NotificationsScreen(): React.ReactElement {
106 const [filter, setFilter] = useState<FilterState>('unread');
107 const [notifications, setNotifications] = useState<Notification[]>([]);
108 const [isLoading, setIsLoading] = useState(true);
109 const [error, setError] = useState<string | null>(null);
110 const [tick, setTick] = useState(0);
111
112 useEffect(() => {
113 let cancelled = false;
114 setIsLoading(true);
115 setError(null);
116
117 listNotifications(filter)
118 .then((data) => {
119 if (!cancelled) setNotifications(data);
120 })
121 .catch((err) => {
122 if (!cancelled) {
123 setError(err instanceof Error ? err.message : 'Failed to load notifications');
124 }
125 })
126 .finally(() => {
127 if (!cancelled) setIsLoading(false);
128 });
129
130 return () => {
131 cancelled = true;
132 };
133 }, [filter, tick]);
134
135 const handleRead = useCallback(async (id: string) => {
136 setNotifications((prev) =>
137 prev.map((n) => (n.id === id ? { ...n, isRead: true } : n)),
138 );
139 try {
140 await markNotificationRead(id);
141 } catch {/* ignore */}
142 }, []);
143
144 const handleMarkAllRead = useCallback(async () => {
145 setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
146 try {
147 await markAllRead();
148 setTick((t) => t + 1);
149 } catch {/* ignore */}
150 }, []);
151
152 const renderNotif = useCallback(
153 ({ item }: { item: Notification }) => (
154 <NotifRow notif={item} onRead={handleRead} />
155 ),
156 [handleRead],
157 );
158
159 const keyExtractor = useCallback((item: Notification) => item.id, []);
160 const unreadCount = notifications.filter((n) => !n.isRead).length;
161
162 if (isLoading && notifications.length === 0) return <LoadingSpinner fullScreen />;
163
164 return (
165 <SafeAreaView style={styles.safe} edges={['top', 'bottom']}>
166 {/* Header */}
167 <View style={styles.header}>
168 <Text style={styles.headerTitle}>Notifications</Text>
169 {unreadCount > 0 && (
170 <TouchableOpacity onPress={handleMarkAllRead}>
171 <Text style={styles.markAllText}>Mark all read</Text>
172 </TouchableOpacity>
173 )}
174 </View>
175
176 {/* Filter tabs */}
177 <View style={styles.filterTabs}>
178 <TouchableOpacity
179 style={[styles.filterTab, filter === 'unread' && styles.filterTabActive]}
180 onPress={() => setFilter('unread')}
181 >
182 <Text style={[styles.filterTabText, filter === 'unread' && styles.filterTabTextActive]}>
183 Unread
184 {unreadCount > 0 && ` (${unreadCount})`}
185 </Text>
186 </TouchableOpacity>
187 <TouchableOpacity
188 style={[styles.filterTab, filter === 'all' && styles.filterTabActive]}
189 onPress={() => setFilter('all')}
190 >
191 <Text style={[styles.filterTabText, filter === 'all' && styles.filterTabTextActive]}>
192 All
193 </Text>
194 </TouchableOpacity>
195 </View>
196
197 {error !== null && <ErrorBanner message={error} onRetry={() => setTick((t) => t + 1)} />}
198
199 <FlatList
200 data={notifications}
201 keyExtractor={keyExtractor}
202 renderItem={renderNotif}
203 style={styles.list}
204 ListEmptyComponent={
205 <View style={styles.emptyState}>
206 <Text style={styles.emptyIcon}>🔔</Text>
207 <Text style={styles.emptyText}>
208 {filter === 'unread' ? "You're all caught up!" : 'No notifications'}
209 </Text>
210 </View>
211 }
212 />
213 </SafeAreaView>
214 );
215}
216
217const styles = StyleSheet.create({
218 safe: {
219 flex: 1,
220 backgroundColor: colors.bg,
221 },
222 header: {
223 flexDirection: 'row',
224 alignItems: 'center',
225 justifyContent: 'space-between',
226 paddingHorizontal: 16,
227 paddingVertical: 14,
228 backgroundColor: colors.bgSecondary,
229 borderBottomWidth: 1,
230 borderBottomColor: colors.border,
231 },
232 headerTitle: {
233 fontSize: 18,
234 fontWeight: '700',
235 color: colors.text,
236 },
237 markAllText: {
238 fontSize: 13,
239 color: colors.textLink,
240 },
241 filterTabs: {
242 flexDirection: 'row',
243 backgroundColor: colors.bgSecondary,
244 borderBottomWidth: 1,
245 borderBottomColor: colors.border,
246 },
247 filterTab: {
248 flex: 1,
249 paddingVertical: 12,
250 alignItems: 'center',
251 borderBottomWidth: 2,
252 borderBottomColor: 'transparent',
253 },
254 filterTabActive: {
255 borderBottomColor: colors.accentBlue,
256 },
257 filterTabText: {
258 fontSize: 14,
259 fontWeight: '500',
260 color: colors.textMuted,
261 },
262 filterTabTextActive: {
263 color: colors.text,
264 },
265 list: {
266 flex: 1,
267 },
268 row: {
269 flexDirection: 'row',
270 alignItems: 'flex-start',
271 padding: 14,
272 borderBottomWidth: 1,
273 borderBottomColor: colors.border,
274 backgroundColor: colors.bgSecondary,
275 gap: 12,
276 },
277 rowRead: {
278 opacity: 0.6,
279 backgroundColor: colors.bg,
280 },
281 iconContainer: {
282 width: 36,
283 height: 36,
284 borderRadius: 8,
285 alignItems: 'center',
286 justifyContent: 'center',
287 flexShrink: 0,
288 },
289 icon: {
290 fontSize: 16,
291 fontWeight: '700',
292 },
293 content: {
294 flex: 1,
295 gap: 4,
296 },
297 title: {
298 fontSize: 14,
299 fontWeight: '500',
300 color: colors.text,
301 lineHeight: 20,
302 },
303 body: {
304 fontSize: 13,
305 color: colors.textMuted,
306 },
307 meta: {
308 flexDirection: 'row',
309 alignItems: 'center',
310 justifyContent: 'space-between',
311 },
312 repoText: {
313 fontSize: 12,
314 color: colors.textMuted,
315 fontFamily: 'monospace',
316 },
317 time: {
318 fontSize: 12,
319 color: colors.textMuted,
320 },
321 unreadDot: {
322 width: 8,
323 height: 8,
324 borderRadius: 4,
325 backgroundColor: colors.accentBlue,
326 marginTop: 6,
327 flexShrink: 0,
328 },
329 emptyState: {
330 padding: 48,
331 alignItems: 'center',
332 gap: 10,
333 },
334 emptyIcon: {
335 fontSize: 36,
336 },
337 emptyText: {
338 fontSize: 15,
339 color: colors.textMuted,
340 textAlign: 'center',
341 },
342});