Songs offered links to all five services. Settings now has "Your music services": pick the ones you use and songs only show those, with the main one as the big button. Until someone picks, they still see all of them. Onboarding sets it to the app you said you listen with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "services" text[];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1791308012441,
|
||||
"tag": "0013_reactions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1792025011207,
|
||||
"tag": "0014_user_services",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export const users = pgTable('users', {
|
||||
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
|
||||
avatarUrl: varchar('avatar_url', { length: 500 }),
|
||||
preferredService: text('preferred_service').$type<StreamingService>(),
|
||||
/** The services they use, so songs only offer those; null until they choose (offer all). */
|
||||
services: text('services').array().$type<StreamingService[]>(),
|
||||
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
|
||||
/** The first account on a server becomes admin (moderation). */
|
||||
role: text('role').$type<'user' | 'admin'>().notNull().default('user'),
|
||||
|
||||
@@ -24,6 +24,7 @@ export const toMeDTO = (u: User): MeDTO => ({
|
||||
pendingEmail: u.pendingEmail,
|
||||
bio: u.bio,
|
||||
preferredService: u.preferredService,
|
||||
services: u.services,
|
||||
role: u.role,
|
||||
onboardedAt: u.onboardedAt?.toISOString() ?? null,
|
||||
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
|
||||
|
||||
@@ -115,3 +115,16 @@ describe('onboarding flag', () => {
|
||||
expect(again.onboardedAt).toBe(first.onboardedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('music services', () => {
|
||||
it('saves the services you use, without duplicates, and clears them', async () => {
|
||||
const { token, user } = await createUser(app(), 'listener');
|
||||
expect(user.services).toBeNull();
|
||||
const patch = (payload: object) => app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload });
|
||||
const saved = (await patch({ services: ['spotify', 'tidal', 'spotify'], preferredService: 'tidal' })).json();
|
||||
expect(saved.services).toEqual(['spotify', 'tidal']);
|
||||
expect(saved.preferredService).toBe('tidal');
|
||||
expect((await patch({ services: ['napster'] })).statusCode).toBe(400);
|
||||
expect((await patch({ services: null })).json().services).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ConnectionDTO,
|
||||
type MeDTO,
|
||||
type NowPlayingProvider,
|
||||
type StreamingService,
|
||||
} from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { RelinkReminder } from '@/components/RelinkReminder';
|
||||
@@ -34,7 +35,7 @@ export default function SettingsScreen() {
|
||||
<ProfileSection />
|
||||
<SharingControls />
|
||||
<RelinkReminder />
|
||||
<PreferredService />
|
||||
<MyServices />
|
||||
<ThisPhone />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
@@ -118,23 +119,44 @@ function AvatarPicker() {
|
||||
);
|
||||
}
|
||||
|
||||
function PreferredService() {
|
||||
/** The services you use: songs offer only these, and open in the main one. */
|
||||
function MyServices() {
|
||||
const { me, setMe } = useSession();
|
||||
const choose = async (service: (typeof STREAMING_SERVICES)[number]) => {
|
||||
const mine = me?.services ?? [];
|
||||
const save = async (services: StreamingService[], preferredService: StreamingService | null) => {
|
||||
try {
|
||||
setMe(await api.updateMe({ preferredService: service }));
|
||||
setMe(await api.updateMe({ services: services.length ? services : null, preferredService }));
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
};
|
||||
const toggle = (s: StreamingService) => {
|
||||
const services = mine.includes(s) ? mine.filter((x) => x !== s) : [...mine, s];
|
||||
// The main service has to be one you use; fall back to the first one left.
|
||||
const main = me?.preferredService && services.includes(me.preferredService) ? me.preferredService : (services[0] ?? null);
|
||||
save(services, main);
|
||||
};
|
||||
return (
|
||||
<Card>
|
||||
<Heading>Open songs in</Heading>
|
||||
<Heading>Your music services</Heading>
|
||||
<Body muted style={styles.small}>
|
||||
Songs only show buttons for the ones you pick.
|
||||
</Body>
|
||||
<Row>
|
||||
{STREAMING_SERVICES.map((s) => (
|
||||
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => choose(s)} />
|
||||
<Chip key={s} label={SERVICE_LABELS[s]} selected={mine.includes(s)} onPress={() => toggle(s)} />
|
||||
))}
|
||||
</Row>
|
||||
{mine.length > 1 && (
|
||||
<>
|
||||
<Heading>Open songs in</Heading>
|
||||
<Row>
|
||||
{mine.map((s) => (
|
||||
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => save(mine, s)} />
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function ListenAppStep() {
|
||||
if (!app) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (app.service) setMe(await api.updateMe({ preferredService: app.service }));
|
||||
if (app.service) setMe(await api.updateMe({ preferredService: app.service, services: [app.service] }));
|
||||
router.push({ pathname: '/onboarding/share', params: { app: app.id } });
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native';
|
||||
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, shareText, trackUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { WEB_URL } from '@/lib/config';
|
||||
import { useSession } from '@/lib/session';
|
||||
import { showError } from '@/lib/errors';
|
||||
@@ -23,7 +23,7 @@ function shareTrack(track: TrackDTO, from: string | undefined) {
|
||||
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
|
||||
const { me } = useSession();
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||
|
||||
return (
|
||||
<View style={styles.track}>
|
||||
|
||||
@@ -222,6 +222,8 @@ input:focus, textarea:focus, select:focus {
|
||||
.card-header .pin { flex-shrink: 0; }
|
||||
.form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
.form label { display: flex; flex-direction: column; gap: 0.35rem; font-weight: 600; font-size: 0.9rem; }
|
||||
.services-picker { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.services-picker legend { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.35rem; }
|
||||
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
|
||||
|
||||
/* Now-playing cards take their colour from the album art. */
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type MeDTO,
|
||||
type NowPlayingProvider,
|
||||
type ProfileDTO,
|
||||
type StreamingService,
|
||||
type TrackSearchResultDTO,
|
||||
type UserSummaryDTO,
|
||||
} from '@musicshare/shared';
|
||||
@@ -58,10 +59,18 @@ function ProfileForm({ me }: { me: MeDTO }) {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.currentTarget);
|
||||
const str = (k: string) => (f.get(k) as string).trim() || null;
|
||||
const services = f.getAll('services') as StreamingService[];
|
||||
const preferred = str('preferredService') as StreamingService | null;
|
||||
try {
|
||||
const updated = await api<MeDTO>('/me', {
|
||||
method: 'PATCH',
|
||||
body: { displayName: str('displayName'), bio: str('bio'), preferredService: str('preferredService') },
|
||||
body: {
|
||||
displayName: str('displayName'),
|
||||
bio: str('bio'),
|
||||
services: services.length ? services : null,
|
||||
// The main service has to be one you use.
|
||||
preferredService: preferred && (!services.length || services.includes(preferred)) ? preferred : (services[0] ?? null),
|
||||
},
|
||||
});
|
||||
await mutate(updated, { revalidate: false });
|
||||
setError(null);
|
||||
@@ -84,6 +93,18 @@ function ProfileForm({ me }: { me: MeDTO }) {
|
||||
Bio
|
||||
<textarea name="bio" defaultValue={me.bio ?? ''} maxLength={280} rows={2} />
|
||||
</label>
|
||||
<fieldset className="services-picker">
|
||||
<legend>Your music services</legend>
|
||||
<span className="muted">Songs only show links for the ones you pick.</span>
|
||||
<div className="row">
|
||||
{STREAMING_SERVICES.map((s) => (
|
||||
<label key={s} className="checkbox">
|
||||
<input type="checkbox" name="services" value={s} defaultChecked={me.services?.includes(s)} />
|
||||
{SERVICE_LABELS[s]}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label>
|
||||
Open songs in
|
||||
<select name="preferredService" defaultValue={me.preferredService ?? ''}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { STREAMING_SERVICES, SERVICE_LABELS, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { useMe } from '@/lib/api';
|
||||
import { ShareButton } from './ShareButton';
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,8 @@ export function TrackCard({
|
||||
/** When the page already shows the artwork big. */
|
||||
hideArtwork?: boolean;
|
||||
}) {
|
||||
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
|
||||
const { me } = useMe();
|
||||
const others = viewerServices(me).filter((s) => s !== preferred);
|
||||
const primary = preferred ? openUrl(track, preferred) : null;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user