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
+5022
-542
No files matched your search
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user