feat(social): show friend presence and require online listening
ci / api / api (push) Successful in 1m11s
ci / db / db (push) Failing after 23s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
ci / ts / ts (packages + web) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
ci / api / api (push) Successful in 1m11s
ci / db / db (push) Failing after 23s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
ci / ts / ts (packages + web) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
This commit is contained in:
1 parent
582c0dc3d4
commit
cd84ceecb2
26 files changed
+574
-400
No files matched your search
@@ -36,7 +36,7 @@ export function ChatConversation({ person, api, queryKey, userId, server, active
|
||||
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>
|
||||
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><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>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, 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';
|
||||
@@ -32,7 +32,8 @@ export function ListeningPanel() {
|
||||
}
|
||||
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)} />}</>;
|
||||
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <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();
|
||||
|
||||
@@ -123,6 +123,7 @@ 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' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
|
||||
{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>
|
||||
|
||||
@@ -17,6 +17,8 @@ for (const width of [320, 800, 1280]) {
|
||||
await openFriends();
|
||||
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
|
||||
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
|
||||
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByText('Не в сети', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByRole('button', { name: 'Слушать вместе', exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
|
||||
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
|
||||
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
|
||||
|
||||
@@ -15,6 +15,28 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('social', () => {
|
||||
it('enables listening together only while a friend is online, independently of music privacy', 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'] });
|
||||
const conversation = await screen.findByRole('region', { name: 'Написать' });
|
||||
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
|
||||
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
|
||||
api.presence.set(bob.id, Date.now());
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await within(conversation).findByText('В сети');
|
||||
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeEnabled();
|
||||
expect(api.activity.has(bob.id)).toBe(false);
|
||||
await user.click(within(conversation).getByRole('button', { name: 'Слушать вместе' }));
|
||||
await screen.findByRole('dialog', { name: 'Слушать вместе' });
|
||||
api.presence.set(bob.id, Date.now() - 46_000);
|
||||
await queryClient.invalidateQueries({ queryKey: ['social'] });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Слушать вместе' })).not.toBeInTheDocument());
|
||||
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
|
||||
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('finds music beyond the last text page without acknowledging unread messages', async () => {
|
||||
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
|
||||
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
|
||||
@@ -113,8 +135,8 @@ describe('social', () => {
|
||||
it('saves privacy and independent request admission preferences', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice });
|
||||
await user.click(await screen.findByRole('button', { name: 'Мой статус скрыт' }));
|
||||
const privacy = await screen.findByRole('group', { name: 'Показывать мой статус' });
|
||||
await user.click(await screen.findByRole('button', { name: 'Моя музыка скрыта' }));
|
||||
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'));
|
||||
|
||||
@@ -9,6 +9,7 @@ import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIco
|
||||
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
|
||||
import { ListenTogetherButton } from './Listening';
|
||||
import { ChatMusic, MessageText } from './ChatMusic';
|
||||
import { Presence } from './Presence';
|
||||
import s from './social.module.css';
|
||||
|
||||
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
|
||||
@@ -85,8 +86,8 @@ export function ChatConversation({ person, api, queryKey, userId, active = true,
|
||||
return <section className={s.chat} aria-label={t('social.chat')}>
|
||||
<header className={s.chatHeader}>
|
||||
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
|
||||
<span className={`${s.avatar} ${person.activity ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<div className={s.chatIdentity}><h2>{person.display_name}</h2><span className={s.muted}>@{person.username}</span></div>
|
||||
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<div className={s.chatIdentity}><h2>{person.display_name}</h2><Presence online={person.online} /></div>
|
||||
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
|
||||
</header>
|
||||
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession, useUser } from '../../app/session';
|
||||
import { useDesktop } from '../../desktop';
|
||||
@@ -34,7 +34,8 @@ export function ListeningPanel({ showPreferences = true, showRequests = true }:
|
||||
}
|
||||
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
|
||||
const { t } = useTranslation(); const [open, setOpen] = useState(false);
|
||||
return <><Button variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
|
||||
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
|
||||
return <><Button disabled={!person.online} title={!person.online ? t('social.together_offline') : undefined} variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && person.online && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
|
||||
}
|
||||
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
|
||||
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import s from './social.module.css';
|
||||
|
||||
export function Presence({ online }: { online: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return <span className={`${s.presence} ${online ? s.presenceOnline : ''}`}><span className={s.presenceDot} aria-hidden />{t(online ? 'social.online' : 'social.offline')}</span>;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldI
|
||||
import s from './social.module.css';
|
||||
import { ListeningPanel, ListenTogetherButton } from './Listening';
|
||||
import { ChatConversation } from './ChatConversation';
|
||||
import { Presence } from './Presence';
|
||||
|
||||
const scopes = new WeakMap<object, number>();
|
||||
let nextScope = 0;
|
||||
@@ -161,8 +162,8 @@ export function SocialPage({ peer = '' }: { peer?: string }) {
|
||||
{tab === 'people' && q.length >= 2 && results.isFetching && <p className={s.muted} role="status">{t('social.loading')}</p>}
|
||||
{!shownPeople.length && inbox.data && !results.isFetching && <EmptyState icon={tab === 'requests' ? inboxIcon : groupIcon} action={!search && tab !== 'people' ? <Button onClick={findPeople}>{t('social.find_people')}</Button> : undefined}>{t(search ? 'social.no_results' : tab === 'requests' ? 'social.no_requests' : 'social.empty')}</EmptyState>}
|
||||
{shownPeople.map(p => tab === 'messages' ? <button key={p.id} type="button" className={`${s.dialogRow} ${selected?.id === p.id ? s.selected : ''}`} aria-pressed={selected?.id === p.id} onClick={() => open(p.id)}>
|
||||
<span className={`${s.avatar} ${p.activity ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span>{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
|
||||
<span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span><Presence online={p.online} />{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
|
||||
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
|
||||
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
|
||||
</aside>
|
||||
@@ -190,7 +191,7 @@ function FriendMenu({ person, act }: { person: SocialPerson; act: (a: SocialActi
|
||||
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected?: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
|
||||
<div className={s.identity}><span className={`${s.avatar} ${p.activity ? s.avatarListening : ''}`} 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>
|
||||
<div className={s.identity}><span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} 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>{p.relationship === 'friend' && <Presence online={p.online} />}</div></div>
|
||||
{p.activity && <div className={s.activity}><span className={s.listening}><Icon src={musicIcon} size={16} />{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 === '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><ListenTogetherButton person={p} /></>}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
.listeningPreview { display: flex; align-items: center; gap: 4px; color: var(--ok); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.avatar { position: relative; width: 42px; height: 42px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); font-size: 16px; font-weight: 600; }
|
||||
.avatarListening::after { content: ''; position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; background: var(--ok); border-radius: 50%; border: 2px solid var(--surface); }
|
||||
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
|
||||
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
|
||||
.presenceOnline .presenceDot { background: var(--ok); }
|
||||
.directory, .events { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
|
||||
.directory .peopleList { padding: 0; overflow: visible; }
|
||||
.directory .listSearch { max-width: 440px; margin-left: 0; }
|
||||
@@ -70,6 +73,7 @@
|
||||
.chatIdentity { flex: 1; min-width: 0; }
|
||||
.chatIdentity h2 { font-size: 16px; line-height: 1.4; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chatIdentity > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.chatIdentity > .presence { display: inline-flex; }
|
||||
.chatTools { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.iconButton { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; flex-shrink: 0; }
|
||||
.backButton { display: none; }
|
||||
|
||||
@@ -90,6 +90,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
const listeningMembers = new Map<string, string>();
|
||||
const listeningRequests = new Map<string, components['schemas']['ListeningRequest'] & { guest_id: string }>();
|
||||
const typing = new Map<string, number>();
|
||||
const presence = 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}`);
|
||||
@@ -100,6 +101,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
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) } : {}),
|
||||
...(last ? { last_message: { id: last.id, sender_id: last.sender_id, text: last.deleted ? '' : last.text.slice(0, 160), attachment_kind: last.attachment_kind, created_at: last.created_at, deleted: last.deleted ?? false } } : {}),
|
||||
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
|
||||
online: !!f?.accepted && !isBlocked(me, p.id) && (presence.get(p.id) ?? 0) > Date.now() - 45_000,
|
||||
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
|
||||
};
|
||||
const notify = (recipient: string, actor: FakeUser, kind: components['schemas']['SocialNotification']['kind']) => notifications.unshift({
|
||||
@@ -244,6 +246,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
}
|
||||
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') {
|
||||
presence.set(user.id, Date.now());
|
||||
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()});
|
||||
@@ -258,6 +261,7 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
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');
|
||||
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
|
||||
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);
|
||||
@@ -510,5 +514,5 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
return problem(404, 'common.not_found');
|
||||
};
|
||||
|
||||
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, activityVisibility, activity, friendRecommendations, listeningPreferences, listeningRooms, listeningMembers, listeningRequests, signOut: () => (current = null) };
|
||||
return { presence, 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