diff --git a/.gitea/workflows/ci-db.yml b/.gitea/workflows/ci-db.yml index ba698f2..07265e6 100644 --- a/.gitea/workflows/ci-db.yml +++ b/.gitea/workflows/ci-db.yml @@ -71,7 +71,7 @@ jobs: done - name: Go — миграции и запросы store working-directory: services/api - run: go test -tags integration -count=1 -v ./internal/dbtest/ + run: go test -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/ - name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch) working-directory: services/api run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/ diff --git a/api/openapi.yaml b/api/openapi.yaml index 83b6864..cba07b0 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -22,6 +22,88 @@ tags: security: - bearerAuth: [] paths: + /api/v1/me/social: + get: + operationId: getSocial + tags: [me] + description: Friends, incoming/outgoing requests, own blocks and up to 100 notifications, unread first. No private account fields. + responses: + '200': + description: Social inbox + content: + application/json: + schema: {$ref: '#/components/schemas/SocialInbox'} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/people: + get: + operationId: searchPeople + tags: [me] + parameters: + - in: query + name: q + required: true + schema: {type: string, minLength: 2, maxLength: 80} + responses: + '200': + description: Up to 30 people, excluding blocked accounts and either direction of personal blocking + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/SocialPerson'} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/actions: + post: + operationId: socialAction + tags: [me] + description: Request is idempotent; accept is recipient-only. Remove cancels, declines or unfriends. Block removes friendship. Unblock never restores it. Read chat acknowledges only messages through up_to_seq. Read notification uses notification ID as target_id. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SocialAction'} + responses: + '204': {description: Applied} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/chats/{peerId}: + parameters: + - in: path + name: peerId + required: true + schema: {type: string, format: uuid} + get: + operationId: getDirectMessages + tags: [me] + description: Last 50 messages in ascending sequence order. Before loads older history. Requires current friendship, never marks messages read. + parameters: + - in: query + name: before + schema: {type: integer, format: int64, minimum: 1} + responses: + '200': + description: Message page + content: + application/json: + schema: + type: array + items: {$ref: '#/components/schemas/DirectMessage'} + default: {$ref: '#/components/responses/Problem'} + post: + operationId: sendDirectMessage + tags: [me] + description: Friends only. Client-generated UUID makes retries idempotent; changed payload for the same ID returns 409. Playlist attachment is its active share UUID; track attachment is a catalog UUID. No arbitrary URLs. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/SendDirectMessage'} + responses: + '200': + description: Stored message + content: + application/json: + schema: {$ref: '#/components/schemas/DirectMessage'} + default: {$ref: '#/components/responses/Problem'} /api/v1/admin/server/updates: get: operationId: getServerUpdates @@ -1994,6 +2076,63 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: + SocialPerson: + type: object + required: [id, username, display_name, relationship, unread] + properties: + id: {type: string, format: uuid} + username: {type: string} + display_name: {type: string} + relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]} + unread: {type: integer} + SocialNotification: + type: object + required: [id, actor_id, actor_name, kind, created_at, read] + properties: + id: {type: string, format: uuid} + actor_id: {type: string, format: uuid} + actor_name: {type: string} + kind: {type: string, enum: [friend_request, friend_accepted, message, music]} + created_at: {type: string, format: date-time} + read: {type: boolean} + SocialInbox: + type: object + required: [people, notifications, unread] + properties: + people: + type: array + items: {$ref: '#/components/schemas/SocialPerson'} + notifications: + type: array + items: {$ref: '#/components/schemas/SocialNotification'} + unread: {type: integer} + SocialAction: + type: object + required: [action, target_id] + properties: + action: {type: string, enum: [request, accept, remove, block, unblock, read_chat, read_notification]} + target_id: {type: string, format: uuid} + up_to_seq: {type: integer, format: int64, minimum: 1} + SendDirectMessage: + type: object + required: [id, text, attachment_kind] + properties: + id: {type: string, format: uuid} + text: {type: string, maxLength: 4000} + attachment_kind: {type: string, enum: [none, track, playlist]} + attachment_id: {type: string, format: uuid} + DirectMessage: + type: object + required: [id, seq, sender_id, text, attachment_kind, created_at, read] + properties: + id: {type: string, format: uuid} + seq: {type: integer, format: int64} + sender_id: {type: string, format: uuid} + text: {type: string} + attachment_kind: {type: string, enum: [none, track, playlist]} + attachment_id: {type: string, format: uuid} + created_at: {type: string, format: date-time} + read: {type: boolean} ServerSource: type: object required: [id, name, url] diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index c397c69..0022ffc 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -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 = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' }; +const ICONS: Record = { 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 = { 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)} /> @@ -42,6 +46,7 @@ export default function TabsLayout() { + @@ -53,11 +58,13 @@ function TabBar({ routes, active, labels, + badges, onPress, }: { routes: string[]; active: number; labels: Record; + badges: Record; 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 }} > - + + + {!!badges[name] && {badges[name]! > 99 ? '99+' : badges[name]}} + {labels[name]} diff --git a/apps/mobile/src/app/(tabs)/friends.tsx b/apps/mobile/src/app/(tabs)/friends.tsx new file mode 100644 index 0000000..abc9fd9 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/friends.tsx @@ -0,0 +1,2 @@ +import { SocialScreen } from '../../features/social/Social'; +export default function FriendsTab() { return ; } diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 1345417..b130815 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -110,6 +110,9 @@ function Screens() { + + + diff --git a/apps/mobile/src/app/player.tsx b/apps/mobile/src/app/player.tsx index 5cd4356..3eea00c 100644 --- a/apps/mobile/src/app/player.tsx +++ b/apps/mobile/src/app/player.tsx @@ -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 ( + {sharing && server && setSharing(false)} />} router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}> diff --git a/apps/mobile/src/app/playlists.tsx b/apps/mobile/src/app/playlists.tsx index b6b5ab1..5d58f08 100644 --- a/apps/mobile/src/app/playlists.tsx +++ b/apps/mobile/src/app/playlists.tsx @@ -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(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(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(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 t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching} + return {shareToken && server && p && setShareToken(null)} />} t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching} ListHeaderComponent={ + {!!navigator.share && } +

