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:
@@ -3,9 +3,10 @@ import { useEffect, useState } from 'react';
|
||||
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import useSWR from 'swr';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { AmbientBackground } from '@/components/AmbientBackground';
|
||||
import { ActivityBell } from '@/components/ActivityBell';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { NowPlayingCard } from '@/components/NowPlayingCard';
|
||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||
@@ -18,10 +19,11 @@ import { useReader } from '@/lib/nowPlayingReader';
|
||||
import { shouldOfferSetup } from '@/lib/setup';
|
||||
import { useRealtime } from '@/lib/useRealtime';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
import { colors, fonts, space } from '@/lib/theme';
|
||||
|
||||
export default function FeedScreen() {
|
||||
const { me } = useSession();
|
||||
const { mutate: mutateGlobal } = useSWRConfig();
|
||||
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed');
|
||||
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly.
|
||||
const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null);
|
||||
@@ -44,7 +46,10 @@ export default function FeedScreen() {
|
||||
}, []);
|
||||
|
||||
useRealtime(true, (msg) => {
|
||||
if (msg.type !== 'now_playing') return;
|
||||
if (msg.type === 'notification') {
|
||||
mutateGlobal('/notifications');
|
||||
return;
|
||||
}
|
||||
if (msg.data.user.id === me?.id) {
|
||||
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
|
||||
return;
|
||||
@@ -88,6 +93,10 @@ export default function FeedScreen() {
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.header}>
|
||||
<View style={styles.topBar}>
|
||||
<Text style={styles.brand}>musicshare</Text>
|
||||
<ActivityBell />
|
||||
</View>
|
||||
<SharingControls compact />
|
||||
<RelinkReminder />
|
||||
{offerAppleMusic && (
|
||||
@@ -170,6 +179,8 @@ const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: colors.bg },
|
||||
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 },
|
||||
header: { gap: space.md, marginBottom: space.xs },
|
||||
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
|
||||
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
|
||||
quiet: { gap: space.lg, paddingVertical: space.xs },
|
||||
quietPerson: { width: 64, alignItems: 'center', gap: 4 },
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
type MeDTO,
|
||||
type NowPlayingProvider,
|
||||
type StreamingService,
|
||||
type NotificationType,
|
||||
NOTIFICATION_PREF_LABELS,
|
||||
NOTIFICATION_TYPES,
|
||||
} from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||
@@ -25,6 +28,7 @@ import { showError } from '@/lib/errors';
|
||||
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
|
||||
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
|
||||
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
|
||||
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
@@ -36,6 +40,7 @@ export default function SettingsScreen() {
|
||||
<SharingControls />
|
||||
<RelinkReminder />
|
||||
<MyServices />
|
||||
<Notifications />
|
||||
<ThisPhone />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
@@ -161,6 +166,75 @@ function MyServices() {
|
||||
);
|
||||
}
|
||||
|
||||
/** What to be notified about, and push delivery on this phone. */
|
||||
function Notifications() {
|
||||
const { me, setMe } = useSession();
|
||||
const push = usePushStatus();
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!me) return null;
|
||||
|
||||
const toggle = async (type: NotificationType) => {
|
||||
try {
|
||||
setMe(await api.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }));
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
const turnOn = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await enablePush();
|
||||
if (result === 'denied') Alert.alert('Notifications are off', 'Allow notifications for musicshare in Android settings, then try again.');
|
||||
if (result === 'no-distributor') push.refresh();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
const turnOff = async () => {
|
||||
setBusy(true);
|
||||
await disablePush().catch((err) => showError(err));
|
||||
push.refresh();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Notifications</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Notify me about:
|
||||
</Body>
|
||||
<Row>
|
||||
{NOTIFICATION_TYPES.map((t) => (
|
||||
<Chip key={t} label={NOTIFICATION_PREF_LABELS[t]} selected={me.notifyPrefs[t]} onPress={() => toggle(t)} />
|
||||
))}
|
||||
</Row>
|
||||
{pushAvailable &&
|
||||
(push.enabled ? (
|
||||
<>
|
||||
<Body muted style={styles.small}>
|
||||
On for this phone{push.distributor ? `, delivered by ${push.distributor}` : ''}.
|
||||
</Body>
|
||||
<Button small title="Turn off on this phone" loading={busy} onPress={turnOff} style={{ alignSelf: 'flex-start' }} />
|
||||
</>
|
||||
) : push.hasDistributor ? (
|
||||
<Button small variant="primary" title="Turn on for this phone" loading={busy} onPress={turnOn} style={{ alignSelf: 'flex-start' }} />
|
||||
) : (
|
||||
<>
|
||||
<Body muted style={styles.small}>
|
||||
musicshare doesn't use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
|
||||
them on. You'll still see everything under the bell on the feed without it.
|
||||
</Body>
|
||||
<Row>
|
||||
<Button small variant="primary" title="Get ntfy" onPress={() => Linking.openURL(NTFY_URL)} />
|
||||
<Button small title="I've installed it" loading={busy} onPress={turnOn} />
|
||||
</Row>
|
||||
</>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Android: share from any music app on this phone (no-op elsewhere). */
|
||||
function ThisPhone() {
|
||||
const { available, granted, playback } = useReader();
|
||||
|
||||
Reference in New Issue
Block a user