feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
Generated
+872
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -10,23 +10,27 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"agora-rtc-sdk-ng": "^4.24.8",
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-intl": "^7.1.6",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"zustand": "^5.0.9",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"tinode-sdk": "^0.25.3",
|
||||
"react-intl": "^7.1.6"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^8.2.2",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"@tailwindcss/vite": "^4.1.16"
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
+50
-7
@@ -3,16 +3,32 @@ import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
|
||||
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { RegisterPage } from '@/features/auth/RegisterPage';
|
||||
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
|
||||
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
||||
import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage';
|
||||
import { CallHistoryView } from '@/features/calls/CallHistoryView';
|
||||
import { BlocklistView } from '@/features/contacts/BlocklistView';
|
||||
import { ContactsLayout } from '@/features/contacts/ContactsLayout';
|
||||
import { EmptyContactsMain } from '@/features/contacts/EmptyContactsMain';
|
||||
import { FriendProfileView } from '@/features/contacts/FriendProfileView';
|
||||
import { GroupListView } from '@/features/contacts/GroupListView';
|
||||
import { NewFriendsView } from '@/features/contacts/NewFriendsView';
|
||||
import { FavoritesLayout } from '@/features/favorites/FavoritesLayout';
|
||||
import { ConversationView } from '@/features/messages/ConversationView';
|
||||
import { EmptyMain } from '@/features/messages/EmptyMain';
|
||||
import { MessagesLayout } from '@/features/messages/MessagesLayout';
|
||||
import { AboutPage } from '@/features/settings/AboutPage';
|
||||
import { GeneralSettingsPage } from '@/features/settings/GeneralSettingsPage';
|
||||
import { NotificationsSettingsPage } from '@/features/settings/NotificationsSettingsPage';
|
||||
import { PrivacySettingsPage } from '@/features/settings/PrivacySettingsPage';
|
||||
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
|
||||
import { SecuritySettingsPage } from '@/features/settings/SecuritySettingsPage';
|
||||
import { SettingsHome } from '@/features/settings/SettingsHome';
|
||||
import { SettingsLayout } from '@/features/settings/SettingsLayout';
|
||||
import { SystemMessagesView } from '@/features/system/SystemMessagesView';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -35,15 +51,42 @@ const router = createBrowserRouter([
|
||||
element: <MessagesLayout />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyMain /> },
|
||||
{ path: 'system', element: <SystemMessagesView /> },
|
||||
{ path: ':topicId', element: <ConversationView /> },
|
||||
],
|
||||
},
|
||||
{ path: 'contacts', element: <SectionPlaceholder title="通讯录" /> },
|
||||
{ path: 'favorites', element: <SectionPlaceholder title="我的收藏" /> },
|
||||
{ path: 'files', element: <SectionPlaceholder title="文件助手" /> },
|
||||
{ path: 'chat/system', element: <SectionPlaceholder title="系统消息" /> },
|
||||
{ path: 'calls', element: <SectionPlaceholder title="通话记录" /> },
|
||||
{ path: 'settings', element: <SectionPlaceholder title="设置" /> },
|
||||
{
|
||||
path: 'contacts',
|
||||
element: <ContactsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyContactsMain /> },
|
||||
{ path: 'requests', element: <NewFriendsView /> },
|
||||
{ path: 'groups', element: <GroupListView /> },
|
||||
{ path: 'blocklist', element: <BlocklistView /> },
|
||||
{ path: ':userId', element: <FriendProfileView /> },
|
||||
],
|
||||
},
|
||||
{ path: 'favorites', element: <FavoritesLayout /> },
|
||||
// README §9.2's dedicated file-assistant browser (categories, storage quota, upload
|
||||
// progress) has NO backing REST API at all (confirmed via a research pass -- the design
|
||||
// spec's own state-management notes admit reuse of existing APIs, i.e. reading the `slf`
|
||||
// topic's messages, same as any other conversation) -- redirecting into the real
|
||||
// conversation on that topic is honest; a fake quota card with invented numbers isn't.
|
||||
{ path: 'files', element: <Navigate to="/messages/slf" replace /> },
|
||||
{ path: 'calls', element: <CallHistoryView /> },
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <SettingsHome /> },
|
||||
{ path: 'profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'security', element: <SecuritySettingsPage /> },
|
||||
{ path: 'notifications', element: <NotificationsSettingsPage /> },
|
||||
{ path: 'privacy', element: <PrivacySettingsPage /> },
|
||||
{ path: 'general', element: <GeneralSettingsPage /> },
|
||||
{ path: 'about', element: <AboutPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { CallRoot } from '@/features/calls/CallRoot';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { initCallSignaling } from '@/stores/calls';
|
||||
|
||||
import { BottomTabBar } from './BottomTabBar';
|
||||
import { NavRail } from './NavRail';
|
||||
@@ -15,6 +19,11 @@ import { NavRail } from './NavRail';
|
||||
export function AppShell() {
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
// Once per app session, regardless of which page is open -- an incoming call must surface even
|
||||
// while browsing contacts/settings/etc, not only while a conversation happens to be open.
|
||||
useEffect(() => {
|
||||
initCallSignaling();
|
||||
}, []);
|
||||
// "sys"/"slf" have their own authoritative unread source in entry_badges (bootstrap.ts) -- excluded
|
||||
// here in case they ever also show up as regular Tinode subscriptions in `contacts`, which would
|
||||
// otherwise double-count the same unread messages into this badge.
|
||||
@@ -36,6 +45,7 @@ export function AppShell() {
|
||||
<BottomTabBar unreadMessages={unreadMessages} />
|
||||
</div>
|
||||
</div>
|
||||
<CallRoot />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||
|
||||
interface AvatarProps {
|
||||
/** Stable id (user_id/topic) used ONLY for the deterministic fallback color -- never the display name. */
|
||||
id: string;
|
||||
name: string | undefined;
|
||||
avatarFileId?: string;
|
||||
/** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's
|
||||
* own color/centering/weight are applied internally, not expected from the caller. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real uploaded avatar (`avatar_file_id`) was, until now, stored and successfully round-tripped
|
||||
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
|
||||
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
|
||||
* avatar produced zero visible change anywhere, including on the very page the upload happened.
|
||||
* `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
|
||||
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
|
||||
* used for chat attachments) expects.
|
||||
*/
|
||||
export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) {
|
||||
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
|
||||
if (remote.url) {
|
||||
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<span className={`grid place-items-center font-medium text-white ${avatarBgClass(id)} ${className}`}>{avatarLetter(name)}</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). */
|
||||
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html).
|
||||
* Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g.
|
||||
* "arrow_back_ios_new"), never something a screen reader should announce. An icon-only
|
||||
* interactive element (button/link with no visible text) must set its own `aria-label`. */
|
||||
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<i className={`material-icons select-none ${className}`} style={style}>
|
||||
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
|
||||
{name}
|
||||
</i>
|
||||
);
|
||||
|
||||
+30
-8
@@ -1,4 +1,4 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
@@ -21,16 +21,38 @@ const BOTTOM_ITEMS: NavRailItem[] = [
|
||||
{ to: '/settings', icon: 'settings', label: '设置' },
|
||||
];
|
||||
|
||||
/** `/files` is `<Navigate to="/messages/slf" replace />` (App.tsx) -- the real URL right after
|
||||
* clicking "文件助手" is `/messages/slf`, which a plain (non-`end`) NavLink match makes "消息"
|
||||
* light up for (a same-topicId prefix match) while "文件助手" itself (`to: '/files'`, a path that
|
||||
* no longer exists once redirected) never matches at all. Overriding both items' active state off
|
||||
* the real pathname instead of trusting NavLink's own prefix match fixes both sides of the mix-up. */
|
||||
function isNavItemActive(to: string, pathname: string): boolean {
|
||||
if (to === '/files') return pathname === '/messages/slf';
|
||||
if (to === '/messages') return pathname.startsWith('/messages') && pathname !== '/messages/slf';
|
||||
return pathname === to || pathname.startsWith(`${to}/`);
|
||||
}
|
||||
|
||||
function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
const location = useLocation();
|
||||
const isActive = isNavItemActive(item.to, location.pathname);
|
||||
return (
|
||||
<NavLink
|
||||
// Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match
|
||||
// logic unconditionally (confirmed by reading react-router's source: passing an `aria-current`
|
||||
// prop only changes the STRING used, not whether it's applied at all, which stays gated on
|
||||
// NavLink's internal isActive). That's exactly `/files`'s own problem above -- NavLink's naive
|
||||
// match would light up "消息" for `/messages/slf`, not "文件助手" -- so a second, independent
|
||||
// aria-current computation fighting the same override this file already needed for `className`
|
||||
// is a real, verifiable a11y bug (screen readers and sighted highlighting would disagree on
|
||||
// which icon is current). Driving both off the one already-correct `isActive` avoids that
|
||||
// entirely.
|
||||
<Link
|
||||
to={item.to}
|
||||
title={item.label}
|
||||
className={({ isActive }) =>
|
||||
`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
|
||||
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
|
||||
}`
|
||||
}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
|
||||
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="!text-2xl" />
|
||||
{item.badge ? (
|
||||
@@ -38,7 +60,7 @@ function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
{item.badge}
|
||||
</b>
|
||||
) : null}
|
||||
</NavLink>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { restoreSession } from '@/lib/tinode/connection';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
import { subscribeMeTopic } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
@@ -27,10 +28,12 @@ export function RequireAuth() {
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const clearTenant = useSessionStore((s) => s.clearTenant);
|
||||
const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
|
||||
const [restoreFailed, setRestoreFailed] = useState(false);
|
||||
const [restoreTimedOut, setRestoreTimedOut] = useState(false);
|
||||
const [contactsError, setContactsError] = useState('');
|
||||
const meSubscribedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (myUserId || !tenant || !hasToken) return undefined;
|
||||
@@ -44,11 +47,16 @@ export function RequireAuth() {
|
||||
.then((uid) => {
|
||||
if (cancelled) return;
|
||||
setMyUserId(uid);
|
||||
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
// docs/IM-前后端对接文档.md: a stale token from a DIFFERENT tenant than the one currently
|
||||
// resolved fails with this specific reason, whose prescribed recovery is "清 Token 并回
|
||||
// 企业码页" -- clearing the tenant here lets the fallback `<Navigate to="/login"/>` below
|
||||
// do that for free, since LoginPage's own `if (!tenant)` guard bounces straight to
|
||||
// `/tenant`, rather than adding a second, parallel navigation path.
|
||||
if (isTenantMismatch(err)) clearTenant();
|
||||
setRestoreFailed(true);
|
||||
}
|
||||
})
|
||||
@@ -59,7 +67,19 @@ export function RequireAuth() {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [tenant, myUserId, hasToken, setMyUserId]);
|
||||
}, [tenant, myUserId, hasToken, setMyUserId, clearTenant]);
|
||||
|
||||
// Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless
|
||||
// of HOW it became known (session restore above, or a fresh login/register that already called
|
||||
// setMyUserId before this component ever mounted) -- LoginPage/RegisterPage used to each fire
|
||||
// their own subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly
|
||||
// logged-in user with an empty conversation list and no error on a transient failure. Centralizing
|
||||
// here means it happens exactly once per authenticated mount and surfaces the same Toast either way.
|
||||
useEffect(() => {
|
||||
if (!myUserId || meSubscribedRef.current) return;
|
||||
meSubscribedRef.current = true;
|
||||
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
|
||||
}, [myUserId]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
if (myUserId) {
|
||||
@@ -70,10 +90,22 @@ export function RequireAuth() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (hasToken && !restoreFailed && !restoreTimedOut) {
|
||||
// `hasToken` is a mount-time snapshot, not live state -- re-checking sessionStorage directly here
|
||||
// (rather than trusting that stale flag alone) catches the token having been cleared elsewhere in
|
||||
// the meantime (connection.ts's reconnect-triggered restoreSession failure, or client.ts's global
|
||||
// 401 handler both clear it without this component's own effect ever running). Without this,
|
||||
// `hasToken` would still read true and the branch below would show "正在恢复登录状态…" forever --
|
||||
// there's no token left to restore and nothing would ever resolve that screen.
|
||||
if (hasToken && !restoreFailed && sessionStorage.getItem('im-session-token')) {
|
||||
// restoreTimedOut only switches the MESSAGE, it must never force a navigate here -- doing so
|
||||
// would unmount this component, which sets the effect's `cancelled` flag and makes the still-
|
||||
// in-flight restoreSession() promise discard its own result via `if (cancelled) return`. A
|
||||
// merely-slow connection (10s+ round trip on cellular/VPN, not necessarily a dead host) would
|
||||
// otherwise get bounced to a forced re-login even when the restore was about to succeed a moment
|
||||
// later. Only an actual rejection (restoreFailed) or the user's own click below should leave.
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
|
||||
正在恢复登录状态…
|
||||
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
|
||||
<a href="/login" className="text-accent no-underline">
|
||||
改为重新登录
|
||||
</a>
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export function SectionPlaceholder({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
{title}(后续阶段实现)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,12 @@ export function Toast({ message, variant = 'success', onDone }: ToastProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-8 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-3 text-sm text-white"
|
||||
// `position:fixed` resolves against the viewport, not AppShell's own Outlet box -- below 640px
|
||||
// that viewport bottom is where the persistent BottomTabBar (82px tall) already sits, so the
|
||||
// unconditional `bottom-12` this used to be sat squarely on top of it on every phone-width
|
||||
// screen, for every Toast call site in the app. `sm:bottom-12` restores the original 48px gap
|
||||
// once the tab bar is gone (>=640px, NavRail shown instead).
|
||||
className="fixed bottom-[130px] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-2.5 text-sm text-white sm:bottom-12"
|
||||
>
|
||||
<Icon name={variant === 'error' ? 'error' : 'check_circle'} className={`text-[18px] ${variant === 'error' ? 'text-danger-text' : 'text-online'}`} />
|
||||
{message}
|
||||
|
||||
@@ -84,13 +84,16 @@ export function EnterpriseRegisterPage() {
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm text-text-body">统一社会信用代码</label>
|
||||
<input
|
||||
value={creditCode}
|
||||
onChange={(e) => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
|
||||
placeholder="请输入18位统一社会信用代码"
|
||||
maxLength={18}
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
|
||||
/>
|
||||
<div className="flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||
<input
|
||||
value={creditCode}
|
||||
onChange={(e) => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
|
||||
placeholder="请输入18位统一社会信用代码"
|
||||
maxLength={18}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
<Icon name="document_scanner" className="flex-none text-[20px] text-focus-border" />
|
||||
</div>
|
||||
<p className="m-0 text-xs text-text-tertiary-3">请填写营业执照上的统一社会信用代码</p>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +108,7 @@ export function EnterpriseRegisterPage() {
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Icon name="add_photo_alternate" className="text-[22px] text-text-tertiary-3" />
|
||||
<Icon name="upload_file" className="text-[22px] text-accent" />
|
||||
<span className="text-sm text-text-body">{file ? file.name : '点击上传营业执照'}</span>
|
||||
<span className="text-xs text-text-tertiary-3">支持 JPG、PNG、WebP、PDF,不超过 10MB</span>
|
||||
</label>
|
||||
|
||||
@@ -6,9 +6,8 @@ import { Icon } from '@/app/Icon';
|
||||
import { Agreement } from '@/features/auth/components/Agreement';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { BASE_APP_NAME } from '@/lib/config';
|
||||
import { connect, getTinode } from '@/lib/tinode/connection';
|
||||
import { getTinode, loginBasic } from '@/lib/tinode/connection';
|
||||
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { subscribeMeTopic } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export function LoginPage() {
|
||||
@@ -43,10 +42,7 @@ export function LoginPage() {
|
||||
setError('');
|
||||
try {
|
||||
const tinode = getTinode();
|
||||
if (!tinode.isConnected()) {
|
||||
await connect();
|
||||
}
|
||||
const ctrl = await tinode.loginBasic(login.trim(), password);
|
||||
const ctrl = await loginBasic(login.trim(), password);
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||
return;
|
||||
@@ -54,7 +50,6 @@ export function LoginPage() {
|
||||
const authToken = tinode.getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId(tinode.getCurrentUserID() || null);
|
||||
subscribeMeTopic().catch(() => {});
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setError(loginErrorMessage(err));
|
||||
@@ -132,10 +127,9 @@ export function LoginPage() {
|
||||
马上注册
|
||||
</Link>
|
||||
</p>
|
||||
<Agreement checked={agreed} onChange={setAgreed} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<Agreement checked={agreed} onChange={setAgreed} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,21 @@ import { verifyRegisterCode } from '@/lib/api/auth';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getTinode, registerAccount } from '@/lib/tinode/connection';
|
||||
import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { subscribeMeTopic } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||||
|
||||
/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a
|
||||
* 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently
|
||||
* drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */
|
||||
function normalizePhoneInput(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits;
|
||||
return withoutCountryCode.slice(0, 11);
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
username?: string;
|
||||
phone?: string;
|
||||
@@ -52,6 +60,10 @@ export function RegisterPage() {
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
|
||||
const usingPhone = !!phone;
|
||||
// `tenants/resolve`'s own real per-tenant password_policy (docs/IM-前后端对接文档.md:534) -- read
|
||||
// off the tenant instead of a hardcoded guess, so this can never drift from ResetPasswordPage's
|
||||
// own minimum (they'd silently diverged to two different hardcoded numbers before this existed).
|
||||
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -65,7 +77,7 @@ export function RegisterPage() {
|
||||
} else if (!INVITE_RE.test(invite)) {
|
||||
next.invite = '用户名注册需填写 5 位字母邀请码';
|
||||
}
|
||||
if (password.length < 8) next.password = '密码至少 8 位';
|
||||
if (password.length < minPasswordLength) next.password = `密码至少 ${minPasswordLength} 位`;
|
||||
setErrors(next);
|
||||
setFormError('');
|
||||
if (Object.keys(next).length || !agreed || submitting) return;
|
||||
@@ -98,7 +110,6 @@ export function RegisterPage() {
|
||||
const authToken = getTinode().getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId((ctrl.params?.user as string | undefined) ?? null);
|
||||
subscribeMeTopic().catch(() => {});
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setFormError(registerErrorMessage(err));
|
||||
@@ -128,7 +139,7 @@ export function RegisterPage() {
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10))}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))}
|
||||
placeholder="用户名(1-10位小写字母或数字)"
|
||||
autoComplete="username"
|
||||
className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring ${
|
||||
@@ -153,9 +164,11 @@ export function RegisterPage() {
|
||||
<span className="text-[15px] text-text-tertiary-2">+86</span>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
|
||||
placeholder="请输入手机号"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
@@ -163,12 +176,13 @@ export function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="短信验证码"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${
|
||||
errors.code ? 'border border-danger-text' : ''
|
||||
}`}
|
||||
@@ -183,7 +197,14 @@ export function RegisterPage() {
|
||||
{errors.code ? <div className="text-xs text-danger-border">{errors.code}</div> : null}
|
||||
</div>
|
||||
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="设置密码" autoComplete="new-password" error={errors.password} />
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="设置密码"
|
||||
autoComplete="new-password"
|
||||
error={errors.password}
|
||||
maxLength={tenant.passwordPolicy?.maxLength}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */}
|
||||
|
||||
@@ -12,6 +12,15 @@ import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
|
||||
/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a
|
||||
* 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently
|
||||
* drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */
|
||||
function normalizePhoneInput(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits;
|
||||
return withoutCountryCode.slice(0, 11);
|
||||
}
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
@@ -22,6 +31,10 @@ export function ResetPasswordPage() {
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
// README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE
|
||||
// field error under the password input, not just the shared top banner every other validation
|
||||
// failure here uses -- this mirrors that without touching the other (phone/code) failure paths.
|
||||
const [passwordFieldError, setPasswordFieldError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
@@ -34,15 +47,28 @@ export function ResetPasswordPage() {
|
||||
}, [phone]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
// Same real per-tenant policy RegisterPage reads (docs/IM-前后端对接文档.md:534) -- was hardcoded
|
||||
// to 8 here, matching Register's OWN hardcode by coincidence, not because either ever read the
|
||||
// real value; SecuritySettingsPage's in-place change had drifted to a DIFFERENT hardcoded 6.
|
||||
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordFieldError('');
|
||||
setError('');
|
||||
if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
|
||||
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码');
|
||||
// Matches Register's rule (>= 8, no upper bound -- the backend's checkPasswordPolicy only
|
||||
// enforces a minimum length, so the message says exactly that and nothing more).
|
||||
if (password.length < 8) return setError('密码至少 8 位');
|
||||
if (password !== confirm) return setError('两次输入的密码不一致');
|
||||
// Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy
|
||||
// only enforces a minimum length, so the message says exactly that and nothing more). Inline-only
|
||||
// (no setError) -- these two are the password fields' OWN validation, already shown right under
|
||||
// the relevant input; also raising the shared top banner with the identical text duplicated the
|
||||
// same message on screen twice for no reason phone/code's genuinely-separate failures don't share.
|
||||
if (password.length < minPasswordLength) {
|
||||
return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`);
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return setPasswordFieldError('两次输入的密码不一致');
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
@@ -58,12 +84,20 @@ export function ResetPasswordPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col bg-surface-page px-6 py-8">
|
||||
<Link to="/login" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
||||
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
|
||||
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link
|
||||
scaled down -- same pattern as RegisterPage's own min-[900px]:hidden header. */}
|
||||
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
|
||||
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
</Link>
|
||||
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">重置密码</strong>
|
||||
</div>
|
||||
<Link to="/login" className="hidden items-center gap-1 px-6 pt-8 text-sm text-text-secondary no-underline min-[900px]:flex">
|
||||
<Icon name="arrow_back_ios_new" className="text-[16px]" />
|
||||
返回登录
|
||||
</Link>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-8">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="m-0 text-[22px] font-bold text-text-title">重置密码</h1>
|
||||
@@ -82,14 +116,16 @@ export function ResetPasswordPage() {
|
||||
<span className="text-[15px] text-text-tertiary-2">+86</span>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
|
||||
placeholder="请输入手机号"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
@@ -100,8 +136,28 @@ export function ResetPasswordPage() {
|
||||
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
|
||||
</div>
|
||||
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="新密码" autoComplete="new-password" />
|
||||
<PasswordInput value={confirm} onChange={setConfirm} placeholder="再次输入新密码" autoComplete="new-password" />
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(v) => {
|
||||
setPassword(v);
|
||||
setPasswordFieldError('');
|
||||
}}
|
||||
placeholder="新密码"
|
||||
autoComplete="new-password"
|
||||
error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined}
|
||||
maxLength={tenant.passwordPolicy?.maxLength}
|
||||
/>
|
||||
<PasswordInput
|
||||
value={confirm}
|
||||
onChange={(v) => {
|
||||
setConfirm(v);
|
||||
setPasswordFieldError('');
|
||||
}}
|
||||
placeholder="再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined}
|
||||
maxLength={tenant.passwordPolicy?.maxLength}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
@@ -10,7 +11,9 @@ import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
|
||||
export function TenantEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setTenant = useSessionStore((s) => s.setTenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
@@ -32,7 +35,26 @@ export function TenantEntryPage() {
|
||||
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
|
||||
// createTinode() only ever reads useSessionStore's tenant at that one construction moment.
|
||||
resetTinode();
|
||||
setTenant({ code: res.tenant.code, name: res.tenant.name, endpoints: res.endpoints, host, secure });
|
||||
// The confirm() dialog above literally promises "切换企业将退出当前登录" -- without these,
|
||||
// that was a false promise: `myUserId` is a separate module-level store that switching
|
||||
// tenant never touched, so RequireAuth's `if (myUserId) return <Outlet/>` would let a
|
||||
// subsequent in-app navigation straight through with the OLD tenant's uid and the OLD
|
||||
// tenant's sessionStorage token, against a freshly-reset Tinode connection that has never
|
||||
// authenticated with the NEW tenant at all. Same teardown SettingsLayout's own logout and
|
||||
// SecuritySettingsPage's account deletion already do.
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
queryClient.clear();
|
||||
setTenant({
|
||||
code: res.tenant.code,
|
||||
name: res.tenant.name,
|
||||
endpoints: res.endpoints,
|
||||
host,
|
||||
secure,
|
||||
passwordPolicy: res.password_policy
|
||||
? { minLength: res.password_policy.min_length ?? 8, maxLength: res.password_policy.max_length ?? 64 }
|
||||
: undefined,
|
||||
});
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
|
||||
|
||||
@@ -8,9 +8,10 @@ interface PasswordInputProps {
|
||||
placeholder: string;
|
||||
autoComplete?: string;
|
||||
error?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export function PasswordInput({ value, onChange, placeholder, autoComplete, error }: PasswordInputProps) {
|
||||
export function PasswordInput({ value, onChange, placeholder, autoComplete, error, maxLength }: PasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -26,6 +27,7 @@ export function PasswordInput({ value, onChange, placeholder, autoComplete, erro
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -68,7 +68,7 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
|
||||
type="button"
|
||||
onClick={click}
|
||||
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
|
||||
className="w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
className="h-9 w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
>
|
||||
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads
|
||||
* as "未接听" from the callee's own history and "已取消" from the caller's. */
|
||||
function statusLabel(item: CallHistoryItem): string {
|
||||
const result = item.result || item.status;
|
||||
const incoming = item.direction === 'incoming';
|
||||
if (result === 'finished') return formatDuration(item.duration_ms);
|
||||
if (result === 'missed') return incoming ? '未接听' : '已取消';
|
||||
if (result === 'declined') return '已拒绝';
|
||||
if (result === 'disconnected') return '异常结束';
|
||||
return result;
|
||||
}
|
||||
|
||||
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
|
||||
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
|
||||
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
|
||||
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike
|
||||
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
|
||||
* a display name via the chat list, same lookup ChatListColumn itself uses.
|
||||
*/
|
||||
export function CallHistoryView() {
|
||||
const navigate = useNavigate();
|
||||
const contacts = useChatList();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['call-history'],
|
||||
queryFn: ({ pageParam }) => getCallHistory(pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
<header className="flex h-15 flex-none items-center border-b border-border-panel bg-white px-6">
|
||||
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
|
||||
</header>
|
||||
<div className="flex flex-col gap-2 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const result = item.result || item.status;
|
||||
const incoming = item.direction === 'incoming';
|
||||
// Mock only reds out a genuine "未接听" (a call that rang and was never answered) --
|
||||
// "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not
|
||||
// something the viewer missed and should feel warned about. This used to redden EVERY
|
||||
// non-'finished' result alike.
|
||||
const isMissed = result === 'missed' && incoming;
|
||||
const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id;
|
||||
const ts = item.started_at || item.created_at;
|
||||
return (
|
||||
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/messages/${item.peer_user_id}`)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(item.peer_user_id)}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
|
||||
<span className={`flex items-center gap-1 truncate text-xs ${isMissed ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
|
||||
<Icon name={incoming ? 'call_received' : 'call_made'} className="text-sm" />
|
||||
{statusLabel(item)}
|
||||
{ts ? ` · ${formatEventTimestamp(new Date(ts))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(item.peer_user_id, name, item.call_type);
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent"
|
||||
>
|
||||
<Icon name={item.call_type === 'video' ? 'videocam' : 'call'} className="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{query.hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
className="py-2 text-center text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatCallDuration } from '@/lib/messages/dispatch';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
function useElapsedSeconds(connectedAt: number | null): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!connectedAt) {
|
||||
setElapsed(0);
|
||||
return undefined;
|
||||
}
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000));
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [connectedAt]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function CircleButton({
|
||||
icon,
|
||||
active,
|
||||
danger,
|
||||
label,
|
||||
onClick,
|
||||
size = 'h-13 w-13',
|
||||
iconSize = 'text-2xl',
|
||||
}: {
|
||||
icon: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
size?: string;
|
||||
iconSize?: string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} aria-label={label} className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={`grid ${size} place-items-center rounded-full ${iconSize} text-white ${
|
||||
danger ? 'bg-[#f34b46]' : active ? 'bg-white !text-[#22262d]' : 'bg-white/[0.16]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
<span className="text-xs text-white">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone-
|
||||
* width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with
|
||||
* no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a
|
||||
* 16px gutter on each side below the point where the fixed width alone would fit. */
|
||||
function IncomingCallCard() {
|
||||
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-[60] w-[360px] max-w-[calc(100vw-32px)] rounded-[14px] bg-white p-5 shadow-[0_16px_48px_rgba(0,0,0,0.2)] sm:right-6 sm:top-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-[15px] font-semibold text-text-heading">{peerName}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">邀请你{callType === 'video' ? '视频' : '语音'}通话</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={rejectCall} aria-label="拒绝" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#f34b46] text-xl text-white">
|
||||
<Icon name="call_end" />
|
||||
</button>
|
||||
<button type="button" onClick={acceptCall} aria-label="接听" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#1fb36a] text-xl text-white">
|
||||
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout
|
||||
* serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small
|
||||
* top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two
|
||||
* per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard`
|
||||
* because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */
|
||||
function MobileIncomingCall() {
|
||||
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
|
||||
<span
|
||||
className={`mt-32 grid h-[120px] w-[120px] place-items-center rounded-[28px] text-[46px] font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
style={{ boxShadow: '0 0 0 14px rgba(43,182,115,0.18), 0 0 0 32px rgba(43,182,115,0.08)' }}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-8 text-2xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-2 text-[15px] text-white/70">邀请你{callType === 'video' ? '视频' : '语音'}通话…</span>
|
||||
<div className="mb-16 mt-auto flex gap-24">
|
||||
<button type="button" onClick={rejectCall} aria-label="拒绝" className="flex flex-col items-center gap-2.5 text-[13px]">
|
||||
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#f34b46] text-[32px]">
|
||||
<Icon name="call_end" />
|
||||
</span>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" onClick={acceptCall} aria-label="接听" className="flex flex-col items-center gap-2.5 text-[13px]">
|
||||
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#1fb36a] text-[32px]">
|
||||
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
|
||||
</span>
|
||||
接听
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) --
|
||||
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
||||
* unmounted between full-screen and minimized). */
|
||||
function MinimizedCallBar() {
|
||||
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
return (
|
||||
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
||||
// and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of
|
||||
// AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width,
|
||||
// there's no `sm:hidden` gate on this component to make the overlap moot).
|
||||
<div className="fixed bottom-[106px] right-6 z-[60] flex w-[280px] items-center gap-3 rounded-xl bg-[#1b2231] px-4 py-3 text-white sm:bottom-6">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="展开通话窗口" className="flex flex-1 items-center gap-3 text-left">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
||||
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
|
||||
<Icon name={muted ? 'mic_off' : 'mic'} className="text-base" />
|
||||
</button>
|
||||
<button type="button" onClick={hangUp} aria-label="挂断" className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f34b46]">
|
||||
<Icon name="call_end" className="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the
|
||||
* design calls for one) -- the old app's own real CallPanel is also just an overlay, never a
|
||||
* separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a
|
||||
* cross-window signaling bus this rewrite has no other need for. */
|
||||
function CallWindow() {
|
||||
const {
|
||||
topic,
|
||||
callType,
|
||||
phase,
|
||||
peerName,
|
||||
connectedAt,
|
||||
muted,
|
||||
cameraOff,
|
||||
hasRemoteVideo,
|
||||
hasLocalVideo,
|
||||
minimized,
|
||||
mediaError,
|
||||
toggleMinimize,
|
||||
toggleMute,
|
||||
toggleCamera,
|
||||
hangUp,
|
||||
} = useCallStore();
|
||||
const remoteRef = useRef<HTMLDivElement>(null);
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteRef.current) playRemoteVideo(remoteRef.current);
|
||||
}, [hasRemoteVideo]);
|
||||
useEffect(() => {
|
||||
if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current);
|
||||
}, [hasLocalVideo, cameraOff]);
|
||||
|
||||
if (minimized) return <MinimizedCallBar />;
|
||||
|
||||
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
|
||||
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
|
||||
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
|
||||
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
|
||||
const hangUpLabel = phase === 'connected' ? '挂断' : '取消';
|
||||
|
||||
if (callType === 'audio') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-between bg-gradient-to-b from-[#1f2a3d] to-[#111826] py-16 text-white">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="absolute left-5 top-5 text-white/70">
|
||||
<Icon name="expand_more" className="text-2xl" />
|
||||
</button>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span
|
||||
// Mock `06-calls.dc.html #1b`: `border-radius:24px` (a squircle), not a perfect circle --
|
||||
// the mobile equivalent of this same avatar (MobileIncomingCall) already gets this right.
|
||||
className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
style={{ boxShadow: '0 0 0 12px rgba(43,182,115,0.18), 0 0 0 28px rgba(43,182,115,0.08)' }}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="text-center text-xl font-semibold">{peerName}</strong>
|
||||
<span className="text-sm text-white/60">{statusLabel}</span>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
|
||||
</div>
|
||||
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] text-white"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, #3b4a63, #1b2231)' }}
|
||||
>
|
||||
{/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10`
|
||||
(here and on every other overlay piece below) is deliberate, not decorative -- without it,
|
||||
this and the real `remoteRef` video div right below are BOTH `position:absolute` with no
|
||||
z-index, so plain DOM order would decide stacking and the video (which comes second) would
|
||||
paint over this header the moment real remote video is actually playing. That case never
|
||||
got exercised live (this sandbox has no real camera, see the calls verification note in the
|
||||
plan file), so it's a latent bug worth closing here rather than leaving for whoever first
|
||||
tests a real 2-camera call to rediscover. */}
|
||||
<div className="absolute inset-x-0 top-0 z-10 hidden items-center justify-between bg-gradient-to-b from-black/40 to-transparent px-6 py-4 md:flex">
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold">{peerName}</strong>
|
||||
<span className="flex items-center gap-1 text-xs text-white/70">
|
||||
{phase === 'connected' ? (
|
||||
<>
|
||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||
</>
|
||||
) : (
|
||||
'正在等待对方接听…'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-white/80">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化">
|
||||
<Icon name="picture_in_picture_alt" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text --
|
||||
those move into the centered overlay below (no remote video yet) or the small pill just
|
||||
under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom
|
||||
control grid, matching the mock exactly. */}
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 py-1.5 md:hidden">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="grid h-10 w-10 place-items-center">
|
||||
<Icon name="picture_in_picture_alt" className="text-xl" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void useCallStore.getState().switchCamera()}
|
||||
aria-label="翻转摄像头"
|
||||
className="grid h-10 w-10 place-items-center"
|
||||
>
|
||||
<Icon name="flip_camera_ios" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{hasRemoteVideo ? (
|
||||
<div className="absolute left-1/2 top-16 z-10 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/40 px-3 py-1 text-xs md:hidden">
|
||||
{peerName}
|
||||
{phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{hasRemoteVideo ? (
|
||||
<div ref={remoteRef} className="h-full w-full [&>video]:h-full [&>video]:w-full [&>video]:object-cover" />
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */}
|
||||
<div className="hidden h-full w-full place-items-center md:grid">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */}
|
||||
<span
|
||||
className={`grid h-[120px] w-[120px] place-items-center rounded-[28px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
||||
has no header info bar to carry the name instead. */}
|
||||
<div className="flex h-full w-full flex-col items-center pt-[150px] md:hidden">
|
||||
<span
|
||||
className={`grid h-[110px] w-[110px] place-items-center rounded-[26px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-1 text-sm text-white/70">
|
||||
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
|
||||
</span>
|
||||
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
||||
above); mobile silently dropped it, showing only the elapsed-time line with no
|
||||
indication of WHY there's no video. */}
|
||||
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track
|
||||
stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain
|
||||
responsive classes, restoring the desktop values under `md:`. */}
|
||||
{!cameraOff ? (
|
||||
<div
|
||||
ref={localRef}
|
||||
className="absolute right-4 top-[110px] z-10 h-40 w-27 overflow-hidden rounded-[14px] border-2 border-white/30 bg-black/40 [&>video]:h-full [&>video]:w-full [&>video]:object-cover md:right-5 md:top-[72px] md:h-[132px] md:w-[200px] md:rounded-xl"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */}
|
||||
<div className="absolute inset-x-0 bottom-8 z-10 hidden justify-center gap-6 md:flex">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
|
||||
<CircleButton icon={cameraOff ? 'videocam_off' : 'videocam'} active={cameraOff} label="摄像头" onClick={toggleCamera} />
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
|
||||
<CircleButton icon="screen_share" label="共享屏幕" onClick={() => {}} />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and
|
||||
bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */}
|
||||
<div className="absolute inset-x-0 bottom-12 z-10 grid grid-cols-3 gap-y-6 px-8 md:hidden">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} size="h-15 w-15" iconSize="text-[26px]" />
|
||||
<CircleButton
|
||||
icon={cameraOff ? 'videocam_off' : 'videocam'}
|
||||
active={cameraOff}
|
||||
label="摄像头"
|
||||
onClick={toggleCamera}
|
||||
size="h-15 w-15"
|
||||
iconSize="text-[26px]"
|
||||
/>
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} size="h-15 w-15" iconSize="text-[26px]" />
|
||||
<span />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} size="h-17 w-17" iconSize="text-[30px]" />
|
||||
<span />
|
||||
</div>
|
||||
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any
|
||||
* call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's
|
||||
* callResultDisplay) so the live moment and the message history agree on how a call is described. */
|
||||
function EndedCallOverlay() {
|
||||
const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore();
|
||||
// Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s
|
||||
// window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each
|
||||
// render would make the displayed value drift depending on when React happens to re-render.
|
||||
const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0));
|
||||
const icon = callType === 'video' ? 'videocam' : 'call';
|
||||
const { label, danger } = (() => {
|
||||
switch (endReason) {
|
||||
case 'finished':
|
||||
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
|
||||
case 'missed':
|
||||
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
|
||||
case 'declined':
|
||||
return { label: '对方已拒绝', danger: true };
|
||||
case 'disconnected':
|
||||
return { label: '通话连接异常,已结束', danger: true };
|
||||
case 'busy':
|
||||
return { label: '对方忙线中', danger: true };
|
||||
default:
|
||||
return { label: '通话已结束', danger: false };
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
|
||||
{/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family --
|
||||
no mock draws this exact result-overlay state, so this follows the nearest one that does). */}
|
||||
<span className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="text-xl font-semibold">{peerName}</strong>
|
||||
<span className={`flex items-center gap-1.5 text-sm ${danger ? 'text-[#f34b46]' : 'text-white/70'}`}>
|
||||
<Icon name={danger ? 'phone_missed' : icon} className="text-base" />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mounted once in AppShell. */
|
||||
export function CallRoot() {
|
||||
const { phase, minimized } = useCallStore();
|
||||
|
||||
if (phase === 'incoming')
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:contents">
|
||||
<IncomingCallCard />
|
||||
</div>
|
||||
<div className="contents md:hidden">
|
||||
<MobileIncomingCall />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (phase === 'outgoing' || phase === 'connected') return minimized ? <MinimizedCallBar /> : <CallWindow />;
|
||||
if (phase === 'ended') return <EndedCallOverlay />;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { QRScannerModal } from '@/features/contacts/QRScannerModal';
|
||||
import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api/friends';
|
||||
import { submitGroupJoinRequest } from '@/lib/api/groups';
|
||||
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 50;
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
interface AddFriendModalProps {
|
||||
onClose: () => void;
|
||||
onSent?: () => void;
|
||||
/** FriendProfileView's "添加好友" button already knows exactly who -- skips straight past the
|
||||
* search step to the verification-message step, rather than making the user re-search someone
|
||||
* whose profile is already open. */
|
||||
initialTarget?: { user_id: string; display_name: string; public_account?: string; avatar_file_id?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.4 (520px). 扫一扫/我的二维码 are real, confirmed-live features now (see
|
||||
* lib/api/qrcodes.ts's header comment for the live-verification writeup) -- only "分享好友" stays
|
||||
* the same "该功能暂未开放" placeholder GroupDetailColumn's own 投诉举报 button already uses, since
|
||||
* sharing a friend's card into a conversation has no backend support anywhere. Search result rows
|
||||
* drive their state from
|
||||
* `relationship`/`can_add` (doc line 1423's confirmed response has no separate `action` field to
|
||||
* read a label off of): already a friend or already pending are shown but not selectable, `can_add:
|
||||
* false` shows a generic "无法添加" per the doc's own "不泄露具体关闭项" privacy principle, and only
|
||||
* a genuinely addable result can be picked before writing the verification message.
|
||||
*/
|
||||
export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchedFor, setSearchedFor] = useState('');
|
||||
const [selected, setSelected] = useState<UserSearchResult | null>(
|
||||
initialTarget ? { ...initialTarget, relationship: 'none', can_add: true } : null,
|
||||
);
|
||||
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
|
||||
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
|
||||
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
|
||||
const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [showMyQrCode, setShowMyQrCode] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// A scanned `purpose:"join_group"` code -- a completely different target shape from `selected`
|
||||
// (a person), so it gets its own state rather than being forced into UserSearchResult's fields.
|
||||
const [groupJoinTarget, setGroupJoinTarget] = useState<(QRResolveGroup & { qrToken: string }) | null>(null);
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const handleScanResolved = (user: QRResolveUser) => {
|
||||
setScanning(false);
|
||||
setSelectedSource('qr');
|
||||
setSelected({ user_id: user.user_id, display_name: user.display_name, public_account: user.public_account, relationship: 'none', can_add: true });
|
||||
};
|
||||
const handleScanAlreadyFriend = (user: QRResolveUser) => {
|
||||
setScanning(false);
|
||||
onClose();
|
||||
navigate(`/contacts/${user.user_id}`);
|
||||
};
|
||||
const handleScanGroupResolved = (group: QRResolveGroup, qrToken: string) => {
|
||||
setScanning(false);
|
||||
setGroupJoinTarget({ ...group, qrToken });
|
||||
};
|
||||
const handleScanAlreadyMember = (group: QRResolveGroup) => {
|
||||
setScanning(false);
|
||||
onClose();
|
||||
navigate(`/messages/${group.topic}`);
|
||||
};
|
||||
|
||||
// docs/IM-前后端对接文档.md:1308: phone-number search only actually happens under `type=phone` --
|
||||
// the default `type=account` never matches a bare phone number, so a search box that always sends
|
||||
// `account` can never satisfy README §5.4's own placeholder implying phone-number search works.
|
||||
const searchType = /^\d{11}$/.test(searchedFor) ? 'phone' : 'account';
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['user-search', searchedFor, searchType],
|
||||
queryFn: () => searchUsers(searchedFor, searchType),
|
||||
enabled: !!searchedFor,
|
||||
});
|
||||
|
||||
const runSearch = () => {
|
||||
setSelected(null);
|
||||
setSearchedFor(query.trim());
|
||||
};
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () => sendFriendRequest(selected?.user_id || '', message.trim() || undefined, selectedSource),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
||||
setSent(true);
|
||||
onSent?.();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发送申请失败'),
|
||||
});
|
||||
|
||||
const groupJoinMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
submitGroupJoinRequest(groupJoinTarget?.topic || '', { message: message.trim() || undefined, source: 'qr', qr_token: groupJoinTarget?.qrToken }),
|
||||
onSuccess: () => setSent(true),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发送入群申请失败'),
|
||||
});
|
||||
|
||||
const rowState = (item: UserSearchResult): { label: string; selectable: boolean } => {
|
||||
if (item.relationship === 'friend') return { label: '已是好友', selectable: false };
|
||||
// docs/IM-前后端对接文档.md:1289 -- the real enum is direction-specific, never a bare "pending"
|
||||
// (comparing against that literal never matched either real value, so a search result with an
|
||||
// outstanding request either direction fell through to the `can_add` check below and could look
|
||||
// fully selectable, risking a duplicate friend-requests submit).
|
||||
if (item.relationship === 'pending_outbound') return { label: '已发送申请', selectable: false };
|
||||
if (item.relationship === 'pending_inbound') return { label: '对方已申请加你', selectable: false };
|
||||
if (!item.can_add) return { label: '无法添加', selectable: false };
|
||||
return { label: '', selectable: true };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[80vh] w-[520px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">添加好友</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-16">
|
||||
<Icon name="check_circle" className="text-[40px] text-success" />
|
||||
<span className="text-sm text-text-body">申请已发送</span>
|
||||
<button type="button" onClick={onClose} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{groupJoinTarget ? (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg bg-accent text-sm font-medium text-white">
|
||||
{groupJoinTarget.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{groupJoinTarget.name}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">{groupJoinTarget.member_count} 人</span>
|
||||
</span>
|
||||
</div>
|
||||
) : initialTarget ? (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
|
||||
<Avatar
|
||||
id={initialTarget.user_id}
|
||||
name={initialTarget.display_name}
|
||||
avatarFileId={initialTarget.avatar_file_id}
|
||||
className="h-10 w-10 flex-none rounded-lg text-sm"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{initialTarget.display_name}</strong>
|
||||
{initialTarget.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {initialTarget.public_account}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 flex-1 items-center rounded-lg border border-[#d5d9df] px-3 focus-within:border-accent focus-within:shadow-[0_0_0_3px_rgba(22,119,255,0.1)]">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runSearch()}
|
||||
placeholder="手机号/账号/昵称"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={runSearch} disabled={!query.trim()} className="flex-none text-sm text-accent disabled:opacity-40">
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) },
|
||||
{ icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) },
|
||||
{ icon: 'share', label: '分享好友', onClick: notImplemented },
|
||||
].map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
|
||||
>
|
||||
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!initialTarget && !groupJoinTarget && searchedFor ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{searchQuery.isLoading ? (
|
||||
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||||
) : !searchQuery.data?.items.length ? (
|
||||
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
|
||||
) : (
|
||||
searchQuery.data.items.map((item) => {
|
||||
const { label, selectable } = rowState(item);
|
||||
const isSelected = selected?.user_id === item.user_id;
|
||||
return (
|
||||
<button
|
||||
key={item.user_id}
|
||||
type="button"
|
||||
disabled={!selectable}
|
||||
onClick={() => {
|
||||
setSelectedSource('search');
|
||||
setSelected(item);
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
|
||||
>
|
||||
<Avatar id={item.user_id} name={item.display_name} avatarFileId={item.avatar_file_id} className="h-10 w-10 flex-none rounded-lg text-sm" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{item.display_name}</strong>
|
||||
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
|
||||
</span>
|
||||
{selectable ? (
|
||||
<span className={`flex-none rounded-full px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
|
||||
{isSelected ? '已选择' : '选择'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">{label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selected || groupJoinTarget ? (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-sm text-text-body">{groupJoinTarget ? '申请留言' : '验证留言'}</div>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
|
||||
rows={3}
|
||||
placeholder={groupJoinTarget ? '申请入群' : `我是${selected?.display_name}的朋友`}
|
||||
className="w-full resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
|
||||
/>
|
||||
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
|
||||
{message.length}/{MAX_MESSAGE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{sent ? null : (
|
||||
<footer className="flex flex-none items-center justify-end gap-2 border-t border-border-list-item px-5 py-3">
|
||||
<button type="button" onClick={onClose} className="h-9 rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (groupJoinTarget ? groupJoinMutation.mutate() : sendMutation.mutate())}
|
||||
disabled={(!selected && !groupJoinTarget) || sendMutation.isPending || groupJoinMutation.isPending}
|
||||
className="h-9 rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{sendMutation.isPending || groupJoinMutation.isPending ? '发送中…' : groupJoinTarget ? '申请加入' : '发送申请'}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Siblings of the backdrop above, not nested inside it -- their own backdrop-click-to-close
|
||||
would otherwise bubble up and close THIS modal too, since only the 520px card (not the
|
||||
backdrop) stops propagation. */}
|
||||
{showMyQrCode ? <QRCodeModal onClose={() => setShowMyQrCode(false)} /> : null}
|
||||
{scanning ? (
|
||||
<QRScannerModal
|
||||
onClose={() => setScanning(false)}
|
||||
onResolved={handleScanResolved}
|
||||
onAlreadyFriend={handleScanAlreadyFriend}
|
||||
onGroupResolved={handleScanGroupResolved}
|
||||
onAlreadyMember={handleScanAlreadyMember}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
/** README §5.6. `getBlocks` nests the user under `.user` (see friends.ts's own comment on that
|
||||
* endpoint's real shape, a doc-drift bug caught during Phase 1-2's review).
|
||||
*
|
||||
* Multi-select + batch-unblock has no dedicated mock anywhere in this design handoff (04-contacts.dc.html's
|
||||
* own #1g only ever shows a single row, one 移出 button each) -- rather than invent a new interaction
|
||||
* pattern from nothing, this reuses the SAME 多选/已选择N项/取消 header convention FavoritesLayout.tsx
|
||||
* already established for its own multi-select, so it's consistent with something this app already does,
|
||||
* not a one-off design.
|
||||
*/
|
||||
export function BlocklistView() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const query = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const unblockMutation = useMutation({
|
||||
mutationFn: (userId: string) => unblockUser(userId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '移出黑名单失败'),
|
||||
});
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const bulkUnblockMutation = useMutation({
|
||||
mutationFn: (userIds: string[]) => batchUnblockUsers(userIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
// A 200 here only means the REQUEST was accepted -- the doc's own worked example returns a
|
||||
// per-item `failed[]` array alongside `items[]`, so some of the selected users can fail to
|
||||
// unblock while the rest succeed. Silently treating this as full success (the same pattern
|
||||
// already fixed for favorites/GroupAdminModal elsewhere in this app) would leave a user still
|
||||
// on the blocklist with no indication anything went wrong.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 人移出失败,请重试`);
|
||||
exitSelectMode();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '批量移出失败'),
|
||||
});
|
||||
|
||||
const toggleSelected = (userId: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const items = query.data || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
{selectMode ? (
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulkUnblockMutation.mutate(Array.from(selected))}
|
||||
disabled={selected.size === 0 || bulkUnblockMutation.isPending}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="remove_circle_outline" className="text-[18px]" />
|
||||
移出
|
||||
</button>
|
||||
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
) : (
|
||||
<header className="flex h-15 flex-none items-center gap-1 border-b border-border-panel bg-white px-6">
|
||||
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="flex-1 text-base font-semibold text-text-heading">
|
||||
黑名单{items.length ? ` ${items.length}` : ''}
|
||||
</strong>
|
||||
{items.length > 1 ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
)}
|
||||
<div className="flex flex-col gap-2.5 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
|
||||
) : (
|
||||
items.map((b) => (
|
||||
<div
|
||||
key={b.user.user_id}
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
onClick={selectMode ? () => toggleSelected(b.user.user_id) : undefined}
|
||||
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
|
||||
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border text-xs text-white ${
|
||||
selected.has(b.user.user_id) ? 'border-accent bg-accent' : 'border-[#d5d9df] bg-white'
|
||||
}`}
|
||||
>
|
||||
{selected.has(b.user.user_id) ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
<Avatar
|
||||
id={b.user.user_id}
|
||||
name={b.user.display_name}
|
||||
avatarFileId={b.user.avatar_file_id}
|
||||
className="h-11 w-11 flex-none rounded-lg text-base grayscale"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{b.user.display_name || b.user.user_id}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
拉黑于 {formatEventTimestamp(new Date(b.blocked_at))} · 对方无法向你发送消息或发起通话
|
||||
</span>
|
||||
</span>
|
||||
{selectMode ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unblockMutation.mutate(b.user.user_id)}
|
||||
disabled={unblockMutation.isPending}
|
||||
className="h-8 flex-none rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{items.length ? (
|
||||
<p className="m-0 mt-2 text-xs text-text-tertiary-3">移出黑名单后恢复通信权限,但不会自动恢复已删除的好友关系。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { ContactsListColumn } from '@/features/contacts/ContactsListColumn';
|
||||
|
||||
/**
|
||||
* README §5.1's 3-column split (NavRail is AppShell's own, rendered above this): list column always
|
||||
* docked (unlike messages, contacts has no detail-panel breakpoint to collapse), main area is
|
||||
* whatever `/contacts/*` sub-route is active. Mirrors MessagesLayout's ChatListColumn+Outlet shape,
|
||||
* minus the detail-panel/history-modal machinery messages needs and contacts doesn't.
|
||||
*
|
||||
* `showListOnNarrow` can't key off a route param the way MessagesLayout keys off `topicId` --
|
||||
* `/contacts/requests|groups|blocklist` are sibling routes with no param at all, not variants of
|
||||
* `:userId`, so this checks the exact index path instead.
|
||||
*/
|
||||
export function ContactsLayout() {
|
||||
const location = useLocation();
|
||||
const showListOnNarrow = location.pathname === '/contacts';
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
||||
<ContactsListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
|
||||
|
||||
const FIXED_ENTRIES = [
|
||||
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
|
||||
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
|
||||
{ key: 'files', route: '/files', icon: 'folder', label: '文件助手', colorClass: 'bg-success-bg text-success' },
|
||||
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
|
||||
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
|
||||
// §5.1's desktop entry list doesn't include it.
|
||||
{ key: 'system', route: '/messages/system', icon: 'notifications', label: '系统消息', colorClass: 'bg-warn-bg text-warn', mobileOnly: true },
|
||||
] as const;
|
||||
|
||||
/** Server-computed `initial` (doc line 1417) sorts CJK names correctly without a client-side pinyin
|
||||
* library; falls back to the display name's own first character for the rare item missing one. */
|
||||
function groupInitial(name: string, initial: string | undefined): string {
|
||||
const raw = (initial || name || '').trim().charAt(0).toUpperCase();
|
||||
return /[A-Z]/.test(raw) ? raw : '#';
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.1 (320px): search + 4 fixed entries (新朋友/群聊/文件助手/黑名单) + letter-grouped
|
||||
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
|
||||
* grouped and searched client-side, same convention ChatListColumn already uses for conversations
|
||||
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
|
||||
*/
|
||||
export function ContactsListColumn() {
|
||||
const { userId } = useParams();
|
||||
const [query, setQuery] = useState('');
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
// Unlike friends/blocks (both already fetchAllPages), this used to read only page 1 -- the request
|
||||
// COUNT BADGE would silently undercount for any account with more than one page of pending requests.
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ['friend-requests', 'inbound', 'pending'],
|
||||
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'pending', cursor)),
|
||||
});
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const friends = friendsQuery.data || [];
|
||||
const filtered = trimmedQuery ? friends.filter((f) => (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmedQuery)) : friends;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byLetter = new Map<string, typeof filtered>();
|
||||
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || '').localeCompare(b.remark || b.display_name || '', 'zh'))) {
|
||||
const letter = groupInitial(f.remark || f.display_name || f.user_id, f.initial);
|
||||
byLetter.set(letter, [...(byLetter.get(letter) || []), f]);
|
||||
}
|
||||
return [...byLetter.entries()].sort(([a], [b]) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b)));
|
||||
}, [filtered]);
|
||||
|
||||
const pendingRequestCount = requestsQuery.data?.length ?? 0;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
<header className="flex h-15 flex-none items-center justify-between px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
|
||||
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-8 w-8 place-items-center text-text-heading">
|
||||
<Icon name="person_add_alt" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索联系人"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{trimmedQuery ? null : (
|
||||
<>
|
||||
{FIXED_ENTRIES.map((entry) => {
|
||||
const badge =
|
||||
entry.key === 'requests'
|
||||
? pendingRequestCount
|
||||
: entry.key === 'blocklist'
|
||||
? (blocksQuery.data?.length ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<Link
|
||||
key={entry.key}
|
||||
to={entry.route}
|
||||
className={`flex h-13 flex-none items-center gap-3 border-b border-border-list-item px-4 no-underline hover:bg-[#f6f8fb] ${
|
||||
'mobileOnly' in entry && entry.mobileOnly ? 'md:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
{/* README §5.7: mobile fixed-entry icons/text are one size up from desktop's
|
||||
36px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
|
||||
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg md:h-9 md:w-9 ${entry.colorClass}`}>
|
||||
<Icon name={entry.icon} className="text-xl" />
|
||||
</span>
|
||||
<strong className="flex-1 truncate text-base font-medium text-[#272b31] md:text-[15px]">{entry.label}</strong>
|
||||
{entry.key === 'requests' && badge ? (
|
||||
<b className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{badge}
|
||||
</b>
|
||||
) : entry.key === 'groups' ? (
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
|
||||
) : entry.key === 'blocklist' && badge ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">{badge}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{friendsQuery.isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
|
||||
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
|
||||
<span className="text-sm">{trimmedQuery ? '未找到相关联系人' : '暂无联系人'}</span>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(([letter, items]) => (
|
||||
<div key={letter} id={`contact-letter-${letter}`}>
|
||||
<div className="bg-[#fafbfc] px-4 pb-1 pt-3.5 text-xs text-text-tertiary-2">{letter}</div>
|
||||
{items.map((f) => {
|
||||
const name = f.remark || f.display_name || f.user_id;
|
||||
const active = userId === f.user_id;
|
||||
return (
|
||||
<Link
|
||||
key={f.user_id}
|
||||
to={`/contacts/${f.user_id}`}
|
||||
className={`flex h-13.5 flex-none items-center gap-3 px-4 no-underline ${active ? 'bg-accent-selected-bg' : 'hover:bg-[#f6f8fb]'}`}
|
||||
>
|
||||
{/* README §5.7: mobile friend-row avatar/text are 44px/16px, one size up from
|
||||
desktop's 40px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
|
||||
<Avatar id={f.user_id} name={name} avatarFileId={f.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base md:h-10 md:w-10 md:text-sm" />
|
||||
<span className="truncate text-base text-[#272b31] md:text-[15px]">{name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{trimmedQuery || groups.length === 0 ? null : (
|
||||
<div className="flex w-4 flex-none flex-col items-center justify-center gap-0.5 py-2">
|
||||
{groups.map(([letter]) => (
|
||||
<a
|
||||
key={letter}
|
||||
href={`#contact-letter-${letter}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(`contact-letter-${letter}`)?.scrollIntoView({ block: 'start' });
|
||||
}}
|
||||
className="text-[10px] leading-[14px] text-text-tertiary-2 hover:font-semibold hover:text-accent"
|
||||
>
|
||||
{letter}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
export function EmptyContactsMain() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
|
||||
<Icon name="person_search" className="!text-5xl" />
|
||||
<span className="text-sm">选择一位联系人查看资料</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getUserProfile, updateFriendRemark } from '@/lib/api/friends';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
|
||||
|
||||
/**
|
||||
* README §5.2 (440px centered card). `getUserProfile` (doc line 1328/1425) has no field describing
|
||||
* HOW two people became friends -- the design's "来源:通过群聊「项目协作群」添加" row has nothing
|
||||
* to read it off of, so it's left out rather than fabricated; every other row maps to a real field.
|
||||
*/
|
||||
export function FriendProfileView() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingRemark, setEditingRemark] = useState(false);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
const [showQrCode, setShowQrCode] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['user-profile', userId],
|
||||
queryFn: () => getUserProfile(userId || ''),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const remarkMutation = useMutation({
|
||||
mutationFn: (remark: string) => updateFriendRemark(userId || '', remark),
|
||||
onSuccess: (_data, remark) => {
|
||||
queryClient.setQueryData(['user-profile', userId], (prev: typeof query.data) => (prev ? { ...prev, remark } : prev));
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
setEditingRemark(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '备注保存失败'),
|
||||
});
|
||||
|
||||
// `/contacts/:userId` is a route only reachable via a route change (list item, search result),
|
||||
// never a param variant of the index -- so ContactsLayout's showListOnNarrow hides the list here
|
||||
// just like every other narrow-viewport sub-route, and this page needs the same back affordance
|
||||
// GroupListView/NewFriendsView/BlocklistView have in their own header. This one has no header bar
|
||||
// at any state (loading/not-found/loaded), so the link is repeated across all three returns.
|
||||
const backLink = (
|
||||
<Link to="/contacts" className="absolute left-4 top-4 z-10 grid h-9.5 w-9.5 place-items-center rounded-full bg-white/70 text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (!userId) return null;
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
{backLink}
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const profile = query.data;
|
||||
if (!profile) {
|
||||
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
|
||||
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
|
||||
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
|
||||
// as if the user doesn't exist; every other failure (404, network error) keeps that fallback.
|
||||
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
{backLink}
|
||||
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFriend = profile.relationship === 'friend';
|
||||
// docs/IM-前后端对接文档.md:1289's real enum is direction-specific -- `isFriend` alone used to
|
||||
// collapse BOTH pending states into "not a friend yet", which fell through to the generic "添加
|
||||
// 好友" button below and let the user re-open AddFriendModal and resubmit a request that's already
|
||||
// outstanding either direction.
|
||||
const isPendingOutbound = profile.relationship === 'pending_outbound';
|
||||
const isPendingInbound = profile.relationship === 'pending_inbound';
|
||||
const name = profile.remark || profile.display_name;
|
||||
const metaParts = [profile.display_name ? `昵称:${profile.display_name}` : null, profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
// README §5.2 (desktop, 44px) vs §5.7 (mobile, 48px) want the SAME three actions at two different
|
||||
// sizes in two different places (inline in the card vs a fixed bottom bar, see below) -- one
|
||||
// function instead of duplicating the branching/handlers so there's exactly one behavior to get
|
||||
// right, not two copies that can drift.
|
||||
const actionButtons = (squareBtnClass: string, iconSizeClass: string) =>
|
||||
isFriend ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
// `profile.user_id`, not `profile.topic` -- confirmed live (see favorites.ts's own
|
||||
// finding) that this API's `topic` field for a P2P relationship is the server's
|
||||
// internal `p2p...`-hashed form, which this app's chat list/router never uses;
|
||||
// every P2P conversation elsewhere in this app is addressed by the peer's own
|
||||
// `usr...` id, which Tinode resolves to the same real topic on the wire.
|
||||
onClick={() => navigate(`/messages/${profile.user_id}`)}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
|
||||
>
|
||||
<Icon name="chat_bubble" className={iconSizeClass} />
|
||||
发消息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'audio');
|
||||
}}
|
||||
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
|
||||
>
|
||||
<Icon name="call" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'video');
|
||||
}}
|
||||
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
|
||||
>
|
||||
<Icon name="videocam" />
|
||||
</button>
|
||||
</>
|
||||
) : isPendingOutbound ? (
|
||||
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
|
||||
等待验证
|
||||
</button>
|
||||
) : isPendingInbound ? (
|
||||
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
|
||||
对方已申请加你
|
||||
</button>
|
||||
) : profile.capabilities?.can_add_friend === false ? (
|
||||
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
|
||||
对方暂不可添加
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingFriend(true)}
|
||||
className={`flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
|
||||
>
|
||||
添加好友
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center-safe justify-center overflow-y-auto bg-surface-content md:py-12">
|
||||
{backLink}
|
||||
<div className="w-full flex-none overflow-hidden bg-white shadow-[0_8px_32px_rgba(35,74,125,0.08)] md:w-[440px] md:rounded-2xl">
|
||||
<div className="h-24 bg-gradient-to-br from-[#e8f1fe] to-[#f3efff]" />
|
||||
<div className="flex flex-col items-center px-6 pb-24 md:pb-6">
|
||||
<Avatar
|
||||
id={profile.user_id}
|
||||
name={name}
|
||||
avatarFileId={profile.avatar_file_id}
|
||||
className="-mt-11 h-22 w-22 flex-none rounded-[20px] border-4 border-white text-2xl"
|
||||
/>
|
||||
<strong className="mt-3 text-xl font-semibold text-text-heading">{name}</strong>
|
||||
{metaParts ? <span className="mt-1 text-[13px] text-text-tertiary-2">{metaParts}</span> : null}
|
||||
{profile.public_account ? <span className="text-[13px] text-text-tertiary-2">ID: {profile.public_account}</span> : null}
|
||||
{profile.signature ? <p className="m-0 mt-2 text-sm text-text-secondary">{profile.signature}</p> : null}
|
||||
|
||||
{/* README §5.7: mobile pins this row OUTSIDE the scroll area (bottom-fixed), so it stays
|
||||
reachable regardless of profile length -- this hidden copy keeps the original inline
|
||||
desktop placement exactly as before; the real mobile copy is the fixed bar rendered
|
||||
after the card below. Both call the same handlers, so there is exactly one behavior. */}
|
||||
<div className="mt-5 hidden w-full items-center gap-2 md:flex">{actionButtons('h-11 w-11', 'text-[18px]')}</div>
|
||||
|
||||
{isFriend ? (
|
||||
<div className="mt-4 flex w-full flex-col divide-y divide-border-list-item border-t border-border-list-item text-sm">
|
||||
{editingRemark ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">备注</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={remarkDraft}
|
||||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft.trim())}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft.trim())} className="flex-none text-accent">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRemarkDraft(profile.remark || '');
|
||||
setEditingRemark(true);
|
||||
}}
|
||||
className="flex justify-between py-3 text-left"
|
||||
>
|
||||
<span className="text-text-body">备注</span>
|
||||
<span className="flex items-center gap-0.5 text-text-tertiary-2">
|
||||
{profile.remark || '未设置'}
|
||||
<Icon name="chevron_right" className="text-[18px]" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setShowQrCode(true)} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">二维码名片</span>
|
||||
<Icon name="qr_code_2" className="text-[20px] text-text-tertiary-2" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only fixed bottom bar (mock `04/1e`: `padding:12px 16px 34px`, 48px buttons) -- the
|
||||
real mobile counterpart to the hidden desktop copy above, pinned to the viewport bottom
|
||||
(not the card) so it stays reachable no matter how far the profile content scrolls.
|
||||
`bottom-[82px] sm:bottom-0`: `position:fixed` resolves against the viewport, not this
|
||||
route's own Outlet box, and below 640px the viewport bottom is where AppShell's persistent
|
||||
BottomTabBar (82px) already lives -- an unqualified `bottom-0` sat directly on top of it,
|
||||
hiding the tab bar entirely while this page was open. `sm:bottom-0` restores flush-bottom
|
||||
once the tab bar is gone (>=640px, NavRail shown on the side instead). */}
|
||||
<div className="fixed inset-x-0 bottom-[82px] z-10 flex gap-3 bg-white px-4 pb-[34px] pt-3 sm:bottom-0 md:hidden">
|
||||
{actionButtons('h-12 w-12', 'text-xl')}
|
||||
</div>
|
||||
|
||||
{showQrCode && userId ? <QRCodeModal identifier={userId} onClose={() => setShowQrCode(false)} /> : null}
|
||||
{addingFriend ? (
|
||||
<AddFriendModal
|
||||
onClose={() => setAddingFriend(false)}
|
||||
initialTarget={{
|
||||
user_id: profile.user_id,
|
||||
display_name: profile.display_name,
|
||||
public_account: profile.public_account,
|
||||
avatar_file_id: profile.avatar_file_id,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getGroups, type GroupRole } from '@/lib/api/groups';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
type Tab = 'all' | 'owner' | 'admin' | 'member';
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'owner', label: '我创建的' },
|
||||
{ key: 'admin', label: '我管理的' },
|
||||
{ key: 'member', label: '我加入的' },
|
||||
];
|
||||
|
||||
/** README §5.5. Fetched once (category='', i.e. every group) and tabbed client-side by `my_role`
|
||||
* rather than hitting `getGroups`'s own owned/managed/joined category filters three more times --
|
||||
* same total data, one request instead of four. */
|
||||
export function GroupListView() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
// Only page 1 used to be fetched -- an account with more than one page of groups would silently
|
||||
// undercount every tab (including "全部") and never list the rest.
|
||||
const query = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
|
||||
const groups = query.data || [];
|
||||
const counts: Record<Tab, number> = {
|
||||
all: groups.length,
|
||||
owner: groups.filter((g) => g.my_role === 'owner').length,
|
||||
admin: groups.filter((g) => g.my_role === 'admin').length,
|
||||
member: groups.filter((g) => g.my_role === 'member').length,
|
||||
};
|
||||
const filtered = tab === 'all' ? groups : groups.filter((g) => g.my_role === tab);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<span className="flex items-center gap-1">
|
||||
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="text-base font-semibold text-text-heading">群聊</strong>
|
||||
</span>
|
||||
<button type="button" onClick={() => setCreatingGroup(true)} className="flex items-center gap-1 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
创建群聊
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-none items-center gap-6 border-b border-border-panel bg-white px-6">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`h-11 border-b-2 text-sm ${tab === t.key ? 'border-accent font-medium text-accent' : 'border-transparent text-text-secondary'}`}
|
||||
>
|
||||
{t.label} {counts[t.key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
|
||||
) : (
|
||||
filtered.map((g) => (
|
||||
<button
|
||||
key={g.topic}
|
||||
type="button"
|
||||
onClick={() => navigate(`/messages/${g.topic}`)}
|
||||
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 text-left shadow-bubble"
|
||||
>
|
||||
<Avatar id={g.topic} name={g.name} avatarFileId={g.avatar_file_id} className="h-11 w-11 flex-none rounded-[10px] text-base" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{g.name}</strong>
|
||||
{g.my_role !== 'member' ? (
|
||||
<span
|
||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${g.my_role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'}`}
|
||||
>
|
||||
{ROLE_LABEL[g.my_role]}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{g.member_count} 人 · 群号 {g.topic}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{creatingGroup ? (
|
||||
<CreateGroupModal
|
||||
onClose={() => setCreatingGroup(false)}
|
||||
onCreated={(topic) => {
|
||||
setCreatingGroup(false);
|
||||
navigate(`/messages/${topic}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type FriendRequest } from '@/lib/api/friends';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
|
||||
|
||||
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
|
||||
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
|
||||
* that, not a pending-only fetch. */
|
||||
export function NewFriendsView() {
|
||||
const queryClient = useQueryClient();
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
|
||||
|
||||
// Only page 1 used to be fetched -- an account with more than one page of historical requests
|
||||
// would silently never show its older ones.
|
||||
const query = useQuery({
|
||||
queryKey: ['friend-requests', 'all'],
|
||||
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'all', cursor)),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
};
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: (req: FriendRequest) => acceptFriendRequest(req.request_id),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
|
||||
});
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (req: FriendRequest) => rejectFriendRequest(req.request_id),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
|
||||
});
|
||||
|
||||
const items = query.data || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<span className="flex items-center gap-1">
|
||||
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="text-base font-semibold text-text-heading">新朋友</strong>
|
||||
</span>
|
||||
<button type="button" onClick={() => setAddingFriend(true)} className="text-sm text-accent">
|
||||
添加好友
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
|
||||
) : (
|
||||
items.map((req) => {
|
||||
const status = handled[req.request_id] || (req.status !== 'pending' ? (req.status === 'accepted' ? 'accepted' : 'rejected') : null);
|
||||
const name = req.peer?.display_name || req.peer?.user_id || req.applicant_id;
|
||||
return (
|
||||
<div key={req.request_id} className="flex items-center gap-3 rounded-xl bg-white px-4.5 py-3.5 shadow-bubble">
|
||||
<span className={`grid h-12 w-12 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.applicant_id)}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
|
||||
{req.message ? <span className="truncate text-[13px] text-text-secondary">{req.message}</span> : null}
|
||||
<span className="truncate text-xs text-text-tertiary-3">
|
||||
{formatEventTimestamp(new Date(req.created_at))}
|
||||
{req.source ? ` · ${SOURCE_LABEL[req.source] || req.source}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{status ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">{status === 'accepted' ? '已通过' : '已拒绝'}</span>
|
||||
) : (
|
||||
<span className="flex flex-none items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => acceptMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
通过验证
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface QRCodeModalProps {
|
||||
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
|
||||
* (README's "二维码名片" row on a friend's profile). */
|
||||
identifier?: string;
|
||||
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
|
||||
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
|
||||
groupTopic?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
|
||||
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
|
||||
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
|
||||
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
|
||||
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
|
||||
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
|
||||
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
|
||||
*/
|
||||
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
|
||||
useEscapeKey(onClose);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drawError, setDrawError] = useState('');
|
||||
const query = useQuery<MyQRCode | GroupQRCode>({
|
||||
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
|
||||
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
|
||||
// isn't in every visitor's main bundle when most sessions never open this modal.
|
||||
import('qrcode')
|
||||
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
|
||||
.catch(() => setDrawError('二维码生成失败'));
|
||||
}, [query.data]);
|
||||
|
||||
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
|
||||
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
|
||||
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||||
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||||
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
|
||||
|
||||
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
|
||||
|
||||
const saveToAlbum = async () => {
|
||||
const blob = await canvasToBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${fileBaseName}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const shareToFriend = async () => {
|
||||
const blob = await canvasToBlob();
|
||||
if (!blob) return;
|
||||
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
|
||||
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
|
||||
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
|
||||
// `canShare({ files })` is the one call that actually answers "can this share an image".
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: fileBaseName });
|
||||
} catch {
|
||||
// AbortError (user cancelled the native share sheet) or any other failure -- either way
|
||||
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
|
||||
}
|
||||
} else {
|
||||
await saveToAlbum();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-7">
|
||||
{profile ? (
|
||||
<>
|
||||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
|
||||
{avatarLetter(profile.display_name)}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
|
||||
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
|
||||
</>
|
||||
) : group ? (
|
||||
<>
|
||||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
|
||||
{avatarLetter(group.name)}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
|
||||
{query.isLoading ? (
|
||||
<span className="text-sm text-text-tertiary-4">生成中…</span>
|
||||
) : query.isError || drawError ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
||||
<Icon name="error_outline" className="text-2xl text-danger-text" />
|
||||
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
|
||||
</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary-3">{caption}</span>
|
||||
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
|
||||
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
|
||||
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
|
||||
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
|
||||
{query.data && !drawError ? (
|
||||
<div className="mt-1 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveToAlbum}
|
||||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||||
>
|
||||
<Icon name="download" className="text-[16px]" />
|
||||
保存到相册
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareToFriend}
|
||||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||||
>
|
||||
<Icon name="share" className="text-[16px]" />
|
||||
分享好友
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { resolveQRCode, type QRResolveGroup, type QRResolveUser } from '@/lib/api/qrcodes';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface QRScannerModalProps {
|
||||
onClose: () => void;
|
||||
/** A resolved, genuinely-addable ('none' relationship, can_add) scanned user. */
|
||||
onResolved: (user: QRResolveUser) => void;
|
||||
/** Scanned code belongs to someone already a friend -- caller decides what "already added"
|
||||
* should do (e.g. navigate to their profile) instead of re-showing an add-friend form. */
|
||||
onAlreadyFriend: (user: QRResolveUser) => void;
|
||||
/** A `purpose:"join_group"` code resolved successfully and the scanning account is NOT already a
|
||||
* member -- optional so a caller that only ever scans for the add-friend flow (there is none
|
||||
* currently, but the type shouldn't force every caller to handle a case it can't reach) doesn't
|
||||
* have to wire a no-op. `qrToken` is threaded through because the join-request submission itself
|
||||
* needs to carry it (docs/IM-前后端对接文档.md:1591). */
|
||||
onGroupResolved?: (group: QRResolveGroup, qrToken: string) => void;
|
||||
/** Mirrors `onAlreadyFriend` for the group case -- confirmed live that resolving a code for a
|
||||
* group you're already in comes back `status:"joined"` rather than something requiring a join
|
||||
* request at all (see qrcodes.ts's QRResolveResult comment). */
|
||||
onAlreadyMember?: (group: QRResolveGroup) => void;
|
||||
}
|
||||
|
||||
const SCAN_INTERVAL_MS = 250;
|
||||
/** Old app's own real, working scanner (im_webapp/src/widgets/qr-scanner.jsx) downscales to this
|
||||
* max edge before decoding -- jsQR's cost scales with pixel count, and a raw 1080p+ camera frame
|
||||
* decoded every 250ms would be needlessly expensive when a QR code only needs a few hundred px
|
||||
* per side to read reliably. */
|
||||
const MAX_FRAME_EDGE = 480;
|
||||
|
||||
type ScanState = 'requesting' | 'scanning' | 'resolving' | 'camera-error' | 'resolve-error';
|
||||
|
||||
/**
|
||||
* README §5.4's "扫一扫" -- full-screen camera viewfinder. Ported cognition (not code) from the old
|
||||
* app's real, working `qr-scanner.jsx` (getUserMedia -> draw each frame to an offscreen canvas ->
|
||||
* jsQR) and `tinode-web.jsx`'s `handleQrDetected` (reject anything not matching the app's own
|
||||
* `im://qr?token=...&purpose=...` scheme client-side before ever sending it to `resolve`, so a
|
||||
* generic URL/WiFi/whatever QR code someone points this at is never even round-tripped).
|
||||
*/
|
||||
export function QRScannerModal({ onClose, onResolved, onAlreadyFriend, onGroupResolved, onAlreadyMember }: QRScannerModalProps) {
|
||||
useEscapeKey(onClose);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const resolvingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const [state, setState] = useState<ScanState>('requesting');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const stopCamera = () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDetected = async (text: string) => {
|
||||
if (resolvingRef.current) return;
|
||||
// Only this app's own generated codes resolve to anything -- a generic URL/WiFi/contact QR is
|
||||
// rejected client-side rather than round-tripped to the server for a guaranteed-useless lookup.
|
||||
if (!text.startsWith('im://qr?')) return;
|
||||
const params = new URLSearchParams(text.slice('im://qr?'.length));
|
||||
const token = params.get('token');
|
||||
const purpose = params.get('purpose') || 'add_friend';
|
||||
if (!token) return;
|
||||
resolvingRef.current = true;
|
||||
setState('resolving');
|
||||
try {
|
||||
const result = await resolveQRCode(token, purpose);
|
||||
// The modal may have been closed (or the user's intent otherwise moved on) while this request
|
||||
// was in flight -- applying its result now would redirect/populate state the user never asked
|
||||
// for after they believed they'd already cancelled.
|
||||
if (!mountedRef.current) return;
|
||||
if (purpose === 'join_group') {
|
||||
// See qrcodes.ts's own QRResolveGroup comment -- confirmed live this nests under `group`
|
||||
// (group_snapshot checked too as a defensive fallback, not because it's expected to fire).
|
||||
const group = result.group || result.group_snapshot;
|
||||
if (!group || !onGroupResolved) {
|
||||
// Same leak as the two other resolve-error branches below: without this, the scan loop and
|
||||
// camera stream both kept running underneath the full-screen error card, so the SAME
|
||||
// unsupported code re-triggered `resolveQRCode` every ~250ms indefinitely, and clicking
|
||||
// "重试" then started a second stream/interval on top without ever stopping this one.
|
||||
stopCamera();
|
||||
setErrorMessage('暂不支持此类二维码');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
return;
|
||||
}
|
||||
stopCamera();
|
||||
if (result.status === 'joined' && onAlreadyMember) onAlreadyMember(group);
|
||||
else onGroupResolved(group, token);
|
||||
return;
|
||||
}
|
||||
if (purpose !== 'add_friend' || !result.user) {
|
||||
stopCamera();
|
||||
setErrorMessage('暂不支持此类二维码');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
return;
|
||||
}
|
||||
stopCamera();
|
||||
if (result.user.relationship === 'friend') {
|
||||
onAlreadyFriend(result.user);
|
||||
} else if (!result.user.can_add) {
|
||||
setErrorMessage('无法添加该用户为好友');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
} else {
|
||||
onResolved(result.user);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
stopCamera();
|
||||
setErrorMessage(err instanceof Error ? err.message : '二维码已失效');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-decoding library isn't
|
||||
// in every visitor's main bundle when most sessions never open the scanner.
|
||||
const startScanLoop = async () => {
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
if (!canvas || !video) return;
|
||||
const { default: jsQR } = await import('jsqr');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
timerRef.current = window.setInterval(() => {
|
||||
if (!ctx || video.readyState !== video.HAVE_ENOUGH_DATA || resolvingRef.current) return;
|
||||
const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(video.videoWidth, video.videoHeight));
|
||||
const w = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
const h = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
const frame = ctx.getImageData(0, 0, w, h);
|
||||
const code = jsQR(frame.data, w, h, { inversionAttempts: 'dontInvert' });
|
||||
if (code?.data) handleDetected(code.data);
|
||||
}, SCAN_INTERVAL_MS);
|
||||
};
|
||||
|
||||
// React 18 StrictMode (dev only) runs mount -> cleanup -> mount synchronously, which starts TWO
|
||||
// overlapping getUserMedia() calls; without this generation token, whichever resolves last wins
|
||||
// and clobbers the other's streamRef/timerRef, leaking the loser's MediaStream (never .stop()'d)
|
||||
// and its setInterval. Matches the same-class guard already used in tinode/connection.ts/topics.ts.
|
||||
const requestGenerationRef = useRef(0);
|
||||
|
||||
const requestCamera = async () => {
|
||||
const myGeneration = ++requestGenerationRef.current;
|
||||
setState('requesting');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
|
||||
if (myGeneration !== requestGenerationRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
setState('scanning');
|
||||
startScanLoop();
|
||||
} catch (err) {
|
||||
if (myGeneration !== requestGenerationRef.current) return;
|
||||
setErrorMessage(err instanceof Error ? err.message : '无法访问摄像头');
|
||||
setState('camera-error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
requestCamera();
|
||||
return stopCamera;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const retry = () => {
|
||||
resolvingRef.current = false;
|
||||
requestCamera();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-black">
|
||||
<header className="flex h-13 flex-none items-center justify-between px-4 text-white">
|
||||
<strong className="text-[15px] font-semibold">扫一扫</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="relative flex-1">
|
||||
<video ref={videoRef} playsInline muted className="h-full w-full object-cover" />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
{state === 'scanning' ? (
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<div className="h-56 w-56 rounded-2xl border-2 border-white/80" />
|
||||
</div>
|
||||
) : null}
|
||||
{state === 'requesting' || state === 'resolving' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/40 text-sm text-white">
|
||||
{state === 'requesting' ? '正在请求摄像头权限…' : '正在识别…'}
|
||||
</div>
|
||||
) : null}
|
||||
{state === 'camera-error' || state === 'resolve-error' ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/70 px-8 text-center text-white">
|
||||
<Icon name={state === 'camera-error' ? 'videocam_off' : 'error_outline'} className="text-4xl" />
|
||||
<span className="text-sm">{state === 'camera-error' ? `无法访问摄像头:${errorMessage}` : errorMessage}</span>
|
||||
<button type="button" onClick={retry} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="flex-none py-4 text-center text-sm text-white/70">将二维码放入框内,即可自动扫描</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
import {
|
||||
batchDeleteFavorites,
|
||||
deleteFavorite,
|
||||
favoriteEntityData,
|
||||
forwardFavorites,
|
||||
getFavorites,
|
||||
type Favorite,
|
||||
type FavoriteType,
|
||||
} from '@/lib/api/favorites';
|
||||
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
|
||||
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: 'all_inbox' },
|
||||
{ key: 'text', label: '文本', icon: 'notes' },
|
||||
{ key: 'image', label: '图片', icon: 'image' },
|
||||
{ key: 'video', label: '视频', icon: 'videocam' },
|
||||
{ key: 'voice', label: '语音', icon: 'mic' },
|
||||
{ key: 'file', label: '文件', icon: 'description' },
|
||||
{ key: 'link', label: '链接', icon: 'link' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §6.1's footer "陈明远 · 项目协作群" names both the sender AND the conversation -- the
|
||||
* confirmed real `source` shape (doc line 1933) only carries `{topic, seq}`, no sender field, so
|
||||
* this can only resolve the CONVERSATION side (via the chat list's own contact cache, same lookup
|
||||
* ChatListColumn/ConversationView already do) and shows just that, not a fabricated sender name.
|
||||
*
|
||||
* Confirmed live: a P2P favorite's `source.topic` comes back in the server's internal `p2p...`-
|
||||
* hashed form (e.g. "p2pH5dvZ3ckWxq4XhSKX6MIow"), not the peer's `usr...` id the chat list and
|
||||
* every route in this app actually key P2P conversations by -- the SDK's own `Topic.topicType()`
|
||||
* doesn't even recognize a "p2p" prefix (its table only has usr/grp/new/nch/chn/sys/slf), so this
|
||||
* is a REST-API-only naming quirk, disconnected from the WS/SDK convention. There's no client-side
|
||||
* way to reverse that hash back to the peer's user id, so a chat-list lookup by topic can never
|
||||
* match it -- showing the raw hash string would be confusing, so this falls back to a plain label
|
||||
* for that case instead of guessing.
|
||||
*/
|
||||
function SourceLine({ source }: { source: Favorite['source'] }) {
|
||||
const contacts = useChatList();
|
||||
if (!source) return null;
|
||||
const known = contacts.find((c) => c.topic === source.topic)?.public?.fn;
|
||||
// Same reasoning as the p2p fallback below -- a group this account has never actually opened as a
|
||||
// real conversation (e.g. joined via an admin action, never chatted in) has no entry in the local
|
||||
// chat-list cache either, and showing the raw internal topic id (`grpXXXXXXXX`) to the user reads
|
||||
// as a broken/leaked implementation detail, not a "no data" state -- fall back to a generic label.
|
||||
const fallback = source.topic.startsWith('p2p') ? '单聊消息' : source.topic.startsWith('grp') ? '群聊' : source.topic;
|
||||
return <span className="truncate">{known || fallback}</span>;
|
||||
}
|
||||
|
||||
function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite; selectMode: boolean; selected: boolean; onToggle: () => void }) {
|
||||
const removed = item.availability !== 'available';
|
||||
|
||||
const body = (() => {
|
||||
if (removed) return <div className="text-sm text-text-tertiary-4">该内容已失效或被删除</div>;
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<p className="m-0 line-clamp-[5] max-h-[88px] overflow-hidden text-sm leading-[1.6] text-text-body">
|
||||
{item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '')}
|
||||
</p>
|
||||
);
|
||||
case 'image':
|
||||
case 'video': {
|
||||
const data = favoriteEntityData(item.snapshot.content, item.type === 'video' ? 'VD' : 'IM');
|
||||
// Video's own `val` is the actual video bytes (webm/mp4) -- not paintable as an <img> --
|
||||
// the paintable poster frame is the separate inline `data.preview` (confirmed live: base64
|
||||
// JPEG signature); image has no separate poster since the image itself IS the thumbnail.
|
||||
const inlineVal = typeof (item.type === 'video' ? data?.preview : data?.val) === 'string' ? ((item.type === 'video' ? data?.preview : data?.val) as string) : undefined;
|
||||
const inlineMime = item.type === 'video' ? 'image/jpeg' : typeof data?.mime === 'string' ? data.mime : undefined;
|
||||
return (
|
||||
<MediaPreview
|
||||
inlineVal={inlineVal}
|
||||
inlineMime={inlineMime}
|
||||
fileId={item.snapshot.thumbnail_file_id || item.snapshot.file_id}
|
||||
isVideo={item.type === 'video'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'voice': {
|
||||
// Reuses the same real, working audio player chat bubbles use (AttachmentPreview.tsx) --
|
||||
// this card used to be static (a bare play_circle icon with no click handler at all), so a
|
||||
// favorited voice message could never actually be played from this view.
|
||||
const data = favoriteEntityData(item.snapshot.content, 'AU');
|
||||
return (
|
||||
// Stops the play/pause click from also bubbling up to the card's own onClick -- in
|
||||
// selectMode that outer handler toggles selection, and without this, tapping play would
|
||||
// simultaneously start/stop audio AND select/deselect the card.
|
||||
<div className="rounded-lg bg-surface-rail px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<AudioAttachment
|
||||
data={{
|
||||
val: typeof data?.val === 'string' ? data.val : undefined,
|
||||
ref: typeof data?.ref === 'string' ? data.ref : item.snapshot.file_id ? `/v0/file/s/${item.snapshot.file_id}` : undefined,
|
||||
mime: typeof data?.mime === 'string' ? data.mime : undefined,
|
||||
duration: typeof data?.duration === 'number' ? data.duration : item.snapshot.duration_ms,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'file': {
|
||||
const data = favoriteEntityData(item.snapshot.content, 'EX');
|
||||
const name = (typeof data?.name === 'string' ? data.name : undefined) || item.snapshot.file?.name || item.snapshot.name || '';
|
||||
const size = (typeof data?.size === 'number' ? data.size : undefined) ?? item.snapshot.file?.size ?? item.snapshot.size ?? 0;
|
||||
const mime = (typeof data?.mime === 'string' ? data.mime : undefined) || item.snapshot.file?.mime || item.snapshot.mime;
|
||||
const style = fileTypeStyle(name, mime);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
<span className="text-xs text-text-tertiary-2">{formatFileSize(size)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
// Unlike image/video/voice/file, a Drafty `LN` entity annotates a RANGE of the message's own
|
||||
// text with a hyperlink (see drafty.tsx's own LN case) -- it has no separate title/thumbnail
|
||||
// data of its own the way a standalone attachment entity does. So a "link" favorite's real
|
||||
// snapshot is most likely just the SAME whole-message wrapper every other type turned out to
|
||||
// be (Part D's finding), meaning its own top-level `text` field (identical source the 'text'
|
||||
// case above already reads) is the one thing actually guaranteed to be there -- the flat
|
||||
// `title`/`url`/`thumbnail_file_id` fields below have never been confirmed against real data
|
||||
// and are kept only as an optional enhancement layer, not the sole source. Without this
|
||||
// fallback, a real link favorite whose backend response doesn't happen to populate those flat
|
||||
// fields rendered a card with a completely empty title line.
|
||||
const text = item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '');
|
||||
const title = item.snapshot.title || item.snapshot.url || text;
|
||||
// Only show a second line when it says something the title line doesn't already -- a
|
||||
// hostname alongside a real title/url is useful, but repeating `text` under itself when
|
||||
// that's ALSO what the title line fell back to would just be the same line twice.
|
||||
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text;
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
{item.snapshot.thumbnail_file_id ? (
|
||||
<span className="h-14 w-14 flex-none overflow-hidden rounded-lg bg-surface-rail">
|
||||
<MediaPreview fileId={item.snapshot.thumbnail_file_id} isVideo={false} square />
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid h-14 w-14 flex-none place-items-center rounded-lg bg-surface-rail">
|
||||
<Icon name="link" className="text-[20px] text-text-tertiary-3" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{title}</span>
|
||||
{subtitle ? <span className="truncate text-xs text-text-tertiary-2">{subtitle}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// A plain <div> (not <button>) -- the voice card nests AudioAttachment's own real <button> inside
|
||||
// this, and a <button> can't legally contain another <button> (React logs a hydration-nesting
|
||||
// error for it, confirmed live). role/tabIndex/onKeyDown restore button semantics only while this
|
||||
// is actually clickable (selectMode); outside selectMode it's inert, same as before.
|
||||
return (
|
||||
<div
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
onClick={selectMode ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
selectMode
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`flex flex-col gap-3 rounded-xl border-2 bg-white p-4 text-left ${selected ? 'border-accent' : 'border-transparent'}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{body}
|
||||
{selectMode ? (
|
||||
<span
|
||||
className={`absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border-list-item pt-2.5 text-xs text-text-tertiary-3">
|
||||
<SourceLine source={item.source} />
|
||||
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({
|
||||
fileId,
|
||||
inlineVal,
|
||||
inlineMime,
|
||||
isVideo,
|
||||
square,
|
||||
}: {
|
||||
fileId?: string;
|
||||
inlineVal?: string;
|
||||
inlineMime?: string;
|
||||
isVideo: boolean;
|
||||
square?: boolean;
|
||||
}) {
|
||||
// Real favorites carry the thumbnail inline as base64 (see favorites.ts's `Favorite` doc comment);
|
||||
// `fileId` is kept as a fallback for a hypothetical external-ref-only variant, never observed live.
|
||||
const inline = inlineVal ? `data:${inlineMime || 'image/jpeg'};base64,${inlineVal}` : undefined;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : fileId ? `/v0/file/s/${fileId}` : undefined);
|
||||
const src = inline || remote.url;
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-lg bg-surface-rail ${square ? 'h-14 w-14' : 'h-[120px] w-full'}`}>
|
||||
{src ? (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{isVideo && src ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* README §6.1 (3-column: Nav / 分类 260px / 内容). `favoriteMessages` (the create side, called from
|
||||
* a message's context menu) lives in messages.ts; this is the read/manage side, new in this pass.
|
||||
*/
|
||||
export function FavoritesLayout() {
|
||||
const queryClient = useQueryClient();
|
||||
const [category, setCategory] = useState<FavoriteType | 'all'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [forwarding, setForwarding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const favoritesQuery = useInfiniteQuery({
|
||||
queryKey: ['favorites', category, debouncedQuery],
|
||||
queryFn: ({ pageParam }) => getFavorites(category, debouncedQuery, pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = favoritesQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const invalidateAll = () => queryClient.invalidateQueries({ queryKey: ['favorites'] });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
// Both API shapes are declared with an optional `failed[]` (favorites.ts:92) -- a 200 here only
|
||||
// means the request was accepted, not that every selected item was actually deleted. Returning
|
||||
// the real response (was previously discarded entirely) so onSuccess can check it, same pattern
|
||||
// already fixed for BlocklistView's batch-unblock/GroupAdminModal elsewhere in this app.
|
||||
mutationFn: async (ids: string[]) => {
|
||||
if (ids.length === 1) {
|
||||
await deleteFavorite(ids[0]);
|
||||
return null;
|
||||
}
|
||||
return batchDeleteFavorites(ids);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidateAll();
|
||||
exitSelectMode();
|
||||
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
|
||||
});
|
||||
const forwardMutation = useMutation({
|
||||
// forwardFavorites already accepts multiple target_topics + a comment natively -- this used to
|
||||
// wrap a single topic in a 1-element array, silently discarding the picker's ability to select
|
||||
// several recipients or attach a comment.
|
||||
mutationFn: ({ targetTopics, comment }: { targetTopics: string[]; comment: string }) =>
|
||||
forwardFavorites([...selected], targetTopics, comment),
|
||||
onSuccess: (data) => {
|
||||
setForwarding(false);
|
||||
exitSelectMode();
|
||||
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
|
||||
// succeed for 2 and fail for 1, and silently reporting "success" either way hides that.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
|
||||
});
|
||||
|
||||
// Switching category mid-multi-select used to leave the OLD category's selections in `selected`
|
||||
// silently intact (nothing in this state ever cleared them) -- the header's "已选择 N 项" count and
|
||||
// any 转发/删除 action would then silently include items the user can no longer even see. Category
|
||||
// switches always start a fresh selection instead.
|
||||
const switchCategory = (key: FavoriteType | 'all') => {
|
||||
setCategory(key);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
// Same reasoning as switchCategory above, for the OTHER thing that changes which items are even
|
||||
// visible -- typing a search query re-filters the list just like picking a category does, and had
|
||||
// the identical gap (nothing here ever cleared `selected` when the query changed).
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const bulkDelete = () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return;
|
||||
deleteMutation.mutate([...selected]);
|
||||
};
|
||||
|
||||
const categoryPicker = (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-3">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => switchCategory(c.key)}
|
||||
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
|
||||
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-body'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchBox = (
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="搜索收藏内容"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* README §6.1's 3-column desktop layout (nav / 260px category rail / content). Below `md`
|
||||
there's no room to dock a 260px rail next to content at all -- screens/05-favorites-
|
||||
history.dc.html's own mobile mock (#1d) doesn't try to: it's a SINGLE screen with a
|
||||
horizontal pill category-switcher inline above the list, not a separate "pick a category"
|
||||
screen you navigate away from. This aside is desktop-only; its mobile equivalent is the
|
||||
pill row rendered inline in the content column below. */}
|
||||
<aside className="hidden h-full w-[260px] flex-none flex-col border-r border-border-panel bg-white md:flex">
|
||||
<header className="flex h-15 flex-none items-center px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">我的收藏</h1>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5">{searchBox}</div>
|
||||
<div className="flex flex-col gap-0.5 px-3">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => switchCategory(c.key)}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm ${
|
||||
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={c.icon} className="text-[18px]" />
|
||||
<span className="flex-1 text-left">{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col bg-surface-content">
|
||||
{selectMode ? (
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-4 md:px-6">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForwarding(true)}
|
||||
disabled={selected.size === 0}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="reply" className="text-[18px]" />
|
||||
转发
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={bulkDelete}
|
||||
disabled={selected.size === 0}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-danger-border-soft px-3.5 text-[13px] text-danger-text disabled:opacity-40"
|
||||
>
|
||||
<Icon name="delete_outline" className="text-[18px]" />
|
||||
删除
|
||||
</button>
|
||||
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile-only header -- `/favorites` is only reachable on a narrow viewport via
|
||||
设置(我的)>我的收藏 (SettingsLayout.tsx), so "back" returns there, matching every
|
||||
other narrow-only-reachable-via-parent-list page's own back-arrow convention. */}
|
||||
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
|
||||
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[18px]" />
|
||||
</Link>
|
||||
<strong className="truncate text-center text-base font-semibold text-text-heading">我的收藏</strong>
|
||||
{items.length ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</header>
|
||||
<div className="flex-none border-b border-border-panel pt-3 md:hidden">
|
||||
<div className="px-4">{searchBox}</div>
|
||||
{categoryPicker}
|
||||
</div>
|
||||
|
||||
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6 md:flex">
|
||||
<strong className="text-base font-semibold text-text-heading">{CATEGORIES.find((c) => c.key === category)?.label}</strong>
|
||||
{items.length ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{favoritesQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : favoritesQuery.isError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20 text-danger-text">
|
||||
<Icon name="error_outline" className="!text-4xl" />
|
||||
<span className="text-sm">加载失败,请重试</span>
|
||||
<button type="button" onClick={() => favoritesQuery.refetch()} className="text-sm text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20 text-text-tertiary-4">
|
||||
<Icon name="inventory_2" className="!text-4xl" />
|
||||
<span className="text-sm">暂无收藏内容</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 1 column under 640px (matches the mobile mock's plain vertical card list exactly),
|
||||
3 at md+ (matches the desktop mock's 3-column grid); 2 in between is this app's own
|
||||
judgment call, not in either mock, for the range where 1 column would waste width
|
||||
and 3 would be as cramped as the bug this replaces. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 md:gap-4">
|
||||
{items.map((item) => (
|
||||
<FavoriteCard
|
||||
key={item.favorite_id}
|
||||
item={item}
|
||||
selectMode={selectMode}
|
||||
selected={selected.has(item.favorite_id)}
|
||||
onToggle={() => toggle(item.favorite_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{favoritesQuery.hasNextPage ? (
|
||||
<div className="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => favoritesQuery.fetchNextPage()}
|
||||
disabled={favoritesQuery.isFetchingNextPage}
|
||||
className="text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{favoritesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{forwarding ? (
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwarding(false)}
|
||||
onPick={(targetTopics, comment) => forwardMutation.mutate({ targetTopics, comment })}
|
||||
/>
|
||||
) : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getFriends } from '@/lib/api/friends';
|
||||
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface CreateGroupModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: (topic: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.7 (680x520): letter-grouped friend picker on the left, selected chips + group-name
|
||||
* input on the right. Grouped by the first character of the display name -- a true pinyin-initial
|
||||
* grouping (matching the reference's A/B/C-style index) would need a pinyin library this codebase
|
||||
* doesn't have anywhere else; first-character buckets are a reasonable stand-in and already correct
|
||||
* for the plain-ASCII usernames this backend's test accounts actually have.
|
||||
*/
|
||||
export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, string>>(new Map());
|
||||
const [name, setName] = useState('');
|
||||
const [joinApproval, setJoinApproval] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const friends = friendsQuery.data || [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
const filtered = friends.filter((f) => !trimmed || (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmed));
|
||||
const byLetter = new Map<string, typeof friends>();
|
||||
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || a.user_id).localeCompare(b.remark || b.display_name || b.user_id))) {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
const letter = /[A-Za-z]/.test(label[0] || '') ? label[0].toUpperCase() : '#';
|
||||
if (!byLetter.has(letter)) byLetter.set(letter, []);
|
||||
byLetter.get(letter)!.push(f);
|
||||
}
|
||||
return [...byLetter.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [friends, query]);
|
||||
|
||||
const toggle = (userId: string, label: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.set(userId, label);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (selected.size === 0 || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
let result: Awaited<ReturnType<typeof createGroup>>;
|
||||
try {
|
||||
result = await createGroup({
|
||||
name: name.trim(),
|
||||
member_user_ids: [...selected.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建群聊失败');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// The group now genuinely exists -- from here on, a failure is NOT "创建群聊失败" (which would
|
||||
// read as "nothing happened, try again" and could prompt a duplicate re-submit). Kept in its own
|
||||
// try/catch so the 进群验证 follow-up call failing can't get blamed on group creation itself.
|
||||
if (joinApproval) {
|
||||
try {
|
||||
// README §4.5's 进群验证 governance toggle defaults off; a group created with "开启进群验证"
|
||||
// checked needs it turned on right after creation since createGroup's own body has no field
|
||||
// for it (docs/IM-前后端对接文档.md:1445's create body is name/description/member_user_ids/
|
||||
// avatar_file_id only -- governance is a separate resource).
|
||||
const gov = await getGroupGovernance(result.topic);
|
||||
await updateGroupGovernance(result.topic, { join_approval: true, version: gov.version });
|
||||
} catch {
|
||||
// Non-fatal: the group is real and usable, just without 进群验证 turned on yet -- that's a
|
||||
// setting the owner can still flip from 群管理 afterward, not a reason to hide a real group.
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
onCreated(result.topic);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[85vh] w-full flex-col rounded-xl bg-white shadow-modal md:h-[520px] md:w-[680px]" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">创建群聊</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col overflow-hidden md:flex-row">
|
||||
<div className="flex w-full flex-1 flex-col border-b border-border-list-item md:w-[340px] md:flex-none md:border-b-0 md:border-r">
|
||||
<div className="flex-none p-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索联系人"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{friendsQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">未找到联系人</div>
|
||||
) : (
|
||||
groups.map(([letter, items]) => (
|
||||
<div key={letter}>
|
||||
<div className="px-2 pb-1 pt-2 text-xs text-text-tertiary-3">{letter}</div>
|
||||
{items.map((f) => {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
const checked = selected.has(f.user_id);
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
onClick={() => toggle(f.user_id, label)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${
|
||||
checked ? 'bg-accent text-white' : 'border-[1.5px] border-[#c5cad2]'
|
||||
}`}
|
||||
>
|
||||
{checked ? <Icon name="check" className="text-[13px]" /> : null}
|
||||
</span>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}
|
||||
>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="flex-none text-sm text-text-secondary">已选 {selected.size} 人</div>
|
||||
<div className="mt-2 flex flex-wrap content-start gap-2 overflow-y-auto" style={{ maxHeight: 220 }}>
|
||||
{[...selected.entries()].map(([userId, label]) => (
|
||||
<span key={userId} className="flex items-center gap-1 rounded-2xl bg-surface-rail py-1 pl-1 pr-2">
|
||||
<span className={`grid h-[22px] w-[22px] place-items-center rounded-full text-[11px] text-white ${avatarBgClass(userId)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="max-w-[100px] truncate text-xs text-text-body">{label}</span>
|
||||
<button type="button" onClick={() => toggle(userId, label)} className="grid h-4 w-4 place-items-center text-text-tertiary-3">
|
||||
<Icon name="close" className="text-[14px]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
||||
placeholder="未填写时默认为成员昵称组合"
|
||||
className="h-10 rounded-lg bg-surface-input px-3 text-sm outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={joinApproval} onChange={(e) => setJoinApproval(e.target.checked)} className="accent-accent" />
|
||||
开启进群验证
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={selected.size === 0 || submitting}
|
||||
className="h-10 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '创建中…' : `创建群聊 (${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||
import {
|
||||
addGroupAdmins,
|
||||
getAllGroupMembers,
|
||||
getGroup,
|
||||
getGroupGovernance,
|
||||
getGroupMutes,
|
||||
getGroupRoleLimits,
|
||||
getGroupSummary,
|
||||
muteGroupMembers,
|
||||
removeGroupAdmin,
|
||||
transferGroupOwner,
|
||||
unmuteGroupMember,
|
||||
updateGroupGovernance,
|
||||
type GroupMember,
|
||||
type GroupMute,
|
||||
} from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupAdminModalProps {
|
||||
topicId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`px-3 py-1 disabled:opacity-50 ${value === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type SubView = 'main' | 'admins' | 'mutes' | 'join-requests' | 'transfer';
|
||||
|
||||
/** README §4.5 (560px): governance toggles/segments + admins/mutes/join-requests/owner-transfer link
|
||||
* rows. `addGroupAdmins`/`removeGroupAdmin`/`transferGroupOwner` all share ONE optimistic-lock
|
||||
* counter with the group profile itself -- confirmed by the doc's own worked example (§6.29):
|
||||
* admins/batch takes version:1 and returns version:2, then owner-transfer takes version:2 and
|
||||
* returns version:3. So the current value always comes from `getGroup`, re-read (via query
|
||||
* invalidation) after every such mutation -- a hardcoded constant would only be correct once. */
|
||||
export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [view, setView] = useState<SubView>('main');
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
|
||||
// README §4.5: 转让群主仅群主可见 -- `GroupPermissions` has no dedicated `can_transfer_ownership`
|
||||
// field, but `GroupProfile.owner` (the real owner's user id) is already fetched here for the
|
||||
// shared version counter, so comparing it against the session's own id is free, no extra request.
|
||||
const isOwner = !!myUserId && groupQuery.data?.owner === myUserId;
|
||||
const govQuery = useQuery({ queryKey: ['group-governance', topicId], queryFn: () => getGroupGovernance(topicId) });
|
||||
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
|
||||
const roleLimitsQuery = useQuery({ queryKey: ['group-role-limits', topicId], queryFn: () => getGroupRoleLimits(topicId) });
|
||||
// Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupAdmins): the entire
|
||||
// `/admins*` surface -- list included, not just add/remove -- requires OWNER, a plain admin gets
|
||||
// 403 even just listing. `enabled: isOwner` stops a non-owner admin from firing a request that can
|
||||
// only ever fail, and pairs with the `isOwner` gate on the "群管理员" row itself below.
|
||||
const adminsQuery = useQuery({
|
||||
queryKey: ['group-members', topicId, 'admin'],
|
||||
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
|
||||
enabled: isOwner,
|
||||
});
|
||||
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getGroupMutes(topicId) });
|
||||
|
||||
const govMutation = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
|
||||
onSuccess: (data) => queryClient.setQueryData(['group-governance', topicId], data),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
|
||||
});
|
||||
const setGov = (
|
||||
patch: Partial<{ join_approval: boolean; mute_all: boolean; member_upload_files: boolean; member_private_chat: boolean; member_invite: boolean }>,
|
||||
) => {
|
||||
if (!govQuery.data) return;
|
||||
govMutation.mutate({ ...patch, version: govQuery.data.version });
|
||||
};
|
||||
|
||||
const maxAdminCount = roleLimitsQuery.data?.max_admin_count;
|
||||
|
||||
if (view === 'join-requests') return <JoinRequestsModal topicId={topicId} onClose={() => setView('main')} />;
|
||||
if (view === 'admins')
|
||||
return (
|
||||
<AdminsSubView
|
||||
topicId={topicId}
|
||||
admins={adminsQuery.data || []}
|
||||
maxAdminCount={maxAdminCount}
|
||||
groupVersion={groupQuery.data?.version}
|
||||
onClose={() => setView('main')}
|
||||
/>
|
||||
);
|
||||
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data?.items || []} onClose={() => setView('main')} />;
|
||||
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[80vh] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群管理</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2 text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">进群验证</span>
|
||||
<span className="text-xs text-text-tertiary-3">开启后新成员需管理员审核</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.join_approval}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">全员禁言</span>
|
||||
<span className="text-xs text-text-tertiary-3">仅群主和管理员可以发言</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.mute_all}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">群文件上传权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_upload_files ? 'all' : 'admin'}
|
||||
options={[
|
||||
{ value: 'all', label: '所有成员' },
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">成员私聊权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_private_chat ? 'all' : 'friends'}
|
||||
options={[
|
||||
{ value: 'all', label: '允许' },
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
|
||||
real, already-read/write-capable field with no row anywhere in 03-group.dc.html's own
|
||||
群管理弹层 mock (#1b only draws 4 governance rows) -- placed here as a 5th row using the
|
||||
SAME SegmentedControl treatment as 群文件上传权限/成员私聊权限 just above (same modal,
|
||||
same "who is allowed to do X" semantics), rather than inventing a new visual pattern. */}
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">邀请入群权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_invite ? 'all' : 'admin'}
|
||||
options={[
|
||||
{ value: 'all', label: '所有成员' },
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_invite: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
|
||||
entire sub-resource (list included) requires ownership, not just admin. */}
|
||||
{isOwner ? (
|
||||
<button type="button" onClick={() => setView('admins')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">群管理员</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="text-xs">
|
||||
{adminsQuery.data?.length ?? 0}
|
||||
{maxAdminCount ? `/${maxAdminCount}` : ''}
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setView('mutes')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">禁言成员</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="text-xs">{mutesQuery.data?.items.length ?? 0} 人</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setView('join-requests')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">进群申请</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{summaryQuery.data?.pending_join_request_count ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{summaryQuery.data.pending_join_request_count}
|
||||
</b>
|
||||
) : null}
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
{isOwner ? (
|
||||
<button type="button" onClick={() => setView('transfer')} className="flex w-full items-center justify-between py-3.5 text-left">
|
||||
<span className="text-text-body">转让群主</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared candidate list for the admin/mute/owner-transfer pickers. All three server endpoints
|
||||
* follow the same "候选" shape (doc §6.19/6.23/6.25): `GET .../members` with an `eligible_role=` or
|
||||
* `mute_eligible=` filter, whose response items still carry `eligible` for entries the caller can't
|
||||
* act on -- so this filters those out client-side rather than trusting the query param alone to
|
||||
* have excluded them server-side. */
|
||||
function MemberPickerList({
|
||||
topicId,
|
||||
cacheKey,
|
||||
opts,
|
||||
onPick,
|
||||
emptyLabel,
|
||||
disabled,
|
||||
}: {
|
||||
topicId: string;
|
||||
cacheKey: string;
|
||||
opts: Parameters<typeof getAllGroupMembers>[1];
|
||||
onPick: (m: GroupMember) => void;
|
||||
emptyLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId, cacheKey], queryFn: () => getAllGroupMembers(topicId, opts) });
|
||||
const candidates = (membersQuery.data || []).filter((m) => m.eligible !== false);
|
||||
if (membersQuery.isLoading) return <div className="py-6 text-center text-sm text-text-tertiary-4">加载中…</div>;
|
||||
if (candidates.length === 0) return <div className="py-6 text-center text-sm text-text-tertiary-4">{emptyLabel}</div>;
|
||||
return (
|
||||
<>
|
||||
{candidates.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick(m)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:opacity-50"
|
||||
>
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SubModalShell({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center gap-2 border-b border-border-list-item px-3">
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 flex-none place-items-center text-text-tertiary-4">
|
||||
<Icon name="arrow_back" />
|
||||
</button>
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminsSubView({
|
||||
topicId,
|
||||
admins,
|
||||
maxAdminCount,
|
||||
groupVersion,
|
||||
onClose,
|
||||
}: {
|
||||
topicId: string;
|
||||
admins: GroupMember[];
|
||||
maxAdminCount: number | undefined;
|
||||
groupVersion: number | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
// `group-members-preview` is a SEPARATE cache key (GroupDetailColumn.tsx's own 9-grid query) --
|
||||
// missing it here meant promoting/demoting an admin never refreshed that grid's own "主"/"管"
|
||||
// corner badges until something else happened to invalidate it.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-role-limits', topicId] });
|
||||
};
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (userId: string) => addGroupAdmins(topicId, [userId], groupVersion ?? 1),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setAdding(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '添加失败'),
|
||||
});
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupAdmin(topicId, userId, groupVersion ?? 1),
|
||||
onSuccess: invalidate,
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '移除失败'),
|
||||
});
|
||||
|
||||
const countLabel = maxAdminCount ? `${admins.length}/${maxAdminCount}` : `${admins.length}`;
|
||||
useEscapeKey(onClose);
|
||||
return (
|
||||
<SubModalShell title={adding ? '添加管理员' : `群管理员 ${countLabel}`} onClose={adding ? () => setAdding(false) : onClose}>
|
||||
{adding ? (
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-admin"
|
||||
opts={{ eligibleRole: 'admin' }}
|
||||
onPick={(m) => addMutation.mutate(m.user_id)}
|
||||
emptyLabel="没有可添加的成员"
|
||||
disabled={addMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{admins.map((a) => (
|
||||
<div key={a.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(a.user_id)}`}>
|
||||
{avatarLetter(a.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{a.display_name || a.user_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(a.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="text-xs text-danger-text disabled:opacity-50"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
添加管理员
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: GroupMute[]; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
|
||||
const muteMutation = useMutation({
|
||||
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setAdding(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '禁言失败'),
|
||||
});
|
||||
const unmuteMutation = useMutation({
|
||||
mutationFn: (userId: string) => unmuteGroupMember(topicId, userId),
|
||||
onSuccess: invalidate,
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '解除禁言失败'),
|
||||
});
|
||||
|
||||
useEscapeKey(onClose);
|
||||
return (
|
||||
<SubModalShell title={adding ? '选择禁言成员' : `禁言成员 ${muted.length}`} onClose={adding ? () => setAdding(false) : onClose}>
|
||||
{adding ? (
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="mute-eligible"
|
||||
opts={{ muteEligible: true }}
|
||||
onPick={(m) => muteMutation.mutate(m.user_id)}
|
||||
emptyLabel="没有可禁言的成员"
|
||||
disabled={muteMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{muted.map((m) => (
|
||||
<div key={m.user.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user.user_id)}`}>
|
||||
{avatarLetter(m.user.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.user.display_name || m.user.user_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unmuteMutation.mutate(m.user.user_id)}
|
||||
disabled={unmuteMutation.isPending}
|
||||
className="text-xs text-accent disabled:opacity-50"
|
||||
>
|
||||
解除禁言
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
禁言成员
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: string; groupVersion: number | undefined; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const transferMutation = useMutation({
|
||||
mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
// Same gap as AdminsSubView's own invalidate() -- the 9-grid "主" badge in GroupDetailColumn
|
||||
// reads this separate cache key, not `group-members`.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '转让群主失败'),
|
||||
});
|
||||
|
||||
useEscapeKey(onClose);
|
||||
return (
|
||||
<SubModalShell title="转让群主" onClose={onClose}>
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-owner"
|
||||
opts={{ eligibleRole: 'owner' }}
|
||||
onPick={(m) => {
|
||||
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
|
||||
}}
|
||||
emptyLabel="没有可转让的成员"
|
||||
disabled={transferMutation.isPending}
|
||||
/>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getAllGroupMembers, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
|
||||
interface GroupAnnouncementModalProps {
|
||||
topicId: string;
|
||||
canEdit: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.8 (480px): publisher avatar/name/role tag + publish time, body text, read count, and an
|
||||
* 编辑 affordance for owner/admin. `PUT .../announcements/current` is optimistic-locked by
|
||||
* `version`, confirmed present in the GET response (doc line 1745, and re-confirmed live). Two
|
||||
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
|
||||
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
|
||||
* -- resolved here against the group's member list, same as any other bare user id in this API.
|
||||
* And `read_count`/`member_count` are confirmed ABSENT (not just unconfirmed), so dropped rather
|
||||
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
|
||||
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
|
||||
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
|
||||
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
|
||||
* of the empty state).
|
||||
*/
|
||||
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const query = useQuery({ queryKey: ['group-announcement', topicId], queryFn: () => getLatestGroupAnnouncement(topicId) });
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['group-announcement', topicId], data);
|
||||
// `GET .../settings` genuinely has no `announcement` field (see GroupDetailColumn.tsx's own
|
||||
// comment on this) -- the detail rail's own announcement preview actually reads
|
||||
// `group-summary`, so invalidating `group-settings` here never refreshed it; the preview text
|
||||
// was stuck at whatever it was before this save until something ELSE happened to touch summary.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发布失败'),
|
||||
});
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(query.data?.content || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[70vh] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群公告</strong>
|
||||
<div className="flex items-center gap-3">
|
||||
{canEdit && !editing ? (
|
||||
<button type="button" onClick={startEdit} className="text-sm text-accent">
|
||||
{query.data?.content ? '编辑' : '发布'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="发布群公告,所有成员都会收到通知"
|
||||
className="resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft.trim())}
|
||||
disabled={!draft.trim() || saveMutation.isPending}
|
||||
className="h-10 self-end rounded-lg bg-btn px-5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{saveMutation.isPending ? '发布中…' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
) : query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !query.data?.content ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群公告</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(query.data.author)}`}>
|
||||
{avatarLetter(author?.display_name || query.data.author)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-body">{author?.display_name || query.data.author}</span>
|
||||
{/* README §4.3: border style, no fill (matches MessageBubble.tsx's MessageRoleTag) --
|
||||
this used to hardcode warn/orange for BOTH owner and admin authors, so an admin's
|
||||
own announcement showed the wrong (owner) color. */}
|
||||
{author?.role === 'owner' ? (
|
||||
<span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">{ROLE_LABEL.owner}</span>
|
||||
) : author?.role === 'admin' ? (
|
||||
<span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">{ROLE_LABEL.admin}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
||||
</div>
|
||||
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { GroupAdminModal } from '@/features/groups/GroupAdminModal';
|
||||
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
||||
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
||||
import {
|
||||
disbandGroup,
|
||||
getGroup,
|
||||
getMyGroupProfile,
|
||||
getGroupMembers,
|
||||
getGroupSettings,
|
||||
getGroupSummary,
|
||||
leaveGroup,
|
||||
updateGroupMemberSettings,
|
||||
updateMyGroupNickname,
|
||||
type GroupMember,
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/groups';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupDetailColumnProps {
|
||||
topicId: string;
|
||||
peerName: string;
|
||||
onClose?: () => void;
|
||||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
// README §4.10/#1e: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
|
||||
// (18×18 knob) -- this used to be the desktop size unconditionally. The travel distance
|
||||
// happens to stay 18px at both sizes (both track and knob grow by the same 4px, so the gap
|
||||
// between them is unchanged), so only the track/knob dimensions need a `md:` fork, not the
|
||||
// translate value.
|
||||
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §4.1's owner/admin corner tags on the 5-col member grid. */
|
||||
function RoleCorner({ role }: { role: GroupMember['role'] }) {
|
||||
if (role === 'owner') return <span className="absolute -left-0.5 -top-0.5 rounded bg-warn px-1 text-[9px] text-white">主</span>;
|
||||
if (role === 'admin') return <span className="absolute -left-0.5 -top-0.5 rounded bg-accent px-1 text-[9px] text-white">管</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
|
||||
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
|
||||
* almost entirely REST per Ch.6 of the protocol doc, not native Tinode `{set}` like a P2P topic's
|
||||
* desc/private would use -- confirmed against the old app's actually-shipped group-management-view.jsx).
|
||||
*/
|
||||
export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: GroupDetailColumnProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const tinode = useTinode();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [nicknameEditing, setNicknameEditing] = useState(false);
|
||||
const [nicknameDraft, setNicknameDraft] = useState('');
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const [openModal, setOpenModal] = useState<'members' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||
|
||||
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
|
||||
// GroupDetailColumn is reused across every group when docked, no `key={topicId}`.
|
||||
useLayoutEffect(() => {
|
||||
setNicknameEditing(false);
|
||||
setNicknameDraft('');
|
||||
setErrorToast('');
|
||||
setOpenModal(null);
|
||||
}, [topicId]);
|
||||
|
||||
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
|
||||
const settingsQuery = useQuery({ queryKey: ['group-settings', topicId], queryFn: () => getGroupSettings(topicId) });
|
||||
// `GET .../settings`'s real response has no `announcement` field at all (confirmed live,
|
||||
// 2026-09-08) despite this file originally typing one on GroupSettings -- the summary endpoint
|
||||
// is the one that actually carries it (also used by ConversationView's notification bar and
|
||||
// GroupAdminModal's join-request badge, so this is a cache hit more often than not).
|
||||
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
|
||||
const membersQuery = useQuery({ queryKey: ['group-members-preview', topicId], queryFn: () => getGroupMembers(topicId, {}) });
|
||||
// `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
|
||||
// only this per-user profile endpoint does, so the nickname row needs its own fetch. Uses the
|
||||
// dedicated `members/me/profile` route, not getGroupMemberProfile(topic, myUserId) -- see that
|
||||
// function's own warning for why the generic by-id path 400s on self-lookups.
|
||||
const myProfileQuery = useQuery({
|
||||
queryKey: ['group-my-profile', topicId, myUserId],
|
||||
queryFn: () => getMyGroupProfile(topicId),
|
||||
enabled: !!myUserId,
|
||||
});
|
||||
const myRole = settingsQuery.data?.my_role;
|
||||
const isOwner = myRole === 'owner';
|
||||
const isAdmin = myRole === 'admin' || isOwner;
|
||||
// GroupAdminModal's "邀请入群权限" governance toggle (所有成员/仅管理员) was added without ever
|
||||
// being read anywhere -- every real gate on the invite affordance stayed hardcoded to
|
||||
// owner/admin-only, so flipping that toggle to "所有成员" had zero effect on what a plain member
|
||||
// could actually see. `member_invite` lives on this same `settingsQuery` response already in scope
|
||||
// (no extra request needed) -- live-verified this reflects the real value (flipped it via
|
||||
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
|
||||
const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true;
|
||||
|
||||
// No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending
|
||||
// one 400s every single call (this was the actual root cause of "member-settings always 400",
|
||||
// not an optimistic-lock issue -- confirmed live 2026-09-09 by reading the backend's request
|
||||
// struct, which doesn't declare a version field at all).
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) => updateGroupMemberSettings(topicId, patch),
|
||||
// The response is the small {pinned, mute_notifications, version, updated_at} shape, not a full
|
||||
// GroupSettings -- merge instead of replacing, or every other cached field (name/owner/governance/
|
||||
// member_count/...) would vanish from the settings panel after the first successful toggle.
|
||||
onSuccess: (data) =>
|
||||
queryClient.setQueryData(['group-settings', topicId], (prev: GroupSettings | undefined) =>
|
||||
prev ? { ...prev, pinned: data.pinned, mute_notifications: data.mute_notifications } : prev,
|
||||
),
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
const nicknameMutation = useMutation({
|
||||
mutationFn: (nickname: string) => updateMyGroupNickname(topicId, nickname, myProfileQuery.data?.version ?? 1),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicId, myUserId] });
|
||||
setNicknameEditing(false);
|
||||
},
|
||||
onError: (err) => showError(err, '昵称保存失败'),
|
||||
});
|
||||
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: () => leaveGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onError: (err) => showError(err, '退出群聊失败'),
|
||||
});
|
||||
|
||||
const disbandMutation = useMutation({
|
||||
mutationFn: () => disbandGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onError: (err) => showError(err, '解散群聊失败'),
|
||||
});
|
||||
|
||||
const clearHistory = () => {
|
||||
if (!window.confirm('确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。')) return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
const startLeaveOrDisband = () => {
|
||||
if (isOwner) {
|
||||
if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return;
|
||||
disbandMutation.mutate();
|
||||
} else {
|
||||
if (!window.confirm('确定退出本群聊吗?')) return;
|
||||
leaveMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const members = membersQuery.data?.items || [];
|
||||
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
|
||||
{/* Desktop header (03-group.dc.html #1a): title left, close-X right -- unchanged. */}
|
||||
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
|
||||
<strong className="text-[15px] font-semibold">群聊设置</strong>
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
{/* Mobile header (#1e, "详情栏内容平铺为整页"): back arrow / centered title / empty. This panel
|
||||
already renders full-screen below 960px (MessagesLayout's own `fixed inset-0 md:hidden`
|
||||
wrapper around ConversationDetailPanel) -- only its OWN header/content never got restyled
|
||||
to match until now. `onClose` doubles as "back": same prop the desktop close-X already
|
||||
calls, which just closes this full-screen overlay -- no separate navigation needed. */}
|
||||
<header className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-list-item bg-white px-2 md:hidden">
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[22px]" />
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<strong className="text-center text-[17px] font-semibold">群聊设置</strong>
|
||||
<span />
|
||||
</header>
|
||||
|
||||
{/* Content. Desktop: `md:contents` makes this wrapper (and the "card" wrapper just below it)
|
||||
disappear as boxes -- their children fall through to be direct flex children of `aside`
|
||||
exactly as before this change, so desktop stays pixel-identical. Mobile: a real scroll
|
||||
container (the `aside` above switches to `overflow-hidden`, handing scrolling to this
|
||||
instead) with the mock's `#f6f7f9` page background and 10px gaps between white "cards".
|
||||
The card GROUPING genuinely differs from desktop here, confirmed by reading both #1a and
|
||||
#1e side by side rather than assumed: mobile puts 群管理 with 群公告/群文件/查找聊天记录
|
||||
(not with 清空聊天记录/投诉举报 like desktop), and mobile's mock has no 我在本群的昵称 row
|
||||
at all -- kept anyway (see below) since it's a real, working desktop feature with no other
|
||||
entry point on mobile; silently dropping it would be a functional regression, not a
|
||||
faithful mobile port. */}
|
||||
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto bg-[#f6f7f9] py-2.5 md:contents">
|
||||
<div className="bg-white md:contents">
|
||||
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||||
{avatarLetter(groupQuery.data?.name || peerName)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">
|
||||
群号 {topicId} · {memberCount} 人
|
||||
</span>
|
||||
</div>
|
||||
{/* 03-group.dc.html #1a: `qr_code_2` icon, `margin-left:auto` -- a real, mock-confirmed
|
||||
entry point this row never had. #1e's mobile avatar row instead ends in a generic
|
||||
`chevron_right` (implying a deeper group-profile screen this app doesn't have), not
|
||||
this icon -- but shipping a REAL, working QR entry point on mobile too (even if its
|
||||
glyph doesn't literally match #1e's) beats a chevron that taps through to nowhere. */}
|
||||
<button type="button" onClick={() => setOpenModal('qrcode')} aria-label="群二维码" className="flex-none text-text-tertiary-3">
|
||||
<Icon name="qr_code_2" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{groupQuery.data?.description ? <p className="m-0 text-[13px] text-text-secondary">{groupQuery.data.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-4 py-3 text-sm md:border-b md:border-border-list-item md:px-5">
|
||||
{/* Desktop-only header row (#1a): "群成员 N · 查看全部" above the grid. Mobile (#1e) has
|
||||
no such row -- it puts the same "查看全部成员" affordance as plain centered text
|
||||
BELOW the grid instead (see the mobile-only button after the grid). */}
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="hidden w-full items-center justify-between py-1 md:flex">
|
||||
<span className="text-text-body">群成员 {memberCount}</span>
|
||||
<span className="flex items-center gap-0.5 text-[13px] text-accent">
|
||||
查看全部
|
||||
<Icon name="chevron_right" className="text-[18px]" />
|
||||
</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-5 gap-2 md:mt-2">
|
||||
{members.slice(0, 9).map((m) => (
|
||||
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
|
||||
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
|
||||
desktop's 40px (unconditional here before this fix). */}
|
||||
<span
|
||||
className={`grid h-11 w-11 place-items-center rounded-lg text-sm font-medium text-white md:h-10 md:w-10 ${avatarBgClass(m.user_id)}`}
|
||||
>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<RoleCorner role={m.role} />
|
||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||
</div>
|
||||
))}
|
||||
{canInvite ? (
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
</span>
|
||||
<span className="text-[11px] text-text-secondary">邀请</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
|
||||
查看全部成员 ({memberCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
|
||||
<button type="button" onClick={() => setOpenModal('announcement')} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">群公告</span>
|
||||
<span className="flex max-w-[65%] items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||||
<span className="truncate">{summaryQuery.data?.announcement?.content || '未设置'}</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpenModal('files')} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">群文件</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenHistory('text')} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">查找聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<button type="button" onClick={() => setOpenModal('admin')} className="flex items-center justify-between py-3 text-left md:hidden">
|
||||
<span className="flex items-center gap-2 text-text-body">
|
||||
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
|
||||
群管理
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
{/* Not in #1e's mock at all (see the block comment above) -- kept for parity with desktop,
|
||||
which does have this row (just grouped with 群管理/清空聊天记录 there instead). */}
|
||||
{nicknameEditing ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">我在本群的昵称</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={nicknameDraft}
|
||||
onChange={(e) => setNicknameDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && nicknameMutation.mutate(nicknameDraft)}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => nicknameMutation.mutate(nicknameDraft)} className="flex-none text-accent">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNicknameDraft(myProfileQuery.data?.nickname || '');
|
||||
setNicknameEditing(true);
|
||||
}}
|
||||
className="flex items-center justify-between py-3 text-left"
|
||||
>
|
||||
<span className="text-text-body">我在本群的昵称</span>
|
||||
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||||
{myProfileQuery.data?.nickname || '未设置'}
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">置顶聊天</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.pinned}
|
||||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||||
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">消息免打扰</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.mute_notifications}
|
||||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||||
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white px-4 pb-2 text-sm md:px-5">
|
||||
{/* Desktop groups 群管理 here (#1a); mobile already rendered it in the card above (#1e),
|
||||
so this copy is md-only to avoid showing the row twice. */}
|
||||
{isAdmin ? (
|
||||
<button type="button" onClick={() => setOpenModal('admin')} className="hidden items-center justify-between py-3 text-left md:flex">
|
||||
<span className="flex items-center gap-2 text-text-body">
|
||||
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
|
||||
群管理
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">清空聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">投诉举报</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop (#1a): bordered pill button, centered with margin -- unchanged. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startLeaveOrDisband}
|
||||
disabled={leaveMutation.isPending || disbandMutation.isPending}
|
||||
className="mx-5 my-4 hidden h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text disabled:opacity-50 md:block"
|
||||
>
|
||||
{isOwner ? '解散群聊' : '退出群聊'}
|
||||
</button>
|
||||
{/* Mobile (#1e): its own plain full-width white card, no border/radius. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startLeaveOrDisband}
|
||||
disabled={leaveMutation.isPending || disbandMutation.isPending}
|
||||
className="flex-none bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50 md:hidden"
|
||||
>
|
||||
{isOwner ? '解散群聊' : '退出群聊'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
|
||||
{openModal === 'members' ? (
|
||||
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
|
||||
) : null}
|
||||
{openModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
|
||||
) : null}
|
||||
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} onClose={() => setOpenModal(null)} /> : null}
|
||||
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { addGroupFile, deleteGroupFile, getAllGroupMembers, getGroupFiles, type GroupFile } from '@/lib/api/groups';
|
||||
import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload';
|
||||
import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
function isPdf(f: GroupFile): boolean {
|
||||
return f.mime_type === 'application/pdf' || f.file_name.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
/** Authenticated fetch-to-blob, shared by download and "open in new tab" -- both need the same
|
||||
* custom auth header a plain `<a href>`/`window.open` can't carry (see AttachmentPreview.tsx's own
|
||||
* comment on this constraint), they just do different things with the resulting blob URL. */
|
||||
async function fetchFileBlobUrl(fileId: string): Promise<string> {
|
||||
const res = await fetch(fileDownloadUrl(`/v0/file/s/${fileId}`), { headers: fileAuthHeaders() });
|
||||
// Group files are GC'd as "unreferenced" within hours (known backend issue) -- without this
|
||||
// check, a 404's JSON error body gets treated as if it were the real file.
|
||||
if (!res.ok) throw new Error();
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
|
||||
interface GroupFilesModalProps {
|
||||
topicId: string;
|
||||
/** Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupFiles): delete is
|
||||
* allowed for the uploader OR a group admin/owner, not the uploader alone -- without this, an
|
||||
* owner/admin has no way to remove another member's upload from this UI even though the server
|
||||
* would accept the request. */
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
|
||||
|
||||
/** `POST .../files`'s `file_type` field has no documented enum anywhere in the protocol doc -- ported
|
||||
* from the old app's own real, working `group-management-view.jsx` (mime-prefix first, a document-
|
||||
* extension allowlist as fallback), which itself notes this field being omitted entirely used to
|
||||
* make every group file show a generic icon regardless of real type -- the read-side icon-branching
|
||||
* bug found in this rewrite's own review pass had the same root cause. */
|
||||
function fileTypeFromMime(mime: string, name: string): string {
|
||||
const prefix = mime.split('/')[0];
|
||||
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
|
||||
const ext = /\.([a-zA-Z0-9]+)$/.exec(name)?.[1]?.toLowerCase();
|
||||
if (mime === 'application/pdf' || (ext && DOCUMENT_EXT.includes(ext))) return 'document';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.9 (480px). Files are registered via the message-send path (attachments.ts) or a direct
|
||||
* upload here -- this view lists/uploads/downloads/deletes the group's file INDEX (lib/api/groups.ts's
|
||||
* getGroupFiles/addGroupFile/deleteGroupFile). Upload reuses the same native `/v0/file/u/` channel as
|
||||
* chat attachments/avatars (uploadFile), then registers the result via addGroupFile -- which needs
|
||||
* more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported
|
||||
* from the old app's real, working upload flow rather than guessed from the doc.
|
||||
*/
|
||||
export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
const [downloadError, setDownloadError] = useState('');
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const filesQuery = useInfiniteQuery({
|
||||
queryKey: ['group-files', topicId, debouncedQuery],
|
||||
queryFn: ({ pageParam }) => getGroupFiles(topicId, debouncedQuery, '', pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const files = filesQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
// `GroupFile.uploader_id` is a bare user id with no display name attached (confirmed live) --
|
||||
// mock `03-group.dc.html #1h`'s rows all show "size · uploader · time", not just "size · time".
|
||||
// Same lookup-by-id-against-the-member-list pattern GroupAnnouncementModal already uses for its
|
||||
// own author row.
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const uploaderName = (uploaderId: string) => membersQuery.data?.find((m) => m.user_id === uploaderId)?.display_name || uploaderId;
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
// Confirmed live: the upload response never echoes back name/size/mime (see upload.ts's own
|
||||
// UploadedFile comment) -- read them off the original File object instead, exactly like the
|
||||
// chat-attachment upload path (lib/tinode/attachments.ts) already correctly does.
|
||||
const uploaded = await uploadFile(file, topicId);
|
||||
await addGroupFile(topicId, {
|
||||
file_id: uploaded.file_id,
|
||||
file_name: file.name,
|
||||
mime_type: file.type,
|
||||
file_type: fileTypeFromMime(file.type, file.name),
|
||||
file_size: file.size,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['group-files', topicId] });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupFileId: string) => deleteGroupFile(topicId, groupFileId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['group-files', topicId] }),
|
||||
onError: (err) => setActionError(err instanceof Error ? err.message : '删除失败'),
|
||||
});
|
||||
|
||||
const downloadFile = (f: GroupFile) =>
|
||||
fetchFileBlobUrl(f.file_id)
|
||||
.then((url) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = f.file_name;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => setDownloadError('文件下载失败,可能已过期'));
|
||||
|
||||
const openFileInNewTab = (f: GroupFile) =>
|
||||
fetchFileBlobUrl(f.file_id)
|
||||
.then((url) => window.open(url, '_blank', 'noopener,noreferrer'))
|
||||
.catch(() => setDownloadError('文件打开失败,可能已过期'));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[520px] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
|
||||
the neutral gray this used to be. */}
|
||||
<label
|
||||
className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
<Icon name={uploading ? 'hourglass_empty' : 'upload'} className="text-[18px]" />
|
||||
上传
|
||||
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
|
||||
</label>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-none px-4 pt-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索群文件"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{filesQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : filesQuery.isError ? (
|
||||
<div className="py-10 text-center text-sm text-danger-text">加载失败,请重试</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群文件</div>
|
||||
) : (
|
||||
<>
|
||||
{files.map((f) => {
|
||||
const style = fileTypeStyle(f.file_name, f.mime_type);
|
||||
// No `permissions` field exists on the real response (see groups.ts's own comment),
|
||||
// but the real delete authorization (confirmed against im_be/server/im_http.go's
|
||||
// serveIMGroupFiles) is uploader OR group admin/owner -- `isAdmin` is passed down from
|
||||
// GroupDetailColumn, which already resolves the caller's real role for this same group.
|
||||
const canDelete = isAdmin || (!!myUserId && f.uploader_id === myUserId);
|
||||
const pdf = isPdf(f);
|
||||
// 03-group.dc.html #1h draws three DIFFERENT trailing icons across its 3 example
|
||||
// rows: a plain `download` for a generic .docx, `open_in_new` for a .pdf (browsers
|
||||
// can render PDFs inline, so "open" is more useful than "save"), and `more_horiz` for
|
||||
// the row whose uploader is "我" (me) -- i.e. the icon is a function of BOTH file type
|
||||
// AND whether this is my own upload, not either one alone.
|
||||
return (
|
||||
<div key={f.group_file_id} className="relative flex items-center gap-1 rounded-lg hover:bg-surface-rail">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (pdf ? openFileInNewTab(f) : downloadFile(f))}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-3 py-2 text-left"
|
||||
>
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{f.file_name}</span>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{formatFileSize(f.file_size)} · {uploaderName(f.uploader_id)} · {formatEventTimestamp(new Date(f.created_at))}
|
||||
</span>
|
||||
</span>
|
||||
{canDelete ? null : <Icon name={pdf ? 'open_in_new' : 'download'} className="flex-none text-[20px] text-text-placeholder-2" />}
|
||||
</button>
|
||||
{canDelete ? (
|
||||
<FileMenu
|
||||
isPdf={pdf}
|
||||
open={openMenuId === f.group_file_id}
|
||||
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
|
||||
onClose={() => setOpenMenuId(null)}
|
||||
onDownload={() => downloadFile(f)}
|
||||
onOpen={() => openFileInNewTab(f)}
|
||||
onDelete={() => {
|
||||
setOpenMenuId(null);
|
||||
if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) deleteMutation.mutate(f.group_file_id);
|
||||
}}
|
||||
deleting={deleteMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filesQuery.hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => filesQuery.fetchNextPage()}
|
||||
disabled={filesQuery.isFetchingNextPage}
|
||||
className="w-full py-3 text-center text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{filesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toast message={downloadError} variant="error" onDone={() => setDownloadError('')} />
|
||||
<Toast message={actionError} variant="error" onDone={() => setActionError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The mock's `more_horiz` trailing icon for a file I uploaded, opening a small anchored menu with
|
||||
* the actions the plain trailing icon otherwise carries alone (预览/下载) plus 删除 -- no mock draws
|
||||
* the open menu itself (only the closed `more_horiz` icon), so the menu's own layout borrows the
|
||||
* small-popover convention already used for MessageContextMenu.tsx (white card, shadow, icon+label
|
||||
* rows, a divider before the destructive action). */
|
||||
function FileMenu({
|
||||
isPdf,
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
onDownload,
|
||||
onOpen,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: {
|
||||
isPdf: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
onDownload: () => void;
|
||||
onOpen: () => void;
|
||||
onDelete: () => void;
|
||||
deleting: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose, open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-none">
|
||||
<button type="button" aria-label="更多操作" onClick={onToggle} className="grid h-8 w-8 place-items-center rounded-md text-text-tertiary-3 hover:bg-white">
|
||||
<Icon name="more_horiz" className="text-[20px]" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg bg-white py-1 shadow-modal">
|
||||
{isPdf ? (
|
||||
<button type="button" onClick={onOpen} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
|
||||
<Icon name="open_in_new" className="text-[18px] text-text-tertiary-2" />
|
||||
预览
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onDownload} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
|
||||
<Icon name="download" className="text-[18px] text-text-tertiary-2" />
|
||||
下载
|
||||
</button>
|
||||
<div className="my-1 border-t border-border-list-item" />
|
||||
<button type="button" onClick={onDelete} disabled={deleting} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger-text disabled:opacity-50">
|
||||
<Icon name="delete_outline" className="text-[18px]" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getFriends } from '@/lib/api/friends';
|
||||
import { getAllGroupMembers, inviteGroupMembers, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
|
||||
// MessageRoleTag exactly (this file previously used filled `bg-*-bg` pills instead).
|
||||
const ROLE_CLASS: Record<GroupRole, string> = { owner: 'border border-warn text-warn', admin: 'border border-accent text-accent', member: '' };
|
||||
|
||||
function RoleTag({ role }: { role: GroupRole }) {
|
||||
if (role === 'member') return null;
|
||||
return <span className={`flex-none rounded-[3px] px-1 text-[10px] leading-[14px] ${ROLE_CLASS[role]}`}>{ROLE_LABEL[role]}</span>;
|
||||
}
|
||||
|
||||
interface GroupMembersModalProps {
|
||||
topicId: string;
|
||||
/** Whether the current viewer can invite -- owner/admin, OR any member when the group's
|
||||
* `governance.member_invite` toggle (GroupAdminModal's "邀请入群权限") is set to "所有成员".
|
||||
* Computed by the caller (GroupDetailColumn already has both `my_role` and `governance` in scope
|
||||
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
|
||||
* what silently made that governance toggle a no-op before this was wired through. */
|
||||
canInvite: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by
|
||||
* `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to
|
||||
* an existing group, not just at creation time). No "移出成员" affordance here: this protocol has
|
||||
* no documented REST or WS operation for removing a group member at all (grepped the whole
|
||||
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
|
||||
export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const members = membersQuery.data || [];
|
||||
const filtered = members.filter((m) => !query.trim() || (m.display_name || m.user_id).toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
const friendsQuery = useQuery({
|
||||
queryKey: ['friends'],
|
||||
queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)),
|
||||
enabled: inviting,
|
||||
});
|
||||
// README §4.7: friends already in the group stay VISIBLE, dimmed + labeled + unselectable -- not
|
||||
// silently filtered out (which used to make the list look shorter than the user's real friend
|
||||
// list with no explanation why some names were missing).
|
||||
const inviteCandidates = (friendsQuery.data || []).map((f) => ({ ...f, alreadyMember: members.some((m) => m.user_id === f.user_id) }));
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{inviting ? '邀请成员' : `群成员 ${members.length}`}</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
{!inviting && canInvite ? (
|
||||
<button type="button" onClick={() => setInviting(true)} className="text-sm text-accent">
|
||||
邀请
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={inviting ? () => setInviting(false) : onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name={inviting ? 'arrow_back' : 'close'} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-none px-4 pt-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={inviting ? '搜索联系人' : '搜索群成员'}
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{inviting ? (
|
||||
inviteCandidates
|
||||
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.map((f) => {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
disabled={inviteMutation.isPending || f.alreadyMember}
|
||||
onClick={() => inviteMutation.mutate(f.user_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
|
||||
f.alreadyMember ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
|
||||
{f.alreadyMember ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">已在群内</span>
|
||||
) : (
|
||||
<Icon name="person_add" className="text-[18px] text-accent" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : membersQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : (
|
||||
filtered.map((m) => (
|
||||
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
<RoleTag role={m.role} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { acceptGroupJoinRequest, getAllGroupMembers, getGroupJoinRequests, rejectGroupJoinRequest, type GroupJoinRequest } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
interface JoinRequestsModalProps {
|
||||
topicId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.6. Reject sends `{version}` ONLY -- no `reason` field, even though the protocol doc's
|
||||
* own example shows one (see lib/api/groups.ts's rejectGroupJoinRequest comment: the old app
|
||||
* confirmed live that the real server 400s on an unrecognized `reason` key).
|
||||
*/
|
||||
export function JoinRequestsModal({ topicId, onClose }: JoinRequestsModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['group-join-requests', topicId],
|
||||
// `'pending'`-only used to mean a request resolved before this session (or by another admin)
|
||||
// just never appeared here at all -- the render below already has a real "已同意/已拒绝" branch
|
||||
// keyed off the server's own `req.status`, it just never got fed anything but pending requests to
|
||||
// apply that branch to. README §4.6's own mock shows an already-resolved row persisting in the
|
||||
// list, matching `'all'`, not `'pending'`.
|
||||
queryFn: () => fetchAllPages<GroupJoinRequest>((cursor) => getGroupJoinRequests(topicId, 'all', cursor)),
|
||||
});
|
||||
// The applicant themself has no display name anywhere on the real response (see groups.ts's own
|
||||
// GroupJoinRequest comment) and isn't a member yet, so there's no list to resolve THEIR name from
|
||||
// either -- shown as a bare id, honestly, rather than guessed. The INVITER (mock: "王工 邀请") IS
|
||||
// already a member by definition, so that name can be resolved the same way GroupFilesModal's
|
||||
// uploader name and GroupAnnouncementModal's author name already do.
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const memberName = (userId: string) => membersQuery.data?.find((m) => m.user_id === userId)?.display_name || userId;
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
};
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: (req: GroupJoinRequest) => acceptGroupJoinRequest(topicId, req.request_id, req.version),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (req: GroupJoinRequest) => rejectGroupJoinRequest(topicId, req.request_id, req.version),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
|
||||
// Unlike accept, nothing here changes group membership -- but `group-summary`'s
|
||||
// `pending_join_request_count` badge (read by GroupAdminModal's "进群申请" row) does need to
|
||||
// drop by one, and this previously invalidated nothing at all, leaving that badge stuck high.
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[480px] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">进群申请</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !query.data?.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无进群申请</div>
|
||||
) : (
|
||||
query.data.map((req) => {
|
||||
const status = handled[req.request_id];
|
||||
return (
|
||||
<div key={req.request_id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.user_id)}`}>
|
||||
{avatarLetter(req.user_id)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{req.user_id}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{/* mock (#1c): "{邀请人} 邀请" when invited, else a source label -- NOT
|
||||
`source==='qr' ? ... : 邀请`, which used to show "邀请" for every
|
||||
non-qr source including a plain self-initiated search request that
|
||||
nobody actually invited (confirmed live: `source` really does take a
|
||||
`"search"` value distinct from an invite). `inviter_id` is the real
|
||||
signal for "was this an invite", not `source`. */}
|
||||
{req.inviter_id ? `${memberName(req.inviter_id)} 邀请` : req.source === 'qr' ? '扫码申请' : '申请加入'}
|
||||
{req.message ? ` · "${req.message}"` : ''} · {formatEventTimestamp(new Date(req.created_at))}
|
||||
</span>
|
||||
</span>
|
||||
{status || req.status !== 'pending' ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">
|
||||
{status === 'accepted' || req.status === 'accepted' ? '已同意' : '已拒绝'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex flex-none items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => acceptMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
同意
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,58 @@ export function FileAttachment({ data }: { data: FileData }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
preview?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
|
||||
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
|
||||
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
|
||||
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
|
||||
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
|
||||
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
|
||||
export function VideoAttachment({ data }: { data: VideoData }) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
|
||||
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
|
||||
const videoSrc = videoInline || remote.url;
|
||||
|
||||
if (playing && videoSrc) {
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
|
||||
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying(true)}
|
||||
disabled={!videoSrc}
|
||||
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
||||
>
|
||||
{posterInline ? (
|
||||
<img src={posterInline} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
{typeof data.duration === 'number' ? (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
@@ -81,23 +133,24 @@ interface AudioData {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function formatAudioDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playError, setPlayError] = useState(false);
|
||||
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return undefined;
|
||||
const onEnded = () => setPlaying(false);
|
||||
audioEl.addEventListener('ended', onEnded);
|
||||
return () => audioEl.removeEventListener('ended', onEnded);
|
||||
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
|
||||
// playing leaves it playing in the background with no control left on screen to stop it.
|
||||
return () => {
|
||||
audioEl.removeEventListener('ended', onEnded);
|
||||
audioEl.pause();
|
||||
};
|
||||
}, [audioEl]);
|
||||
|
||||
const toggle = () => {
|
||||
@@ -108,15 +161,23 @@ export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
return;
|
||||
}
|
||||
if (audioEl.src !== src) audioEl.src = src;
|
||||
audioEl.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
setPlayError(false);
|
||||
audioEl
|
||||
.play()
|
||||
.then(() => setPlaying(true))
|
||||
.catch(() => setPlayError(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
|
||||
<Icon name={playing ? 'pause_circle' : 'play_circle'} className="text-[24px] text-accent" />
|
||||
<Icon
|
||||
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
|
||||
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
|
||||
/>
|
||||
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
|
||||
<span className="flex-none text-xs text-text-secondary">{remote.loading ? '…' : formatAudioDuration(data.duration)}</span>
|
||||
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
|
||||
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getGroups } from '@/lib/api/groups';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||
import { formatListTime } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接
|
||||
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate
|
||||
* notifications table (not the topic itself) and 文件助手's own dedicated 3-column layout (README
|
||||
* §9.2) is Phase 8, not built yet -- so both route to the same SectionPlaceholder-backed "later
|
||||
* phase" destinations the rest of the nav already uses (contacts/favorites/calls/settings), rather
|
||||
* than a half-working ConversationView that assumes a regular P2P/group contact.
|
||||
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
|
||||
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
|
||||
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
|
||||
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
|
||||
* a route outside MessagesLayout entirely). `/files` redirects into a real
|
||||
* ConversationView on the `slf` topic instead -- README §9.2's own dedicated 3-column file browser
|
||||
* (categories, storage quota, upload progress) has no backing REST API at all (confirmed via a
|
||||
* research pass), so a plain conversation view is the honest choice over a quota card with invented
|
||||
* numbers.
|
||||
*/
|
||||
const PINNED_ENTRIES = [
|
||||
{
|
||||
@@ -24,7 +33,7 @@ const PINNED_ENTRIES = [
|
||||
badgeKey: 'system_unread_count' as const,
|
||||
label: '系统消息',
|
||||
icon: 'notifications',
|
||||
route: '/chat/system',
|
||||
route: '/messages/system',
|
||||
colorClass: 'bg-warn-bg text-warn',
|
||||
},
|
||||
{
|
||||
@@ -37,16 +46,32 @@ const PINNED_ENTRIES = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
|
||||
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
|
||||
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. */
|
||||
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
|
||||
* descending within each group. The pinned flag is read straight off the SDK's own ambient
|
||||
* `contact.private.pinned` (confirmed present in tinode-sdk.d.ts's `TopicDescPrivate`) rather than
|
||||
* a separate REST fetch per topic -- DetailColumn's "置顶聊天" toggle already writes through the
|
||||
* conversation-settings REST endpoint, and the server mirrors that same flag back onto the topic's
|
||||
* Tinode-level `private` field, so this is free (no extra request) instead of N per-topic fetches
|
||||
* just to render the list. */
|
||||
export function ChatListColumn() {
|
||||
const { topicId } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const tinode = useTinode();
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
// Shared with GroupListView's own `['groups','all']` query (same key) -- React Query dedupes the
|
||||
// two rather than this list issuing its own redundant fetch, and it's one bulk paginated call
|
||||
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
|
||||
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
|
||||
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
|
||||
const [query, setQuery] = useState('');
|
||||
const sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
const sorted = [...contacts].sort((a, b) => {
|
||||
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
|
||||
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
|
||||
});
|
||||
const trimmedQuery = query.trim();
|
||||
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
|
||||
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
|
||||
@@ -55,7 +80,11 @@ export function ChatListColumn() {
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
<header className="flex h-15 flex-none items-center justify-between px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
|
||||
<Icon name="add_comment" className="text-text-heading" />
|
||||
{/* README §5.4's 添加好友 entry point is Phase 4 (contacts), not built yet -- this only
|
||||
opens 创建群聊 (§4.7) for now, the one thing this icon can actually do today. */}
|
||||
<button type="button" onClick={() => setCreatingGroup(true)} className="grid h-8 w-8 place-items-center text-text-heading">
|
||||
<Icon name="add_comment" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
@@ -69,11 +98,17 @@ export function ChatListColumn() {
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{pinned.map((entry) => {
|
||||
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
|
||||
// `/files` redirects to `/messages/slf`, so its "currently open" check is keyed off the
|
||||
// resulting topicId rather than the route it navigates from, unlike `/messages/system`
|
||||
// which is the real, final path.
|
||||
const active = entry.route === '/files' ? topicId === 'slf' : location.pathname === entry.route;
|
||||
return (
|
||||
<Link
|
||||
key={entry.key}
|
||||
to={entry.route}
|
||||
className="grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item bg-white py-2.5 pl-4 pr-3 no-underline hover:bg-[#f6f8fb]"
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
|
||||
<Icon name={entry.icon} className="text-2xl" />
|
||||
@@ -101,11 +136,14 @@ export function ChatListColumn() {
|
||||
const name = contact.public?.fn || contact.topic;
|
||||
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||
const active = topicId === contact.topic;
|
||||
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
||||
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
|
||||
const muted = !!contact.private?.mute_notifications;
|
||||
return (
|
||||
<Link
|
||||
key={contact.topic}
|
||||
to={`/messages/${contact.topic}`}
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
@@ -120,12 +158,23 @@ export function ChatListColumn() {
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
|
||||
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
|
||||
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
|
||||
{typeof memberCount === 'number' ? (
|
||||
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{memberCount}人</span>
|
||||
) : null}
|
||||
</span>
|
||||
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
||||
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
|
||||
{contact.unread ? (
|
||||
{/* Same reference mock: a muted conversation's unread count is replaced by a small
|
||||
muted-bell glyph, not shown alongside or as a louder red badge -- the whole point
|
||||
of 消息免打扰 (DetailColumn's own toggle, which writes this same `private` field)
|
||||
is to stop this row from demanding attention the way an unmuted one does. */}
|
||||
{muted ? (
|
||||
contact.unread ? <Icon name="notifications_off" className="text-base text-text-tertiary-4" /> : null
|
||||
) : contact.unread ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{contact.unread}
|
||||
</b>
|
||||
@@ -136,6 +185,16 @@ export function ChatListColumn() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creatingGroup ? (
|
||||
<CreateGroupModal
|
||||
onClose={() => setCreatingGroup(false)}
|
||||
onCreated={(topic) => {
|
||||
setCreatingGroup(false);
|
||||
navigate(`/messages/${topic}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
@@ -8,28 +9,35 @@ import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { EmojiPanel } from '@/features/messages/EmojiPanel';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
import { MentionPicker } from '@/features/messages/MentionPicker';
|
||||
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
||||
import { MessageRow } from '@/features/messages/MessageBubble';
|
||||
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
|
||||
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
|
||||
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||
import { useClickOutside } from '@/lib/hooks';
|
||||
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||
import { isRecalled } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
||||
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface OutletCtx {
|
||||
onToggleDetail: () => void;
|
||||
/** Whether the detail panel is CURRENTLY shown -- true always at >=1280px (docked, no close
|
||||
* affordance), true/false at 960-1279px depending on the drawer's open state. Drives the header
|
||||
* "i" button's highlight; previously hardcoded to the breakpoint itself (always blue at
|
||||
* 960-1279px regardless of the drawer's real state, always grey at >=1280px despite the panel
|
||||
* being permanently open there) rather than this actual state. */
|
||||
/** Whether the detail panel is CURRENTLY shown -- at >=1280px this is the persisted
|
||||
* (localStorage) docked-panel state, at 960-1279px the temporary drawer state. Drives the
|
||||
* header "i" button's highlight. */
|
||||
detailVisible: boolean;
|
||||
/** Opens the shared history-search modal (owned by MessagesLayout, the closest common ancestor of
|
||||
* this view and DetailColumn, which can also open it from its own "查找聊天记录" row). */
|
||||
@@ -42,6 +50,10 @@ interface ContextMenuState {
|
||||
msg: StoredMessage;
|
||||
}
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function ConversationView() {
|
||||
const { topicId } = useParams();
|
||||
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
|
||||
@@ -60,6 +72,15 @@ export function ConversationView() {
|
||||
const setDraft = (value: string) => {
|
||||
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
|
||||
};
|
||||
// Same per-topic keying as draftByTopic, and for the same reason -- a mention picked while
|
||||
// composing in group A must not still be "pending" if the user switches to group B and back
|
||||
// before sending (buildMentionedContent would then splice a real MN entity for a name that's
|
||||
// just inert leftover text in a completely different conversation's draft).
|
||||
const [mentionsByTopic, setMentionsByTopic] = useState<Record<string, PendingMention[]>>({});
|
||||
const pendingMentions = (topicId && mentionsByTopic[topicId]) || [];
|
||||
const [mentionTrigger, setMentionTrigger] = useState<{ start: number; query: string } | null>(null);
|
||||
const [groupModal, setGroupModal] = useState<'announcement' | 'join-requests' | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
||||
@@ -69,9 +90,22 @@ export function ConversationView() {
|
||||
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
||||
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const emojiContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mentionContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Neither popover had a way to dismiss except re-clicking its own toggle -- a real gap a user
|
||||
// report caught (open the emoji panel, click elsewhere in the app, and it just sits there).
|
||||
useClickOutside(emojiContainerRef, () => setEmojiOpen(false), emojiOpen);
|
||||
useClickOutside(mentionContainerRef, () => setMentionTrigger(null), !!mentionTrigger);
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const [successToast, setSuccessToast] = useState('');
|
||||
const streamRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
// "Jump to message" (HistorySearchModal result click, via stores/messageJump.ts). `highlightedSeq`
|
||||
// drives MessageRow's brief flash; `pendingJumpSeqRef` is a ref (not state) because it's read/written
|
||||
// across two different effects below without needing a render of its own.
|
||||
const jumpTarget = useMessageJumpStore((s) => s.target);
|
||||
const [highlightedSeq, setHighlightedSeq] = useState<number | null>(null);
|
||||
const pendingJumpSeqRef = useRef<number | null>(null);
|
||||
// Bumped every time a conversation is (re)opened below -- NOT the same as comparing topicId by
|
||||
// value, which would false-positive if the user opens A, then B, then A again within a single
|
||||
// loadOlder() round trip: topicId ends up back at "A" even though the DOM has since been torn
|
||||
@@ -85,6 +119,15 @@ export function ConversationView() {
|
||||
// over-size-limit attachment would throw a friendly Chinese error from uploadFile() that then
|
||||
// just vanished, leaving the user staring at nothing happening with no idea why.
|
||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||
// docs/IM-前后端对接文档.md §4.16: "成功 Toast 只在至少一项成功时展示,部分失败需给出汇总" -- this
|
||||
// response's `created_count`/`existing_count`/`failed[]` were being resolved and thrown away
|
||||
// entirely, so favoriting from a message's context menu or the multi-select bar gave the user
|
||||
// total silence either way: no confirmation on full success, and no way to learn that some of
|
||||
// several selected messages weren't actually favorited.
|
||||
const notifyFavoriteResult = (result: { created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }) => {
|
||||
if (result.created_count + result.existing_count > 0) setSuccessToast('已收藏');
|
||||
if (result.failed.length) setErrorToast(`${result.failed.length} 条消息收藏失败`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId) return undefined;
|
||||
@@ -107,6 +150,11 @@ export function ConversationView() {
|
||||
setForwardMsg(null);
|
||||
setBulkForwardOpen(false);
|
||||
setEmojiOpen(false);
|
||||
// Without this, a mention picker left open while typing in group A reappears in group B after a
|
||||
// fast conversation switch (isGroup is true there too), anchored to a caret offset that describes
|
||||
// A's draft, not B's -- picking a suggestion would splice "@Name" into the wrong text at the wrong
|
||||
// position.
|
||||
setMentionTrigger(null);
|
||||
// Unmounting VoiceRecorder stops its media stream via its own cleanup effect -- without this,
|
||||
// a recording started in conversation A silently keeps going after navigating to B, and its
|
||||
// onSend callback (re-created every render off the CURRENT topicId) would publish the eventual
|
||||
@@ -121,6 +169,14 @@ export function ConversationView() {
|
||||
// An error toast from the previous conversation (e.g. "图片发送失败") would otherwise keep
|
||||
// showing over whichever conversation is now open, misattributing the failure.
|
||||
setErrorToast('');
|
||||
setSuccessToast('');
|
||||
// A jump-to-message request still pending when the user switches away (its fetch was in flight,
|
||||
// or it resolved but the scroll-on-mount effect hadn't fired yet) would otherwise survive into
|
||||
// the NEW conversation and, once ITS messages populate, try to scroll+highlight whatever row in
|
||||
// B happens to share the SAME seq number the user searched for in A -- seq is per-topic, so a
|
||||
// collision across two different conversations is completely ordinary, not a rare edge case.
|
||||
pendingJumpSeqRef.current = null;
|
||||
setHighlightedSeq(null);
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,15 +188,99 @@ export function ConversationView() {
|
||||
// still scrolls the newly-opened one to the bottom -- `messages.length` alone wouldn't change.
|
||||
}, [messages.length, topicId]);
|
||||
|
||||
// Consumes a pending "jump to message" request (HistorySearchModal result click). Fetches directly
|
||||
// via `since`/`before` (arbitrary absolute seq bounds per the Tinode wire protocol, not required to
|
||||
// be adjacent to whatever's already cached) rather than routing through loadOlder()'s gap-fill --
|
||||
// that machinery is scoped to backfilling holes within the currently-visible min/max span, not
|
||||
// "there's an entirely separate old cluster somewhere else the user wants to see right now".
|
||||
useEffect(() => {
|
||||
if (!jumpTarget || jumpTarget.topic !== topicId) return;
|
||||
useMessageJumpStore.getState().clear();
|
||||
const seq = jumpTarget.seq;
|
||||
pendingJumpSeqRef.current = seq;
|
||||
if (messages.some((m) => m.seq === seq)) return; // already loaded -- the effect below scrolls to it
|
||||
autoScrollRef.current = false; // otherwise the scroll-to-bottom effect above fights this jump
|
||||
const t = tinode.getTopic(topicId);
|
||||
const half = Math.floor(MESSAGES_PAGE / 2);
|
||||
t.getMeta(t.startMetaQuery().withData(Math.max(1, seq - half), seq + half + 1, MESSAGES_PAGE).build()).catch(() => {
|
||||
pendingJumpSeqRef.current = null;
|
||||
showError(null, '未能加载该消息,可能已被删除');
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jumpTarget, topicId]);
|
||||
|
||||
// Performs the actual scroll+highlight once the target row exists in the DOM -- covers both the
|
||||
// "already loaded" case (this and the effect above run in the same commit, so the ref is already
|
||||
// set by the time this checks) and the "had to fetch first" case (re-fires once `messages` picks up
|
||||
// the newly-arrived data via useTopicMessages' own subscription, no manual wiring needed here).
|
||||
useEffect(() => {
|
||||
const seq = pendingJumpSeqRef.current;
|
||||
if (seq == null) return;
|
||||
const el = streamRef.current?.querySelector(`[data-seq="${seq}"]`);
|
||||
if (!el) return;
|
||||
pendingJumpSeqRef.current = null;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
setHighlightedSeq(seq);
|
||||
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
|
||||
}, [messages, jumpTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId || messages.length === 0) return;
|
||||
tinode.getTopic(topicId).noteRead(0);
|
||||
}, [topicId, messages.length, tinode]);
|
||||
|
||||
// Computed independently of `topic` below (not `topic.isGroupType()`) so this and the group-data
|
||||
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
|
||||
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
|
||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const groupProfileQuery = useQuery({
|
||||
queryKey: ['group', topicId],
|
||||
queryFn: () => getGroup(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const groupSummaryQuery = useQuery({
|
||||
queryKey: ['group-summary', topicId],
|
||||
queryFn: () => getGroupSummary(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const governanceQuery = useQuery({
|
||||
queryKey: ['group-governance', topicId],
|
||||
queryFn: () => getGroupGovernance(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const mutesQuery = useQuery({
|
||||
queryKey: ['group-mutes', topicId],
|
||||
queryFn: () => getGroupMutes(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
||||
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
|
||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||
const groupSublineParts = [
|
||||
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
|
||||
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!topicId) return null;
|
||||
const topic = tinode.getTopic(topicId);
|
||||
const peerName = contact?.public?.fn || topicId;
|
||||
const isGroup = topic.isGroupType();
|
||||
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
|
||||
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
|
||||
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
|
||||
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
|
||||
// regardless of who actually sent the quoted message. `memberByUserId` already resolves this
|
||||
// correctly per-bubble (senderName below); reused here for the identical lookup rather than a
|
||||
// second, divergent resolution path.
|
||||
const quotedSenderLabel = (fromUserId: string | undefined) => {
|
||||
if (myUserId && fromUserId === myUserId) return '我';
|
||||
if (isGroup && fromUserId) return memberByUserId.get(fromUserId)?.display_name || fromUserId;
|
||||
return peerName;
|
||||
};
|
||||
|
||||
/**
|
||||
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
|
||||
@@ -190,16 +330,92 @@ export function ConversationView() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setDraft('');
|
||||
setMentionTrigger(null);
|
||||
if (replyTo) {
|
||||
const label = myUserId && replyTo.from === myUserId ? '我' : peerName;
|
||||
sendReply(topic, text, replyTo, label);
|
||||
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that also
|
||||
// pings someone is a rare enough compound case that it isn't worth a second stitching path;
|
||||
// the "@name" text still sends fine, just without the entity/highlight.
|
||||
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
|
||||
setReplyTo(null);
|
||||
} else if (pendingMentions.length > 0) {
|
||||
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
|
||||
} else {
|
||||
sendText(topic, text);
|
||||
sendText(topic, text).catch((err) => showError(err, '发送失败'));
|
||||
}
|
||||
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
|
||||
};
|
||||
|
||||
const handleDraftChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setDraft(value);
|
||||
if (isGroup) setMentionTrigger(detectMentionTrigger(value, e.target.selectionStart ?? value.length));
|
||||
};
|
||||
|
||||
const pickMention = (choice: PendingMention) => {
|
||||
if (!mentionTrigger || !topicId) return;
|
||||
const before = draft.slice(0, mentionTrigger.start);
|
||||
const after = draft.slice(mentionTrigger.start + 1 + mentionTrigger.query.length);
|
||||
const inserted = `${before}@${choice.label} `;
|
||||
setDraft(inserted + after);
|
||||
setMentionsByTopic((prev) => ({ ...prev, [topicId]: [...(prev[topicId] || []), choice] }));
|
||||
setMentionTrigger(null);
|
||||
setEmojiOpen(false);
|
||||
// Matches insertMentionTrigger's own restore below -- without an explicit setSelectionRange
|
||||
// (deferred a frame so the textarea's DOM value has actually updated to `inserted + after`
|
||||
// first), the cursor doesn't reliably land right after the just-inserted "@Name ", making it
|
||||
// awkward to keep typing; a bare focus() alone doesn't move the caret to a specific offset.
|
||||
const el = textareaRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(inserted.length, inserted.length);
|
||||
});
|
||||
};
|
||||
|
||||
/** README §4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */
|
||||
const insertMentionTrigger = () => {
|
||||
const el = textareaRef.current;
|
||||
const pos = el?.selectionStart ?? draft.length;
|
||||
const needsSpace = pos > 0 && !/\s/.test(draft[pos - 1] ?? ' ');
|
||||
const insertion = `${needsSpace ? ' ' : ''}@`;
|
||||
const next = draft.slice(0, pos) + insertion + draft.slice(pos);
|
||||
setDraft(next);
|
||||
setMentionTrigger({ start: pos + insertion.length - 1, query: '' });
|
||||
setEmojiOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape' && mentionTrigger) {
|
||||
setMentionTrigger(null);
|
||||
return;
|
||||
}
|
||||
// A picked mention should delete as one atomic "@label" unit on Backspace, not character by
|
||||
// character (README §4.3 doesn't spell this out explicitly, but every real chat app treats an
|
||||
// inserted mention this way, and a user report confirmed the plain-textarea default -- delete
|
||||
// one character at a time -- read as broken here specifically). Only intercepts a plain caret
|
||||
// (no active selection) sitting right after one of THIS topic's actually-tracked mentions, so
|
||||
// an unrelated hand-typed "foo@bar" is untouched.
|
||||
if (e.key === 'Backspace' && topicId && pendingMentions.length > 0) {
|
||||
const el = e.currentTarget;
|
||||
if (el.selectionStart === el.selectionEnd) {
|
||||
const pos = el.selectionStart;
|
||||
const before = draft.slice(0, pos);
|
||||
const hit = pendingMentions.find((m) => before.endsWith(`@${m.label} `) || before.endsWith(`@${m.label}`));
|
||||
if (hit) {
|
||||
const token = before.endsWith(`@${hit.label} `) ? `@${hit.label} ` : `@${hit.label}`;
|
||||
const newBefore = before.slice(0, before.length - token.length);
|
||||
const next = newBefore + draft.slice(pos);
|
||||
e.preventDefault();
|
||||
setDraft(next);
|
||||
setMentionsByTopic((prev) => ({ ...prev, [topicId]: (prev[topicId] || []).filter((m) => m !== hit) }));
|
||||
requestAnimationFrame(() => el.setSelectionRange(newBefore.length, newBefore.length));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key !== 'Enter' || e.shiftKey) return;
|
||||
// Guards against CJK IME candidate-selection Enter keystrokes being mistaken for submit --
|
||||
// a real, unfixed bug class in the old app (confirmed by its own research: zero isComposing/
|
||||
@@ -232,15 +448,37 @@ export function ConversationView() {
|
||||
|
||||
const bulkFavorite = () => {
|
||||
if (selectedSeqs.size === 0) return;
|
||||
favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch((err) => showError(err, '收藏失败'));
|
||||
// Same recalled-mid-session race as bulkForward above -- a message favorited with null content
|
||||
// would just be a useless empty entry in 我的收藏 with no way to tell what it used to say.
|
||||
const seqs = [...selectedSeqs].filter((seq) => {
|
||||
const msg = topic.findMessage(seq);
|
||||
return !msg || !isRecalled(msg);
|
||||
});
|
||||
if (seqs.length) {
|
||||
favoriteMessages(seqs.map((seq) => ({ topic: topicId, seq })))
|
||||
.then(notifyFavoriteResult)
|
||||
.catch((err) => showError(err, '收藏失败'));
|
||||
}
|
||||
exitSelectMode();
|
||||
};
|
||||
|
||||
const bulkForward = (targetTopic: string) => {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
for (const seq of selectedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
if (msg) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
const bulkForward = (targetTopics: string[], comment: string) => {
|
||||
// `selectedSeqs` is a Set, which iterates in INSERTION (click) order, not numeric order -- a
|
||||
// user who happened to click message #10 before #3 would otherwise forward them in that click
|
||||
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
|
||||
// actually said first.
|
||||
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
||||
for (const targetTopic of targetTopics) {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
for (const seq of orderedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox
|
||||
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
|
||||
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
|
||||
// with a misleading "forwarded from" tag rather than the message the user actually picked.
|
||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
}
|
||||
setBulkForwardOpen(false);
|
||||
exitSelectMode();
|
||||
@@ -270,7 +508,9 @@ export function ConversationView() {
|
||||
setReplyTo(msg);
|
||||
break;
|
||||
case 'favorite':
|
||||
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch((err) => showError(err, '收藏失败'));
|
||||
favoriteMessages([{ topic: topicId, seq: msg.seq }])
|
||||
.then(notifyFavoriteResult)
|
||||
.catch((err) => showError(err, '收藏失败'));
|
||||
break;
|
||||
case 'forward':
|
||||
setForwardMsg(msg);
|
||||
@@ -312,19 +552,42 @@ export function ConversationView() {
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
|
||||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||
{contact?.online ? '在线' : '离线'}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">
|
||||
{peerName}
|
||||
{isGroup ? <span className="ml-1 font-normal text-text-tertiary-2">({groupMemberCount})</span> : null}
|
||||
</strong>
|
||||
{isGroup ? (
|
||||
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
|
||||
) : (
|
||||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||
{contact?.online ? '在线' : '离线'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
|
||||
<Icon name="call" />
|
||||
</button>
|
||||
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
|
||||
<Icon name="videocam" />
|
||||
</button>
|
||||
{isGroup ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
|
||||
}}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="call" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
|
||||
}}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="videocam" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenHistory('text')}
|
||||
@@ -344,27 +607,56 @@ export function ConversationView() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
{isGroup && groupSummaryQuery.data?.announcement?.content ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroupModal('announcement')}
|
||||
className="flex items-center gap-2 border-b border-[#eceef1] bg-[#fff8e6] px-5 py-2.5 text-left text-[13px] text-[#6d4c41]"
|
||||
>
|
||||
<Icon name="campaign" className="flex-none text-[18px] text-announce" />
|
||||
<strong className="flex-none font-semibold">群公告</strong>
|
||||
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.announcement.content}</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-[#c2a26c]" />
|
||||
</button>
|
||||
) : null}
|
||||
{isGroup && isGroupAdmin && !!groupSummaryQuery.data?.pending_join_request_count ? (
|
||||
<div className="flex items-center gap-2 border-b border-[#eceef1] bg-[#e8f1fe] px-5 py-2.5 text-[13px] text-[#1a4f9c]">
|
||||
<Icon name="person_add" className="flex-none text-[18px] text-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.pending_join_request_count} 条新的进群申请</span>
|
||||
<button type="button" onClick={() => setGroupModal('join-requests')} className="flex-none font-medium text-accent">
|
||||
去处理
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={streamRef} onScroll={handleScroll} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
||||
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||||
{messages.map((msg) => (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
topic={topic}
|
||||
msg={msg}
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||
onToggleSelect={toggleSelect}
|
||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||
/>
|
||||
))}
|
||||
{messages.map((msg) => {
|
||||
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
topic={topic}
|
||||
msg={msg}
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
senderName={sender?.display_name}
|
||||
senderRole={sender?.role}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
|
||||
onToggleSelect={toggleSelect}
|
||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||
onError={setErrorToast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectMode ? (
|
||||
<div className="flex h-18 flex-none items-center justify-around border-t border-border-panel bg-white">
|
||||
<div className="flex h-18 flex-none items-center justify-center gap-10 border-t border-border-panel bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkForwardOpen(true)}
|
||||
@@ -389,52 +681,83 @@ export function ConversationView() {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
{replyTo ? (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
|
||||
<span className="truncate">
|
||||
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
|
||||
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||||
<Icon name="close" className="text-[16px]" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative flex items-center gap-1 px-1 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmojiOpen((v) => !v)}
|
||||
className={`grid h-8.5 w-8.5 place-items-center rounded text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default'}`}
|
||||
>
|
||||
<Icon name="sentiment_satisfied_alt" />
|
||||
</button>
|
||||
{emojiOpen ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<EmojiPanel
|
||||
onPick={(emoji) => {
|
||||
setDraft(draft + emoji);
|
||||
setEmojiOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||||
<div className="relative flex items-center gap-0.5 px-1 pb-1">
|
||||
<div ref={emojiContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmojiOpen((v) => !v);
|
||||
setMentionTrigger(null);
|
||||
}}
|
||||
className={`grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default hover:bg-[#f2f4f7]'}`}
|
||||
>
|
||||
<Icon name="sentiment_satisfied_alt" />
|
||||
</button>
|
||||
{emojiOpen ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<EmojiPanel
|
||||
onPick={(emoji) => {
|
||||
setDraft(draft + emoji);
|
||||
setEmojiOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
|
||||
<Icon name="image" />
|
||||
<input type="file" accept="image/*" onChange={handlePickImage} className="hidden" />
|
||||
</label>
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
|
||||
<Icon name="attach_file" />
|
||||
<input type="file" onChange={handlePickFile} className="hidden" />
|
||||
</label>
|
||||
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={notImplemented}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="contact_page" />
|
||||
</button>
|
||||
{isGroup ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertMentionTrigger}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="alternate_email" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecording(true)}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default"
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="mic_none" />
|
||||
</button>
|
||||
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenHistory('text')}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="history" />
|
||||
</button>
|
||||
{mentionTrigger && isGroup ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<MentionPicker topicId={topicId} query={mentionTrigger.query} onPick={pickMention} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{recording ? (
|
||||
<VoiceRecorder
|
||||
@@ -446,19 +769,25 @@ export function ConversationView() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
{/* textarea + send button must share one flex row (design: 02-messages.dc.html's
|
||||
`display:flex; align-items:flex-end; gap:12px`) -- they were previously two block-level
|
||||
siblings with no shared flex parent (the actual parent only had `flex-none`, a flex-CHILD
|
||||
property that does nothing to make ITSELF a flex container), so the button fell to its
|
||||
own line below the textarea instead of sitting beside it. */}
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
className="h-9 flex-none rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
@@ -482,8 +811,12 @@ export function ConversationView() {
|
||||
{forwardMsg ? (
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwardMsg(null)}
|
||||
onPick={(targetTopic) => {
|
||||
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic)).catch((err) => showError(err, '转发失败'));
|
||||
onPick={(targetTopics, comment) => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
const target = tinode.getTopic(targetTopic);
|
||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
}
|
||||
setForwardMsg(null);
|
||||
}}
|
||||
/>
|
||||
@@ -491,7 +824,13 @@ export function ConversationView() {
|
||||
|
||||
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
|
||||
|
||||
{groupModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isGroupAdmin} onClose={() => setGroupModal(null)} />
|
||||
) : null}
|
||||
{groupModal === 'join-requests' ? <JoinRequestsModal topicId={topicId} onClose={() => setGroupModal(null)} /> : null}
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
<Toast message={successToast} variant="success" onDone={() => setSuccessToast('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
interface DetailColumnProps {
|
||||
topicId: string;
|
||||
@@ -36,10 +37,13 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
// README §3.4: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
|
||||
// (18×18 knob) -- same fix as GroupDetailColumn.tsx's identical Toggle copy. The travel
|
||||
// distance stays 18px at both sizes since track and knob grow by the same 4px.
|
||||
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[20px]' : 'translate-x-0.5'}`}
|
||||
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -128,8 +132,8 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[320px] lg:w-[300px]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold">聊天设置</strong>
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
@@ -138,7 +142,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col items-center gap-1 border-b border-list-item px-5 pb-4 pt-6">
|
||||
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
|
||||
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
@@ -156,18 +160,32 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
<Icon name="chat_bubble_outline" className="text-[20px]" />
|
||||
<span className="text-xs text-text-secondary">发消息</span>
|
||||
</button>
|
||||
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
|
||||
<button
|
||||
type="button"
|
||||
disabled={profileQuery.data?.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
|
||||
}}
|
||||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="call" className="text-[20px]" />
|
||||
<span className="text-xs text-text-secondary">语音</span>
|
||||
</button>
|
||||
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
|
||||
<button
|
||||
type="button"
|
||||
disabled={profileQuery.data?.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
|
||||
}}
|
||||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="videocam" className="text-[20px]" />
|
||||
<span className="text-xs text-text-secondary">视频</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-list-item px-5 pb-3 pt-2 text-sm">
|
||||
<div className="border-b border-border-list-item px-5 pb-3 pt-2 text-sm">
|
||||
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
|
||||
<span className="text-text-body">查找聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
@@ -189,7 +207,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border-b border-list-item px-5 text-sm">
|
||||
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
|
||||
{remarkEditing ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">备注</span>
|
||||
|
||||
@@ -5,27 +5,52 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 200;
|
||||
|
||||
interface ForwardPickerModalProps {
|
||||
onPick: (targetTopic: string) => void;
|
||||
onPick: (targetTopics: string[], comment: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified single-target contact picker for the message context menu's "转发". The full design
|
||||
* (README §3.6) supports multi-select + a merge/separate-forward toggle for the multi-select bulk
|
||||
* bar's forward action -- this is the smaller single-message version, one tap sends immediately.
|
||||
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
|
||||
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
|
||||
* forward -- all three previously shared this component when it only supported picking ONE target
|
||||
* and sending immediately with no comment, meaning forwarding to several people required repeating
|
||||
* the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts
|
||||
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
|
||||
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
|
||||
* so callers loop `targetTopics` themselves -- this component only collects the selection.
|
||||
*/
|
||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||
const contacts = useChatList();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [comment, setComment] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
||||
|
||||
const toggle = (topic: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(topic)) next.delete(topic);
|
||||
else next.add(topic);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (selected.size === 0) return;
|
||||
onPick([...selected], comment.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">转发到</strong>
|
||||
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">
|
||||
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
|
||||
</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
@@ -43,21 +68,46 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{filtered.map((c) => (
|
||||
<button
|
||||
key={c.topic}
|
||||
type="button"
|
||||
onClick={() => c.topic && onPick(c.topic)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
|
||||
{filtered.map((c) => {
|
||||
const isSelected = !!c.topic && selected.has(c.topic);
|
||||
return (
|
||||
<button
|
||||
key={c.topic}
|
||||
type="button"
|
||||
onClick={() => c.topic && toggle(c.topic)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
{avatarLetter(c.public?.fn)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
|
||||
</button>
|
||||
))}
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(c.public?.fn)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
|
||||
>
|
||||
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-none border-t border-border-list-item p-3">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
|
||||
rows={2}
|
||||
placeholder="附言(选填)"
|
||||
className="w-full resize-none rounded-lg bg-surface-input p-2.5 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={selected.size === 0}
|
||||
className="mt-2 h-9 w-full rounded-lg bg-btn text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
发送{selected.size ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getAllGroupMembers } from '@/lib/api/groups';
|
||||
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
type Tab = 'text' | MediaType;
|
||||
|
||||
@@ -55,22 +59,17 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
|
||||
}
|
||||
|
||||
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
|
||||
* formatter regular chat bubbles use -- `snapshot.content` is a real, complete Drafty document
|
||||
* (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this
|
||||
* server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than
|
||||
* picking fields back out of it by hand. */
|
||||
function SnapshotContent({ item }: { item: SearchHit }) {
|
||||
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
|
||||
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
|
||||
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
|
||||
* through rather than picking fields back out of it by hand. The previous version's manual
|
||||
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
||||
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
||||
* search against a real plain-text message showed before this fix. */
|
||||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||
const content = item.snapshot?.content;
|
||||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||
return (
|
||||
<div className="text-sm text-text-body">
|
||||
{renderDrafty({
|
||||
txt: content.txt || ' ',
|
||||
fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }],
|
||||
ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record<string, unknown> })),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,14 +130,40 @@ interface HistorySearchModalProps {
|
||||
topicId: string;
|
||||
initialTab?: Tab;
|
||||
onClose: () => void;
|
||||
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
|
||||
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
|
||||
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
|
||||
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
}
|
||||
|
||||
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
|
||||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: HistorySearchModalProps) {
|
||||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
useEscapeKey(onClose);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId),
|
||||
enabled: isGroup,
|
||||
});
|
||||
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
|
||||
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
|
||||
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
|
||||
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
|
||||
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
|
||||
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
|
||||
// prefix removal, not a lossy truncation.
|
||||
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
|
||||
const senderLabel = (from: string): string => {
|
||||
const full = normalizeSender(from);
|
||||
if (myUserId && full === myUserId) return '我';
|
||||
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
|
||||
return peerName;
|
||||
};
|
||||
|
||||
const searchQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-search', topicId, debouncedQuery],
|
||||
@@ -159,31 +184,72 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const fileLinkQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-search-media', topicId, tab],
|
||||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, '', tab as 'file' | 'link', pageParam),
|
||||
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
|
||||
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
|
||||
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
|
||||
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
|
||||
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
|
||||
// header element sitting above all four tabs, not a 消息-tab-only control.
|
||||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
enabled: tab === 'file' || tab === 'link',
|
||||
});
|
||||
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
|
||||
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
|
||||
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
|
||||
// hands off the target and gets out of the way.
|
||||
const jumpToMessage = (seq: number) => {
|
||||
useMessageJumpStore.getState().requestJump(topicId, seq);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[620px] w-[720px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
|
||||
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
|
||||
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
|
||||
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
|
||||
// exactly as before.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
|
||||
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
|
||||
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||||
disabled={tab === 'image' || tab === 'video'}
|
||||
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{query ? (
|
||||
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
|
||||
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
|
||||
取消
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-none gap-1 border-b border-list-item px-5 pt-3">
|
||||
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-1.5 rounded-t-lg px-3 py-2 text-sm ${
|
||||
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
|
||||
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
@@ -193,40 +259,69 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-none px-5 py-3 md:block">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||||
disabled={tab === 'image' || tab === 'video'}
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'text' ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-none px-5 py-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索聊天记录"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||||
{!debouncedQuery.trim() ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
|
||||
) : searchQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||||
) : searchQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>搜索失败,请重试</span>
|
||||
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !searchItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{searchItems.map((item) => (
|
||||
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||
<span>{formatEventTimestamp(new Date(item.ts))}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate">
|
||||
<SnapshotContent item={item} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{searchItems.map((item) => {
|
||||
const fullFrom = normalizeSender(item.from);
|
||||
const name = senderLabel(item.from);
|
||||
return (
|
||||
<li key={item.seq}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => jumpToMessage(item.seq)}
|
||||
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
|
||||
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||
<span className="truncate text-text-tertiary-2">{name}</span>
|
||||
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{searchQuery.hasNextPage ? (
|
||||
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
|
||||
@@ -239,6 +334,13 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{mediaQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : mediaQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !mediaItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
@@ -258,13 +360,24 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{fileLinkQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : fileLinkQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !fileLinkItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{fileLinkItems.map((item) =>
|
||||
tab === 'link' ? <LinkResult key={item.seq} item={item} /> : <SnapshotContent key={item.seq} item={item} />,
|
||||
tab === 'link' ? (
|
||||
<LinkResult key={item.seq} item={item} />
|
||||
) : (
|
||||
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{fileLinkQuery.hasNextPage ? (
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getGroupMembers, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useDebouncedValue } from '@/lib/hooks';
|
||||
import { MENTION_ALL_SENTINEL, type PendingMention } from '@/lib/messages/mentions';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
|
||||
interface MentionPickerProps {
|
||||
topicId: string;
|
||||
query: string;
|
||||
onPick: (choice: PendingMention) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计稿 4.3:输入 `@` 触发的 260px 浮层。候选来自 `GET .../members?query=&mentionable=true`
|
||||
* (协议文档 1052/1735 行)-- 这一个响应同时带 `items`(已经是可提及成员,不用再拿全量列表自己过滤)
|
||||
* 和 `permissions.can_mention_all`,比向 ConversationView 另外要这两样数据都便宜。"所有人"是虚拟项,
|
||||
* 不对应真实成员,只在 `can_mention_all` 为真时出现在首位。
|
||||
*/
|
||||
export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
|
||||
const debouncedQuery = useDebouncedValue(query, 200);
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-mentionable-members', topicId, debouncedQuery],
|
||||
queryFn: () => getGroupMembers(topicId, { query: debouncedQuery, mentionable: true }),
|
||||
});
|
||||
const items = membersQuery.data?.items || [];
|
||||
const canMentionAll = !!membersQuery.data?.permissions?.can_mention_all;
|
||||
const showAll = canMentionAll && (!query || '所有人'.includes(query));
|
||||
|
||||
if (!showAll && items.length === 0 && !membersQuery.isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="w-[260px] overflow-hidden rounded-[10px] border border-border-panel bg-white py-1 shadow-menu">
|
||||
{membersQuery.isLoading ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-text-tertiary-4">加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
{showAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: MENTION_ALL_SENTINEL, label: '所有人' })}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
||||
>
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-accent text-white">
|
||||
<Icon name="group" className="text-[16px]" />
|
||||
</span>
|
||||
<span className="text-sm text-text-body">所有人</span>
|
||||
</button>
|
||||
) : null}
|
||||
{items.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
||||
>
|
||||
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
{m.role !== 'member' ? (
|
||||
<span
|
||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
|
||||
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
|
||||
}`}
|
||||
>
|
||||
{ROLE_LABEL[m.role]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,68 @@
|
||||
import type { StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { isRecalled, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
||||
import { retryMessage } from '@/lib/tinode/send';
|
||||
|
||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
|
||||
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
|
||||
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
|
||||
* the real profile page, the one thing a contact-card message is fundamentally for. */
|
||||
function ContactCardBubble({ content }: { content: ContactCardContent }) {
|
||||
const name = content.display_name || content.public_account || content.user_id;
|
||||
return (
|
||||
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
||||
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
|
||||
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
|
||||
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MessageRowProps {
|
||||
topic: Topic;
|
||||
msg: StoredMessage;
|
||||
mine: boolean;
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
|
||||
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
|
||||
* for per-message attribution once there's more than one possible sender. */
|
||||
senderName?: string;
|
||||
senderRole?: 'owner' | 'admin' | 'member';
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
||||
* context menu's target. */
|
||||
menuActive?: boolean;
|
||||
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
|
||||
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
|
||||
* open outline, since the two states mean different things (context menu open vs. "you asked to find
|
||||
* this one") and can't reuse the same visual without conflating them. */
|
||||
highlighted?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
|
||||
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
|
||||
* even made. */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function MessageRow({
|
||||
@@ -29,20 +71,28 @@ export function MessageRow({
|
||||
mine,
|
||||
isGroup,
|
||||
peerName,
|
||||
senderName,
|
||||
senderRole,
|
||||
selectMode,
|
||||
selected,
|
||||
menuActive,
|
||||
highlighted,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
onError,
|
||||
}: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
|
||||
return (
|
||||
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
|
||||
此消息已被删除
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noticeText = systemNoticeText(msg);
|
||||
if (noticeText) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 py-1">
|
||||
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
|
||||
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
|
||||
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
|
||||
</div>
|
||||
@@ -52,23 +102,33 @@ export function MessageRow({
|
||||
const failed = isFailedStatus(topic, msg);
|
||||
const sending = isSendingStatus(topic, msg);
|
||||
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
|
||||
const callResult = callResultDisplay(msg, mine);
|
||||
const contactCard = contactCardContentOf(msg);
|
||||
|
||||
const retry = () => {
|
||||
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
|
||||
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
|
||||
{selectMode ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</button>
|
||||
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
|
||||
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
|
||||
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
|
||||
// row over, while still refusing to let it join a batch action.
|
||||
isRecalled(msg) ? (
|
||||
<div className="h-5 w-5 flex-none" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
@@ -76,25 +136,46 @@ export function MessageRow({
|
||||
e.preventDefault();
|
||||
onContextMenu?.(e, msg);
|
||||
}}
|
||||
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
|
||||
mine ? 'bg-accent' : avatarBgClass(msg.from || peerName)
|
||||
mine ? 'bg-accent' : avatarBgClass(msg.from || (isGroup ? '' : peerName))
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(mine ? '我' : peerName)}
|
||||
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)}
|
||||
</span>
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
|
||||
<div className={`flex items-end gap-1.5 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{isGroup ? (
|
||||
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
<span>{mine ? '我' : senderName || msg.from}</span>
|
||||
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
|
||||
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
|
||||
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
|
||||
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
|
||||
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
|
||||
<div
|
||||
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
|
||||
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
||||
>
|
||||
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
|
||||
{isRecalled(msg) ? (
|
||||
'⚠ 消息已删除'
|
||||
) : callResult ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
|
||||
{callResult.text}
|
||||
</span>
|
||||
) : contactCard ? (
|
||||
<ContactCardBubble content={contactCard} />
|
||||
) : (
|
||||
renderDrafty(msg.content)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
|
||||
@@ -90,7 +90,7 @@ export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAc
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{trailing.length ? <div className="my-1 h-px bg-list-item" /> : null}
|
||||
{trailing.length ? <div className="my-1 h-px bg-border-list-item" /> : null}
|
||||
{trailing.map((action) => {
|
||||
const item = ITEMS[action];
|
||||
return (
|
||||
|
||||
@@ -1,38 +1,111 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { Outlet, useParams } from 'react-router-dom';
|
||||
import { Outlet, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
||||
import { DetailColumn } from '@/features/messages/DetailColumn';
|
||||
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
||||
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
||||
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
|
||||
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
|
||||
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
|
||||
* read on every render. */
|
||||
function ConversationDetailPanel({
|
||||
isGroup,
|
||||
topicId,
|
||||
peerName,
|
||||
onClose,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
isGroup: boolean;
|
||||
topicId: string;
|
||||
peerName: string;
|
||||
onClose?: () => void;
|
||||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||
}) {
|
||||
return isGroup ? (
|
||||
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
|
||||
) : (
|
||||
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
|
||||
);
|
||||
}
|
||||
|
||||
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
|
||||
|
||||
function readPersistedDetailPanelOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 4-tier responsive split described in the design handoff:
|
||||
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
|
||||
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
|
||||
* provide one at this tier -- see ConversationView).
|
||||
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon.
|
||||
* - >=1280: list + main + detail all docked side by side.
|
||||
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
|
||||
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
|
||||
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
|
||||
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
|
||||
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
|
||||
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
|
||||
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
|
||||
* affordance at all).
|
||||
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
|
||||
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
|
||||
* to dock or drawer it in without covering the conversation entirely anyway.
|
||||
*/
|
||||
export function MessagesLayout() {
|
||||
const { topicId } = useParams();
|
||||
const location = useLocation();
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
|
||||
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
|
||||
const isDocked = useMediaQuery('(min-width: 1280px)');
|
||||
const showListOnNarrow = !topicId;
|
||||
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
|
||||
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
|
||||
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
|
||||
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
|
||||
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
|
||||
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
|
||||
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
|
||||
// and effects for no visible reason, and briefly having two real instances of the same panel alive
|
||||
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
|
||||
// that isn't safe to run twice.
|
||||
const isMdUp = useMediaQuery('(min-width: 960px)');
|
||||
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
|
||||
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
|
||||
// to the same list-only view as the bare `/messages` index at narrow widths.
|
||||
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
|
||||
const contacts = useChatList();
|
||||
const tinode = useTinode();
|
||||
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
|
||||
// The "i" button should read as active whenever the detail panel is actually visible right now --
|
||||
// always true when docked (>=1280px, no close affordance), otherwise whatever the drawer/full-page
|
||||
// toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
|
||||
const detailVisible = isDocked || detailOpen;
|
||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
||||
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
|
||||
|
||||
const toggleDetail = () => {
|
||||
if (isDocked) {
|
||||
setDetailPanelOpen((v) => {
|
||||
const next = !v;
|
||||
try {
|
||||
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
|
||||
// toggle itself still works for the rest of this session.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setDetailOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
// MessagesLayout is one long-lived instance reused across every conversation (route is
|
||||
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
|
||||
@@ -49,28 +122,42 @@ export function MessagesLayout() {
|
||||
<ChatListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible, onOpenHistory: setHistoryTab }} />
|
||||
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
|
||||
</div>
|
||||
</div>
|
||||
{topicId ? (
|
||||
{isDocked && detailPanelOpen && topicId ? (
|
||||
<div className="hidden h-full lg:block">
|
||||
<DetailColumn topicId={topicId} peerName={peerName} onOpenHistory={setHistoryTab} />
|
||||
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
|
||||
</div>
|
||||
) : null}
|
||||
{detailOpen && topicId ? (
|
||||
{!isDocked && isMdUp && detailOpen && topicId ? (
|
||||
<div className="absolute inset-0 hidden md:block lg:hidden">
|
||||
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
|
||||
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
|
||||
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
|
||||
<ConversationDetailPanel
|
||||
isGroup={isGroup}
|
||||
topicId={topicId}
|
||||
peerName={peerName}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onOpenHistory={setHistoryTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{detailOpen && topicId ? (
|
||||
{!isDocked && !isMdUp && detailOpen && topicId ? (
|
||||
<div className="fixed inset-0 z-30 bg-white md:hidden">
|
||||
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
|
||||
<ConversationDetailPanel
|
||||
isGroup={isGroup}
|
||||
topicId={topicId}
|
||||
peerName={peerName}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onOpenHistory={setHistoryTab}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
|
||||
{historyTab && topicId ? (
|
||||
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { retryReconnectNow } from '@/lib/tinode/connection';
|
||||
import { useReconnectState } from '@/lib/tinode/hooks';
|
||||
|
||||
/**
|
||||
* README §3.7's four top-banner states, overlaying ChatList+Main. Only one shows at a time,
|
||||
* priority: offline > reconnecting > just-reconnected > realname-reminder (most to least urgent).
|
||||
* "已重新连接,正在同步 N 条新消息" is shown without a fake N -- the old app never implemented this
|
||||
* state at all (confirmed by research), and there's no reliable signal here for "how many messages
|
||||
* arrived while disconnected," so inventing a number would just be dishonest data.
|
||||
* README §3.7's top-banner states, overlaying ChatList+Main. Only one shows at a time, priority:
|
||||
* offline > reconnecting > just-reconnected (most to least urgent). "已重新连接,正在同步 N 条新
|
||||
* 消息" is shown without a fake N -- the old app never implemented this state at all (confirmed by
|
||||
* research), and there's no reliable signal here for "how many messages arrived while disconnected,"
|
||||
* so inventing a number would just be dishonest data.
|
||||
*/
|
||||
export function OfflineBanner() {
|
||||
const navigate = useNavigate();
|
||||
const reconnect = useReconnectState();
|
||||
const bootstrap = useBootstrap();
|
||||
const realname = bootstrap.data?.security?.realname;
|
||||
|
||||
if (!reconnect.networkOnline) {
|
||||
return (
|
||||
@@ -52,19 +46,5 @@ export function OfflineBanner() {
|
||||
);
|
||||
}
|
||||
|
||||
if (realname && !realname.status.includes('verified') && realname.allow_skip) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center justify-between bg-warn-bg px-4 text-[13px] text-[#8a5a12]">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon name="gpp_maybe" className="text-[16px] text-warn" />
|
||||
根据法规要求,请先完成实名认证后再使用全部功能
|
||||
</span>
|
||||
<button type="button" onClick={() => navigate('/settings')} className="text-accent">
|
||||
去认证
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
|
||||
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
|
||||
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
|
||||
* Same UX contract as HistorySearchModal.tsx's own (dead-code, see its comment) LinkResult -- this is
|
||||
* the renderer (lib/messages/drafty.tsx's `LN` case) that component itself falls back to for a hit
|
||||
* that doesn't carry its own `url` enrichment field, so fixing it here covers both call sites: this
|
||||
* was previously the only actually-reachable one, since SearchHit's real response has no `url` field.
|
||||
*/
|
||||
export function SafeLink({ href, children }: { href: string | undefined; children: ReactNode }) {
|
||||
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'chat_message') });
|
||||
if (!href) return <span className="text-accent underline">{children}</span>;
|
||||
|
||||
const open = async () => {
|
||||
let result;
|
||||
try {
|
||||
result = await safetyCheck.mutateAsync(href);
|
||||
} catch {
|
||||
window.alert('链接安全检查失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'block') {
|
||||
window.alert('该链接存在安全风险,已被拦截。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={open} disabled={safetyCheck.isPending} className="text-accent underline disabled:opacity-60">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -122,12 +122,13 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between rounded-lg bg-[#fff0f0] px-4">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-danger-text">
|
||||
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
|
||||
<span className="flex flex-none items-center gap-2 text-sm font-medium text-danger-text">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-danger-text" />
|
||||
录音中 {formatSeconds(seconds)}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
|
||||
<div className="flex flex-none items-center gap-4">
|
||||
<button type="button" onClick={() => finish(false)} className="text-sm text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { getLatestRelease } from '@/lib/api/app';
|
||||
|
||||
const CURRENT_VERSION = __APP_VERSION__;
|
||||
|
||||
/**
|
||||
* README §8.7's "v0.25 · 有新版本" row. Confirmed live (2026-09-08): `platform:"web"` is REJECTED
|
||||
* outright -- `400 MALFORMED_REQUEST {"details":{"field":"platform"}}` -- the real backend only
|
||||
* accepts the doc's own example values (`ios`/`android`), no web support at all. Passing a wrong
|
||||
* platform to get SOME answer would be dishonest (it'd be checking updates for the wrong client),
|
||||
* so this shows an honest "not supported" state instead of pretending the check succeeded.
|
||||
*/
|
||||
export function AboutPage() {
|
||||
const checkMutation = useMutation({ mutationFn: () => getLatestRelease(CURRENT_VERSION) });
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">关于 MeetingIM</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
||||
<span className="text-text-body">当前版本</span>
|
||||
<span className="text-text-tertiary-2">{CURRENT_VERSION}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkMutation.mutate()}
|
||||
disabled={checkMutation.isPending}
|
||||
className="flex w-full items-center justify-between px-7 py-4 text-left"
|
||||
>
|
||||
<span className="text-text-body">检查更新</span>
|
||||
<span className="text-text-tertiary-2">
|
||||
{checkMutation.isPending
|
||||
? '检查中…'
|
||||
: checkMutation.isError
|
||||
? '暂不支持检查更新'
|
||||
: checkMutation.data
|
||||
? checkMutation.data.update_type === 'none'
|
||||
? '已是最新版本'
|
||||
: `发现新版本 ${checkMutation.data.latest_version}`
|
||||
: '›'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
|
||||
/**
|
||||
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
|
||||
* all (docs/IM-后端问题清单.md-adjacent research: "No backend endpoint clears cache/history -- both
|
||||
* are pure client-side operations"). Computing a real, honest "just this app's cache" byte count is
|
||||
* not reliably possible (`navigator.storage.estimate()` reports the WHOLE origin, not a
|
||||
* per-feature breakdown) -- rather than show a fabricated-looking number, this omits the size and
|
||||
* just performs and confirms the real clear (this app's own Cache Storage buckets, per the SW-
|
||||
* caching behavior already relied on elsewhere in this app).
|
||||
*/
|
||||
export function GeneralSettingsPage() {
|
||||
const [status, setStatus] = useState<'idle' | 'clearing' | 'done'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const clearCache = async () => {
|
||||
if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return;
|
||||
setStatus('clearing');
|
||||
try {
|
||||
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
setStatus('done');
|
||||
} catch (err) {
|
||||
setStatus('idle');
|
||||
setError(err instanceof Error ? err.message : '清除缓存失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">通用</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<button type="button" onClick={clearCache} disabled={status === 'clearing'} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
||||
<span className="text-text-body">清空缓存</span>
|
||||
<span className="text-text-tertiary-2">{status === 'clearing' ? '清除中…' : status === 'done' ? '已清除 ›' : '›'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* README §8.1's directory lists a dedicated "消息通知" section, but there is no per-type
|
||||
* notification-preference REST endpoint anywhere (confirmed: grepped both protocol-doc copies for
|
||||
* notification_settings/推送偏好, zero hits; the old app's own settings list has no such row
|
||||
* either) -- the doc's own claim that "服务端只保存应用内通知偏好" has nothing backing it. Rather
|
||||
* than build toggles that write nowhere, this only wires the one thing that's real and entirely
|
||||
* client-side: the browser's own Notification permission.
|
||||
*/
|
||||
export function NotificationsSettingsPage() {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(typeof Notification !== 'undefined' ? Notification.permission : 'denied');
|
||||
|
||||
const request = async () => {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
};
|
||||
|
||||
const label = { granted: '已允许', denied: '已拒绝', default: '未设置' }[permission];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">消息通知</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between px-7 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">桌面通知</span>
|
||||
<span className="text-xs text-text-tertiary-3">由浏览器管理,用于在新消息到达时提醒你</span>
|
||||
</div>
|
||||
{permission === 'default' ? (
|
||||
<button type="button" onClick={request} className="h-8 flex-none rounded-lg bg-btn px-3 text-xs font-medium text-white">
|
||||
开启
|
||||
</button>
|
||||
) : (
|
||||
<span className={`flex-none text-xs ${permission === 'granted' ? 'text-success' : 'text-text-tertiary-2'}`}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getBlocks } from '@/lib/api/friends';
|
||||
import { getMyPrivacy, updateMyPrivacy, type AddMeMethods } from '@/lib/api/profile';
|
||||
|
||||
const ADD_ME_LABELS: { key: keyof AddMeMethods; label: string }[] = [
|
||||
{ key: 'account', label: '通过用户 ID' },
|
||||
{ key: 'phone', label: '通过手机号' },
|
||||
{ key: 'qr', label: '通过二维码' },
|
||||
{ key: 'group', label: '通过群聊' },
|
||||
];
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'everyone', label: '所有人' },
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
{ value: 'nobody', label: '不展示' },
|
||||
];
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* screens/07-profile-settings.dc.html#1c: 添加我的方式 (4 toggles, ungrouped -- no dividers between
|
||||
* them, just the section label above) sits in its own card; 黑名单 sits in a second card alone
|
||||
* (that mockup also puts "加我为好友时需要验证" there, but that toggle has NO backing field anywhere
|
||||
* in this response shape or the group-invite backend logic -- confirmed absent, not built). The
|
||||
* visibility/read-receipts rows below have no mockup at all (added once the real backend fields were
|
||||
* found during research) -- given their own third card rather than folding them into either of the
|
||||
* above, so a real capability the design never anticipated doesn't look like it was crammed in.
|
||||
*/
|
||||
export function PrivacySettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const query = useQuery({ queryKey: ['my-privacy'], queryFn: getMyPrivacy });
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof updateMyPrivacy>[0]) => updateMyPrivacy(patch),
|
||||
// Merge, don't replace -- the doc's own worked example for this exact endpoint (line 2494)
|
||||
// returns a PARTIAL object (`{add_me_methods,read_receipts,version,updated_at}`, missing
|
||||
// `profile_visibility`/`last_seen_visibility`/`editable`). A blind replace would erase those
|
||||
// from the cache after every single save, most importantly `editable`: once it silently
|
||||
// disappeared, `const editable = query.data.editable || {}` re-derives an EMPTY object, which
|
||||
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
|
||||
// to restore it.
|
||||
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
|
||||
});
|
||||
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
|
||||
if (!query.data) return;
|
||||
mutation.mutate({ ...partial, version: query.data.version });
|
||||
};
|
||||
|
||||
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
|
||||
// `isLoading` alone used to gate this: it turns false once the fetch SETTLES, including into an
|
||||
// error, but `query.data` stays undefined either way -- the page was falling through to the same
|
||||
// "加载中…" text forever on a genuine failure (a slow/dropped connection, not just "still loading"),
|
||||
// with no retry affordance at all. Same fix already applied to SystemMessagesView's own query.
|
||||
if (query.isError || !query.data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const editable = query.data.editable || {};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">隐私</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="px-7 pb-1.5 pt-4 text-[13px] text-text-tertiary-2">添加我的方式</div>
|
||||
{ADD_ME_LABELS.map((item, i) => (
|
||||
<div key={item.key} className={`flex items-center justify-between px-7 py-3 ${i === ADD_ME_LABELS.length - 1 ? 'pb-4.5' : ''}`}>
|
||||
<span className="text-text-body">{item.label}</span>
|
||||
<Toggle
|
||||
checked={query.data.add_me_methods[item.key]}
|
||||
disabled={editable.add_me_methods === false || mutation.isPending}
|
||||
onChange={() => patch({ add_me_methods: { [item.key]: !query.data.add_me_methods[item.key] } })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => navigate('/contacts/blocklist')} className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm">
|
||||
<span className="text-text-body">黑名单</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
{blocksQuery.data?.length ?? 0} 人
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
||||
<span className="text-text-body">资料可见范围</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={editable.profile_visibility === false || mutation.isPending}
|
||||
onClick={() => patch({ profile_visibility: opt.value })}
|
||||
className={`px-3 py-1.5 ${query.data.profile_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
||||
<span className="text-text-body">最近在线可见范围</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={editable.last_seen_visibility === false || mutation.isPending}
|
||||
onClick={() => patch({ last_seen_visibility: opt.value })}
|
||||
className={`px-3 py-1.5 ${query.data.last_seen_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-7 py-4">
|
||||
<span className="text-text-body">已读回执</span>
|
||||
<Toggle
|
||||
checked={query.data.read_receipts}
|
||||
disabled={editable.read_receipts === false || mutation.isPending}
|
||||
onChange={() => patch({ read_receipts: !query.data.read_receipts })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const GENDER_OPTIONS: { value: 'male' | 'female' | 'other'; label: string }[] = [
|
||||
{ value: 'male', label: '男' },
|
||||
{ value: 'female', label: '女' },
|
||||
{ value: 'other', label: '保密' },
|
||||
];
|
||||
const DEFAULT_MAX_SIGNATURE_LENGTH = 60;
|
||||
|
||||
/** Plain-text-looking field: a real `<input>` with all box chrome stripped so it reads as the
|
||||
* design's "label / value / edit-affordance-icon" row instead of a boxed form control, while
|
||||
* staying genuinely editable (click anywhere in the value cell to type). */
|
||||
const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function InlineField(props, ref) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={`w-full border-0 bg-transparent p-0 text-sm text-text-body outline-none [&::-webkit-calendar-picker-indicator]:hidden ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
|
||||
* `/v0/file/u/` channel, NOT `POST /v1/im/public/uploads?purpose=user_avatar` -- confirmed
|
||||
* live-broken (docs/IM-后端问题清单.md 问题28: that endpoint 400s on `purpose=user_avatar`
|
||||
* specifically), and the old app's own real avatar-upload code never goes through it either.
|
||||
*/
|
||||
export function ProfileSettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const query = useQuery({ queryKey: ['my-profile'], queryFn: getMyProfile });
|
||||
const [draft, setDraft] = useState<{ nickname: string; gender: string; birth_date: string; region: string; signature: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [showQrCode, setShowQrCode] = useState(false);
|
||||
const birthDateRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data && !draft) {
|
||||
setDraft({
|
||||
nickname: query.data.nickname,
|
||||
gender: query.data.gender || '',
|
||||
birth_date: query.data.birth_date || '',
|
||||
region: query.data.region || '',
|
||||
signature: query.data.signature || '',
|
||||
});
|
||||
}
|
||||
}, [query.data, draft]);
|
||||
|
||||
// saveMutation and avatarMutation both PATCH the same optimistic-locked resource. The disabled=
|
||||
// props below on the avatar input/save button stop the two from ever being pending at the same
|
||||
// time, closing the race where both could send the SAME stale `query.data.version` and whichever
|
||||
// response resolves last would win the cache. Independent of that: EITHER mutation's own response
|
||||
// can itself be a PARTIAL profile object -- the doc's own worked example for this endpoint (line
|
||||
// 2492) returns only `{avatar_file_id,nickname,signature,version,updated_at}`, omitting
|
||||
// `birth_date`/`gender`/`region`/`public_account`/`field_rules` entirely, and avatarMutation's own
|
||||
// request body is deliberately partial (`{avatar_file_id,version}` only) so its response is the
|
||||
// most likely of the two to come back partial too. A blind `setQueryData(key, data)` replace would
|
||||
// erase those fields from the cache after a single avatar change -- merging onto the existing
|
||||
// cached object (not just overwriting it) is what actually prevents that regardless of exactly how
|
||||
// partial a given response turns out to be.
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!query.data || !draft) throw new Error('尚未加载');
|
||||
return updateMyProfile({ ...draft, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
|
||||
});
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const uploaded = await uploadFile(file);
|
||||
const fileId = uploaded.url.split('/').pop() || '';
|
||||
if (!query.data) throw new Error('尚未加载');
|
||||
return updateMyProfile({ avatar_file_id: fileId, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setUploadingAvatar(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '头像上传失败');
|
||||
setUploadingAvatar(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('图片大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
setUploadingAvatar(true);
|
||||
avatarMutation.mutate(file);
|
||||
};
|
||||
|
||||
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
|
||||
// Same gap as PrivacySettingsPage's own query: `draft` only ever gets set from a successful fetch
|
||||
// (the effect above is gated on `query.data`), so a genuine fetch failure left `isLoading` false
|
||||
// but `draft` permanently null -- falling through to the exact same "加载中…" text forever, with no
|
||||
// way to retry.
|
||||
if (query.isError || !draft) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxSignatureLength = query.data?.field_rules?.signature?.max_length || DEFAULT_MAX_SIGNATURE_LENGTH;
|
||||
const dirty =
|
||||
query.data &&
|
||||
(draft.nickname !== query.data.nickname ||
|
||||
draft.gender !== (query.data.gender || '') ||
|
||||
draft.birth_date !== (query.data.birth_date || '') ||
|
||||
draft.region !== (query.data.region || '') ||
|
||||
draft.signature !== (query.data.signature || ''));
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">个人资料</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-5 rounded-xl bg-white px-7 py-6">
|
||||
<div className="relative flex-none">
|
||||
<Avatar id={myUserId || ''} name={draft.nickname} avatarFileId={query.data?.avatar_file_id} className="h-20 w-20 rounded-[20px] text-2xl" />
|
||||
<label className="absolute -bottom-1 -right-1 grid h-[26px] w-[26px] cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm">
|
||||
<Icon name="photo_camera" className="text-[15px]" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={handlePickAvatar}
|
||||
className="hidden"
|
||||
disabled={uploadingAvatar || saveMutation.isPending}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<strong className="truncate text-lg font-semibold text-text-heading">{draft.nickname}</strong>
|
||||
<span className="truncate text-[13px] text-text-tertiary-2">
|
||||
ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQrCode(true)}
|
||||
className="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-[#d5d9df] px-2.5 text-[13px] text-text-tertiary-1 sm:px-3.5"
|
||||
>
|
||||
<Icon name="qr_code_2" className="text-[18px]" />
|
||||
<span className="hidden sm:inline">我的二维码</span>
|
||||
</button>
|
||||
</div>
|
||||
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
|
||||
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">昵称</span>
|
||||
<InlineField
|
||||
value={draft.nickname}
|
||||
onChange={(e) => setDraft({ ...draft, nickname: e.target.value })}
|
||||
maxLength={query.data?.field_rules?.nickname?.max_length || 32}
|
||||
/>
|
||||
<Icon name="edit" className="text-[20px] text-icon-arrow" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">性别</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, gender: opt.value })}
|
||||
className={`px-3.5 py-1.5 ${draft.gender === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">出生日期</span>
|
||||
<InlineField
|
||||
ref={birthDateRef}
|
||||
type="date"
|
||||
value={draft.birth_date}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => setDraft({ ...draft, birth_date: e.target.value })}
|
||||
/>
|
||||
<button type="button" onClick={() => birthDateRef.current?.showPicker?.()} className="grid place-items-center text-icon-arrow">
|
||||
<Icon name="calendar_today" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">地区</span>
|
||||
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-start gap-x-2 px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="pt-2 text-text-secondary">个性签名</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={draft.signature}
|
||||
onChange={(e) => setDraft({ ...draft, signature: e.target.value.slice(0, maxSignatureLength) })}
|
||||
rows={3}
|
||||
className="min-h-[60px] w-full resize-none rounded-lg bg-surface-input p-3 pr-14 text-sm outline-none"
|
||||
/>
|
||||
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
|
||||
{draft.signature.length}/{maxSignatureLength}
|
||||
</span>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
query.data &&
|
||||
setDraft({
|
||||
nickname: query.data.nickname,
|
||||
gender: query.data.gender || '',
|
||||
birth_date: query.data.birth_date || '',
|
||||
region: query.data.region || '',
|
||||
signature: query.data.signature || '',
|
||||
})
|
||||
}
|
||||
disabled={!dirty}
|
||||
className="h-9.5 rounded-lg border border-[#d5d9df] px-5 text-sm text-text-tertiary-1 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!dirty || saveMutation.isPending || uploadingAvatar}
|
||||
className="h-9.5 rounded-lg bg-btn px-5 text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{saveMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getTinode, resetTinode } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* screens/07-profile-settings.dc.html#1b. That mockup's 手机号/登录设备 rows have no real data
|
||||
* source and aren't built as decorative fakes: phone number display (no `GET my phone` endpoint
|
||||
* anywhere -- the only place a phone surfaces is a masked hint as a SIDE EFFECT of starting an
|
||||
* unrelated SMS challenge, not a passive read) and the logged-in-device list (only a kick-by-id
|
||||
* DELETE exists, itself marked 待实现, with no GET list endpoint to populate rows from). Password
|
||||
* change and account deletion both go through native Tinode `{acc}`/`{del}` calls (see
|
||||
* tinode-sdk.d.ts's own comments) -- there is no REST path for either, confirmed across both doc
|
||||
* copies and the backend problem list.
|
||||
*/
|
||||
export function SecuritySettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [deletingAccount, setDeletingAccount] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
// Real per-tenant policy (docs/IM-前后端对接文档.md:534, fetched once at tenant-resolve time and
|
||||
// stored on `tenant`) -- was hardcoded to 6 here, silently DIFFERENT from Register/ResetPassword's
|
||||
// own (also-hardcoded, but at least matching each other) 8, so a password this page accepted could
|
||||
// still get rejected by the backend's real policy.
|
||||
const minPasswordLength = tenant?.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const passwordsMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
// `updateAccountBasic`'s own type comment (tinode-sdk.d.ts) already confirms, from this
|
||||
// engagement's earlier research into the old app's real working code: the server does NOT
|
||||
// verify a "current password" for this call at all -- an already-authenticated WS session is
|
||||
// by itself sufficient to change the password to anything. `currentPassword` below is therefore
|
||||
// NOT sent anywhere and is NOT a real credential check -- it exists purely as a client-side "did
|
||||
// you mean to do this" gate (the submit button stays disabled while it's empty), the same
|
||||
// fat-finger/shared-device speed bump most password-change screens have, not a security boundary.
|
||||
// A REAL re-verification would need the protocol's own `POST /security/challenges` sensitive-
|
||||
// action flow, but the only confirmed `scene` values for that endpoint (`set_independent_password`/
|
||||
// `close_youth_mode`) are both youth-mode-specific -- and youth mode was removed from this app
|
||||
// entirely per an earlier explicit user request, so that path isn't available here either.
|
||||
mutationFn: () => {
|
||||
const tinode = getTinode();
|
||||
return tinode.updateAccountBasic(null, tinode.getCurrentLogin(), newPassword);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setChangingPassword(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setSuccess('密码已修改。');
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '密码修改失败'),
|
||||
});
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (deletingAccount) return;
|
||||
if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return;
|
||||
if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return;
|
||||
setDeletingAccount(true);
|
||||
try {
|
||||
await getTinode().delCurrentUser(true);
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
// Deliberately NOT clearTenant() -- see SettingsLayout.tsx's logout for why: it clears
|
||||
// useSessionStore's `tenant`, and LoginPage's own tenant guard immediately bounces navigate(
|
||||
// '/login') below back to the tenant-code entry screen instead of this tenant's login page.
|
||||
//
|
||||
// Same shared-device concern as SettingsLayout's logout -- a next login in this tab should
|
||||
// never briefly render this now-deleted account's cached data.
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注销失败');
|
||||
setDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">账号与安全</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
{changingPassword ? (
|
||||
<div className="flex flex-col gap-3 px-7 py-4">
|
||||
<span className="text-text-body">修改密码</span>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="输入当前密码"
|
||||
autoComplete="current-password"
|
||||
className="rounded-md bg-surface-input px-3 py-2 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={`输入新密码(至少 ${minPasswordLength} 位)`}
|
||||
autoComplete="new-password"
|
||||
maxLength={tenant?.passwordPolicy?.maxLength}
|
||||
className="rounded-md bg-surface-input px-3 py-2 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
maxLength={tenant?.passwordPolicy?.maxLength}
|
||||
className={`rounded-md bg-surface-input px-3 py-2 outline-none ${passwordsMismatch ? 'border border-danger-text' : ''}`}
|
||||
/>
|
||||
{passwordsMismatch ? <span className="text-xs text-danger-border">两次输入的密码不一致</span> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChangingPassword(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => passwordMutation.mutate()}
|
||||
disabled={
|
||||
currentPassword.length === 0 ||
|
||||
newPassword.length < minPasswordLength ||
|
||||
passwordsMismatch ||
|
||||
confirmPassword.length === 0 ||
|
||||
passwordMutation.isPending
|
||||
}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{passwordMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
||||
<span className="text-text-body">修改密码</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteAccount}
|
||||
disabled={deletingAccount}
|
||||
className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm disabled:opacity-50"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-text-body">注销账号</span>
|
||||
<span className="text-xs text-text-tertiary-2">{deletingAccount ? '注销中…' : '注销后数据不可恢复'}</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
<Toast message={success} variant="success" onDone={() => setSuccess('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
|
||||
/**
|
||||
* `/settings` alone has no natural "empty" content the way contacts/messages do -- on desktop
|
||||
* (directory + content docked side by side) the most-relevant first section fills the content pane
|
||||
* directly. On narrow viewports this must NOT redirect there (an earlier version did): SettingsLayout
|
||||
* only shows the directory list at this exact bare path, and a redirect away from it would bounce
|
||||
* straight past that list before a phone-width visitor ever saw it -- so this renders nothing there,
|
||||
* leaving the directory (rendered by the parent layout) as the whole screen.
|
||||
*/
|
||||
export function SettingsHome() {
|
||||
const isDesktop = useMediaQuery('(min-width: 960px)');
|
||||
return isDesktop ? <ProfileSettingsPage /> : null;
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DIRECTORY = [
|
||||
{ key: 'profile', to: '/settings/profile', icon: 'account_circle', label: '个人资料' },
|
||||
{ key: 'security', to: '/settings/security', icon: 'security', label: '账号与安全' },
|
||||
{ key: 'notifications', to: '/settings/notifications', icon: 'notifications', label: '消息通知' },
|
||||
{ key: 'privacy', to: '/settings/privacy', icon: 'lock', label: '隐私' },
|
||||
{ key: 'general', to: '/settings/general', icon: 'tune', label: '通用' },
|
||||
{ key: 'about', to: '/settings/about', icon: 'info', label: '关于 MeetingIM' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.7 (mobile "我的" page): 我的收藏/文件助手/通话记录 are grouped ABOVE the regular
|
||||
* settings directory. Desktop never needs this list -- those three already have their own
|
||||
* permanent NavRail icons there -- but on a phone-width viewport (bottom tab bar instead of a rail)
|
||||
* there is otherwise NO way to reach `/favorites` or `/calls` at all short of typing the URL
|
||||
* directly (confirmed live: BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2
|
||||
* when those two routes didn't exist yet) -- a real navigation gap this Phase 9 pass caught, not a
|
||||
* cosmetic one. `/files` reuses the same `slf`-conversation redirect the desktop entry point does.
|
||||
*/
|
||||
const MOBILE_SHORTCUTS = [
|
||||
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
|
||||
{ key: 'files', to: '/files', icon: 'folder', label: '文件助手', colorClass: 'text-success' },
|
||||
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely
|
||||
* client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout
|
||||
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
|
||||
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
|
||||
*/
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
// README §8.7 draws TWO separate mobile screens (`#1d` 我的 hub, `#1e` 设置 sub-list) that don't
|
||||
// map 1:1 onto the desktop directory -- this is which of the two the bare `/settings` mobile route
|
||||
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
|
||||
const [mobileSubView, setMobileSubView] = useState(false);
|
||||
|
||||
const logout = () => {
|
||||
if (!window.confirm('确定退出登录吗?')) return;
|
||||
setLoggingOut(true);
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
// Deliberately NOT clearTenant() -- that clears useSessionStore's `tenant`, and LoginPage's own
|
||||
// `if (!tenant) return <Navigate to="/tenant" replace />` immediately bounces the navigate('/login')
|
||||
// below straight back to the tenant-code entry screen instead of this tenant's actual login page
|
||||
// (confirmed live: this used to be a real dead end, not a hypothetical). A plain logout should
|
||||
// stay on the same enterprise's login screen, ready for the same or a different user to sign back
|
||||
// in -- only the explicit "切换企业" link is supposed to clear the tenant.
|
||||
//
|
||||
// The QueryClient instance is module-scoped for the whole SPA lifetime (App.tsx) and survives a
|
||||
// client-side route change -- without this, a second account logging in on the same tab/device
|
||||
// (a shared kiosk machine, realistic for an enterprise client) would briefly render the FIRST
|
||||
// account's still-cached profile/privacy/blocks data before the background refetch resolves.
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// Below the desktop breakpoint there's no room to dock both columns (confirmed live: without
|
||||
// this, the 260px directory and the content column both try to render side by side on a phone-
|
||||
// width viewport, squeezing the content into a sliver narrow enough that its own text wraps
|
||||
// one character per line). Mirrors ContactsLayout's exact `showListOnNarrow` pattern: show the
|
||||
// directory only at the bare `/settings` index, content only once a specific section is picked.
|
||||
const showListOnNarrow = location.pathname === '/settings';
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<aside className={`h-full w-full flex-none flex-col bg-white md:flex md:w-[260px] md:border-r md:border-border-panel ${showListOnNarrow ? 'flex' : 'hidden'}`}>
|
||||
{/* Desktop (README §8.1): one directory, matches ≥md exactly as before. */}
|
||||
<div className="hidden h-full flex-1 flex-col md:flex">
|
||||
<header className="flex h-15 flex-none items-center px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
|
||||
</header>
|
||||
<div className="flex items-center gap-3 border-b border-border-list-item px-5 pb-4.5 pt-2">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
avatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
className="h-12 w-12 flex-none rounded-xl text-base"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-semibold text-text-body">{meQuery.data?.profile.nickname}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">ID:{meQuery.data?.profile.public_account}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
|
||||
{DIRECTORY.map((item) => {
|
||||
const active = location.pathname.startsWith(item.to);
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm no-underline ${
|
||||
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="text-[18px]" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
disabled={loggingOut}
|
||||
className="mx-3 my-4 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory:
|
||||
tappable profile card (-> /settings/profile), 我的收藏/文件助手/通话记录 (this app's only
|
||||
way to reach those three on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then
|
||||
账号与安全/隐私 direct, and "设置" as its own second-level list rather than a 4th direct
|
||||
link -- these two screens genuinely don't share one flat structure in the mock. */}
|
||||
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>
|
||||
<Link to="/settings/profile" className="flex items-center gap-3.5 bg-white px-5 py-5 no-underline">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
avatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
className="h-16 w-16 flex-none rounded-2xl text-2xl"
|
||||
/>
|
||||
<strong className="flex-1 truncate text-[19px] font-semibold text-text-heading">{meQuery.data?.profile.nickname}</strong>
|
||||
<Icon name="qr_code_2" className="flex-none text-xl text-text-tertiary-3" />
|
||||
<Icon name="chevron_right" className="flex-none text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2.5 py-2.5">
|
||||
<div className="flex flex-col bg-white">
|
||||
{MOBILE_SHORTCUTS.map((item, i) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 px-5 py-3.5 text-base text-text-body no-underline ${i < MOBILE_SHORTCUTS.length - 1 ? 'border-b border-border-list-item' : ''}`}
|
||||
>
|
||||
<Icon name={item.icon} className={`text-xl ${item.colorClass}`} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
<Link to="/settings/security" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<Icon name="security" className="text-xl text-[#7c5cf6]" />
|
||||
<span className="flex-1">账号与安全</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<Link to="/settings/privacy" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<Icon name="lock" className="text-xl text-[#e2537a]" />
|
||||
<span className="flex-1">隐私</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<button type="button" onClick={() => setMobileSubView(true)} className="flex items-center gap-3 px-5 py-3.5 text-left text-base text-text-body">
|
||||
<Icon name="settings" className="text-xl text-text-tertiary-3" />
|
||||
<span className="flex-1">设置</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile "设置" sub-list (mock `07/1e`) -- a second, DIFFERENT grouping from both the hub
|
||||
above and the desktop directory: 清空缓存/意见反馈/关于 sit here directly, not nested
|
||||
under 通用. 清空缓存 links through to 通用 rather than duplicating that action (and its
|
||||
already-established "no fake byte count" honesty) in a second place; 意见反馈 has no
|
||||
generic post-login feedback endpoint anywhere in this protocol (the only feedback API,
|
||||
`/tenant-entry-feedback`, is unauthenticated and pre-login-only per its own name) so this
|
||||
is a real, recorded backend gap, not a missed wire-up. 关于 omits the mock's red-dot
|
||||
"有新版本" -- already confirmed elsewhere that state can never be real (the update-check
|
||||
endpoint always rejects `platform=web`), showing it would be lying about system state. */}
|
||||
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'flex' : 'hidden'}`}>
|
||||
<div className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center bg-white px-1">
|
||||
<button type="button" onClick={() => setMobileSubView(false)} className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
</button>
|
||||
<strong className="text-center text-[17px] font-semibold text-text-heading">设置</strong>
|
||||
<span />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 py-2.5">
|
||||
<div className="flex flex-col bg-white">
|
||||
{DIRECTORY.filter((item) => item.key !== 'profile' && item.key !== 'about').map((item, i, arr) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline ${i < arr.length - 1 ? 'border-b border-border-list-item' : ''}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
<Link to="/settings/general" className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<span>清空缓存</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<button type="button" onClick={notImplemented} className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-left text-base text-text-body">
|
||||
<span>意见反馈</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</button>
|
||||
<Link to="/settings/about" className="flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<span>关于 MeetingIM</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
</div>
|
||||
<button type="button" onClick={logout} disabled={loggingOut} className="bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className={`h-full min-w-0 flex-1 flex-col overflow-y-auto bg-[#f6f7f9] md:flex ${showListOnNarrow ? 'hidden' : 'flex'}`}>
|
||||
<Link to="/settings" className="flex h-11 flex-none items-center gap-1 px-4 text-sm text-text-secondary no-underline md:hidden">
|
||||
<Icon name="arrow_back" className="text-[18px]" />
|
||||
设置
|
||||
</Link>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
/**
|
||||
* Ported verbatim from the old app's message-center-view.jsx (lines ~420-455) -- the backend writes
|
||||
* raw event codes into `summary` in several real cases instead of human text (confirmed live,
|
||||
* docs/IM-后端问题清单.md 问题30), so this maps event/summary codes to display text rather than
|
||||
* trusting `summary` blindly. Lookup order: NOTIFICATION_TEXT[summary] first (covers the bare
|
||||
* "accepted"/"rejected"/"continue"/"cancel" codes), then NOTIFICATION_TEXT[event_type], then the
|
||||
* raw summary (covers genuinely human text like a meeting title or report date), then a generic
|
||||
* fallback -- never silently show nothing.
|
||||
*/
|
||||
const NOTIFICATION_TEXT: Record<string, string> = {
|
||||
'group.created': '你创建了一个新群聊',
|
||||
'group.join_request.created': '有人申请加入你管理的群聊',
|
||||
'group.announcement.updated': '群公告有更新',
|
||||
'group.member.joined': '有成员加入了群聊',
|
||||
'group.member.left': '有成员退出了群聊',
|
||||
'group.member.updated': '群成员资料有更新',
|
||||
'group.mute.updated': '群禁言设置有变更',
|
||||
'group.owner.transferred': '群主已转让',
|
||||
'group.role.updated': '群成员角色有变更',
|
||||
'group.settings.updated': '群设置有更新',
|
||||
'group.join_request.accepted': '你的入群申请已通过',
|
||||
'group.join_request.rejected': '你的入群申请被拒绝',
|
||||
accepted: '对方已通过你的好友申请',
|
||||
rejected: '对方拒绝了你的好友申请',
|
||||
continue: '你确认继续了一次安全风险提示',
|
||||
cancel: '你取消了一次风险操作确认',
|
||||
};
|
||||
|
||||
function notificationText(item: SystemNotification): string {
|
||||
return NOTIFICATION_TEXT[item.summary] || NOTIFICATION_TEXT[item.event_type] || item.summary || '您有一条新的系统通知。';
|
||||
}
|
||||
|
||||
/** icon/color per event family -- matches the design's 新设备登录/好友申请/群聊动态/版本更新
|
||||
* categories, generalized by event_type prefix since the real event set is broader than those 4. */
|
||||
function iconForEvent(eventType: string): { icon: string; className: string } {
|
||||
if (eventType.startsWith('friend.')) return { icon: 'person_add', className: 'bg-accent-soft-bg text-accent' };
|
||||
if (eventType.startsWith('group.')) return { icon: 'groups', className: 'bg-success-bg text-success' };
|
||||
if (eventType.startsWith('security.')) return { icon: 'gpp_maybe', className: 'bg-error-strip-bg text-unread' };
|
||||
if (eventType.startsWith('meeting.')) return { icon: 'event', className: 'bg-warn-bg text-warn' };
|
||||
return { icon: 'notifications', className: 'bg-warn-bg text-warn' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes on `event_type`, NOT `action.type` -- confirmed live (docs/IM-后端问题清单.md 问题30) a
|
||||
* real friend-request notification's `action` is just `{"user_id":"..."}`, no `type` key at all,
|
||||
* contradicting this doc's own worked example. `action.device`/`action.ip` (new-device-login) are
|
||||
* typed but confirmed NOT populated by any real response yet (新增12, still open) -- this simply
|
||||
* won't render a link for that case until the backend actually implements it, rather than building
|
||||
* a rich device/IP card around data that can't exist yet.
|
||||
*/
|
||||
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
|
||||
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
|
||||
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
|
||||
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
|
||||
}
|
||||
if (item.action?.device || item.action?.ip) return { label: '前往安全设置', to: '/settings/security' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** README §9.1 (four-column layout, no detail rail). No "mark all read" endpoint exists anywhere
|
||||
* in the protocol doc (confirmed via a research pass) -- "全部标为已读" loops the single-item
|
||||
* `POST .../read` over every currently-unread item instead of a batch call that doesn't exist. */
|
||||
export function SystemMessagesView() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const bootstrap = useBootstrap();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['system-notifications'],
|
||||
queryFn: ({ pageParam }) => getSystemNotifications(pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const unread = items.filter((i) => !i.read_at);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const readMutation = useMutation({
|
||||
mutationFn: (id: string) => markSystemNotificationRead(id),
|
||||
onSuccess: (data, id) => {
|
||||
queryClient.setQueryData(['system-notifications'], (prev: typeof query.data) =>
|
||||
prev
|
||||
? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: p.items.map((i) => (i.id === id ? { ...i, read_at: data.read_at } : i)) })) }
|
||||
: prev,
|
||||
);
|
||||
// AppShell's own NavRail/BottomTabBar unread badge trusts `bootstrap.entry_badges.
|
||||
// system_unread_count` as its ONLY source for this topic (deliberately excluded from the
|
||||
// live per-conversation sum) -- but that query is `staleTime: Infinity` and nothing anywhere
|
||||
// else invalidates it, so without this the badge would never decrease within a session no
|
||||
// matter how many notifications get marked read here.
|
||||
queryClient.invalidateQueries({ queryKey: ['bootstrap'] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
|
||||
});
|
||||
const [markingAll, setMarkingAll] = useState(false);
|
||||
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
|
||||
// used to leave any unread notification on a page the user never scrolled to untouched, while the
|
||||
// button itself still disappeared (since it's gated on the loaded-only `unread.length`), falsely
|
||||
// implying everything had been cleared. Pages through every remaining page first, then marks the
|
||||
// now-complete set, so "全部" is actually all of it.
|
||||
const markAllRead = async () => {
|
||||
setMarkingAll(true);
|
||||
try {
|
||||
// `query` is a snapshot from THIS render -- fetchNextPage() updates the cache and causes a
|
||||
// re-render with a NEW query object, but this closure keeps holding the old one, so its
|
||||
// `.hasNextPage` never changes and this loop would never exit for any account with more than
|
||||
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
|
||||
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
|
||||
// of trusting a captured object's field is what actually terminates.
|
||||
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
while (cursor) {
|
||||
await query.fetchNextPage();
|
||||
cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
}
|
||||
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
|
||||
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
|
||||
// it the same as mutate) -- this swallow only stops Promise.all's fail-fast rejection from
|
||||
// ALSO becoming an unhandled rejection on top of that, it doesn't hide the error itself.
|
||||
await Promise.all(allItems.filter((i) => !i.read_at).map((i) => readMutation.mutateAsync(i.id))).catch(() => {});
|
||||
} finally {
|
||||
setMarkingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-[#eef1f4]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-warn-bg text-warn">
|
||||
<Icon name="notifications" className="text-xl" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">系统消息</strong>
|
||||
<span className="text-xs text-text-tertiary-3">由 MeetingIM 安全团队发送,不可回复</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* `unread` only reflects pages already loaded into this view, but `markAllRead` itself
|
||||
correctly walks every remaining page (see its own comment below) -- gating visibility on
|
||||
`unread.length` alone defeats that fix by hiding the button the moment the FIRST page
|
||||
happens to show zero unread, even while an older unread notification sits unreached on a
|
||||
later page. `bootstrap.entry_badges.system_unread_count` is the same authoritative,
|
||||
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
|
||||
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
|
||||
whenever it's available. */}
|
||||
{(bootstrap.data?.entry_badges?.system_unread_count ?? unread.length) ? (
|
||||
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
|
||||
{markingAll ? '标记中…' : '全部标为已读'}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="flex flex-col items-center gap-3 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-sm text-text-tertiary-4">暂无系统消息</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const { icon, className } = iconForEvent(item.event_type);
|
||||
const action = actionRoute(item);
|
||||
const isUnread = !item.read_at;
|
||||
return (
|
||||
<div key={item.id} className={`w-full flex-none rounded-xl bg-white p-[18px] md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}>
|
||||
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.created_at))}</div>
|
||||
<div className="mt-2 flex items-start gap-3">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg ${className}`}>
|
||||
<Icon name={icon} className="text-xl" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<strong className="flex-1 text-[15px] font-semibold text-text-heading">{item.title}</strong>
|
||||
{isUnread ? <b className="flex-none rounded bg-unread px-1 text-[10px] leading-[14px] text-white">新</b> : null}
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[#4a4f57]">{notificationText(item)}</p>
|
||||
{action ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isUnread) readMutation.mutate(item.id);
|
||||
navigate(action.to);
|
||||
}}
|
||||
className="self-start text-sm text-accent"
|
||||
>
|
||||
{action.label} ›
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
|
||||
other categories' cards (好友申请/群聊动态/版本更新) have none. */}
|
||||
{item.event_type.startsWith('security.') ? (
|
||||
<footer className="mt-3 border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">保持警惕 · MeetingIM 安全团队</footer>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{query.hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
className="py-2 text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-1
@@ -103,7 +103,7 @@
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
|
||||
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
/* screens/01-account.dc.html's 50px auth CTAs (登录/注册/提交...). */
|
||||
@@ -114,6 +114,19 @@
|
||||
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
/* MessageRow's jump-to-message flash (ConversationView, 查找聊天记录 result click) -- a brief
|
||||
background pulse marking the message the user asked to find, fading back to transparent so it
|
||||
doesn't look like a permanent selection state. */
|
||||
@keyframes jump-highlight {
|
||||
0%,
|
||||
30% {
|
||||
background-color: var(--color-accent-soft-bg);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-surface-page);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { imGet } from './client';
|
||||
|
||||
export interface AppRelease {
|
||||
latest_version: string;
|
||||
min_supported_version: string;
|
||||
update_type: 'none' | 'optional' | 'required';
|
||||
release_notes?: string;
|
||||
store_url?: string;
|
||||
package_url?: string;
|
||||
checksum?: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:2366-2367. `platform` here is `web`, matching this rewrite's own
|
||||
* target -- not confirmed against a real response (the doc only shows ios/android in its own
|
||||
* example), so `update_type` may come back differently for an unrecognized platform value. */
|
||||
export function getLatestRelease(currentVersion: string, platform: 'web' | 'ios' | 'android' = 'web') {
|
||||
const qs = new URLSearchParams({ platform, current_version: currentVersion, channel: 'stable' });
|
||||
return imGet<AppRelease>(`/app/releases/latest?${qs.toString()}`);
|
||||
}
|
||||
@@ -6,13 +6,6 @@ export interface MessagePolicy {
|
||||
recall_window_seconds?: number;
|
||||
}
|
||||
|
||||
export interface RealnameStatus {
|
||||
required: boolean;
|
||||
allow_skip: boolean;
|
||||
remind_after?: string;
|
||||
status: 'unverified' | 'pending' | 'verified' | string;
|
||||
}
|
||||
|
||||
/** README §2.1's "固定置顶两项" -- topic names are fixed/well-known on this backend (confirmed:
|
||||
* docs/IM-前后端对接文档.md:909, `file_assistant.topic:"slf"`, `system_messages.topic:"sys"`), not
|
||||
* per-user values the client has to discover another way. */
|
||||
@@ -29,7 +22,6 @@ export interface BootstrapEntry {
|
||||
export interface BootstrapConfig {
|
||||
message_policy?: MessagePolicy;
|
||||
limits?: { max_forward_targets?: number; [key: string]: unknown };
|
||||
security?: { realname?: RealnameStatus };
|
||||
features?: Record<string, boolean>;
|
||||
permissions?: Record<string, boolean>;
|
||||
modules?: unknown;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Call REST endpoints (docs/IM-前后端对接文档.md §8.12.1, lines 2066-2076). Confirmed live
|
||||
// (2026-09-08): `POST /v1/im/calls/rtc-join` is real and processes requests meaningfully (a probe
|
||||
// with a made-up call_id returned `404 CALL_NOT_FOUND`, not a generic route-404 or a
|
||||
// field-validation error) -- this is the Agora/声网 RTC path the doc's own closing paragraph
|
||||
// describes for "新客户端" (§8.12.1), NOT the raw-WebRTC/SDP-over-{note} path §0.5.4.7 and §8.1-8.11
|
||||
// otherwise describe throughout -- the old app's own real, shipped call code corroborates that the
|
||||
// raw-WebRTC path is the one that's actually broken in production (its own comments say the
|
||||
// call-invite pub is gated behind a feature flag that currently rejects it). The initial invite
|
||||
// still goes through the SAME `{pub}` message as the raw-WebRTC story (confirmed by the doc's own
|
||||
// sentence: "新客户端在发起方收到 ctrl.params.seq 后...分别调用 rtc-join" -- i.e. rtc-join happens
|
||||
// AFTER and IN ADDITION TO the pub-based invite, not instead of it) -- see send.ts's call-invite
|
||||
// helper. Ringing/accept/hang-up stay on Tinode `{note}` (lightweight state signals, not media
|
||||
// negotiation); only the SDP/ICE exchange is replaced by Agora's own SDK doing media transport.
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface RtcJoinResult {
|
||||
provider: string;
|
||||
auth_mode: string;
|
||||
app_id: string;
|
||||
channel_name: string;
|
||||
uid: string;
|
||||
token: string;
|
||||
token_expires_at: string;
|
||||
call_id: string;
|
||||
topic: string;
|
||||
seq: number;
|
||||
call_type: 'audio' | 'video';
|
||||
role: 'caller' | 'callee';
|
||||
}
|
||||
|
||||
export function joinRtcCall(topic: string, callId: string, seq: number, callType: 'audio' | 'video', role: 'caller' | 'callee') {
|
||||
return imPost<RtcJoinResult>('/calls/rtc-join', { topic, call_id: callId, seq, call_type: callType, role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real call `75728`) -- completely different from the doc's own minimal
|
||||
* example: `direction` is a string enum (`"outgoing"`/presumably `"incoming"`), not an `incoming`
|
||||
* boolean; there's no `peer` object at all, just a flat `peer_user_id` (a real `usr...` id, NOT the
|
||||
* `p2p...`-hashed form `topic` itself comes back as -- so unlike favorites.ts's `source.topic`, THIS
|
||||
* field can be resolved to a display name via the chat list); and there are four separate
|
||||
* timestamps (`created_at`/`started_at`/`finished_at`/`updated_at`), no single `ts`.
|
||||
*/
|
||||
export interface CallHistoryItem {
|
||||
topic: string;
|
||||
seq: number;
|
||||
call_id: string;
|
||||
call_type: 'audio' | 'video';
|
||||
direction: 'incoming' | 'outgoing' | string;
|
||||
status: string;
|
||||
result?: string;
|
||||
end_reason?: string;
|
||||
duration_ms: number;
|
||||
caller_id: string;
|
||||
callee_id: string;
|
||||
peer_user_id: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getCallHistory(cursor = '', limit = 20) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function getTopicCallHistory(topic: string, cursor = '') {
|
||||
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls/${encodeURIComponent(topic)}${cursor ? `?cursor=${cursor}` : ''}`);
|
||||
}
|
||||
@@ -39,6 +39,14 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||||
PERMISSION_DENIED: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
|
||||
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
|
||||
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
|
||||
// internal message or the bare reason string straight to the user for this exact reason code,
|
||||
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
|
||||
FORBIDDEN: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
|
||||
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
|
||||
NOT_FOUND: '内容不存在或已被删除',
|
||||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||||
@@ -59,22 +67,28 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
|
||||
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
|
||||
TOO_MANY_FILES: '文件数量超出限制',
|
||||
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
||||
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
||||
GROUP_MUTED: '你已被禁言,无法发言',
|
||||
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
|
||||
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
|
||||
INVITATION_EXPIRED: '邀请已过期',
|
||||
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
|
||||
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
|
||||
FILE_TOO_LARGE: '文件大小超出限制',
|
||||
FILE_FORBIDDEN: '没有权限访问此文件',
|
||||
FILE_UNAVAILABLE: '文件暂不可用',
|
||||
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
|
||||
REALNAME_REQUIRED: '需要先完成实名认证',
|
||||
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
|
||||
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
||||
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
||||
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
||||
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
||||
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
||||
USER_NOT_DISCOVERABLE: '未找到该用户',
|
||||
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
||||
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
||||
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
|
||||
// verification-code verify) -- keep generic enough to be accurate for both.
|
||||
CODE_INVALID: '验证码或独立密码不正确',
|
||||
@@ -167,6 +181,11 @@ export async function imRequest<T = unknown>(
|
||||
}
|
||||
if (!response.ok || payload?.code) {
|
||||
const known = translateReason(payload.reason, payload.details);
|
||||
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
|
||||
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
|
||||
// else in this client uniformly handled this before, so a session invalidated mid-use just made
|
||||
// every subsequent call fail the same way forever with no path back to a real login.
|
||||
if (auth && response.status === 401 && authToken()) forceReauth();
|
||||
throw new ImApiError(
|
||||
known || payload.message || payload.reason || response.statusText || '请求失败',
|
||||
payload.reason,
|
||||
@@ -177,6 +196,30 @@ export async function imRequest<T = unknown>(
|
||||
return normalizeResponse(payload) as T;
|
||||
}
|
||||
|
||||
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
|
||||
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
|
||||
* cache, any open modal's local state) resets from scratch rather than needing each of them to
|
||||
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
|
||||
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
|
||||
* token expires) only navigates once. */
|
||||
let reauthTriggered = false;
|
||||
function forceReauth() {
|
||||
if (reauthTriggered) return;
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
// The latch only needs to survive until the hard reload below actually happens -- that reload is
|
||||
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
|
||||
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
|
||||
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `<Navigate>`, which
|
||||
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
|
||||
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
|
||||
// same SPA instance.
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
reauthTriggered = true;
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Favorites REST endpoints (docs/IM-前后端对接文档.md ch.7.1-7.8). `favoriteMessages` (the create
|
||||
// side) already lives in messages.ts since it's called from a message's context menu, not from
|
||||
// here -- this file is the read/manage side (list, detail, delete, batch-delete, forward).
|
||||
import type { DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { imDelete, imGet, imPost } from './client';
|
||||
|
||||
export type FavoriteType = 'text' | 'image' | 'video' | 'voice' | 'file' | 'link';
|
||||
|
||||
/**
|
||||
* `snapshot`'s shape is polymorphic per `type`. Confirmed live (2026-09-08, real favorite
|
||||
* `fav_I2yn5Rdux0E`) that a `type:"text"` snapshot is NOT the doc's minimal `{text}` example (line
|
||||
* 1933) -- it's essentially the whole original message: `{content, from, head, seq, text, topic,
|
||||
* ts, type, version}`, i.e. `text`/`content` are duplicates of each other and `seq`/`topic`/`type`
|
||||
* duplicate the outer `source`/`type` fields. Genuinely useful new field: `from` -- a real sender
|
||||
* user id, WITHOUT its `usr` prefix (confirmed: my own id is `usrH5dvZ3ckWxo`, this field came back
|
||||
* as `H5dvZ3ckWxo`) -- a per-field prefix-stripping quirk not seen on any other endpoint in this
|
||||
* app, so re-adding `usr` before matching it against `contacts[].topic` elsewhere is a real but
|
||||
* fragile workaround, not done here yet (see the plan file's Phase 5 findings).
|
||||
*
|
||||
* Confirmed live (2026-09-08, data-rich test account) that this SAME "whole original message"
|
||||
* shape applies to EVERY type, not just `text` -- `image`/`video`/`voice`/`file` all come back as
|
||||
* `{content, from, head, seq, text, topic, ts, type, version}` too, where `content` is a real Drafty
|
||||
* object (never the doc's flat `snapshot.file_id`/`duration_ms`/`snapshot.file{...}` shape). The
|
||||
* actual per-type media data lives nested in `content.ent[].data`, keyed by Drafty entity type
|
||||
* (`tp`): `IM` (image), `VD` (video), `AU` (voice), `EX` (file). Voice/image/video carry their
|
||||
* payload INLINE as base64 in `data.val` (confirmed by decoding the base64 header: voice -> `RIFF`
|
||||
* WAV, image -> PNG signature, video's `val` -> WebM/EBML signature); video ALSO carries a separate
|
||||
* inline base64 JPEG poster frame in `data.preview` (distinct from `val`, which is the actual video
|
||||
* bytes) -- there is no separate thumbnail for image since the image IS the thumbnail. `file` has no
|
||||
* `val` at all, only an external `ref` (`/v0/file/s/{id}`), matching how `EX` already works in real
|
||||
* chat messages. The old flat `snapshot.file_id`/`thumbnail_file_id`/`duration_ms`/`file{...}` fields
|
||||
* below are kept as a defensive fallback (never observed live, but the doc's own worked examples
|
||||
* disagree with its prose here too -- see next paragraph -- so it's cheap insurance, not dead code).
|
||||
*
|
||||
* The doc's own worked examples also disagree with its OWN prose for `type:"file"`: §7.3's text
|
||||
* says file snapshots nest under `snapshot.file{name,size,mime,file_id}`, but the worked JSON
|
||||
* example (line 1936) has `file_id`/`name` FLAT on `snapshot` directly, no nesting -- both shapes
|
||||
* are typed as optional so either renders, trusting the concrete example over the prose.
|
||||
*/
|
||||
export interface Favorite {
|
||||
favorite_id: string;
|
||||
type: FavoriteType;
|
||||
snapshot: {
|
||||
text?: string;
|
||||
content?: DraftyContent | string;
|
||||
from?: string;
|
||||
matches?: Array<{ at: number; len: number }>;
|
||||
thumbnail_file_id?: string;
|
||||
file_id?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
mime?: string;
|
||||
file?: { name: string; size: number; mime: string; file_id: string };
|
||||
duration_ms?: number;
|
||||
waveform?: number[];
|
||||
url?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
risk_level?: string;
|
||||
};
|
||||
source?: { topic: string; seq: number };
|
||||
collected_at: string;
|
||||
availability: 'available' | 'removed' | string;
|
||||
permissions?: { can_copy?: boolean };
|
||||
}
|
||||
|
||||
/** Pulls one Drafty entity's `data` out of a favorite's `snapshot.content` by entity type (`tp`) --
|
||||
* see the `Favorite` doc comment above for why this, not the flat `snapshot.*` fields, is where the
|
||||
* real per-type media data for `image`/`video`/`voice`/`file` actually lives. */
|
||||
export function favoriteEntityData(content: Favorite['snapshot']['content'], tp: string): Record<string, unknown> | undefined {
|
||||
if (!content || typeof content === 'string') return undefined;
|
||||
return content.ent?.find((e) => e.tp === tp)?.data;
|
||||
}
|
||||
|
||||
export function getFavorites(type: FavoriteType | 'all' = 'all', query = '', cursor = '') {
|
||||
const qs = new URLSearchParams({ type });
|
||||
if (query) qs.set('q', query);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: Favorite[]; next_cursor?: string }>(`/favorites?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function getFavorite(favoriteId: string) {
|
||||
return imGet<Favorite>(`/favorites/${encodeURIComponent(favoriteId)}`);
|
||||
}
|
||||
|
||||
export function deleteFavorite(favoriteId: string) {
|
||||
return imDelete<{ favorite_id: string; deleted: boolean }>(`/favorites/${encodeURIComponent(favoriteId)}`);
|
||||
}
|
||||
|
||||
export function batchDeleteFavorites(favoriteIds: string[]) {
|
||||
return imPost<{ items: Array<{ favorite_id: string; deleted: boolean }>; failed?: Array<{ favorite_id: string; reason: string }> }>(
|
||||
'/favorites/batch-delete',
|
||||
{ favorite_ids: favoriteIds },
|
||||
);
|
||||
}
|
||||
|
||||
export function forwardFavorites(favoriteIds: string[], targetTopics: string[], comment = '') {
|
||||
return imPost<{
|
||||
items: Array<{ favorite_id: string; target_topic: string; status: string; seq?: number }>;
|
||||
failed?: Array<{ favorite_id: string; target_topic: string; reason: string }>;
|
||||
}>('/favorites/forward', { favorite_ids: favoriteIds, target_topics: targetTopics, comment });
|
||||
}
|
||||
@@ -3,16 +3,26 @@
|
||||
// direct-message-view.jsx) -- verified there, not guessed.
|
||||
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
||||
|
||||
/** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter
|
||||
* itself), so the contacts list groups by THIS field, not a client-side first-character guess like
|
||||
* CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */
|
||||
export interface Friend {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
remark?: string;
|
||||
avatar_file_id?: string;
|
||||
initial?: string;
|
||||
topic?: string;
|
||||
relationship?: string;
|
||||
online_visibility?: string;
|
||||
capabilities?: { can_send?: boolean; can_call?: boolean };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function getFriends(query = '', cursor = '') {
|
||||
export function getFriends(query = '', cursor = '', initial = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (query) qs.set('query', query);
|
||||
if (initial) qs.set('initial', initial);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
|
||||
@@ -26,15 +36,22 @@ export function updateFriendRemark(userId: string, remark: string) {
|
||||
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
|
||||
}
|
||||
|
||||
/** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's
|
||||
* nothing in the response describing what UI affordance to show -- that's derived client-side
|
||||
* from `relationship`/`can_add` instead), and `avatar_file_id` is present. */
|
||||
export interface UserSearchResult {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
relationship: 'none' | 'friend' | 'pending' | string;
|
||||
avatar_file_id?: string;
|
||||
public_account?: string;
|
||||
// docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare
|
||||
// "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'`
|
||||
// comparison could ever match either real pending state).
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
can_add: boolean;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export function searchUsers(query: string, type: 'account' = 'account', cursor = '') {
|
||||
export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') {
|
||||
const qs = new URLSearchParams({ q: query, type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
|
||||
@@ -49,6 +66,9 @@ export function sendFriendRequest(targetUserId: string, message?: string, source
|
||||
});
|
||||
}
|
||||
|
||||
/** `source` and `created_at` (doc line 1419's worked example) were missing from this file's
|
||||
* original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at`
|
||||
* is mentioned in the doc's prose but not its own worked example, kept optional either way. */
|
||||
export interface FriendRequest {
|
||||
request_id: string;
|
||||
applicant_id: string;
|
||||
@@ -56,12 +76,22 @@ export interface FriendRequest {
|
||||
direction: 'inbound' | 'outbound';
|
||||
status: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
peer?: { user_id: string; display_name?: string };
|
||||
topic: string;
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
export function getFriendRequests() {
|
||||
return imGet<{ items: FriendRequest[]; next_cursor?: string }>('/friend-requests');
|
||||
/** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the
|
||||
* endpoint with no filters at all, whose default direction/status scope isn't documented; the
|
||||
* design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together
|
||||
* (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on
|
||||
* whatever an unfiltered call happens to return. */
|
||||
export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') {
|
||||
const qs = new URLSearchParams({ direction, status });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function acceptFriendRequest(requestId: string, version?: number) {
|
||||
@@ -72,6 +102,14 @@ export function rejectFriendRequest(requestId: string, version?: number) {
|
||||
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the
|
||||
* doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess.
|
||||
* `remark` was also confirmed absent from a real friend's response even though the doc's worked
|
||||
* example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile.
|
||||
* display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap
|
||||
* between the doc and what this deployment actually returns right now.
|
||||
*/
|
||||
export interface UserProfile {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
@@ -81,8 +119,11 @@ export interface UserProfile {
|
||||
region?: string;
|
||||
public_account?: string;
|
||||
signature?: string;
|
||||
relationship: string;
|
||||
// Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific
|
||||
// pending states, never a bare "pending".
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
topic?: string;
|
||||
capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean };
|
||||
}
|
||||
|
||||
/** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's
|
||||
@@ -114,6 +155,15 @@ export function unblockUser(userId: string) {
|
||||
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this
|
||||
* backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug
|
||||
* that comes from assuming a 200 means every item succeeded). */
|
||||
export function batchUnblockUsers(userIds: string[]) {
|
||||
return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', {
|
||||
user_ids: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
export interface BlockedUser {
|
||||
user: { user_id: string; display_name?: string; avatar_file_id?: string };
|
||||
blocked_at: string;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
|
||||
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
|
||||
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
|
||||
// instead of the doc where they conflict (see each function's own comment):
|
||||
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
|
||||
// - group-files search param is `q`, not `query`
|
||||
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
|
||||
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
|
||||
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
|
||||
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
|
||||
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
|
||||
import { fetchAllPages } from './client';
|
||||
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
||||
|
||||
export interface GroupPermissions {
|
||||
can_edit_profile?: boolean;
|
||||
can_invite_members?: boolean;
|
||||
can_manage_members?: boolean;
|
||||
can_manage_settings?: boolean;
|
||||
can_leave?: boolean;
|
||||
can_mention_all?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupSummaryItem {
|
||||
topic: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
member_count: number;
|
||||
my_role: GroupRole;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (category) qs.set('category', category);
|
||||
if (query) qs.set('query', query);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export interface GroupProfile {
|
||||
topic: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
member_count: number;
|
||||
version: number;
|
||||
permissions?: GroupPermissions;
|
||||
}
|
||||
|
||||
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
|
||||
return imPost<GroupProfile>('/groups', body);
|
||||
}
|
||||
|
||||
export function getGroup(topic: string) {
|
||||
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
|
||||
}
|
||||
|
||||
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
|
||||
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
|
||||
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
|
||||
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
|
||||
* example for this response, so this was a guess until checked against the real payload. */
|
||||
export interface GroupGovernance {
|
||||
join_approval: boolean;
|
||||
join_approval_editable?: boolean;
|
||||
mute_all: boolean;
|
||||
mute_all_editable?: boolean;
|
||||
member_upload_files: boolean;
|
||||
member_upload_files_editable?: boolean;
|
||||
member_private_chat: boolean;
|
||||
member_private_chat_editable?: boolean;
|
||||
member_invite: boolean;
|
||||
member_invite_editable?: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
|
||||
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
|
||||
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
|
||||
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
|
||||
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
|
||||
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
|
||||
* file hadn't typed at all until this pass.
|
||||
*/
|
||||
export interface GroupSettings {
|
||||
topic: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar_file_id?: string;
|
||||
owner: string;
|
||||
my_role: GroupRole;
|
||||
permissions?: GroupPermissions;
|
||||
member_count: number;
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
message_retention?: string;
|
||||
governance?: GroupGovernance;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function getGroupSettings(topic: string) {
|
||||
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
|
||||
}
|
||||
|
||||
export interface GroupMemberSettings {
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
|
||||
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
|
||||
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
|
||||
* locking at all (the server bumps its own stored version unconditionally on every call); the
|
||||
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
|
||||
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
|
||||
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
|
||||
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
|
||||
}
|
||||
|
||||
export function leaveGroup(topic: string) {
|
||||
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
|
||||
}
|
||||
|
||||
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */
|
||||
export function disbandGroup(topic: string) {
|
||||
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
|
||||
}
|
||||
|
||||
export type GroupRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
avatar_file_id?: string;
|
||||
role: GroupRole;
|
||||
mentionable?: boolean;
|
||||
eligible?: boolean;
|
||||
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
|
||||
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
|
||||
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
|
||||
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
|
||||
ineligible_reason?: string;
|
||||
acs?: { want?: string; given?: string; mode?: string };
|
||||
}
|
||||
|
||||
/** `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
|
||||
* only getGroupMemberProfile() does. Don't read nickname off this list. */
|
||||
export function getGroupMembers(
|
||||
topic: string,
|
||||
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.query) qs.set('query', opts.query);
|
||||
if (opts.cursor) qs.set('cursor', opts.cursor);
|
||||
if (opts.role) qs.set('role', opts.role);
|
||||
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
|
||||
if (opts.mentionable) qs.set('mentionable', 'true');
|
||||
if (opts.muteEligible) qs.set('mute_eligible', 'true');
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
|
||||
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllGroupMembers(
|
||||
topic: string,
|
||||
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
||||
) {
|
||||
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
|
||||
}
|
||||
|
||||
export interface GroupMemberProfile {
|
||||
topic: string;
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
group_nickname?: string;
|
||||
role: GroupRole;
|
||||
relationship?: string;
|
||||
capabilities?: { can_send?: boolean; can_call?: boolean };
|
||||
}
|
||||
|
||||
/** `topic` must be the `grp...` id, never a `usr...` id -- confirmed by the protocol doc's own warning.
|
||||
* Do NOT call this with your own user_id: the backend computes a self/other relationship context
|
||||
* for this generic path that errors out when both sides are the same uid (confirmed live 2026-09-09,
|
||||
* stable 400 MALFORMED_REQUEST). Use `getMyGroupProfile()` for your own group nickname instead --
|
||||
* the backend added a dedicated `members/me/profile` route that sidesteps this entirely. */
|
||||
export function getGroupMemberProfile(topic: string, userId: string) {
|
||||
return imGet<GroupMemberProfile>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}/profile`);
|
||||
}
|
||||
|
||||
/** Dedicated self-profile route -- unlike getGroupMemberProfile(topic, myUserId), this one actually
|
||||
* works (see that function's own warning) and, as a bonus, returns the real optimistic-lock `version`
|
||||
* on every read, so callers no longer need to remember the last save's version between edits. */
|
||||
export function getMyGroupProfile(topic: string) {
|
||||
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
|
||||
}
|
||||
|
||||
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
|
||||
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
|
||||
nickname,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGroupRoleLimits(topic: string) {
|
||||
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
|
||||
}
|
||||
|
||||
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
|
||||
return imPost<{
|
||||
succeeded: Array<{ user_id: string; role: GroupRole }>;
|
||||
failed?: Array<{ user_id: string; reason: string }>;
|
||||
admins: GroupMember[];
|
||||
version: number;
|
||||
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
|
||||
}
|
||||
|
||||
export function removeGroupAdmin(topic: string, userId: string, version: number) {
|
||||
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
|
||||
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
|
||||
{ headers: { 'If-Match': String(version) } },
|
||||
);
|
||||
}
|
||||
|
||||
export interface GroupMute {
|
||||
user: { user_id: string; display_name?: string };
|
||||
muted_by?: string;
|
||||
muted_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
||||
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
|
||||
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
|
||||
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
|
||||
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
|
||||
`/groups/${encodeURIComponent(topic)}/mutes`,
|
||||
{ members, reason },
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupMutes(topic: string, cursor = '') {
|
||||
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
|
||||
}
|
||||
|
||||
export function unmuteGroupMember(topic: string, userId: string) {
|
||||
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet
|
||||
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
|
||||
* that's added. Kept typed so the group-management UI can be built ahead of that. */
|
||||
export function transferGroupOwner(topic: string, targetUserId: string, version: number) {
|
||||
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
|
||||
target_user_id: targetUserId,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGroupGovernance(topic: string) {
|
||||
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
|
||||
}
|
||||
|
||||
export function updateGroupGovernance(
|
||||
topic: string,
|
||||
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
|
||||
) {
|
||||
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
||||
}
|
||||
|
||||
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
|
||||
return imPost<{ items: Array<{ user_id: string; status: string; invitation_id?: string }> }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
||||
user_ids: userIds,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
|
||||
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
|
||||
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
|
||||
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
|
||||
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
|
||||
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
|
||||
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
|
||||
export interface GroupJoinRequest {
|
||||
request_id: string;
|
||||
user_id: string;
|
||||
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
|
||||
* a self-initiated request (search/QR). */
|
||||
inviter_id: string;
|
||||
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
|
||||
message?: string;
|
||||
status: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
handled_by?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
|
||||
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
|
||||
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
|
||||
* "request to join" with no QR code involved would omit both, though this app doesn't have another
|
||||
* UI entry point that reaches a group's join-requests without already being a member (no public
|
||||
* group directory/search exists anywhere in this codebase or its design handoff). */
|
||||
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
|
||||
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
|
||||
const qs = new URLSearchParams({ status });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
|
||||
return imPost<{ request_id: string; status: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
|
||||
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
|
||||
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
|
||||
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
|
||||
return imPost<{ request_id: string; status: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmed live: `announcement` is always a present object, even with nothing published --
|
||||
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
|
||||
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
|
||||
export function getGroupSummary(topic: string) {
|
||||
return imGet<{
|
||||
pending_join_request_count: number;
|
||||
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
|
||||
permissions?: GroupPermissions;
|
||||
}>(`/groups/${encodeURIComponent(topic)}/summary`);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
||||
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
||||
* list for a display name/role, same as any other bare user id in this API. `read_count`/
|
||||
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
|
||||
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
|
||||
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
|
||||
* callers must check `content` for emptiness, not object existence. */
|
||||
export interface GroupAnnouncement {
|
||||
announcement_id: string;
|
||||
topic: string;
|
||||
content: string;
|
||||
version: number;
|
||||
published_at: string;
|
||||
author: string;
|
||||
permissions?: { can_edit?: boolean };
|
||||
}
|
||||
|
||||
export function getLatestGroupAnnouncement(topic: string) {
|
||||
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
|
||||
}
|
||||
|
||||
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
|
||||
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
|
||||
}
|
||||
|
||||
export function readGroupAnnouncement(topic: string, announcementId: string) {
|
||||
return imPost<{ announcement_id: string; read_at: string }>(
|
||||
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
|
||||
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
|
||||
* previously declared -- undetected until now because this list only ever rendered its own
|
||||
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
|
||||
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
|
||||
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
|
||||
export interface GroupFile {
|
||||
group_file_id: string;
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
uploader_id?: string;
|
||||
created_at: string;
|
||||
download_state?: string;
|
||||
}
|
||||
|
||||
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
|
||||
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
|
||||
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (q) qs.set('q', q);
|
||||
if (type) qs.set('type', type);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
|
||||
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
|
||||
* set below.
|
||||
*/
|
||||
export function addGroupFile(
|
||||
topic: string,
|
||||
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
|
||||
) {
|
||||
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
|
||||
}
|
||||
|
||||
export function deleteGroupFile(topic: string, groupFileId: string) {
|
||||
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
|
||||
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
|
||||
// calls this then hard-deletes the message from the local topic cache itself.
|
||||
import type { DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface RecallResult {
|
||||
@@ -57,7 +59,12 @@ export interface SearchHit {
|
||||
matches?: Array<{ start: number; end: number }>;
|
||||
snippet?: string;
|
||||
snapshot?: {
|
||||
content?: { txt?: string; fmt?: unknown[]; ent?: DraftyEntity[] };
|
||||
// A plain, unformatted text message's `content` is confirmed LIVE to arrive as a bare string
|
||||
// (not the `{txt,fmt,ent}` object shape this field was originally typed as) -- the same
|
||||
// string-vs-object duality `renderDrafty` (lib/messages/drafty.tsx) already handles for every
|
||||
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
|
||||
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
|
||||
content?: DraftyContent | string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// System notifications REST endpoints (docs/IM-前后端对接文档.md:890-912). Confirmed live field
|
||||
// facts (via a research pass over docs/IM-后端问题清单*.md, 问题30/新增32/33) that diverge from
|
||||
// this doc's own worked example -- see each item's comment below.
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface SystemNotification {
|
||||
id: string;
|
||||
/** Real values seen include the `friend.*`/`group.*`/`message.*`/`meeting.*`/`security.risk.*`
|
||||
* family from the WS business-event table (doc lines 408-420) -- route on this, not `action.type`
|
||||
* (see below). */
|
||||
event_type: string;
|
||||
title: string;
|
||||
/** Confirmed live (问题30): this is sometimes a raw machine code (`"accepted"`,
|
||||
* `"security.risk.cancel"`) instead of human text, and can be empty. Never render blindly --
|
||||
* always run through NOTIFICATION_TEXT first. */
|
||||
summary: string;
|
||||
/**
|
||||
* Confirmed live (问题30) the shape varies by source and does NOT reliably match this doc's own
|
||||
* worked example (`{"type":"friend_request","request_id":"frq_1"}`) -- a real friend-request
|
||||
* notification's `action` was just `{"user_id":"usr..."}`, no `type` key at all. Group events:
|
||||
* `{topic}`. Meeting events: inconsistently either `{module,resource_id}` or `{meeting_id}`.
|
||||
* New-device-login's documented `{device_name,platform,ip_masked,login_at,security_action}`
|
||||
* shape (doc line 891) is confirmed NOT IMPLEMENTED server-side at all (新增12, still open) --
|
||||
* never assume these fields are populated.
|
||||
*/
|
||||
action?: {
|
||||
type?: string;
|
||||
request_id?: string;
|
||||
topic?: string;
|
||||
user_id?: string;
|
||||
device?: string;
|
||||
ip?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function getSystemNotifications(cursor = '', limit = 20) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: SystemNotification[]; next_cursor?: string }>(`/system-notifications?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (新增32, still open): marking an ALREADY-read notification echoes back whatever
|
||||
* `read_at` you send as if it succeeded, but the stored value stays frozen at the first real write
|
||||
* -- the response lies about updating it. Never treat this response's `read_at` as authoritative;
|
||||
* only use it to flip local `read_at` truthy/falsy, matching the old app's own workaround.
|
||||
*/
|
||||
export function markSystemNotificationRead(id: string, readAt = new Date().toISOString()) {
|
||||
return imPost<{ status: string; id: string; read_at: string }>(`/system-notifications/${encodeURIComponent(id)}/read`, { read_at: readAt });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Own-profile/privacy REST endpoints (docs/IM-前后端对接文档.md ch.10, lines 2281-2397). Confirmed
|
||||
// against the doc's own worked examples (table lines 2490-2494) and, where the doc has no example
|
||||
// at all, against the rewritten old app's real working code (im_webapp/src/views/personal-settings-
|
||||
// view.jsx) -- flagged per-field below.
|
||||
import { imGet, imPatch } from './client';
|
||||
|
||||
/** `GET /v1/im/me` -- thin summary for a home/header card. Distinct from `getMyProfile` below (the
|
||||
* full profile-edit source) -- confirmed live these are two separate endpoints with different
|
||||
* field sets, not one response read two ways. */
|
||||
export interface MeSummary {
|
||||
user_id: string;
|
||||
profile: { public_account: string; nickname: string; avatar_file_id?: string; signature?: string; version: number };
|
||||
profile_version: number;
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return imGet<MeSummary>('/me');
|
||||
}
|
||||
|
||||
export interface MyProfile {
|
||||
avatar_file_id?: string;
|
||||
nickname: string;
|
||||
birth_date?: string;
|
||||
signature?: string;
|
||||
gender?: 'male' | 'female' | 'other' | string;
|
||||
region?: string;
|
||||
email?: string;
|
||||
public_account: string;
|
||||
version: number;
|
||||
field_rules?: { nickname?: { min_length: number; max_length: number }; signature?: { max_length: number } };
|
||||
}
|
||||
|
||||
export function getMyProfile() {
|
||||
return imGet<MyProfile>('/me/profile');
|
||||
}
|
||||
|
||||
/** doc.md:2492 only shows nickname/signature in its worked example; gender/region have no doc
|
||||
* example at all but the old app's real, working code PATCHes them the identical way (`gender`
|
||||
* enum confirmed `male|female|other`, personal-settings-view.jsx:214). */
|
||||
export function updateMyProfile(patch: Partial<Pick<MyProfile, 'nickname' | 'birth_date' | 'signature' | 'gender' | 'region' | 'avatar_file_id' | 'email'>> & { version: number }) {
|
||||
return imPatch<MyProfile>('/me/profile', patch);
|
||||
}
|
||||
|
||||
export interface AddMeMethods {
|
||||
account: boolean;
|
||||
phone: boolean;
|
||||
qr: boolean;
|
||||
group: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `profile_visibility`/`last_seen_visibility` enum confirmed as `everyone|friends|nobody` only via
|
||||
* the old app's own code comment (the doc's worked examples only ever show `"friends"`) -- the
|
||||
* server's validator 400s `contacts`/`hidden` outright, don't offer those as options.
|
||||
*
|
||||
* There is NO "加我为好友时需要验证" field anywhere in this response -- confirmed absent both in
|
||||
* the doc and in the old app's own `UserPrivacySettings` research (personal-settings-view.jsx:534-
|
||||
* 538: the design reference shows a control for it, but neither the response shape nor the group-
|
||||
* invite backend logic has anything to gate). Don't build that toggle.
|
||||
*/
|
||||
export interface PrivacySettings {
|
||||
add_me_methods: AddMeMethods;
|
||||
profile_visibility: 'everyone' | 'friends' | 'nobody' | string;
|
||||
last_seen_visibility: 'everyone' | 'friends' | 'nobody' | string;
|
||||
read_receipts: boolean;
|
||||
version: number;
|
||||
editable?: Partial<Record<keyof PrivacySettings, boolean>>;
|
||||
}
|
||||
|
||||
export function getMyPrivacy() {
|
||||
return imGet<PrivacySettings>('/me/privacy');
|
||||
}
|
||||
|
||||
export function updateMyPrivacy(
|
||||
patch: Partial<{ add_me_methods: Partial<AddMeMethods>; profile_visibility: string; last_seen_visibility: string; read_receipts: boolean }> & {
|
||||
version: number;
|
||||
},
|
||||
) {
|
||||
return imPatch<PrivacySettings>('/me/privacy', patch);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// QR code REST endpoints (docs/IM-前后端对接文档.md §5.15.1). Confirmed LIVE against the real
|
||||
// deployment (2026-09-08, two fresh test accounts) -- `resolveQRCode`'s real response shape nests
|
||||
// `relationship`/`can_add`/`action`/`public_account` all under `user`, unlike the doc's own worked
|
||||
// example which shows them as siblings of `user` at the top level. Also confirmed live: resolving a
|
||||
// code against the SAME account that minted it is rejected (`400 MALFORMED_REQUEST`, `field:"token"`,
|
||||
// message "malformed request" -- distinct from a genuinely garbage token, which instead gets message
|
||||
// "qr code invalid" with no `details.field` at all) -- cross-account resolution works correctly.
|
||||
//
|
||||
// The security rationale for why this exists at all instead of just encoding a raw user id (doc line
|
||||
// 1299): "二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录
|
||||
// Token" -- the QR image payload is `qr_payload` (an `im://qr?token=...&purpose=...` URI), not
|
||||
// `qr_token` alone, matching the old app's own `im://qr` scheme (im_webapp/src/views/tinode-web.jsx
|
||||
// confirmed this exact prefix check before ever sending a scanned string to `resolve`).
|
||||
import { imPost } from './client';
|
||||
|
||||
export interface QRCodeDisplayProfile {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
public_account?: string;
|
||||
}
|
||||
|
||||
export interface MyQRCode {
|
||||
qr_token: string;
|
||||
/** What actually gets encoded into the QR image -- `im://qr?token=...&purpose=...`. */
|
||||
qr_payload: string;
|
||||
expires_at: string;
|
||||
display_profile: QRCodeDisplayProfile;
|
||||
}
|
||||
|
||||
export function createMyQRCode(expiresInSeconds = 86400) {
|
||||
return imPost<MyQRCode>('/me/qrcodes', { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
|
||||
/** `identifier`: user_id/username/public_account -- viewing a specific friend's own QR card. */
|
||||
export function createUserQRCode(identifier: string, expiresInSeconds = 86400) {
|
||||
return imPost<MyQRCode>(`/users/${encodeURIComponent(identifier)}/qrcodes`, { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
|
||||
export interface QRResolveUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
public_account?: string;
|
||||
// docs/IM-前后端对接文档.md:1289 -- same real direction-specific enum as friends.ts's
|
||||
// UserSearchResult/UserProfile, never a bare "pending".
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
can_add: boolean;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** Confirmed LIVE (2026-09-08, same account that minted the code -- a real cross-account "scan a
|
||||
* stranger's group code" resolve hasn't been exercised, this session only had one account free):
|
||||
* a `purpose:"join_group"` resolve nests the group under `group` (NOT `group_snapshot`, despite
|
||||
* the mint response using that key -- resolve and mint use different field names for the same
|
||||
* shape), and carries a `status` this specific case's doc example (line 1737, mint-only) never
|
||||
* showed: resolving your own group's code (i.e. already a member) comes back `status:"joined"`.
|
||||
* What a genuinely-not-yet-a-member resolve returns for `status` is unconfirmed -- treated as
|
||||
* "anything other than 'joined'" in QRScannerModal/AddFriendModal, i.e. the general case that still
|
||||
* needs a real join-request submission. */
|
||||
export interface QRResolveGroup {
|
||||
topic: string;
|
||||
name: string;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
export interface QRResolveResult {
|
||||
purpose: 'add_friend' | 'join_group' | string;
|
||||
source: string;
|
||||
source_topic: string;
|
||||
expires_at: string;
|
||||
user?: QRResolveUser;
|
||||
group?: QRResolveGroup;
|
||||
group_snapshot?: QRResolveGroup;
|
||||
/** join_group only -- `"joined"` means the scanning account is already a member (confirmed live);
|
||||
* any other value (including absent) is treated as "not a member yet, submit a join request". */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function resolveQRCode(token: string, purpose: 'add_friend' | 'join_group' | string = 'add_friend') {
|
||||
return imPost<QRResolveResult>('/qrcodes/resolve', { token, purpose });
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1590 -- 群管理员/群主展示的入群二维码 (mirrors createMyQRCode/
|
||||
* createUserQRCode's shape, just scoped to a group and carrying `group_snapshot` instead of a
|
||||
* personal `display_profile`). */
|
||||
export interface GroupQRCode {
|
||||
qr_token: string;
|
||||
qr_payload: string;
|
||||
expires_at: string;
|
||||
group_snapshot: QRResolveGroup;
|
||||
}
|
||||
|
||||
export function createGroupQRCode(topic: string, expiresInSeconds = 86400) {
|
||||
return imPost<GroupQRCode>(`/groups/${encodeURIComponent(topic)}/qrcodes`, { expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
@@ -12,11 +12,16 @@ import { API_KEY } from '@/lib/config';
|
||||
import { authToken, fileBase } from './client';
|
||||
import { getTinode } from '../tinode/connection';
|
||||
|
||||
/** Confirmed live: the real `ctrl.params` shape is `{file_id, url, expires}` -- no `name`/`size`/
|
||||
* `mime` at all. Every existing caller (avatar upload, chat attachments) only ever reads `.url`, so
|
||||
* this went unnoticed until a caller needing the other three (GroupFilesModal's addGroupFile
|
||||
* registration) actually tried to read them and got `undefined` silently sent as `file_name`. Callers
|
||||
* that need name/size/mime must read them off the original `File` object they already have, not
|
||||
* expect the upload response to echo them back. */
|
||||
export interface UploadedFile {
|
||||
url: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
file_id: string;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { IAgoraRTCClient, ICameraVideoTrack, IMicrophoneAudioTrack, IRemoteVideoTrack } from 'agora-rtc-sdk-ng';
|
||||
|
||||
import type { RtcJoinResult } from '@/lib/api/calls';
|
||||
|
||||
/**
|
||||
* Thin wrapper around the Agora Web SDK -- this app's actual media transport (see lib/api/calls.ts's
|
||||
* header comment for why: confirmed live that `POST /v1/im/calls/rtc-join` is the real, working
|
||||
* path, not raw WebRTC/SDP-over-Tinode-`{note}`). Kept as module-level state, not Zustand -- an
|
||||
* `IAgoraRTCClient`/media-track instance isn't meaningful React state (nothing should re-render
|
||||
* because a client object's internal fields changed), and playing a video track is an imperative
|
||||
* `track.play(domElement)` call, not a declarative prop.
|
||||
*
|
||||
* The SDK itself (~1.6MB) is dynamically imported inside `joinCall`, not at module top level --
|
||||
* loading it eagerly would put a large media SDK in every page's initial bundle even though most
|
||||
* sessions never place a call.
|
||||
*/
|
||||
let client: IAgoraRTCClient | null = null;
|
||||
let localAudioTrack: IMicrophoneAudioTrack | null = null;
|
||||
let localVideoTrack: ICameraVideoTrack | null = null;
|
||||
let remoteVideoTrack: IRemoteVideoTrack | null = null;
|
||||
// `createCameraVideoTrack()` (see joinCall below) opens whatever camera the browser/OS defaults to
|
||||
// without asking -- normally the front ("user") one, so this starts in sync with that assumption
|
||||
// rather than guessing wrong on the very first flip.
|
||||
let usingBackCamera = false;
|
||||
|
||||
export interface JoinedCall {
|
||||
localVideoTrack: ICameraVideoTrack | null;
|
||||
}
|
||||
|
||||
/** Joins the Agora channel named by the rtc-join response and publishes local mic (+ camera, for a
|
||||
* video call) tracks. Requesting `getUserMedia`-equivalent access here, not earlier -- matches the
|
||||
* old app's own real timing (captures media once the call is actually about to carry it, not on
|
||||
* page load). */
|
||||
export async function joinCall(join: RtcJoinResult, callType: 'audio' | 'video', onRemoteUser: (hasVideo: boolean) => void): Promise<JoinedCall> {
|
||||
const { default: AgoraRTC } = await import('agora-rtc-sdk-ng');
|
||||
client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
|
||||
client.on('user-published', async (user, mediaType) => {
|
||||
await client!.subscribe(user, mediaType);
|
||||
if (mediaType === 'video' && user.videoTrack) {
|
||||
remoteVideoTrack = user.videoTrack;
|
||||
onRemoteUser(true);
|
||||
}
|
||||
if (mediaType === 'audio' && user.audioTrack) {
|
||||
user.audioTrack.play();
|
||||
}
|
||||
});
|
||||
client.on('user-unpublished', (_user, mediaType) => {
|
||||
if (mediaType === 'video') {
|
||||
remoteVideoTrack = null;
|
||||
onRemoteUser(false);
|
||||
}
|
||||
});
|
||||
|
||||
await client.join(join.app_id, join.channel_name, join.token, join.uid);
|
||||
|
||||
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
|
||||
const tracks: Array<IMicrophoneAudioTrack | ICameraVideoTrack> = [localAudioTrack];
|
||||
if (callType === 'video') {
|
||||
localVideoTrack = await AgoraRTC.createCameraVideoTrack();
|
||||
tracks.push(localVideoTrack);
|
||||
}
|
||||
await client.publish(tracks);
|
||||
|
||||
return { localVideoTrack };
|
||||
}
|
||||
|
||||
export function playRemoteVideo(el: HTMLElement) {
|
||||
remoteVideoTrack?.play(el);
|
||||
}
|
||||
|
||||
export function playLocalVideo(el: HTMLElement) {
|
||||
localVideoTrack?.play(el);
|
||||
}
|
||||
|
||||
// Was fire-and-forget (`.setEnabled()`'s own promise never returned) -- a rejection (track already
|
||||
// closed, permission revoked mid-call) had nowhere to go but an unhandled rejection, and the caller
|
||||
// (stores/calls.ts's toggleMute) had no way to know it failed and roll the button back. Made async +
|
||||
// re-thrown so it can.
|
||||
export async function setMuted(muted: boolean) {
|
||||
if (!localAudioTrack) return;
|
||||
await localAudioTrack.setEnabled(!muted);
|
||||
}
|
||||
|
||||
export async function setCameraOff(off: boolean) {
|
||||
if (!localVideoTrack) return;
|
||||
await localVideoTrack.setEnabled(!off);
|
||||
}
|
||||
|
||||
/** Front/back camera switch (06-calls.dc.html #1f's `flip_camera_ios` mobile header icon --
|
||||
* previously a `notImplemented()` placeholder, see Part G's requirements-diff findings). Uses
|
||||
* `setDevice({facingMode})` rather than enumerating `AgoraRTC.getCameras()` and guessing which
|
||||
* device is "the other one" by its (unstandardized, often-empty-until-permission-granted) label --
|
||||
* `facingMode` is the browser's own signal for which physical camera to use, no guessing needed.
|
||||
* NOT verified against a real device: this sandboxed environment has no camera, the same
|
||||
* documented limitation that's kept full 2-way video verification out of reach for this whole
|
||||
* feature (see the plan file's calls section) -- correct per the SDK's own typed API, unverified
|
||||
* live. */
|
||||
export async function switchCamera(): Promise<void> {
|
||||
if (!localVideoTrack) return;
|
||||
const next = !usingBackCamera;
|
||||
usingBackCamera = next;
|
||||
try {
|
||||
await localVideoTrack.setDevice({ facingMode: next ? 'environment' : 'user' });
|
||||
} catch (err) {
|
||||
// Roll the optimistic flip back and rethrow -- matches setMuted/setCameraOff's own contract
|
||||
// (this file stays a thin, rethrowing wrapper; stores/calls.ts's own toggleMute/toggleCamera
|
||||
// are what catch and surface `mediaError`, and switchCamera's caller needs the same treatment,
|
||||
// not a second, inconsistent error-handling convention just for this one control).
|
||||
usingBackCamera = !next;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRemoteVideo(): boolean {
|
||||
return !!remoteVideoTrack;
|
||||
}
|
||||
|
||||
/** Tears down everything -- call on hang-up from either side, or on error. Safe to call more than
|
||||
* once (every step is guarded by the ref still being set). */
|
||||
export async function leaveCall() {
|
||||
localAudioTrack?.close();
|
||||
localAudioTrack = null;
|
||||
localVideoTrack?.close();
|
||||
localVideoTrack = null;
|
||||
remoteVideoTrack = null;
|
||||
usingBackCamera = false;
|
||||
// Capture and null out the module-level ref BEFORE awaiting -- a rapid hang-up-then-redial can let
|
||||
// a new joinCall() reassign `client` to a fresh instance while this leave() is still in flight;
|
||||
// without capturing it first, the two lines after the await would operate on the NEW call's
|
||||
// client instead of the one actually being left.
|
||||
const c = client;
|
||||
client = null;
|
||||
if (c) {
|
||||
await c.leave().catch(() => {});
|
||||
c.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings
|
||||
// themselves carry over unchanged even though this is a from-scratch rewrite.
|
||||
|
||||
/** Version-independent product name (Firebase app name, youth-mode copy, etc.). */
|
||||
/** Version-independent product name (Firebase app name, in-app copy, etc.). */
|
||||
export const BASE_APP_NAME = 'MeetingIM';
|
||||
|
||||
/** Name used in the Tinode client's User-Agent string. */
|
||||
@@ -62,7 +62,8 @@ export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
|
||||
/** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
|
||||
export const MAX_PEER_TITLE_LENGTH = 24;
|
||||
|
||||
export const MESSAGE_PREVIEW_LENGTH = 80;
|
||||
@@ -82,4 +83,4 @@ export const QRCODE_SIZE = 128;
|
||||
export const WAKE_UP_TIMEOUT = 80_000;
|
||||
export const WAKE_UP_TICK = 1_000;
|
||||
|
||||
export const TOAST_DURATION = 3_000;
|
||||
export const TOAST_DURATION = 2_500;
|
||||
|
||||
+60
-7
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
|
||||
|
||||
@@ -65,14 +65,67 @@ export function useMediaQuery(query: string): boolean {
|
||||
return matches;
|
||||
}
|
||||
|
||||
interface EscapeStackEntry {
|
||||
cb: () => void;
|
||||
}
|
||||
const escapeStack: EscapeStackEntry[] = [];
|
||||
let escapeListenerAttached = false;
|
||||
|
||||
function handleGlobalEscape(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return;
|
||||
// Only the TOPMOST (most-recently-mounted) overlay closes -- without this, opening a QR scanner
|
||||
// modal from inside AddFriendModal meant BOTH modals' independent useEscapeKey listeners fired on
|
||||
// the same keydown, so pressing Escape to back out of just the scanner closed the whole add-friend
|
||||
// flow too (the backdrop-click equivalent of this was already fixed via Fragment-sibling nesting,
|
||||
// but that DOM-structural fix does nothing for a global keydown listener with no concept of nesting).
|
||||
escapeStack[escapeStack.length - 1]?.cb();
|
||||
}
|
||||
|
||||
/** Closes an overlay (modal/menu/popover) on Escape -- every one of this app's own overlays already
|
||||
* closes on backdrop click, but none had a keyboard equivalent. */
|
||||
* closes on backdrop click, but none had a keyboard equivalent. Stack-aware: when overlays are
|
||||
* nested (a modal opened from inside another modal), Escape closes only the innermost one. */
|
||||
export function useEscapeKey(onEscape: () => void): void {
|
||||
const entryRef = useRef<EscapeStackEntry>({ cb: onEscape });
|
||||
// Keeps the stack entry's callback fresh without re-running the mount/unmount effect below on
|
||||
// every render -- an effect (not a render-time ref write) so it runs after commit, not during render.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onEscape();
|
||||
entryRef.current.cb = onEscape;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const entry = entryRef.current;
|
||||
escapeStack.push(entry);
|
||||
if (!escapeListenerAttached) {
|
||||
window.addEventListener('keydown', handleGlobalEscape);
|
||||
escapeListenerAttached = true;
|
||||
}
|
||||
return () => {
|
||||
const idx = escapeStack.indexOf(entry);
|
||||
if (idx !== -1) escapeStack.splice(idx, 1);
|
||||
if (escapeStack.length === 0) {
|
||||
window.removeEventListener('keydown', handleGlobalEscape);
|
||||
escapeListenerAttached = false;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a toggle-button-triggered popover (emoji panel, @mention picker, ...) on any click outside
|
||||
* `ref`'s subtree -- `ref` must wrap BOTH the toggle button and the popover itself, so a click on the
|
||||
* toggle button counts as "inside" and is left to the button's own onClick (which already flips the
|
||||
* open state); only listens while `active` so re-clicking the toggle to open doesn't immediately
|
||||
* attach a listener that fires for that same click's bubble. Only active-state gating (not `onClose`)
|
||||
* is in the deps list, matching MessageContextMenu.tsx's own click-outside pattern -- an inline
|
||||
* `onClose` arrow function is expected to be a new reference every render.
|
||||
*/
|
||||
export function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClose: () => void, active: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [onEscape]);
|
||||
window.addEventListener('click', handler);
|
||||
return () => window.removeEventListener('click', handler);
|
||||
}, [active]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { callContentOf, callDurationMsOf, callStateOf } from '@/lib/tinode/calls';
|
||||
|
||||
/**
|
||||
* Two different "message is gone" protocol paths render differently (confirmed against the old
|
||||
* app's chat-message.jsx, a real, previously-hit gap): `msg.hi` is the server's own soft-delete
|
||||
@@ -35,3 +37,71 @@ export function systemNoticeText(msg: StoredMessage): string | null {
|
||||
if (headType !== 'system_notice' && headType !== 'system_action') return null;
|
||||
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
|
||||
}
|
||||
|
||||
export interface ContactCardContent {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
avatar_file_id?: string;
|
||||
public_account?: string;
|
||||
}
|
||||
|
||||
function isContactCardContent(content: unknown): content is ContactCardContent {
|
||||
return !!content && typeof content === 'object' && typeof (content as ContactCardContent).user_id === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.3's 名片 composer button has no send path anywhere in this app (no client here ever
|
||||
* constructs one -- see AddFriendModal/ConversationView's own `notImplemented()` for that button),
|
||||
* but a contact-card message CAN still arrive from another client. Confirmed real shape via
|
||||
* docs/IM-前后端对接文档.md:311's worked example -- a backend-custom `head['x-im-type']:
|
||||
* "contact_card"` with a FLAT `{user_id,display_name,avatar_file_id,public_account}` content, NOT a
|
||||
* Drafty entity. This app's own `drafty.tsx` used to assume (per a stale header comment inherited
|
||||
* from the SDK's generic ambient type list) that contact cards would show up as a Drafty `TC`-tagged
|
||||
* entity the same way images/audio do -- they don't, so `renderDrafty` was silently fed a
|
||||
* non-Drafty-shaped object for this message type and rendered nothing. This is dispatched the same
|
||||
* way `systemNoticeText`/`callResultDisplay` are: by `head['x-im-type']`, checked BEFORE ever calling
|
||||
* `renderDrafty` on `msg.content`.
|
||||
*/
|
||||
export function contactCardContentOf(msg: StoredMessage): ContactCardContent | null {
|
||||
if (msg.head?.['x-im-type'] !== 'contact_card') return null;
|
||||
return isContactCardContent(msg.content) ? msg.content : null;
|
||||
}
|
||||
|
||||
export function formatCallDuration(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §7's "通话结果消息" -- icon + text mapped from `head.webrtc`, confirmed live (doc line
|
||||
* 1953/1954): `missed` alone covers both "未接听" and "已取消" (a callee who let it ring out and a
|
||||
* caller who cancelled before pickup both replace to the SAME wire value) -- direction picks the
|
||||
* text, not the wire value, using `mine` (was the ORIGINAL invite sent by me, i.e. was I the
|
||||
* caller) exactly the way this app already threads that prop through MessageRow. No `busy` value
|
||||
* exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite
|
||||
* the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can
|
||||
* produce it yet.
|
||||
*/
|
||||
export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null {
|
||||
const content = callContentOf(msg);
|
||||
const state = callStateOf(msg);
|
||||
if (!content || !state) return null;
|
||||
const icon = content.callType === 'video' ? 'videocam' : 'call';
|
||||
switch (state) {
|
||||
case 'finished': {
|
||||
const duration = callDurationMsOf(msg);
|
||||
return { icon, danger: false, text: duration > 0 ? `通话时长 ${formatCallDuration(duration)}` : '已结束' };
|
||||
}
|
||||
case 'missed':
|
||||
return { icon: 'phone_missed', danger: true, text: mine ? '已取消' : '未接听' };
|
||||
case 'declined':
|
||||
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
|
||||
case 'disconnected':
|
||||
return { icon: 'phone_missed', danger: true, text: '异常结束' };
|
||||
case 'accepted':
|
||||
case 'started':
|
||||
return { icon, danger: false, text: '通话中' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+111
-63
@@ -2,7 +2,8 @@ import { Fragment, type ReactNode } from 'react';
|
||||
|
||||
import { Drafty, type DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { SafeLink } from '@/features/messages/SafeLink';
|
||||
|
||||
/**
|
||||
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
|
||||
@@ -15,70 +16,117 @@ import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/mes
|
||||
* not an oversight (confirmed against the old app's formatters.js:122-128, which does hash-color
|
||||
* mentions the same way avatars are colored).
|
||||
*/
|
||||
function formatNode(
|
||||
style: string | undefined,
|
||||
data: Record<string, unknown> | undefined,
|
||||
values: ReactNode[] | null,
|
||||
key: number | string,
|
||||
): ReactNode {
|
||||
const children = values ?? undefined;
|
||||
switch (style) {
|
||||
case 'ST':
|
||||
return <b key={key}>{children}</b>;
|
||||
case 'EM':
|
||||
return <i key={key}>{children}</i>;
|
||||
case 'DL':
|
||||
return <s key={key}>{children}</s>;
|
||||
case 'CO':
|
||||
return (
|
||||
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
case 'BR':
|
||||
return <br key={key} />;
|
||||
case 'MN':
|
||||
return (
|
||||
<span key={key} className="text-accent">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
case 'HL':
|
||||
return (
|
||||
<mark key={key} className="bg-warn-bg">
|
||||
{children}
|
||||
</mark>
|
||||
);
|
||||
case 'LN': {
|
||||
const url = typeof data?.url === 'string' ? data.url : undefined;
|
||||
return (
|
||||
<a key={key} href={url} target="_blank" rel="noreferrer" className="text-accent underline">
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
case 'IM':
|
||||
return <ImageAttachment key={key} data={data || {}} />;
|
||||
case 'EX':
|
||||
return <FileAttachment key={key} data={data || {}} />;
|
||||
case 'AU':
|
||||
return <AudioAttachment key={key} data={data || {}} />;
|
||||
case 'QQ':
|
||||
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style
|
||||
// span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`.
|
||||
return (
|
||||
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <Fragment key={key}>{children}</Fragment>;
|
||||
/** README §6.3 (mock `05/1e`/`1f`): search-result keyword matches render as `<mark style="background:
|
||||
* transparent; color:#1677ff; font-weight:500">`, not a yellow highlight -- a plain-text style, not
|
||||
* the SDK's own unrelated `HL` Drafty tag (that one is `bg-warn-bg`, a different, currently-unused
|
||||
* feature for user-authored highlight formatting, not search-match highlighting). Global,
|
||||
* case-insensitive, one `<mark>` per match. */
|
||||
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [text];
|
||||
const haystack = text.toLowerCase();
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let idx = haystack.indexOf(needle, cursor);
|
||||
if (idx === -1) return [text];
|
||||
let n = 0;
|
||||
while (idx !== -1) {
|
||||
if (idx > cursor) parts.push(text.slice(cursor, idx));
|
||||
parts.push(
|
||||
<mark key={`${keyPrefix}-hl-${n++}`} className="bg-transparent font-medium text-accent">
|
||||
{text.slice(idx, idx + query.trim().length)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = idx + query.trim().length;
|
||||
idx = haystack.indexOf(needle, cursor);
|
||||
}
|
||||
if (cursor < text.length) parts.push(text.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined): ReactNode {
|
||||
/** Curried on `highlightQuery` rather than taking it as a plain 5th parameter -- `Drafty.format`'s
|
||||
* own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call),
|
||||
* so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally
|
||||
* matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */
|
||||
function makeFormatNode(highlightQuery: string | undefined) {
|
||||
return function formatNode(
|
||||
style: string | undefined,
|
||||
data: Record<string, unknown> | undefined,
|
||||
values: ReactNode[] | null,
|
||||
key: number | string,
|
||||
): ReactNode {
|
||||
const children = values ?? undefined;
|
||||
switch (style) {
|
||||
case 'ST':
|
||||
return <b key={key}>{children}</b>;
|
||||
case 'EM':
|
||||
return <i key={key}>{children}</i>;
|
||||
case 'DL':
|
||||
return <s key={key}>{children}</s>;
|
||||
case 'CO':
|
||||
return (
|
||||
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
case 'BR':
|
||||
return <br key={key} />;
|
||||
case 'MN':
|
||||
return (
|
||||
<span key={key} className="text-accent">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
case 'HL':
|
||||
return (
|
||||
<mark key={key} className="bg-warn-bg">
|
||||
{children}
|
||||
</mark>
|
||||
);
|
||||
case 'LN': {
|
||||
const url = typeof data?.url === 'string' ? data.url : undefined;
|
||||
return (
|
||||
<SafeLink key={key} href={url}>
|
||||
{children}
|
||||
</SafeLink>
|
||||
);
|
||||
}
|
||||
case 'IM':
|
||||
return <ImageAttachment key={key} data={data || {}} />;
|
||||
case 'EX':
|
||||
return <FileAttachment key={key} data={data || {}} />;
|
||||
case 'AU':
|
||||
return <AudioAttachment key={key} data={data || {}} />;
|
||||
case 'VD':
|
||||
return <VideoAttachment key={key} data={data || {}} />;
|
||||
case 'QQ':
|
||||
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
|
||||
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.
|
||||
return (
|
||||
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
// A genuine plain-text leaf (per treeBottomUp: no children, so `values` collapses to the
|
||||
// node's own raw `[text]`) is the only shape safe to slice up for highlighting -- anything
|
||||
// else (an already-formatted child array from a styled ancestor) is left exactly as before.
|
||||
if (highlightQuery && values?.length === 1 && typeof values[0] === 'string') {
|
||||
return <Fragment key={key}>{highlightMatches(values[0], highlightQuery, key)}</Fragment>;
|
||||
}
|
||||
return <Fragment key={key}>{children}</Fragment>;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
|
||||
* as before; only HistorySearchModal's search results pass one. */
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
|
||||
if (content == null) return null;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Drafty.isPlainText(content)) return Drafty.toPlainText(content);
|
||||
return Drafty.format(content, formatNode);
|
||||
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
||||
if (Drafty.isPlainText(content)) {
|
||||
const text = Drafty.toPlainText(content);
|
||||
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
||||
}
|
||||
return Drafty.format(content, makeFormatNode(highlightQuery));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Drafty, type DraftyContent } from 'tinode-sdk';
|
||||
|
||||
export interface PendingMention {
|
||||
userId: string;
|
||||
/** Display text WITHOUT the leading '@' -- what actually appears in the composed plain text
|
||||
* (e.g. "张三", or "所有人" for the mention-all virtual entry). */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Drafty.mention(name, uid)` (tinode.dev.js:2169) never validates `uid` -- it's just stored as
|
||||
* the MN entity's `data.val`, opaque to the SDK and to this app's own renderer (drafty.tsx's `MN`
|
||||
* case renders `children` regardless of who it is). There's no real per-tenant user id for
|
||||
* "everyone", so this is this client's own made-up placeholder, not a protocol value -- fine since
|
||||
* nothing on the receive side needs to resolve it back to an actual user.
|
||||
*/
|
||||
export const MENTION_ALL_SENTINEL = 'all';
|
||||
|
||||
/**
|
||||
* Composes the final Drafty content for a group message that may contain @ mentions. The composer
|
||||
* is a plain `<textarea>` (not rich/contenteditable), so a picked mention is inserted as literal
|
||||
* `@label ` text in the draft and tracked separately in `mentions` -- this walks the final text
|
||||
* once at send time, finds each tracked mention's `@label` occurrence (left to right, never
|
||||
* re-matching a range an earlier mention already claimed, so pinging the same person twice doesn't
|
||||
* collapse onto one occurrence), and splices in real `Drafty.mention()` entities around plain-text
|
||||
* runs via `Drafty.append` -- the same stitching pattern send.ts's `sendReply` already uses for
|
||||
* quote blocks. Falls back to the plain `sendText` parse path when there's nothing to splice
|
||||
* (no mentions, or none of them still appear in the edited text).
|
||||
*/
|
||||
export function buildMentionedContent(text: string, mentions: PendingMention[]): DraftyContent | string {
|
||||
const toPlain = () => {
|
||||
const parsed = Drafty.parse(text);
|
||||
return Drafty.isPlainText(parsed) ? text : parsed;
|
||||
};
|
||||
if (mentions.length === 0) return toPlain();
|
||||
|
||||
const matches: Array<{ start: number; end: number; mention: PendingMention }> = [];
|
||||
let searchFrom = 0;
|
||||
for (const mention of mentions) {
|
||||
const needle = `@${mention.label}`;
|
||||
const idx = text.indexOf(needle, searchFrom);
|
||||
if (idx === -1) continue;
|
||||
matches.push({ start: idx, end: idx + needle.length, mention });
|
||||
searchFrom = idx + needle.length;
|
||||
}
|
||||
if (matches.length === 0) return toPlain();
|
||||
matches.sort((a, b) => a.start - b.start);
|
||||
|
||||
let content: DraftyContent | undefined;
|
||||
const appendPlain = (slice: string) => {
|
||||
if (!slice) return;
|
||||
const plain = Drafty.parse(slice);
|
||||
content = content ? Drafty.append(content, plain) : plain;
|
||||
};
|
||||
let cursor = 0;
|
||||
for (const m of matches) {
|
||||
appendPlain(text.slice(cursor, m.start));
|
||||
// `Drafty.mention(name, uid)` (tinode.dev.js:2169-2176) stores `name` VERBATIM as the run's
|
||||
// plain text with no automatic '@' -- passing the bare label rendered as "ixp355087" instead
|
||||
// of "@ixp355087" (confirmed live: a sent mention's `MN` span text had no '@' at all), so the
|
||||
// '@' has to be included here to end up inside the styled span, matching the design's own
|
||||
// `<span style="color:#1677ff;">@我</span>`.
|
||||
const mn = Drafty.mention(`@${m.mention.label}`, m.mention.userId);
|
||||
content = content ? Drafty.append(content, mn) : mn;
|
||||
cursor = m.end;
|
||||
}
|
||||
appendPlain(text.slice(cursor));
|
||||
return content ?? toPlain();
|
||||
}
|
||||
|
||||
export interface MentionTrigger {
|
||||
/** Index of the triggering '@' itself within `text`. */
|
||||
start: number;
|
||||
/** Text typed so far between the '@' and the cursor, used to filter the picker's candidate list. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects an in-progress `@mention` at the cursor: the nearest unclosed '@' before `cursorPos`,
|
||||
* closed by any whitespace typed between it and the cursor (same as most chat composers). Doesn't
|
||||
* require the '@' itself to be preceded by whitespace -- CJK sentences routinely run straight into
|
||||
* it ("大家注意@张三", no space before the '@', matching the design reference image) unlike English
|
||||
* "foo@bar", so gating on that would silently break the exact reference case this is built for.
|
||||
* A stray "name@example.com" typed in a group chat will float the picker uninvasively until the
|
||||
* next space closes it -- an acceptable trade for not breaking the primary CJK use case.
|
||||
*/
|
||||
export function detectMentionTrigger(text: string, cursorPos: number): MentionTrigger | null {
|
||||
const uptoCursor = text.slice(0, cursorPos);
|
||||
const at = uptoCursor.lastIndexOf('@');
|
||||
if (at === -1) return null;
|
||||
const query = uptoCursor.slice(at + 1);
|
||||
if (/\s/.test(query)) return null;
|
||||
return { start: at, query };
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
|
||||
* back to a bracketed type label. Own messages get a "我:" prefix. Grown incrementally -- VC (call)
|
||||
* duration/answered-state isn't modeled yet (Phase 6 territory), so call messages get a plain label
|
||||
* for now rather than guessing at a content shape no research has confirmed.
|
||||
* back to a bracketed type label. Own messages get a "我:" prefix.
|
||||
*/
|
||||
export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: string | null): string {
|
||||
if (!msg) return '';
|
||||
@@ -16,6 +14,18 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
|
||||
// app's own equivalent renderer does the same, since a hi-flagged message can still carry
|
||||
// non-null content that must NOT leak into this preview.
|
||||
if (isServerDeleted(msg) || content == null) return `${prefix}[消息已删除]`;
|
||||
// System notices/contact cards/call-result messages are dispatched by `head` (`x-im-type`/
|
||||
// `webrtc`), not by Drafty content shape -- confirmed in dispatch.ts's own header comments, same
|
||||
// order MessageBubble.tsx already checks these in. Their `content` is a flat, non-Drafty object
|
||||
// (`{text}` / `{user_id,...}` / `{callId,...}`), so skipping this check falls through to the
|
||||
// Drafty-entity branches below, which read a `.ent` array none of these objects have -- this is
|
||||
// exactly how the chat list's "undefined" preview bug happened (`Drafty.preview()` fed an object
|
||||
// it doesn't recognize, `Drafty.toPlainText()` on its result stringifying to "undefined").
|
||||
const noticeText = systemNoticeText(msg);
|
||||
if (noticeText != null) return noticeText;
|
||||
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
|
||||
const callResult = callResultDisplay(msg, msg.from === myUserId);
|
||||
if (callResult) return `${prefix}${callResult.text}`;
|
||||
if (typeof content === 'string' || Drafty.isPlainText(content)) {
|
||||
return prefix + Drafty.toPlainText(content);
|
||||
}
|
||||
@@ -32,10 +42,6 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
|
||||
}
|
||||
case 'EX':
|
||||
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
|
||||
case 'TC':
|
||||
return `${prefix}[名片]`;
|
||||
case 'VC':
|
||||
return `${prefix}[语音/视频通话]`;
|
||||
default:
|
||||
return prefix + Drafty.toPlainText(Drafty.preview(content, 40));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Drafty, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -49,7 +50,7 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
size: file.size,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image' });
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size })
|
||||
: Drafty.attachFile(undefined, { mime: file.type, refurl: (await uploadFile(file, topic.name)).url, filename: file.name, size: file.size });
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file' });
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -84,6 +85,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
|
||||
filename: file.name,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice' });
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
|
||||
/**
|
||||
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
|
||||
* 424-435/1945-2076): the invite is a plain-JSON `{pub}` (NOT Drafty -- `topic.createMessage` only
|
||||
* auto-Drafty-wraps string/Drafty-shaped content, a plain `{callId,...}` object passes through as
|
||||
* literal `content`), `head:{mime:"application/json", webrtc:"started", "x-im-type":"call"}`.
|
||||
* Ringing/accept/hang-up then go over `topic.videoCall()` (thin wrapper around the SDK's native
|
||||
* `{note what:"call"}`, see tinode-sdk.d.ts's own comment) -- NOT offer/answer/ice-candidate, which
|
||||
* this app doesn't use (see calls.ts's own header comment in lib/api on the Agora/rtc-join media
|
||||
* path this backend actually implements instead of raw SDP/ICE over the IM WebSocket).
|
||||
*/
|
||||
export interface CallContent {
|
||||
callId: string;
|
||||
callType: 'audio' | 'video';
|
||||
audio: boolean;
|
||||
video: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line
|
||||
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */
|
||||
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined';
|
||||
|
||||
export function callContentOf(msg: StoredMessage): CallContent | null {
|
||||
const webrtc = msg.head?.webrtc;
|
||||
if (typeof webrtc !== 'string' || !msg.content || typeof msg.content !== 'object') return null;
|
||||
const content = msg.content as unknown as CallContent;
|
||||
if (typeof content.callId !== 'string') return null;
|
||||
return content;
|
||||
}
|
||||
|
||||
export function callStateOf(msg: StoredMessage): CallWebrtcState | null {
|
||||
const webrtc = msg.head?.webrtc;
|
||||
return typeof webrtc === 'string' ? (webrtc as CallWebrtcState) : null;
|
||||
}
|
||||
|
||||
export function callDurationMsOf(msg: StoredMessage): number {
|
||||
const duration = msg.head?.['webrtc-duration'];
|
||||
return typeof duration === 'number' ? duration : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the call invite. `noEcho: false` (matches send.ts's sendText) so the caller's own UI gets an
|
||||
* immediate optimistic bubble via `publishDraft`, same as any other message.
|
||||
*
|
||||
* Confirmed live: an audio call's outbound packet never actually carries `video:false` on the wire
|
||||
* -- the SDK's own `#send()` runs every outbound packet through a generic `simplify()` (tinode.dev.
|
||||
* js:5881-5903) that recursively deletes any falsy field, with no way to mark one as intentional.
|
||||
* This isn't fixable from the call site (it strips ANY false-valued content field this app might
|
||||
* ever send this way, not just this one) and doesn't matter in practice here -- neither this file's
|
||||
* own `callContentOf` nor CallRoot.tsx reads `content.audio`/`content.video`, only `content.
|
||||
* callType` (always a non-empty string, never stripped), so a missing boolean never surfaces as a
|
||||
* real bug -- but a future call site that DID need to trust an explicit `false` over "field absent"
|
||||
* would silently lose that distinction.
|
||||
*/
|
||||
export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | 'video'): Promise<{ code: number; params?: { seq: number } }> {
|
||||
const content: CallContent = { callId, callType, audio: true, video: callType === 'video' };
|
||||
// `createMessage`'s own type only admits Drafty content, but its real implementation (tinode.dev.
|
||||
// js:7170-7182) passes any non-string content through untouched -- verified reading that source,
|
||||
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
|
||||
const msg = topic.createMessage(content as unknown as DraftyContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
|
||||
}
|
||||
|
||||
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('ringing', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('accept', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
|
||||
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { Tinode, type LoginCtrl } from 'tinode-sdk';
|
||||
|
||||
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
|
||||
@@ -17,9 +18,30 @@ export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'au
|
||||
let tinode: Tinode | null = null;
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
const listeners = new Set<() => void>();
|
||||
// Whether this connection has EVER reached 'authenticated' (survives the 'disconnected' state a
|
||||
// reconnect passes through, so onConnect below can tell "first connect, nothing to restore yet"
|
||||
// apart from "was logged in, the socket just dropped and came back"). Reset in resetTinode() only.
|
||||
let wasAuthenticated = false;
|
||||
|
||||
/**
|
||||
* Fired once a post-reconnect restoreSession() succeeds (see onConnect below). A plain listener
|
||||
* set rather than this module importing topics.ts directly -- confirmed by reading tinode.dev.js
|
||||
* that Tinode's own #disconnected() handler resets EVERY cached topic's `_attached` flag to false
|
||||
* on any drop (including the `me` topic and every contact's one-shot preview subscription, not just
|
||||
* the one topic a user happens to have open), so a bare reconnect+relogin leaves the whole app's
|
||||
* subscriptions dead even though the "已重连同步" banner says otherwise -- but deciding what
|
||||
* "resume subscriptions" means is topics.ts's job, not this connection-layer module's.
|
||||
*/
|
||||
const reconnectAuthListeners = new Set<() => void>();
|
||||
|
||||
export function onReauthenticated(callback: () => void): () => void {
|
||||
reconnectAuthListeners.add(callback);
|
||||
return () => reconnectAuthListeners.delete(callback);
|
||||
}
|
||||
|
||||
function setStatus(next: ConnectionStatus) {
|
||||
if (status === next) return;
|
||||
if (next === 'authenticated') wasAuthenticated = true;
|
||||
status = next;
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
@@ -149,6 +171,30 @@ function createTinode(): Tinode {
|
||||
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
|
||||
setStatus('connected');
|
||||
stopReconnectRetry();
|
||||
// The SDK's own auto-backoff (and the manual "立即重试") only re-establish the WebSocket --
|
||||
// confirmed by reading the SDK source that reconnecting never resends {login}. The server
|
||||
// treats every fresh connection as unauthenticated until told otherwise, so without this, a
|
||||
// reconnect after a transient drop (WiFi handoff, laptop sleep/wake, VPN blip -- routine on the
|
||||
// kind of enterprise network this app runs on) would leave every subsequent request silently
|
||||
// failing while the UI's "已重连同步" banner tells the user everything is fine.
|
||||
if (wasAuthenticated) {
|
||||
const token = sessionStorage.getItem('im-session-token');
|
||||
if (token) {
|
||||
restoreSession(token)
|
||||
.then(() => reconnectAuthListeners.forEach((cb) => cb()))
|
||||
.catch((err) => {
|
||||
// Token no longer valid (revoked/expired server-side while we were disconnected) -- can't
|
||||
// silently recover, so fall back to the same "force a real login" outcome a failed
|
||||
// restore-on-page-load already gets via RequireAuth.
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
// Same doc-prescribed recovery RequireAuth's own restore-failure path applies: a token
|
||||
// that's valid for a DIFFERENT tenant than the one this connection is against must also
|
||||
// drop the tenant, or the next /login mount reuses it and repeats the same mismatch.
|
||||
if (isTenantMismatch(err)) useSessionStore.getState().clearTenant();
|
||||
});
|
||||
}
|
||||
}
|
||||
if (wasReconnecting) {
|
||||
setReconnectState({ justReconnected: true });
|
||||
if (justReconnectedTimer) clearTimeout(justReconnectedTimer);
|
||||
@@ -203,6 +249,7 @@ export function resetTinode() {
|
||||
tinode = null;
|
||||
connectPromise = null;
|
||||
restorePromise = null;
|
||||
wasAuthenticated = false;
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
@@ -251,6 +298,27 @@ export function restoreSession(token: string): Promise<string | null> {
|
||||
return restorePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduped the same way connect()/restoreSession() are above, for the identical reason: LoginPage's
|
||||
* own `submitting` state guard only takes effect once React re-renders with the submit button
|
||||
* disabled, which is a moment too late for a genuine fast double-click/double-Enter to be stopped by
|
||||
* -- every OTHER non-idempotent SDK call in this module (connect/restoreSession/registerAccount) was
|
||||
* already deduped against exactly this race, `loginBasic` was the one left calling straight through.
|
||||
*/
|
||||
let loginPromise: Promise<LoginCtrl> | null = null;
|
||||
|
||||
export function loginBasic(login: string, password: string): Promise<LoginCtrl> {
|
||||
if (loginPromise) return loginPromise;
|
||||
loginPromise = (async () => {
|
||||
const t = getTinode();
|
||||
if (!t.isConnected()) await connect();
|
||||
return t.loginBasic(login, password);
|
||||
})().finally(() => {
|
||||
loginPromise = null;
|
||||
});
|
||||
return loginPromise;
|
||||
}
|
||||
|
||||
export function getConnectionStatus(): ConnectionStatus {
|
||||
return status;
|
||||
}
|
||||
@@ -274,9 +342,10 @@ export interface RegisterParams {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified username-or-phone registration (product Ch.1.4). Two DIFFERENT phone representations are
|
||||
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed live
|
||||
* against the real backend, then traced to server/user.go's resolveRegistrationAccount):
|
||||
* Unified username-or-phone registration (product Ch.1.4). THREE DIFFERENT phone representations
|
||||
* are involved in one `{acc}` packet and mixing them up throws a 422 "policy violation" with no
|
||||
* further detail (confirmed live against the real backend, then traced to server/user.go's
|
||||
* resolveRegistrationAccount):
|
||||
* - `login_` (createAccountBasic's first arg, becomes the secret's account half) must equal what
|
||||
* resolveRegistrationAccount computes as secretAccount -- for phone registration (and no
|
||||
* username typed) that's the E.164-normalized phone, e.g. "+8613900005232"; if a username was
|
||||
@@ -286,6 +355,12 @@ export interface RegisterParams {
|
||||
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
|
||||
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit number
|
||||
* with no "+86" prefix.
|
||||
* - `cred[].val` (the `tel` credential itself) is ALSO the bare 11-digit number, not E.164 -- every
|
||||
* one of the doc's own worked examples (docs/IM-前后端对接文档.md, both the 手机号验证方式 and
|
||||
* 仅手机号注册方式 blocks) shows `cred.val` identical to the bare `phone` field even in the exact
|
||||
* same JSON object where `secret` DOES carry the `+86`-prefixed form -- the doc is deliberately
|
||||
* inconsistent between the two, not sloppy. A missed 3rd case: an earlier version of this
|
||||
* function got the first two representations right but still E.164-prefixed this one.
|
||||
*
|
||||
* On the username-only path (no phone at all) `cred` must be left `undefined`, never a literal
|
||||
* `null` -- tinode-sdk's `Tinode.credential()` does `typeof meth == 'object'` to sniff its argument
|
||||
@@ -293,7 +368,21 @@ export interface RegisterParams {
|
||||
* "Cannot destructure property 'val' of 'meth' as it is null" instead of sending the {acc} message
|
||||
* at all (confirmed by reading the old app's own history of this exact bug).
|
||||
*/
|
||||
export async function registerAccount({
|
||||
let registerPromise: Promise<LoginCtrl> | null = null;
|
||||
|
||||
/** Deduped the same way connect()/restoreSession() are above, for the same class of reason:
|
||||
* createAccountBasic() is non-idempotent (creates a new account per call), so two overlapping
|
||||
* invocations -- e.g. a slow network letting a double-click on "注册" land before the submit
|
||||
* button's own pending-state disables it -- would otherwise send two `{acc}` packets. */
|
||||
export function registerAccount(params: RegisterParams): Promise<LoginCtrl> {
|
||||
if (registerPromise) return registerPromise;
|
||||
registerPromise = registerAccountImpl(params).finally(() => {
|
||||
registerPromise = null;
|
||||
});
|
||||
return registerPromise;
|
||||
}
|
||||
|
||||
async function registerAccountImpl({
|
||||
username,
|
||||
phone,
|
||||
password,
|
||||
@@ -311,7 +400,7 @@ export async function registerAccount({
|
||||
const secretAccount = usingPhone ? username || `+86${phone}` : username;
|
||||
const cred =
|
||||
usingPhone && verificationToken && challengeId
|
||||
? Tinode.credential({ meth: 'tel', val: `+86${phone}`, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||
? Tinode.credential({ meth: 'tel', val: phone, resp: verificationToken, params: { challenge_id: challengeId } })
|
||||
: undefined;
|
||||
|
||||
const trimmedName = username.trim();
|
||||
|
||||
@@ -11,6 +11,15 @@ const CTRL_ERROR_TEXT_CN: Record<string, string> = {
|
||||
'policy violation': '当前操作不符合安全策略要求',
|
||||
'permission denied': '没有权限执行此操作,请重新登录',
|
||||
'internal error': '服务器繁忙,请稍后重试',
|
||||
// Confirmed live 2026-09-09: registering with a username or phone number that's already taken
|
||||
// returns this exact raw SDK text ("duplicate credential (409)") -- without this entry,
|
||||
// registerErrorMessage()'s fallback showed the raw English straight to a Chinese-language user.
|
||||
'duplicate credential': '该用户名或手机号已被注册,请更换后重试',
|
||||
// docs/IM-前后端对接文档.md's own worked example (`{"login":...,"secret":"其他租户 token"}` ->
|
||||
// `403 tenant mismatch`) -- without this entry, loginErrorMessage's generic `code===403` fallback
|
||||
// below shows "账号或密码错误" for what's actually a stale-token-from-a-different-tenant case, which
|
||||
// is a materially different, more confusing message (the password wasn't wrong at all).
|
||||
'tenant mismatch': '登录状态异常,请重新进入企业码',
|
||||
};
|
||||
|
||||
function translateCtrlError(err: unknown): string | null {
|
||||
@@ -20,6 +29,16 @@ function translateCtrlError(err: unknown): string | null {
|
||||
return CTRL_ERROR_TEXT_CN[normalized] || null;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md: this specific reason means the client is holding a session token
|
||||
* issued by a DIFFERENT tenant than the one it's currently connected to -- the doc's own prescribed
|
||||
* recovery is "清 Token 并回企业码页" (clear the token and return to the tenant-code entry screen),
|
||||
* not just show an error and let the user retry the same broken state. */
|
||||
export function isTenantMismatch(err: unknown): boolean {
|
||||
const raw = (err as Error)?.message;
|
||||
if (!raw) return false;
|
||||
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
|
||||
}
|
||||
|
||||
export function loginErrorMessage(err: unknown): string {
|
||||
const known = translateCtrlError(err);
|
||||
if (known) return known;
|
||||
|
||||
@@ -7,6 +7,27 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr
|
||||
return typeof content === 'string' ? Drafty.init(content) : content;
|
||||
}
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
|
||||
* `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the
|
||||
* SAME value rather than mint a new one -- that's the field's entire purpose (line 315's own worked
|
||||
* example: resending an identical client_mid is expected to return the FIRST send's seq, not create
|
||||
* a second message). Every send helper below was missing this entirely, meaning a retry after an
|
||||
* ambiguous failure (the `{pub}` reached the server and was applied, but the `{ctrl}` ack itself
|
||||
* got lost on the way back) had no way to be deduplicated server-side and could double-post.
|
||||
* `crypto.randomUUID()` (the same primitive client.ts's own requestId() already relies on for
|
||||
* REST's Idempotency-Key) satisfies the doc's "ULID/UUID" requirement; kept local to this file
|
||||
* rather than importing requestId() across the REST/WS module boundary for what's a WS-protocol
|
||||
* concern here. Exported for attachments.ts/calls.ts's own `{pub}` sends (image/file/voice/call
|
||||
* invite), which need the identical field for the identical reason -- one source of truth for how
|
||||
* this app generates the id, not a copy in every file that happens to publish a message.
|
||||
*/
|
||||
export function newClientMid(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
|
||||
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
|
||||
@@ -16,7 +37,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
const parsed = Drafty.parse(text);
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
if (head) msg.head = Object.assign(msg.head || {}, head);
|
||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Same createMessage+publishDraft mechanics as sendText, but for content already assembled by the
|
||||
* caller (mentions.ts's `buildMentionedContent`, which splices real `Drafty.mention()` entities
|
||||
* into the plain-text draft) rather than a fresh `Drafty.parse` of raw text. Kept separate from
|
||||
* sendText rather than adding an optional param there, so every other existing call site (plain
|
||||
* P2P/group sends) is untouched.
|
||||
*/
|
||||
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -25,12 +59,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
* the same content/head -- ported from the old app's context-menu "重试" handler (context-menu.jsx),
|
||||
* not a simple re-publish of the same object. Returns undefined if `seq` is no longer in the local
|
||||
* cache (already flushed, or never existed).
|
||||
*
|
||||
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
|
||||
* newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back
|
||||
* to a fresh one only for a locally-cached failed message from before this field existed at all
|
||||
* (there's no original id to recover in that one case).
|
||||
*/
|
||||
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
|
||||
const failed = topic.flushMessage(seq);
|
||||
if (!failed) return undefined;
|
||||
const msg = topic.createMessage((failed.content as DraftyContent | string) ?? '', false);
|
||||
if (failed.head) msg.head = Object.assign(msg.head || {}, failed.head);
|
||||
const existingMid = failed.head?.['x-im-client-mid'];
|
||||
msg.head = Object.assign(msg.head || {}, failed.head, {
|
||||
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
|
||||
});
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -52,7 +94,7 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
|
||||
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
|
||||
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
|
||||
const msg = topic.createMessage(finalContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq) });
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -66,6 +108,6 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
|
||||
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
|
||||
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
|
||||
const newMsg = target.createMessage(content, false);
|
||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}` });
|
||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
|
||||
return target.publishDraft(newMsg, Promise.resolve());
|
||||
}
|
||||
|
||||
Vendored
+36
-1
@@ -73,6 +73,11 @@ declare module 'tinode-sdk' {
|
||||
export interface TopicDescPrivate {
|
||||
comment?: string;
|
||||
pinned?: boolean;
|
||||
/** Mirrored onto this same `private` object by the same `{set sub private:{...}}` write that
|
||||
* sets `pinned` (docs/IM-前后端对接文档.md:366's own worked example sets both fields together
|
||||
* in one packet) -- free to read off `me.contacts()` for the same reason `pinned` already is,
|
||||
* no extra per-topic settings fetch needed. */
|
||||
mute_notifications?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -222,10 +227,24 @@ declare module 'tinode-sdk' {
|
||||
delMessagesAll(hardDel?: boolean): Promise<unknown>;
|
||||
delMessagesList(list: number[], hardDel?: boolean): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Thin wrapper around `Tinode.videoCall` (tinode.dev.js:5074-5079, 7343-7350) -- builds and sends
|
||||
* a `{note topic, seq, what:"call", event, payload}` packet. Guards `!this._attached` client-side
|
||||
* EXCEPT for `ringing`/`hang-up`, which the SDK allows even before the topic is subscribed (a
|
||||
* callee receiving a fresh invite hasn't necessarily subscribed to the caller's P2P topic yet).
|
||||
* `event` is the doc's confirmed whitelist (§0.5.4.7): `ringing|accept|hang-up` for this app
|
||||
* (offer/answer/ice-candidate are NOT used -- see calls.ts's own header comment on why this app
|
||||
* uses the Agora/rtc-join media path instead of raw SDP/ICE over this channel). Always pass a
|
||||
* real `{callId, ...}` payload -- confirmed live the doc's protocol requires it on every note,
|
||||
* unlike the old app's real code (which the SDK lets send `undefined`, but the new backend
|
||||
* doesn't accept that).
|
||||
*/
|
||||
videoCall(event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>): Promise<unknown> | undefined;
|
||||
|
||||
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
|
||||
onData?: (data?: StoredMessage) => void;
|
||||
onAllMessagesReceived?: (count: number) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string }) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
|
||||
onMetaDesc?: (desc: unknown) => void;
|
||||
onSubsUpdated?: (subs: string[], count?: number) => void;
|
||||
onPres?: (pres: unknown) => void;
|
||||
@@ -263,6 +282,10 @@ declare module 'tinode-sdk' {
|
||||
isConnected(): boolean;
|
||||
isAuthenticated(): boolean;
|
||||
getCurrentUserID(): string;
|
||||
/** The login credential username (not the display nickname) -- needed as the unchanged
|
||||
* passthrough value when calling `updateAccountBasic` to change ONLY the password (confirmed
|
||||
* against the old app's real working code, tinode-web.jsx:1964). */
|
||||
getCurrentLogin(): string;
|
||||
|
||||
login(scheme: string, secret: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
loginBasic(username: string, password: string, cred?: unknown): Promise<LoginCtrl>;
|
||||
@@ -278,6 +301,18 @@ declare module 'tinode-sdk' {
|
||||
password: string,
|
||||
params: { public?: TopicDescPublic; tags?: string[]; cred?: CredentialParam[]; attachments?: string[] },
|
||||
): Promise<LoginCtrl>;
|
||||
/** Native `{acc}` account update -- there is no REST endpoint for changing password while
|
||||
* logged in (confirmed: doc only has the logged-out SMS reset flow), so this is the only real
|
||||
* path. Pass `uid: null` for "myself" and `username` unchanged (`getCurrentLogin()`) to change
|
||||
* only the password -- confirmed against the old app's real working call site. Server does NOT
|
||||
* verify any "current password" (confirmed: personal-settings-view.jsx's own research comment;
|
||||
* a client-side "原密码" field is a fat-finger speed bump only, not a real credential check). */
|
||||
updateAccountBasic(uid: string | null, username: string | null, password: string, params?: unknown): Promise<unknown>;
|
||||
/** Native `{del what:"user" hard:true}` -- there is no REST endpoint for account deletion
|
||||
* (confirmed absent from both doc copies and the backend problem list). No cooldown/soft-
|
||||
* delete and no password re-check server-side (confirmed against server/user.go per the old
|
||||
* app's own research comment) -- the client-side confirm dialog is the only safety net. */
|
||||
delCurrentUser(hard: boolean): Promise<void>;
|
||||
|
||||
/** Wraps a single {meth,val,resp,params} credential into the array shape {acc}/createAccountBasic expects. */
|
||||
static credential(cred: CredentialInput): CredentialParam[] | null;
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { MESSAGES_PAGE } from '@/lib/config';
|
||||
|
||||
import { getTinode } from './connection';
|
||||
import { getTinode, onReauthenticated } from './connection';
|
||||
|
||||
const topicListeners = new Map<string, Set<() => void>>();
|
||||
const anyTopicListeners = new Set<() => void>();
|
||||
@@ -45,11 +45,33 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
|
||||
return () => anyTopicListeners.delete(callback);
|
||||
}
|
||||
|
||||
export interface RawTopicInfo {
|
||||
what: 'read' | 'recv' | 'kp' | 'call';
|
||||
seq?: number;
|
||||
from?: string;
|
||||
event?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
const infoListeners = new Set<(topicName: string, info: RawTopicInfo) => void>();
|
||||
|
||||
/** Raw `{info}` payloads (read/recv/kp/call), forwarded verbatim -- unlike `subscribeAnyTopicChange`/
|
||||
* `subscribeTopicChanges`, which only signal "something changed, re-read the snapshot" for the
|
||||
* chat-list/message-list hooks, calls.ts needs the actual `event`/`payload` fields off a `what:
|
||||
* "call"` info to drive its own ringing/accept/hang-up state machine -- those aren't reconstructable
|
||||
* from a message snapshot re-read the way a new chat message is. */
|
||||
export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicInfo) => void): () => void {
|
||||
infoListeners.add(callback);
|
||||
return () => infoListeners.delete(callback);
|
||||
}
|
||||
|
||||
function wireTopicCallbacks(topic: Topic) {
|
||||
if (wiredTopics.has(topic)) return;
|
||||
const notify = () => notifyTopic(topic.name);
|
||||
topic.onData = notify;
|
||||
topic.onInfo = notify;
|
||||
topic.onInfo = (info) => {
|
||||
notify();
|
||||
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
|
||||
};
|
||||
topic.onMetaDesc = notify;
|
||||
topic.onSubsUpdated = notify;
|
||||
topic.onPres = notify;
|
||||
@@ -220,3 +242,34 @@ function invalidateChatListSnapshot() {
|
||||
chatListSnapshot = null;
|
||||
}
|
||||
anyTopicListeners.add(invalidateChatListSnapshot);
|
||||
|
||||
/**
|
||||
* Re-fires the `me` topic subscribe and every contact's preview subscribe once a WS reconnect has
|
||||
* successfully re-authenticated (connection.ts's onReauthenticated). Bypasses subscribeMeTopic's/
|
||||
* prefetchPreview's own "already done" guards on purpose -- those guards exist to stop a second
|
||||
* *concurrent* call from firing a redundant `{sub}` while one is already in flight, not to reflect
|
||||
* whether the topic is still genuinely attached, and after any disconnect it never is (the SDK
|
||||
* resets `_attached` on every cached topic, confirmed by reading tinode.dev.js's #disconnected()).
|
||||
* `Topic.subscribe()` itself only short-circuits when `_attached` is still true, so calling it again
|
||||
* here for an already-detached topic genuinely resends `{sub}`, not a silent no-op.
|
||||
*
|
||||
* Deliberately scoped to topics this module already knows about (the `me` topic + everything
|
||||
* prefetchPreview has ever touched) rather than tracking "the one conversation currently open" as
|
||||
* separate state -- in practice the chat list and an open conversation are mounted together in this
|
||||
* app's layout, so the open topic's contact row has already gone through prefetchPreview by the time
|
||||
* anyone could be looking at it. A conversation reached by a deep link that never rendered the chat
|
||||
* list at all is the one case this doesn't cover; also out of scope: backfilling any messages that
|
||||
* were sent by the peer during the drop itself -- this only resumes *live* updates going forward,
|
||||
* it doesn't gap-fill what was missed while disconnected.
|
||||
*/
|
||||
onReauthenticated(() => {
|
||||
const me = getTinode().getMeTopic();
|
||||
wireTopicCallbacks(me);
|
||||
me.onContactUpdate = () => notifyTopic(me.name);
|
||||
me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).catch(() => {});
|
||||
for (const topicName of previewPrefetched) {
|
||||
const topic = getTinode().getTopic(topicName);
|
||||
wireTopicCallbacks(topic);
|
||||
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { joinRtcCall } from '@/lib/api/calls';
|
||||
import {
|
||||
joinCall,
|
||||
leaveCall,
|
||||
setCameraOff as agoraSetCameraOff,
|
||||
setMuted as agoraSetMuted,
|
||||
switchCamera as agoraSwitchCamera,
|
||||
} from '@/lib/calls/agora-client';
|
||||
import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls';
|
||||
import { getTinode } from '@/lib/tinode/connection';
|
||||
import { getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
|
||||
|
||||
/** Generous local ring-timeout fallback -- see startCall's own comment on why this exists. */
|
||||
const RING_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface CallState {
|
||||
phase: CallPhase;
|
||||
topic: string | null;
|
||||
callId: string | null;
|
||||
seq: number | null;
|
||||
callType: 'audio' | 'video' | null;
|
||||
peerName: string;
|
||||
minimized: boolean;
|
||||
muted: boolean;
|
||||
cameraOff: boolean;
|
||||
connectedAt: number | null;
|
||||
hasRemoteVideo: boolean;
|
||||
// Set once joinCall() actually resolves with a local camera track -- CallRoot's self-preview
|
||||
// effect used to fire once on mount, when the async join chain (invite/accept -> rtc-join REST ->
|
||||
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
|
||||
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
|
||||
hasLocalVideo: boolean;
|
||||
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
|
||||
// comment) so it's deliberately NOT part of `CallWebrtcState` -- but it IS a real value this app's
|
||||
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
|
||||
// caller receives, which flows straight into this same field.
|
||||
endReason: CallWebrtcState | 'busy' | null;
|
||||
/** Was THIS session the one that placed the call (via startCall), not the one that received it --
|
||||
* needed to pick "已取消" vs "未接听" for the same wire value the same way dispatch.ts's
|
||||
* callResultDisplay already does for the permanent chat-bubble record of the same call. */
|
||||
wasCaller: boolean;
|
||||
/** docs/IM-前后端对接文档.md §8.6: "切换失败恢复真实按钮状态并给本地提示" -- toggleMute/
|
||||
* toggleCamera set this on a real Agora-level failure, after rolling the optimistic flip back. */
|
||||
mediaError: string | null;
|
||||
|
||||
startCall: (topic: string, peerName: string, callType: 'audio' | 'video') => Promise<void>;
|
||||
acceptCall: () => Promise<void>;
|
||||
rejectCall: () => void;
|
||||
hangUp: () => void;
|
||||
toggleMinimize: () => void;
|
||||
toggleMute: () => Promise<void>;
|
||||
toggleCamera: () => Promise<void>;
|
||||
switchCamera: () => Promise<void>;
|
||||
setHasRemoteVideo: (v: boolean) => void;
|
||||
_reset: () => void;
|
||||
_endWithReason: (reason: CallWebrtcState | 'busy') => void;
|
||||
}
|
||||
|
||||
const IDLE = {
|
||||
phase: 'idle' as const,
|
||||
topic: null,
|
||||
callId: null,
|
||||
seq: null,
|
||||
callType: null,
|
||||
peerName: '',
|
||||
minimized: false,
|
||||
muted: false,
|
||||
cameraOff: false,
|
||||
connectedAt: null,
|
||||
hasRemoteVideo: false,
|
||||
hasLocalVideo: false,
|
||||
endReason: null,
|
||||
wasCaller: false,
|
||||
mediaError: null,
|
||||
};
|
||||
|
||||
export const useCallStore = create<CallState>((set, get) => ({
|
||||
...IDLE,
|
||||
|
||||
startCall: async (topic, peerName, callType) => {
|
||||
const tinodeTopic = getTinode().getTopic(topic);
|
||||
const callId = getTinode().getNextUniqueId();
|
||||
set({ ...IDLE, phase: 'outgoing', topic, callId, callType, peerName, wasCaller: true });
|
||||
try {
|
||||
const ctrl = await sendCallInvite(tinodeTopic, callId, callType);
|
||||
const seq = ctrl?.params?.seq;
|
||||
if (!seq || get().callId !== callId) return;
|
||||
set({ seq });
|
||||
// Safety net, not the primary mechanism: the protocol doc only confirms the server marks an
|
||||
// unanswered call `missed` in the persisted MESSAGE, never confirmed to also push a live
|
||||
// `{info}` event to the caller for that specific case (unlike accept/hang-up, which do). If
|
||||
// that push genuinely never comes, nothing else here would ever leave 'outgoing' -- this timer
|
||||
// is a no-op (cleared by the time it'd fire) on every path that already ends the call some
|
||||
// other way, and only actually does something on that one unconfirmed gap.
|
||||
window.setTimeout(() => {
|
||||
if (get().callId === callId && get().phase === 'outgoing') get().hangUp();
|
||||
}, RING_TIMEOUT_MS);
|
||||
} catch {
|
||||
// The invite itself failed to send -- nothing to ring, don't strand the "正在等待对方接听…"
|
||||
// overlay forever with no way out but a manual hang-up click.
|
||||
if (get().callId === callId) get()._reset();
|
||||
}
|
||||
// Media is NOT captured/joined here -- docs/IM-前后端对接文档.md §8.7: "接听前媒体轨默认不发给
|
||||
// 对方". The actual RTC join happens once the peer's 'accept' event arrives (see the
|
||||
// subscribeTopicInfo handler in initCallSignaling below), mirroring acceptCall()'s own timing on
|
||||
// the callee side. Previously this joined+published immediately after the invite ack, meaning
|
||||
// the caller's mic (and camera, for a video call) were live and transmitting for the entire
|
||||
// ringing period -- before the callee had done anything at all.
|
||||
},
|
||||
|
||||
acceptCall: async () => {
|
||||
const { topic, callId, seq, callType } = get();
|
||||
if (!topic || !callId || seq == null || !callType) return;
|
||||
const tinodeTopic = getTinode().getTopic(topic);
|
||||
sendCallAccept(tinodeTopic, seq, callId);
|
||||
set({ phase: 'connected', connectedAt: Date.now() });
|
||||
try {
|
||||
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
|
||||
if (get().callId !== callId) return;
|
||||
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
|
||||
if (get().callId === callId) set({ hasLocalVideo: callType === 'video' });
|
||||
} catch {
|
||||
// Same guard the structurally-identical caller-side accept handler below already has, for the
|
||||
// identical reason: `callId` here is the ORIGINAL id this whole acceptCall() call was for,
|
||||
// captured before the async join even started. Without re-checking it, a hang-up (either
|
||||
// side) that lands WHILE joinRtcCall()/joinCall() is still in flight -- easily reachable,
|
||||
// since `phase` already flipped to 'connected' above before the RTC join even begins, making
|
||||
// the hang-up button clickable during this whole window -- let this catch unconditionally
|
||||
// clobber whatever CORRECT end-reason that hang-up had already set (or a brand-new
|
||||
// subsequent call's state) with a false "连接异常". A media/RTC join failure is still a
|
||||
// distinct outcome from a normal hang-up -- routing it through hangUp() would have mislabeled
|
||||
// it as "已结束"/"已取消" depending on connectedAt, instead of the honest "连接异常" this is --
|
||||
// just only when THIS call is still the live one.
|
||||
if (get().callId !== callId) return;
|
||||
const { topic: currentTopic, seq: currentSeq } = get();
|
||||
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId);
|
||||
leaveCall();
|
||||
get()._endWithReason('disconnected');
|
||||
}
|
||||
},
|
||||
|
||||
rejectCall: () => {
|
||||
const { topic, seq, callId } = get();
|
||||
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, 'declined');
|
||||
get()._endWithReason('declined');
|
||||
},
|
||||
|
||||
hangUp: () => {
|
||||
const { topic, seq, callId, connectedAt } = get();
|
||||
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId);
|
||||
leaveCall();
|
||||
get()._endWithReason(connectedAt ? 'finished' : 'missed');
|
||||
},
|
||||
|
||||
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
|
||||
|
||||
// Both used to flip the local flag in the exact same tick as firing (unawaited, uncaught)
|
||||
// agoraSetMuted/agoraSetCameraOff -- a real Agora-level failure (permission revoked mid-call,
|
||||
// track already closed) left the button showing the wrong state forever with no rollback and no
|
||||
// user-facing error, contradicting docs/IM-前后端对接文档.md §8.6's explicit requirement.
|
||||
toggleMute: async () => {
|
||||
const next = !get().muted;
|
||||
set({ muted: next, mediaError: null });
|
||||
try {
|
||||
await agoraSetMuted(next);
|
||||
} catch {
|
||||
set({ muted: !next, mediaError: '切换静音失败,请重试' });
|
||||
}
|
||||
},
|
||||
|
||||
toggleCamera: async () => {
|
||||
const next = !get().cameraOff;
|
||||
set({ cameraOff: next, mediaError: null });
|
||||
try {
|
||||
await agoraSetCameraOff(next);
|
||||
} catch {
|
||||
set({ cameraOff: !next, mediaError: '切换摄像头失败,请重试' });
|
||||
}
|
||||
},
|
||||
|
||||
// switchCamera() (front/back) previously had no error handling at all anywhere in its call chain
|
||||
// -- agora-client.ts's own version now rolls its internal flag back and rethrows on failure (a
|
||||
// device with no rear camera, or a browser that doesn't support `facingMode`), but nothing
|
||||
// surfaced that to the user; CallRoot.tsx's own click handler discarded the promise outright with
|
||||
// no `.catch()`. Routed through the same mediaError channel as toggleMute/toggleCamera above for
|
||||
// one consistent user-facing failure convention, not a second bespoke one.
|
||||
switchCamera: async () => {
|
||||
try {
|
||||
await agoraSwitchCamera();
|
||||
} catch {
|
||||
set({ mediaError: '切换摄像头失败,请重试' });
|
||||
}
|
||||
},
|
||||
|
||||
setHasRemoteVideo: (v) => set({ hasRemoteVideo: v }),
|
||||
|
||||
_reset: () => set({ ...IDLE }),
|
||||
|
||||
// `'ended'` briefly shows a result overlay (CallRoot) before settling back to idle -- previously
|
||||
// every end-of-call path went straight to `{...IDLE, endReason: reason}` in one `set()`, so `phase`
|
||||
// was ALREADY 'idle' the instant `endReason` was set; CallRoot's top-level render switch only shows
|
||||
// something for 'incoming'/'outgoing'/'connected', so the whole result (icon/text) was set but
|
||||
// never actually rendered by anything, the entire 3s window it existed for.
|
||||
_endWithReason: (reason) => {
|
||||
// Preserve exactly the fields EndedCallOverlay needs to render (who/what/how-long) -- everything
|
||||
// else genuinely IS done (media already torn down by the caller before this runs) and correctly
|
||||
// resets to its idle default. An earlier version of this spread `...IDLE` over everything, which
|
||||
// wiped peerName/topic/callType/wasCaller too, leaving the overlay rendering a blank peer name.
|
||||
const { topic, peerName, callType, connectedAt, wasCaller } = get();
|
||||
set({ ...IDLE, phase: 'ended', endReason: reason, topic, peerName, callType, connectedAt, wasCaller });
|
||||
window.setTimeout(() => {
|
||||
if (useCallStore.getState().phase === 'ended') useCallStore.setState({ ...IDLE });
|
||||
}, 3000);
|
||||
},
|
||||
}));
|
||||
|
||||
const seenCallIds = new Set<string>();
|
||||
let listenersWired = false;
|
||||
|
||||
/**
|
||||
* Global call-signal wiring -- call once from AppShell so incoming calls surface regardless of
|
||||
* which page is open. `subscribeAnyTopicChange` fires on ANY topic change (new messages, presence,
|
||||
* etc, not just calls), so this re-scans every contact's latest message on each firing and dedupes
|
||||
* by `callId` (a plain "did this seq change" check isn't enough: the SAME invite message can
|
||||
* legitimately re-notify, e.g. once for `onData` and again for an unrelated `onPres` on the same
|
||||
* topic bumping `notifyTopic`). Auto-declines a second incoming call while one is already active --
|
||||
* matches the old app's own real behavior (tinode-web.jsx's busy-auto-hangup), not a new policy.
|
||||
*/
|
||||
export function initCallSignaling() {
|
||||
if (listenersWired) return;
|
||||
listenersWired = true;
|
||||
|
||||
subscribeAnyTopicChange(() => {
|
||||
const myUserId = useSessionStore.getState().myUserId;
|
||||
if (!myUserId) return;
|
||||
const peerNames = new Map<string, string>();
|
||||
getTinode()
|
||||
.getMeTopic()
|
||||
.contacts((c) => {
|
||||
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
|
||||
});
|
||||
|
||||
for (const topicName of peerNames.keys()) {
|
||||
const messages = getMessagesSnapshot(topicName);
|
||||
const latest = messages[messages.length - 1];
|
||||
if (!latest || latest.from === myUserId || typeof latest.seq !== 'number') continue;
|
||||
const content = callContentOf(latest);
|
||||
if (!content || callStateOf(latest) !== 'started' || seenCallIds.has(content.callId)) continue;
|
||||
seenCallIds.add(content.callId);
|
||||
|
||||
// `'ended'` is a terminal display-only phase (_endWithReason's own 3s result-overlay window,
|
||||
// see below) -- treating it the same as a genuinely live call meant a real new incoming call
|
||||
// arriving in the 3 seconds right after ANY previous call ended (from anyone, unrelated) got
|
||||
// auto-declined as "busy" even though nothing was actually in progress anymore.
|
||||
const currentPhase = useCallStore.getState().phase;
|
||||
if (currentPhase !== 'idle' && currentPhase !== 'ended') {
|
||||
// Busy -- auto-decline without ever showing the incoming-call UI for this second call.
|
||||
sendCallHangup(getTinode().getTopic(topicName), latest.seq, content.callId, 'busy');
|
||||
continue;
|
||||
}
|
||||
useCallStore.setState({
|
||||
...IDLE,
|
||||
phase: 'incoming',
|
||||
topic: topicName,
|
||||
callId: content.callId,
|
||||
seq: latest.seq,
|
||||
callType: content.callType,
|
||||
peerName: peerNames.get(topicName) || topicName,
|
||||
});
|
||||
sendCallRinging(getTinode().getTopic(topicName), latest.seq, content.callId);
|
||||
// Mirrors startCall()'s own safety-net timer (same RING_TIMEOUT_MS, same reasoning: the doc
|
||||
// never confirms a live push specifically for "caller's device vanished without ever sending
|
||||
// an explicit hang-up"). Without this, the callee side had NO timeout at all -- if the
|
||||
// caller's client crashed/lost network/went to sleep mid-ring, this incoming-call UI stayed up
|
||||
// forever with only a manual decline as an escape. `hangUp()` (not rejectCall()) is the right
|
||||
// call here, not just for symmetry: it resolves locally to 'missed' (correct -- this ring was
|
||||
// never actually declined, it just went unanswered) rather than 'declined', and still notifies
|
||||
// the caller so their own UI resolves too instead of relying on their side's timeout as well.
|
||||
const ringingCallId = content.callId;
|
||||
window.setTimeout(() => {
|
||||
if (useCallStore.getState().callId === ringingCallId && useCallStore.getState().phase === 'incoming') {
|
||||
useCallStore.getState().hangUp();
|
||||
}
|
||||
}, RING_TIMEOUT_MS);
|
||||
}
|
||||
});
|
||||
|
||||
subscribeTopicInfo((topicName, info) => {
|
||||
if (info.what !== 'call' || typeof info.event !== 'string') return;
|
||||
const store = useCallStore.getState();
|
||||
const payloadCallId = info.payload?.callId;
|
||||
if (store.topic !== topicName || (payloadCallId && payloadCallId !== store.callId)) return;
|
||||
if (info.event === 'ringing' && store.phase === 'outgoing') {
|
||||
// Purely a UI cue (ring-back tone) -- no state transition needed beyond what's already shown.
|
||||
return;
|
||||
}
|
||||
if (info.event === 'accept' && store.phase === 'outgoing') {
|
||||
useCallStore.setState({ phase: 'connected', connectedAt: Date.now() });
|
||||
const { topic: joinTopic, callId, seq, callType } = store;
|
||||
if (joinTopic && callId && seq != null && callType) {
|
||||
joinRtcCall(joinTopic, callId, seq, callType, 'caller')
|
||||
.then(async (join) => {
|
||||
if (useCallStore.getState().callId !== callId) return;
|
||||
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
|
||||
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video' });
|
||||
})
|
||||
.catch(() => {
|
||||
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
|
||||
// above, before this join attempt) the instant `accept` came in, so routing this through
|
||||
// the generic hangUp() would read that truthy `connectedAt` and misreport a real "通话时长"
|
||||
// for a call that never actually connected media at all -- go straight to the honest
|
||||
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
|
||||
if (useCallStore.getState().callId !== callId) return;
|
||||
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId);
|
||||
leaveCall();
|
||||
useCallStore.getState()._endWithReason('disconnected');
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (info.event === 'hang-up') {
|
||||
const reason = info.payload?.reason;
|
||||
const connectedAt = useCallStore.getState().connectedAt;
|
||||
leaveCall();
|
||||
// Collapsing every reason except 'busy' into 'finished' meant a callee who clicked 拒绝 made
|
||||
// the CALLER's overlay wrongly show "通话已结束" instead of "对方已拒绝" (rejectCall() has
|
||||
// already been sending a real `reason:'declined'` all along, just never read back out here),
|
||||
// and a caller who cancelled before pickup made the CALLEE's overlay wrongly show "通话已结束"
|
||||
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
|
||||
// local hangUp() above already makes for its own no-reason-given case.
|
||||
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Transient cross-component instruction: HistorySearchModal (owned by MessagesLayout, a sibling of
|
||||
* ConversationView, not an ancestor of it) sets this when a search result is clicked; ConversationView
|
||||
* consumes it and clears it. Matches this codebase's own established pattern for this exact category
|
||||
* of problem (stores/calls.ts's `useCallStore`), rather than widening ConversationView's Outlet
|
||||
* context (`onOpenHistory` etc.) with a second, differently-shaped callback, or inventing a URL-param
|
||||
* convention this app doesn't otherwise use anywhere.
|
||||
*
|
||||
* A search result is always for the SAME topic already open behind the modal (HistorySearchModal has
|
||||
* no cross-conversation search), so `topic` here is mostly a defensive consistency check, not a real
|
||||
* "jump to a different conversation" feature.
|
||||
*/
|
||||
interface MessageJumpState {
|
||||
target: { topic: string; seq: number } | null;
|
||||
requestJump: (topic: string, seq: number) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useMessageJumpStore = create<MessageJumpState>((set) => ({
|
||||
target: null,
|
||||
requestJump: (topic, seq) => set({ target: { topic, seq } }),
|
||||
clear: () => set({ target: null }),
|
||||
}));
|
||||
@@ -8,6 +8,11 @@ export interface TenantContext {
|
||||
/** Real Tinode WS host + scheme, parsed from tenants/resolve's `endpoints.websocket`. */
|
||||
host: string;
|
||||
secure: boolean;
|
||||
/** `tenants/resolve`'s own `password_policy` (docs/IM-前后端对接文档.md:534, a real per-tenant
|
||||
* field, not a guess) -- kept here so every password-entry page (Register/ResetPassword/
|
||||
* SecuritySettings) reads ONE real source instead of each hardcoding its own guessed minimum
|
||||
* (they'd drifted to two different numbers, 6 and 8, before this existed). */
|
||||
passwordPolicy?: { minLength: number; maxLength: number };
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
|
||||
Reference in New Issue
Block a user