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,25 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'expo-module-gradle-plugin'
|
||||
}
|
||||
|
||||
group = 'tech.chrisedwards.musicshare.push'
|
||||
version = '0.1.0'
|
||||
|
||||
android {
|
||||
namespace "tech.chrisedwards.musicshare.push"
|
||||
defaultConfig {
|
||||
versionCode 1
|
||||
versionName "0.1.0"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// UnifiedPush: notifications through a distributor app the user picks (e.g. ntfy on our own
|
||||
// server) instead of Google's Firebase. Open source (Apache-2.0), no Play Services.
|
||||
implementation "org.unifiedpush.android:connector:3.3.5"
|
||||
implementation "androidx.core:core-ktx:1.16.0"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<!-- Receives endpoints and messages from the UnifiedPush distributor (via the connector library). -->
|
||||
<service
|
||||
android:name=".MusicsharePushService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package tech.chrisedwards.musicshare.push
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import org.json.JSONObject
|
||||
import org.unifiedpush.android.connector.FailedReason
|
||||
import org.unifiedpush.android.connector.PushService
|
||||
import org.unifiedpush.android.connector.data.PushEndpoint
|
||||
import org.unifiedpush.android.connector.data.PushMessage
|
||||
|
||||
/**
|
||||
* Runs without the app UI: the distributor (e.g. ntfy) wakes it with new endpoints and
|
||||
* messages. Messages arrive already decrypted by the connector library.
|
||||
*/
|
||||
class MusicsharePushService : PushService() {
|
||||
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
|
||||
val keys = endpoint.pubKeySet ?: run {
|
||||
Log.w(TAG, "endpoint without keys; can't receive encrypted pushes")
|
||||
return
|
||||
}
|
||||
val ep = Endpoint(endpoint.url, keys.pubKey, keys.auth)
|
||||
PushPrefs(this).endpoint = ep
|
||||
PushBus.emit(ep)
|
||||
}
|
||||
|
||||
override fun onMessage(message: PushMessage, instance: String) {
|
||||
try {
|
||||
show(this, JSONObject(String(message.content, Charsets.UTF_8)))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "couldn't show push", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
|
||||
Log.w(TAG, "registration failed: $reason")
|
||||
forget()
|
||||
}
|
||||
|
||||
override fun onUnregistered(instance: String) = forget()
|
||||
|
||||
private fun forget() {
|
||||
PushPrefs(this).endpoint = null
|
||||
PushBus.emit(null)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MusicsharePush"
|
||||
private const val CHANNEL = "activity"
|
||||
|
||||
/** { id, title, body, path } from the server; tapping opens musicshare://<path>. */
|
||||
fun show(context: Context, data: JSONObject) {
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled()) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(CHANNEL, "Activity", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
channel.description = "Reactions, new followers and songs sent to you"
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
val id = data.optString("id", System.currentTimeMillis().toString())
|
||||
val open = Intent(Intent.ACTION_VIEW, Uri.parse("musicshare:/" + data.optString("path", "/")))
|
||||
.setPackage(context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val tap = PendingIntent.getActivity(context, id.hashCode(), open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_stat_musicshare)
|
||||
.setColor(0xFF7C4DFF.toInt())
|
||||
.setContentTitle(data.optString("title"))
|
||||
.setContentText(data.optString("body"))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(data.optString("body")))
|
||||
.setContentIntent(tap)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
try {
|
||||
manager.notify(id.hashCode(), notification)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "notification permission missing", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package tech.chrisedwards.musicshare.push
|
||||
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.exception.Exceptions
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import org.unifiedpush.android.connector.UnifiedPush
|
||||
|
||||
class PushModule : Module() {
|
||||
private val context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
|
||||
|
||||
private val listener: (Endpoint?) -> Unit = { sendEvent("onEndpoint", mapOf("endpoint" to it?.toMap())) }
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("MusicsharePush")
|
||||
|
||||
Events("onEndpoint")
|
||||
|
||||
OnStartObserving { PushBus.add(listener) }
|
||||
OnStopObserving { PushBus.remove(listener) }
|
||||
|
||||
/** Package names of installed UnifiedPush distributors (ntfy etc.). */
|
||||
Function("distributors") { UnifiedPush.getDistributors(context) }
|
||||
|
||||
/** The distributor in use, if registered. */
|
||||
Function("currentDistributor") { UnifiedPush.getAckDistributor(context) }
|
||||
|
||||
/** The endpoint to give the server: { url, p256dh, auth }, or null. */
|
||||
Function("getEndpoint") { PushPrefs(context).endpoint?.toMap() }
|
||||
|
||||
/**
|
||||
* Picks a distributor (asks if there are several) and registers. Resolves false when
|
||||
* there's none installed. The endpoint arrives later through onEndpoint.
|
||||
*/
|
||||
AsyncFunction("register") { vapidPublicKey: String, promise: Promise ->
|
||||
// It may ask the user to pick a distributor, so it needs the app on screen.
|
||||
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
|
||||
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { ok ->
|
||||
if (ok) UnifiedPush.register(context, "default", "musicshare notifications", vapidPublicKey)
|
||||
promise.resolve(ok)
|
||||
}
|
||||
}
|
||||
|
||||
Function("unregister") {
|
||||
UnifiedPush.unregister(context, "default")
|
||||
PushPrefs(context).endpoint = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package tech.chrisedwards.musicshare.push
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/** The current push endpoint, kept so JS can send it to the server whenever the app opens. */
|
||||
internal class PushPrefs(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("musicshare.push", Context.MODE_PRIVATE)
|
||||
|
||||
var endpoint: Endpoint?
|
||||
get() {
|
||||
val url = prefs.getString("url", null) ?: return null
|
||||
return Endpoint(url, prefs.getString("p256dh", null) ?: return null, prefs.getString("auth", null) ?: return null)
|
||||
}
|
||||
set(value) {
|
||||
val edit = prefs.edit()
|
||||
if (value == null) edit.clear() else edit.putString("url", value.url).putString("p256dh", value.p256dh).putString("auth", value.auth)
|
||||
edit.apply()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class Endpoint(val url: String, val p256dh: String, val auth: String) {
|
||||
fun toMap() = mapOf("url" to url, "p256dh" to p256dh, "auth" to auth)
|
||||
}
|
||||
|
||||
/** Tells the JS module (when the app is open) that the endpoint changed. */
|
||||
internal object PushBus {
|
||||
private val listeners = mutableSetOf<(Endpoint?) -> Unit>()
|
||||
@Synchronized fun add(listener: (Endpoint?) -> Unit) { listeners.add(listener) }
|
||||
@Synchronized fun remove(listener: (Endpoint?) -> Unit) { listeners.remove(listener) }
|
||||
@Synchronized fun emit(endpoint: Endpoint?) { listeners.forEach { it(endpoint) } }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!-- Status bar icon: a music note (status bar icons must be single-colour). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h4V3h-6z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["tech.chrisedwards.musicshare.push.PushModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Android-only native module; the default export is null where it is unavailable.
|
||||
export { default } from './src/PushModule';
|
||||
export type { PushEndpoint } from './src/PushModule';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NativeModule, requireOptionalNativeModule } from 'expo';
|
||||
|
||||
export interface PushEndpoint {
|
||||
url: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
type Events = { onEndpoint: (event: { endpoint: PushEndpoint | null }) => void };
|
||||
|
||||
declare class MusicsharePushModule extends NativeModule<Events> {
|
||||
distributors(): string[];
|
||||
currentDistributor(): string | null;
|
||||
getEndpoint(): PushEndpoint | null;
|
||||
register(vapidPublicKey: string): Promise<boolean>;
|
||||
unregister(): void;
|
||||
}
|
||||
|
||||
/** Android only (UnifiedPush); null elsewhere and in Expo Go. */
|
||||
export default requireOptionalNativeModule<MusicsharePushModule>('MusicsharePush');
|
||||
@@ -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();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Body, Button, Title } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
|
||||
import { syncDevice } from '@/lib/nowPlayingReader';
|
||||
import { syncPush } from '@/lib/push';
|
||||
import { SessionProvider, useSession } from '@/lib/session';
|
||||
import { colors, space } from '@/lib/theme';
|
||||
|
||||
@@ -45,6 +46,7 @@ function AppNavigator() {
|
||||
// Keep the Android listener pointed at this account (no-op elsewhere).
|
||||
useEffect(() => {
|
||||
if (signedIn) syncDevice().catch(() => {});
|
||||
if (signedIn) syncPush().catch(() => {});
|
||||
}, [signedIn]);
|
||||
|
||||
if (!ready) return null;
|
||||
@@ -99,6 +101,9 @@ function AppNavigator() {
|
||||
<Stack.Screen name="change-password" options={{ title: 'Change password', presentation: 'modal' }} />
|
||||
<Stack.Screen name="change-email" options={{ title: 'Change email', presentation: 'modal' }} />
|
||||
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
|
||||
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
|
||||
<Stack.Screen name="t/[id]" options={{ title: '' }} />
|
||||
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={!signedIn}>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import type { TrackDTO } from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { emptyFollowText, useFollowList } from '@/components/FollowList';
|
||||
import { Body, Button, Field } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { showError } from '@/lib/errors';
|
||||
import { successHaptic, tapHaptic } from '@/lib/haptics';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { colors, radius, space } from '@/lib/theme';
|
||||
|
||||
/** Send a song to some of your followers, with an optional note. */
|
||||
export default function SendSongScreen() {
|
||||
const { trackId } = useLocalSearchParams<{ trackId: string }>();
|
||||
const { me } = useSession();
|
||||
const { data: track } = useSWR<TrackDTO>(trackId ? `/tracks/${trackId}` : null);
|
||||
const followers = useFollowList(me?.username, 'followers');
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggle = (username: string) => {
|
||||
tapHaptic();
|
||||
setPicked((p) => {
|
||||
const next = new Set(p);
|
||||
if (next.has(username)) next.delete(username);
|
||||
else next.add(username);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.sendSong({ trackId, to: [...picked], message: message.trim() || null });
|
||||
successHaptic();
|
||||
router.back();
|
||||
} catch (err) {
|
||||
showError(err, "Couldn't send");
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
style={{ backgroundColor: colors.bg }}
|
||||
contentContainerStyle={styles.list}
|
||||
data={followers.users}
|
||||
keyExtractor={(u) => u.id}
|
||||
onEndReached={followers.loadMore}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: space.md }}>
|
||||
{track && (
|
||||
<Text style={styles.song} numberOfLines={2}>
|
||||
{track.title} <Text style={styles.muted}>· {track.artist}</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Field placeholder="Add a note (optional)" value={message} onChangeText={setMessage} maxLength={280} />
|
||||
<Body muted style={{ fontSize: 13 }}>
|
||||
You can send songs to people who follow you.
|
||||
</Body>
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={followers.isLoading ? null : <Body muted>{emptyFollowText('followers')}</Body>}
|
||||
renderItem={({ item: u }) => {
|
||||
const on = picked.has(u.username);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => toggle(u.username)}
|
||||
style={[styles.person, on && styles.on]}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityState={{ checked: on }}>
|
||||
<Avatar user={u} size={36} />
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{u.displayName ?? u.username}
|
||||
</Text>
|
||||
<Text style={[styles.check, on && { color: colors.text }]}>{on ? '✓' : '○'}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<Button
|
||||
variant="primary"
|
||||
title={picked.size ? `Send to ${picked.size}` : 'Pick someone'}
|
||||
disabled={!picked.size}
|
||||
loading={busy}
|
||||
onPress={send}
|
||||
style={{ marginTop: space.md }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
list: { padding: space.lg, gap: space.sm, paddingBottom: space.xl * 2 },
|
||||
song: { color: colors.text, fontSize: 17, fontWeight: '700' },
|
||||
muted: { color: colors.muted, fontWeight: '400' },
|
||||
person: { flexDirection: 'row', alignItems: 'center', gap: space.md, padding: space.sm, borderRadius: radius.md },
|
||||
on: { backgroundColor: colors.surface2 },
|
||||
name: { flex: 1, color: colors.text, fontSize: 15, fontWeight: '600' },
|
||||
check: { color: colors.muted, fontSize: 18 },
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
import useSWR from 'swr';
|
||||
import type { TrackDTO } from '@musicshare/shared';
|
||||
import { Lyrics } from '@/components/Lyrics';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { TrackCard } from '@/components/TrackCard';
|
||||
import { Body, Card } from '@/components/ui';
|
||||
import { useSession } from '@/lib/session';
|
||||
|
||||
/** One song: where notifications and shared links lead. */
|
||||
export default function TrackScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { me } = useSession();
|
||||
const { data: track, error } = useSWR<TrackDTO>(id ? `/tracks/${id}` : null);
|
||||
return (
|
||||
<Screen edges={[]}>
|
||||
<Stack.Screen options={{ title: track?.title ?? '' }} />
|
||||
{error && <Body muted>That song couldn't be found.</Body>}
|
||||
{track && (
|
||||
<>
|
||||
<TrackCard track={track} preferred={me?.preferredService} />
|
||||
<Card>
|
||||
<Lyrics track={track} />
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { router } from 'expo-router';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import useSWR from 'swr';
|
||||
import type { NotificationListDTO } from '@musicshare/shared';
|
||||
import { colors } from '@/lib/theme';
|
||||
|
||||
/** Bell with the unread count; opens your notifications. */
|
||||
export function ActivityBell() {
|
||||
const { data } = useSWR<NotificationListDTO>('/notifications');
|
||||
const unread = data?.unread ?? 0;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => router.push('/notifications')}
|
||||
hitSlop={10}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={unread ? `Notifications, ${unread} new` : 'Notifications'}
|
||||
style={styles.bell}>
|
||||
<Text style={styles.icon}>🔔</Text>
|
||||
{unread > 0 && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>{unread > 99 ? '99+' : unread}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bell: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface2 },
|
||||
icon: { fontSize: 18 },
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -4,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
paddingHorizontal: 4,
|
||||
borderRadius: 9,
|
||||
backgroundColor: colors.danger,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
@@ -60,6 +61,11 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{me && (
|
||||
<Pressable onPress={() => router.push(`/send/${track.id}`)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Send to friends">
|
||||
<Text style={[styles.service, styles.share]}>Send ✉</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
|
||||
<Text style={[styles.service, styles.share]}>Share ↗</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PermissionsAndroid, Platform } from 'react-native';
|
||||
import Push, { type PushEndpoint } from '@/modules/push';
|
||||
import { api } from './api';
|
||||
|
||||
/**
|
||||
* Push notifications through UnifiedPush: a distributor app on the phone (ntfy is the
|
||||
* usual one, pointed at your own server) holds the connection, so there's no Google
|
||||
* Firebase. Android only; elsewhere the app's notification list still works.
|
||||
*/
|
||||
export const pushAvailable = Push !== null;
|
||||
|
||||
/** ntfy, the distributor we suggest, on F-Droid. */
|
||||
export const NTFY_URL = 'https://f-droid.org/packages/io.heckel.ntfy/';
|
||||
|
||||
const send = (ep: PushEndpoint) =>
|
||||
api.addPushSubscription({ kind: 'unifiedpush', endpoint: ep.url, keys: { p256dh: ep.p256dh, auth: ep.auth } });
|
||||
|
||||
/** Make sure the server has this phone's current endpoint (call when signed in). */
|
||||
export async function syncPush() {
|
||||
const ep = Push?.getEndpoint();
|
||||
if (ep) await send(ep);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for permission and register with the distributor. Returns 'no-distributor' when
|
||||
* none is installed; otherwise the endpoint arrives shortly (see usePushStatus).
|
||||
*/
|
||||
export async function enablePush(): Promise<'ok' | 'no-distributor' | 'denied'> {
|
||||
if (!Push) return 'no-distributor';
|
||||
if (Platform.OS === 'android' && Number(Platform.Version) >= 33) {
|
||||
const result = await PermissionsAndroid.request('android.permission.POST_NOTIFICATIONS');
|
||||
if (result !== PermissionsAndroid.RESULTS.GRANTED) return 'denied';
|
||||
}
|
||||
const { publicKey } = await api.pushKey();
|
||||
return (await Push.register(publicKey)) ? 'ok' : 'no-distributor';
|
||||
}
|
||||
|
||||
export async function disablePush() {
|
||||
const ep = Push?.getEndpoint();
|
||||
Push?.unregister();
|
||||
if (ep) await api.removePushSubscription(ep.url);
|
||||
}
|
||||
|
||||
/** Whether push is set up, which distributor delivers it, and whether any is installed. Keeps the server told of changes. */
|
||||
export function usePushStatus() {
|
||||
const [endpoint, setEndpoint] = useState(() => Push?.getEndpoint() ?? null);
|
||||
const [distributors, setDistributors] = useState(() => Push?.distributors() ?? []);
|
||||
useEffect(() => {
|
||||
if (!Push) return;
|
||||
const sub = Push.addListener('onEndpoint', ({ endpoint: ep }) => {
|
||||
setEndpoint(ep);
|
||||
setDistributors(Push!.distributors());
|
||||
if (ep) send(ep).catch(() => {});
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
return {
|
||||
enabled: Boolean(endpoint),
|
||||
distributor: endpoint ? (Push?.currentDistributor() ?? null) : null,
|
||||
hasDistributor: distributors.length > 0,
|
||||
refresh: () => {
|
||||
setEndpoint(Push?.getEndpoint() ?? null);
|
||||
setDistributors(Push?.distributors() ?? []);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user