feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
This commit is contained in:
1 parent
f1528de948
commit
0a05bfe2cd
43 files changed
+2828
-389
No files matched your search
@@ -8,18 +8,21 @@ import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { Cover, Icon, type IconName } from '../../ui/kit';
|
||||
import { useSocialUnread } from '../../features/social/Social';
|
||||
import { UpdateDialog } from '../../ui/UpdateDialog';
|
||||
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
||||
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
|
||||
|
||||
export default function TabsLayout() {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const unread = useSocialUnread();
|
||||
const labels: Record<string, string> = {
|
||||
index: t('nav.home'),
|
||||
search: t('mobile.search'),
|
||||
collection: t('nav.collection'),
|
||||
profile: t('mobile.profile'),
|
||||
friends: t('social.people'),
|
||||
};
|
||||
const net = useNetInfo();
|
||||
return (
|
||||
@@ -34,6 +37,7 @@ export default function TabsLayout() {
|
||||
routes={state.routes.map((r) => r.name)}
|
||||
active={state.index}
|
||||
labels={labels}
|
||||
badges={{ friends: unread }}
|
||||
onPress={(name) => navigation.navigate(name)}
|
||||
/>
|
||||
</View>
|
||||
@@ -42,6 +46,7 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="index" />
|
||||
<Tabs.Screen name="search" />
|
||||
<Tabs.Screen name="collection" />
|
||||
<Tabs.Screen name="friends" />
|
||||
<Tabs.Screen name="profile" />
|
||||
</Tabs>
|
||||
<UpdateDialog />
|
||||
@@ -53,11 +58,13 @@ function TabBar({
|
||||
routes,
|
||||
active,
|
||||
labels,
|
||||
badges,
|
||||
onPress,
|
||||
}: {
|
||||
routes: string[];
|
||||
active: number;
|
||||
labels: Record<string, string>;
|
||||
badges: Record<string, number>;
|
||||
onPress: (name: string) => void;
|
||||
}) {
|
||||
const colors = useColors();
|
||||
@@ -81,10 +88,13 @@ function TabBar({
|
||||
onPress={() => onPress(name)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: on }}
|
||||
accessibilityLabel={labels[name]}
|
||||
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
|
||||
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
|
||||
>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
<View>
|
||||
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
|
||||
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
|
||||
</View>
|
||||
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
|
||||
{labels[name]}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SocialScreen } from '../../features/social/Social';
|
||||
export default function FriendsTab() { return <SocialScreen root />; }
|
||||
@@ -110,6 +110,9 @@ function Screens() {
|
||||
<Stack.Screen name="album/[id]" />
|
||||
<Stack.Screen name="artist/[id]" />
|
||||
<Stack.Screen name="playlists" />
|
||||
<Stack.Screen name="social" />
|
||||
<Stack.Screen name="social-track" />
|
||||
<Stack.Screen name="social-playlist" />
|
||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||
import { MusicShareSheet } from '../features/social/Social';
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
import { useSessionCtx, useUser } from '../lib/session';
|
||||
@@ -39,20 +40,11 @@ export default function PlayerScreen() {
|
||||
const duration = st.durationMs || track.duration_ms;
|
||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||
const share = async () => {
|
||||
if (!server || sharing) return;
|
||||
setSharing(true);
|
||||
try {
|
||||
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||
} catch {
|
||||
Alert.alert(t('player.share'), t('player.share_error'));
|
||||
} finally {
|
||||
setSharing(false);
|
||||
}
|
||||
};
|
||||
const share = () => setSharing(true);
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
|
||||
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { MusicShareSheet } from '../features/social/Social';
|
||||
import { playlistShareUrl, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
|
||||
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
import { player } from '../player/controller';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||
@@ -77,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
|
||||
}
|
||||
|
||||
function Detail({ id }: { id: string }) {
|
||||
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
@@ -94,7 +96,7 @@ function Detail({ id }: { id: string }) {
|
||||
if (!server || !p) throw new Error('Server unavailable');
|
||||
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
|
||||
setShareToken(token);
|
||||
} });
|
||||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||
@@ -112,7 +114,7 @@ function Detail({ id }: { id: string }) {
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||
};
|
||||
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SharedMusic } from '../features/social/SharedMusic';
|
||||
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
|
||||
@@ -0,0 +1,2 @@
|
||||
import { SharedMusic } from '../features/social/SharedMusic';
|
||||
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
|
||||
@@ -0,0 +1 @@
|
||||
export { SocialScreen as default } from '../features/social/Social';
|
||||
Reference in new issue
Block a user