Files
musicshare/apps/mobile/src/components/ProfileView.tsx
T
chrisandClaude Opus 5.5 ece9c4fd64 Add recently played to profiles
A play is recorded when a new track starts (pausing and resuming doesn't
count; returning after 10+ minutes does). Profiles show the last 10,
excluding the track playing now; 50 are kept per user. Recent plays are
free on every plan; the paid flag is renamed to fullHistory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-03 16:02:38 -04:00

128 lines
4.1 KiB
TypeScript

import { router } from 'expo-router';
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { ProfileDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { useRealtime } from '@/lib/useRealtime';
import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme';
import { Avatar } from './Avatar';
import { NowPlayingCard } from './NowPlayingCard';
import { RecentPlays } from './RecentPlays';
import { Screen } from './Screen';
import { TrackCard } from './TrackCard';
import { Body, Button, Card, Heading, Row, Title } from './ui';
export function ProfileView({ username, edges }: { username: string; edges?: ('top' | 'bottom')[] }) {
const { me } = useSession();
const { data: profile, error, mutate } = useSWR<ProfileDTO>(`/users/${username}`);
const [busy, setBusy] = useState(false);
const [refreshing, setRefreshing] = useState(false);
useRealtime(true, (msg) => {
if (msg.type === 'now_playing' && profile && msg.data.user.id === profile.user.id) {
// A new track moves the previous one into "Recently played", so refetch; otherwise patch in place.
const newTrack = msg.data.track.id !== profile.nowPlaying?.track.id;
mutate({ ...profile, nowPlaying: msg.data }, { revalidate: newTrack });
}
});
const act = async (fn: () => Promise<unknown>) => {
setBusy(true);
try {
await fn();
await mutate();
} catch (err) {
showError(err);
}
setBusy(false);
};
if (error) {
return (
<Screen edges={edges}>
<Body muted>User not found.</Body>
</Screen>
);
}
if (!profile) return <Screen edges={edges} />;
const { user } = profile;
return (
<Screen
edges={edges}
refreshing={refreshing}
onRefresh={async () => {
setRefreshing(true);
await mutate().catch(() => {});
setRefreshing(false);
}}>
<View style={styles.header}>
<Avatar user={user} size={72} />
<View style={{ flex: 1, gap: 2 }}>
<Title numberOfLines={1}>{user.displayName ?? user.username}</Title>
<Text style={styles.meta}>
@{user.username} · {profile.followerCount} followers · {profile.followingCount} following
</Text>
</View>
</View>
{user.bio && <Body>{user.bio}</Body>}
<Row>
{profile.isSelf ? (
<Button small title="Edit profile" onPress={() => router.push('/settings')} />
) : (
<>
<Button
small
variant={profile.isFollowing ? 'secondary' : 'primary'}
title={profile.isFollowing ? 'Following' : 'Follow'}
disabled={busy}
onPress={() => act(() => (profile.isFollowing ? api.unfollow(username) : api.follow(username)))}
/>
{profile.isFollowing && (
<Button
small
title={profile.isPinned ? '★ Pinned' : '☆ Pin to top'}
disabled={busy}
onPress={() => act(() => (profile.isPinned ? api.unpin(username) : api.pin(username)))}
/>
)}
</>
)}
</Row>
{profile.featured && (
<Card>
<Heading>Featured song</Heading>
<TrackCard track={profile.featured.track} preferred={me?.preferredService} />
{profile.featured.comment && <Text style={styles.quote}>“{profile.featured.comment}”</Text>}
</Card>
)}
{profile.nowPlaying ? (
<NowPlayingCard np={profile.nowPlaying} preferred={me?.preferredService} showUser={false} />
) : (
<Body muted>Nothing playing right now.</Body>
)}
<RecentPlays plays={profile.recent} preferred={me?.preferredService} />
</Screen>
);
}
const styles = StyleSheet.create({
header: { flexDirection: 'row', alignItems: 'center', gap: space.lg },
meta: { color: colors.muted, fontSize: 13 },
quote: {
color: colors.text,
fontStyle: 'italic',
borderLeftWidth: 3,
borderLeftColor: colors.accent,
paddingLeft: space.md,
fontSize: 15,
},
});