diff --git a/apps/web/e2e/mini-social.spec.ts b/apps/web/e2e/mini-social.spec.ts new file mode 100644 index 0000000..51dbddd --- /dev/null +++ b/apps/web/e2e/mini-social.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test'; + +for (const width of [1280, 1920]) { + test(`mini chat keeps the page and draft at ${width}px`, async ({ page }, info) => { + await page.setViewportSize({ width, height: 900 }); + await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); + await page.goto('/login'); + await page.getByLabel('Логин', { exact: true }).fill('alice'); + await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password'); + await page.getByRole('button', { name: 'Войти', exact: true }).click(); + if (width === 1920) { + await page.getByRole('button', { name: 'Меню профиля', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click(); + await page.getByRole('button', { name: 'Остров', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await page.getByRole('link', { name: 'Главная', exact: true }).first().click(); + } + const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true }); + const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true }); + await toggle.click(); + const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true }); + await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator'); + await panel.getByRole('button', { name: /moderator/ }).click(); + const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true }); + await composer.fill('Черновик из мини-чата'); + await expect(page).toHaveURL(/\/$/); + await panel.getByRole('button', { name: 'Свернуть чат', exact: true }).click(); + await expect(panel).toBeHidden(); + await expect(toggle).toBeFocused(); + await page.getByRole('link', { name: 'История', exact: true }).first().click(); + await toggle.click(); + await expect(composer).toHaveValue('Черновик из мини-чата'); + await panel.getByRole('button', { name: 'Отправить', exact: true }).click(); + await expect(panel.getByText('Черновик из мини-чата', { exact: true })).toBeVisible(); + await expect(composer).toBeEmpty(); + await expect(page).toHaveURL(/\/collection\/history$/); + const panelBox = (await panel.boundingBox())!; + const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер', exact: true }).boundingBox())!; + expect(panelBox.width).toBeLessThanOrEqual(600); + expect(panelBox.x).toBeGreaterThanOrEqual(width - 640); + expect(panelBox.y).toBeGreaterThanOrEqual(60); + expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y); + await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true }); + await composer.fill('Продолжить в полном чате'); + await composer.press('Escape'); + await expect(panel).toBeHidden(); + await toggle.click(); + await expect(composer).toHaveValue('Продолжить в полном чате'); + await page.setViewportSize({ width: 800, height: 900 }); + await expect(dock).toHaveCount(0); + await page.setViewportSize({ width, height: 900 }); + await expect(composer).toHaveValue('Продолжить в полном чате'); + await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click(); + await expect(page).toHaveURL(/\/friends\?/); + await expect(dock).toHaveCount(0); + await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveValue('Продолжить в полном чате'); + }); +} diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 8f2e60d..03878e5 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -134,8 +134,9 @@ 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 { api, user } = await renderApp([alice], '/', { signedInAs: alice }); + await user.click(await screen.findByRole('button', { name: 'Меню профиля' })); + await user.click(await screen.findByRole('menuitem', { 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: 'Друзьям' })); @@ -152,15 +153,19 @@ describe('social', () => { it('shows persistent notifications and acknowledges only the chosen event', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); - const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); - await screen.findByRole('heading', { name: 'Друзья и общение' }); + const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice }); + api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false }); api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false }); await queryClient.invalidateQueries({ queryKey: ['social'] }); - await user.click(await screen.findByRole('button', { name: 'Уведомления 1' })); + await user.click(await screen.findByRole('button', { name: 'Меню профиля' })); + await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' })); await screen.findByText('Хочет добавить вас в друзья'); await user.click(screen.getByRole('button', { name: 'Прочитано' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument()); expect(api.notifications[0]?.read).toBe(true); + await user.click(screen.getByRole('button', { name: 'Открыть' })); + await screen.findByRole('button', { name: 'Принять' }); + expect(router.state.location.pathname).toBe('/friends'); expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id }); }); it('searches, requests, cancels, accepts and exchanges messages and music', async () => { diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index 881be8d..926a2a0 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -149,7 +149,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: () => }); -const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record): { peer?: string } => typeof s.peer === 'string' ? { peer: s.peer } : {}, component: () => }); +const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return ; } }); const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => }); const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => }); const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage }); diff --git a/apps/web/src/features/social/MiniSocial.tsx b/apps/web/src/features/social/MiniSocial.tsx new file mode 100644 index 0000000..a6471de --- /dev/null +++ b/apps/web/src/features/social/MiniSocial.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from 'react'; +import { useRouterState, useNavigate } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; +import { Button, EmptyState, FormError, Icon } from '../../shared/ui/ui'; +import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icons'; +import { useErrorText } from '../../shared/lib/errors'; +import { ChatConversation } from './ChatConversation'; +import { Presence } from './Presence'; +import { useSocial, useSocialDialogOpen } from './Social'; +import s from './social.module.css'; + +/** Lives in the shell so browsing and minimizing never change the selected conversation. */ +export function MiniSocial() { + const { t } = useTranslation(); + const { api, inbox, key, user } = useSocial(); + const errorText = useErrorText(); + const navigate = useNavigate(); + const pathname = useRouterState({ select: state => state.location.pathname }); + const dialogOpen = useSocialDialogOpen(); + const [desktop, setDesktop] = useState(() => window.matchMedia?.('(min-width: 1024px)').matches ?? false); + const [open, setOpen] = useState(false); + const [peer, setPeer] = useState(''); + const [search, setSearch] = useState(''); + const trigger = useRef(null); + const panel = useRef(null); + const shown = desktop && pathname !== '/friends'; + useEffect(() => { + const media = window.matchMedia?.('(min-width: 1024px)'); + if (!media) return; + const change = () => setDesktop(media.matches); + media.addEventListener('change', change); + return () => media.removeEventListener('change', change); + }, []); + useEffect(() => { + if (open && shown) panel.current?.querySelector('input')?.focus(); + }, [open, shown]); + const friends = (inbox.data?.people ?? []).filter(person => person.relationship === 'friend'); + const selected = friends.find(person => person.id === peer); + const unread = friends.reduce((sum, person) => sum + person.unread, 0); + const filtered = [...friends].sort((a, b) => Number(b.online) - Number(a.online) || (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name)) + .filter(person => `${person.display_name} ${person.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())); + const minimize = () => { setOpen(false); trigger.current?.focus(); }; + if (!shown) return null; + return ; +} diff --git a/apps/web/src/features/social/Social.tsx b/apps/web/src/features/social/Social.tsx index c3cf8b3..4ba48ae 100644 --- a/apps/web/src/features/social/Social.tsx +++ b/apps/web/src/features/social/Social.tsx @@ -16,7 +16,7 @@ import { Presence } from './Presence'; const scopes = new WeakMap(); let nextScope = 0; -function useSocial() { +export function useSocial() { const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? ''; @@ -36,11 +36,15 @@ export function FriendRecommendations() { } type ShareValue = { attachment: MusicAttachment; url: string }; +type AccountDialog = 'privacy' | 'notifications' | null; +const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} }); +export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null; const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); }); export const useMusicShare = () => useContext(ShareContext); export function SocialProvider({ children }: { children: ReactNode }) { const [share, setShare] = useState(null); - return {children}{share && setShare(null)} />}; + const [dialog, setDialog] = useState(null); + return {children}{share && setShare(null)} />}; } function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) { const { t } = useTranslation(); @@ -75,10 +79,55 @@ function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) ; } -type SocialTab = 'messages' | 'people' | 'requests' | 'notifications'; +export function useSocialProfileMenu() { + const { t, i18n } = useTranslation(); + const { api, inbox, key } = useSocial(); + const cache = useQueryClient(); + const navigate = useNavigate(); + const errorText = useErrorText(); + const { dialog, setDialog } = useContext(AccountDialogContext); + const action = 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 people = inbox.data?.people ?? []; + const currentVisibility = inbox.data?.activity_visibility ?? 'nobody'; + const open = (id: string) => { + const relationship = people.find(p => p.id === id)?.relationship; + const tab = relationship === 'friend' ? 'messages' : relationship === 'incoming' || relationship === 'outgoing' ? 'requests' : 'people'; + setDialog(null); + void navigate({ to: '/friends', search: { peer: id, tab } }); + }; + return { + items: [ + { label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') }, + { label: `${t('social.notifications')}${inbox.data?.unread ? ` ${inbox.data.unread}` : ''}`, icon: inboxIcon, onSelect: () => setDialog('notifications') }, + ], + dialogs: <> + {dialog === 'privacy' && setDialog(null)}> +
{t('social.activity_visibility')}
{(['nobody', 'friends', 'everyone'] as const).map(v => )}

{t('social.activity_privacy_hint')}

+ {visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null} + + {people.some(p => p.relationship === 'blocked') &&

{t('social.blocked_people')}

{people.filter(p => p.relationship === 'blocked').map(p => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}
} +
} + {dialog === 'notifications' && setDialog(null)}> + {inbox.error || action.error ? errorText(inbox.error ?? action.error) : null} +
+ {inbox.data?.notifications.length === 0 && {t('social.no_notifications')}} + {inbox.data?.notifications.map(n =>
+ {n.actor_name.slice(0, 1).toUpperCase()} +
{n.actor_name}

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

+
{!n.read && }
+
)} +
+
} + , + }; +} + +type SocialTab = 'messages' | 'people' | 'requests'; type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' }; -export function SocialPage({ peer = '' }: { peer?: string }) { +export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) { + const { dialog } = useContext(AccountDialogContext); const { t, i18n } = useTranslation(); const { api, inbox, key, user } = useSocial(); const cache = useQueryClient(); @@ -87,7 +136,6 @@ export function SocialPage({ peer = '' }: { peer?: string }) { const [tab, setTab] = useState('messages'); const [search, setSearch] = useState(''); const [q, setQ] = useState(''); - const [settings, setSettings] = useState(false); const [confirm, setConfirm] = useState(null); const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false); const searchRef = useRef(null); @@ -102,10 +150,10 @@ 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: tab === 'people' && q.length >= 2 }); const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } }); - const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } }); const people = inbox.data?.people ?? []; const friends = people.filter(p => p.relationship === 'friend'); const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name)); + useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]); const initialPeer = conversations[0]?.id; useEffect(() => { // Pin the initial desktop selection: a newer message in another dialog must @@ -130,15 +178,10 @@ export function SocialPage({ peer = '' }: { peer?: string }) { if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op }); else action.mutate({ action: op, target_id: person.id }); }; - const currentVisibility = inbox.data?.activity_visibility ?? 'nobody'; const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search'); return
-
{t('social.space')}

{t('social.title')}

{t('social.subtitle')}

-
- - -
+

{t('social.title')}

@@ -147,15 +190,7 @@ export function SocialPage({ peer = '' }: { peer?: string }) { {inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null} - {tab === 'notifications' ?
-

{t('social.notifications')}

- {inbox.data?.notifications.length === 0 && {t('social.no_notifications')}} - {inbox.data?.notifications.map(n =>
- {n.actor_name.slice(0, 1).toUpperCase()} -
{n.actor_name}

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

-
{!n.read && }
-
)} -
:
+
- {tab === 'messages' && (selected ? act(selected, op)} />} /> :

{t('social.conversation_title')}

{t('social.choose_chat')}

)} -
} + {tab === 'messages' && (selected ? act(selected, op)} />} /> :

{t('social.conversation_title')}

{t('social.choose_chat')}

)} +
- {settings && setSettings(false)}> -
{t('social.activity_visibility')}
{(['nobody', 'friends', 'everyone'] as const).map(v => )}

{t('social.activity_privacy_hint')}

- {visibility.error ? errorText(visibility.error) : null} - - {people.some(p => p.relationship === 'blocked') &&

{t('social.blocked_people')}

{people.filter(p => p.relationship === 'blocked').map(p => {}} act={op => act(p, op)} />)}
} -
} {confirm && { if (!action.isPending) setConfirm(null); }} footer={<>}>

{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}

{action.error ? errorText(action.error) : null}
}
; } diff --git a/apps/web/src/features/social/social.module.css b/apps/web/src/features/social/social.module.css index 7fed7a6..2232e71 100644 --- a/apps/web/src/features/social/social.module.css +++ b/apps/web/src/features/social/social.module.css @@ -1,10 +1,7 @@ -.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 20px; } +.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; } .pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; } -.page h1 { font-size: clamp(22px, 2.2vw, 30px); letter-spacing: -.035em; margin: 4px 0 0; } -.pageHeader p { margin: 8px 0 0; } -.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: 10px; font-weight: 600; color: var(--text3); } +.page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; } .muted { color: var(--text2); font-size: 13px; line-height: 1.6; } -.headerActions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: end; } .hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); } .tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; } .tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; } @@ -133,6 +130,42 @@ .musicUnavailable { opacity: .7; } .musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; } .musicMessages .musicCard { width: 100%; } +.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; } +.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: 0 16px 64px #0008; overflow: hidden; animation: panelIn .2s ease-out; } +.miniDockIsland { margin-bottom: 132px; } +.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); } +.miniPanel[hidden] { display: none; } +.miniHeader { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 16px; border-bottom: 1px solid var(--line); } +.miniHeader > strong { flex: 1; font-size: 14px; } +.miniHeader > button { min-height: 36px; padding: 0 8px; font-size: 12px; } +.miniWorkspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 170px minmax(0, 1fr); } +.miniFriends { overflow-y: auto; min-width: 0; padding: 8px; border-right: 1px solid var(--line); } +.miniFriends .listSearch { padding: 0 8px; gap: 5px; margin: 0 0 8px; min-height: 36px; } +.miniFriends .listSearch input { height: 36px; font-size: 11px; } +.miniFriends .dialogRow { gap: 8px; padding: 10px 6px; } +.miniFriends .avatar { width: 28px; height: 28px; font-size: 12px; } +.miniFriends .dialogBody strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; } +.miniFriends .badge { min-width: 17px; height: 17px; padding: 0 4px; font-size: 10px; } +.miniWorkspace .chat { min-width: 0; } +.miniWorkspace .chatHeader { gap: 8px; padding: 10px; } +.miniWorkspace .chatHeader .avatar { width: 30px; height: 30px; font-size: 12px; } +.miniWorkspace .chatIdentity h2 { font-size: 14px; } +.miniWorkspace .chatTools .togetherLabel { display: none; } +.miniWorkspace .chatTools > button, .miniWorkspace .chatTools > div > button { width: 34px; min-width: 34px; padding: 0; } +.miniWorkspace .chatTabs { gap: 14px; padding: 0 12px; } +.miniWorkspace .chatTabs button { font-size: 11px; } +.miniWorkspace .messages { padding: 12px; gap: 10px; } +.miniWorkspace .bubble { max-width: 94%; font-size: 13px; padding: 8px 10px 4px; } +.miniWorkspace .composer { padding: 8px; gap: 4px; } +.miniWorkspace .composer .iconButton { width: 34px; min-width: 34px; } +.miniWorkspace .composer textarea { padding: 8px; font-size: 13px; } +.miniWorkspace .welcome { padding: 20px; } +.miniTrigger { position: relative; pointer-events: auto; display: grid; place-items: center; width: 48px; height: 48px; flex-shrink: 0; border-radius: 50%; color: var(--text); border: 1px solid var(--line2); background: var(--surface); box-shadow: 0 6px 24px #0005; cursor: pointer; } +.miniTrigger:hover, .miniTrigger[aria-expanded='true'] { background: var(--accent-fill); color: var(--on-accent, #fff); } +.miniTrigger:focus-visible { outline: 2px solid var(--accent2); outline-offset: 3px; } +.miniTrigger > .badge { position: absolute; top: -5px; right: -5px; } +@media(max-width: 1023px) { .miniDock { display: none; } } +@media(prefers-reduced-motion: reduce) { .miniPanel { animation: none; } } .attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; } .expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; } @keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } } @@ -140,10 +173,10 @@ @keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } } @keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } } @keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } } -@media(max-width: 1150px) { .headerActions { gap: 0; } .pageHeader { align-items: start; } .headerActions > button { font-size: 12px; padding: 0 10px; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } } -@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .headerActions { justify-content: start; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } } +@media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } } +@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } } @media(max-width: 767px) { - .page { gap: 12px; } .pageHeader { padding: 0 2px; } .pageHeader p { display: none; } .eyebrow { display: none; } .page h1 { font-size: 23px; } .headerActions { width: 100%; justify-content: space-between; } + .page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; } .hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; } .workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; } .conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; } diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx index 873831b..174f87b 100644 --- a/apps/web/src/widgets/AppShell.tsx +++ b/apps/web/src/widgets/AppShell.tsx @@ -38,7 +38,8 @@ import { PlayerBar } from './PlayerBar'; 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 { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social'; +import { MiniSocial } from '../features/social/MiniSocial'; import { ListeningBanner } from '../features/social/Listening'; type NavPath = @@ -164,6 +165,7 @@ function Shell() { const { t } = useTranslation(); const user = useUser(); const socialUnread = useSocialUnread(); + const socialProfile = useSocialProfileMenu(); const session = useSession(); const desktop = useDesktop(); const navigate = useNavigate(); @@ -270,6 +272,7 @@ function Shell() { } items={[ + ...socialProfile.items, { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, ...(!desktop ? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }] @@ -301,7 +304,9 @@ function Shell() { )} + {socialProfile.dialogs} + diff --git a/docs/social.md b/docs/social.md index 92bc14b..664c30f 100644 --- a/docs/social.md +++ b/docs/social.md @@ -3,6 +3,12 @@ Социальные функции работают между пользователями одного сервера. В web и desktop открывается раздел «Друзья и общение», на Android — вкладка «Друзья». + +На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px. +Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением +черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px +используется полный раздел общения. + ## Поведение - Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек. diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 2fff3a3..d92dd61 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -387,6 +387,9 @@ "no_results": "No playlists found" }, "social": { + "mini_chat": "Friends and chat", + "open_full": "Open full view", + "minimize_chat": "Minimize chat", "online": "Online", "offline": "Offline", "together_offline": "Listen together when your friend is online", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 035ad8e..c503264 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -387,6 +387,9 @@ "no_results": "Плейлисты не найдены" }, "social": { + "mini_chat": "Друзья и чат", + "open_full": "Открыть раздел", + "minimize_chat": "Свернуть чат", "online": "В сети", "offline": "Не в сети", "together_offline": "Слушать вместе можно, когда друг в сети",