Notifications and sending songs to friends
Reactions to your songs, new followers, and songs sent to you are now notifications: a bell on the feed (app and website) with an unread count that updates live, and a list that marks them read. "Send ✉" on a song sends it to people who follow you, with an optional note. Settings picks which kinds to be notified about. Push delivery uses no third-party accounts: - Android: UnifiedPush through a distributor app (ntfy), pointed at our own ntfy server (docker-compose.ntfy.yml). Works the same in the F-Droid build; no Firebase. - Browsers: standard Web Push from the existing service worker. Both use Web Push encryption with a VAPID key pair the server makes and keeps in the database. Push addresses are checked like any other user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers the new data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
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, 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, fontWeight: '700', fontSize: 15 },
|
||||
body: { color: colors.text, opacity: 0.85, fontSize: 14 },
|
||||
time: { color: colors.muted, fontSize: 12 },
|
||||
art: { width: 44, height: 44, borderRadius: 8 },
|
||||
});
|
||||
Reference in New Issue
Block a user