diff --git a/api/openapi.yaml b/api/openapi.yaml index cba07b0..015f42d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -22,6 +22,118 @@ tags: security: - bearerAuth: [] paths: + /api/v1/me/social/recommendations: + put: + operationId: setFriendsRecommendations + tags: [me] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [enabled] + properties: + enabled: {type: boolean} + responses: + '204': {description: Saved} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/listening: + get: + operationId: getListeningState + tags: [me] + responses: + '200': + description: Listening preferences, current room and pending requests + content: + application/json: + schema: {$ref: '#/components/schemas/ListeningState'} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/listening/preferences: + put: + operationId: setListeningPreferences + tags: [me] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ListeningPreferences'} + responses: + '204': {description: Saved} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/listening/actions: + post: + operationId: listeningAction + tags: [me] + description: Join requests target the host; invites target a guest and use the sender's own stream. Accept/decline/cancel target a request UUID. Leave/end target the room UUID. Automatic acceptance always respects the receiving user's admission policy. No control transfer. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ListeningAction'} + responses: + '204': {description: Applied} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/listening/state: + put: + operationId: publishListeningState + tags: [me] + description: Only the room owner's source device may publish. Monotonic sequence prevents stale updates; room expires after 30 seconds without host heartbeat. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ListeningRoomInput'} + responses: + '204': {description: Applied} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/activity: + put: + operationId: publishListeningActivity + tags: [me] + description: Ephemeral playback lease, expires after 90 seconds. Ignored when visibility is nobody. Sequence is monotonic per device; paused updates clear only that device. No offline replay. + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ListeningActivityInput'} + responses: + '204': {description: Applied} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/activity/visibility: + put: + operationId: setActivityVisibility + tags: [me] + description: Applies to all devices. Nobody clears all current activity immediately. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [visibility] + properties: + visibility: {$ref: '#/components/schemas/ActivityVisibility'} + responses: + '204': {description: Saved} + default: {$ref: '#/components/responses/Problem'} + /api/v1/social/activity/{userId}/playlist: + get: + operationId: getActivityPlaylist + tags: [me] + description: Read-only current playlist subject to live visibility, friendship, blocking and expiry. Does not create or reveal a permanent share token. + parameters: + - in: path + name: userId + required: true + schema: {type: string, format: uuid} + responses: + '200': + description: Read-only playlist + content: + application/json: + schema: {$ref: '#/components/schemas/Playlist'} + default: {$ref: '#/components/responses/Problem'} /api/v1/me/social: get: operationId: getSocial @@ -79,6 +191,9 @@ paths: - in: query name: before schema: {type: integer, format: int64, minimum: 1} + - in: query + name: q + schema: {type: string, maxLength: 100} responses: '200': description: Message page @@ -104,6 +219,69 @@ paths: application/json: schema: {$ref: '#/components/schemas/DirectMessage'} default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/chats/{peerId}/meta: + parameters: + - in: path + name: peerId + required: true + schema: {type: string, format: uuid} + get: + operationId: getChatMeta + tags: [me] + responses: + '200': + description: Ephemeral typing indicator, friends only + content: + application/json: + schema: + type: object + required: [typing] + properties: + typing: {type: boolean} + default: {$ref: '#/components/responses/Problem'} + put: + operationId: setChatTyping + tags: [me] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [typing] + properties: + typing: {type: boolean} + responses: + '204': {description: Applied, lease expires after 6 seconds} + default: {$ref: '#/components/responses/Problem'} + /api/v1/me/social/chats/{peerId}/messages/{messageId}: + patch: + operationId: updateChatMessage + tags: [me] + description: Friends only. Edit/delete are sender-only. Delete removes content for both participants; react toggles one allowed emoji. Reply targets must belong to the same conversation. + parameters: + - in: path + name: peerId + required: true + schema: {type: string, format: uuid} + - in: path + name: messageId + required: true + schema: {type: string, format: uuid} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [action] + properties: + action: {type: string, enum: [edit, delete, react]} + text: {type: string, maxLength: 4000} + emoji: {type: string, enum: ['❤️', '👍', '🔥', '😂', '🎵', '👏']} + responses: + '204': {description: Applied} + default: {$ref: '#/components/responses/Problem'} /api/v1/admin/server/updates: get: operationId: getServerUpdates @@ -2076,6 +2254,103 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: + ListeningPreferences: + type: object + required: [requests, auto_accept, autoplay] + properties: + requests: {$ref: '#/components/schemas/ActivityVisibility'} + auto_accept: {$ref: '#/components/schemas/ActivityVisibility'} + autoplay: {type: boolean, default: true} + ListeningAction: + type: object + required: [id, action, target_id] + properties: + id: {type: string, format: uuid} + action: {type: string, enum: [join, invite, accept, decline, cancel, leave, end]} + target_id: {type: string, format: uuid} + device_id: {type: string, format: uuid} + track_id: {type: string, format: uuid} + position_ms: {type: integer, minimum: 0} + playing: {type: boolean} + ListeningRoomInput: + type: object + required: [room_id, device_id, sequence, playing, position_ms] + properties: + room_id: {type: string, format: uuid} + device_id: {type: string, format: uuid} + sequence: {type: integer, format: int64, minimum: 1} + track_id: {type: string, format: uuid} + playing: {type: boolean} + position_ms: {type: integer, minimum: 0} + ListeningRoom: + type: object + required: [id, owner_id, owner_name, device_id, track_id, position_ms, playing, updated_at, participants] + properties: + id: {type: string, format: uuid} + owner_id: {type: string, format: uuid} + owner_name: {type: string} + device_id: {type: string, format: uuid} + track_id: {type: string, format: uuid, nullable: true} + position_ms: {type: integer} + playing: {type: boolean} + updated_at: {type: string, format: date-time} + participants: + type: array + items: + type: object + required: [id, name] + properties: + id: {type: string, format: uuid} + name: {type: string} + ListeningRequest: + type: object + required: [id, sender_id, sender_name, recipient_id, owner_id, kind, expires_at] + properties: + id: {type: string, format: uuid} + sender_id: {type: string, format: uuid} + sender_name: {type: string} + recipient_id: {type: string, format: uuid} + owner_id: {type: string, format: uuid} + kind: {type: string, enum: [join, invite]} + expires_at: {type: string, format: date-time} + ListeningState: + type: object + required: [preferences, room, requests, server_time] + properties: + preferences: {$ref: '#/components/schemas/ListeningPreferences'} + room: + nullable: true + allOf: + - {$ref: '#/components/schemas/ListeningRoom'} + requests: + type: array + items: {$ref: '#/components/schemas/ListeningRequest'} + server_time: {type: string, format: date-time} + ActivityVisibility: + type: string + enum: [nobody, friends, everyone] + default: nobody + ListeningActivityInput: + type: object + required: [device_id, sequence, playing, context] + properties: + device_id: {type: string, format: uuid} + sequence: {type: integer, format: int64, minimum: 1} + playing: {type: boolean} + position_ms: {type: integer, minimum: 0, default: 0} + track_id: {type: string, format: uuid} + context: {type: string, enum: [other, wave, playlist]} + playlist_id: {type: string, format: uuid} + ListeningActivity: + type: object + required: [track_id, track_title, artist, context] + properties: + track_id: {type: string, format: uuid} + track_title: {type: string} + artist: {type: string} + context: {type: string, enum: [other, wave, playlist]} + playlist_id: {type: string, format: uuid} + playlist_name: {type: string} SocialPerson: type: object required: [id, username, display_name, relationship, unread] @@ -2085,6 +2360,7 @@ components: display_name: {type: string} relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]} unread: {type: integer} + activity: {$ref: '#/components/schemas/ListeningActivity'} SocialNotification: type: object required: [id, actor_id, actor_name, kind, created_at, read] @@ -2092,13 +2368,15 @@ components: 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]} + kind: {type: string, enum: [friend_request, friend_accepted, message, music, listening_request, listening_invite, listening_accepted]} created_at: {type: string, format: date-time} read: {type: boolean} SocialInbox: type: object - required: [people, notifications, unread] + required: [people, notifications, unread, activity_visibility, friends_recommendations] properties: + friends_recommendations: {type: boolean, default: true} + activity_visibility: {$ref: '#/components/schemas/ActivityVisibility'} people: type: array items: {$ref: '#/components/schemas/SocialPerson'} @@ -2121,6 +2399,7 @@ components: text: {type: string, maxLength: 4000} attachment_kind: {type: string, enum: [none, track, playlist]} attachment_id: {type: string, format: uuid} + reply_to: {type: string, format: uuid} DirectMessage: type: object required: [id, seq, sender_id, text, attachment_kind, created_at, read] @@ -2133,6 +2412,21 @@ components: attachment_id: {type: string, format: uuid} created_at: {type: string, format: date-time} read: {type: boolean} + reply_to: {type: string, format: uuid} + reply_preview: {type: string} + edited_at: {type: string, format: date-time} + deleted: {type: boolean} + reactions: + type: array + items: + $ref: '#/components/schemas/MessageReaction' + MessageReaction: + type: object + required: [emoji, count, mine] + properties: + emoji: {type: string} + count: {type: integer} + mine: {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 0022ffc..0d573c0 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -8,7 +8,8 @@ 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 { NativeChatProvider, useSocialUnread } from '../../features/social/Social'; +import { ListeningBanner } from '../../features/social/Listening'; import { UpdateDialog } from '../../ui/UpdateDialog'; const ICONS: Record = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' }; @@ -26,12 +27,13 @@ export default function TabsLayout() { }; const net = useNetInfo(); return ( - <> + {net.isConnected === false && } ( + r.name)} @@ -50,7 +52,7 @@ export default function TabsLayout() { - + ); } diff --git a/apps/mobile/src/app/player.tsx b/apps/mobile/src/app/player.tsx index 3eea00c..84dd5c4 100644 --- a/apps/mobile/src/app/player.tsx +++ b/apps/mobile/src/app/player.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native'; import { MusicShareSheet } from '../features/social/Social'; +import { ListeningBanner } from '../features/social/Listening'; import { trackShareUrl } from '@pipu/core'; import { useSessionCtx, useUser } from '../lib/session'; @@ -22,6 +23,7 @@ export default function PlayerScreen() { const { t } = useTranslation(); const router = useRouter(); const st = usePlayerState(); + const locked = st.listening?.locked === true; const prefs = useUser().ui_prefs; const { server } = useSessionCtx(); const [sharing, setSharing] = useState(false); @@ -45,6 +47,7 @@ export default function PlayerScreen() { return ( {sharing && server && setSharing(false)} />} + router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}> @@ -94,7 +97,7 @@ export default function PlayerScreen() { setBarWidth(e.nativeEvent.layout.width || 1)} - onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)} + disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)} accessibilityRole="adjustable" accessibilityLabel={t('player.progress')} accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }} @@ -116,18 +119,18 @@ export default function PlayerScreen() { react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}> - void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}> + void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}> player.toggle()} + disabled={locked} onPress={() => player.toggle()} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')} style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }} > - player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}> + player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}> react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}> diff --git a/apps/mobile/src/features/social/ChatConversation.tsx b/apps/mobile/src/features/social/ChatConversation.tsx new file mode 100644 index 0000000..7b81fb3 --- /dev/null +++ b/apps/mobile/src/features/social/ChatConversation.tsx @@ -0,0 +1,61 @@ +import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core'; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'expo-router'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native'; +import { eventId } from '../../player/eventId'; +import { useColors } from '../../theme'; +import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit'; +import { readDraft, saveDraft } from './drafts'; + +function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) { + const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current; + useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]); + return {children}; +} +export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) { + const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText(); + const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState(''); + const [reply,setReply]=useState(null);const [editing,setEditing]=useState(null);const [menu,setMenu]=useState(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true); + const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null); + useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]); + useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]); + useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]); + useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]); + useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]); + useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]); + const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false}); + const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false}); + const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read); + useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]); + const refresh=()=>cache.invalidateQueries({queryKey}); + const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh}); + const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}}); + const scroll=useRef(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]); + const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);}; + const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);}; + return +

{person.display_name}

{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}
+ {if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}> + {messages.hasNextPage&&} + {messages.isPending &&

{t('social.loading')}

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

{t(q ? 'social.chat_no_results' : 'social.first_message')}

} + {list.map((m, i) => + {(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && } + {m.id === firstUnread && !q &&
{t('social.new_messages')}
} +
+ {m.reply_to &&
{m.reply_preview || t('social.message_deleted')}
} +
{m.deleted ? t('social.message_deleted') : m.text}
+ {!m.deleted && attachmentPath(m) && {t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗} + {!m.deleted && !!m.reactions?.length &&
{m.reactions.map(r => )}
} +
{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''} + {!m.deleted && { if (editing) cancelEdit(); setReply(m); } }, + ...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })), + ...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []), + ]} />} +
+
+
)} + {send.isPending && !editing && !list.some(m => m.id === pending.current?.id) &&
{text} · …
} +
+
+
{active && meta.data?.typing ? `${person.display_name} ${t('social.typing')}` : ''}
+ {(reply || editing) &&
{t(editing ? 'social.editing' : 'social.replying')}

{(editing ?? reply)?.text}

} +
{ e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}> +