feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
This commit is contained in:
1 parent
0a05bfe2cd
commit
11a16cd023
59 files changed
+5010
-530
No files matched your search
+296
-2
@@ -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]
|
||||
|
||||
@@ -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<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
|
||||
@@ -26,12 +27,13 @@ export default function TabsLayout() {
|
||||
};
|
||||
const net = useNetInfo();
|
||||
return (
|
||||
<>
|
||||
<NativeChatProvider>
|
||||
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
||||
<Tabs
|
||||
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
|
||||
tabBar={({ state, navigation }) => (
|
||||
<View>
|
||||
<ListeningBanner />
|
||||
<MiniPlayer />
|
||||
<TabBar
|
||||
routes={state.routes.map((r) => r.name)}
|
||||
@@ -50,7 +52,7 @@ export default function TabsLayout() {
|
||||
<Tabs.Screen name="profile" />
|
||||
</Tabs>
|
||||
<UpdateDialog />
|
||||
</>
|
||||
</NativeChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Screen>
|
||||
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
|
||||
<ListeningBanner />
|
||||
<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')}>
|
||||
@@ -94,7 +97,7 @@ export default function PlayerScreen() {
|
||||
<View style={{ gap: 6 }}>
|
||||
<Pressable
|
||||
onLayout={(e: LayoutChangeEvent) => 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() {
|
||||
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
|
||||
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
|
||||
<Icon name="skip-previous" size={40} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => 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' }}
|
||||
>
|
||||
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
|
||||
<Icon name="skip-next" size={40} color={colors.text} />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
|
||||
|
||||
@@ -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 <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
|
||||
}
|
||||
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; 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<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(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<ScrollView>(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 <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
|
||||
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
|
||||
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
|
||||
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
|
||||
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
|
||||
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
|
||||
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
|
||||
<Bubble mine={m.sender_id===userId} animate={!reduced}>
|
||||
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
|
||||
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
|
||||
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
|
||||
</Pressable>
|
||||
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
|
||||
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
|
||||
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{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')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
|
||||
</Bubble>
|
||||
</View>)}
|
||||
</ScrollView>
|
||||
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
|
||||
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
|
||||
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
|
||||
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
|
||||
</KeyboardAvoidingView>;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
|
||||
function useListening() {
|
||||
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
|
||||
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
|
||||
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||
return { api, key, query, user };
|
||||
}
|
||||
export function ListeningPanel() {
|
||||
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
|
||||
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
|
||||
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
|
||||
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
|
||||
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
|
||||
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
|
||||
</View>}
|
||||
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
|
||||
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
return <><Button title={t('social.together')} variant="ghost" onPress={() => setOpen(true)} />{open && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
|
||||
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
|
||||
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
|
||||
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
|
||||
</ScrollView></Screen></Modal>;
|
||||
}
|
||||
export function ListeningBanner() {
|
||||
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
|
||||
if (!room) return null;
|
||||
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
|
||||
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
|
||||
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
|
||||
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
|
||||
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
|
||||
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
|
||||
</View>;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { player } from '../../player/controller';
|
||||
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||
|
||||
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
const user = useUser();
|
||||
@@ -17,20 +17,22 @@ export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
|
||||
const { t } = useTranslation();
|
||||
const errorText = useErrorText();
|
||||
const [playError, setPlayError] = useState<unknown>();
|
||||
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id], queryFn: async () => {
|
||||
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
|
||||
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
|
||||
if (kind === 'track') {
|
||||
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
|
||||
return { name: track.title, tracks: [track] };
|
||||
return { id: track.id, name: track.title, tracks: [track] };
|
||||
}
|
||||
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
|
||||
} });
|
||||
return <Screen><FlatList data={query.data?.tracks ?? []} keyExtractor={x => x.id}
|
||||
const visible = query.isError ? undefined : query.data;
|
||||
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
|
||||
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
|
||||
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
|
||||
<H1>{query.data?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
|
||||
{query.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
|
||||
</View>}
|
||||
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(query.data?.tracks ?? [], index, { type: 'collection', id, label: query.data?.name }).catch(setPlayError)} />}
|
||||
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
|
||||
/></Screen>;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { eventId } from '../../player/eventId';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppState, Clipboard, KeyboardAvoidingView, Modal, ScrollView, Share, Text, View } from 'react-native';
|
||||
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, useUser } from '../../lib/session';
|
||||
import { useColors } from '../../theme';
|
||||
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
|
||||
@@ -20,6 +23,30 @@ function useSocial() {
|
||||
return { api, key, inbox, user, active };
|
||||
}
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
|
||||
export function NativeChatProvider({ children }: { children: ReactNode }) {
|
||||
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
|
||||
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
|
||||
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
|
||||
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
|
||||
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
|
||||
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
|
||||
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
|
||||
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
|
||||
</Screen>
|
||||
</View></View>
|
||||
</Modal>
|
||||
</ChatDockContext.Provider>;
|
||||
}
|
||||
|
||||
export function FriendRecommendations() {
|
||||
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
|
||||
}
|
||||
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { inbox, api, key } = useSocial();
|
||||
@@ -31,7 +58,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
|
||||
const send = useMutation({ mutationFn: async (peer: string) => {
|
||||
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
|
||||
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
|
||||
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
|
||||
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
|
||||
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
|
||||
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
@@ -49,6 +76,7 @@ export function MusicShareSheet({ attachment, url, close }: { attachment: MusicA
|
||||
}
|
||||
|
||||
export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
const dock = useContext(ChatDockContext);
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
@@ -62,12 +90,20 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
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 act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
|
||||
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
|
||||
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
|
||||
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
|
||||
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
|
||||
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||
<Muted>{t('social.activity_visibility')}</Muted>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
|
||||
<Muted>{t('social.activity_privacy_hint')}</Muted>
|
||||
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
|
||||
</View>
|
||||
<ListeningPanel />
|
||||
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
|
||||
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
|
||||
@@ -87,7 +123,15 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
|
||||
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
|
||||
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
|
||||
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => setPeer(p.id)} />}
|
||||
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
|
||||
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
|
||||
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
|
||||
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
|
||||
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
|
||||
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
|
||||
</View>}
|
||||
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
|
||||
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
|
||||
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
|
||||
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
|
||||
@@ -96,52 +140,9 @@ export function SocialScreen({ root = false }: { root?: boolean }) {
|
||||
</>}
|
||||
</ScrollView></Screen>;
|
||||
}
|
||||
function Chat({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const { server } = useSessionCtx();
|
||||
const { api, key, user, active } = useSocial();
|
||||
const router = useRouter();
|
||||
const cache = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [text, setText] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
useFocusEffect(useCallback(() => { setFocused(true); return () => setFocused(false); }, []));
|
||||
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, enabled: active && focused, refetchInterval: active && focused ? 8_000 : false });
|
||||
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);
|
||||
useEffect(() => {
|
||||
if (last && unread && active && focused) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey: key })).catch(() => undefined);
|
||||
}, [last, unread, active, focused, api, person.id, messages.dataUpdatedAt]);
|
||||
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 }); } });
|
||||
const scroll = useRef<ScrollView>(null);
|
||||
const [scrollBottom, setScrollBottom] = useState(true);
|
||||
useEffect(() => { setScrollBottom(true); }, [last]);
|
||||
return <KeyboardAvoidingView behavior="padding" style={{ flex: 1 }}>
|
||||
<View style={{ padding: 16, gap: 8 }}><H1>{person.display_name}</H1><Muted>{t('social.chat_hint')}</Muted><ErrorBox>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</ErrorBox></View>
|
||||
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16, gap: 12 }} onContentSizeChange={() => { if (scrollBottom) scroll.current?.scrollToEnd({ animated: false }); }}>
|
||||
{messages.hasNextPage && <Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={() => { setScrollBottom(false); void messages.fetchNextPage(); }} />}
|
||||
{messages.isPending && <Muted>{t('social.loading')}</Muted>}
|
||||
{messages.isSuccess && !list.length && <Muted>{t('social.first_message')}</Muted>}
|
||||
{list.map(m => <View key={m.id} style={{ alignSelf: m.sender_id === user.id ? 'flex-end' : 'flex-start', maxWidth: '88%', padding: 14, borderRadius: 16, backgroundColor: m.sender_id === user.id ? colors['accent-soft'] : colors.surface2, gap: 8 }}>
|
||||
<Text style={{ color: colors.text, fontSize: 15 }}>{m.text}</Text>
|
||||
{attachmentPath(m) && <Button title={t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} variant="secondary" onPress={() => {
|
||||
if (!server || !m.attachment_id) return;
|
||||
if (m.attachment_kind === 'track') router.push({ pathname: '/social-track', params: { id: m.attachment_id } });
|
||||
else router.push({ pathname: '/social-playlist', params: { id: m.attachment_id } });
|
||||
}} />}
|
||||
<Muted>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</Muted>
|
||||
</View>)}
|
||||
</ScrollView>
|
||||
<View style={{ padding: 16, gap: 12, borderTopWidth: 1, borderTopColor: colors.line }}>
|
||||
<Field label={t('social.message')} value={text} onChangeText={setText} maxLength={4000} multiline editable={!send.isPending} />
|
||||
<Button title={t('social.send')} disabled={!text.trim()} busy={send.isPending} onPress={() => send.mutate()} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>;
|
||||
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
|
||||
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
|
||||
const [focused,setFocused]=useState(false);
|
||||
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
|
||||
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { sha256 } from 'js-sha256';
|
||||
import { chatDraftKey } from '@pipu/core';
|
||||
|
||||
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
|
||||
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
|
||||
export async function readDraft(server: string, user: string, peer: string) {
|
||||
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
|
||||
}
|
||||
export async function saveDraft(server: string, user: string, peer: string, text: string) {
|
||||
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
|
||||
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
|
||||
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
createActivityPublisher,
|
||||
type ActivityPublisher,
|
||||
ListeningCoordinator,
|
||||
type ListeningRoom,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveInfo,
|
||||
@@ -32,6 +36,7 @@ export { offlineWave };
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
listening?: import('@pipu/core').ListeningClientState;
|
||||
ready: boolean;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
|
||||
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
|
||||
*/
|
||||
class Controller {
|
||||
private activity: ActivityPublisher | null = null;
|
||||
private listening: ListeningCoordinator | null = null;
|
||||
private interrupted = false;
|
||||
private remoteRoom: string | null = null;
|
||||
private queueVersion = 0;
|
||||
private session: AuthSession | null = null;
|
||||
private server = '';
|
||||
private events: EventBuffer | null = null;
|
||||
@@ -104,12 +114,27 @@ class Controller {
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.activity?.update(this.state);
|
||||
this.listening?.update();
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
|
||||
attach(session: AuthSession | null, server: string, userId: string | null) {
|
||||
if (this.session === session) return;
|
||||
this.activity?.dispose();
|
||||
this.listening?.dispose();
|
||||
this.listening = null;
|
||||
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
|
||||
if (session && this.activity) {
|
||||
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
|
||||
read: this.getState,
|
||||
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
|
||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
this.listening.start();
|
||||
}
|
||||
void this.events?.flush();
|
||||
this.session = session;
|
||||
this.server = server;
|
||||
@@ -240,35 +265,49 @@ class Controller {
|
||||
}
|
||||
|
||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
if (this.listening?.locked) return;
|
||||
const version = ++this.queueVersion;
|
||||
const current = () => version === this.queueVersion && !this.listening?.locked;
|
||||
const list = tracks.filter(playable);
|
||||
const target = tracks[index];
|
||||
if (!target || !list.length) return;
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
const native = await this.toRntp(list);
|
||||
if (!current()) return;
|
||||
this.leaveWave();
|
||||
this.finishAttempt(this.lastPositionMs / 1000);
|
||||
const start = Math.max(list.indexOf(target), 0);
|
||||
this.update({ queue: list, context, index: start, track: list[start] ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(list));
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.skip(start);
|
||||
if (!current()) return;
|
||||
await TrackPlayer.play();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.listening?.locked) return;
|
||||
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.listening?.locked) return;
|
||||
void TrackPlayer.skipToNext().catch(() => undefined);
|
||||
}
|
||||
|
||||
async prev() {
|
||||
if (this.listening?.locked) return;
|
||||
const { position } = await TrackPlayer.getProgress();
|
||||
if (this.listening?.locked) return;
|
||||
if (position > 3) await TrackPlayer.seekTo(0);
|
||||
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
|
||||
}
|
||||
|
||||
seek(ms: number) {
|
||||
if (this.listening?.locked) return;
|
||||
const t = this.state.track;
|
||||
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
|
||||
this.attempt?.seek(ms);
|
||||
@@ -306,6 +345,7 @@ class Controller {
|
||||
// --- «Моя волна» ---
|
||||
|
||||
async startWave(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
if (!this.session) return;
|
||||
await this.setup();
|
||||
this.leaveWave();
|
||||
@@ -335,6 +375,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||
@@ -422,18 +463,28 @@ class Controller {
|
||||
|
||||
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
|
||||
async restore() {
|
||||
if (!this.session || this.state.track) return;
|
||||
if (!this.session || this.state.track || this.listening?.locked) return;
|
||||
const session = this.session;
|
||||
const initial = this.state;
|
||||
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
try {
|
||||
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
|
||||
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
|
||||
if (!current()) return;
|
||||
if (!st.track_id || !st.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
|
||||
if (!current()) return;
|
||||
const tracks = res.items.filter(playable);
|
||||
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
|
||||
if (!tracks.length) return;
|
||||
const native = await this.toRntp(tracks);
|
||||
if (!current()) return;
|
||||
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
|
||||
await TrackPlayer.reset();
|
||||
await TrackPlayer.add(await this.toRntp(tracks));
|
||||
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||
await TrackPlayer.add(native);
|
||||
if (this.state.queue !== tracks || this.listening?.locked) return;
|
||||
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
|
||||
} catch {
|
||||
// офлайн или нет сохранённого состояния
|
||||
@@ -441,6 +492,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.queueVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.attempt = null;
|
||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||
@@ -451,6 +503,35 @@ class Controller {
|
||||
this.save();
|
||||
void this.events?.flush();
|
||||
}
|
||||
|
||||
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
|
||||
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
|
||||
setInterrupted(paused: boolean) { this.interrupted = paused; }
|
||||
beginListeningAudio() { this.listening?.beginAudio(); }
|
||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||
if (!room.track_id) { await TrackPlayer.pause(); return; }
|
||||
await this.setup();
|
||||
if (!current()) return;
|
||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
const native = await this.toRntp([track]);
|
||||
if (!current()) return;
|
||||
this.remoteRoom = room.id;
|
||||
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
|
||||
await TrackPlayer.pause();
|
||||
if (!current()) return;
|
||||
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
|
||||
await TrackPlayer.reset();
|
||||
if (!current()) return;
|
||||
await TrackPlayer.add(native);
|
||||
}
|
||||
if (!current()) return;
|
||||
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
|
||||
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
|
||||
if (!current()) { await TrackPlayer.pause(); return; }
|
||||
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
|
||||
else if (this.state.playing) await TrackPlayer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
export const player = new Controller();
|
||||
@@ -7,14 +7,15 @@ import { player } from './controller';
|
||||
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
|
||||
*/
|
||||
export async function PlaybackService(): Promise<void> {
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
|
||||
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
|
||||
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
|
||||
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
|
||||
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
|
||||
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
|
||||
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
|
||||
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
|
||||
player.setInterrupted(paused || permanent);
|
||||
if (permanent) void TrackPlayer.pause();
|
||||
else if (paused) void TrackPlayer.pause();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FriendRecommendations } from '../features/social/Social';
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||
@@ -38,6 +39,7 @@ export function RecommendationSettings() {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const errorText = useErrorText();
|
||||
return <View>
|
||||
<FriendRecommendations />
|
||||
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||
|
||||
@@ -70,3 +70,18 @@ it('does not let a late context response replace a manually selected playlist',
|
||||
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||
expect(player.getState().context?.type).toBe('album');
|
||||
});
|
||||
|
||||
it('does not restore a stale saved queue after manual playback starts', async () => {
|
||||
const { player } = await import('../src/player/controller');
|
||||
const saved = deferred<unknown>();
|
||||
const get = vi.fn().mockReturnValue(saved.promise);
|
||||
const post = vi.fn();
|
||||
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||
const restoring = player.restore();
|
||||
await vi.waitFor(() => expect(get).toHaveBeenCalled());
|
||||
await player.playList([track('chosen')], 0, { type: 'queue' });
|
||||
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
|
||||
await restoring;
|
||||
expect(player.getState().track?.id).toBe('chosen');
|
||||
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
|
||||
for (const width of [320, 800, 1280]) {
|
||||
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
|
||||
await page.goto('/login');
|
||||
@@ -21,6 +22,10 @@ for (const width of [320, 800, 1280]) {
|
||||
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
|
||||
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
|
||||
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AuthSession } from '@pipu/core';
|
||||
import { AuthSession, refreshListening } from '@pipu/core';
|
||||
import { createI18n } from '@pipu/i18n';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
@@ -15,6 +15,74 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('social', () => {
|
||||
it('follows a room with locked playback controls, independent volume and local exit', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
const id = crypto.randomUUID();
|
||||
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
|
||||
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
|
||||
refreshListening(session);
|
||||
await screen.findByText('Слушаете вместе с bob');
|
||||
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
|
||||
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
|
||||
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
|
||||
expect(api.listeningMembers.has(alice.id)).toBe(false);
|
||||
});
|
||||
it('edits, replies, reacts, searches, deletes and preserves a minimized draft', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Написать' }));
|
||||
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
|
||||
await user.type(composer(), 'Первый вариант');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
await screen.findByText('Первый вариант');
|
||||
const actions = async (name: string) => {
|
||||
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
|
||||
await user.click(await screen.findByRole('menuitem', { name }));
|
||||
};
|
||||
await actions('Редактировать');
|
||||
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
|
||||
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||
await screen.findByText('Исправленный текст', { exact: true });
|
||||
await actions('❤️');
|
||||
await screen.findByRole('button', { name: '❤️ 1' });
|
||||
await actions('Ответить');
|
||||
await user.type(composer(), 'Ответ на музыку');
|
||||
await user.click(screen.getByRole('button', { name: 'Отправить' }));
|
||||
await waitFor(() => expect(api.messages).toHaveLength(2));
|
||||
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
|
||||
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
|
||||
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
|
||||
await actions('Удалить для всех');
|
||||
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
|
||||
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
|
||||
await user.type(composer(), 'Мой черновик');
|
||||
await user.click(screen.getByRole('button', { name: 'Свернуть чат' }));
|
||||
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'bob' }));
|
||||
expect(composer()).toHaveValue('Мой черновик');
|
||||
});
|
||||
|
||||
it('saves privacy and independent request admission preferences', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
|
||||
const privacy = await screen.findByRole('group', { name: 'Показывать мой статус' });
|
||||
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
|
||||
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
|
||||
await user.click(screen.getByText('Слушать вместе', { exact: true }));
|
||||
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
|
||||
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
|
||||
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
|
||||
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
|
||||
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
|
||||
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
|
||||
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
|
||||
});
|
||||
it('shows persistent notifications and acknowledges only the chosen event', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const bob = makeUser('bob', 'user');
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
@@ -148,6 +148,7 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
};
|
||||
|
||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
|
||||
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => <SocialPage peer={socialRoute.useSearch().peer} /> });
|
||||
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
@@ -295,6 +296,7 @@ const routeTree = rootRoute.addChildren([
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
activityPlaylistRoute,
|
||||
socialRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { attachmentPath, CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Field, FormError, Menu } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
|
||||
export function ChatConversation({ person, api, queryKey, userId, compact = false, active = true }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; compact?: boolean; active?: boolean }) {
|
||||
const { t, i18n } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText();
|
||||
const server = useDesktop()?.server ?? window.location.origin;
|
||||
const draftKey = chatDraftKey(server, userId, person.id);
|
||||
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
|
||||
const [reply, setReply] = useState<DirectMessage | null>(null); const [editing, setEditing] = useState<DirectMessage | null>(null);
|
||||
const [search, setSearch] = useState(''); const [q, setQ] = useState(''); const [emojis, setEmojis] = useState(false);
|
||||
const beforeEdit = useRef(''); const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
|
||||
const typedAt = useRef(0);
|
||||
const [visible, setVisible] = useState(document.visibilityState === 'visible');
|
||||
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
|
||||
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
|
||||
useEffect(() => {
|
||||
if (editing) return;
|
||||
const timer = setTimeout(() => { try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Private mode can deny storage. */ } }, 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, draftKey, editing]);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, active, visible, editing, person.id, api]);
|
||||
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 && visible, refetchInterval: active && visible ? 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 && visible ? 3000 : false });
|
||||
const list = [...(messages.data?.pages ?? [])].reverse().flat(); const last = list.at(-1)?.seq;
|
||||
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
|
||||
useEffect(() => {
|
||||
if (last && firstUnread && visible && active && !q) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
|
||||
}, [last, firstUnread, visible, active, q, api, person.id, cache, messages.dataUpdatedAt]);
|
||||
const end = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { if (active && !q) end.current?.scrollIntoView?.({ block: 'nearest' }); }, [last, active, q]);
|
||||
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: uuidv7(), 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 cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
|
||||
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
|
||||
return <section className={s.chat} aria-label={t('social.chat')}>
|
||||
{!compact && <header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>}
|
||||
<div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} /></div>
|
||||
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
|
||||
<div className={s.messages}>
|
||||
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
|
||||
{messages.isPending && <p>{t('social.loading')}</p>}
|
||||
{!list.length && messages.isSuccess && <p className={s.empty}>{t(q ? 'social.chat_no_results' : 'social.first_message')}</p>}
|
||||
{list.map((m, i) => <Fragment key={m.id}>
|
||||
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
|
||||
{m.id === firstUnread && !q && <div className={s.date}>{t('social.new_messages')}</div>}
|
||||
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''}`}>
|
||||
{m.reply_to && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
|
||||
<div className={s.messageText}>{m.deleted ? t('social.message_deleted') : m.text}</div>
|
||||
{!m.deleted && attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
|
||||
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
|
||||
<div className={s.messageFoot}><small className={s.muted}>{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')}` : ''}</small>
|
||||
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger="•••" items={[
|
||||
{ label: t('social.reply'), onSelect: () => { 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' }) }] : []),
|
||||
]} />}
|
||||
</div>
|
||||
</article>
|
||||
</Fragment>)}
|
||||
{send.isPending && !editing && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
|
||||
<div ref={end} />
|
||||
</div>
|
||||
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? `${person.display_name} ${t('social.typing')}` : ''}</div>
|
||||
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')}>×</Button></div>}
|
||||
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
|
||||
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
|
||||
<Button variant="ghost" aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)}>☺</Button>
|
||||
{emojis && <div className={s.reactions}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); }}>{emoji}</button>)}</div>}
|
||||
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t(editing ? 'playlists.save' : 'social.send')}</Button>
|
||||
</form>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { useDesktop } from '../../desktop';
|
||||
import { usePlayer, usePlayerState } from '../../player/context';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
|
||||
function useListening() {
|
||||
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
|
||||
const api = useMemo(() => socialApi(session), [session]);
|
||||
const key = ['listening', server, user.id] as const;
|
||||
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
|
||||
return { api, key, query, user };
|
||||
}
|
||||
export function ListeningPanel() {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
const { api, key, query, user } = useListening();
|
||||
const prefs = query.data?.preferences;
|
||||
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <div className={s.stack}>
|
||||
<details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
|
||||
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
|
||||
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
|
||||
</details>
|
||||
{query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
|
||||
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
|
||||
</div>;
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
return <><Button variant="ghost" onClick={() => setOpen(true)}>{t('social.together')}</Button>{open && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
const { api, key } = useListening(); const state = usePlayerState();
|
||||
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
|
||||
const [id] = useState(uuidv7);
|
||||
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
|
||||
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
|
||||
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
|
||||
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
|
||||
</Dialog>;
|
||||
}
|
||||
export function ListeningBanner() {
|
||||
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
|
||||
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
|
||||
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
|
||||
if (!room) return null;
|
||||
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
|
||||
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
|
||||
<FormError>{action.error ? errorText(action.error) : null}</FormError>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { attachmentPath, socialApi, uuidv7, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { attachmentPath, socialApi, uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { Link, useNavigate, useRouterState } from '@tanstack/react-router';
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
@@ -8,6 +8,8 @@ import { useDesktop } from '../../desktop';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||
import s from './social.module.css';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
|
||||
const scopes = new WeakMap<object, number>();
|
||||
let nextScope = 0;
|
||||
@@ -25,12 +27,35 @@ function useSocial() {
|
||||
|
||||
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
|
||||
|
||||
export function FriendRecommendations() {
|
||||
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
|
||||
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
|
||||
}
|
||||
|
||||
type ShareValue = { attachment: MusicAttachment; url: string };
|
||||
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
|
||||
const ChatContext = createContext<(id: string) => void>(() => {});
|
||||
export const useMusicShare = () => useContext(ShareContext);
|
||||
export function SocialProvider({ children }: { children: ReactNode }) {
|
||||
const [share, setShare] = useState<ShareValue | null>(null);
|
||||
return <ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider>;
|
||||
const [chat, setChat] = useState<{ peer: string | null; open: boolean }>({ peer: null, open: false });
|
||||
return <ShareContext.Provider value={setShare}><ChatContext.Provider value={id => setChat({ peer: id, open: true })}>{children}<ChatDock chat={chat} change={setChat} />{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ChatContext.Provider></ShareContext.Provider>;
|
||||
}
|
||||
|
||||
function ChatDock({ chat, change }: { chat: { peer: string | null; open: boolean }; change: (value: { peer: string | null; open: boolean }) => void }) {
|
||||
const { inbox } = useSocial(); const { t } = useTranslation();
|
||||
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
|
||||
const selected = friends.find(p => p.id === chat.peer);
|
||||
const unread = friends.reduce((sum, p) => sum + p.unread, 0);
|
||||
const pathname = useRouterState({ select: state => state.location.pathname });
|
||||
useEffect(() => { if (window.matchMedia?.('(max-width: 1023px)').matches) change({ ...chat, open: false }); }, [pathname]);
|
||||
return <aside className={`${s.dock} ${chat.open ? s.dockOpen : ''}`} aria-label={t('social.messages')}>
|
||||
<div className={s.dockHeader}><button type="button" aria-expanded={chat.open} onClick={() => change({ ...chat, open: !chat.open })}>{selected?.display_name ?? t('social.messages')}{unread ? ` · ${unread}` : ''}</button>{chat.open && <><button type="button" onClick={() => change({ peer: null, open: true })} aria-label={t('social.people')}>☰</button><button type="button" onClick={() => change({ ...chat, open: false })} aria-label={t('social.minimize')}>−</button></>}</div>
|
||||
<div hidden={!chat.open}>
|
||||
{selected ? <Chat key={selected.id} person={selected} compact active={chat.open} /> : <div className={s.dockList}>{friends.map(p => <button key={p.id} type="button" onClick={() => change({ peer: p.id, open: true })}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><span>{p.display_name}</span>{p.unread ? <small>{p.unread}</small> : null}</button>)}{!friends.length && <p className={s.empty}>{t('social.no_friends_share')}</p>}</div>}
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -70,6 +95,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
const { api, inbox, key } = useSocial();
|
||||
const cache = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const showChat = useContext(ChatContext);
|
||||
const errorText = useErrorText();
|
||||
const [tab, setTab] = useState<'people' | 'notifications'>('people');
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -77,11 +103,18 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
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 visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
|
||||
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 } }); };
|
||||
const open = (id: string) => { setTab('people'); setSearch(''); setQ(''); if (inbox.data?.people.some(p => p.id === id && p.relationship === 'friend')) showChat(id); else void navigate({ to: '/friends', search: { peer: id } }); };
|
||||
return <div className={s.page}>
|
||||
<header><h1>{t('social.title')}</h1><p className={s.muted}>{t('social.subtitle')}</p></header>
|
||||
<fieldset className={s.privacy} disabled={!inbox.data || visibility.isPending}>
|
||||
<legend>{t('social.activity_visibility')}</legend>
|
||||
<div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(inbox.data?.activity_visibility ?? 'nobody') === v} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => visibility.mutate(v)}>{t(`social.visibility_${v}`)}</Button>)}</div>
|
||||
<p className={s.muted}>{t('social.activity_privacy_hint')}</p>
|
||||
<FormError>{visibility.error ? errorText(visibility.error) : null}</FormError>
|
||||
</fieldset>
|
||||
<ListeningPanel />
|
||||
<nav className={s.tabs} aria-label={t('social.title')}>
|
||||
<Button variant={tab === 'people' ? 'primary' : 'ghost'} onClick={() => setTab('people')}>{t('social.people')}</Button>
|
||||
<Button variant={tab === 'notifications' ? 'primary' : 'ghost'} onClick={() => setTab('notifications')}>{t('social.notifications')} {inbox.data?.unread || ''}</Button>
|
||||
@@ -94,14 +127,13 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
<div><strong>{n.actor_name}</strong><p>{t(`social.kind_${n.kind}`)}</p><time className={s.muted}>{new Date(n.created_at).toLocaleString()}</time></div>
|
||||
<div className={s.actions}><Button onClick={() => open(n.actor_id)}>{t('social.open')}</Button>{!n.read && <Button disabled={action.isPending} onClick={() => action.mutate({ action: 'read_notification', target_id: n.id })}>{t('social.mark_read')}</Button>}</div>
|
||||
</article>)}
|
||||
</div> : <div className={s.layout}>
|
||||
</div> : <div>
|
||||
<aside className={s.stack}>
|
||||
<Field label={t('social.search')} value={search} onChange={setSearch} type="search" maxLength={80} />
|
||||
{q.length >= 2 && results.isFetching && <p role="status">{t('social.loading')}</p>}
|
||||
{!people.length && inbox.data && !results.isFetching && <p className={s.empty}>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</p>}
|
||||
{people.map(p => <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={a => action.mutate({ action: a, target_id: p.id })} />)}
|
||||
</aside>
|
||||
{selected ? <Chat key={selected.id} person={selected} /> : <div className={s.empty}>{t('social.choose_chat')}</div>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -109,8 +141,16 @@ function Person({ person: p, busy, open, act, selected }: { person: SocialPerson
|
||||
const { t } = useTranslation();
|
||||
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
|
||||
<div className={s.identity}><span className={s.avatar} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div></div></div>
|
||||
{p.activity && <div className={s.activity}>
|
||||
<span className={s.listening}>{t('social.listening')}</span>
|
||||
<Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>
|
||||
{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}
|
||||
{p.activity.context === 'wave' && <span className={s.muted}>{t('wave.title')}</span>}
|
||||
{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}
|
||||
</div>}
|
||||
<div className={s.actions}>
|
||||
{p.relationship === 'friend' && <Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button>}
|
||||
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
|
||||
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
|
||||
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
|
||||
{['incoming', 'outgoing', 'friend'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')}</Button>}
|
||||
@@ -118,48 +158,7 @@ function Person({ person: p, busy, open, act, selected }: { person: SocialPerson
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
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<HTMLDivElement>(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 <section className={s.chat} aria-label={t('social.chat')}>
|
||||
<header><strong>{person.display_name}</strong><p className={s.muted}>{t('social.chat_hint')}</p></header>
|
||||
<FormError>{messages.error || send.error ? errorText(messages.error ?? send.error) : null}</FormError>
|
||||
<div className={s.messages}>
|
||||
{messages.hasNextPage && <Button busy={messages.isFetchingNextPage} onClick={() => void messages.fetchNextPage()}>{t('social.older')}</Button>}
|
||||
{messages.isPending && <p>{t('social.loading')}</p>}
|
||||
{!list.length && messages.isSuccess && <p className={s.empty}>{t('social.first_message')}</p>}
|
||||
{list.map(m => <article key={m.id} className={`${s.bubble} ${m.sender_id === user.id ? s.mine : ''}`}>
|
||||
<div>{m.text}</div>
|
||||
{attachmentPath(m) && <Link className={s.music} to={m.attachment_kind === 'track' ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id: m.attachment_id!, shareId: m.attachment_id! }}>{t(m.attachment_kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')} ↗</Link>}
|
||||
<small className={s.muted}>{new Date(m.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{m.sender_id === user.id ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
|
||||
</article>)}<div ref={end} />
|
||||
</div>
|
||||
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
|
||||
<textarea aria-label={t('social.message')} placeholder={t('social.message')} maxLength={4000} rows={2} value={text} disabled={send.isPending} onChange={e => setText(e.target.value)} />
|
||||
<Button type="submit" variant="primary" busy={send.isPending} disabled={!text.trim()}>{t('social.send')}</Button>
|
||||
</form>
|
||||
</section>;
|
||||
function Chat({ person, compact = false, active = true }: { person: SocialPerson; compact?: boolean; active?: boolean }) {
|
||||
const { api,key,user }=useSocial();
|
||||
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} compact={compact} active={active} />;
|
||||
}
|
||||
@@ -4,6 +4,53 @@
|
||||
.tabs, .actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.layout { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
|
||||
.stack { display: grid; gap: 12px; }
|
||||
.privacy { border: 1px solid var(--line); border-radius: 16px; padding: 16px; margin: 0; min-width: 0; }
|
||||
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
|
||||
.privacy p { margin-bottom: 0; }
|
||||
.activity { display: grid; gap: 5px; border-left: 2px solid var(--ok); padding-left: 12px; overflow-wrap: anywhere; }
|
||||
.activity a { color: var(--accent2); text-decoration: none; }
|
||||
.activity a:hover { text-decoration: underline; }
|
||||
.listening { font-size: 12px; color: var(--ok); }
|
||||
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
|
||||
.choice { display: flex; gap: 8px; padding: 10px 0; align-items: center; }
|
||||
.dock { position: fixed; right: 24px; bottom: 104px; z-index: 45; width: 220px; border-radius: 16px; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); overflow: hidden; }
|
||||
.dockOpen { width: min(390px, calc(100vw - 32px)); max-height: calc(100dvh - 124px); }
|
||||
.dockHeader { display: flex; align-items: center; min-height: 48px; padding: 0 8px; background: var(--surface2); gap: 4px; }
|
||||
.dockHeader button { border: 0; background: transparent; color: var(--text); font: inherit; padding: 12px; cursor: pointer; }
|
||||
.dockHeader button:first-child { flex: 1; text-align: left; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dock .chat { border: 0; border-radius: 0; display: flex; flex-direction: column; max-height: calc(100dvh - 174px); }
|
||||
.dock .messages { flex: 1; min-height: 60px; max-height: min(40vh, 360px); padding: 14px; }
|
||||
.dock .composer { padding: 12px; flex-wrap: wrap; }
|
||||
.dock .composer textarea { flex-basis: 100%; }
|
||||
.dockList { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.dockList button { display: flex; gap: 12px; align-items: center; border: 0; background: transparent; color: var(--text); padding: 12px; width: 100%; cursor: pointer; border-radius: 10px; text-align: left; font: inherit; }
|
||||
.dockList button:hover { background: var(--hover); }
|
||||
.dockList small { margin-left: auto; color: var(--accent2); }
|
||||
.dockOpen { animation: chatReveal .22s cubic-bezier(.2,.8,.2,1); transform-origin: bottom right; }
|
||||
.bubble { animation: messageIn .18s ease-out; }
|
||||
.chatSearch { padding: 10px 14px; border-bottom: 1px solid var(--line); }
|
||||
.chatSearch label { font-size: 11px; }
|
||||
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0; }
|
||||
.quote { margin: 0; padding-left: 10px; border-left: 2px solid var(--accent2); color: var(--text2); font-size: 12px; max-height: 60px; overflow: hidden; }
|
||||
.messageText { user-select: text; }
|
||||
.messageFoot { display: flex; align-items: center; gap: 8px; justify-content: space-between; }
|
||||
.messageFoot button { font-size: 12px; min-width: 24px; }
|
||||
.deleted { font-style: italic; opacity: .7; }
|
||||
.sending { opacity: .6; }
|
||||
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); color: var(--text); padding: 5px 9px; cursor: pointer; transition: transform .15s, background .15s; }
|
||||
.reactions button:hover { transform: translateY(-2px); }
|
||||
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
|
||||
.typing { min-height: 19px; padding: 0 14px; color: var(--text3); font-size: 11px; }
|
||||
.replyBar { display: flex; gap: 10px; align-items: center; padding: 8px 14px; background: var(--accent-soft); }
|
||||
.replyBar > div { flex: 1; min-width: 0; }
|
||||
.replyBar small { color: var(--accent2); }
|
||||
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; }
|
||||
@keyframes chatReveal { from { opacity: 0; transform: translateY(16px) scale(.97); } to { opacity: 1; transform: none; } }
|
||||
@keyframes messageIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
@media(prefers-reduced-motion: reduce) { .dockOpen, .bubble { animation: none; } .reactions button { transition: none; } .reactions button:hover { transform: none; } }
|
||||
@media(max-width: 760px) { .dock { bottom: 162px; right: 12px; width: 160px; } .dockOpen { width: calc(100vw - 24px); } .dock .messages { max-height: 32vh; } }
|
||||
@media(max-width: 760px) { .dockOpen { max-height: calc(100dvh - 182px); } .dock .chat { max-height: calc(100dvh - 232px); } }
|
||||
.person { padding: 16px; border: 1px solid var(--line); background: var(--surface); border-radius: 16px; display: grid; gap: 16px; overflow-wrap: anywhere; }
|
||||
.identity { display: flex; gap: 12px; align-items: center; }
|
||||
.avatar { width: 40px; height: 40px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -57,7 +57,20 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
|
||||
export function ActivityPlaylistPage({ userId }: { userId: string }) {
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const { t } = useTranslation();
|
||||
const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 });
|
||||
return <div className={s.page}>
|
||||
<Link to="/friends">← {t('social.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <PageSkeleton />}
|
||||
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) {
|
||||
const openShare = useMusicShare();
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
@@ -108,7 +121,7 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
|
||||
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
|
||||
{!readOnly && <>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
import { Onboarding } from './Onboarding';
|
||||
import { FriendRecommendations } from '../features/social/Social';
|
||||
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
@@ -371,6 +372,7 @@ function RecommendationSettings() {
|
||||
const errorText = useErrorText();
|
||||
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
|
||||
return <section className={s.section}>
|
||||
<FriendRecommendations />
|
||||
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
|
||||
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
|
||||
<FormError>{options.error ? errorText(options.error) : null}</FormError>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
EventBuffer,
|
||||
createActivityPublisher,
|
||||
type ActivityPublisher,
|
||||
ListeningCoordinator,
|
||||
type ListeningRoom,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveOutcome,
|
||||
@@ -35,6 +39,10 @@ const PRELOAD_BEFORE_MS = 20_000;
|
||||
* Media Session и сохранение «трек и момент» (локально и на сервере).
|
||||
*/
|
||||
export class Player {
|
||||
private activity: ActivityPublisher | null = null;
|
||||
private listening: ListeningCoordinator | null = null;
|
||||
private applyingSync = false;
|
||||
private remoteRoom: string | null = null;
|
||||
readonly offline: DesktopOffline | null;
|
||||
private retryEvents: (() => Promise<void>) | null = null;
|
||||
private readonly engine: AudioEngine;
|
||||
@@ -93,6 +101,17 @@ export class Player {
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
start() {
|
||||
this.activity ??= createActivityPublisher(this.session);
|
||||
this.activity.update(this.state);
|
||||
if (!this.listening) {
|
||||
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
|
||||
read: this.getState,
|
||||
pause: () => { this.restoreVersion++; this.leaveWave(); this.audio.pause(); },
|
||||
apply: (room, position, current) => this.syncListening(room, position, current),
|
||||
changed: listening => this.update({ listening }),
|
||||
});
|
||||
this.listening.start();
|
||||
}
|
||||
this.offline?.activate();
|
||||
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||
if (this.retryEvents) {
|
||||
@@ -116,6 +135,8 @@ export class Player {
|
||||
|
||||
private update(patch: Partial<PlayerState>) {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.activity?.update(this.state);
|
||||
this.listening?.update();
|
||||
for (const fn of this.listeners) fn();
|
||||
this.media.syncDesktopMedia();
|
||||
}
|
||||
@@ -135,6 +156,7 @@ export class Player {
|
||||
|
||||
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
|
||||
playList(tracks: Track[], index: number, context: PlayContext) {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
@@ -151,6 +173,7 @@ export class Player {
|
||||
|
||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||
if (this.listening?.locked) return;
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
@@ -190,6 +213,7 @@ export class Player {
|
||||
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||
*/
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
if (this.listening?.locked) return;
|
||||
const wave = this.state.wave;
|
||||
if (!wave) return this.startWave(activity, mood);
|
||||
this.update({ wave: { ...wave, activity, mood } });
|
||||
@@ -215,6 +239,7 @@ export class Player {
|
||||
}
|
||||
|
||||
stopWave() {
|
||||
if (this.listening?.locked) return;
|
||||
this.leaveWave();
|
||||
this.pause();
|
||||
}
|
||||
@@ -247,11 +272,13 @@ export class Player {
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
if (this.state.playing) this.pause();
|
||||
else this.play();
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
if (!this.queue.current) return;
|
||||
if (!this.audio.src || !this.attempt) {
|
||||
void this.load(true, this.state.positionMs);
|
||||
@@ -263,10 +290,12 @@ export class Player {
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
this.audio.pause();
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.listening?.locked) return;
|
||||
this.finishAttempt('skip');
|
||||
if (this.queue.next()) {
|
||||
this.syncQueue();
|
||||
@@ -278,6 +307,7 @@ export class Player {
|
||||
|
||||
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
|
||||
prev() {
|
||||
if (this.listening?.locked) return;
|
||||
if (this.audio.currentTime > 3) {
|
||||
this.seek(0);
|
||||
return;
|
||||
@@ -290,6 +320,7 @@ export class Player {
|
||||
}
|
||||
|
||||
jump(index: number) {
|
||||
if (this.listening?.locked) return;
|
||||
this.finishAttempt('skip');
|
||||
this.queue.jump(index);
|
||||
this.syncQueue();
|
||||
@@ -297,6 +328,7 @@ export class Player {
|
||||
}
|
||||
|
||||
seek(ms: number) {
|
||||
if (this.listening?.locked && !this.applyingSync) return;
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
const to = Math.min(Math.max(ms, 0), track.duration_ms);
|
||||
@@ -323,12 +355,14 @@ export class Player {
|
||||
}
|
||||
|
||||
toggleShuffle() {
|
||||
if (this.listening?.locked) return;
|
||||
this.queue.setShuffle(!this.queue.shuffle);
|
||||
this.syncQueue();
|
||||
this.saveLocal();
|
||||
}
|
||||
|
||||
cycleRepeat() {
|
||||
if (this.listening?.locked) return;
|
||||
const order: RepeatMode[] = ['off', 'all', 'one'];
|
||||
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
|
||||
this.syncQueue();
|
||||
@@ -363,13 +397,13 @@ export class Player {
|
||||
|
||||
// --- загрузка трека ---
|
||||
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
private async load(autoplay: boolean, startMs = 0, current = () => true) {
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
const version = this.restoreVersion;
|
||||
if (this.offline) {
|
||||
await this.offline.ready.catch(() => undefined);
|
||||
if (this.queue.current !== track || version !== this.restoreVersion) return;
|
||||
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
|
||||
}
|
||||
this.offline?.keep([track.id]);
|
||||
const source = this.offline?.local(track).stream_url ?? track.stream_url;
|
||||
@@ -396,6 +430,7 @@ export class Player {
|
||||
try {
|
||||
const resumed = this.engine.resume();
|
||||
if (resumed) await resumed;
|
||||
if (!current() || version !== this.restoreVersion) return;
|
||||
await this.audio.play();
|
||||
} catch {
|
||||
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
|
||||
@@ -649,6 +684,10 @@ export class Player {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.listening?.dispose();
|
||||
this.listening = null;
|
||||
this.activity?.dispose();
|
||||
this.activity = null;
|
||||
this.restoreVersion++;
|
||||
this.wavePolicy.activate(null);
|
||||
this.fading = false;
|
||||
@@ -661,4 +700,30 @@ export class Player {
|
||||
this.media.dispose();
|
||||
void this.events.flush();
|
||||
}
|
||||
|
||||
beginListeningAudio() {
|
||||
if (this.state.listening?.room?.playing) {
|
||||
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
|
||||
}
|
||||
this.listening?.beginAudio();
|
||||
}
|
||||
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
|
||||
if (!room.track_id) { this.audio.pause(); return; }
|
||||
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
|
||||
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
|
||||
if (!current()) return;
|
||||
this.remoteRoom = room.id;
|
||||
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
|
||||
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
|
||||
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
|
||||
return;
|
||||
}
|
||||
if (!current()) return;
|
||||
this.applyingSync = true;
|
||||
try {
|
||||
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
|
||||
if (room.playing && !this.state.playing) this.play();
|
||||
if (!room.playing && this.state.playing) this.pause();
|
||||
} finally { this.applyingSync = false; }
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
listening?: import('@pipu/core').ListeningClientState;
|
||||
wave: WaveInfo | null;
|
||||
track: Track | null;
|
||||
queue: Track[];
|
||||
|
||||
@@ -210,6 +210,7 @@ export function Menu({
|
||||
header,
|
||||
align = 'end',
|
||||
triggerClassName,
|
||||
portal = false,
|
||||
}: {
|
||||
label: string;
|
||||
trigger: ReactNode;
|
||||
@@ -217,8 +218,10 @@ export function Menu({
|
||||
header?: ReactNode;
|
||||
align?: 'start' | 'end';
|
||||
triggerClassName?: string;
|
||||
portal?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState<CSSProperties>();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -246,7 +249,7 @@ export function Menu({
|
||||
els[next]?.focus();
|
||||
};
|
||||
const menu = (
|
||||
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
<div ref={menuRef} style={portal && !window.matchMedia?.('(max-width: 767px)').matches ? position : undefined} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
|
||||
{header}
|
||||
{items.map((it) => (
|
||||
<button
|
||||
@@ -273,11 +276,18 @@ export function Menu({
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={e => {
|
||||
if (portal) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const bottom = Math.max(12, window.innerHeight - rect.top + 6);
|
||||
setPosition({ position: 'fixed', zIndex: 60, top: 'auto', bottom, right: Math.max(12, window.innerWidth - rect.right), maxHeight: Math.max(120, window.innerHeight - bottom - 12), overflowY: 'auto' });
|
||||
}
|
||||
setOpen(v => !v);
|
||||
}}
|
||||
>
|
||||
{trigger}
|
||||
</button>
|
||||
{open && (window.matchMedia?.('(max-width: 767px)').matches
|
||||
{open && (portal || window.matchMedia?.('(max-width: 767px)').matches
|
||||
? createPortal(menu, document.body)
|
||||
: menu)}
|
||||
</div>
|
||||
|
||||
@@ -82,11 +82,21 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const blocks = new Set<string>();
|
||||
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
|
||||
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
|
||||
const activityVisibility = new Map<string, components['schemas']['ActivityVisibility']>();
|
||||
const friendRecommendations = new Map<string, boolean>();
|
||||
const activity = new Map<string, components['schemas']['ListeningActivity'] & { device_id: string; position_ms: number }>();
|
||||
const listeningPreferences = new Map<string, components['schemas']['ListeningPreferences']>();
|
||||
const listeningRooms = new Map<string, components['schemas']['ListeningRoom']>();
|
||||
const listeningMembers = new Map<string, string>();
|
||||
const listeningRequests = new Map<string, components['schemas']['ListeningRequest'] & { guest_id: string }>();
|
||||
const typing = new Map<string, number>();
|
||||
const defaultListening = { requests: 'nobody', auto_accept: 'nobody', autoplay: true } as const;
|
||||
const pair = (a: string, b: string) => [a, b].sort().join(':');
|
||||
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
|
||||
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
|
||||
const f = friendships.get(pair(me, p.id));
|
||||
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username,
|
||||
const visible = activityVisibility.get(p.id) === 'everyone' || activityVisibility.get(p.id) === 'friends' && f?.accepted;
|
||||
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username, ...(visible && activity.has(p.id) && !isBlocked(me,p.id) ? { activity: activity.get(p.id) } : {}),
|
||||
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
|
||||
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
|
||||
};
|
||||
@@ -216,7 +226,63 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
|
||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||
|
||||
if (path === '/api/v1/me/social/activity/visibility' && body) { activityVisibility.set(user.id, body.visibility as components['schemas']['ActivityVisibility']); if (body.visibility === 'nobody') activity.delete(user.id); return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/recommendations' && body) { friendRecommendations.set(user.id,!!body.enabled);return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/activity' && body) {
|
||||
if (activityVisibility.get(user.id) !== undefined && activityVisibility.get(user.id) !== 'nobody' && body.playing) {
|
||||
const track = albumTracks.find(t => t.id === body.track_id); const playlist = playlists.get(String(body.playlist_id));
|
||||
if (track) activity.set(user.id,{track_id:track.id,track_title:track.title,artist:'Кино',context: body.context as 'wave'|'playlist'|'other',device_id:String(body.device_id),position_ms:Number(body.position_ms??0),...(playlist ? {playlist_id:playlist.id,playlist_name:playlist.name} : {})});
|
||||
} else activity.delete(user.id);
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const activityPlaylistPath=/^\/api\/v1\/social\/activity\/([^/]+)\/playlist$/.exec(path);
|
||||
if (activityPlaylistPath) {
|
||||
const owner=users.get(activityPlaylistPath[1]!); const a=owner ? socialPerson(user.id,owner).activity : undefined; const p=a?.playlist_id ? playlists.get(a.playlist_id) : undefined;
|
||||
return p ? json({...p,share_id:null}) : problem(404,'social.activity_unavailable');
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/preferences' && body) { listeningPreferences.set(user.id,body as components['schemas']['ListeningPreferences']);return new Response(null,{status:204}); }
|
||||
if (path === '/api/v1/me/social/listening') {
|
||||
const room=listeningRooms.get(listeningMembers.get(user.id) ?? '');
|
||||
if(room)room.participants=[...listeningMembers].filter(([,id])=>id===room.id).map(([id])=>({id,name:users.get(id)?.display_name??users.get(id)?.username??id}));
|
||||
return json({preferences:listeningPreferences.get(user.id)??defaultListening,room:room??null,requests:[...listeningRequests.values()].filter(r=>r.recipient_id===user.id||r.sender_id===user.id),server_time:new Date().toISOString()});
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/state' && body) {
|
||||
const room=listeningRooms.get(String(body.room_id));if(!room||room.owner_id!==user.id||room.device_id!==body.device_id)return problem(403,'social.unavailable');
|
||||
Object.assign(room,{track_id:body.track_id??null,position_ms:body.position_ms,playing:body.playing,updated_at:new Date().toISOString()});return new Response(null,{status:204});
|
||||
}
|
||||
if (path === '/api/v1/me/social/listening/actions' && body) {
|
||||
const target=String(body.target_id);const op=String(body.action);
|
||||
if(op==='join'||op==='invite'){
|
||||
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
|
||||
const friend=!!friendships.get(pair(user.id,target))?.accepted;
|
||||
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.unavailable');
|
||||
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
|
||||
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
|
||||
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
|
||||
else listeningRequests.set(String(body.id),{id:String(body.id),owner_id:host,guest_id:guest,sender_id:user.id,sender_name:user.username,recipient_id:target,kind:op,expires_at:new Date(Date.now()+120000).toISOString()});
|
||||
}else if(op==='accept'||op==='decline'||op==='cancel'){
|
||||
const r=listeningRequests.get(target);if(r){if(op==='accept'&&r.recipient_id===user.id){const room=[...listeningRooms.values()].find(room=>room.owner_id===r.owner_id);if(room)listeningMembers.set(r.guest_id,room.id);}listeningRequests.delete(target);}
|
||||
}else if(op==='leave'||op==='end'){
|
||||
const room=listeningRooms.get(target);if(room?.owner_id===user.id){listeningRooms.delete(target);for(const [u,r]of listeningMembers)if(r===target)listeningMembers.delete(u);}else listeningMembers.delete(user.id);
|
||||
}
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
const chatMeta=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/meta$/.exec(path);
|
||||
if(chatMeta){const peer=chatMeta[1]!;if(!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');if(req.method==='PUT'){typing.set(`${user.id}:${peer}`,body?.typing?Date.now()+6000:0);return new Response(null,{status:204});}return json({typing:(typing.get(`${peer}:${user.id}`)??0)>Date.now()});}
|
||||
const updateMessage=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/messages\/([^/]+)$/.exec(path);
|
||||
if(updateMessage&&body){
|
||||
const peer=updateMessage[1]!;const m=messages.find(m=>m.id===updateMessage[2]&&pair(m.sender_id,m.recipient)===pair(user.id,peer));
|
||||
if(!m||!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');
|
||||
if(body.action!=='react'&&m.sender_id!==user.id)return problem(403,'social.unavailable');
|
||||
if(body.action==='edit'){m.text=String(body.text);m.edited_at=new Date().toISOString();}
|
||||
if(body.action==='delete'){m.deleted=true;m.text='';m.attachment_id=undefined;m.attachment_kind='none';m.reactions=[];}
|
||||
if(body.action==='react'){const reactions=m.reactions??[];const old=reactions.find(r=>r.emoji===body.emoji);m.reactions=old?reactions.filter(r=>r!==old):[...reactions,{emoji:String(body.emoji),count:1,mine:true}];}
|
||||
return new Response(null,{status:204});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/me/social') return json({
|
||||
activity_visibility: activityVisibility.get(user.id) ?? 'nobody',
|
||||
friends_recommendations: friendRecommendations.get(user.id) ?? true,
|
||||
people: [...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !blocks.has(`${p.id}:${user.id}`)).map(p => socialPerson(user.id, p)).filter(p => p.relationship !== 'none'),
|
||||
notifications: notifications.filter(n => n.recipient === user.id && !isBlocked(user.id, n.actor_id)),
|
||||
unread: notifications.filter(n => n.recipient === user.id && !n.read && !isBlocked(user.id, n.actor_id)).length,
|
||||
@@ -239,7 +305,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
|
||||
if (chatPath) {
|
||||
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
|
||||
if (req.method === 'GET') return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before')))).slice(-50));
|
||||
if (req.method === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q))).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
|
||||
if (req.method === 'POST' && body) {
|
||||
const old=messages.find(m => m.id===body.id);if(old)return json(old);
|
||||
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
|
||||
@@ -442,5 +508,5 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, signOut: () => (current = null) };
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, activityVisibility, activity, friendRecommendations, listeningPreferences, listeningRooms, listeningMembers, listeningRequests, signOut: () => (current = null) };
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import { SearchBox } from './SearchBox';
|
||||
import s from './AppShell.module.css';
|
||||
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
|
||||
import { useSocialUnread, SocialProvider } from '../features/social/Social';
|
||||
import { ListeningBanner } from '../features/social/Listening';
|
||||
|
||||
type NavPath =
|
||||
| '/friends'
|
||||
@@ -312,6 +313,7 @@ function ShellMain() {
|
||||
return (
|
||||
<main id="content" className={s.content} tabIndex={-1}>
|
||||
<DesktopOfflineBanner />
|
||||
<ListeningBanner />
|
||||
<div key={key} className={s.routeIn}>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -40,6 +40,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
const player = usePlayer();
|
||||
const openShare = useMusicShare();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const narrow = useNarrow();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
@@ -132,7 +133,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
@@ -140,11 +141,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={s.play}
|
||||
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||
aria-busy={st.loading || undefined}
|
||||
onClick={() => player.toggle()}
|
||||
disabled={locked} onClick={() => player.toggle()}
|
||||
>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={26} />
|
||||
</button>
|
||||
<button
|
||||
@@ -168,7 +169,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
aria-valuetext={formatDuration(st.positionMs)}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
@@ -190,7 +191,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={cx(s.icon, st.shuffle && s.accent)}
|
||||
aria-label={t('player.shuffle')}
|
||||
aria-pressed={st.shuffle}
|
||||
onClick={() => player.toggleShuffle()}
|
||||
disabled={locked} onClick={() => player.toggleShuffle()}
|
||||
>
|
||||
<Icon src={shuffleIcon} size={20} />
|
||||
</button>
|
||||
@@ -199,7 +200,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
||||
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||
aria-label={repeatLabel}
|
||||
title={repeatLabel}
|
||||
onClick={() => player.cycleRepeat()}
|
||||
disabled={locked} onClick={() => player.cycleRepeat()}
|
||||
>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||
</button>
|
||||
@@ -271,6 +272,7 @@ function NowPlayingSheet({
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const track = st.track;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -321,7 +323,7 @@ function NowPlayingSheet({
|
||||
step={1000}
|
||||
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||
aria-label={t('player.progress')}
|
||||
onChange={(e) => player.seek(Number(e.target.value))}
|
||||
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
|
||||
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||
/>
|
||||
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||
@@ -330,13 +332,13 @@ function NowPlayingSheet({
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
|
||||
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
|
||||
<Icon src={prevIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
|
||||
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} disabled={locked} onClick={() => player.toggle()}>
|
||||
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
|
||||
<Icon src={nextIcon} size={28} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
|
||||
@@ -344,10 +346,10 @@ function NowPlayingSheet({
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sheetExtra}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
|
||||
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} disabled={locked} onClick={() => player.toggleShuffle()}>
|
||||
<Icon src={shuffleIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
|
||||
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} disabled={locked} onClick={() => player.cycleRepeat()}>
|
||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
|
||||
</button>
|
||||
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
|
||||
@@ -379,6 +381,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const st = usePlayerState();
|
||||
const locked = st.listening?.locked === true;
|
||||
const upcoming = st.queue.slice(st.index, st.index + 40);
|
||||
const close = useRef<HTMLButtonElement>(null);
|
||||
// Клавиатура: при открытии фокус в панель, Escape закрывает.
|
||||
@@ -408,7 +411,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
|
||||
type="button"
|
||||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||||
aria-current={k === 0 || undefined}
|
||||
onClick={() => player.jump(st.index + k)}
|
||||
disabled={st.listening?.locked} onClick={() => player.jump(st.index + k)}
|
||||
>
|
||||
<Cover album={tr.album} size={40} />
|
||||
<span className={s.meta}>
|
||||
|
||||
+52
-1
@@ -31,7 +31,7 @@
|
||||
В скрытой вкладке и в фоне Android автоматического подтверждения нет.
|
||||
|
||||
Сейчас это уведомления **внутри работающего приложения**, с обновлением центра каждые 15 секунд
|
||||
и открытого чата каждые 8 секунд. Web использует стандартную приостановку фонового polling
|
||||
и открытого чата каждые 3 секунды. Web использует стандартную приостановку фонового polling
|
||||
React Query; Android останавливает polling при уходе приложения в фон. Уведомления ОС при
|
||||
закрытом клиенте (Web Push/FCM/нативный desktop) в эту реализацию не входят.
|
||||
|
||||
@@ -56,3 +56,54 @@ React Query; Android останавливает polling при уходе при
|
||||
`pnpm --filter @pipu/web test src/App.test.tsx -t 'social|playlist sharing'`,
|
||||
`pnpm --filter @pipu/web test:e2e e2e/social.spec.ts e2e/playlists.spec.ts`.
|
||||
Интеграционный сценарий включён в `ci-db`.
|
||||
|
||||
## Активность и приватность
|
||||
|
||||
В разделе друзей настройка «Показывать мой статус» принимает значения «Никому»
|
||||
(по умолчанию), «Друзьям», «Всем». Сервер проверяет аудиторию, дружбу и блокировки.
|
||||
При «Никому» сведения о текущем прослушивании не сохраняются. Переключение на это
|
||||
значение удаляет активность всех устройств. Публикация имеет последовательный номер
|
||||
и срок жизни 90 секунд; пауза снимает статус, потерянное соединение не оставляет вечный статус.
|
||||
|
||||
Карточка показывает трек и контекст: «Моя волна», плейлист или обычное прослушивание.
|
||||
Трек открывается в каталоге. Плейлист доступен для чтения, пока активность видима:
|
||||
этот переход не создаёт постоянной ссылки общего доступа и не раскрывает существующую.
|
||||
|
||||
## Совместное прослушивание
|
||||
|
||||
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
|
||||
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
|
||||
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
|
||||
Настройки приёма запросов/приглашений и автоматического принятия независимы,
|
||||
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
|
||||
Дополнительная настройка управляет запуском звука при подключении.
|
||||
|
||||
Только исходное устройство хозяина публикует состояние комнаты. Слушатели получают
|
||||
трек, паузу и позицию с поправкой на серверное время каждые 2 секунды. Обычные кнопки
|
||||
управления, очередь и перемотка заблокированы; громкость, реакции и выход доступны.
|
||||
Это синхронизация собственных потоков из каталога с допуском дрейфа 1,5 секунды,
|
||||
а не ретрансляция аудиобайта хозяина. Браузер может потребовать нажатия «Включить звук».
|
||||
После 10 секунд потери связи слушатель ставится на паузу, комната истекает через 30 секунд
|
||||
без хозяина, отсутствующий слушатель удаляется через 60 секунд, запрос действует 2 минуты.
|
||||
|
||||
## Чат
|
||||
|
||||
Web и desktop используют выдвижную панель справа снизу, Android — общую нижнюю панель.
|
||||
Поддерживаются текст и музыка, ответы с цитатой, шесть реакций, редактирование своих
|
||||
сообщений, удаление для всех, поиск по тексту, история, даты, прочтение и индикатор набора.
|
||||
Удаление очищает исходный текст и музыкальное вложение. Черновики разделены по серверу,
|
||||
пользователю и собеседнику. Скрытая панель не отмечает переписку прочитанной.
|
||||
Анимации учитывают системное уменьшение движения. Фото, файлы и голосовые не входят в версию.
|
||||
|
||||
Миграции `00020`–`00022` добавляют активность, комнаты и возможности чата.
|
||||
Уведомления дополнены запросом, приглашением и принятием совместного прослушивания.
|
||||
|
||||
## Сигнал друзей в рекомендациях
|
||||
|
||||
В настройках рекомендаций можно отключить влияние друзей. Используется только их
|
||||
текущая видимая активность с учётом аудитории и блокировок, без скрытой истории.
|
||||
Go передаёт до 30 допустимых треков; Python добавляет до 10 кандидатов, небольшой бонус
|
||||
с учётом выбранного контекста и не более одного кандидата с источником `friends` на выдачу.
|
||||
Дизлайки, недоступные треки и недавние повторы исключаются прежними фильтрами.
|
||||
При недоступном рекомендателе резервный подбор добавляет не более одного такого трека
|
||||
в каждую вторую выдачу; тематические волны с seed сохраняют свой сценарий.
|
||||
@@ -4,6 +4,139 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/me/social/recommendations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["setFriendsRecommendations"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/listening": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getListeningState"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/listening/preferences": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put: operations["setListeningPreferences"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/listening/actions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** @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. */
|
||||
post: operations["listeningAction"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/listening/state": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** @description Only the room owner's source device may publish. Monotonic sequence prevents stale updates; room expires after 30 seconds without host heartbeat. */
|
||||
put: operations["publishListeningState"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** @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. */
|
||||
put: operations["publishListeningActivity"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/activity/visibility": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/** @description Applies to all devices. Nobody clears all current activity immediately. */
|
||||
put: operations["setActivityVisibility"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/social/activity/{userId}/playlist": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Read-only current playlist subject to live visibility, friendship, blocking and expiry. Does not create or reveal a permanent share token. */
|
||||
get: operations["getActivityPlaylist"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -74,6 +207,41 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/chats/{peerId}/meta": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getChatMeta"];
|
||||
put: operations["setChatTyping"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/me/social/chats/{peerId}/messages/{messageId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** @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. */
|
||||
patch: operations["updateChatMessage"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/admin/server/updates": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1692,6 +1860,111 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
ListeningPreferences: {
|
||||
requests: components["schemas"]["ActivityVisibility"];
|
||||
auto_accept: components["schemas"]["ActivityVisibility"];
|
||||
/** @default true */
|
||||
autoplay: boolean;
|
||||
};
|
||||
ListeningAction: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
action: "join" | "invite" | "accept" | "decline" | "cancel" | "leave" | "end";
|
||||
/** Format: uuid */
|
||||
target_id: string;
|
||||
/** Format: uuid */
|
||||
device_id?: string;
|
||||
/** Format: uuid */
|
||||
track_id?: string;
|
||||
position_ms?: number;
|
||||
playing?: boolean;
|
||||
};
|
||||
ListeningRoomInput: {
|
||||
/** Format: uuid */
|
||||
room_id: string;
|
||||
/** Format: uuid */
|
||||
device_id: string;
|
||||
/** Format: int64 */
|
||||
sequence: number;
|
||||
/** Format: uuid */
|
||||
track_id?: string;
|
||||
playing: boolean;
|
||||
position_ms: number;
|
||||
};
|
||||
ListeningRoom: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
owner_id: string;
|
||||
owner_name: string;
|
||||
/** Format: uuid */
|
||||
device_id: string;
|
||||
/** Format: uuid */
|
||||
track_id: string | null;
|
||||
position_ms: number;
|
||||
playing: boolean;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
participants: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
ListeningRequest: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
sender_id: string;
|
||||
sender_name: string;
|
||||
/** Format: uuid */
|
||||
recipient_id: string;
|
||||
/** Format: uuid */
|
||||
owner_id: string;
|
||||
/** @enum {string} */
|
||||
kind: "join" | "invite";
|
||||
/** Format: date-time */
|
||||
expires_at: string;
|
||||
};
|
||||
ListeningState: {
|
||||
preferences: components["schemas"]["ListeningPreferences"];
|
||||
room: components["schemas"]["ListeningRoom"] | null;
|
||||
requests: components["schemas"]["ListeningRequest"][];
|
||||
/** Format: date-time */
|
||||
server_time: string;
|
||||
};
|
||||
/**
|
||||
* @default nobody
|
||||
* @enum {string}
|
||||
*/
|
||||
ActivityVisibility: "nobody" | "friends" | "everyone";
|
||||
ListeningActivityInput: {
|
||||
/** Format: uuid */
|
||||
device_id: string;
|
||||
/** Format: int64 */
|
||||
sequence: number;
|
||||
playing: boolean;
|
||||
/** @default 0 */
|
||||
position_ms: number;
|
||||
/** Format: uuid */
|
||||
track_id?: string;
|
||||
/** @enum {string} */
|
||||
context: "other" | "wave" | "playlist";
|
||||
/** Format: uuid */
|
||||
playlist_id?: string;
|
||||
};
|
||||
ListeningActivity: {
|
||||
/** Format: uuid */
|
||||
track_id: string;
|
||||
track_title: string;
|
||||
artist: string;
|
||||
/** @enum {string} */
|
||||
context: "other" | "wave" | "playlist";
|
||||
/** Format: uuid */
|
||||
playlist_id?: string;
|
||||
playlist_name?: string;
|
||||
};
|
||||
SocialPerson: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -1700,6 +1973,7 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
relationship: "none" | "incoming" | "outgoing" | "friend" | "blocked";
|
||||
unread: number;
|
||||
activity?: components["schemas"]["ListeningActivity"];
|
||||
};
|
||||
SocialNotification: {
|
||||
/** Format: uuid */
|
||||
@@ -1708,12 +1982,15 @@ export interface components {
|
||||
actor_id: string;
|
||||
actor_name: string;
|
||||
/** @enum {string} */
|
||||
kind: "friend_request" | "friend_accepted" | "message" | "music";
|
||||
kind: "friend_request" | "friend_accepted" | "message" | "music" | "listening_request" | "listening_invite" | "listening_accepted";
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
read: boolean;
|
||||
};
|
||||
SocialInbox: {
|
||||
/** @default true */
|
||||
friends_recommendations: boolean;
|
||||
activity_visibility: components["schemas"]["ActivityVisibility"];
|
||||
people: components["schemas"]["SocialPerson"][];
|
||||
notifications: components["schemas"]["SocialNotification"][];
|
||||
unread: number;
|
||||
@@ -1734,6 +2011,8 @@ export interface components {
|
||||
attachment_kind: "none" | "track" | "playlist";
|
||||
/** Format: uuid */
|
||||
attachment_id?: string;
|
||||
/** Format: uuid */
|
||||
reply_to?: string;
|
||||
};
|
||||
DirectMessage: {
|
||||
/** Format: uuid */
|
||||
@@ -1750,6 +2029,18 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
read: boolean;
|
||||
/** Format: uuid */
|
||||
reply_to?: string;
|
||||
reply_preview?: string;
|
||||
/** Format: date-time */
|
||||
edited_at?: string;
|
||||
deleted?: boolean;
|
||||
reactions?: components["schemas"]["MessageReaction"][];
|
||||
};
|
||||
MessageReaction: {
|
||||
emoji: string;
|
||||
count: number;
|
||||
mine: boolean;
|
||||
};
|
||||
ServerSource: {
|
||||
id: string;
|
||||
@@ -2917,6 +3208,192 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
setFriendsRecommendations: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Saved */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getListeningState: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Listening preferences, current room and pending requests */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningState"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
setListeningPreferences: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningPreferences"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Saved */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
listeningAction: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningAction"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Applied */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
publishListeningState: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningRoomInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Applied */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
publishListeningActivity: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ListeningActivityInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Applied */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
setActivityVisibility: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
visibility: components["schemas"]["ActivityVisibility"];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Saved */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getActivityPlaylist: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
userId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Read-only playlist */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Playlist"];
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getSocial: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2988,6 +3465,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
before?: number;
|
||||
q?: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
@@ -3036,6 +3514,90 @@ export interface operations {
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getChatMeta: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Ephemeral typing indicator, friends only */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
typing: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
setChatTyping: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
typing: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Applied */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
updateChatMessage: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
peerId: string;
|
||||
messageId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/** @enum {string} */
|
||||
action: "edit" | "delete" | "react";
|
||||
text?: string;
|
||||
/** @enum {string} */
|
||||
emoji?: "❤️" | "👍" | "🔥" | "😂" | "🎵" | "👏";
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Applied */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
default: components["responses"]["Problem"];
|
||||
};
|
||||
};
|
||||
getServerUpdates: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ActivityPublisher } from './activity';
|
||||
|
||||
const track = { id: '00000000-0000-4000-8000-000000000001' };
|
||||
const device = '00000000-0000-4000-8000-000000000002';
|
||||
describe('listening activity leases', () => {
|
||||
it('publishes actual playback, throttles progress, and clears on pause', async () => {
|
||||
let now = 1000; const sent: unknown[] = [];
|
||||
const publisher = new ActivityPublisher(async value => { sent.push(value); }, device, () => now);
|
||||
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent).toEqual([]);
|
||||
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 123 }); await publisher.idle();
|
||||
expect(sent[0]).toMatchObject({ context: 'wave', track_id: track.id, sequence: 1, position_ms: 123 });
|
||||
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 900 }); await publisher.idle(); expect(sent).toHaveLength(1);
|
||||
now += 20_001; publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 20123 }); await publisher.idle(); expect(sent).toHaveLength(2);
|
||||
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent.at(-1)).toMatchObject({ playing: false, sequence: 3 });
|
||||
publisher.dispose(); await publisher.idle();
|
||||
});
|
||||
it('serializes an in-flight play and a final stop without replaying intermediate tracks', async () => {
|
||||
let release!: () => void; const waiting = new Promise<void>(r => { release = r; }); const sent: { playing: boolean; sequence: number }[] = [];
|
||||
const publisher = new ActivityPublisher(async value => { sent.push(value); if (sent.length === 1) await waiting; }, device);
|
||||
publisher.update({ track, playing: true, context: null });
|
||||
publisher.update({ track: { id: device }, playing: true, context: null });
|
||||
publisher.dispose(); release(); await publisher.idle();
|
||||
expect(sent.map(v => [v.playing, v.sequence])).toEqual([[true, 1], [false, 2]]);
|
||||
});
|
||||
it('only treats UUID collection contexts as playlists', async () => {
|
||||
const sent: { context: string }[] = []; const publisher = new ActivityPublisher(async v => { sent.push(v); }, device);
|
||||
publisher.update({ track, playing: true, context: { type: 'collection', id: 'likes' } }); await publisher.idle(); expect(sent[0]?.context).toBe('other');
|
||||
publisher.update({ track, playing: true, context: { type: 'collection', id: device } }); await publisher.idle(); expect(sent[1]?.context).toBe('playlist');
|
||||
publisher.dispose(); await publisher.idle();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { type AuthSession, unwrap } from './auth';
|
||||
import { uuidv7, type PlayContext } from './player';
|
||||
|
||||
export type ActivityVisibility = components['schemas']['ActivityVisibility'];
|
||||
export type ListeningActivity = components['schemas']['ListeningActivity'];
|
||||
type Input = components['schemas']['ListeningActivityInput'];
|
||||
export interface ActivityPlaybackState {
|
||||
positionMs?: number;
|
||||
playing: boolean;
|
||||
track: { id: string } | null;
|
||||
context: PlayContext | null;
|
||||
wave?: unknown;
|
||||
}
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const publishers = new WeakMap<AuthSession, Set<ActivityPublisher>>();
|
||||
|
||||
/** Ephemeral, serialized heartbeats. Never persists or replays offline activity. */
|
||||
export class ActivityPublisher {
|
||||
private current: ActivityPlaybackState = { playing: false, track: null, context: null };
|
||||
private pending: Omit<Input, 'device_id' | 'sequence'> | null = null;
|
||||
private sequence = 0;
|
||||
private lastKey = '';
|
||||
private lastAttempt = 0;
|
||||
private active = false;
|
||||
private closed = false;
|
||||
private flight: Promise<void> | null = null;
|
||||
constructor(private readonly send: (body: Input) => Promise<void>, readonly deviceId: string, private readonly now = Date.now, private readonly onDispose = () => {}) {}
|
||||
|
||||
update(state: ActivityPlaybackState, force = false) {
|
||||
if (this.closed) return;
|
||||
this.current = state;
|
||||
const playing = state.playing && !!state.track;
|
||||
if (!playing && !this.active) return;
|
||||
const playlist = playing && !state.wave && state.context?.type === 'collection' && UUID.test(state.context.id ?? '') ? state.context.id : undefined;
|
||||
const body: Omit<Input, 'device_id' | 'sequence'> = playing
|
||||
? { position_ms: 0, playing: true, track_id: state.track!.id, context: state.wave || state.context?.type === 'wave' ? 'wave' : playlist ? 'playlist' : 'other', ...(playlist ? { playlist_id: playlist } : {}) }
|
||||
: { position_ms: 0, playing: false, context: 'other' };
|
||||
const key = JSON.stringify(body);
|
||||
if (!force && key === this.lastKey && (!playing || this.now() - this.lastAttempt < 20_000)) return;
|
||||
this.active ||= playing;
|
||||
this.lastKey = key;
|
||||
this.lastAttempt = this.now();
|
||||
this.pending = { ...body, ...(playing ? { position_ms: Math.max(0, Math.round(state.positionMs ?? 0)) } : {}) };
|
||||
if (!this.flight) this.flight = this.drain();
|
||||
}
|
||||
refresh() { this.update(this.current, true); }
|
||||
idle() { return this.flight ?? Promise.resolve(); }
|
||||
private async drain() {
|
||||
while (this.pending) {
|
||||
const body = this.pending;
|
||||
this.pending = null;
|
||||
try { await this.send({ ...body, device_id: this.deviceId, sequence: ++this.sequence }); }
|
||||
catch { /* A failed heartbeat expires; it must never interrupt playback. */ }
|
||||
}
|
||||
this.flight = null;
|
||||
}
|
||||
dispose() {
|
||||
if (this.closed) return;
|
||||
this.update({ playing: false, track: null, context: null }, true);
|
||||
this.closed = true;
|
||||
this.onDispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function createActivityPublisher(session: AuthSession, deviceId = uuidv7()) {
|
||||
let set = publishers.get(session);
|
||||
if (!set) { set = new Set(); publishers.set(session, set); }
|
||||
const publisher = new ActivityPublisher(async body => { unwrap(await session.client.PUT('/api/v1/me/social/activity', { body })); }, deviceId, Date.now, () => set!.delete(publisher));
|
||||
set.add(publisher);
|
||||
return publisher;
|
||||
}
|
||||
export function refreshListeningActivity(session: AuthSession) { publishers.get(session)?.forEach(p => p.refresh()); }
|
||||
@@ -24,6 +24,9 @@ export {
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { playlistShareUrl, trackShareUrl } from './share';
|
||||
export { socialApi, attachmentPath, type SocialPerson, type SocialInbox, type SocialAction, type DirectMessage, type SendDirectMessage, type MusicAttachment } from './social';
|
||||
export { CHAT_REACTIONS, chatDraftKey, type ChatMessageUpdate } from './social';
|
||||
export { ActivityPublisher, createActivityPublisher, type ActivityVisibility, type ListeningActivity } from './activity';
|
||||
export { ListeningCoordinator, refreshListening, type ListeningClientState, type ListeningRoom, type ListeningState, type ListeningPreferences, type ListeningAction } from './listening';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthSession } from './auth';
|
||||
import { ListeningCoordinator, type ListeningRoom, type ListeningState } from './listening';
|
||||
|
||||
const host='00000000-0000-4000-8000-000000000001';const guest='00000000-0000-4000-8000-000000000002';const device='00000000-0000-4000-8000-000000000003';
|
||||
const room: ListeningRoom={id:'room',owner_id:host,owner_name:'Host',device_id:device,track_id:'track',playing:true,position_ms:1000,updated_at:'2026-10-23T00:00:00Z',participants:[{id:host,name:'Host'},{id:guest,name:'Guest'}]};
|
||||
function setup(user=guest,sourceDevice='other'){
|
||||
let now=1000;let state:ListeningState={preferences:{requests:'friends',auto_accept:'nobody',autoplay:true},room:{...room},requests:[],server_time:'2026-10-23T00:00:02Z'};
|
||||
const put=vi.fn(async()=>({response:new Response(null,{status:204})}));const post=vi.fn(async()=>({response:new Response(null,{status:204})}));
|
||||
const get=vi.fn(async()=>({data:state,response:new Response()}));
|
||||
const session={state:{status:'signed_in',user:{id:user}},client:{GET:get,PUT:put,POST:post}} as unknown as AuthSession;
|
||||
const adapter={read:()=>({track:{id:'track'},playing:true,positionMs:4000,context:null}),apply:vi.fn(async(_r:ListeningRoom,_p:number,_current:()=>boolean)=>{}),pause:vi.fn(),changed:vi.fn()};
|
||||
const coordinator=new ListeningCoordinator(session,sourceDevice,adapter,()=>now);
|
||||
return {coordinator,adapter,get,put,post,setState:(v:ListeningState)=>{state=v;},state,advance:(ms:number)=>{now+=ms;}};
|
||||
}
|
||||
describe('shared listening',()=>{
|
||||
it('publishes only from the source device and leaves source playback running',async()=>{
|
||||
const e=setup(host,device);await e.coordinator.poll(true);expect(e.put).toHaveBeenCalledWith('/api/v1/me/social/listening/state',expect.objectContaining({body:expect.objectContaining({device_id:device,position_ms:4000})}));expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();expect(e.adapter.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
it('locks follower controls and extrapolates the host position from server time',async()=>{
|
||||
const e=setup();await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({track_id:'track'}),3000,expect.any(Function));expect(e.put).not.toHaveBeenCalled();e.coordinator.dispose();
|
||||
});
|
||||
it('prepares silent audio when autoplay is disabled',async()=>{
|
||||
const e=setup();e.state.preferences.autoplay=false;await e.coordinator.poll(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({locked:true,needsStart:true}));e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));e.coordinator.dispose();
|
||||
});
|
||||
it('invalidates late synchronization and allows local exit even without a connection',async()=>{
|
||||
const e=setup();let current!:()=>boolean;let release!:()=>void;
|
||||
e.adapter.apply.mockImplementation(async(_r,_p,live)=>{current=live;await new Promise<void>(r=>{release=r;});});
|
||||
const pending=e.coordinator.poll(true);await vi.waitFor(()=>expect(current).toBeDefined());expect(current()).toBe(true);e.coordinator.leaveLocally();expect(current()).toBe(false);expect(e.coordinator.locked).toBe(false);release();await pending;e.coordinator.dispose();
|
||||
});
|
||||
it('pauses on connection loss and on room termination',async()=>{
|
||||
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { type AuthSession, unwrap } from './auth';
|
||||
import type { ActivityPlaybackState } from './activity';
|
||||
|
||||
export type ListeningRoom = components['schemas']['ListeningRoom'];
|
||||
export type ListeningState = components['schemas']['ListeningState'];
|
||||
export type ListeningPreferences = components['schemas']['ListeningPreferences'];
|
||||
export type ListeningAction = components['schemas']['ListeningAction'];
|
||||
export interface ListeningClientState { room: ListeningRoom | null; locked: boolean; needsStart: boolean; disconnected: boolean }
|
||||
export interface ListeningPlaybackState extends ActivityPlaybackState { positionMs: number }
|
||||
interface Adapter {
|
||||
read(): ListeningPlaybackState;
|
||||
apply(room: ListeningRoom, position: number, current: () => boolean): Promise<void>;
|
||||
pause(): void;
|
||||
changed(state: ListeningClientState): void;
|
||||
}
|
||||
const clients = new WeakMap<AuthSession, Set<ListeningCoordinator>>();
|
||||
|
||||
/** Source keeps its own wave/queue. Followers play only the source's current track. */
|
||||
export class ListeningCoordinator {
|
||||
private snapshot: ListeningState | null = null;
|
||||
private generation = 0;
|
||||
private sequence = 0;
|
||||
private busy = false;
|
||||
private closed = false;
|
||||
private lastPoll = 0;
|
||||
private lastSuccess = 0;
|
||||
private key = '';
|
||||
private enabled = new Set<string>();
|
||||
private ignored = new Set<string>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
locked = false;
|
||||
constructor(private readonly session: AuthSession, readonly deviceId: string, private readonly adapter: Adapter, private readonly now = Date.now) {}
|
||||
start() {
|
||||
let set = clients.get(this.session); if (!set) { set = new Set(); clients.set(this.session, set); } set.add(this);
|
||||
this.timer ??= setInterval(() => void this.poll(), 2000);
|
||||
void this.poll(true);
|
||||
}
|
||||
private source(room: ListeningRoom) { return this.session.state.status === 'signed_in' && room.owner_id === this.session.state.user.id && room.device_id === this.deviceId; }
|
||||
private emit(room: ListeningRoom | null, needsStart = false, disconnected = false) {
|
||||
this.locked = !!room && !this.source(room);
|
||||
this.adapter.changed({ room, locked: this.locked, needsStart, disconnected });
|
||||
}
|
||||
update() {
|
||||
const room = this.snapshot?.room;
|
||||
if (!room || !this.source(room)) return;
|
||||
const state = this.adapter.read();
|
||||
const key = `${state.track?.id}:${state.playing}`;
|
||||
if (key !== this.key) { this.key = key; void this.poll(true); }
|
||||
}
|
||||
refresh() { void this.poll(true); }
|
||||
allowJoining() { this.ignored.clear(); }
|
||||
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
|
||||
leaveLocally() {
|
||||
if (this.snapshot?.room) this.ignored.add(this.snapshot.room.id);
|
||||
this.generation++;
|
||||
if (this.locked) this.adapter.pause();
|
||||
this.emit(null);
|
||||
}
|
||||
prepare(action: ListeningAction): ListeningAction {
|
||||
if (action.action !== 'invite') return action;
|
||||
const state = this.adapter.read();
|
||||
return { ...action, device_id: this.deviceId, track_id: state.track?.id, position_ms: Math.max(0, Math.round(state.positionMs)), playing: state.playing };
|
||||
}
|
||||
async poll(force = false) {
|
||||
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
|
||||
if (this.session.state?.status !== 'signed_in') return;
|
||||
this.busy = true; this.lastPoll = this.now();
|
||||
try {
|
||||
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
|
||||
if (this.closed) return;
|
||||
this.lastSuccess = this.now();
|
||||
const previous = this.snapshot?.room;
|
||||
this.snapshot = state;
|
||||
const room = state.room;
|
||||
if (previous?.id !== room?.id) this.generation++;
|
||||
if (!room) { if (this.locked) this.adapter.pause(); this.emit(null); return; }
|
||||
if (this.ignored.has(room.id)) {
|
||||
this.emit(null);
|
||||
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
|
||||
return;
|
||||
}
|
||||
if (this.source(room)) {
|
||||
this.emit(room);
|
||||
const playback = this.adapter.read();
|
||||
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
|
||||
return;
|
||||
}
|
||||
if (!this.locked) this.adapter.pause();
|
||||
const needsStart = !state.preferences.autoplay && !this.enabled.has(room.id);
|
||||
this.emit(room, needsStart);
|
||||
const version = this.generation;
|
||||
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
|
||||
await this.adapter.apply(needsStart ? { ...room, playing: false } : room, room.position_ms + elapsed, () => !this.closed && this.locked && version === this.generation && !this.ignored.has(room.id));
|
||||
} catch {
|
||||
if (this.closed) return;
|
||||
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
|
||||
} finally { this.busy = false; }
|
||||
}
|
||||
dispose() {
|
||||
if (this.closed) return;
|
||||
this.closed = true; this.generation++;
|
||||
if (this.timer) clearInterval(this.timer); this.timer = null;
|
||||
clients.get(this.session)?.delete(this);
|
||||
const room = this.snapshot?.room;
|
||||
if (this.locked) this.adapter.pause();
|
||||
if (room) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendListeningAction(session: AuthSession, action: ListeningAction) {
|
||||
const set = clients.get(session);
|
||||
if (action.action === 'leave' || action.action === 'end') set?.forEach(c => c.leaveLocally());
|
||||
const client = set?.values().next().value as ListeningCoordinator | undefined;
|
||||
unwrap(await session.client.POST('/api/v1/me/social/listening/actions', { body: client?.prepare(action) ?? action }));
|
||||
if (action.action !== 'leave' && action.action !== 'end') set?.forEach(c => c.allowJoining());
|
||||
set?.forEach(c => c.refresh());
|
||||
}
|
||||
export function refreshListening(session: AuthSession) { clients.get(session)?.forEach(c => c.refresh()); }
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { type AuthSession, unwrap } from './auth';
|
||||
import { refreshListeningActivity, type ActivityVisibility } from './activity';
|
||||
import { sendListeningAction, refreshListening, type ListeningAction, type ListeningPreferences } from './listening';
|
||||
|
||||
export type SocialPerson = components['schemas']['SocialPerson'];
|
||||
export type SocialInbox = components['schemas']['SocialInbox'];
|
||||
@@ -7,14 +9,29 @@ export type SocialAction = components['schemas']['SocialAction'];
|
||||
export type DirectMessage = components['schemas']['DirectMessage'];
|
||||
export type SendDirectMessage = components['schemas']['SendDirectMessage'];
|
||||
export type MusicAttachment = { kind: 'track' | 'playlist'; id: string; title: string };
|
||||
export type ChatMessageUpdate = { action: 'edit' | 'delete' | 'react'; text?: string; emoji?: '❤️' | '👍' | '🔥' | '😂' | '🎵' | '👏' };
|
||||
export const CHAT_REACTIONS = ['❤️', '👍', '🔥', '😂', '🎵', '👏'] as const;
|
||||
export function chatDraftKey(server: string, user: string, peer: string) { return `pipu.chat-draft.${encodeURIComponent(server)}.${user}.${peer}`; }
|
||||
|
||||
/** All clients use the authenticated transport, including refresh and account isolation. */
|
||||
export function socialApi(session: AuthSession) {
|
||||
return {
|
||||
inbox: async () => unwrap(await session.client.GET('/api/v1/me/social')),
|
||||
setFriendsRecommendations: async (enabled: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/recommendations', { body: { enabled } })),
|
||||
listening: async () => unwrap(await session.client.GET('/api/v1/me/social/listening')),
|
||||
listeningAction: (action: ListeningAction) => sendListeningAction(session, action),
|
||||
setListeningPreferences: async (body: ListeningPreferences) => { unwrap(await session.client.PUT('/api/v1/me/social/listening/preferences', { body })); refreshListening(session); },
|
||||
setVisibility: async (visibility: ActivityVisibility) => {
|
||||
unwrap(await session.client.PUT('/api/v1/me/social/activity/visibility', { body: { visibility } }));
|
||||
refreshListeningActivity(session);
|
||||
},
|
||||
activityPlaylist: async (userId: string) => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })),
|
||||
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
|
||||
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
|
||||
messages: async (peerId: string, before?: number) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before } } })),
|
||||
messages: async (peerId: string, before?: number, q?: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q } } })),
|
||||
chatMeta: async (peerId: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } } })),
|
||||
typing: async (peerId: string, typing: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } }, body: { typing } })),
|
||||
updateMessage: async (peerId: string, messageId: string, body: ChatMessageUpdate) => unwrap(await session.client.PATCH('/api/v1/me/social/chats/{peerId}/messages/{messageId}', { params: { path: { peerId, messageId } }, body })),
|
||||
send: async (peerId: string, body: SendDirectMessage) => unwrap(await session.client.POST('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId } }, body })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,6 +433,59 @@
|
||||
"back": "Back",
|
||||
"quiet": "No sounds. New events appear in the counter.",
|
||||
"share_error": "Could not send. Please try again.",
|
||||
"attachment_unavailable": "This music is no longer available."
|
||||
"attachment_unavailable": "This music is no longer available.",
|
||||
"activity_visibility": "Show my listening activity",
|
||||
"visibility_nobody": "Nobody",
|
||||
"visibility_friends": "Friends",
|
||||
"visibility_everyone": "Everyone",
|
||||
"activity_privacy_hint": "Hidden by default. Your audience can see the track and open the playlist while you listen.",
|
||||
"listening": "Listening now",
|
||||
"from_playlist": "Playlist “{{name}}”",
|
||||
"kind_listening_request": "Wants to listen with you",
|
||||
"kind_listening_invite": "Invites you to their stream",
|
||||
"kind_listening_accepted": "Accepted listening together",
|
||||
"together": "Listen together",
|
||||
"join_stream": "Join their listening stream",
|
||||
"invite_stream": "Invite to my listening stream",
|
||||
"stream_choice": "Who leads the listening session",
|
||||
"stream_hint": "The host controls tracks, pause and seeking. Everyone keeps their own volume and can leave.",
|
||||
"stream_requests": "Accept requests and invitations from",
|
||||
"stream_auto": "Automatically accept from",
|
||||
"stream_autoplay": "Start audio when joining",
|
||||
"stream_prefs_hint": "Automatic acceptance still follows your allowed audience. An invitation never transfers control of another player.",
|
||||
"stream_request_sent": "Request sent",
|
||||
"stream_invite_sent": "Invitation sent",
|
||||
"stream_incoming_join": "{{name}} wants to join you",
|
||||
"stream_incoming_invite": "{{name}} invites you to their stream",
|
||||
"stream_pending": "Waiting for confirmation",
|
||||
"stream_host": "You are hosting",
|
||||
"stream_following": "Listening with {{name}}",
|
||||
"stream_controls": "The host controls playback. Your volume and likes remain available.",
|
||||
"stream_end": "End stream",
|
||||
"stream_leave": "Leave session",
|
||||
"stream_start_audio": "Start listening",
|
||||
"stream_lost": "Connection to the host was lost. Playback is paused.",
|
||||
"stream_listeners": "Listeners: {{count}}",
|
||||
"stream_waiting_audio": "You have joined. Start audio when you are ready.",
|
||||
"friends_recommendations": "Use friends’ music in My Wave",
|
||||
"friends_recommendations_hint": "Mix in a little music that friends allow you to see. Your own taste remains the main signal.",
|
||||
"messages": "Messages",
|
||||
"minimize": "Minimize chat",
|
||||
"message_actions": "Message actions",
|
||||
"reply": "Reply",
|
||||
"replying": "Replying to a message",
|
||||
"edit_message": "Edit",
|
||||
"editing": "Editing message",
|
||||
"delete_message": "Delete for everyone",
|
||||
"message_deleted": "Message deleted",
|
||||
"edited": "edited",
|
||||
"typing": "typing…",
|
||||
"chat_search": "Search conversation",
|
||||
"chat_no_results": "No messages found",
|
||||
"cancel_edit": "Cancel editing",
|
||||
"cancel_reply": "Cancel reply",
|
||||
"emoji": "Add emoji",
|
||||
"draft": "Draft",
|
||||
"new_messages": "New messages"
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@
|
||||
"unavailable": "Chats are available to friends only. The friendship may have ended or the person is unavailable",
|
||||
"rate_limited": "Too many actions. Please wait a minute",
|
||||
"message_conflict": "This message was already sent with different content",
|
||||
"attachment_unavailable": "This track or playlist link is no longer available"
|
||||
"attachment_unavailable": "This track or playlist link is no longer available",
|
||||
"activity_unavailable": "This activity or playlist is no longer available",
|
||||
"listening_busy": "Leave your current listening session first",
|
||||
"listening_expired": "The request or stream has expired",
|
||||
"listening_pending": "A request is already pending"
|
||||
}
|
||||
}
|
||||
@@ -433,6 +433,59 @@
|
||||
"back": "Назад",
|
||||
"quiet": "Без звуков. Новые события отмечаются счётчиком.",
|
||||
"share_error": "Не удалось отправить. Попробуйте ещё раз.",
|
||||
"attachment_unavailable": "Музыка больше недоступна."
|
||||
"attachment_unavailable": "Музыка больше недоступна.",
|
||||
"activity_visibility": "Показывать мой статус",
|
||||
"visibility_nobody": "Никому",
|
||||
"visibility_friends": "Друзьям",
|
||||
"visibility_everyone": "Всем",
|
||||
"activity_privacy_hint": "По умолчанию статус скрыт. Выбранная аудитория увидит трек и сможет открыть плейлист, пока вы его слушаете.",
|
||||
"listening": "Слушает сейчас",
|
||||
"from_playlist": "Плейлист «{{name}}»",
|
||||
"kind_listening_request": "Хочет слушать вместе с вами",
|
||||
"kind_listening_invite": "Приглашает на своё прослушивание",
|
||||
"kind_listening_accepted": "Подтвердил(а) совместное прослушивание",
|
||||
"together": "Слушать вместе",
|
||||
"join_stream": "Подключиться к другу",
|
||||
"invite_stream": "Пригласить на своё прослушивание",
|
||||
"stream_choice": "Кто ведёт прослушивание",
|
||||
"stream_hint": "Ведущий управляет треками, паузой и перемоткой. Громкость и выход — у каждого свои.",
|
||||
"stream_requests": "Принимать запросы и приглашения от",
|
||||
"stream_auto": "Автоматически принимать от",
|
||||
"stream_autoplay": "Запускать звук при подключении",
|
||||
"stream_prefs_hint": "Автопринятие работает только для разрешённой аудитории. Приглашение на своё прослушивание не передаёт управление чужим плеером.",
|
||||
"stream_request_sent": "Запрос отправлен",
|
||||
"stream_invite_sent": "Приглашение отправлено",
|
||||
"stream_incoming_join": "{{name}} хочет подключиться к вам",
|
||||
"stream_incoming_invite": "{{name}} приглашает на своё прослушивание",
|
||||
"stream_pending": "Ждём подтверждения",
|
||||
"stream_host": "Вы ведёте прослушивание",
|
||||
"stream_following": "Слушаете вместе с {{name}}",
|
||||
"stream_controls": "Плеером управляет ведущий. Громкость и лайки доступны вам.",
|
||||
"stream_end": "Завершить эфир",
|
||||
"stream_leave": "Выйти из прослушивания",
|
||||
"stream_start_audio": "Начать слушать",
|
||||
"stream_lost": "Связь с ведущим потеряна. Воспроизведение приостановлено.",
|
||||
"stream_listeners": "Слушатели: {{count}}",
|
||||
"stream_waiting_audio": "Подключение подтверждено. Звук включается по вашему нажатию.",
|
||||
"friends_recommendations": "Учитывать музыку друзей в Моей волне",
|
||||
"friends_recommendations_hint": "Добавлять немного музыки, которую друзья разрешили показывать вам. Ваш вкус остаётся главным.",
|
||||
"messages": "Сообщения",
|
||||
"minimize": "Свернуть чат",
|
||||
"message_actions": "Действия с сообщением",
|
||||
"reply": "Ответить",
|
||||
"replying": "Ответ на сообщение",
|
||||
"edit_message": "Редактировать",
|
||||
"editing": "Редактирование сообщения",
|
||||
"delete_message": "Удалить для всех",
|
||||
"message_deleted": "Сообщение удалено",
|
||||
"edited": "изменено",
|
||||
"typing": "печатает…",
|
||||
"chat_search": "Поиск в переписке",
|
||||
"chat_no_results": "Сообщений не найдено",
|
||||
"cancel_edit": "Отменить редактирование",
|
||||
"cancel_reply": "Отменить ответ",
|
||||
"emoji": "Добавить эмодзи",
|
||||
"draft": "Черновик",
|
||||
"new_messages": "Новые сообщения"
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@
|
||||
"unavailable": "Чат доступен только друзьям. Возможно, дружба завершена или пользователь недоступен",
|
||||
"rate_limited": "Слишком много действий. Подождите минуту",
|
||||
"message_conflict": "Это сообщение уже отправлено с другим содержимым",
|
||||
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны"
|
||||
"attachment_unavailable": "Трек или ссылка на плейлист больше недоступны",
|
||||
"activity_unavailable": "Активность или плейлист больше недоступны",
|
||||
"listening_busy": "Сначала выйдите из текущего прослушивания",
|
||||
"listening_expired": "Запрос или эфир завершён",
|
||||
"listening_pending": "Запрос уже ожидает подтверждения"
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
||||
Client: &http.Client{}}, libStore, dto, log)
|
||||
wh.Warm = pb.Prewarm
|
||||
return server.NewRouter(server.Deps{
|
||||
Social: social.NewHandler(pool),
|
||||
Social: social.NewHandler(pool, pb),
|
||||
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
|
||||
Genius: genius.NewHandler(geniusSvc),
|
||||
Log: log,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -122,7 +122,7 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
||||
if route == "POST /api/v1/admin/users/import" {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, MaxUserArchiveBytes)
|
||||
}
|
||||
if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
|
||||
if (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch) && strings.HasPrefix(r.URL.Path, "/api/v1/me/social/") {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithPrincipal(ctx, p)))
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// ReadSocialPlaylist is a projection port for social after live activity authorization.
|
||||
// DTO signing and reactions still use the requesting viewer's principal from ctx.
|
||||
func (h *Handler) ReadSocialPlaylist(ctx context.Context, id uuid.UUID) (gen.Playlist, error) {
|
||||
var owner uuid.UUID
|
||||
e := h.store.pool.QueryRow(ctx, `SELECT user_id FROM playlists WHERE id=$1`, id).Scan(&owner)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return gen.Playlist{}, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||
}
|
||||
if e != nil {
|
||||
return gen.Playlist{}, e
|
||||
}
|
||||
p, e := h.readPlaylist(ctx, owner, id, false)
|
||||
p.ShareId = nil
|
||||
return p, e
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
func validateActivity(a gen.ListeningActivityInput) error {
|
||||
if a.DeviceId == uuid.Nil || a.Sequence < 1 {
|
||||
return invalid()
|
||||
}
|
||||
if a.PositionMs != nil && (*a.PositionMs < 0 || *a.PositionMs > 86400000) {
|
||||
return invalid()
|
||||
}
|
||||
if a.Context != "other" && a.Context != "wave" && a.Context != "playlist" {
|
||||
return invalid()
|
||||
}
|
||||
if a.Playing && (a.TrackId == nil || *a.TrackId == uuid.Nil) {
|
||||
return invalid()
|
||||
}
|
||||
if a.Context == "playlist" && (a.PlaylistId == nil || *a.PlaylistId == uuid.Nil) {
|
||||
return invalid()
|
||||
}
|
||||
if a.Context != "playlist" && a.PlaylistId != nil {
|
||||
return invalid()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (h *Handler) SetFriendsRecommendations(ctx context.Context, r gen.SetFriendsRecommendationsRequestObject) (gen.SetFriendsRecommendationsResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
_, e = h.store.pool.Exec(ctx, `UPDATE users SET friends_recommendations=$2 WHERE id=$1`, id, r.Body.Enabled)
|
||||
return gen.SetFriendsRecommendations204Response{}, e
|
||||
}
|
||||
func (h *Handler) PublishListeningActivity(ctx context.Context, r gen.PublishListeningActivityRequestObject) (gen.PublishListeningActivityResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
if e = validateActivity(*r.Body); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e = h.store.publishActivity(ctx, id, *r.Body); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return gen.PublishListeningActivity204Response{}, nil
|
||||
}
|
||||
func (h *Handler) SetActivityVisibility(ctx context.Context, r gen.SetActivityVisibilityRequestObject) (gen.SetActivityVisibilityResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
v := r.Body.Visibility
|
||||
if v != "nobody" && v != "friends" && v != "everyone" {
|
||||
return nil, invalid()
|
||||
}
|
||||
tx, e := h.store.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, e = tx.Exec(ctx, `UPDATE users SET activity_visibility=$2 WHERE id=$1`, id, v); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if v == "nobody" {
|
||||
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1`, id); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if e = tx.Commit(ctx); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return gen.SetActivityVisibility204Response{}, nil
|
||||
}
|
||||
func (h *Handler) GetActivityPlaylist(ctx context.Context, r gen.GetActivityPlaylistRequestObject) (gen.GetActivityPlaylistResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
activity, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
a := activity[r.UserId]
|
||||
if a == nil || a.PlaylistId == nil {
|
||||
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
|
||||
}
|
||||
p, e := h.playlists.ReadSocialPlaylist(ctx, *a.PlaylistId)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
// Recheck after projecting the playlist: a concurrent privacy change must close the view.
|
||||
current, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
now := current[r.UserId]
|
||||
if now == nil || now.PlaylistId == nil || *now.PlaylistId != *a.PlaylistId {
|
||||
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
|
||||
}
|
||||
return gen.GetActivityPlaylist200JSONResponse(p), nil
|
||||
}
|
||||
|
||||
func (s *Store) publishActivity(ctx context.Context, id uuid.UUID, a gen.ListeningActivityInput) error {
|
||||
tx, e := s.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var visibility string
|
||||
if e = tx.QueryRow(ctx, `SELECT activity_visibility FROM users WHERE id=$1 FOR UPDATE`, id).Scan(&visibility); e != nil {
|
||||
return e
|
||||
}
|
||||
if visibility == "nobody" {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
var track, playlist *uuid.UUID
|
||||
kind := "other"
|
||||
if a.Playing {
|
||||
var exists bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, a.TrackId).Scan(&exists); e != nil {
|
||||
return e
|
||||
}
|
||||
if !exists {
|
||||
return invalid()
|
||||
}
|
||||
track = a.TrackId
|
||||
kind = string(a.Context)
|
||||
if kind == "playlist" {
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM playlists WHERE id=$2 AND (user_id=$1 OR share_id IS NOT NULL))`, id, a.PlaylistId).Scan(&exists); e != nil {
|
||||
return e
|
||||
}
|
||||
if exists {
|
||||
playlist = a.PlaylistId
|
||||
} else {
|
||||
kind = "other"
|
||||
}
|
||||
}
|
||||
}
|
||||
position := 0
|
||||
if a.PositionMs != nil {
|
||||
position = *a.PositionMs
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO listening_activity(user_id,device_id,sequence,track_id,context,playlist_id,position_ms) VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT(user_id,device_id) DO UPDATE SET sequence=EXCLUDED.sequence,track_id=EXCLUDED.track_id,context=EXCLUDED.context,playlist_id=EXCLUDED.playlist_id,position_ms=EXCLUDED.position_ms,updated_at=now()
|
||||
WHERE EXCLUDED.sequence>listening_activity.sequence`, id, a.DeviceId, a.Sequence, track, kind, playlist, position)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1 AND updated_at<now()-interval '1 day'`, id); e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) activities(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]*gen.ListeningActivity, error) {
|
||||
out := map[uuid.UUID]*gen.ListeningActivity{}
|
||||
if len(ids) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, e := s.pool.Query(ctx, `SELECT u.id,t.id,t.title,
|
||||
COALESCE((SELECT string_agg(ar.name,', ' ORDER BY ta.position) FROM track_artists ta JOIN artists ar ON ar.id=ta.artist_id WHERE ta.track_id=t.id AND ta.role='main'),''),
|
||||
CASE WHEN a.context='playlist' AND p.id IS NULL THEN 'other' ELSE a.context END,p.id,p.name
|
||||
FROM users u JOIN LATERAL(SELECT * FROM listening_activity la WHERE la.user_id=u.id AND la.track_id IS NOT NULL AND la.updated_at>now()-interval '90 seconds' ORDER BY la.updated_at DESC,la.device_id LIMIT 1) a ON true
|
||||
JOIN tracks t ON t.id=a.track_id
|
||||
LEFT JOIN playlists p ON p.id=a.playlist_id AND (p.user_id=u.id OR p.share_id IS NOT NULL)
|
||||
WHERE u.id=ANY($2::uuid[]) AND NOT u.is_blocked
|
||||
AND (u.id=$1 OR u.activity_visibility='everyone' OR (u.activity_visibility='friends' AND EXISTS(SELECT 1 FROM friendships f WHERE f.low_id=LEAST($1::uuid,u.id) AND f.high_id=GREATEST($1::uuid,u.id) AND f.accepted)))
|
||||
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=u.id) OR (b.user_id=u.id AND b.peer_id=$1))`, viewer, ids)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var a gen.ListeningActivity
|
||||
if e = rows.Scan(&id, &a.TrackId, &a.TrackTitle, &a.Artist, &a.Context, &a.PlaylistId, &a.PlaylistName); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out[id] = &a
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (s *Store) attachActivity(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error {
|
||||
ids := make([]uuid.UUID, 0, len(people))
|
||||
for _, p := range people {
|
||||
ids = append(ids, p.Id)
|
||||
}
|
||||
activities, e := s.activities(ctx, viewer, ids)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for i := range people {
|
||||
people[i].Activity = activities[people[i].Id]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func validEmoji(s string) bool {
|
||||
switch s {
|
||||
case "❤️", "👍", "🔥", "😂", "🎵", "👏":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (h *Handler) GetChatMeta(ctx context.Context, r gen.GetChatMetaRequestObject) (gen.GetChatMetaResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var typing bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM chat_typing WHERE user_id=$1 AND peer_id=$2 AND expires_at>now())`, r.PeerId, id).Scan(&typing)
|
||||
return gen.GetChatMeta200JSONResponse{Typing: typing}, e
|
||||
}
|
||||
func (h *Handler) SetChatTyping(ctx context.Context, r gen.SetChatTypingRequestObject) (gen.SetChatTypingResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
tx, e := pairTx(ctx, h.store.pool, id, r.PeerId)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if e = allowed(ctx, tx, id, r.PeerId); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body.Typing {
|
||||
_, e = tx.Exec(ctx, `INSERT INTO chat_typing(user_id,peer_id,expires_at) VALUES($1,$2,now()+interval '6 seconds') ON CONFLICT(user_id,peer_id) DO UPDATE SET expires_at=EXCLUDED.expires_at`, id, r.PeerId)
|
||||
} else {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM chat_typing WHERE user_id=$1 AND peer_id=$2`, id, r.PeerId)
|
||||
}
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return gen.SetChatTyping204Response{}, tx.Commit(ctx)
|
||||
}
|
||||
func (h *Handler) UpdateChatMessage(ctx context.Context, r gen.UpdateChatMessageRequestObject) (gen.UpdateChatMessageResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
e = h.store.updateMessage(ctx, id, r.PeerId, r.MessageId, *r.Body)
|
||||
return gen.UpdateChatMessage204Response{}, e
|
||||
}
|
||||
func (s *Store) updateMessage(ctx context.Context, id, peer, message uuid.UUID, a gen.UpdateChatMessageJSONRequestBody) error {
|
||||
tx, e := pairTx(ctx, s.pool, id, peer)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if e = allowed(ctx, tx, id, peer); e != nil {
|
||||
return e
|
||||
}
|
||||
var sender uuid.UUID
|
||||
var kind string
|
||||
var deleted bool
|
||||
e = tx.QueryRow(ctx, `SELECT sender_id,attachment_kind,deleted_at IS NOT NULL FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) FOR UPDATE`, message, id, peer).Scan(&sender, &kind, &deleted)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return apperr.NotFound("social.message_conflict", "message unavailable")
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if a.Action != "react" && sender != id {
|
||||
return denied()
|
||||
}
|
||||
if deleted {
|
||||
if a.Action == "delete" {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
return apperr.Conflict("social.message_conflict", "message deleted")
|
||||
}
|
||||
switch a.Action {
|
||||
case "edit":
|
||||
if a.Text == nil || utf8.RuneCountInString(*a.Text) > 4000 || (kind == "none" && strings.TrimSpace(*a.Text) == "") {
|
||||
return invalid()
|
||||
}
|
||||
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text=$2,edited_at=now() WHERE id=$1`, message, strings.TrimSpace(*a.Text))
|
||||
case "delete":
|
||||
_, e = tx.Exec(ctx, `UPDATE direct_messages SET text='[deleted]',attachment_kind='none',attachment_id=NULL,deleted_at=now() WHERE id=$1`, message)
|
||||
if e == nil {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1`, message)
|
||||
}
|
||||
if e == nil {
|
||||
_, e = tx.Exec(ctx, `UPDATE social_notifications SET read_at=COALESCE(read_at,now()) WHERE message_seq=(SELECT seq FROM direct_messages WHERE id=$1)`, message)
|
||||
}
|
||||
case "react":
|
||||
if a.Emoji == nil || !validEmoji(string(*a.Emoji)) {
|
||||
return invalid()
|
||||
}
|
||||
result, err := tx.Exec(ctx, `DELETE FROM message_reactions WHERE message_id=$1 AND user_id=$2 AND emoji=$3`, message, id, *a.Emoji)
|
||||
e = err
|
||||
if e == nil && result.RowsAffected() == 0 {
|
||||
_, e = tx.Exec(ctx, `INSERT INTO message_reactions(message_id,user_id,emoji) VALUES($1,$2,$3)`, message, id, *a.Emoji)
|
||||
}
|
||||
default:
|
||||
return invalid()
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
func enrichMessages(ctx context.Context, tx pgx.Tx, viewer uuid.UUID, messages []gen.DirectMessage) error {
|
||||
if len(messages) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(messages))
|
||||
replies := []uuid.UUID{}
|
||||
positions := map[uuid.UUID]int{}
|
||||
for i, m := range messages {
|
||||
ids = append(ids, m.Id)
|
||||
positions[m.Id] = i
|
||||
if m.ReplyTo != nil {
|
||||
replies = append(replies, *m.ReplyTo)
|
||||
}
|
||||
empty := []gen.MessageReaction{}
|
||||
messages[i].Reactions = &empty
|
||||
}
|
||||
rows, e := tx.Query(ctx, `SELECT message_id,emoji,count(*),bool_or(user_id=$2) FROM message_reactions WHERE message_id=ANY($1::uuid[]) GROUP BY message_id,emoji ORDER BY emoji`, ids, viewer)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var r gen.MessageReaction
|
||||
if e = rows.Scan(&id, &r.Emoji, &r.Count, &r.Mine); e != nil {
|
||||
rows.Close()
|
||||
return e
|
||||
}
|
||||
i := positions[id]
|
||||
*messages[i].Reactions = append(*messages[i].Reactions, r)
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if len(replies) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, e = tx.Query(ctx, `SELECT id,CASE WHEN deleted_at IS NULL THEN left(text,160) ELSE '' END FROM direct_messages WHERE id=ANY($1::uuid[])`, replies)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer rows.Close()
|
||||
previews := map[uuid.UUID]string{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var text string
|
||||
if e = rows.Scan(&id, &text); e != nil {
|
||||
return e
|
||||
}
|
||||
previews[id] = text
|
||||
}
|
||||
for i, m := range messages {
|
||||
if m.ReplyTo != nil {
|
||||
p := previews[*m.ReplyTo]
|
||||
messages[i].ReplyPreview = &p
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//go:build integration
|
||||
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"os"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/db"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActivityRoomsAndChatPrivacy(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, e := db.Connect(ctx, url)
|
||||
require.NoError(t, e)
|
||||
defer pool.Close()
|
||||
require.NoError(t, db.Migrate(ctx, pool))
|
||||
s := &Store{pool: pool}
|
||||
h := &Handler{store: s}
|
||||
owner, friend, other := uuid.New(), uuid.New(), uuid.New()
|
||||
users := []uuid.UUID{owner, friend, other}
|
||||
for _, id := range users {
|
||||
_, e = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash) VALUES($1,$2,'x')`, id, id.String())
|
||||
require.NoError(t, e)
|
||||
}
|
||||
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, users)
|
||||
artist, album, track, playlist, device := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
_, e = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
|
||||
require.NoError(t, e)
|
||||
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
|
||||
_, e = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album',$2,$3)`, album, album.String(), artist)
|
||||
require.NoError(t, e)
|
||||
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
|
||||
_, e = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'Track',$2,$3,180000)`, track, track.String(), album)
|
||||
require.NoError(t, e)
|
||||
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=$1`, track)
|
||||
_, e = pool.Exec(ctx, `INSERT INTO playlists(id,user_id,name) VALUES($1,$2,'Private playlist')`, playlist, owner)
|
||||
require.NoError(t, e)
|
||||
activity := gen.ListeningActivityInput{DeviceId: device, Sequence: 1, Playing: true, TrackId: &track, Context: "playlist", PlaylistId: &playlist}
|
||||
require.NoError(t, s.publishActivity(ctx, owner, activity))
|
||||
var count int
|
||||
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM listening_activity WHERE user_id=$1`, owner).Scan(&count))
|
||||
require.Zero(t, count)
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET activity_visibility='friends' WHERE id=$1`, owner)
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, s.publishActivity(ctx, owner, activity))
|
||||
hidden, e := s.activities(ctx, friend, []uuid.UUID{owner})
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, hidden)
|
||||
require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "request", TargetId: friend}))
|
||||
require.NoError(t, s.action(ctx, friend, gen.SocialAction{Action: "accept", TargetId: owner}))
|
||||
visible, e := s.activities(ctx, friend, []uuid.UUID{owner})
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, &playlist, visible[owner].PlaylistId)
|
||||
hidden, e = s.activities(ctx, other, []uuid.UUID{owner})
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, hidden)
|
||||
activity.Sequence = 3
|
||||
activity.Playing = false
|
||||
activity.Context = "other"
|
||||
activity.PlaylistId = nil
|
||||
activity.TrackId = nil
|
||||
require.NoError(t, s.publishActivity(ctx, owner, activity))
|
||||
activity.Sequence = 2
|
||||
activity.Playing = true
|
||||
activity.Context = "playlist"
|
||||
activity.PlaylistId = &playlist
|
||||
activity.TrackId = &track
|
||||
require.NoError(t, s.publishActivity(ctx, owner, activity))
|
||||
hidden, e = s.activities(ctx, friend, []uuid.UUID{owner})
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, hidden)
|
||||
activity.Sequence = 4
|
||||
require.NoError(t, s.publishActivity(ctx, owner, activity))
|
||||
join := gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}
|
||||
require.Error(t, s.listeningAction(ctx, friend, join)) // default nobody
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='friends',listening_auto_accept='everyone' WHERE id=$1`, owner)
|
||||
require.NoError(t, e)
|
||||
require.Error(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "join", TargetId: owner}))
|
||||
require.NoError(t, s.listeningAction(ctx, friend, join))
|
||||
state, e := s.listeningState(ctx, friend)
|
||||
require.NoError(t, e)
|
||||
require.NotNil(t, state.Room)
|
||||
require.Equal(t, owner, state.Room.OwnerId)
|
||||
require.Len(t, state.Room.Participants, 2)
|
||||
hostctx := auth.WithPrincipal(ctx, auth.Principal{UserID: owner})
|
||||
badctx := auth.WithPrincipal(ctx, auth.Principal{UserID: friend})
|
||||
input := gen.ListeningRoomInput{RoomId: state.Room.Id, DeviceId: device, Sequence: 1, Playing: false, TrackId: &track, PositionMs: 3000}
|
||||
_, e = h.PublishListeningState(badctx, gen.PublishListeningStateRequestObject{Body: &input})
|
||||
require.Error(t, e)
|
||||
_, e = h.PublishListeningState(hostctx, gen.PublishListeningStateRequestObject{Body: &input})
|
||||
require.NoError(t, e)
|
||||
require.NoError(t, s.listeningAction(ctx, owner, gen.ListeningAction{Id: uuid.New(), Action: "end", TargetId: state.Room.Id}))
|
||||
state, e = s.listeningState(ctx, friend)
|
||||
require.NoError(t, e)
|
||||
require.Nil(t, state.Room)
|
||||
_, e = pool.Exec(ctx, `UPDATE users SET listening_requests='everyone',listening_auto_accept='nobody' WHERE id=$1`, other)
|
||||
require.NoError(t, e)
|
||||
invite := gen.ListeningAction{Id: uuid.New(), Action: "invite", TargetId: other, DeviceId: &device, TrackId: &track}
|
||||
require.NoError(t, s.listeningAction(ctx, owner, invite))
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Len(t, state.Requests, 1)
|
||||
require.Nil(t, state.Room)
|
||||
require.Error(t, s.listeningAction(ctx, friend, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
|
||||
require.NoError(t, s.listeningAction(ctx, other, gen.ListeningAction{Id: uuid.New(), Action: "accept", TargetId: invite.Id}))
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Equal(t, owner, state.Room.OwnerId)
|
||||
_, e = pool.Exec(ctx, `UPDATE listening_rooms SET updated_at=now()-interval '31 seconds' WHERE id=$1`, state.Room.Id)
|
||||
require.NoError(t, e)
|
||||
state, e = s.listeningState(ctx, other)
|
||||
require.NoError(t, e)
|
||||
require.Nil(t, state.Room)
|
||||
// Chat: ownership, replies, reaction toggles, content removal and conversation search.
|
||||
message, e := s.send(ctx, owner, friend, gen.SendDirectMessage{Id: uuid.New(), Text: "First message", AttachmentKind: "none"})
|
||||
require.NoError(t, e)
|
||||
reply, e := s.send(ctx, friend, owner, gen.SendDirectMessage{Id: uuid.New(), Text: "Reply", AttachmentKind: "none", ReplyTo: &message.Id})
|
||||
require.NoError(t, e)
|
||||
edited := "Edited message"
|
||||
require.Error(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited}))
|
||||
require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "edit", Text: &edited}))
|
||||
reaction := gen.UpdateChatMessageJSONBodyEmoji("🔥")
|
||||
require.NoError(t, s.updateMessage(ctx, friend, owner, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "react", Emoji: &reaction}))
|
||||
history, e := s.messages(ctx, owner, friend, nil)
|
||||
require.NoError(t, e)
|
||||
require.Len(t, *history[0].Reactions, 1)
|
||||
require.Equal(t, "Edited message", *history[1].ReplyPreview)
|
||||
matches, e := s.messagePage(ctx, friend, owner, nil, "Edited")
|
||||
require.NoError(t, e)
|
||||
require.Len(t, matches, 1)
|
||||
require.NoError(t, s.updateMessage(ctx, owner, friend, message.Id, gen.UpdateChatMessageJSONRequestBody{Action: "delete"}))
|
||||
history, e = s.messages(ctx, owner, friend, nil)
|
||||
require.NoError(t, e)
|
||||
require.True(t, *history[0].Deleted)
|
||||
require.Empty(t, history[0].Text)
|
||||
require.Empty(t, *history[0].Reactions)
|
||||
require.Equal(t, reply.ReplyTo, history[1].ReplyTo)
|
||||
require.NoError(t, s.action(ctx, owner, gen.SocialAction{Action: "block", TargetId: friend}))
|
||||
hidden, e = s.activities(ctx, friend, []uuid.UUID{owner})
|
||||
require.NoError(t, e)
|
||||
require.Empty(t, hidden)
|
||||
_, e = h.GetChatMeta(badctx, gen.GetChatMetaRequestObject{PeerId: owner})
|
||||
require.Error(t, e)
|
||||
}
|
||||
@@ -13,9 +13,18 @@ import (
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
type Handler struct{ store *Store }
|
||||
type ActivityPlaylistReader interface {
|
||||
ReadSocialPlaylist(context.Context, uuid.UUID) (gen.Playlist, error)
|
||||
}
|
||||
|
||||
func NewHandler(pool *pgxpool.Pool) *Handler { return &Handler{store: &Store{pool: pool}} }
|
||||
type Handler struct {
|
||||
store *Store
|
||||
playlists ActivityPlaylistReader
|
||||
}
|
||||
|
||||
func NewHandler(pool *pgxpool.Pool, playlists ActivityPlaylistReader) *Handler {
|
||||
return &Handler{store: &Store{pool: pool}, playlists: playlists}
|
||||
}
|
||||
func userID(ctx context.Context) (uuid.UUID, error) {
|
||||
p, ok := auth.PrincipalFrom(ctx)
|
||||
if !ok {
|
||||
@@ -71,10 +80,20 @@ func (h *Handler) GetDirectMessages(ctx context.Context, r gen.GetDirectMessages
|
||||
if r.PeerId == uuid.Nil || r.PeerId == id || (r.Params.Before != nil && *r.Params.Before < 1) {
|
||||
return nil, invalid()
|
||||
}
|
||||
v, e := h.store.messages(ctx, id, r.PeerId, r.Params.Before)
|
||||
q := ""
|
||||
if r.Params.Q != nil {
|
||||
q = strings.TrimSpace(*r.Params.Q)
|
||||
}
|
||||
if utf8.RuneCountInString(q) > 100 {
|
||||
return nil, invalid()
|
||||
}
|
||||
v, e := h.store.messagePage(ctx, id, r.PeerId, r.Params.Before, q)
|
||||
return gen.GetDirectMessages200JSONResponse(v), e
|
||||
}
|
||||
func validateMessage(m gen.SendDirectMessage) error {
|
||||
if m.ReplyTo != nil && *m.ReplyTo == uuid.Nil {
|
||||
return invalid()
|
||||
}
|
||||
if m.Id == uuid.Nil || utf8.RuneCountInString(m.Text) > 4000 {
|
||||
return invalid()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"pipu/api/internal/api/gen"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
"time"
|
||||
)
|
||||
|
||||
func audienceAllows(policy string, friends bool) bool {
|
||||
return policy == "everyone" || (policy == "friends" && friends)
|
||||
}
|
||||
func validAudience(v gen.ActivityVisibility) bool {
|
||||
return v == "nobody" || v == "friends" || v == "everyone"
|
||||
}
|
||||
func (h *Handler) SetListeningPreferences(ctx context.Context, r gen.SetListeningPreferencesRequestObject) (gen.SetListeningPreferencesResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil || !validAudience(r.Body.Requests) || !validAudience(r.Body.AutoAccept) {
|
||||
return nil, invalid()
|
||||
}
|
||||
_, e = h.store.pool.Exec(ctx, `UPDATE users SET listening_requests=$2,listening_auto_accept=$3,listening_autoplay=$4 WHERE id=$1`, id, r.Body.Requests, r.Body.AutoAccept, r.Body.Autoplay)
|
||||
return gen.SetListeningPreferences204Response{}, e
|
||||
}
|
||||
func (h *Handler) GetListeningState(ctx context.Context, _ gen.GetListeningStateRequestObject) (gen.GetListeningStateResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
v, e := h.store.listeningState(ctx, id)
|
||||
return gen.GetListeningState200JSONResponse(v), e
|
||||
}
|
||||
func (h *Handler) ListeningAction(ctx context.Context, r gen.ListeningActionRequestObject) (gen.ListeningActionResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil || r.Body.Id == uuid.Nil || r.Body.TargetId == uuid.Nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
e = h.store.listeningAction(ctx, id, *r.Body)
|
||||
return gen.ListeningAction204Response{}, e
|
||||
}
|
||||
func (h *Handler) PublishListeningState(ctx context.Context, r gen.PublishListeningStateRequestObject) (gen.PublishListeningStateResponseObject, error) {
|
||||
id, e := userID(ctx)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if r.Body == nil {
|
||||
return nil, invalid()
|
||||
}
|
||||
a := r.Body
|
||||
if a.DeviceId == uuid.Nil || a.RoomId == uuid.Nil || a.Sequence < 1 || a.PositionMs < 0 || a.PositionMs > 86400000 || (a.Playing && a.TrackId == nil) {
|
||||
return nil, invalid()
|
||||
}
|
||||
result, e := h.store.pool.Exec(ctx, `UPDATE listening_rooms SET track_id=$4,position_ms=$5,playing=$6,sequence=$7,updated_at=now()
|
||||
WHERE id=$1 AND owner_id=$2 AND device_id=$3 AND sequence<$7 AND updated_at>now()-interval '30 seconds'
|
||||
AND ($4::uuid IS NULL OR EXISTS(SELECT 1 FROM tracks WHERE id=$4))`, a.RoomId, id, a.DeviceId, a.TrackId, a.PositionMs, a.Playing, a.Sequence)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
var own bool
|
||||
e = h.store.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_rooms WHERE id=$1 AND owner_id=$2 AND device_id=$3 AND updated_at>now()-interval '30 seconds')`, a.RoomId, id, a.DeviceId).Scan(&own)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !own {
|
||||
return nil, denied()
|
||||
}
|
||||
}
|
||||
return gen.PublishListeningState204Response{}, nil
|
||||
}
|
||||
|
||||
func (s *Store) cleanListening(ctx context.Context) error {
|
||||
_, e := s.pool.Exec(ctx, `DELETE FROM listening_rooms WHERE updated_at<now()-interval '30 seconds'`)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = s.pool.Exec(ctx, `UPDATE listening_requests SET status='cancelled' WHERE status='pending' AND expires_at<=now()`)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = s.pool.Exec(ctx, `DELETE FROM listening_members m USING listening_rooms r,users u WHERE m.room_id=r.id AND u.id=r.owner_id AND m.user_id<>r.owner_id
|
||||
AND (m.last_seen<now()-interval '60 seconds' OR u.is_blocked OR EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=r.owner_id AND b.peer_id=m.user_id) OR (b.user_id=m.user_id AND b.peer_id=r.owner_id)))`)
|
||||
return e
|
||||
}
|
||||
func (s *Store) listeningState(ctx context.Context, id uuid.UUID) (gen.ListeningState, error) {
|
||||
out := gen.ListeningState{Requests: []gen.ListeningRequest{}, ServerTime: time.Now().UTC()}
|
||||
if e := s.cleanListening(ctx); e != nil {
|
||||
return out, e
|
||||
}
|
||||
if e := s.pool.QueryRow(ctx, `SELECT listening_requests,listening_auto_accept,listening_autoplay,now() FROM users WHERE id=$1`, id).Scan(&out.Preferences.Requests, &out.Preferences.AutoAccept, &out.Preferences.Autoplay, &out.ServerTime); e != nil {
|
||||
return out, e
|
||||
}
|
||||
if _, e := s.pool.Exec(ctx, `UPDATE listening_members SET last_seen=now() WHERE user_id=$1`, id); e != nil {
|
||||
return out, e
|
||||
}
|
||||
var room gen.ListeningRoom
|
||||
var participants []byte
|
||||
e := s.pool.QueryRow(ctx, `SELECT r.id,r.owner_id,COALESCE(u.display_name,u.username::text),r.device_id,r.track_id,r.position_ms,r.playing,r.updated_at,
|
||||
COALESCE((SELECT json_agg(json_build_object('id',p.id,'name',COALESCE(p.display_name,p.username::text)) ORDER BY p.username) FROM listening_members lm JOIN users p ON p.id=lm.user_id WHERE lm.room_id=r.id),'[]')
|
||||
FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id JOIN users u ON u.id=r.owner_id WHERE m.user_id=$1`, id).Scan(&room.Id, &room.OwnerId, &room.OwnerName, &room.DeviceId, &room.TrackId, &room.PositionMs, &room.Playing, &room.UpdatedAt, &participants)
|
||||
if e == nil {
|
||||
if e = json.Unmarshal(participants, &room.Participants); e != nil {
|
||||
return out, e
|
||||
}
|
||||
out.Room = &room
|
||||
} else if !errors.Is(e, pgx.ErrNoRows) {
|
||||
return out, e
|
||||
}
|
||||
rows, e := s.pool.Query(ctx, `SELECT q.id,q.sender_id,COALESCE(u.display_name,u.username::text),q.recipient_id,q.owner_id,q.kind,q.expires_at
|
||||
FROM listening_requests q JOIN users u ON u.id=q.sender_id WHERE q.status='pending' AND q.expires_at>now() AND (q.sender_id=$1 OR q.recipient_id=$1)
|
||||
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=q.sender_id AND b.peer_id=q.recipient_id) OR (b.user_id=q.recipient_id AND b.peer_id=q.sender_id))
|
||||
ORDER BY q.created_at LIMIT 50`, id)
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var q gen.ListeningRequest
|
||||
if e = rows.Scan(&q.Id, &q.SenderId, &q.SenderName, &q.RecipientId, &q.OwnerId, &q.Kind, &q.ExpiresAt); e != nil {
|
||||
return out, e
|
||||
}
|
||||
out.Requests = append(out.Requests, q)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func listeningPolicy(ctx context.Context, tx pgx.Tx, recipient, sender uuid.UUID) (bool, bool, error) {
|
||||
var policy, automatic string
|
||||
var friends, blocked bool
|
||||
e := tx.QueryRow(ctx, `SELECT listening_requests,listening_auto_accept,
|
||||
EXISTS(SELECT 1 FROM friendships WHERE low_id=LEAST($1::uuid,$2::uuid) AND high_id=GREATEST($1::uuid,$2::uuid) AND accepted),
|
||||
is_blocked OR EXISTS(SELECT 1 FROM users WHERE id=$2 AND is_blocked) OR EXISTS(SELECT 1 FROM social_blocks WHERE (user_id=$1 AND peer_id=$2) OR (user_id=$2 AND peer_id=$1))
|
||||
FROM users WHERE id=$1`, recipient, sender).Scan(&policy, &automatic, &friends, &blocked)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return false, false, denied()
|
||||
}
|
||||
if e != nil {
|
||||
return false, false, e
|
||||
}
|
||||
return !blocked && audienceAllows(policy, friends), !blocked && audienceAllows(automatic, friends), nil
|
||||
}
|
||||
func admitListening(ctx context.Context, tx pgx.Tx, request, room, guest, recipient, sender uuid.UUID) error {
|
||||
allowed, _, e := listeningPolicy(ctx, tx, recipient, sender)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !allowed {
|
||||
return denied()
|
||||
}
|
||||
result, e := tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2) ON CONFLICT(user_id) DO UPDATE SET last_seen=now() WHERE listening_members.room_id=EXCLUDED.room_id`, guest, room)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return apperr.Conflict("social.listening_busy", "already in another room")
|
||||
}
|
||||
_, e = tx.Exec(ctx, `UPDATE listening_requests SET status='accepted' WHERE id=$1`, request)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return notify(ctx, tx, sender, recipient, "listening_accepted", nil)
|
||||
}
|
||||
func (s *Store) listeningAction(ctx context.Context, id uuid.UUID, a gen.ListeningAction) error {
|
||||
if e := s.cleanListening(ctx); e != nil {
|
||||
return e
|
||||
}
|
||||
tx, e := s.pool.Begin(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
switch a.Action {
|
||||
case "leave", "end":
|
||||
var owner uuid.UUID
|
||||
e = tx.QueryRow(ctx, `SELECT owner_id FROM listening_rooms WHERE id=$1 FOR UPDATE`, a.TargetId).Scan(&owner)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if a.Action == "end" && owner != id {
|
||||
return denied()
|
||||
}
|
||||
if owner == id {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM listening_rooms WHERE id=$1`, a.TargetId)
|
||||
} else {
|
||||
_, e = tx.Exec(ctx, `DELETE FROM listening_members WHERE room_id=$1 AND user_id=$2`, a.TargetId, id)
|
||||
}
|
||||
case "accept", "decline", "cancel":
|
||||
var room, guest, recipient, sender, owner uuid.UUID
|
||||
var status string
|
||||
e = tx.QueryRow(ctx, `SELECT room_id,guest_id,recipient_id,sender_id,owner_id,status FROM listening_requests WHERE id=$1 AND expires_at>now()`, a.TargetId).Scan(&room, &guest, &recipient, &sender, &owner, &status)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
return apperr.NotFound("social.listening_expired", "request expired")
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if (a.Action == "cancel" && sender != id) || (a.Action != "cancel" && recipient != id) {
|
||||
return denied()
|
||||
}
|
||||
if status != "pending" {
|
||||
return nil
|
||||
}
|
||||
// Lock both accounts in UUID order before testing admission and membership.
|
||||
if _, e = tx.Exec(ctx, `SELECT id FROM users WHERE id=ANY($1::uuid[]) ORDER BY id FOR UPDATE`, []uuid.UUID{owner, guest}); e != nil {
|
||||
return e
|
||||
}
|
||||
if e = tx.QueryRow(ctx, `SELECT status FROM listening_requests WHERE id=$1 AND expires_at>now() FOR UPDATE`, a.TargetId).Scan(&status); e != nil {
|
||||
return e
|
||||
}
|
||||
if status != "pending" {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if a.Action == "accept" {
|
||||
e = admitListening(ctx, tx, a.TargetId, room, guest, recipient, sender)
|
||||
} else {
|
||||
next := "declined"
|
||||
if a.Action == "cancel" {
|
||||
next = "cancelled"
|
||||
}
|
||||
_, e = tx.Exec(ctx, `UPDATE listening_requests SET status=$2 WHERE id=$1`, a.TargetId, next)
|
||||
}
|
||||
case "join", "invite":
|
||||
if id == a.TargetId {
|
||||
return invalid()
|
||||
}
|
||||
owner, guest, recipient := a.TargetId, id, a.TargetId
|
||||
if a.Action == "invite" {
|
||||
owner, guest, recipient = id, a.TargetId, a.TargetId
|
||||
}
|
||||
if _, e = tx.Exec(ctx, `SELECT id FROM users WHERE id=ANY($1::uuid[]) ORDER BY id FOR UPDATE`, []uuid.UUID{owner, guest}); e != nil {
|
||||
return e
|
||||
}
|
||||
permitted, automatic, err := listeningPolicy(ctx, tx, recipient, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !permitted {
|
||||
return denied()
|
||||
}
|
||||
var repeated bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_requests WHERE id=$1 AND sender_id=$2 AND owner_id=$3 AND guest_id=$4 AND kind=$5)`, a.Id, id, owner, guest, a.Action).Scan(&repeated); e != nil {
|
||||
return e
|
||||
}
|
||||
if repeated {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
var busy bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM listening_members m JOIN listening_rooms r ON r.id=m.room_id WHERE (m.user_id=$1 OR m.user_id=$2) AND r.owner_id<>$1)`, owner, guest).Scan(&busy); e != nil {
|
||||
return e
|
||||
}
|
||||
if busy {
|
||||
return apperr.Conflict("social.listening_busy", "already in another room")
|
||||
}
|
||||
var recent int
|
||||
if e = tx.QueryRow(ctx, `SELECT count(*) FROM listening_requests WHERE sender_id=$1 AND created_at>now()-interval '1 minute'`, id).Scan(&recent); e != nil {
|
||||
return e
|
||||
}
|
||||
if recent >= 10 {
|
||||
return apperr.TooManyRequests("social.rate_limited", 60)
|
||||
}
|
||||
var room uuid.UUID
|
||||
e = tx.QueryRow(ctx, `SELECT id FROM listening_rooms WHERE owner_id=$1`, owner).Scan(&room)
|
||||
if errors.Is(e, pgx.ErrNoRows) {
|
||||
var device, track uuid.UUID
|
||||
position := 0
|
||||
playing := true
|
||||
if a.Action == "invite" {
|
||||
if a.DeviceId == nil || *a.DeviceId == uuid.Nil || a.TrackId == nil || *a.TrackId == uuid.Nil {
|
||||
return invalid()
|
||||
}
|
||||
device, track = *a.DeviceId, *a.TrackId
|
||||
if a.PositionMs != nil {
|
||||
position = *a.PositionMs
|
||||
}
|
||||
if position < 0 || position > 86400000 {
|
||||
return invalid()
|
||||
}
|
||||
if a.Playing != nil {
|
||||
playing = *a.Playing
|
||||
}
|
||||
} else {
|
||||
visible, err := s.activities(ctx, id, []uuid.UUID{owner})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if visible[owner] == nil {
|
||||
return apperr.NotFound("social.activity_unavailable", "activity unavailable")
|
||||
}
|
||||
e = tx.QueryRow(ctx, `SELECT device_id,track_id,position_ms+LEAST(90000,GREATEST(0,(EXTRACT(EPOCH FROM(now()-updated_at))*1000)::int)) FROM listening_activity WHERE user_id=$1 AND track_id IS NOT NULL AND updated_at>now()-interval '90 seconds' ORDER BY updated_at DESC,device_id LIMIT 1`, owner).Scan(&device, &track, &position)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
var exists bool
|
||||
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, track).Scan(&exists); e != nil {
|
||||
return e
|
||||
}
|
||||
if !exists {
|
||||
return invalid()
|
||||
}
|
||||
room = uuid.New()
|
||||
_, e = tx.Exec(ctx, `INSERT INTO listening_rooms(id,owner_id,device_id,track_id,position_ms,playing) VALUES($1,$2,$3,$4,$5,$6)`, room, owner, device, track, position, playing)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
_, e = tx.Exec(ctx, `INSERT INTO listening_members(user_id,room_id) VALUES($1,$2)`, owner, room)
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
result, err := tx.Exec(ctx, `INSERT INTO listening_requests(id,room_id,owner_id,guest_id,sender_id,recipient_id,kind) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT DO NOTHING`, a.Id, room, owner, guest, id, recipient, a.Action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return apperr.Conflict("social.listening_pending", "request already pending")
|
||||
}
|
||||
if automatic {
|
||||
e = admitListening(ctx, tx, a.Id, room, guest, recipient, id)
|
||||
} else {
|
||||
kind := "listening_request"
|
||||
if a.Action == "invite" {
|
||||
kind = "listening_invite"
|
||||
}
|
||||
e = notify(ctx, tx, recipient, id, kind, nil)
|
||||
}
|
||||
default:
|
||||
return invalid()
|
||||
}
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -38,3 +38,14 @@ func TestHandlersRequireIdentityAndRejectSelf(t *testing.T) {
|
||||
_, e = h.SearchPeople(ctx, gen.SearchPeopleRequestObject{Params: gen.SearchPeopleParams{Q: " "}})
|
||||
require.Error(t, e)
|
||||
}
|
||||
|
||||
func TestListeningAudienceAndReactionValidation(t *testing.T) {
|
||||
require.False(t, audienceAllows("nobody", true))
|
||||
require.False(t, audienceAllows("friends", false))
|
||||
require.True(t, audienceAllows("friends", true))
|
||||
require.True(t, audienceAllows("everyone", false))
|
||||
require.False(t, audienceAllows("unexpected", true))
|
||||
require.True(t, validEmoji("❤️"))
|
||||
require.False(t, validEmoji("<script>"))
|
||||
require.Error(t, validateActivity(gen.ListeningActivityInput{DeviceId: uuid.New(), Sequence: 1, Playing: true, Context: "wave"}))
|
||||
}
|
||||
@@ -73,7 +73,11 @@ func (s *Store) people(ctx context.Context, id uuid.UUID, q string) ([]gen.Socia
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return scanPeople(rows)
|
||||
people, e := scanPeople(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return people, s.attachActivity(ctx, id, people)
|
||||
}
|
||||
func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error) {
|
||||
out := gen.SocialInbox{People: []gen.SocialPerson{}, Notifications: []gen.SocialNotification{}}
|
||||
@@ -85,6 +89,12 @@ func (s *Store) inbox(ctx context.Context, id uuid.UUID) (gen.SocialInbox, error
|
||||
if e != nil {
|
||||
return out, e
|
||||
}
|
||||
if e = s.attachActivity(ctx, id, out.People); e != nil {
|
||||
return out, e
|
||||
}
|
||||
if e = s.pool.QueryRow(ctx, `SELECT activity_visibility,friends_recommendations FROM users WHERE id=$1`, id).Scan(&out.ActivityVisibility, &out.FriendsRecommendations); e != nil {
|
||||
return out, e
|
||||
}
|
||||
rows, e = s.pool.Query(ctx, `SELECT n.id,n.actor_id,COALESCE(u.display_name,u.username::text),n.kind,n.created_at,n.read_at IS NOT NULL
|
||||
FROM social_notifications n JOIN users u ON u.id=n.actor_id WHERE n.user_id=$1 AND NOT u.is_blocked
|
||||
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=n.actor_id) OR (b.peer_id=$1 AND b.user_id=n.actor_id))
|
||||
@@ -215,14 +225,19 @@ func (s *Store) action(ctx context.Context, id uuid.UUID, a gen.SocialAction) er
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
const messageColumns = `id,seq,sender_id,text,attachment_kind,attachment_id,created_at,read_at IS NOT NULL`
|
||||
const messageColumns = `id,seq,sender_id,CASE WHEN deleted_at IS NULL THEN text ELSE '' END,attachment_kind,attachment_id,created_at,read_at IS NOT NULL,reply_to,edited_at,deleted_at IS NOT NULL`
|
||||
|
||||
func scanMessage(row pgx.Row) (gen.DirectMessage, error) {
|
||||
var m gen.DirectMessage
|
||||
e := row.Scan(&m.Id, &m.Seq, &m.SenderId, &m.Text, &m.AttachmentKind, &m.AttachmentId, &m.CreatedAt, &m.Read)
|
||||
var deleted bool
|
||||
e := row.Scan(&m.Id, &m.Seq, &m.SenderId, &m.Text, &m.AttachmentKind, &m.AttachmentId, &m.CreatedAt, &m.Read, &m.ReplyTo, &m.EditedAt, &deleted)
|
||||
m.Deleted = &deleted
|
||||
return m, e
|
||||
}
|
||||
func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64) ([]gen.DirectMessage, error) {
|
||||
return s.messagePage(ctx, id, peer, before, "")
|
||||
}
|
||||
func (s *Store) messagePage(ctx context.Context, id, peer uuid.UUID, before *int64, q string) ([]gen.DirectMessage, error) {
|
||||
tx, e := pairTx(ctx, s.pool, id, peer)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
@@ -231,7 +246,7 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
|
||||
if e = allowed(ctx, tx, id, peer); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) ORDER BY seq DESC LIMIT 50`, id, peer, before)
|
||||
rows, e := tx.Query(ctx, `SELECT `+messageColumns+` FROM direct_messages WHERE LEAST(sender_id,recipient_id)=LEAST($1::uuid,$2::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($1::uuid,$2::uuid) AND ($3::bigint IS NULL OR seq<$3) AND ($4='' OR (deleted_at IS NULL AND strpos(lower(text),lower($4))>0)) ORDER BY seq DESC LIMIT 50`, id, peer, before, q)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -250,7 +265,8 @@ func (s *Store) messages(ctx context.Context, id, peer uuid.UUID, before *int64)
|
||||
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||
out[i], out[j] = out[j], out[i]
|
||||
}
|
||||
return out, nil
|
||||
rows.Close()
|
||||
return out, enrichMessages(ctx, tx, id, out)
|
||||
}
|
||||
func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectMessage) (gen.DirectMessage, error) {
|
||||
var zero gen.DirectMessage
|
||||
@@ -274,10 +290,13 @@ func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectM
|
||||
if e != nil {
|
||||
return zero, e
|
||||
}
|
||||
if m.SenderId != id || recipient != peer || m.Text != strings.TrimSpace(in.Text) || string(m.AttachmentKind) != string(in.AttachmentKind) || !sameAttachment {
|
||||
sameReply := (m.ReplyTo == nil && in.ReplyTo == nil) || (m.ReplyTo != nil && in.ReplyTo != nil && *m.ReplyTo == *in.ReplyTo)
|
||||
if m.SenderId != id || recipient != peer || m.Text != strings.TrimSpace(in.Text) || string(m.AttachmentKind) != string(in.AttachmentKind) || !sameAttachment || !sameReply {
|
||||
return zero, apperr.Conflict("social.message_conflict", "message ID already used")
|
||||
}
|
||||
return m, nil
|
||||
out := []gen.DirectMessage{m}
|
||||
e = enrichMessages(ctx, tx, id, out)
|
||||
return out[0], e
|
||||
}
|
||||
if !errors.Is(e, pgx.ErrNoRows) {
|
||||
return zero, e
|
||||
@@ -303,7 +322,17 @@ func (s *Store) send(ctx context.Context, id, peer uuid.UUID, in gen.SendDirectM
|
||||
return zero, apperr.NotFound("social.attachment_unavailable", "music is unavailable")
|
||||
}
|
||||
}
|
||||
m, e = scanMessage(tx.QueryRow(ctx, `INSERT INTO direct_messages(id,sender_id,recipient_id,text,attachment_kind,attachment_id) VALUES($1,$2,$3,$4,$5,$6) RETURNING `+messageColumns, in.Id, id, peer, strings.TrimSpace(in.Text), in.AttachmentKind, in.AttachmentId))
|
||||
if in.ReplyTo != nil {
|
||||
var valid bool
|
||||
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM direct_messages WHERE id=$1 AND LEAST(sender_id,recipient_id)=LEAST($2::uuid,$3::uuid) AND GREATEST(sender_id,recipient_id)=GREATEST($2::uuid,$3::uuid) AND deleted_at IS NULL)`, in.ReplyTo, id, peer).Scan(&valid)
|
||||
if e != nil {
|
||||
return zero, e
|
||||
}
|
||||
if !valid {
|
||||
return zero, apperr.Conflict("social.message_conflict", "reply unavailable")
|
||||
}
|
||||
}
|
||||
m, e = scanMessage(tx.QueryRow(ctx, `INSERT INTO direct_messages(id,sender_id,recipient_id,text,attachment_kind,attachment_id,reply_to) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING `+messageColumns, in.Id, id, peer, strings.TrimSpace(in.Text), in.AttachmentKind, in.AttachmentId, in.ReplyTo))
|
||||
if e != nil {
|
||||
return zero, e
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package wave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Only currently visible activity is a social signal. A hidden profile never contributes
|
||||
// its listening history, and self feedback/recent plays retain precedence.
|
||||
func (h *Handler) friendCandidates(ctx context.Context, user uuid.UUID, exclude []string) ([]string, error) {
|
||||
rows, e := h.pool.Query(ctx, `SELECT a.track_id::text FROM listening_activity a
|
||||
JOIN users friend ON friend.id=a.user_id AND NOT friend.is_blocked AND friend.activity_visibility IN ('friends','everyone')
|
||||
JOIN users own ON own.id=$1 AND own.friends_recommendations
|
||||
JOIN friendships f ON f.low_id=LEAST($1::uuid,a.user_id) AND f.high_id=GREATEST($1::uuid,a.user_id) AND f.accepted
|
||||
JOIN tracks t ON t.id=a.track_id JOIN media_files mf ON mf.id=t.primary_file_id AND mf.missing_since IS NULL
|
||||
JOIN library_sources src ON src.id=mf.source_id AND src.status='mounted'
|
||||
WHERE a.updated_at>now()-interval '90 seconds' AND NOT(a.track_id::text=ANY($2::text[]))
|
||||
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=a.user_id) OR (b.user_id=a.user_id AND b.peer_id=$1))
|
||||
AND NOT EXISTS(SELECT 1 FROM track_reactions r WHERE r.user_id=$1 AND r.track_id=a.track_id AND r.reaction='dislike')
|
||||
AND NOT EXISTS(SELECT 1 FROM play_events e WHERE e.user_id=$1 AND e.track_id=a.track_id AND e.type='play_start' AND e.occurred_at>now()-interval '3 hours')
|
||||
GROUP BY a.track_id ORDER BY count(DISTINCT a.user_id) DESC,max(a.updated_at) DESC LIMIT 30`, user, exclude)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
return pgx.CollectRows(rows, pgx.RowTo[string])
|
||||
}
|
||||
func blendFriendPick(picks []Pick, ids []string) []Pick {
|
||||
if len(ids) == 0 || len(picks) < 3 {
|
||||
return picks
|
||||
}
|
||||
for _, id := range ids {
|
||||
exists := false
|
||||
for _, p := range picks {
|
||||
if p.ID == id {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
out := append([]Pick(nil), picks...)
|
||||
out[len(out)-1] = Pick{ID: id, Source: "friends", Score: 0.1}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return picks
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package wave
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFriendMixRemainsMinorityAndDoesNotDuplicate(t *testing.T) {
|
||||
original := []Pick{{ID: "one"}, {ID: "two"}, {ID: "three"}, {ID: "four"}, {ID: "five"}}
|
||||
mixed := blendFriendPick(original, []string{"one", "friend"})
|
||||
require.Len(t, mixed, 5)
|
||||
require.Equal(t, "friends", mixed[4].Source)
|
||||
require.Equal(t, "friend", mixed[4].ID)
|
||||
require.Equal(t, "five", original[4].ID)
|
||||
require.Equal(t, original, blendFriendPick(original, []string{"one"}))
|
||||
}
|
||||
@@ -67,6 +67,7 @@ type Feedback struct {
|
||||
|
||||
// BatchRequest — запрос к рекомендателю.
|
||||
type BatchRequest struct {
|
||||
FriendTrackIDs []string `json:"friend_track_ids,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
SessionID string `json:"session_id"`
|
||||
Activity *string `json:"activity"`
|
||||
@@ -295,6 +296,11 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
|
||||
UserID: s.UserID.String(), SessionID: s.ID.String(), Activity: s.Activity, Mood: s.Mood,
|
||||
ExcludeIDs: served, Feedback: fb, N: BatchSize,
|
||||
}
|
||||
friendIDs, friendErr := h.friendCandidates(ctx, s.UserID, served)
|
||||
if friendErr != nil {
|
||||
h.log.WarnContext(ctx, "friend music unavailable", "err", friendErr)
|
||||
}
|
||||
req.FriendTrackIDs = friendIDs
|
||||
if s.SeedType != nil && s.SeedID != nil {
|
||||
req.Seed = &Seed{Type: *s.SeedType, ID: s.SeedID.String()}
|
||||
}
|
||||
@@ -311,6 +317,9 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
|
||||
if picks, err = h.fallback(ctx, s, served); err != nil {
|
||||
return gen.WaveBatch{}, err
|
||||
}
|
||||
if s.SeedType == nil && len(served)%10 >= 5 {
|
||||
picks = blendFriendPick(picks, friendIDs)
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]uuid.UUID, 0, len(picks))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE users ADD COLUMN activity_visibility text NOT NULL DEFAULT 'nobody'
|
||||
CHECK(activity_visibility IN ('nobody','friends','everyone'));
|
||||
ALTER TABLE users ADD COLUMN friends_recommendations boolean NOT NULL DEFAULT true;
|
||||
CREATE TABLE listening_activity (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL,
|
||||
sequence bigint NOT NULL CHECK(sequence>0),
|
||||
position_ms integer NOT NULL DEFAULT 0 CHECK(position_ms>=0),
|
||||
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
context text NOT NULL CHECK(context IN ('other','wave','playlist')),
|
||||
playlist_id uuid REFERENCES playlists(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(user_id,device_id)
|
||||
);
|
||||
CREATE INDEX listening_activity_recent_idx ON listening_activity(user_id,updated_at DESC) WHERE track_id IS NOT NULL;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE listening_activity;
|
||||
ALTER TABLE users DROP COLUMN activity_visibility;
|
||||
ALTER TABLE users DROP COLUMN friends_recommendations;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE users ADD COLUMN listening_requests text NOT NULL DEFAULT 'nobody' CHECK(listening_requests IN ('nobody','friends','everyone')),
|
||||
ADD COLUMN listening_auto_accept text NOT NULL DEFAULT 'nobody' CHECK(listening_auto_accept IN ('nobody','friends','everyone')),
|
||||
ADD COLUMN listening_autoplay boolean NOT NULL DEFAULT true;
|
||||
CREATE TABLE listening_rooms (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id uuid NOT NULL,
|
||||
sequence bigint NOT NULL DEFAULT 0,
|
||||
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
position_ms integer NOT NULL DEFAULT 0 CHECK(position_ms>=0),
|
||||
playing boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE listening_members (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
room_id uuid NOT NULL REFERENCES listening_rooms(id) ON DELETE CASCADE,
|
||||
last_seen timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX listening_members_room_idx ON listening_members(room_id);
|
||||
CREATE TABLE listening_requests (
|
||||
id uuid PRIMARY KEY,
|
||||
room_id uuid NOT NULL REFERENCES listening_rooms(id) ON DELETE CASCADE,
|
||||
owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
guest_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
sender_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
recipient_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK(kind IN ('join','invite')),
|
||||
status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined','cancelled')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL DEFAULT now()+interval '2 minutes',
|
||||
CHECK(owner_id<>guest_id)
|
||||
);
|
||||
CREATE UNIQUE INDEX listening_requests_pending_idx ON listening_requests(owner_id,guest_id) WHERE status='pending';
|
||||
CREATE INDEX listening_requests_recipient_idx ON listening_requests(recipient_id,expires_at) WHERE status='pending';
|
||||
ALTER TABLE social_notifications DROP CONSTRAINT social_notifications_kind_check;
|
||||
ALTER TABLE social_notifications ADD CHECK(kind IN ('friend_request','friend_accepted','message','music','listening_request','listening_invite','listening_accepted'));
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM social_notifications WHERE kind LIKE 'listening_%';
|
||||
ALTER TABLE social_notifications DROP CONSTRAINT social_notifications_kind_check;
|
||||
ALTER TABLE social_notifications ADD CHECK(kind IN ('friend_request','friend_accepted','message','music'));
|
||||
DROP TABLE listening_requests;
|
||||
DROP TABLE listening_members;
|
||||
DROP TABLE listening_rooms;
|
||||
ALTER TABLE users DROP COLUMN listening_requests, DROP COLUMN listening_auto_accept, DROP COLUMN listening_autoplay;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE direct_messages ADD COLUMN reply_to uuid REFERENCES direct_messages(id) ON DELETE SET NULL,
|
||||
ADD COLUMN edited_at timestamptz, ADD COLUMN deleted_at timestamptz;
|
||||
CREATE TABLE message_reactions (
|
||||
message_id uuid NOT NULL REFERENCES direct_messages(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji text NOT NULL CHECK(emoji IN ('❤️','👍','🔥','😂','🎵','👏')),
|
||||
PRIMARY KEY(message_id,user_id,emoji)
|
||||
);
|
||||
CREATE TABLE chat_typing (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
peer_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
PRIMARY KEY(user_id,peer_id), CHECK(user_id<>peer_id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE chat_typing;
|
||||
DROP TABLE message_reactions;
|
||||
ALTER TABLE direct_messages DROP COLUMN reply_to, DROP COLUMN edited_at, DROP COLUMN deleted_at;
|
||||
@@ -313,6 +313,7 @@ def build_batch(
|
||||
rng: np.random.Generator | None = None,
|
||||
ranker: Ranker | None = None,
|
||||
ctx: Context | None = None,
|
||||
friend_ids: list[str] | None = None,
|
||||
) -> list[Pick]:
|
||||
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
|
||||
rng = rng or np.random.default_rng()
|
||||
@@ -363,6 +364,8 @@ def build_batch(
|
||||
if seeds:
|
||||
q = index.emb[seeds].mean(axis=0)
|
||||
add(_top((index.emb @ q).astype(np.float64), allowed & index.has_emb, 100), "seed")
|
||||
friends = [index.pos[t] for t in (friend_ids or []) if t in index.pos and allowed[index.pos[t]]][:10]
|
||||
add(friends, "friends")
|
||||
query = np.maximum(sim_session, sim_taste) if svec is not None or len(centroids) else None
|
||||
if query is not None:
|
||||
# «Новое» помечаем раньше audio_sim: неслышанный трек рядом со вкусом считается исследованием.
|
||||
@@ -446,6 +449,8 @@ def build_batch(
|
||||
pred = ranker.predict(x)
|
||||
score = np.argsort(np.argsort(pred)) / max(len(pred) - 1, 1)
|
||||
|
||||
# Social influence is small, context-sensitive, and survives the learned ranker.
|
||||
score = score + 0.06 * np.isin(cand, friends) * f
|
||||
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
|
||||
picks: list[Pick] = []
|
||||
chosen: list[int] = []
|
||||
@@ -457,6 +462,8 @@ def build_batch(
|
||||
best_j, best_v = -1, -np.inf
|
||||
for j in order:
|
||||
i = int(cand[j])
|
||||
if sources[i] == "friends" and any(p.source == "friends" for p in picks):
|
||||
continue
|
||||
a = index.artist[i]
|
||||
if any(index.artist[c] == a for c in chosen) or recent_artists.count(a) >= 2:
|
||||
continue
|
||||
@@ -471,7 +478,9 @@ def build_batch(
|
||||
continue
|
||||
if chosen:
|
||||
chosen_artists = {index.artist[c] for c in chosen}
|
||||
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists]
|
||||
social_chosen = any(p.source == "friends" for p in picks)
|
||||
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists
|
||||
and not (sources[int(cand[j])] == "friends" and social_chosen)]
|
||||
if not rest:
|
||||
break
|
||||
best_j = max(rest, key=lambda j: score[j])
|
||||
|
||||
@@ -38,6 +38,7 @@ class BatchRequest(BaseModel):
|
||||
mood: str | None = None
|
||||
seed: Seed | None = None
|
||||
exclude_ids: list[str] = Field(default_factory=list, max_length=1000)
|
||||
friend_track_ids: list[str] = Field(default_factory=list, max_length=30)
|
||||
feedback: list[Feedback] = Field(default_factory=list, max_length=200)
|
||||
n: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
@@ -93,6 +94,7 @@ class WaveService:
|
||||
n=req.n,
|
||||
exclude=set(req.exclude_ids),
|
||||
seed_ids=seeds,
|
||||
friend_ids=req.friend_track_ids,
|
||||
rng=np.random.default_rng(),
|
||||
ranker=ranker,
|
||||
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
|
||||
|
||||
@@ -45,6 +45,23 @@ def profile(**kw: object) -> Profile:
|
||||
return Profile(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_friend_signal_is_bounded_and_respects_negative_feedback() -> None:
|
||||
index = library()
|
||||
blocked = {"t0-0", "t1-0"}
|
||||
picks = build_batch(index, profile(disliked=blocked), Session(), [], n=5,
|
||||
friend_ids=index.ids, rng=np.random.default_rng(8))
|
||||
assert not blocked.intersection(p.id for p in picks)
|
||||
assert sum(p.source == "friends" for p in picks) <= 1
|
||||
assert len(picks) == 5
|
||||
|
||||
|
||||
def test_empty_friend_signal_does_not_change_ranking() -> None:
|
||||
index = library()
|
||||
first = build_batch(index, profile(), Session(), [], rng=np.random.default_rng(17))
|
||||
second = build_batch(index, profile(), Session(), [], friend_ids=[], rng=np.random.default_rng(17))
|
||||
assert [p.id for p in first] == [p.id for p in second]
|
||||
|
||||
|
||||
def cluster(track_id: str) -> int:
|
||||
return int(track_id[1])
|
||||
|
||||
|
||||
Reference in new issue
Block a user