Files
musicshare/apps/mobile/src/app/notifications.tsx
T
chrisandClaude Opus 5.5 864ab88627 New look: slate-indigo panels, pink accent, Poppins, and a gradient card for your song
Inspired by a dashboard design the user shared.
- App: new palette and fonts, borderless rounded panels with soft shadows, a pink-to-blue
  gradient card for your own song (text and controls switch to white on it), flat cards for
  friends, a 'Hi Chris, welcome back' header, an outline bell, and a pink pill on the active tab.
- Website: the same look, plus a wide-screen layout with a sidebar, the page in a rounded panel,
  and a right column with your profile, recent activity and recent plays. Phones keep the top
  bar and bottom tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 15:01:20 -04:00

85 lines
3.3 KiB
TypeScript

import { Image } from 'expo-image';
import { router, useFocusEffect } from 'expo-router';
import { useCallback } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSWRConfig } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { notificationPath, notificationText, timeAgo, type NotificationDTO, type NotificationListDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Body } from '@/components/ui';
import { api } from '@/lib/api';
import { colors, fonts, space } from '@/lib/theme';
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsScreen() {
const { mutate: mutateGlobal } = useSWRConfig();
const { data, size, setSize, isLoading, mutate } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => api.notifications(cursor),
);
const items = data?.flatMap((p) => p.notifications) ?? [];
const more = Boolean(data?.at(-1)?.nextCursor);
// Seen once you've looked; the bell clears (the list keeps showing which were new until you leave).
const unread = data?.[0]?.unread ?? 0;
useFocusEffect(
useCallback(() => {
if (!unread) return;
api
.markNotificationsRead()
.then(() => mutateGlobal('/notifications'))
.catch(() => {});
return () => {
mutate();
};
}, [unread, mutate, mutateGlobal]),
);
return (
<FlatList
data={items}
keyExtractor={(n) => n.id}
contentContainerStyle={styles.list}
style={{ backgroundColor: colors.bg }}
onEndReached={() => more && setSize(size + 1)}
ListEmptyComponent={
isLoading ? null : <Body muted>Nothing yet. Reactions, new followers and songs sent to you show up here.</Body>
}
renderItem={({ item }) => <Row n={item} />}
/>
);
}
function Row({ n }: { n: NotificationDTO }) {
const { title, body } = notificationText(n);
return (
<Pressable
onPress={() => router.push(notificationPath(n) as never)}
style={({ pressed }) => [styles.row, !n.read && styles.unread, pressed && { opacity: 0.7 }]}
accessibilityRole="link">
<Avatar user={n.actor} size={40} />
<View style={styles.text}>
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
<Text style={styles.body} numberOfLines={3}>
{body}
</Text>
<Text style={styles.time}>{timeAgo(n.createdAt)}</Text>
</View>
{n.track?.artworkUrl && <Image source={n.track.artworkUrl} style={styles.art} contentFit="cover" />}
</Pressable>
);
}
const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.sm },
row: { flexDirection: 'row', gap: space.md, alignItems: 'center', padding: space.md, borderRadius: 14 },
unread: { backgroundColor: colors.surface2 },
text: { flex: 1, minWidth: 0, gap: 2 },
title: { color: colors.text, fontFamily: fonts.display, fontSize: 15 },
body: { color: colors.text, opacity: 0.85, fontSize: 14, fontFamily: fonts.body },
time: { color: colors.muted, fontSize: 12 },
art: { width: 44, height: 44, borderRadius: 8 },
});