{t('social.send_friend')}

+ {inbox.isPending &&

{t('social.loading')}

} + {inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') &&

{t('social.no_friends_share')}

} + {inbox.data?.people.filter(p => p.relationship === 'friend').map(p => )} + {action.error || inbox.error ? errorText(action.error ?? inbox.error) : null} + + ; +} + +export function SocialPage({ peer = '' }: { peer?: string }) { + const { t } = useTranslation(); + const { api, inbox, key } = useSocial(); + const cache = useQueryClient(); + const navigate = useNavigate(); + const errorText = useErrorText(); + const [tab, setTab] = useState<'people' | 'notifications'>('people'); + const [search, setSearch] = useState(''); + const [q, setQ] = useState(''); + useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]); + const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 }); + const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); + const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend'); + const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? []; + const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); void navigate({ to: '/friends', search: { peer: id } }); }; + return
+

{t('social.title')}

{t('social.subtitle')}

+ + {inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null} + {inbox.isPending &&

{t('social.loading')}

} + {tab === 'notifications' ?
+ {inbox.data?.notifications.length === 0 &&

{t('social.no_notifications')}

} + {inbox.data?.notifications.map(n =>
+
{n.actor_name}

{t(`social.kind_${n.kind}`)}

+
{!n.read && }
+
)} +
:
+ + {selected ? :
{t('social.choose_chat')}
} +
} +
; +} +function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected: boolean; open: () => void; act: (a: SocialAction['action']) => void }) { + const { t } = useTranslation(); + return
+
{p.display_name.slice(0, 1).toUpperCase()}
{p.display_name}
@{p.username} · {t(`social.rel_${p.relationship}`)}
+
+ {p.relationship === 'friend' && } + {p.relationship === 'none' && } + {p.relationship === 'incoming' && } + {['incoming', 'outgoing', 'friend'].includes(p.relationship) && } + +
+
; +} +function Chat({ person }: { person: SocialPerson }) { + const { t } = useTranslation(); + const { api, key, user } = useSocial(); + const cache = useQueryClient(); + const errorText = useErrorText(); + const [text, setText] = useState(''); + const pending = useRef<{ id: string; text: string } | null>(null); + const messages = useInfiniteQuery({ queryKey: [...key, 'chat', person.id], initialPageParam: undefined as number | undefined, + queryFn: ({ pageParam }) => api.messages(person.id, pageParam), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined, refetchInterval: 8_000 }); + const list = [...(messages.data?.pages ?? [])].reverse().flat(); + const last = list.at(-1)?.seq; + const unread = list.some(m => m.sender_id !== user.id && !m.read); + const [visible, setVisible] = useState(document.visibilityState === 'visible'); + useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []); + useEffect(() => { + if (!last || !unread || !visible) return; + let cancelled = false; + void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => { if (!cancelled) void cache.invalidateQueries({ queryKey: key }); }).catch(() => undefined); + return () => { cancelled = true; }; + }, [last, unread, visible, api, person.id, cache, user.id, messages.dataUpdatedAt]); + const end = useRef(null); + useEffect(() => { end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last]); + const send = useMutation({ mutationFn: async () => { + if (!pending.current || pending.current.text !== text) pending.current = { id: uuidv7(), text }; + return api.send(person.id, { ...pending.current, attachment_kind: 'none' }); + }, onSuccess: async () => { setText(''); pending.current = null; await cache.invalidateQueries({ queryKey: key }); } }); + return
+
{person.display_name}

{t('social.chat_hint')}

+ {messages.error || send.error ? errorText(messages.error ?? send.error) : null} +
+ {messages.hasNextPage && } + {messages.isPending &&

{t('social.loading')}

} + {!list.length && messages.isSuccess &&

{t('social.first_message')}

} + {list.map(m =>
+
{m.text}
+ {attachmentPath(m) && {t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗} + {new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''} +
)}
+
+
{ e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}> +