Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c5b329179 | ||
|
|
50767f701f | ||
|
|
425b06aa71 | ||
|
|
804618ad63 | ||
|
|
df22f26628 | ||
|
|
c53e6552eb | ||
|
|
66dad284c7 | ||
|
|
3fab8b08b9 | ||
|
|
f570483b62 | ||
|
|
3df61e9e59 | ||
|
|
4bf4e75cac | ||
|
|
3d6ae089a1 | ||
|
|
cd6b7675d4 | ||
|
|
8a593f50c3 | ||
|
|
ab3843dbdb | ||
|
|
d1f3aea26e | ||
|
|
8431f83ede | ||
|
|
c455644cca | ||
|
|
823a47e182 | ||
|
|
92892e3e7b | ||
|
|
8013c1675f | ||
|
|
ff69e4548c | ||
|
|
29a1f1c2fd | ||
|
|
97e8d7144f | ||
|
|
18d21a03bb | ||
|
|
8f4b0de636 | ||
|
|
ab96ae1fee | ||
|
|
fb1fda689c | ||
|
|
776131b1de | ||
|
|
0824502ddd | ||
|
|
9adadaa57d | ||
|
|
c3e8f91364 | ||
|
|
e78437cb2a | ||
|
|
70cc977e16 | ||
|
|
d5fc03db0e | ||
|
|
f9d4d48148 | ||
|
|
37d08e8989 | ||
|
|
76156a1c75 | ||
|
|
4e1f1cbf0f | ||
|
|
3280b92a09 |
@@ -1,10 +1,10 @@
|
||||
name: Deploy Webapp (Blue-Green)
|
||||
name: Deploy im-webapp (k3s test cluster)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: '镜像tag,留空则使用commit短哈希'
|
||||
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
@@ -17,14 +17,23 @@ jobs:
|
||||
id: vars
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.image_tag }}"
|
||||
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
|
||||
[ -z "$TAG" ] && TAG=test
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 构建镜像
|
||||
run: |
|
||||
docker build -t im-webapp:${{ steps.vars.outputs.tag }} \
|
||||
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" .
|
||||
- name: 登录镜像仓库
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
|
||||
|
||||
- name: 蓝绿部署
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
IMAGE=im-webapp:${{ steps.vars.outputs.tag }} /opt/apps/im-webapp/deploy.sh
|
||||
docker build \
|
||||
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" \
|
||||
-t git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }} .
|
||||
docker push git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }}
|
||||
|
||||
- name: 部署到k3s测试集群
|
||||
run: |
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
|
||||
chmod 600 ~/.kube/config
|
||||
kubectl rollout restart deployment/im-webapp -n im-webapp-system
|
||||
kubectl rollout status deployment/im-webapp -n im-webapp-system --timeout=120s
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Kill switch for the pre-rewrite service worker (formerly at repo root, see git history).
|
||||
// That worker cached every same-origin GET (including session/bootstrap API calls) keyed only
|
||||
// by frontend PACKAGE_VERSION -- a backend-only change (deploy, migration, cutover) never
|
||||
// invalidated it, so browsers that had visited this origin before the rewrite kept getting
|
||||
// stale API responses served instead of hitting the new backend, which looked like "login
|
||||
// succeeds then immediately bounces back to the login page".
|
||||
//
|
||||
// This file's only job is to replace that worker in already-registered browsers and remove
|
||||
// it entirely: purge its caches, unregister, then reload any open tabs so they load uncontrolled.
|
||||
// The rewritten app (web/) does not use a service worker for anything -- do not add fetch
|
||||
// caching logic here.
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map(key => caches.delete(key)));
|
||||
await self.registration.unregister();
|
||||
const clientsList = await self.clients.matchAll({ type: 'window' });
|
||||
for (const client of clientsList) {
|
||||
client.navigate(client.url);
|
||||
}
|
||||
})());
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
|
||||
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
|
||||
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
|
||||
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
|
||||
@@ -97,6 +101,13 @@ export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
{/* Mounted above the router (not inside AppShell) -- window.confirm()/window.alert()
|
||||
replacements need to work on unauthenticated pages too (e.g. TenantEntryPage's "switch
|
||||
tenant" confirmation), not just the logged-in app tree. */}
|
||||
<ConfirmDialogRoot />
|
||||
<GlobalToastRoot />
|
||||
<KickedNoticeRoot />
|
||||
<SecurityChallengeModal />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { CallRoot } from '@/features/calls/CallRoot';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { initCallSignaling } from '@/stores/calls';
|
||||
import { useNotificationNavStore } from '@/stores/notificationNav';
|
||||
|
||||
import { BottomTabBar } from './BottomTabBar';
|
||||
import { NavRail } from './NavRail';
|
||||
@@ -17,8 +21,15 @@ import { NavRail } from './NavRail';
|
||||
* columns within a section.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const navigate = useNavigate();
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
// Same `['me']` query SettingsLayout already runs -- sharing the key means whichever mounts
|
||||
// first fetches it and the other just reads the cache, not two redundant requests. This was
|
||||
// never wired up here before, so the rail's self-tile (NavRail below) had no avatar/name data
|
||||
// at all and fell back to a permanently-hardcoded "我" glyph regardless of who was logged in or
|
||||
// what avatar they'd uploaded -- see Avatar.tsx's own comment for the wider version of this gap.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
// 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(() => {
|
||||
@@ -34,10 +45,31 @@ export function AppShell() {
|
||||
contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
|
||||
(bootstrap.data?.entry_badges?.system_unread_count || 0);
|
||||
|
||||
// Same canonical number the rail/tab-bar badges above already show -- keeps the browser tab's
|
||||
// favicon badge and (while backgrounded) scrolling title in sync with it rather than tracking a
|
||||
// second, potentially-divergent count.
|
||||
useEffect(() => {
|
||||
updateTabIndicator(unreadMessages);
|
||||
}, [unreadMessages]);
|
||||
|
||||
// Consumes a desktop-notification click (lib/notifications/push.ts has no React Router access of
|
||||
// its own -- see stores/notificationNav.ts for why this goes through a store instead).
|
||||
const pendingNotificationTopic = useNotificationNavStore((s) => s.pendingTopic);
|
||||
useEffect(() => {
|
||||
if (!pendingNotificationTopic) return;
|
||||
navigate(`/messages/${pendingNotificationTopic}`);
|
||||
useNotificationNavStore.getState().clear();
|
||||
}, [pendingNotificationTopic, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
|
||||
<div className="hidden sm:flex sm:h-full">
|
||||
<NavRail unreadMessages={unreadMessages} />
|
||||
<NavRail
|
||||
unreadMessages={unreadMessages}
|
||||
selfId={meQuery.data?.user_id}
|
||||
selfName={meQuery.data?.profile.nickname || '我'}
|
||||
selfAvatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
|
||||
@@ -9,6 +9,10 @@ interface AvatarProps {
|
||||
/** 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;
|
||||
/** Overrides the hashed `avatarBgClass(id)` fallback color -- e.g. "bg-accent" for the current
|
||||
* user's own message-row avatar, which by design is always the fixed accent color rather than a
|
||||
* per-id hash like every other tile. No effect once avatarFileId resolves to a real image. */
|
||||
fallbackBgClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,12 +24,14 @@ interface AvatarProps {
|
||||
* `/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) {
|
||||
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: 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>
|
||||
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useConfirmDialogStore } from '@/stores/confirmDialog';
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `requestConfirm()` (confirmDialog.ts)
|
||||
* most recently asked for. Visual language matches this app's other small action dialogs (e.g. the
|
||||
* forward picker's outlined-gray "取消" + solid colored confirm button, `02-messages.dc.html`'s own
|
||||
* spec for that pairing) rather than inventing a new button treatment just for this. */
|
||||
export function ConfirmDialogRoot() {
|
||||
const pending = useConfirmDialogStore((s) => s.pending);
|
||||
const respond = useConfirmDialogStore((s) => s.respond);
|
||||
// Always called (hooks can't be conditional) -- a no-op onEscape while nothing is pending is
|
||||
// harmless, matching how every other modal in this app registers unconditionally too.
|
||||
useEscapeKey(() => respond(false));
|
||||
|
||||
if (!pending) return null;
|
||||
const { title, message, confirmLabel = '确定', cancelLabel = '取消', danger } = pending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(false)}>
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{title ? <strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong> : null}
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(false)}
|
||||
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(true)}
|
||||
className={`grid h-9 place-items-center rounded-lg px-4 text-sm font-medium text-white ${danger ? 'bg-danger-text' : 'bg-btn'}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { useGlobalToastStore } from '@/stores/globalToast';
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `notify()` (globalToast.ts) most
|
||||
* recently set. `key={token}` forces Toast to remount (not just re-render) so its own auto-dismiss
|
||||
* timer restarts even when the exact same message is fired twice in a row, which a plain `message`
|
||||
* string prop wouldn't detect as a change. */
|
||||
export function GlobalToastRoot() {
|
||||
const message = useGlobalToastStore((s) => s.message);
|
||||
const variant = useGlobalToastStore((s) => s.variant);
|
||||
const token = useGlobalToastStore((s) => s.token);
|
||||
if (!message) return null;
|
||||
return <Toast key={token} message={message} variant={variant} onDone={() => useGlobalToastStore.setState({ message: '' })} />;
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
/** 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`. */
|
||||
* interactive element (button/link with no visible text) must set its own `aria-label`.
|
||||
*
|
||||
* Deliberately `im-icon`, NOT Google Fonts' own `.material-icons` class -- see index.css's own
|
||||
* comment on that rule for why: Google's stylesheet ships that class completely unlayered, which
|
||||
* under CSS Cascade Layers always outranks Tailwind's layered utilities regardless of specificity
|
||||
* or source order, silently ignoring every `text-[Npx]` size override across the whole app. */
|
||||
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
|
||||
<i aria-hidden="true" className={`im-icon select-none ${className}`} style={style}>
|
||||
{name}
|
||||
</i>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { clearKickedInfo } from '@/lib/tinode/connection';
|
||||
import { kickedNoticeText } from '@/lib/tinode/errors';
|
||||
import { useKickedInfo } from '@/lib/tinode/hooks';
|
||||
|
||||
/**
|
||||
* Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot --
|
||||
* an eviction can land on any authenticated page, not just ones that happen to render their own
|
||||
* connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already
|
||||
* torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's
|
||||
* only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端
|
||||
* 接口文档.md §2.4's "弹出中文提示后跳转登录页".
|
||||
*
|
||||
* A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for
|
||||
* an eviction that already happened, offering one would be misleading. Hard `window.location.href`
|
||||
* navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical
|
||||
* reason: every piece of in-memory state (TanStack Query cache, any component's local state) should
|
||||
* reset from scratch, not rely on each one separately noticing "you're logged out now".
|
||||
*/
|
||||
export function KickedNoticeRoot() {
|
||||
const kicked = useKickedInfo();
|
||||
if (!kicked) return null;
|
||||
const { title, message } = kickedNoticeText(kicked.reason);
|
||||
|
||||
const acknowledge = () => {
|
||||
clearKickedInfo();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25">
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal">
|
||||
<strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong>
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button type="button" onClick={acknowledge} className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-4
@@ -1,5 +1,6 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface NavRailItem {
|
||||
@@ -54,13 +55,22 @@ function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface NavRailProps {
|
||||
unreadMessages?: number;
|
||||
/** The logged-in user's own id/name/avatar (`GET /v1/im/me`) -- drives the rail's top self-tile.
|
||||
* All optional so the rail still renders (as a letter-tile fallback) before that request settles. */
|
||||
selfId?: string;
|
||||
selfName?: string;
|
||||
selfAvatarFileId?: string;
|
||||
}
|
||||
|
||||
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
|
||||
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
||||
export function NavRail({ unreadMessages = 0, selfId, selfName, selfAvatarFileId }: NavRailProps) {
|
||||
return (
|
||||
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
|
||||
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-semibold text-white">
|
||||
{selfInitial}
|
||||
</div>
|
||||
<Link to="/settings/profile" title="个人资料" aria-label="个人资料" className="mb-[22px]">
|
||||
<Avatar id={selfId || ''} name={selfName} avatarFileId={selfAvatarFileId} className="h-[38px] w-[38px] rounded-[10px] text-base" />
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||
{TOP_ITEMS.map((item) => (
|
||||
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { initMessageNotifications } from '@/lib/notifications/push';
|
||||
import { restoreSession } from '@/lib/tinode/connection';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
import { subscribeMeTopic } from '@/lib/tinode/topics';
|
||||
import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
|
||||
@@ -81,6 +82,22 @@ export function RequireAuth() {
|
||||
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
|
||||
}, [myUserId]);
|
||||
|
||||
// Separate from the one-time subscribeMeTopic effect above on purpose: this one has real
|
||||
// start/stop semantics (a timer + listeners, not a non-idempotent WS packet) and needs to
|
||||
// restart cleanly across StrictMode's mount->cleanup->remount, not be guarded against it. See
|
||||
// startMeReconciliation's own comment for what backend gap this is mitigating.
|
||||
useEffect(() => {
|
||||
if (!myUserId) return undefined;
|
||||
return startMeReconciliation();
|
||||
}, [myUserId]);
|
||||
|
||||
// Same start/stop shape as the reconciliation effect above, same reason it isn't merged into the
|
||||
// one-time subscribeMeTopic effect.
|
||||
useEffect(() => {
|
||||
if (!myUserId) return undefined;
|
||||
return initMessageNotifications();
|
||||
}, [myUserId]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
if (myUserId) {
|
||||
return (
|
||||
@@ -106,9 +123,17 @@ export function RequireAuth() {
|
||||
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>
|
||||
{/* Only past the same timeout the message text itself waits for -- a normal restore usually
|
||||
resolves in well under a second (real WS round trip), and this link showing unconditionally
|
||||
from the very first frame made every ordinary reload/deep-link flash a "重新登录" escape
|
||||
hatch that had nothing to escape from (a real, reported false-alarm: "短暂白屏并误显改为
|
||||
重新登录"). The escape hatch is only actually useful once restoreTimedOut has already told
|
||||
the user this is taking longer than normal. */}
|
||||
{restoreTimedOut ? (
|
||||
<a href="/login" className="text-accent no-underline">
|
||||
改为重新登录
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { requestSecurityChallenge, verifySecurityChallenge } from '@/lib/api/security';
|
||||
import { useSecurityChallengeStore } from '@/stores/securityChallenge';
|
||||
|
||||
/** Mounted once at the app root (App.tsx), same placement/pattern as ConfirmDialogRoot -- renders
|
||||
* whatever `requestActionToken()` (stores/securityChallenge.ts) most recently asked for: the
|
||||
* protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766), needed before
|
||||
* a group can actually be dissolved (or its ownership transferred) -- both require `X-Action-Token`,
|
||||
* and there was previously no UI anywhere in this app that could produce one. */
|
||||
export function SecurityChallengeModal() {
|
||||
const pending = useSecurityChallengeStore((s) => s.pending);
|
||||
const respond = useSecurityChallengeStore((s) => s.respond);
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [phoneMask, setPhoneMask] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
useEscapeKey(() => respond(null));
|
||||
|
||||
const tick = (initial: number) => {
|
||||
window.clearInterval(timerRef.current);
|
||||
setSeconds(initial);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Fires the first SMS the moment the modal opens -- the doc's own flow has no separate "get code"
|
||||
// click for this endpoint (unlike VerificationCodeButton's register/reset-password scenes), since
|
||||
// the phone number isn't something the user enters here at all (read server-side from the login
|
||||
// identity), so there's nothing to wait on the user to fill in first.
|
||||
useEffect(() => {
|
||||
window.clearInterval(timerRef.current);
|
||||
setChallengeId('');
|
||||
setCode('');
|
||||
setError('');
|
||||
setSeconds(0);
|
||||
if (!pending) return undefined;
|
||||
let cancelled = false;
|
||||
requestSecurityChallenge(pending.scene)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setChallengeId(data.challenge_id);
|
||||
setPhoneMask(data.phone_mask);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pending?.scene]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
const resend = () => {
|
||||
if (busy || seconds > 0) return;
|
||||
setError('');
|
||||
setBusy(true);
|
||||
requestSecurityChallenge(pending.scene)
|
||||
.then((data) => {
|
||||
setChallengeId(data.challenge_id);
|
||||
setPhoneMask(data.phone_mask);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
})
|
||||
.catch((err) => setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请输入 6 位验证码');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const verified = await verifySecurityChallenge(challengeId, code);
|
||||
respond(verified.action_token);
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(null)}>
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<strong className="mb-2 block text-base font-semibold text-text-heading">安全验证</strong>
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">
|
||||
即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="请输入 6 位验证码"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
className="h-9 min-w-0 flex-1 rounded-lg border border-border-panel px-3 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={busy || seconds > 0 || !challengeId}
|
||||
className="h-9 w-28 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
>
|
||||
{seconds > 0 ? `重新获取(${seconds}s)` : '重新获取'}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="m-0 mt-2 text-xs text-danger-text">{error}</p> : null}
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(null)}
|
||||
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !challengeId || code.length !== 6}
|
||||
className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? '验证中…' : '确定'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
interface SelectionCheckboxProps {
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared circular multi-select indicator -- every list in this app that lets you pick multiple
|
||||
* items (forward targets, message multi-select, create-group contact picker, favorites, blocklist)
|
||||
* used to hand-copy this same circle-that-fills-with-a-checkmark markup into its own file, each
|
||||
* with small, accidental class differences (a stray extra border in the selected state here, a
|
||||
* slightly different gray there) -- fixing the same visual bug five separate times is exactly the
|
||||
* failure mode a shared component exists to prevent.
|
||||
*
|
||||
* Draws the checkmark as a hand-drawn SVG path, not the Material Icons font glyph `Icon` uses
|
||||
* everywhere else: the font's hinting isn't tuned for a ~13px glyph inside a 20px circle, so above
|
||||
* 1x pixel density it renders visibly soft/asymmetric where a vector path stays crisp at any scale.
|
||||
* A deliberate one-off exception to this app's usual `Icon`-for-everything convention, not a second
|
||||
* icon system -- scoped to this one selection indicator.
|
||||
*/
|
||||
export function SelectionCheckbox({ selected, className = '' }: SelectionCheckboxProps) {
|
||||
return (
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'} ${className}`}
|
||||
>
|
||||
{selected ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3 w-3 text-white" aria-hidden="true">
|
||||
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -105,7 +105,21 @@ export function EnterpriseRegisterPage() {
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!selected) return;
|
||||
// `accept` is only a file-picker hint, not enforcement -- a user can still pick
|
||||
// an oversized or wrong-type file via "所有文件". Matching TenantFeedbackPage's
|
||||
// own screenshot validation rather than silently uploading and waiting on the
|
||||
// backend's FILE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE rejection for the same check.
|
||||
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
|
||||
setError('营业执照上传失败:仅支持 JPG/PNG/WebP/PDF,且不超过 10MB');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setFile(selected);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<Icon name="upload_file" className="text-[22px] text-accent" />
|
||||
|
||||
@@ -100,7 +100,7 @@ export function ResetPasswordPage() {
|
||||
<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>
|
||||
<h1 className="m-0 hidden text-[22px] font-bold text-text-title min-[900px]:block">重置密码</h1>
|
||||
<p className="m-0 text-sm text-text-tertiary-1">
|
||||
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
|
||||
</p>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Icon } from '@/app/Icon';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { resolveTenant } from '@/lib/api/tenant';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
|
||||
export function TenantEntryPage() {
|
||||
@@ -24,8 +25,14 @@ export function TenantEntryPage() {
|
||||
if (!code.trim() || submitting) return;
|
||||
// Reachable by navigating back here while already logged in (no route guard keeps an
|
||||
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
|
||||
// that live session with no warning at all.
|
||||
if (myUserId && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return;
|
||||
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
|
||||
// Zustand value that resets to null on any fresh mount of this module (a page reload while
|
||||
// sitting on /tenant, or opening this URL directly in a new tab) even though the session token
|
||||
// is still sitting in sessionStorage and would otherwise restore fine -- confirmed by reading
|
||||
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
|
||||
// never mounts for this route. Checking the token directly closes that gap.
|
||||
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
|
||||
if (hasLiveSession && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -87,6 +94,7 @@ export function TenantEntryPage() {
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="请输入企业码"
|
||||
maxLength={64}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -47,12 +47,20 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
|
||||
|
||||
const click = async () => {
|
||||
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
|
||||
// The phone number this request is actually FOR -- captured now, not read again after the
|
||||
// `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in
|
||||
// flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever
|
||||
// number is now showing, with nothing on screen indicating the mismatch -- the eventual
|
||||
// verification would silently operate on an account the user never intended.
|
||||
const requestedPhone = phone;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await requestVerificationCode(scene, phone);
|
||||
const data = await requestVerificationCode(scene, requestedPhone);
|
||||
if (requestedPhone !== phone) return;
|
||||
onChallenge(data.challenge_id);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
} catch (err) {
|
||||
if (requestedPhone !== phone) return;
|
||||
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
|
||||
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
|
||||
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
|
||||
@@ -28,9 +28,12 @@ function statusLabel(item: CallHistoryItem): string {
|
||||
/** 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.
|
||||
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used
|
||||
* to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening
|
||||
* a friend's profile does); `topic` (the same opaque `p2p...` form the chat list itself caches once
|
||||
* a conversation has real history) is what display-name resolution matches against instead -- the
|
||||
* two are different identifier spaces and matching name lookup against `peer_user_id` was a real,
|
||||
* live-confirmed bug (Zentao #27) that always fell through to the raw uid.
|
||||
*/
|
||||
export function CallHistoryView() {
|
||||
const navigate = useNavigate();
|
||||
@@ -45,12 +48,30 @@ export function CallHistoryView() {
|
||||
|
||||
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">
|
||||
{/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录
|
||||
(SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention
|
||||
FavoritesLayout's own mobile header already uses, and the same gap this file didn't have
|
||||
until now (there was no way back from here at all short of a system back gesture). */}
|
||||
<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>
|
||||
<span />
|
||||
</header>
|
||||
<header className="hidden h-15 flex-none items-center border-b border-border-panel bg-white px-6 md:flex">
|
||||
<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>
|
||||
) : 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-center text-sm text-text-tertiary-4">暂无通话记录</div>
|
||||
) : (
|
||||
@@ -62,7 +83,14 @@ export function CallHistoryView() {
|
||||
// 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;
|
||||
// NOT `c.topic === item.peer_user_id` -- live-confirmed real bug (Zentao #27): `peer_user_id`
|
||||
// is a real `usr...` id, but an established P2P conversation's `contact.topic` is the SDK's
|
||||
// renamed, opaque `p2p...` form (see getP2PPeerUserId's own doc comment in topics.ts for the
|
||||
// full mechanism) -- the two are different identifier spaces and this comparison never
|
||||
// matched for any call with real history, always falling through to the raw uid. `item.topic`
|
||||
// is that same `p2p...` form already (confirmed live: identical string to what the chat list
|
||||
// caches for this conversation), so matching on it directly is the correct, direct fix.
|
||||
const name = contacts.find((c) => c.topic === item.topic)?.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">
|
||||
|
||||
@@ -375,8 +375,11 @@ function EndedCallOverlay() {
|
||||
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
|
||||
case 'missed':
|
||||
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
|
||||
// `wasCaller` here is `false` exactly when this device is the one that just clicked
|
||||
// "拒绝" (rejectCall() only runs during `phase==='incoming'`) -- same distinction `missed`
|
||||
// right above already makes, "对方已拒绝" is backwards on the decliner's own screen.
|
||||
case 'declined':
|
||||
return { label: '对方已拒绝', danger: true };
|
||||
return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true };
|
||||
case 'disconnected':
|
||||
return { label: '通话连接异常,已结束', danger: true };
|
||||
case 'busy':
|
||||
|
||||
@@ -12,13 +12,10 @@ import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api
|
||||
import { submitGroupJoinRequest } from '@/lib/api/groups';
|
||||
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { notImplemented } from '@/stores/globalToast';
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 50;
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
interface AddFriendModalProps {
|
||||
onClose: () => void;
|
||||
onSent?: () => void;
|
||||
@@ -197,9 +194,9 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
|
||||
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"
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
|
||||
>
|
||||
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
|
||||
<Icon name={action.icon} className="text-xl text-accent" />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -211,6 +208,13 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
|
||||
<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.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-sm text-danger-text">
|
||||
<span>搜索失败,请重试</span>
|
||||
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !searchQuery.data?.items.length ? (
|
||||
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
|
||||
) : (
|
||||
@@ -223,6 +227,18 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
|
||||
type="button"
|
||||
disabled={!selectable}
|
||||
onClick={() => {
|
||||
// Real, live-confirmed backend validation (im_http.go's
|
||||
// serveIMFriendRequestCreate): the `source` field's switch only recognizes
|
||||
// `search`/`qr`/`group` -- there is no `phone` case, so it falls to
|
||||
// `default` and 400s with "invalid friend request source". A prior pass
|
||||
// here tried to report phone-originated searches as their own `phone`
|
||||
// source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"),
|
||||
// which broke every phone-search-based friend request outright -- reproduced
|
||||
// live with two brand-new accounts before this fix (first-ever request,
|
||||
// never a retry/idempotency issue as it first looked). Phone and account
|
||||
// search both go through the same `search` channel from the backend's own
|
||||
// point of view; there is no server-side data to recover that distinction
|
||||
// from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this.
|
||||
setSelectedSource('search');
|
||||
setSelected(item);
|
||||
}}
|
||||
@@ -234,7 +250,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
|
||||
{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'}`}>
|
||||
<span className={`flex-none rounded-md px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
|
||||
{isSelected ? '已选择' : '选择'}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
|
||||
@@ -102,6 +103,13 @@ export function BlocklistView() {
|
||||
<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>
|
||||
) : 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-center text-sm text-text-tertiary-4">黑名单是空的</div>
|
||||
) : (
|
||||
@@ -114,15 +122,7 @@ export function BlocklistView() {
|
||||
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}
|
||||
{selectMode ? <SelectionCheckbox selected={selected.has(b.user.user_id)} /> : null}
|
||||
<Avatar
|
||||
id={b.user.user_id}
|
||||
name={b.user.display_name}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ContactsListColumn() {
|
||||
<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">
|
||||
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-10 w-10 place-items-center text-text-heading md:h-9 md:w-9">
|
||||
<Icon name="person_add_alt" />
|
||||
</button>
|
||||
</header>
|
||||
@@ -119,6 +119,13 @@ export function ContactsListColumn() {
|
||||
)}
|
||||
{friendsQuery.isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : friendsQuery.isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => friendsQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</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" />
|
||||
|
||||
@@ -70,12 +70,28 @@ export function FriendProfileView() {
|
||||
// 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.
|
||||
// as if the user doesn't exist.
|
||||
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
|
||||
// A genuine 404 is the only case that should actually read as "this user doesn't exist" --
|
||||
// every OTHER failure (network error, 5xx, a transient timeout) used to fall into that same
|
||||
// "未找到该用户" bucket too, telling the user an account is gone/never existed when the real
|
||||
// problem is just that this one request failed and could succeed on retry.
|
||||
const isNotFound = query.error instanceof ImApiError && query.error.status === 404;
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-sm text-text-tertiary-4">
|
||||
{backLink}
|
||||
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
|
||||
{isPrivate ? (
|
||||
'对方设置了资料保护,暂时无法查看'
|
||||
) : isNotFound ? (
|
||||
'未找到该用户'
|
||||
) : (
|
||||
<>
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,13 @@ export function GroupListView() {
|
||||
<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>
|
||||
) : 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>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
|
||||
) : (
|
||||
@@ -89,7 +96,11 @@ export function GroupListView() {
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{g.member_count} 人 · 群号 {g.topic}
|
||||
{/* `GET /groups`'s own `member_count` can come back as a bare 0 for a real, non-empty
|
||||
group (confirmed live -- same backend dual-counter split as ChatListColumn's own
|
||||
"N人" pill, 问题8 in the backend issue list) -- no real group has zero members, so
|
||||
an exact zero here means "don't trust this field", not "empty group". */}
|
||||
{g.member_count > 0 ? `${g.member_count} 人 · ` : null}群号 {g.topic}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
|
||||
|
||||
@@ -11,7 +11,12 @@ import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type Frien
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
|
||||
// `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's
|
||||
// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the
|
||||
// same `search` value an account-name search does, since the server has no separate channel for it
|
||||
// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it
|
||||
// tried to report `phone` as its own source instead).
|
||||
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
|
||||
|
||||
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
|
||||
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
|
||||
@@ -69,6 +74,13 @@ export function NewFriendsView() {
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center 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-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
|
||||
) : (
|
||||
|
||||
@@ -35,15 +35,23 @@ export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProp
|
||||
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const draw = () => {
|
||||
if (!query.data || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
setDrawError('');
|
||||
// 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]);
|
||||
};
|
||||
|
||||
useEffect(draw, [query.data]);
|
||||
|
||||
const retry = () => {
|
||||
if (query.isError) query.refetch();
|
||||
else draw();
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -117,6 +125,9 @@ export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProp
|
||||
<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>
|
||||
<button type="button" onClick={retry} className="text-xs text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-q
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
@@ -20,6 +21,7 @@ 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';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
|
||||
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: 'all_inbox' },
|
||||
@@ -189,17 +191,9 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
|
||||
>
|
||||
<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}
|
||||
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border-list-item pt-2.5 text-xs text-text-tertiary-3">
|
||||
<div className="flex items-center justify-between border-t border-border-row 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>
|
||||
@@ -277,8 +271,16 @@ export function FavoritesLayout() {
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidateAll();
|
||||
exitSelectMode();
|
||||
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
// Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure --
|
||||
// the error toast named a count ("N项删除失败") but gave no way to act on it, so retrying
|
||||
// meant re-selecting from scratch by memory. Only actually-failed ids stay selected instead,
|
||||
// primed for an immediate retry.
|
||||
if (data?.failed?.length) {
|
||||
setSelected(new Set(data.failed.map((f) => f.favorite_id)));
|
||||
setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
} else {
|
||||
exitSelectMode();
|
||||
}
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
|
||||
});
|
||||
@@ -290,10 +292,13 @@ export function FavoritesLayout() {
|
||||
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.
|
||||
// succeed for 2 and fail for 1, and silently reporting "success" either way (including
|
||||
// clearing the favorites selection) hid that AND left nothing to retry from. The picker
|
||||
// itself still closes (which of several targets failed isn't tracked here) but the source
|
||||
// favorites stay selected so re-opening "转发" immediately can retry against the same set.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
||||
else exitSelectMode();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
|
||||
});
|
||||
@@ -327,9 +332,9 @@ export function FavoritesLayout() {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const bulkDelete = () => {
|
||||
const bulkDelete = async () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return;
|
||||
if (!(await requestConfirm({ message: `删除已选择的 ${selected.size} 项收藏?`, danger: true }))) return;
|
||||
deleteMutation.mutate([...selected]);
|
||||
};
|
||||
|
||||
@@ -341,7 +346,7 @@ export function FavoritesLayout() {
|
||||
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'
|
||||
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-tertiary-1'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
@@ -385,7 +390,7 @@ export function FavoritesLayout() {
|
||||
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={c.icon} className="text-[18px]" />
|
||||
<Icon name={c.icon} className={`text-[18px] ${category === c.key ? '' : 'text-text-tertiary-4'}`} />
|
||||
<span className="flex-1 text-left">{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getFriends } from '@/lib/api/friends';
|
||||
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
|
||||
@@ -61,13 +62,37 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
let result: Awaited<ReturnType<typeof createGroup>>;
|
||||
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a combination
|
||||
// of member nicknames when left blank), but `name` has no such fallback server-side (unlike
|
||||
// `description`, which the protocol doc explicitly allows empty) -- confirmed live: submitting a
|
||||
// blank name gets rejected outright with MALFORMED_REQUEST, not silently defaulted. Declared
|
||||
// outside the try block since the catch below needs it too.
|
||||
const usedAutoName = !name.trim();
|
||||
try {
|
||||
// Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when
|
||||
// it had to cut names short, so this can't itself produce an over-length name the server would
|
||||
// also reject.
|
||||
const trimmedName = name.trim();
|
||||
let finalName = trimmedName;
|
||||
if (!finalName) {
|
||||
const joined = [...selected.values()].join('、');
|
||||
finalName = joined.length > MAX_TITLE_LENGTH ? `${joined.slice(0, MAX_TITLE_LENGTH - 1)}…` : joined;
|
||||
}
|
||||
result = await createGroup({
|
||||
name: name.trim(),
|
||||
name: finalName,
|
||||
member_user_ids: [...selected.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建群聊失败');
|
||||
// Real, live-confirmed gap (Zentao #26): a name the user never typed themselves -- the
|
||||
// "未填写时默认为成员昵称组合" fallback just above -- can still get rejected (e.g. content
|
||||
// moderation flagging a purely-numeric nickname combination), and the generic error message
|
||||
// gives someone who left the field blank no clue that "the name" is even what's wrong, let
|
||||
// alone that they can fix it by typing one in themselves.
|
||||
if (usedAutoName) {
|
||||
setError('自动生成的群名未通过审核,请手动输入一个群名后重试');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : '创建群聊失败');
|
||||
}
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
@@ -133,13 +158,7 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
|
||||
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>
|
||||
<SelectionCheckbox selected={checked} />
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getAllGroupMembers,
|
||||
getGroup,
|
||||
getGroupGovernance,
|
||||
getGroupMutes,
|
||||
getAllGroupMutes,
|
||||
getGroupRoleLimits,
|
||||
getGroupSummary,
|
||||
muteGroupMembers,
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { requestActionToken } from '@/stores/securityChallenge';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupAdminModalProps {
|
||||
@@ -105,7 +107,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
|
||||
enabled: isOwner,
|
||||
});
|
||||
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getGroupMutes(topicId) });
|
||||
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getAllGroupMutes(topicId) });
|
||||
|
||||
const govMutation = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
|
||||
@@ -132,7 +134,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
onClose={() => setView('main')}
|
||||
/>
|
||||
);
|
||||
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data?.items || []} onClose={() => setView('main')} />;
|
||||
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data || []} onClose={() => setView('main')} />;
|
||||
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
|
||||
|
||||
return (
|
||||
@@ -152,7 +154,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.join_approval}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.join_approval_editable === false}
|
||||
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
|
||||
/>
|
||||
</div>
|
||||
@@ -163,7 +165,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.mute_all}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.mute_all_editable === false}
|
||||
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
|
||||
/>
|
||||
</div>
|
||||
@@ -176,7 +178,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_upload_files_editable === false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
@@ -188,7 +190,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
|
||||
/>
|
||||
</div>
|
||||
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
|
||||
@@ -205,7 +207,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_invite: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_invite_editable === false}
|
||||
/>
|
||||
</div>
|
||||
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
|
||||
@@ -225,7 +227,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
<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>
|
||||
<span className="text-xs">{mutesQuery.data?.length ?? 0} 人</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -383,10 +385,20 @@ function AdminsSubView({
|
||||
</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>
|
||||
{(() => {
|
||||
const atLimit = !!maxAdminCount && admins.length >= maxAdminCount;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
disabled={atLimit}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent disabled:text-text-tertiary-3"
|
||||
>
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
{atLimit ? '已达管理员人数上限' : '添加管理员'}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
@@ -398,6 +410,13 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
|
||||
const queryClient = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// `GroupMute` itself carries only a bare `user_id` (see its own type comment) -- resolve a real
|
||||
// name/avatar the same way ConversationView.tsx's `memberByUserId` does, rather than rendering the
|
||||
// raw id (this list previously crashed outright reading a `.user.display_name` that never existed
|
||||
// on the real response, confirmed live: `Cannot read properties of undefined (reading 'user_id')`
|
||||
// the moment a real muted member was ever rendered here).
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const memberByUserId = new Map((membersQuery.data || []).map((m) => [m.user_id, m]));
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
|
||||
const muteMutation = useMutation({
|
||||
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
|
||||
@@ -427,22 +446,25 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{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>
|
||||
))}
|
||||
{muted.map((m) => {
|
||||
const name = memberByUserId.get(m.user_id)?.display_name || m.user_id;
|
||||
return (
|
||||
<div key={m.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_id)}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unmuteMutation.mutate(m.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]" />
|
||||
禁言成员
|
||||
@@ -458,7 +480,8 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const transferMutation = useMutation({
|
||||
mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1),
|
||||
mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) =>
|
||||
transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
|
||||
@@ -478,8 +501,11 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-owner"
|
||||
opts={{ eligibleRole: 'owner' }}
|
||||
onPick={(m) => {
|
||||
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
|
||||
onPick={async (m) => {
|
||||
if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return;
|
||||
const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
|
||||
if (!actionToken) return;
|
||||
transferMutation.mutate({ userId: m.user_id, actionToken });
|
||||
}}
|
||||
emptyLabel="没有可转让的成员"
|
||||
disabled={transferMutation.isPending}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLayoutEffect, 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';
|
||||
@@ -24,8 +25,11 @@ import {
|
||||
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 { forgetTopic } from '@/lib/tinode/topics';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { requestActionToken } from '@/stores/securityChallenge';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupDetailColumnProps {
|
||||
@@ -78,7 +82,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
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 [openModal, setOpenModal] = useState<'members' | 'invite' | '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 --
|
||||
@@ -117,6 +121,11 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
// (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;
|
||||
// Same gap as `canInvite` right above (same governance toggle pattern, same root cause: the
|
||||
// "群文件上传权限" toggle in GroupAdminModal writes `member_upload_files` but nothing ever read it
|
||||
// back) -- `GroupFilesModal`'s upload button was unconditionally visible/clickable regardless of
|
||||
// this setting.
|
||||
const canUpload = isAdmin || settingsQuery.data?.governance?.member_upload_files === 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",
|
||||
@@ -143,33 +152,57 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
onError: (err) => showError(err, '昵称保存失败'),
|
||||
});
|
||||
|
||||
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
||||
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
||||
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
||||
// stub for this group after leaving/disbanding it (same lingering-stub symptom `forgetTopic` was
|
||||
// originally built to fix for the refresh/404 case, just reached via a third path); (2) that same
|
||||
// late signal, left unguarded, used to re-fire ConversationView's generic "该群聊已解散" toast on
|
||||
// top of this action's own navigate -- wrong wording specifically for 退出群聊 (leaving yourself
|
||||
// isn't the group being dissolved). `markTopicGoneAndNotify`'s own guard (topics.ts) now skips that
|
||||
// late signal entirely once `forgetTopic` has already marked this name gone here.
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: () => leaveGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onSuccess: () => {
|
||||
forgetTopic(topicId);
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '退出群聊失败'),
|
||||
});
|
||||
|
||||
const disbandMutation = useMutation({
|
||||
mutationFn: () => disbandGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
|
||||
onSuccess: () => {
|
||||
forgetTopic(topicId);
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '解散群聊失败'),
|
||||
});
|
||||
|
||||
const clearHistory = () => {
|
||||
if (!window.confirm('确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。')) return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
const clearHistory = async () => {
|
||||
if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return;
|
||||
try {
|
||||
await tinode.getTopic(topicId).delMessagesAll(false);
|
||||
} catch (err) {
|
||||
showError(err, '清空聊天记录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
notify('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
const startLeaveOrDisband = () => {
|
||||
const startLeaveOrDisband = async () => {
|
||||
if (isOwner) {
|
||||
if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return;
|
||||
disbandMutation.mutate();
|
||||
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
|
||||
const actionToken = await requestActionToken({
|
||||
scene: 'group_dissolve',
|
||||
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
|
||||
});
|
||||
if (!actionToken) return;
|
||||
disbandMutation.mutate(actionToken);
|
||||
} else {
|
||||
if (!window.confirm('确定退出本群聊吗?')) return;
|
||||
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
|
||||
leaveMutation.mutate();
|
||||
}
|
||||
};
|
||||
@@ -220,9 +253,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
<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>
|
||||
<Avatar
|
||||
id={topicId}
|
||||
name={groupQuery.data?.name || peerName}
|
||||
avatarFileId={groupQuery.data?.avatar_file_id}
|
||||
className="h-13 w-13 flex-none rounded-xl text-xl"
|
||||
/>
|
||||
<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">
|
||||
@@ -257,17 +293,24 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
<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>
|
||||
<Avatar
|
||||
id={m.user_id}
|
||||
name={m.display_name}
|
||||
avatarFileId={m.avatar_file_id}
|
||||
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
|
||||
/>
|
||||
<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">
|
||||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
||||
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
|
||||
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
|
||||
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
|
||||
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
|
||||
// plain member list -- only THIS tile's whole purpose is inviting.
|
||||
<button type="button" onClick={() => setOpenModal('invite')} 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>
|
||||
@@ -403,13 +446,18 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
|
||||
{openModal === 'members' ? (
|
||||
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
|
||||
{openModal === 'members' || openModal === 'invite' ? (
|
||||
<GroupMembersModal
|
||||
topicId={topicId}
|
||||
canInvite={canInvite}
|
||||
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
||||
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 === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} canUpload={canUpload} onClose={() => setOpenModal(null)} /> : null}
|
||||
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
</aside>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
function isPdf(f: GroupFile): boolean {
|
||||
@@ -33,6 +34,11 @@ interface GroupFilesModalProps {
|
||||
* owner/admin has no way to remove another member's upload from this UI even though the server
|
||||
* would accept the request. */
|
||||
isAdmin: boolean;
|
||||
/** `governance.member_upload_files` (所有成员/仅管理员), computed by the caller alongside
|
||||
* `isAdmin` the exact same way `GroupDetailColumn`'s own `canInvite` already is -- without this,
|
||||
* the toggle in GroupAdminModal had zero effect on this button, the same class of bug
|
||||
* `canInvite` was already fixed for. */
|
||||
canUpload: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -59,7 +65,7 @@ function fileTypeFromMime(mime: string, name: string): string {
|
||||
* 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) {
|
||||
export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupFilesModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -138,14 +144,19 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
|
||||
<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>
|
||||
the neutral gray this used to be. Hidden (not just disabled) when governance says
|
||||
only admins may upload and this viewer isn't one -- there's no "ask an admin" action
|
||||
a plain member could take from here, so a visible-but-blocked button would only
|
||||
invite a confusing click. */}
|
||||
{canUpload ? (
|
||||
<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>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
@@ -211,9 +222,11 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
|
||||
onClose={() => setOpenMenuId(null)}
|
||||
onDownload={() => downloadFile(f)}
|
||||
onOpen={() => openFileInNewTab(f)}
|
||||
onDelete={() => {
|
||||
onDelete={async () => {
|
||||
setOpenMenuId(null);
|
||||
if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) deleteMutation.mutate(f.group_file_id);
|
||||
if (await requestConfirm({ message: `确定删除文件"${f.file_name}"吗?`, danger: true })) {
|
||||
deleteMutation.mutate(f.group_file_id);
|
||||
}
|
||||
}}
|
||||
deleting={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { getAllGroupMembers, inviteGroupMembers, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
@@ -28,6 +28,13 @@ interface GroupMembersModalProps {
|
||||
* 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;
|
||||
/** "invite" opens straight into the invite-candidates view, skipping the member list entirely --
|
||||
* the dedicated "邀请" entry point (GroupDetailColumn's dashed-border tile) used to route through
|
||||
* here too but land on the member list regardless, requiring a SECOND click on an in-modal "邀请"
|
||||
* button to actually reach this view (a real, reported UX complaint: "点击邀请那个按钮弹出的并不是
|
||||
* 直接到邀请,而是显示的成员列表,还要再次点击邀请"). Defaults to "members" for every OTHER caller
|
||||
* (查看全部成员 etc.), which should still land on the member list as before. */
|
||||
initialView?: 'members' | 'invite';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -36,11 +43,27 @@ interface GroupMembersModalProps {
|
||||
* 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) {
|
||||
/** Per-candidate outcome once invited THIS session -- keyed by user_id, separate from `alreadyMember`
|
||||
* (a person can be successfully invited without becoming a member yet -- see the "pending"/
|
||||
* "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations
|
||||
* (confirmed: grepped the protocol doc), so this can only ever reflect invites sent from THIS modal
|
||||
* session, not ones sent earlier and never followed up on -- reopening the modal loses this. */
|
||||
type InviteOutcome = GroupInvitationResult['status'];
|
||||
|
||||
const OUTCOME_LABEL: Record<InviteOutcome, string> = {
|
||||
joined: '已加入',
|
||||
already_member: '已在群内',
|
||||
pending: '邀请已发送',
|
||||
pending_approval: '待对方确认',
|
||||
failed: '邀请失败',
|
||||
};
|
||||
|
||||
export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [inviting, setInviting] = useState(initialView === 'invite');
|
||||
const [error, setError] = useState('');
|
||||
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
@@ -59,7 +82,17 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, userId) => {
|
||||
// A real, previously-missing gap this fixes: the old version only ever refetched the member
|
||||
// list and left it at that -- for the common "joined immediately" case that's a silent success
|
||||
// with no feedback beyond the candidate eventually re-rendering as "已在群内" on its own, and
|
||||
// for "pending"/"pending_approval" (requires the target's own action, or the group's/their own
|
||||
// approval setting) there was NO feedback at all -- the row just sat there looking un-invited,
|
||||
// inviting a confused repeat click. `items[0]` because this call always invites exactly one
|
||||
// user_id at a time.
|
||||
const outcome = data.items[0]?.status;
|
||||
if (outcome) setOutcomeByUser((prev) => ({ ...prev, [userId]: outcome }));
|
||||
if (outcome === 'failed' && data.items[0]?.reason) setError(data.items[0].reason);
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
@@ -101,22 +134,34 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
|
||||
.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;
|
||||
// `alreadyMember` (a real member, known up front) and a THIS-SESSION invite outcome
|
||||
// are two different reasons a candidate can be non-actionable -- alreadyMember wins if
|
||||
// both are somehow true (the member-list refetch above should already have cleared the
|
||||
// "joined" outcome to alreadyMember=true by then anyway).
|
||||
const outcome = f.alreadyMember ? 'already_member' : outcomeByUser[f.user_id];
|
||||
// "failed" deliberately does NOT count as settled -- could be a transient network blip,
|
||||
// and there's no harm in letting the same click retry (a genuinely permanent failure,
|
||||
// e.g. the target blocked invites, just fails again harmlessly). Every other outcome is
|
||||
// a real, done state with nothing left to retry.
|
||||
const settled = !!outcome && outcome !== 'failed';
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
disabled={inviteMutation.isPending || f.alreadyMember}
|
||||
disabled={inviteMutation.isPending || settled}
|
||||
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' : ''
|
||||
settled ? '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>
|
||||
{outcome ? (
|
||||
<span className={`flex-none text-xs ${outcome === 'failed' ? 'text-danger-text' : 'text-text-tertiary-3'}`}>
|
||||
{OUTCOME_LABEL[outcome]}
|
||||
</span>
|
||||
) : (
|
||||
<Icon name="person_add" className="text-[18px] text-accent" />
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate, useParams } 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 { getFriends } from '@/lib/api/friends';
|
||||
import { getGroups } from '@/lib/api/groups';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||
import { formatListTime } from '@/lib/messages/time';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
@@ -55,6 +57,9 @@ export function ChatListColumn() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
// `slf` (chat with yourself) never has its own `.public.fn` -- shares AppShell's `['me']` query
|
||||
// key so this is a cache read, not an extra request, in the common case that already mounted it.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
const tinode = useTinode();
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
@@ -63,6 +68,13 @@ export function ChatListColumn() {
|
||||
// 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 avatarByGroupTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.avatar_file_id]));
|
||||
// Same real gap as the conversation screen's header/message-row avatars (see ConversationView.tsx):
|
||||
// a P2P row's `contact` object comes from the Tinode SDK's own local cache, which was never taught
|
||||
// to carry the REST-only `avatar_file_id` this app uploads avatars through -- shares ContactsListColumn's
|
||||
// exact `['friends']` query key, so this is a cache read whenever that page has already been visited.
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const avatarByUserId = new Map(friendsQuery.data?.map((f) => [f.user_id, f.avatar_file_id]));
|
||||
const [query, setQuery] = useState('');
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
const sorted = [...contacts].sort((a, b) => {
|
||||
@@ -70,7 +82,13 @@ export function ChatListColumn() {
|
||||
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 filtered = trimmedQuery
|
||||
? sorted.filter((c) =>
|
||||
(c.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : c.public?.fn || c.topic || '')
|
||||
.toLowerCase()
|
||||
.includes(trimmedQuery.toLowerCase()),
|
||||
)
|
||||
: sorted;
|
||||
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
|
||||
|
||||
return (
|
||||
@@ -127,12 +145,20 @@ export function ChatListColumn() {
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((contact) => {
|
||||
const name = contact.public?.fn || contact.topic;
|
||||
const name = contact.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : contact.public?.fn || contact.topic;
|
||||
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||
const active = topicId === contact.topic;
|
||||
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
||||
// `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group
|
||||
// the single-group `GET /groups/{topic}` correctly reports as several members -- the same
|
||||
// dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed
|
||||
// live to surface as a full zero rather than a smaller mismatch. No real group has zero
|
||||
// members (the owner alone is one), so the `> 0` check below treats an exact zero as
|
||||
// "don't trust this" and hides the pill instead of showing a number already proven wrong.
|
||||
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
|
||||
const muted = !!contact.private?.mute_notifications;
|
||||
const avatarFileId =
|
||||
contact.topic === 'slf' ? meQuery.data?.profile.avatar_file_id : isGroup ? avatarByGroupTopic.get(contact.topic) : avatarByUserId.get(contact.topic);
|
||||
return (
|
||||
<Link
|
||||
key={contact.topic}
|
||||
@@ -141,10 +167,8 @@ export function ChatListColumn() {
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(name)}
|
||||
<span className="relative block h-11 w-11 flex-none">
|
||||
<Avatar id={contact.topic || ''} name={name} avatarFileId={avatarFileId} className="h-11 w-11 rounded-lg text-base" />
|
||||
{contact.online ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
|
||||
) : null}
|
||||
@@ -154,8 +178,8 @@ export function ChatListColumn() {
|
||||
<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>
|
||||
{typeof memberCount === 'number' && memberCount > 0 ? (
|
||||
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
|
||||
) : null}
|
||||
</span>
|
||||
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { EmojiPanel } from '@/features/messages/EmojiPanel';
|
||||
@@ -16,9 +17,10 @@ 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 { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
|
||||
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
|
||||
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||
import { useClickOutside } from '@/lib/hooks';
|
||||
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||
@@ -26,10 +28,22 @@ 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 { isTopicGone } from '@/lib/tinode/errors';
|
||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
|
||||
import {
|
||||
closeConversation,
|
||||
ensureTopicAttached,
|
||||
forgetTopic,
|
||||
getP2PPeerUserId,
|
||||
markConfirmedRead,
|
||||
openConversation,
|
||||
setActiveConversationTopic,
|
||||
subscribeTopicGone,
|
||||
} from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify, notImplemented } from '@/stores/globalToast';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
@@ -50,12 +64,9 @@ interface ContextMenuState {
|
||||
msg: StoredMessage;
|
||||
}
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function ConversationView() {
|
||||
const { topicId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const tinode = useTinode();
|
||||
@@ -72,6 +83,80 @@ export function ConversationView() {
|
||||
const setDraft = (value: string) => {
|
||||
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
|
||||
};
|
||||
|
||||
// Server-side draft persistence (Zentao #19: draft cleared on conversation switch/page refresh --
|
||||
// draftByTopic above only survives WITHIN this one mounted session, never a refresh or a different
|
||||
// device/tab). `getTopicDraft`/`putTopicDraft`'s own comment covers the real, live `PUT /v1/im/
|
||||
// topics/{topic}/draft` endpoint this hits (the protocol doc marks it "待实现", which is stale).
|
||||
//
|
||||
// draftVersionRef, not state -- purely an implementation detail this optimistic-lock write needs to
|
||||
// send back, never rendered, so it doesn't need to trigger a re-render when it changes.
|
||||
const draftVersionRef = useRef<Record<string, number>>({});
|
||||
// The content this app most recently confirmed IS what the server has (from either a completed load
|
||||
// or a completed save) -- lets the debounce-save effect below tell "the user actually typed
|
||||
// something new" apart from "draft just changed because the load effect populated it with what the
|
||||
// server already had," which would otherwise queue an entirely redundant save of identical content
|
||||
// the moment every conversation with an existing draft is opened.
|
||||
const lastSyncedContentRef = useRef<Record<string, string>>({});
|
||||
// Tracks which topics' server draft has already been fetched THIS session -- fetching again every
|
||||
// time the user merely glances back at an already-open conversation would risk clobbering local text
|
||||
// already being typed with whatever stale snapshot the server still has from before this session.
|
||||
const draftLoadedRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!topicId || draftLoadedRef.current.has(topicId)) return;
|
||||
draftLoadedRef.current.add(topicId);
|
||||
const forTopic = topicId;
|
||||
getTopicDraft(forTopic)
|
||||
.then((d) => {
|
||||
draftVersionRef.current[forTopic] = d.version;
|
||||
lastSyncedContentRef.current[forTopic] = d.content;
|
||||
if (!d.content) return;
|
||||
// Local (this-session) text always wins over the server snapshot -- the whole point of
|
||||
// fetching is recovering a draft AFTER a refresh/new session, not overwriting an edit already
|
||||
// in progress if this same topic was somehow already typed into before this fetch resolved.
|
||||
setDraftByTopic((prev) => (prev[forTopic] ? prev : { ...prev, [forTopic]: d.content }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [topicId]);
|
||||
|
||||
// Debounced (1.5s after the user stops typing) rather than on every keystroke -- draft content
|
||||
// isn't urgent enough to warrant a network write per character. `pendingDraftRef` (not just the
|
||||
// timer) is what the flush effect below actually sends, kept up to date on every keystroke
|
||||
// regardless of whether ITS OWN cleanup below beat the timer to canceling it.
|
||||
const pendingDraftRef = useRef<{ topic: string; content: string; timer: ReturnType<typeof setTimeout> } | null>(null);
|
||||
const flushDraftSave = useCallback(() => {
|
||||
const pending = pendingDraftRef.current;
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pendingDraftRef.current = null;
|
||||
const version = draftVersionRef.current[pending.topic] ?? 0;
|
||||
putTopicDraft(pending.topic, pending.content, version)
|
||||
.then((d) => {
|
||||
draftVersionRef.current[pending.topic] = d.version;
|
||||
lastSyncedContentRef.current[pending.topic] = pending.content;
|
||||
})
|
||||
.catch(() => {
|
||||
// A stale optimistic-lock version (edited on another device/tab in between) or a transient
|
||||
// network failure -- a personal, single-user, ephemeral draft doesn't warrant a refetch-and-
|
||||
// retry dance over losing the very latest keystrokes here; the next successful save (the next
|
||||
// debounce tick, or the next time this conversation is opened) catches back up regardless.
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!topicId || !draftLoadedRef.current.has(topicId)) return undefined;
|
||||
if (draft === lastSyncedContentRef.current[topicId]) return undefined; // nothing actually changed
|
||||
if (pendingDraftRef.current) clearTimeout(pendingDraftRef.current.timer);
|
||||
const timer = setTimeout(flushDraftSave, 1500);
|
||||
pendingDraftRef.current = { topic: topicId, content: draft, timer };
|
||||
return () => clearTimeout(timer);
|
||||
}, [topicId, draft, flushDraftSave]);
|
||||
// Separate from the effect above on purpose: THIS one's cleanup fires flushDraftSave() itself (not
|
||||
// just clearTimeout) so switching to a different conversation -- or closing/navigating away
|
||||
// entirely -- saves whatever's pending immediately, instead of abandoning it to a 1.5s timer that
|
||||
// may never get the chance to fire once its own component instance's timers are gone.
|
||||
useEffect(() => {
|
||||
return () => flushDraftSave();
|
||||
}, [topicId, flushDraftSave]);
|
||||
// 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
|
||||
@@ -131,12 +216,103 @@ export function ConversationView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId) return undefined;
|
||||
openConversation(topicId).catch(() => {});
|
||||
// Unlike every other action in this component (see showError's own comment above), a failed
|
||||
// subscribe here used to just vanish into an empty `.catch(() => {})` -- the user would land on
|
||||
// a totally blank message list with no error, no retry, and (since the noteRead effect below
|
||||
// only fires once `messages.length > 0`) a read receipt that can never be sent, leaving the chat
|
||||
// list's unread badge permanently stuck for a conversation that in fact never loaded at all.
|
||||
openConversation(topicId)
|
||||
.then((topic) => {
|
||||
// A P2P conversation reached via the OTHER user's `usr...` id (the only address that exists
|
||||
// before a real conversation has ever happened -- e.g. straight from a friend's profile,
|
||||
// never yet in the chat list) gets handed back a DIFFERENT real topic name by the server
|
||||
// (its actual `p2p...` hash) the moment the subscribe succeeds -- confirmed live: the SDK's
|
||||
// own Topic#subscribe (tinode.dev.js) renames+re-keys its local cache entry from `usr...` to
|
||||
// that real name whenever the topic was `_new` (true for every freshly-constructed Topic
|
||||
// object, which this always is on first open). Every OTHER piece of this component --
|
||||
// submit()'s send, the header's presence lookup, MessageBubble's retry -- calls
|
||||
// `tinode.getTopic(topicId)` fresh off the URL param on its own, completely independently of
|
||||
// whatever object `openConversation` itself subscribed. Left alone, that second `getTopic`
|
||||
// call is a cache MISS against the now-renamed key, silently manufacturing a brand new,
|
||||
// never-subscribed, empty stand-in object -- every send/read/presence check from then on
|
||||
// operates on that empty stand-in, not the real, subscribed, actually-receiving-data one.
|
||||
// Real messages keep arriving successfully (confirmed via a raw-protocol repro: the {pub}
|
||||
// lands and a fresh {sub}'s history dump shows it) but never reach this component's topic
|
||||
// object, so the chat window shows nothing and looks like sending silently failed. Replacing
|
||||
// the URL to the real name the instant it's known makes every later `getTopic(topicId)` in
|
||||
// this component resolve the SAME object subscribe() just populated, not a fresh impostor.
|
||||
if (topic.name !== topicId) navigate(`/messages/${topic.name}`, { replace: true });
|
||||
// Real, live-confirmed gap on top of the rename issue above: for exactly this brand-new-P2P-
|
||||
// via-alias case, `openConversation`'s own `withLaterDesc()` (an incremental "only if changed
|
||||
// since I last knew" fetch) can come back with the subscribe successful but NO desc AND no
|
||||
// presence at all -- `topic.public` stays permanently `null` and `topic.online` stays falsy
|
||||
// (confirmed live: still true 10+ seconds later, no amount of waiting fixes either one),
|
||||
// which is why the header/chat-list fall back to the raw topic id and "离线" forever on THIS
|
||||
// specific path (reached live via a friend's profile "发消息" button, never through a fresh
|
||||
// page load -- a full reload's own `subscribeMeTopic()` happens to paper over this by
|
||||
// fetching every contact's full subscription state unconditionally, which is exactly why this
|
||||
// was never caught by any test that reloaded the page as part of "opening" a conversation).
|
||||
// `me.getMeta(withOneSub(topic))` is the same targeted-refresh call the SDK's own presence
|
||||
// routing (`_routePres`, tinode.dev.js) uses for "just learned about this topic" -- unlike a
|
||||
// plain desc fetch on the topic itself, this one comes back with BOTH desc AND online status
|
||||
// in the same `sub` entry (confirmed live capturing the raw frame during an earlier
|
||||
// investigation), and `TopicMe`'s own `_processMetaSubs` merges both onto this exact topic
|
||||
// object before notifying -- wireTopicCallbacks already wired `me`'s `onMetaSub`/
|
||||
// `onSubsUpdated` at login, so the response correctly invalidates the chat list once it lands.
|
||||
if (!topic.public) {
|
||||
const me = tinode.getMeTopic();
|
||||
me.getMeta(me.startMetaQuery().withOneSub(undefined, topic.name).build()).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// A group dissolved before this specific page load ever subscribed to it -- distinct from
|
||||
// subscribeTopicGone below, which only covers watching it get dissolved live while already
|
||||
// subscribed. See isTopicGone's own comment for the real, reported bug this closes (a
|
||||
// deep-link/refresh into an already-dissolved group left the screen sitting on a raw-topic-id,
|
||||
// "1970/1/1", "0 members" stub instead of leaving). Same user-facing outcome as the live case
|
||||
// (toast + bounce to the chat list) for consistency, not a special "this group doesn't exist"
|
||||
// message -- from this user's vantage point both cases are just "this group is gone now".
|
||||
if (isTopicGone(err)) {
|
||||
// The failed subscribe attempt above already left a bare, empty stub for this topic in the
|
||||
// SDK's cache (getTopic() creates one just by being called, before it knows whether the
|
||||
// subscribe will succeed) -- without this, that stub alone is enough to make the chat list
|
||||
// keep showing this now-gone group as a broken-looking row even after navigating away.
|
||||
forgetTopic(topicId);
|
||||
notify('该群聊已解散');
|
||||
navigate('/messages', { replace: true });
|
||||
return;
|
||||
}
|
||||
showError(err, '加载会话失败,请重试');
|
||||
});
|
||||
return () => {
|
||||
closeConversation(topicId).catch(() => {});
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [topicId]);
|
||||
|
||||
// Switching into a chat window should let the user start typing immediately, not require an extra
|
||||
// click into the input first -- matches every mainstream IM client. Deliberately NOT keyed on
|
||||
// `messages` (would re-steal focus away from whatever the user is doing every time a new message
|
||||
// arrives while they're already composing) -- only the initial switch itself should grab focus.
|
||||
useEffect(() => {
|
||||
if (!topicId) return;
|
||||
textareaRef.current?.focus();
|
||||
}, [topicId]);
|
||||
|
||||
// Someone else dissolving the group currently open here (or this account being removed from it)
|
||||
// fires the SDK's `onDeleteTopic`, not `onData`/`onPres` -- those only invalidate this screen's
|
||||
// cached snapshots for re-render, they don't tell it the topic itself is gone. Left unhandled, the
|
||||
// member who happened to be looking at that conversation just keeps staring at a now-defunct screen
|
||||
// (an actual reported bug: staying on the dissolved group's view with no way back except a manual
|
||||
// nav). `notify()` (the global toast, not this screen's own `errorToast`) survives the navigate.
|
||||
useEffect(() => {
|
||||
return subscribeTopicGone((goneTopicName) => {
|
||||
if (goneTopicName !== topicId) return;
|
||||
notify('该群聊已解散');
|
||||
navigate('/messages', { replace: true });
|
||||
});
|
||||
}, [topicId, navigate]);
|
||||
|
||||
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
|
||||
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
|
||||
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
|
||||
@@ -179,6 +355,16 @@ export function ConversationView() {
|
||||
setHighlightedSeq(null);
|
||||
}, [topicId]);
|
||||
|
||||
// Tells lib/notifications/push.ts "the user is already looking at this one" so a message arriving
|
||||
// here doesn't also pop a desktop notification for something already on screen -- cleared on
|
||||
// switch/unmount so leaving this screen (closing the tab, navigating to /contacts, etc.) doesn't
|
||||
// leave some OTHER, no-longer-open conversation permanently exempt from notifications.
|
||||
useEffect(() => {
|
||||
if (!topicId) return undefined;
|
||||
setActiveConversationTopic(topicId);
|
||||
return () => setActiveConversationTopic(null);
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
||||
@@ -224,15 +410,100 @@ export function ConversationView() {
|
||||
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
|
||||
}, [messages, jumpTarget]);
|
||||
|
||||
// Gated on the tab actually being visible, not just this conversation being mounted -- a
|
||||
// conversation left open in a BACKGROUNDED tab used to have every arriving message immediately
|
||||
// noteRead()'d purely because the route was still mounted, with nobody actually looking at it. That
|
||||
// made the chat-list unread count (and, since today, the tab-title scroll/favicon badge/desktop
|
||||
// notification it drives -- see lib/notifications/) silently stay at zero for a conversation that
|
||||
// was, from the user's real vantage point, sitting unread in a background tab the whole time (a
|
||||
// real, reported case: systest01 has systest02's conversation open, tabs away, systest02 sends a
|
||||
// message -- the tab title never picked it up). The effect below catches back up the moment the tab
|
||||
// becomes visible again while still on this same conversation, so nothing is left permanently
|
||||
// unread just because this guard skipped it while hidden.
|
||||
//
|
||||
// markConfirmedRead alongside noteRead -- this app's own independent "genuinely seen" record (see
|
||||
// its own comment in topics.ts) that topics.ts's me.onPres correction relies on to undo the SDK's
|
||||
// separate, unwanted auto-read-mark on a `{pres what:"msg"}` push for a topic that ISN'T actually
|
||||
// being looked at. noteRead() alone only fixes what THIS component itself would have marked read;
|
||||
// it doesn't tell that other mechanism what the user has genuinely seen.
|
||||
useEffect(() => {
|
||||
if (!topicId || messages.length === 0) return;
|
||||
tinode.getTopic(topicId).noteRead(0);
|
||||
}, [topicId, messages.length, tinode]);
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const topic = tinode.getTopic(topicId);
|
||||
topic.noteRead(0);
|
||||
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
|
||||
}, [topicId, messages, tinode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId) return undefined;
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
tinode.getTopic(topicId).noteRead(0);
|
||||
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('focus', onVisible);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('focus', onVisible);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [topicId, 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();
|
||||
// `slf` (chatting with yourself, née "文件助手" -- see ChatListColumn.tsx's PINNED_ENTRIES
|
||||
// comment on why that framing was deliberately dropped) isn't in your own contacts/friends list,
|
||||
// so `contact` below is always undefined for it -- shares AppShell's/SettingsLayout's `['me']`
|
||||
// query so this doesn't add a request beyond whichever of those mounts first.
|
||||
const isSelf = topicId === 'slf';
|
||||
// No longer gated to `enabled: isSelf` -- every conversation (not just chatting with yourself)
|
||||
// needs your OWN avatar_file_id now, for the "mine" message-row avatar below. Shares the same
|
||||
// `['me']` key NavRail/ChatListColumn/SettingsLayout already keep warm, so this is a cache read in
|
||||
// the normal case, not a new request.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
// NOT topicId -- see getP2PPeerUserId's own doc comment. A P2P topic with any real history is
|
||||
// addressed by a real, opaque `p2p...` name, not the peer's `usr...` id (that only holds true in
|
||||
// the brief window before the very first subscribe) -- confirmed live: without this, this query
|
||||
// 404s (`GET /users/{p2p...}/profile`) for every established conversation, and only ever appeared
|
||||
// to work in earlier testing because that test happened to use a conversation opened for the first
|
||||
// time in the same session, before the SDK's own rename kicked in.
|
||||
const peerUserId = !isGroup && !isSelf && topicId ? getP2PPeerUserId(tinode.getTopic(topicId), myUserId) : undefined;
|
||||
// Real gap this was written to close: the chat header/message-row avatars only ever drew the
|
||||
// letter-tile fallback, even after a real custom avatar was uploaded and correctly showing up on
|
||||
// the contacts pages (FriendProfileView/ContactsListColumn) -- those read this exact same REST
|
||||
// profile, the conversation screen just never fetched it. Same query DetailColumn already runs
|
||||
// (`['user-profile', peerUserId]`), so this dedupes with it whenever the detail panel happens to be
|
||||
// open, and only fetches for a real P2P peer, never for a group or `slf`.
|
||||
const peerProfileQuery = useQuery({
|
||||
queryKey: ['user-profile', peerUserId],
|
||||
queryFn: () => getUserProfile(peerUserId || ''),
|
||||
enabled: !!peerUserId,
|
||||
});
|
||||
// Live-confirmed real bug this closes (Zentao #28): sending into a P2P conversation blocked in
|
||||
// EITHER direction showed only the generic per-bubble "发送失败,点击重试", no reason, on both sides
|
||||
// -- reading tinode-sdk source confirmed the specific rejection reason can never be recovered
|
||||
// after a failed send (Topic#publishMessage's own .catch swallows it into a bare `_failed` flag and
|
||||
// never rethrows), so the only real fix is checking BEFORE attempting to send, not reacting after.
|
||||
// This endpoint is P2P-only and reports the relationship from either side (`blocked_by_me` on the
|
||||
// blocker's own view, `blocked_by_peer` on the blocked side) -- see getConversationContext's own
|
||||
// doc comment for why `getUserProfile`'s similar-looking `capabilities.can_send` can't be reused for
|
||||
// this (403s entirely from the blocked party's own perspective instead of reporting false).
|
||||
const conversationContextQuery = useQuery({
|
||||
queryKey: ['conversation-context', topicId],
|
||||
queryFn: () => getConversationContext(topicId || ''),
|
||||
enabled: !!topicId && !isGroup && !isSelf,
|
||||
});
|
||||
const blockedNotice =
|
||||
conversationContextQuery.data?.relationship === 'blocked_by_me'
|
||||
? '你已将对方加入黑名单,无法发送消息'
|
||||
: conversationContextQuery.data?.relationship === 'blocked_by_peer'
|
||||
? '对方已将你加入黑名单,暂时无法发送消息'
|
||||
: conversationContextQuery.data?.can_send === false
|
||||
? '暂时无法向对方发送消息'
|
||||
: null;
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId || ''),
|
||||
@@ -253,9 +524,20 @@ export function ConversationView() {
|
||||
queryFn: () => getGroupGovernance(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
// Real, live-confirmed crash (not theoretical): this used to call `getGroupMutes` (single page,
|
||||
// `{items, next_cursor}` shape) under query key `['group-mutes', topicId]` -- the EXACT SAME key
|
||||
// `GroupAdminModal.tsx` uses for `getAllGroupMutes` (all pages, flat array). Both mounted together
|
||||
// (opening 群管理 from a group's own conversation screen, which is the only way to reach it) means
|
||||
// whichever query resolves last overwrites the shared cache entry with ITS shape; the other
|
||||
// consumer then reads `array.items.length` -- `.items` is undefined on a plain array, and unlike
|
||||
// `mutesQuery.data?.items.length`'s leading `?.`, nothing guards THAT specific property access, so
|
||||
// it throws and takes down the whole conversation screen. Matching `getAllGroupMutes` here (same
|
||||
// function, same shape, same key) turns this from two competing queries into one correctly-shared
|
||||
// one -- also fixes this subline's own under-count for any group with more muted members than a
|
||||
// single page holds, the same truncation bug `getAllGroupMutes`'s own doc comment already flags.
|
||||
const mutesQuery = useQuery({
|
||||
queryKey: ['group-mutes', topicId],
|
||||
queryFn: () => getGroupMutes(topicId || ''),
|
||||
queryFn: () => getAllGroupMutes(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
||||
@@ -263,13 +545,21 @@ export function ConversationView() {
|
||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||
const groupSublineParts = [
|
||||
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
|
||||
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
|
||||
mutesQuery.data?.length ? `${mutesQuery.data.length} 位成员被禁言` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!topicId) return null;
|
||||
const topic = tinode.getTopic(topicId);
|
||||
const peerName = contact?.public?.fn || topicId;
|
||||
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId;
|
||||
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
|
||||
const myAvatarFileId = meQuery.data?.profile.avatar_file_id;
|
||||
const peerAvatarFileId = isSelf ? myAvatarFileId : peerProfileQuery.data?.avatar_file_id;
|
||||
const headerAvatarFileId = isGroup ? groupProfileQuery.data?.avatar_file_id : peerAvatarFileId;
|
||||
// Color-hash id, not the file lookup above -- topicId is right for a group (its own identity), but
|
||||
// for P2P this should be the peer's stable usr id (peerUserId), matching the same hash ContactsListColumn/
|
||||
// FriendProfileView use for this same person, rather than the p2p topic name (which isP2P/self also
|
||||
// never had a usr-shaped id for in the first place).
|
||||
const headerAvatarId = isGroup ? topicId : peerUserId || topicId;
|
||||
// `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,
|
||||
@@ -331,17 +621,28 @@ export function ConversationView() {
|
||||
if (!text) return;
|
||||
setDraft('');
|
||||
setMentionTrigger(null);
|
||||
if (replyTo) {
|
||||
// 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).catch((err) => showError(err, '发送失败'));
|
||||
}
|
||||
const pendingReplyTo = replyTo;
|
||||
setReplyTo(null);
|
||||
// The composer's own topic can be detached by the time this actually fires -- e.g. a
|
||||
// disconnect/reconnect cycle resets every topic's attached state (see connection.ts's
|
||||
// onReauthenticated), and publishing on a detached topic is rejected outright by the SDK. The
|
||||
// forward flow above already guards against exactly this (see ensureTopicAttached's own
|
||||
// comment for why it's the common case, not an edge case); the primary composer -- used far
|
||||
// more often than forwarding -- had no equivalent guard at all.
|
||||
ensureTopicAttached(topicId)
|
||||
.then((target) => {
|
||||
if (pendingReplyTo) {
|
||||
// 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.
|
||||
return sendReply(target, text, pendingReplyTo, quotedSenderLabel(pendingReplyTo.from));
|
||||
}
|
||||
if (pendingMentions.length > 0) {
|
||||
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
|
||||
}
|
||||
return sendText(target, text);
|
||||
})
|
||||
.catch((err) => showError(err, '发送失败'));
|
||||
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
|
||||
};
|
||||
|
||||
@@ -361,14 +662,18 @@ export function ConversationView() {
|
||||
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.
|
||||
// (deferred 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. Deferred via
|
||||
// setTimeout(0), not requestAnimationFrame -- rAF callbacks are paused outright while the tab is
|
||||
// backgrounded/hidden (confirmed live: an rAF scheduled right after a click never fired at all
|
||||
// in a hidden tab, a setTimeout(0) scheduled the same way fired normally), and this restore needs
|
||||
// to run regardless of whether the tab happens to be foregrounded at that exact moment.
|
||||
const el = textareaRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(inserted.length, inserted.length);
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
/** README §4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */
|
||||
@@ -381,10 +686,10 @@ export function ConversationView() {
|
||||
setDraft(next);
|
||||
setMentionTrigger({ start: pos + insertion.length - 1, query: '' });
|
||||
setEmojiOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -439,9 +744,9 @@ export function ConversationView() {
|
||||
setSelectedSeqs(new Set());
|
||||
};
|
||||
|
||||
const bulkDelete = () => {
|
||||
const bulkDelete = async () => {
|
||||
if (selectedSeqs.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
|
||||
if (!(await requestConfirm({ message: `删除已选择的 ${selectedSeqs.size} 条消息?`, danger: true }))) return;
|
||||
topic.delMessagesList([...selectedSeqs], false);
|
||||
exitSelectMode();
|
||||
};
|
||||
@@ -554,11 +859,7 @@ export function ConversationView() {
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
|
||||
<span
|
||||
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<Avatar id={headerAvatarId} name={peerName} avatarFileId={headerAvatarFileId} className="h-9.5 w-9.5 flex-none rounded-lg text-sm" />
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold text-text-heading">
|
||||
{peerName}
|
||||
@@ -566,7 +867,7 @@ export function ConversationView() {
|
||||
</strong>
|
||||
{isGroup ? (
|
||||
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
|
||||
) : (
|
||||
) : isSelf ? null : (
|
||||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||
{contact?.online ? '在线' : '离线'}
|
||||
</span>
|
||||
@@ -575,7 +876,7 @@ export function ConversationView() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isGroup ? null : (
|
||||
{isGroup || isSelf ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -642,16 +943,19 @@ export function ConversationView() {
|
||||
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||||
{messages.map((msg) => {
|
||||
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
|
||||
const mine = !!myUserId && msg.from === myUserId;
|
||||
const rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId;
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
topic={topic}
|
||||
msg={msg}
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
mine={mine}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
senderName={sender?.display_name}
|
||||
senderRole={sender?.role}
|
||||
avatarFileId={rowAvatarFileId}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||
@@ -691,6 +995,10 @@ export function ConversationView() {
|
||||
</div>
|
||||
) : (
|
||||
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
{blockedNotice ? (
|
||||
<div className="flex h-18 items-center justify-center text-sm text-text-tertiary-3">{blockedNotice}</div>
|
||||
) : (
|
||||
<>
|
||||
{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">
|
||||
@@ -717,8 +1025,24 @@ export function ConversationView() {
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<EmojiPanel
|
||||
onPick={(emoji) => {
|
||||
setDraft(draft + emoji);
|
||||
// Same restore-focus-and-caret dance as insertMentionTrigger/pickMention above
|
||||
// -- clicking the emoji button moves DOM focus to it like any button click,
|
||||
// and without this the textarea stays unfocused until the user clicks back
|
||||
// into it (confirmed by a user report: Enter didn't send right after picking
|
||||
// an emoji because the textarea itself had lost focus). Inserts at the actual
|
||||
// caret position rather than always appending to the end, matching how a
|
||||
// mention insertion already behaves, so the two don't disagree about where
|
||||
// "here" is. setTimeout(0), not requestAnimationFrame -- see insertMentionTrigger's
|
||||
// own comment for why (rAF never fires at all while the tab is hidden).
|
||||
const el = textareaRef.current;
|
||||
const pos = el?.selectionStart ?? draft.length;
|
||||
const next = draft.slice(0, pos) + emoji + draft.slice(pos);
|
||||
setDraft(next);
|
||||
setEmojiOpen(false);
|
||||
setTimeout(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos + emoji.length, pos + emoji.length);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -803,6 +1127,8 @@ export function ConversationView() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLayoutEffect, 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 { fetchAllPages } from '@/lib/api/client';
|
||||
@@ -18,9 +19,13 @@ import {
|
||||
updateFriendRemark,
|
||||
} from '@/lib/api/friends';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { getP2PPeerUserId } from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface DetailColumnProps {
|
||||
topicId: string;
|
||||
@@ -78,14 +83,34 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
|
||||
// to yourself.
|
||||
const isSelf = topicId === 'slf';
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
// NOT topicId -- see getP2PPeerUserId's own doc comment. Every lookup below that means "the other
|
||||
// user" (profile, friends-list match, block, remark) has to go through this, not the raw topic id,
|
||||
// because the two stop being interchangeable the instant this conversation has any real history.
|
||||
const peerUserId = isSelf ? topicId : getP2PPeerUserId(tinode.getTopic(topicId), myUserId);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
|
||||
const friend = friendsQuery.data?.find((f) => f.user_id === peerUserId);
|
||||
|
||||
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId), enabled: !isSelf });
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile', peerUserId],
|
||||
queryFn: () => getUserProfile(peerUserId || ''),
|
||||
// `!isSelf` alone isn't enough now that peerUserId can genuinely be undefined for a beat (the
|
||||
// subscriber list not loaded yet) -- without also gating on it, this fires with `undefined` the
|
||||
// instant isSelf is false, same class of guaranteed-404 bug this whole fix targets, just with
|
||||
// the literal string "undefined" as the id instead of the p2p topic name.
|
||||
enabled: !isSelf && !!peerUserId,
|
||||
});
|
||||
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
// Same real gap as ChatListColumn/ConversationView's avatars -- this panel's header tile only ever
|
||||
// drew the letter fallback, even though `profileQuery`/`friendsQuery` right above already carry a
|
||||
// real `avatar_file_id` (that's how ContactsListColumn/FriendProfileView show it correctly). `friend`
|
||||
// (the `/friends` list, same source the contacts pages already trust) takes priority since it's the
|
||||
// one confirmed accurate; `profileQuery` covers a P2P peer who isn't (or is no longer) a friend.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
|
||||
const avatarFileId = isSelf ? meQuery.data?.profile.avatar_file_id : (friend?.avatar_file_id ?? profileQuery.data?.avatar_file_id);
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['conversation-settings', topicId],
|
||||
@@ -100,7 +125,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
});
|
||||
|
||||
const remarkMutation = useMutation({
|
||||
mutationFn: (remark: string) => updateFriendRemark(topicId, remark),
|
||||
mutationFn: (remark: string) => updateFriendRemark(peerUserId, remark),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
setRemarkEditing(false);
|
||||
@@ -109,16 +134,16 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
|
||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
|
||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === peerUserId);
|
||||
|
||||
const deleteFriendMutation = useMutation({
|
||||
mutationFn: () => deleteFriend(topicId),
|
||||
mutationFn: () => deleteFriend(peerUserId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
navigate('/messages');
|
||||
@@ -126,18 +151,18 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
onError: (err) => showError(err, '删除好友失败'),
|
||||
});
|
||||
|
||||
const clearHistory = () => {
|
||||
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
|
||||
const clearHistory = async () => {
|
||||
if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
};
|
||||
|
||||
const startDeleteFriend = () => {
|
||||
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
|
||||
const startDeleteFriend = async () => {
|
||||
if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return;
|
||||
deleteFriendMutation.mutate();
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
notify('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -152,9 +177,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<Avatar id={peerUserId || topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
|
||||
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
|
||||
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
|
||||
{profileQuery.data?.signature ? (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
||||
import type { Contact } from 'tinode-sdk';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
@@ -30,6 +31,9 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [comment, setComment] = useState('');
|
||||
// Both start expanded -- collapsing is for a long list getting in the way of the OTHER section,
|
||||
// not a default state anyone would want on first open.
|
||||
const [collapsed, setCollapsed] = useState<{ friends: boolean; groups: boolean }>({ friends: false, groups: false });
|
||||
useEscapeKey(onClose);
|
||||
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
||||
|
||||
@@ -75,6 +79,10 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCollapsed = (section: 'friends' | 'groups') => {
|
||||
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (selected.size === 0) return;
|
||||
onPick([...selected], comment.trim());
|
||||
@@ -95,15 +103,23 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
{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>
|
||||
<SelectionCheckbox selected={isSelected} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSectionHeader = (label: string, section: 'friends' | 'groups', topics: string[], allSelected: boolean) => (
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
<button type="button" onClick={() => toggleCollapsed(section)} className="flex items-center gap-0.5 text-xs text-text-tertiary-2">
|
||||
<Icon name="chevron_right" className={`text-sm transition-transform ${collapsed[section] ? '' : 'rotate-90'}`} />
|
||||
{label}
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleSectionSelectAll(topics, allSelected)} className="text-xs text-accent">
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
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()}>
|
||||
@@ -130,32 +146,14 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{friends.length ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
<span className="text-xs text-text-tertiary-2">好友</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionSelectAll(friendTopics, allFriendsSelected)}
|
||||
className="text-xs text-accent"
|
||||
>
|
||||
{allFriendsSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{friends.map(renderRow)}
|
||||
{renderSectionHeader('好友', 'friends', friendTopics, allFriendsSelected)}
|
||||
{collapsed.friends ? null : friends.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{groups.length ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
<span className="text-xs text-text-tertiary-2">群聊</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionSelectAll(groupTopics, allGroupsSelected)}
|
||||
className="text-xs text-accent"
|
||||
>
|
||||
{allGroupsSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{groups.map(renderRow)}
|
||||
{renderSectionHeader('群聊', 'groups', groupTopics, allGroupsSelected)}
|
||||
{collapsed.groups ? null : groups.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{!friends.length && !groups.length ? (
|
||||
|
||||
@@ -10,6 +10,8 @@ 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 { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
@@ -90,14 +92,14 @@ function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string
|
||||
try {
|
||||
result = await safetyCheck.mutateAsync(item.url!);
|
||||
} catch {
|
||||
window.alert('链接安全检查失败,请稍后重试。');
|
||||
notify('链接安全检查失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'block') {
|
||||
window.alert('该链接存在安全风险,已被拦截。');
|
||||
notify('该链接存在安全风险,已被拦截。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
|
||||
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
|
||||
window.open(item.url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
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';
|
||||
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||
|
||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||
@@ -47,6 +48,10 @@ interface MessageRowProps {
|
||||
* for per-message attribution once there's more than one possible sender. */
|
||||
senderName?: string;
|
||||
senderRole?: 'owner' | 'admin' | 'member';
|
||||
/** The avatar to show for whoever sent THIS row -- resolved by ConversationView per-message (its
|
||||
* own `avatar_file_id`, the group member's, or the peer's, depending on `mine`/`isGroup`), not a
|
||||
* single fixed prop like `peerName`, since every row in a group can have a different sender. */
|
||||
avatarFileId?: string;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
||||
@@ -73,6 +78,7 @@ export function MessageRow({
|
||||
peerName,
|
||||
senderName,
|
||||
senderRole,
|
||||
avatarFileId,
|
||||
selectMode,
|
||||
selected,
|
||||
menuActive,
|
||||
@@ -106,7 +112,14 @@ export function MessageRow({
|
||||
const contactCard = contactCardContentOf(msg);
|
||||
|
||||
const retry = () => {
|
||||
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
|
||||
if (typeof msg.seq !== 'number') return;
|
||||
const seq = msg.seq;
|
||||
// The original send most likely failed BECAUSE the topic was detached (see ConversationView's
|
||||
// submit() for the same reasoning) -- retrying without re-attaching first would just fail the
|
||||
// exact same way again.
|
||||
ensureTopicAttached(topic.name)
|
||||
.then(() => retryMessage(topic, seq))
|
||||
.catch(() => onError?.('重试发送失败'));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -119,14 +132,8 @@ export function MessageRow({
|
||||
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 type="button" onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)} className="flex-none">
|
||||
<SelectionCheckbox selected={selected} />
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
@@ -138,17 +145,36 @@ export function MessageRow({
|
||||
}}
|
||||
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 || (isGroup ? '' : peerName))
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)}
|
||||
</span>
|
||||
{/* Group members' avatar/name go to their profile (same destination ContactCardBubble's own
|
||||
card already uses, which works for a non-friend group member too -- FriendProfileView's
|
||||
relationship-aware buttons handle that case, it isn't friends-only). Not for `mine` -- that
|
||||
slot always reads "我", and viewing "your own profile" via a route built around a two-party
|
||||
relationship isn't a case this app has ever needed to support. Not during `selectMode`
|
||||
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
|
||||
not navigate away and lose whatever's already been picked. */}
|
||||
{isGroup && !mine && !selectMode && msg.from ? (
|
||||
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
|
||||
<Avatar id={msg.from} name={senderName || msg.from} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
||||
</Link>
|
||||
) : (
|
||||
<Avatar
|
||||
id={msg.from || (isGroup ? '' : peerName)}
|
||||
name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
|
||||
avatarFileId={avatarFileId}
|
||||
fallbackBgClass={mine ? 'bg-accent' : undefined}
|
||||
className="h-9 w-9 flex-none rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
{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>
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
|
||||
{senderName || msg.from}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{mine ? '我' : senderName || msg.from}</span>
|
||||
)}
|
||||
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -158,7 +184,7 @@ export function MessageRow({
|
||||
-- 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}
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-unread" /> : 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'
|
||||
@@ -179,7 +205,7 @@ export function MessageRow({
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
<button type="button" onClick={retry} className="text-xs text-danger-text">
|
||||
<button type="button" onClick={retry} className="text-xs text-unread">
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Outlet, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
||||
@@ -8,6 +9,7 @@ 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 { getMe } from '@/lib/api/profile';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
@@ -86,7 +88,14 @@ export function MessagesLayout() {
|
||||
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
|
||||
const contacts = useChatList();
|
||||
const tinode = useTinode();
|
||||
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
|
||||
// `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the
|
||||
// lookup below always misses for it -- same gap ConversationView.tsx's own header already had and
|
||||
// fixed the same way, just computed independently here for this panel's title/detail-column
|
||||
// props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has
|
||||
// already mounted it, not a second request.
|
||||
const isSelf = topicId === 'slf';
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
|
||||
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
|
||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
||||
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ReactNode } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
|
||||
@@ -22,14 +24,14 @@ export function SafeLink({ href, children }: { href: string | undefined; childre
|
||||
try {
|
||||
result = await safetyCheck.mutateAsync(href);
|
||||
} catch {
|
||||
window.alert('链接安全检查失败,请稍后重试。');
|
||||
notify('链接安全检查失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'block') {
|
||||
window.alert('该链接存在安全风险,已被拦截。');
|
||||
notify('该链接存在安全风险,已被拦截。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
|
||||
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
|
||||
@@ -123,8 +123,8 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
|
||||
return (
|
||||
<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" />
|
||||
<span className="flex flex-none items-center gap-2 text-sm font-medium text-text-body">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-unread" />
|
||||
录音中 {formatSeconds(seconds)}
|
||||
</span>
|
||||
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
|
||||
/**
|
||||
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
|
||||
@@ -16,7 +17,7 @@ export function GeneralSettingsPage() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const clearCache = async () => {
|
||||
if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return;
|
||||
if (!(await requestConfirm({ message: '确定清除本地缓存吗?不会影响你的账号数据。' }))) return;
|
||||
setStatus('clearing');
|
||||
try {
|
||||
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
|
||||
|
||||
@@ -62,7 +62,12 @@ export function PrivacySettingsPage() {
|
||||
// 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 : '设置失败'),
|
||||
// Same reasoning as ProfileSettingsPage's own saveMutation: a version conflict shouldn't leave
|
||||
// the cached `version` stale, or every retry just resends the same rejected value forever.
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '设置失败');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
|
||||
},
|
||||
});
|
||||
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
|
||||
if (!query.data) return;
|
||||
@@ -113,7 +118,7 @@ export function PrivacySettingsPage() {
|
||||
</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">
|
||||
<div className="flex items-center justify-between border-b border-border-row 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) => (
|
||||
@@ -129,7 +134,7 @@ export function PrivacySettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
|
||||
<div className="flex items-center justify-between border-b border-border-row 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) => (
|
||||
|
||||
@@ -32,9 +32,14 @@ const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `/v0/file/u/` channel (the one every other upload in this app already goes through), NOT
|
||||
* `POST /v1/im/public/uploads` -- that's a different, pre-login-only channel for enterprise
|
||||
* registration/tenant feedback (`lib/api/tenant.ts`'s `uploadPublicFile`, whose own `purpose` type
|
||||
* is explicitly `'enterprise_license' | 'tenant_entry_feedback'`), never meant for an authenticated
|
||||
* user's own avatar. Passes `purpose: 'user_avatar'` explicitly per docs/IM-前后端对接文档.md:2293
|
||||
* -- this used to be omitted entirely (traced to `purpose=user_avatar` 400ing on THIS channel too,
|
||||
* docs/IM-后端问题清单.md 问题28, since confirmed fixed: a real PNG upload with that purpose now
|
||||
* returns 200).
|
||||
*/
|
||||
export function ProfileSettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -79,15 +84,21 @@ export function ProfileSettingsPage() {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
|
||||
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
|
||||
// on another tab/device) 400s here, but the cached `query.data.version` used to just sit there
|
||||
// unchanged -- every retry would resend the exact same stale version and 400 again forever, the
|
||||
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched.
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
|
||||
},
|
||||
});
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const uploaded = await uploadFile(file);
|
||||
const fileId = uploaded.url.split('/').pop() || '';
|
||||
const uploaded = await uploadFile(file, undefined, 'user_avatar');
|
||||
if (!query.data) throw new Error('尚未加载');
|
||||
return updateMyProfile({ avatar_file_id: fileId, version: query.data.version });
|
||||
return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
@@ -173,7 +184,7 @@ export function ProfileSettingsPage() {
|
||||
{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]">
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">昵称</span>
|
||||
<InlineField
|
||||
value={draft.nickname}
|
||||
@@ -183,7 +194,7 @@ export function ProfileSettingsPage() {
|
||||
<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]">
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row 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) => (
|
||||
@@ -200,7 +211,7 @@ export function ProfileSettingsPage() {
|
||||
<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]">
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">出生日期</span>
|
||||
<InlineField
|
||||
ref={birthDateRef}
|
||||
@@ -214,9 +225,9 @@ export function ProfileSettingsPage() {
|
||||
</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]">
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row 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="未填写" />
|
||||
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getTinode, resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
@@ -66,8 +67,8 @@ export function SecuritySettingsPage() {
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (deletingAccount) return;
|
||||
if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return;
|
||||
if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return;
|
||||
if (!(await requestConfirm({ message: '注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?', danger: true }))) return;
|
||||
if (!(await requestConfirm({ message: '请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。', danger: true }))) return;
|
||||
setDeletingAccount(true);
|
||||
try {
|
||||
await getTinode().delCurrentUser(true);
|
||||
@@ -94,7 +95,14 @@ export function SecuritySettingsPage() {
|
||||
<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">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPassword.length === 0 || newPassword.length < minPasswordLength || passwordsMismatch || confirmPassword.length === 0 || passwordMutation.isPending) return;
|
||||
passwordMutation.mutate();
|
||||
}}
|
||||
className="flex flex-col gap-3 px-7 py-4"
|
||||
>
|
||||
<span className="text-text-body">修改密码</span>
|
||||
<input
|
||||
type="password"
|
||||
@@ -138,8 +146,7 @@ export function SecuritySettingsPage() {
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => passwordMutation.mutate()}
|
||||
type="submit"
|
||||
disabled={
|
||||
currentPassword.length === 0 ||
|
||||
newPassword.length < minPasswordLength ||
|
||||
@@ -152,7 +159,7 @@ export function SecuritySettingsPage() {
|
||||
{passwordMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -5,8 +5,11 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { logout as revokeServerSession } from '@/lib/api/auth';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notImplemented } from '@/stores/globalToast';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DIRECTORY = [
|
||||
@@ -33,16 +36,7 @@ const MOBILE_SHORTCUTS = [
|
||||
{ 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('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */
|
||||
export function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@@ -55,9 +49,14 @@ export function SettingsLayout() {
|
||||
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
|
||||
const [mobileSubView, setMobileSubView] = useState(false);
|
||||
|
||||
const logout = () => {
|
||||
if (!window.confirm('确定退出登录吗?')) return;
|
||||
const logout = async () => {
|
||||
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
|
||||
setLoggingOut(true);
|
||||
// docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近
|
||||
// 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空
|
||||
// sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条
|
||||
// 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。
|
||||
await revokeServerSession().catch(() => {});
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
@@ -91,7 +90,7 @@ export function SettingsLayout() {
|
||||
<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">
|
||||
<div className="flex items-center gap-3 border-b border-border-row px-5 pb-4.5 pt-2">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
@@ -114,7 +113,7 @@ export function SettingsLayout() {
|
||||
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="text-[18px]" />
|
||||
<Icon name={item.icon} className="text-[20px]" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
@@ -124,7 +123,7 @@ export function SettingsLayout() {
|
||||
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"
|
||||
className="mx-5 mt-auto mb-5 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { acceptGroupInvitation } from '@/lib/api/groups';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
@@ -61,6 +62,12 @@ function iconForEvent(eventType: string): { icon: string; className: string } {
|
||||
*/
|
||||
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
|
||||
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
|
||||
// Handled separately (see the card's own `action.type === 'group_invitation_accept'` branch below,
|
||||
// rendering a real "接受邀请" button) -- NOT a plain navigation like every other group.* event.
|
||||
// You are not yet a member of this group at the point this notification arrives, so navigating
|
||||
// straight to `/messages/{topic}` (what the branch below would otherwise do, since this event also
|
||||
// carries a real `grp...` action.topic) would open a conversation you have no access to.
|
||||
if (item.action?.type === 'group_invitation_accept') return null;
|
||||
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
|
||||
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
|
||||
}
|
||||
@@ -102,6 +109,29 @@ export function SystemMessagesView() {
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
|
||||
});
|
||||
|
||||
// README/docs :1464/1739's `group.invitation.created` flow -- the doc marks the accept endpoint
|
||||
// "待实现" but it's real (see acceptGroupInvitation's own comment). Success takes you straight to
|
||||
// the group (you're a member now); failure is surfaced inline on the card itself (an expired/
|
||||
// already-handled invitation, or a race where someone else's action beat you to it), not just a
|
||||
// toast, so it doesn't look like the button silently did nothing.
|
||||
const [invitationError, setInvitationError] = useState<Record<string, string>>({});
|
||||
const acceptInvitationMutation = useMutation({
|
||||
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
|
||||
onSuccess: (data, invitationId) => {
|
||||
setInvitationError((prev) => {
|
||||
if (!prev[invitationId]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[invitationId];
|
||||
return next;
|
||||
});
|
||||
navigate(`/messages/${data.topic}`);
|
||||
},
|
||||
onError: (err, invitationId) => {
|
||||
setInvitationError((prev) => ({ ...prev, [invitationId]: 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
|
||||
@@ -117,10 +147,22 @@ export function SystemMessagesView() {
|
||||
// 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.
|
||||
// `fetchNextPage()` swallows its own request failures (TanStack Query v5's QueryObserver
|
||||
// catches internally rather than rejecting) -- a page that fails to load leaves `next_cursor`
|
||||
// exactly where it was, so a plain `while (cursor)` would retry the SAME stuck page forever on
|
||||
// any transient failure (expired token, network blip), leaving `markingAll` stuck true and
|
||||
// this button permanently showing "标记中…" with nothing telling the user why. Capped so a
|
||||
// failure surfaces as an error instead of hanging.
|
||||
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
while (cursor) {
|
||||
let guard = 0;
|
||||
while (cursor && guard++ < 200) {
|
||||
await query.fetchNextPage();
|
||||
cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
const next = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
if (next === cursor) {
|
||||
setError('翻页失败,请重试');
|
||||
return;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
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
|
||||
@@ -155,7 +197,11 @@ export function SystemMessagesView() {
|
||||
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) ? (
|
||||
{/* `??` only guards against a MISSING bootstrap count, not a STALE one -- bootstrap is
|
||||
`staleTime: Infinity` and only ever invalidated in the "count goes down" direction
|
||||
(readMutation's onSuccess below), so a cached `0` from before any new notification
|
||||
arrived would otherwise out-vote a real non-empty `unread` this render actually has. */}
|
||||
{Math.max(bootstrap.data?.entry_badges?.system_unread_count ?? 0, unread.length) ? (
|
||||
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
|
||||
{markingAll ? '标记中…' : '全部标为已读'}
|
||||
</button>
|
||||
@@ -178,8 +224,30 @@ export function SystemMessagesView() {
|
||||
const { icon, className } = iconForEvent(item.event_type);
|
||||
const action = actionRoute(item);
|
||||
const isUnread = !item.read_at;
|
||||
// The whole card is the click target, not just the "查看…" link below -- most
|
||||
// notifications (好友申请已通过/拒绝, most 群聊动态) have no `actionRoute` at all, so
|
||||
// gating mark-read on that inner link left them with literally nothing clickable
|
||||
// anywhere on the card, permanently stuck showing "新" no matter how long they sat there
|
||||
// (a real, reported bug: "有些消息没有链接,没地方点击就一直是新"). A card with an action
|
||||
// still marks read AND navigates; one without just marks read, since there's nowhere to go.
|
||||
const activate = () => {
|
||||
if (isUnread) readMutation.mutate(item.id);
|
||||
if (action) navigate(action.to);
|
||||
};
|
||||
return (
|
||||
<div key={item.id} className={`w-full flex-none rounded-xl bg-white p-[18px] md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}>
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={activate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
className={`w-full flex-none cursor-pointer rounded-xl bg-white p-[18px] text-left 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}`}>
|
||||
@@ -191,17 +259,36 @@ export function SystemMessagesView() {
|
||||
{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>
|
||||
{/* Plain text, not a nested `<button>` -- it's inside the card's own click target
|
||||
above, and a button-in-button (role="button" div counts the same way) is the
|
||||
same illegal-nesting/double-fire shape already fixed once in this codebase for
|
||||
favorites' voice player. */}
|
||||
{action ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null}
|
||||
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
|
||||
<div className="mt-1 flex flex-col items-start gap-1.5">
|
||||
{/* A REAL nested button, unlike the plain-text action label above -- accepting
|
||||
is a distinct action from "open this card" (which only marks it read; see
|
||||
actionRoute's own comment for why this event type has no plain navigation
|
||||
at all), so stopPropagation is the right tool here rather than avoiding the
|
||||
nesting the way the plain-text case does. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={acceptInvitationMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isUnread) readMutation.mutate(item.id);
|
||||
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
|
||||
}}
|
||||
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
|
||||
? '处理中…'
|
||||
: '接受邀请'}
|
||||
</button>
|
||||
{invitationError[item.action.invitation_id] ? (
|
||||
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Material Icons' OWN Google Fonts stylesheet (index.html's `<link>`) ships its `.material-icons`
|
||||
* class completely unlayered -- plain CSS, no `@layer`. Tailwind v4 (the `@import` above) puts every
|
||||
* one of its own utilities inside `@layer utilities`. Per the CSS Cascade Layers spec, ANY unlayered
|
||||
* declaration beats EVERY layered one for the same property on the same element, regardless of
|
||||
* selector specificity or which stylesheet loads later in the document -- confirmed live: an
|
||||
* `<Icon className="text-[14px]">` computed to `font-size: 24px` regardless of the requested size,
|
||||
* every time, because Google's unlayered rule was always winning over Tailwind's layered
|
||||
* `.text-\[14px\]` utility. Icon.tsx uses its own `im-icon` class (not `.material-icons`) specifically
|
||||
* so Google's competing rule never matches these elements at all -- this is the same visual default
|
||||
* (24px, line-height 1, etc., copied 1:1 from Google's own stylesheet), just declared inside `@layer
|
||||
* base` so Tailwind's `@layer utilities` (which Tailwind itself registers after `base`) can actually
|
||||
* override it, the way every call site sizing an icon via `text-[Npx]` already assumed it could.
|
||||
*/
|
||||
@layer base {
|
||||
.im-icon {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Design tokens ported 1:1 from docs/design_handoff_meetingim_web/README.md's "Design Tokens"
|
||||
* section. Values are exact hex/px from the design handoff, not semantic guesses -- keep them in
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { imPost, imRequest } from './client';
|
||||
|
||||
/**
|
||||
* docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/
|
||||
* 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个
|
||||
* 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。
|
||||
* 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次
|
||||
* 调用失败而跳过后续的本地清理。
|
||||
*/
|
||||
export function logout() {
|
||||
return imPost<void>('/auth/logout');
|
||||
}
|
||||
|
||||
export interface VerificationCodeResponse {
|
||||
challenge_id: string;
|
||||
resend_after_seconds: number;
|
||||
|
||||
+20
-11
@@ -7,6 +7,7 @@
|
||||
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
|
||||
// a real, repeatedly-rediscovered bug in the old codebase).
|
||||
import { API_KEY } from '@/lib/config';
|
||||
import { getKickedInfo } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DEFAULT_API_BASE = '/v1/im';
|
||||
@@ -29,7 +30,7 @@ export class ImApiError extends Error {
|
||||
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
|
||||
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||||
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试',
|
||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||
AUTH_FAILED: '账号或密码错误',
|
||||
TOKEN_EXPIRED: '登录已过期,请重新登录',
|
||||
@@ -86,6 +87,9 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
|
||||
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
||||
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
||||
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
||||
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
|
||||
// friends, or a request between the two of you (either direction) is already pending.
|
||||
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
|
||||
USER_NOT_DISCOVERABLE: '未找到该用户',
|
||||
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
||||
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
||||
@@ -207,17 +211,22 @@ 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;
|
||||
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
|
||||
reauthTriggered = true;
|
||||
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
|
||||
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
|
||||
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
|
||||
// network timing, not something this code controls). Without this, a REST 401 that happens to
|
||||
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
|
||||
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
|
||||
// that WS message (usually already in flight over an open connection, typically faster than a
|
||||
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
|
||||
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
|
||||
// by far the common case) just navigate ~200ms later than before -- not perceptible.
|
||||
window.setTimeout(() => {
|
||||
if (getKickedInfo()) return;
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
@@ -143,6 +143,33 @@ export function getConversationSettings(topic: string) {
|
||||
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on
|
||||
* the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities.
|
||||
* can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28):
|
||||
* sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试"
|
||||
* with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can
|
||||
* never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line
|
||||
* 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows,
|
||||
* so there's no error object left for application code to read once a send has already failed. This
|
||||
* endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after.
|
||||
* `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already
|
||||
* fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of
|
||||
* returning a normal response, so it can't be used for this specific check either.
|
||||
*/
|
||||
export interface ConversationContext {
|
||||
topic: string;
|
||||
peer_user_id: string;
|
||||
display_name: string;
|
||||
can_send: boolean;
|
||||
relationship: 'blocked_by_me' | 'blocked_by_peer' | string;
|
||||
capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean };
|
||||
}
|
||||
|
||||
export function getConversationContext(topic: string) {
|
||||
return imGet<ConversationContext>(`/conversations/${encodeURIComponent(topic)}/context`);
|
||||
}
|
||||
|
||||
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
|
||||
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
|
||||
}
|
||||
|
||||
+67
-14
@@ -130,9 +130,17 @@ 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)}`);
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
|
||||
* `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
|
||||
* fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
|
||||
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
|
||||
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
|
||||
* a truly empty body under that header as malformed rather than "no optional fields provided". */
|
||||
export function disbandGroup(topic: string, actionToken: string) {
|
||||
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
|
||||
body: {},
|
||||
headers: { 'X-Action-Token': actionToken },
|
||||
});
|
||||
}
|
||||
|
||||
export type GroupRole = 'owner' | 'admin' | 'member';
|
||||
@@ -231,11 +239,18 @@ export function removeGroupAdmin(topic: string, userId: string, version: number)
|
||||
);
|
||||
}
|
||||
|
||||
/** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) --
|
||||
* no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint
|
||||
* never returns a readable name, only the bare id -- callers needing one must cross-reference the
|
||||
* member list themselves, same as GroupFilesModal already has to for `uploader_id`). */
|
||||
export interface GroupMute {
|
||||
user: { user_id: string; display_name?: string };
|
||||
user_id: string;
|
||||
muted_by?: string;
|
||||
muted_at: string;
|
||||
expires_at: string | null;
|
||||
expires_at?: string | null;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
||||
@@ -252,18 +267,26 @@ export function getGroupMutes(topic: string, cursor = '') {
|
||||
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
|
||||
}
|
||||
|
||||
/** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own
|
||||
* `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page
|
||||
* function directly, silently truncating both the list and its own header count once a group has
|
||||
* enough muted members to span more than one page. */
|
||||
export function getAllGroupMutes(topic: string) {
|
||||
return fetchAllPages((cursor) => getGroupMutes(topic, 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,
|
||||
});
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
|
||||
* `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
|
||||
export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
|
||||
return imPost<{ owner: string; previous_owner: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/owner-transfer`,
|
||||
{ target_user_id: targetUserId, version },
|
||||
{ headers: { 'X-Action-Token': actionToken } },
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupGovernance(topic: string) {
|
||||
@@ -277,13 +300,43 @@ export function updateGroupGovernance(
|
||||
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the
|
||||
* doc's own single-outcome worked example): `"joined"` when the group's own governance AND the
|
||||
* target's own privacy setting both don't require approval (added immediately, no further action
|
||||
* needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a
|
||||
* real GroupInvitation record is created instead, delivered to the target as a system notification --
|
||||
* see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this
|
||||
* app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a
|
||||
* `reason`.
|
||||
*/
|
||||
export interface GroupInvitationResult {
|
||||
user_id: string;
|
||||
status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed';
|
||||
invitation_id?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
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`, {
|
||||
return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
||||
user_ids: userIds,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The target's own action on a `group.invitation.created` system notification (README/docs
|
||||
* :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource
|
||||
* directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group
|
||||
* immediately on success (no further approval step at this point -- any approval requirement was
|
||||
* already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status
|
||||
* instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself,
|
||||
* which this same endpoint resolves).
|
||||
*/
|
||||
export function acceptGroupInvitation(invitationId: string) {
|
||||
return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`);
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// calls this then hard-deletes the message from the local topic cache itself.
|
||||
import type { DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { imGet, imPost } from './client';
|
||||
import { imGet, imPost, imPut } from './client';
|
||||
|
||||
export interface RecallResult {
|
||||
topic: string;
|
||||
@@ -107,6 +107,33 @@ export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '')
|
||||
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export interface TopicDraft {
|
||||
topic: string;
|
||||
content: string;
|
||||
entities: unknown[];
|
||||
version: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.2's unsent-message-draft persistence (Zentao #19: input clears on conversation switch/
|
||||
* page refresh). docs/IM-前后端对接文档.md:1003/1247 marks `PUT /v1/im/topics/{topic}/draft` as
|
||||
* "待实现" (not yet implemented) and describes local-only storage as the baseline -- but confirmed by
|
||||
* reading im_be's own source (server/im_http.go's serveIMTopicDraft, wired to store.Conversations.
|
||||
* GetDraft/PutDraft), this endpoint is REAL and already live, the doc is just stale. A draft that's
|
||||
* never been saved comes back as a 200 (not 404) with `content:""`/`version:0`, so GET never needs
|
||||
* special-casing a not-found error -- confirmed in the same source.
|
||||
*/
|
||||
export function getTopicDraft(topic: string) {
|
||||
return imGet<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`);
|
||||
}
|
||||
|
||||
/** `entities` intentionally always `[]` for now -- this app's draft state is plain text only (no
|
||||
* in-progress @mention/attachment entities tracked yet), and the field is optional server-side. */
|
||||
export function putTopicDraft(topic: string, content: string, version: number) {
|
||||
return imPut<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version });
|
||||
}
|
||||
|
||||
/**
|
||||
* A single message's "收藏" context-menu action is really just a one-item batch (docs/
|
||||
* IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->
|
||||
|
||||
@@ -15,3 +15,27 @@ export interface UrlSafetyResult {
|
||||
export function checkUrlSafety(url: string, source: string) {
|
||||
return imPost<UrlSafetyResult>('/security/urls/check', { url, source });
|
||||
}
|
||||
|
||||
/** Sensitive-action re-verification (docs/IM-前后端对接文档.md:762-766, §6.17's group-dissolve line).
|
||||
* Confirmed by reading im_be's own `validIMSecurityChallengeScene` (server/im_http.go) -- the doc
|
||||
* text only spells out the two youth-mode scenes by name, but the server also accepts
|
||||
* `group_dissolve`/`group_owner_transfer`, which is what actually lets this flow work for either. */
|
||||
export type SecurityChallengeScene = 'group_dissolve' | 'group_owner_transfer';
|
||||
|
||||
export interface SecurityChallenge {
|
||||
challenge_id: string;
|
||||
phone_mask: string;
|
||||
expires_in_seconds: number;
|
||||
resend_after_seconds: number;
|
||||
}
|
||||
|
||||
export function requestSecurityChallenge(scene: SecurityChallengeScene) {
|
||||
return imPost<SecurityChallenge>('/security/challenges', { scene, channel: 'sms' });
|
||||
}
|
||||
|
||||
export function verifySecurityChallenge(challengeId: string, code: string) {
|
||||
return imPost<{ action_token: string; action: string; expires_at: string }>(
|
||||
`/security/challenges/${encodeURIComponent(challengeId)}/verify`,
|
||||
{ code },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,13 +36,22 @@ const UPLOAD_CTRL_TEXT_CN: Record<string, string> = {
|
||||
'not found': '内容不存在或已被删除',
|
||||
};
|
||||
|
||||
export async function uploadFile(file: File, topic?: string): Promise<UploadedFile> {
|
||||
/**
|
||||
* `purpose` only matters when `topic` is omitted (a chat/group-file upload always sends
|
||||
* `chat_file` regardless) -- currently just the avatar upload path, which used to send no
|
||||
* `purpose` at all. That omission dated back to this same endpoint's `purpose=user_avatar` 400ing
|
||||
* (docs/IM-后端问题清单.md 问题28) -- confirmed since fixed (a real PNG upload with that purpose
|
||||
* now returns 200), so there's no reason left to omit it.
|
||||
*/
|
||||
export async function uploadFile(file: File, topic?: string, purpose?: string): Promise<UploadedFile> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.set('id', getTinode().getNextUniqueId());
|
||||
if (topic) {
|
||||
form.append('purpose', 'chat_file');
|
||||
form.append('topic', topic);
|
||||
} else if (purpose) {
|
||||
form.append('purpose', purpose);
|
||||
}
|
||||
const res = await fetch(`${fileBase()}/u/`, {
|
||||
method: 'POST',
|
||||
@@ -72,6 +81,16 @@ export function fileAuthHeaders(): Record<string, string> {
|
||||
return { 'X-MeetingIM-APIKey': API_KEY, Authorization: `Token ${authToken()}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Some backend-issued absolute URLs (e.g. Aliyun OSS presigned URLs) come back as `http://` even
|
||||
* though every page in this app is served over `https://` -- the browser blocks those as mixed
|
||||
* content before any of our code sees a response. There's no legitimate case where this app needs
|
||||
* to fetch a plain-http resource, so upgrading the scheme here is safe and has no downside.
|
||||
*/
|
||||
export function upgradeToHttps(url: string): string {
|
||||
return url.startsWith('http://') ? `https://${url.slice('http://'.length)}` : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute download URL for a file reference returned by uploadFile() or a received attachment.
|
||||
* The upload response's `url` (e.g. "/v0/file/s/file123") is already rooted at the tenant's file
|
||||
@@ -79,7 +98,7 @@ export function fileAuthHeaders(): Record<string, string> {
|
||||
* fileBase() down to just its origin before appending, rather than concatenating the two paths.
|
||||
*/
|
||||
export function fileDownloadUrl(refUrl: string): string {
|
||||
if (refUrl.startsWith('http')) return refUrl;
|
||||
if (refUrl.startsWith('http')) return upgradeToHttps(refUrl);
|
||||
const origin = fileBase().replace(/\/v0\/file.*$/, '');
|
||||
return `${origin}${refUrl}`;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,14 @@ export const IMAGE_THUMBNAIL_DIM = 36;
|
||||
export const VIDEO_THUMBNAIL_WIDTH = 48;
|
||||
|
||||
export const MAX_ONLINE_IN_TOPIC = 4;
|
||||
export const MAX_TITLE_LENGTH = 60;
|
||||
/** `im_be/server/im_http.go`'s `serveIMGroupCreate`: `len([]rune(name)) < 1 || > 32` rejects outright
|
||||
* with a bare MALFORMED_REQUEST (no `details.field`, unlike the description-length check right next
|
||||
* to it) -- was `60` here, a value that let CreateGroupModal's input accept and submit names the
|
||||
* backend always rejects, surfacing only the generic "内容不符合平台规范" (indistinguishable from an
|
||||
* actual content-moderation hit, since the backend response itself doesn't say which). Capping input
|
||||
* at the real limit prevents the over-length case from ever reaching the backend at all (live-
|
||||
* confirmed real bug, Zentao #26). */
|
||||
export const MAX_TITLE_LENGTH = 32;
|
||||
/** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
|
||||
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
|
||||
export const MAX_PEER_TITLE_LENGTH = 24;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
||||
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
|
||||
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
|
||||
* own `LOCAL_SEQID`, not exported as a Tinode static -- verified against source, not guessed). */
|
||||
@@ -33,7 +33,13 @@ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | nu
|
||||
*/
|
||||
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
||||
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
||||
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
|
||||
// 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on
|
||||
// contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/
|
||||
// forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an
|
||||
// object it doesn't recognize, producing empty/malformed output rather than an error a user would
|
||||
// notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types.
|
||||
const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
||||
const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward'];
|
||||
// Defer to the server's own supported_actions list when it's present, so a server-side policy
|
||||
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
|
||||
// will just 400 when tapped -- the time-window check below still applies on top of this.
|
||||
|
||||
@@ -18,12 +18,28 @@ export function isRecalled(msg: StoredMessage): boolean {
|
||||
return !isServerDeleted(msg) && msg.content == null;
|
||||
}
|
||||
|
||||
interface SystemNoticeContent {
|
||||
interface FlatTextContent {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function isSystemNoticeContent(content: unknown): content is SystemNoticeContent {
|
||||
return !!content && typeof content === 'object' && typeof (content as SystemNoticeContent).text === 'string';
|
||||
function isFlatTextContent(content: unknown): content is FlatTextContent {
|
||||
return !!content && typeof content === 'object' && typeof (content as FlatTextContent).text === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* `{text: "..."}` -- a flat, non-Drafty content shape (no `txt`/`fmt`/`ent`) this backend uses for
|
||||
* more than one kind of message, not just the centered system-event lines `systemNoticeText` below
|
||||
* renders: a real, live-confirmed example is the auto-posted "群公告\n<announcement text>" message
|
||||
* a group's announcement publish creates, which has an ordinary sender/avatar (NOT a centered system
|
||||
* line) and `head['x-im-type']:"text"` (not `system_notice`) -- but the SAME flat `{text}` content
|
||||
* shape regardless. `renderDrafty`/`formatMessagePreview` both need this recognized as plain text
|
||||
* before falling through to real Drafty processing (`Drafty.format`/`Drafty.preview` on an object
|
||||
* with none of the fields they expect renders empty/`undefined`, confirmed live: a group's own
|
||||
* announcement-notice bubble showed no text at all, and the chat list's preview literally read "我:
|
||||
* undefined").
|
||||
*/
|
||||
export function flatTextContentOf(content: unknown): string | null {
|
||||
return isFlatTextContent(content) ? content.text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +51,7 @@ function isSystemNoticeContent(content: unknown): content is SystemNoticeContent
|
||||
export function systemNoticeText(msg: StoredMessage): string | null {
|
||||
const headType = msg.head?.['x-im-type'];
|
||||
if (headType !== 'system_notice' && headType !== 'system_action') return null;
|
||||
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
|
||||
return flatTextContentOf(msg.content);
|
||||
}
|
||||
|
||||
export interface ContactCardContent {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Drafty, type DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { SafeLink } from '@/features/messages/SafeLink';
|
||||
import { flatTextContentOf } from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
|
||||
@@ -124,6 +125,8 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
|
||||
if (content == null) return null;
|
||||
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
||||
const flatText = flatTextContentOf(content);
|
||||
if (flatText != null) return highlightQuery ? <>{highlightMatches(flatText, highlightQuery, 'plain')}</> : flatText;
|
||||
if (Drafty.isPlainText(content)) {
|
||||
const text = Drafty.toPlainText(content);
|
||||
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { callResultDisplay, contactCardContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, flatTextContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
|
||||
@@ -26,6 +26,13 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
|
||||
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
|
||||
const callResult = callResultDisplay(msg, msg.from === myUserId);
|
||||
if (callResult) return `${prefix}${callResult.text}`;
|
||||
// Same flat `{text}` shape `systemNoticeText` above already knows about, but NOT gated to
|
||||
// `system_notice`/`system_action` -- a real, live-confirmed example is a group's own
|
||||
// announcement-publish notice (`head['x-im-type']:"text"`, ordinary sender, not a centered system
|
||||
// line), which fell through everything above straight into the Drafty-entity branches below and
|
||||
// produced the literal string "undefined" (confirmed live: chat list showed "我:undefined").
|
||||
const flatText = flatTextContentOf(content);
|
||||
if (flatText != null) return prefix + flatText;
|
||||
if (typeof content === 'string' || Drafty.isPlainText(content)) {
|
||||
return prefix + Drafty.toPlainText(content);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Desktop/browser Notification API integration: fires a real OS-level notification for a genuinely
|
||||
// new incoming message, with the same "when NOT to" judgment calls every other IM web client makes.
|
||||
// Only works while this tab is open (foreground or backgrounded) -- there is no Service Worker/Push
|
||||
// subscription here, so this cannot notify once the browser itself is fully closed. That would need
|
||||
// a backend push-subscription endpoint + VAPID keys, a materially bigger feature than this.
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||
import {
|
||||
getActiveConversationTopic,
|
||||
getChatListSnapshot,
|
||||
getMessagesSnapshot,
|
||||
subscribeAnyTopicChange,
|
||||
subscribeNewGroupDiscovered,
|
||||
} from '@/lib/tinode/topics';
|
||||
import { useNotificationNavStore } from '@/stores/notificationNav';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
// "Am I already looking at this exact conversation" (topic open AND tab visible) is tracked in
|
||||
// topics.ts, not here -- topics.ts's own me.onPres correction (see its comment) needs the same
|
||||
// state, and topics.ts can't import it back from this module without a cycle. ConversationView calls
|
||||
// topics.ts's setActiveConversationTopic directly; this module just reads it via the getter.
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
/** How recent a topic's first-ever-observed message must be to count as "genuinely live" rather than
|
||||
* catch-up history -- see checkTopic's own comment. Generous relative to DEBOUNCE_MS on purpose: a
|
||||
* slow network round trip for a brand-new friend's actual first message shouldn't get misjudged as
|
||||
* history just because it took a couple of seconds to arrive. */
|
||||
const LIVE_MESSAGE_MAX_AGE_MS = 10_000;
|
||||
const lastSeqByTopic = new Map<string, number>();
|
||||
let pendingScan: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function latestMessageOf(topicName: string): StoredMessage | undefined {
|
||||
const msgs = getMessagesSnapshot(topicName);
|
||||
return msgs[msgs.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* One global debounce covers every topic at once (re-scanning the whole chat list is cheap and
|
||||
* already the pattern getChatListSnapshot/invalidateChatListSnapshot itself uses) rather than a
|
||||
* per-topic timer -- coalescing matters because a historical page load (opening a conversation for
|
||||
* the first time, or its chat-list preview prefetch) fires this same underlying "something changed"
|
||||
* signal once per message in the batch, arriving synchronously back-to-back. Checking on every one of
|
||||
* those would seed/compare a topic's baseline against a moving mid-batch target and could misfire on
|
||||
* messages that are actually old; waiting for the burst to settle means the first evaluation always
|
||||
* sees the batch's true final state.
|
||||
*/
|
||||
function scheduleScan() {
|
||||
if (pendingScan) clearTimeout(pendingScan);
|
||||
pendingScan = setTimeout(() => {
|
||||
pendingScan = null;
|
||||
for (const contact of getChatListSnapshot()) {
|
||||
if (contact.topic) checkTopic(contact.topic);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function checkTopic(topicName: string) {
|
||||
const latest = latestMessageOf(topicName);
|
||||
if (!latest || typeof latest.seq !== 'number') return;
|
||||
const previousSeq = lastSeqByTopic.get(topicName);
|
||||
lastSeqByTopic.set(topicName, latest.seq);
|
||||
if (previousSeq !== undefined && latest.seq <= previousSeq) return; // no actual new message
|
||||
// A topic observed here for the very first time (fresh login, or a brand-new conversation just
|
||||
// learned about) can't be judged as "new" by seq alone -- a months-old conversation surfacing for
|
||||
// the first time this session and a brand-new friend's actual first-ever message look identical by
|
||||
// that measure (both are "seq 1, never seen before"), but only one of them should notify. Message
|
||||
// age is what actually distinguishes them: something that happened within roughly this debounce
|
||||
// window is genuinely live; anything older is catch-up history, silently seeded as the baseline
|
||||
// instead. A topic already being tracked (previousSeq defined) always notifies on a real seq
|
||||
// increase regardless of age -- that case doesn't have this ambiguity to begin with.
|
||||
if (previousSeq === undefined) {
|
||||
const ts = latest.ts instanceof Date ? latest.ts.getTime() : NaN;
|
||||
const age = Number.isFinite(ts) ? Date.now() - ts : Infinity;
|
||||
if (age > LIVE_MESSAGE_MAX_AGE_MS) return;
|
||||
}
|
||||
if (shouldNotify(topicName, latest)) fireNotification(topicName, latest);
|
||||
}
|
||||
|
||||
function shouldNotify(topicName: string, msg: StoredMessage): boolean {
|
||||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return false;
|
||||
const myUserId = useSessionStore.getState().myUserId;
|
||||
// Your own message, echoed back (e.g. sent from another tab/device) -- never alert yourself.
|
||||
if (msg.from && myUserId && msg.from === myUserId) return false;
|
||||
// Already looking at this exact conversation, in the tab that's actually on screen right now --
|
||||
// the active-topic check alone isn't enough, since a backgrounded tab still keeps ConversationView
|
||||
// mounted.
|
||||
if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return false;
|
||||
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
|
||||
if (contact?.private?.mute_notifications) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shared by both real call sites below -- a new message, or being newly added to a group. `tag:
|
||||
* topicName` makes a second notification from the same conversation REPLACE the first in the OS
|
||||
* notification tray instead of stacking, matching every mainstream IM web client. */
|
||||
function showNotification(topicName: string, title: string, body: string) {
|
||||
let notif: Notification;
|
||||
try {
|
||||
notif = new Notification(title, { body, tag: topicName, icon: '/favicon.svg' });
|
||||
} catch {
|
||||
// Some browsers/contexts throw constructing Notification even with permission granted (e.g. an
|
||||
// OS-level notification setting disabled after the fact) -- never let this crash the message
|
||||
// pipeline over a non-essential feature.
|
||||
return;
|
||||
}
|
||||
notif.onclick = () => {
|
||||
window.focus();
|
||||
useNotificationNavStore.getState().requestNavigate(topicName);
|
||||
notif.close();
|
||||
};
|
||||
}
|
||||
|
||||
function fireNotification(topicName: string, msg: StoredMessage) {
|
||||
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
|
||||
const title = contact?.public?.fn || topicName;
|
||||
const myUserId = useSessionStore.getState().myUserId;
|
||||
const body = formatMessagePreview(msg, myUserId) || '发来一条新消息';
|
||||
showNotification(topicName, title, body);
|
||||
}
|
||||
|
||||
/** "被拉入群时title的消息没有提示" -- topics.ts's reconcileMeSubs() is the only thing that can even
|
||||
* detect this (nothing else observes a group appearing that this client never knew about), so it
|
||||
* fires this via subscribeNewGroupDiscovered rather than this module trying to infer "newly added"
|
||||
* from message/seq activity, which a group with zero messages so far wouldn't have at all. */
|
||||
function onNewGroupDiscovered(topicName: string) {
|
||||
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
|
||||
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
|
||||
if (contact?.private?.mute_notifications) return;
|
||||
const title = contact?.public?.fn || topicName;
|
||||
showNotification(topicName, title, '你被拉入了这个群聊');
|
||||
}
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
/** Call once per authenticated session (RequireAuth.tsx, alongside startMeReconciliation) and invoke
|
||||
* the returned function on logout/unmount. Safe to start/stop repeatedly. */
|
||||
export function initMessageNotifications(): () => void {
|
||||
if (unsubscribe) return unsubscribe;
|
||||
const unsubScan = subscribeAnyTopicChange(scheduleScan);
|
||||
const unsubNewGroup = subscribeNewGroupDiscovered(onNewGroupDiscovered);
|
||||
unsubscribe = () => {
|
||||
unsubScan();
|
||||
unsubNewGroup();
|
||||
};
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
if (pendingScan) clearTimeout(pendingScan);
|
||||
pendingScan = null;
|
||||
lastSeqByTopic.clear();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Browser-tab-level "you have unread messages" signal: a scrolling title (only while the tab is
|
||||
// backgrounded -- flashing/scrolling while the user is actively looking at it would just be
|
||||
// annoying, and every real IM web client stops the moment you look back) plus a numeric badge drawn
|
||||
// onto the favicon (visible regardless of focus, same as Gmail/Slack's own tab badge).
|
||||
|
||||
const BASE_TITLE = document.title;
|
||||
const TICK_MS = 300;
|
||||
const TITLE_WINDOW = 18;
|
||||
|
||||
let scrollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let scrollText = '';
|
||||
let scrollPos = 0;
|
||||
|
||||
function stopScrolling() {
|
||||
if (scrollTimer) {
|
||||
clearInterval(scrollTimer);
|
||||
scrollTimer = null;
|
||||
}
|
||||
document.title = BASE_TITLE;
|
||||
}
|
||||
|
||||
/** Left-scrolling marquee over `📨 (N条新消息) <original title>` -- shifts one character per tick
|
||||
* and wraps, rather than just alternating two strings, so it reads as genuine "滚动" rather than a
|
||||
* blink. Re-callable while already running to pick up a changed count without restarting the timer
|
||||
* (only the text/position reset; a second interval is never created). */
|
||||
function startScrolling(count: number) {
|
||||
scrollText = `📨 (${count}条新消息) ${BASE_TITLE} `;
|
||||
scrollPos = 0;
|
||||
if (scrollTimer) return;
|
||||
scrollTimer = setInterval(() => {
|
||||
const doubled = scrollText + scrollText;
|
||||
document.title = doubled.slice(scrollPos, scrollPos + TITLE_WINDOW);
|
||||
scrollPos = (scrollPos + 1) % scrollText.length;
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
// Stop scrolling and restore the plain title the instant the user looks back at the tab, rather
|
||||
// than waiting for the next unread-count change to notice -- otherwise a title that scrolled to a
|
||||
// mid-cycle, half-scrambled substring could sit there indefinitely after the user has already
|
||||
// returned, if no new message happens to arrive to trigger another updateTabIndicator() call.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') stopScrolling();
|
||||
});
|
||||
|
||||
let faviconImg: HTMLImageElement | null = null;
|
||||
const FAVICON_HREF = '/favicon.svg';
|
||||
|
||||
/** Draws a small red circle + white count in the favicon's top-right corner onto a canvas, then
|
||||
* points the existing `<link rel="icon">` at the resulting PNG data URL -- mutating the same link
|
||||
* element's href/type rather than adding a second competing `<link rel="icon">`, since browsers'
|
||||
* tie-breaking between multiple icon links is inconsistent. Reverts to the plain SVG favicon at 0. */
|
||||
function updateFaviconBadge(count: number) {
|
||||
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) return;
|
||||
if (count <= 0) {
|
||||
link.href = FAVICON_HREF;
|
||||
link.type = 'image/svg+xml';
|
||||
return;
|
||||
}
|
||||
if (!faviconImg) {
|
||||
faviconImg = new Image();
|
||||
faviconImg.src = FAVICON_HREF;
|
||||
}
|
||||
const draw = () => {
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx || !faviconImg) return;
|
||||
ctx.drawImage(faviconImg, 0, 0, size, size);
|
||||
const radius = size * 0.32;
|
||||
const cx = size - radius;
|
||||
const cy = radius;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f34b46'; // --color-unread (src/index.css) -- same red the in-app badges use
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${Math.round(radius * 1.15)}px -apple-system, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(count > 99 ? '99+' : String(count), cx, cy + 1);
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.type = 'image/png';
|
||||
};
|
||||
if (faviconImg.complete) draw();
|
||||
else faviconImg.onload = draw;
|
||||
}
|
||||
|
||||
/** Call whenever the app's canonical total-unread number changes (AppShell.tsx already computes it
|
||||
* for NavRail/BottomTabBar) -- keeps the favicon badge and, while backgrounded, the scrolling title
|
||||
* in sync with that same number rather than tracking a second, potentially-divergent count. */
|
||||
export function updateTabIndicator(unreadCount: number) {
|
||||
updateFaviconBadge(unreadCount);
|
||||
if (unreadCount > 0 && document.visibilityState !== 'visible') {
|
||||
startScrolling(unreadCount);
|
||||
} else {
|
||||
stopScrolling();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Drafty, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
? Drafty.insertImage(undefined, 0, { mime: file.type, bits: await fileToBase64(file), width, height, filename: file.name, size: file.size })
|
||||
: Drafty.insertImage(undefined, 0, {
|
||||
mime: file.type,
|
||||
refurl: (await uploadFile(file, topic.name)).url,
|
||||
refurl: upgradeToHttps((await uploadFile(file, topic.name)).url),
|
||||
width,
|
||||
height,
|
||||
filename: file.name,
|
||||
@@ -58,7 +58,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
const content =
|
||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||
? 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 });
|
||||
: Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((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', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
@@ -79,7 +79,7 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
|
||||
? Drafty.insertAudio(undefined, 0, { mime: file.type, bits: await fileToBase64(file), duration: durationMs, size: file.size, filename: file.name })
|
||||
: Drafty.insertAudio(undefined, 0, {
|
||||
mime: file.type,
|
||||
refurl: (await uploadFile(file, topic.name)).url,
|
||||
refurl: upgradeToHttps((await uploadFile(file, topic.name)).url),
|
||||
duration: durationMs,
|
||||
size: file.size,
|
||||
filename: file.name,
|
||||
|
||||
@@ -66,14 +66,35 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
|
||||
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `Topic#videoCall` sends a `{note}` packet, which (unlike `{pub}`) carries no `id` field -- read
|
||||
* the SDK source: with no id to route a response through, a disconnected socket makes the
|
||||
* underlying `sendText()` throw SYNCHRONOUSLY rather than reject a promise. None of this module's
|
||||
* callers in `stores/calls.ts` await or `.catch()` these calls (they're fire-and-forget signals),
|
||||
* so an uncaught synchronous throw here doesn't just fail silently -- it aborts whatever code was
|
||||
* running right after the call site. Concretely: `hangUp()`'s 60s ring-timeout callback calls this,
|
||||
* and if the connection happens to be down at that exact moment (e.g. `resetTinode()` ran because
|
||||
* the account was evicted mid-call), the exception skips every line after it in that function,
|
||||
* including the local Agora `leaveCall()` cleanup and the UI's own end-of-call state transition --
|
||||
* the call window would stay stuck open with a live camera/mic track. These three are the only
|
||||
* `note`-based signals in the app; wrapping here (once) covers every call site in one place.
|
||||
*/
|
||||
function sendNote(topic: Topic, event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>) {
|
||||
try {
|
||||
return topic.videoCall(event, seq, payload);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('ringing', seq, { callId });
|
||||
return sendNote(topic, 'ringing', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
|
||||
return topic.videoCall('accept', seq, { callId });
|
||||
return sendNote(topic, 'accept', seq, { callId });
|
||||
}
|
||||
|
||||
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
|
||||
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
|
||||
return sendNote(topic, 'hang-up', seq, reason ? { callId, reason } : { callId });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// silently stomp on each other. This module claims all the SDK's callback slots exactly once and
|
||||
// fans them out to any number of subscribers via its own Set, which is what hooks.ts's
|
||||
// useSyncExternalStore-based hooks actually subscribe to.
|
||||
import { Tinode, type LoginCtrl } from 'tinode-sdk';
|
||||
import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk';
|
||||
|
||||
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
@@ -109,6 +109,32 @@ if (typeof window !== 'undefined') {
|
||||
window.addEventListener('offline', () => setReconnectState({ networkOnline: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制下线通知与顶号互踢-后端接口文档.md §2: an account-level eviction is a `{ctrl}` with
|
||||
* `code:205, text:"evicted"` and NO `topic` (a topic-scoped one -- e.g. plain removal from a group --
|
||||
* carries a topic name instead and is left to whatever already handles that; not this module's
|
||||
* concern). `reason` drives which notice text KickedNoticeRoot shows.
|
||||
*/
|
||||
let kickedInfo: { reason: string | undefined } | null = null;
|
||||
const kickedListeners = new Set<() => void>();
|
||||
|
||||
export function getKickedInfo(): { reason: string | undefined } | null {
|
||||
return kickedInfo;
|
||||
}
|
||||
|
||||
export function subscribeKicked(callback: () => void): () => void {
|
||||
kickedListeners.add(callback);
|
||||
return () => kickedListeners.delete(callback);
|
||||
}
|
||||
|
||||
/** KickedNoticeRoot calls this once the user has acknowledged the notice, right before navigating
|
||||
* to /login -- clears the flag so a later fresh login doesn't leave a stale notice around to
|
||||
* reappear (e.g. StrictMode remount, or the user landing back on some cached page via history). */
|
||||
export function clearKickedInfo() {
|
||||
kickedInfo = null;
|
||||
kickedListeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
/**
|
||||
* The server extends the stock {hi} packet with a `tenant` field the vendored JS SDK has no API for
|
||||
* (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private,
|
||||
@@ -205,6 +231,21 @@ function createTinode(): Tinode {
|
||||
if (next <= 0) clearCountdown();
|
||||
}, 1000);
|
||||
};
|
||||
t.onCtrlMessage = (ctrl: TinodeCtrl) => {
|
||||
if (ctrl.code !== 205 || ctrl.text !== 'evicted' || ctrl.topic) return;
|
||||
// Tear down the session THE MOMENT eviction is detected, not when the user dismisses the
|
||||
// notice -- the doc requires reconnect to stop immediately, and resetTinode()'s disconnect()
|
||||
// sets the SDK's own #boffClosed flag before this handler returns, which is what actually
|
||||
// prevents the backoff timer from ever scheduling another attempt (confirmed by reading the
|
||||
// real bundle's Connection#init_ws -- disconnect() is the only thing that does this; there is
|
||||
// no separate "stop autoreconnect" method to call instead). Only the user-facing navigation to
|
||||
// /login is deferred, so they get a chance to actually read why.
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
resetTinode();
|
||||
kickedInfo = { reason: typeof ctrl.params?.reason === 'string' ? ctrl.params.reason : undefined };
|
||||
kickedListeners.forEach((cb) => cb());
|
||||
};
|
||||
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,23 @@ export function isTenantMismatch(err: unknown): boolean {
|
||||
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
|
||||
}
|
||||
|
||||
/**
|
||||
* `openConversation()`'s `{sub}` genuinely rejects with a clean `CommError` (code 404, message
|
||||
* "topic not found (404)") for a topic that no longer exists -- confirmed live: a group dissolved
|
||||
* by its owner, then deep-linked/refreshed into directly by a former member (not the "I'm actively
|
||||
* watching it get dissolved right now" case subscribeTopicGone already covers), reproducibly 404s
|
||||
* exactly like this. Before this check existed, ConversationView's generic catch just showed
|
||||
* "加载会话失败,请重试" and left the screen sitting on a half-initialized topic stub (raw topic id
|
||||
* as the name, "1970/1/1" for `touched`, "0" members -- `getTinode().getTopic()` lazily creates that
|
||||
* stub the moment anything asks for the name, whether or not the subscribe behind it ever succeeds),
|
||||
* a real reported bug ("当打开的群聊被删除后, 刷新还在群聊中 但是显示的不对"). 404 alone is treated
|
||||
* as sufficiently unambiguous for "this topic doesn't exist" -- there's no other real reason a
|
||||
* `{sub}` would come back with this exact code.
|
||||
*/
|
||||
export function isTopicGone(err: unknown): boolean {
|
||||
return (err as { code?: number })?.code === 404;
|
||||
}
|
||||
|
||||
export function loginErrorMessage(err: unknown): string {
|
||||
const known = translateCtrlError(err);
|
||||
if (known) return known;
|
||||
@@ -87,3 +104,36 @@ export function validateCredentialsMessage(params: ValidateCredentialsParams | u
|
||||
const reason = params?.reason;
|
||||
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
|
||||
}
|
||||
|
||||
/** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording,
|
||||
* so this stays a straight lookup rather than something to rephrase later without checking the doc
|
||||
* again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table
|
||||
* merges them into a single row). */
|
||||
const KICKED_REASON_TEXT_CN: Record<string, { title: string; message: string }> = {
|
||||
replaced_by_new_login: {
|
||||
title: '账号已在其他设备登录',
|
||||
message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。',
|
||||
},
|
||||
admin_user_evict: {
|
||||
title: '账号已被管理员下线',
|
||||
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
|
||||
},
|
||||
admin_device_kick: {
|
||||
title: '账号已被管理员下线',
|
||||
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
|
||||
},
|
||||
user_deleted: {
|
||||
title: '账号已注销',
|
||||
message: '您的账号已注销,当前会话已退出。',
|
||||
},
|
||||
user_state_changed: {
|
||||
title: '账号已被限制登录',
|
||||
message: '您的账号已被管理员限制登录,如有疑问请联系管理员。',
|
||||
},
|
||||
};
|
||||
|
||||
const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' };
|
||||
|
||||
export function kickedNoticeText(reason: string | undefined): { title: string; message: string } {
|
||||
return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,16 @@ import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import type { Contact, StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { getConnectionStatus, getReconnectState, getTinode, subscribeConnectionStatus, subscribeReconnectState, type ReconnectState } from './connection';
|
||||
import {
|
||||
getConnectionStatus,
|
||||
getKickedInfo,
|
||||
getReconnectState,
|
||||
getTinode,
|
||||
subscribeConnectionStatus,
|
||||
subscribeKicked,
|
||||
subscribeReconnectState,
|
||||
type ReconnectState,
|
||||
} from './connection';
|
||||
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics';
|
||||
|
||||
/** The current WS connection status, reactive via useSyncExternalStore. */
|
||||
@@ -15,6 +24,12 @@ export function useReconnectState(): ReconnectState {
|
||||
return useSyncExternalStore(subscribeReconnectState, getReconnectState);
|
||||
}
|
||||
|
||||
/** Non-null once the server has forcibly evicted this session (顶号/管理端踢出/封禁/删号/重置密码 --
|
||||
* see 强制下线通知与顶号互踢-后端接口文档.md §2); KickedNoticeRoot is the sole consumer. */
|
||||
export function useKickedInfo() {
|
||||
return useSyncExternalStore(subscribeKicked, getKickedInfo);
|
||||
}
|
||||
|
||||
/** The singleton Tinode client instance (stable reference, not itself reactive). */
|
||||
export function useTinode() {
|
||||
return getTinode();
|
||||
|
||||
Vendored
+70
@@ -33,6 +33,18 @@ declare module 'tinode-sdk' {
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** One entry from `Topic#subscribers()` -- fields confirmed live against a real P2P topic (only
|
||||
* `user` is actually consumed anywhere in this app so far; the rest are declared for completeness). */
|
||||
export interface Subscription {
|
||||
user: string;
|
||||
online?: boolean;
|
||||
updated?: Date;
|
||||
read?: number;
|
||||
recv?: number;
|
||||
acs?: { want?: string; given?: string; mode?: string };
|
||||
public?: TopicDescPublic | null;
|
||||
}
|
||||
|
||||
export interface CredentialParam {
|
||||
meth: string;
|
||||
val?: string;
|
||||
@@ -191,6 +203,20 @@ declare module 'tinode-sdk' {
|
||||
isSelfType(): boolean;
|
||||
getAccessMode(): AccessMode | undefined;
|
||||
|
||||
/**
|
||||
* Iterates this topic's cached subscriber list, one `Subscription` per call -- for a P2P topic
|
||||
* this is always exactly the two participants (self included), confirmed live. This is the only
|
||||
* reliable way to recover the OTHER party's real `usr...` id once a P2P topic has been subscribed
|
||||
* at least once: the SDK renames a freshly-addressed `usr...` topic to a real, opaque `p2p...`
|
||||
* name the moment `subscribe()` succeeds (see topics.ts's `openConversation`), and -- confirmed by
|
||||
* reading tinode.dev.js -- `Topic.topicType()`'s own prefix table has no `p2p` entry at all (only
|
||||
* `usr` maps to the P2P type), so `isP2PType()`/`p2pPeerDesc()` silently stop working the instant
|
||||
* a topic has this real name. `topic.name` itself is NOT a usable substitute for the peer's id
|
||||
* past that point -- anything needing "the other user" (profile lookup, friends-list matching,
|
||||
* block/remark) must go through this method instead.
|
||||
*/
|
||||
subscribers(callback: (sub: Subscription) => void): void;
|
||||
|
||||
/**
|
||||
* Iterates locally-cached messages in `[sinceId, beforeId]` (both omitted = everything cached).
|
||||
* `callback(msg, prevMsg, nextMsg, bufferIndex)` fires once per message, already resolved to the
|
||||
@@ -246,9 +272,20 @@ declare module 'tinode-sdk' {
|
||||
onAllMessagesReceived?: (count: number) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
|
||||
onMetaDesc?: (desc: unknown) => void;
|
||||
/** `me`-topic-only in practice (verified tinode.dev.js:4654-4657): fired with the just-subscribed
|
||||
* Topic itself the moment a brand-new contact's subscribe resolves -- notably including the
|
||||
* usr-alias -> real-topic-name rename case (Topic#subscribe, same code block that updates
|
||||
* `this.name`). Distinct from `onSubsUpdated` below (which the SDK ALSO fires in that same
|
||||
* block, with just the name/count) -- wired to the identical notify callback here since both
|
||||
* ultimately mean the same thing for this app's purposes: "the chat list's contact set changed,
|
||||
* recompute it." */
|
||||
onMetaSub?: (topic: Topic) => void;
|
||||
onSubsUpdated?: (subs: string[], count?: number) => void;
|
||||
onPres?: (pres: unknown) => void;
|
||||
onAuxUpdated?: (aux: unknown) => void;
|
||||
/** Fired by the SDK's own `Topic#_gone()` once this topic is confirmed deleted (group dissolved,
|
||||
* or this account removed from it) -- clears `public`/`private`/messages just before firing. */
|
||||
onDeleteTopic?: () => void;
|
||||
}
|
||||
|
||||
export class MeTopic extends Topic {
|
||||
@@ -294,6 +331,18 @@ declare module 'tinode-sdk' {
|
||||
|
||||
getMeTopic(): MeTopic;
|
||||
getTopic(topicName: string): Topic;
|
||||
/** Iterates EVERY locally-cached topic (any type, including `me`/`fnd`), unlike `MeTopic.contacts()`
|
||||
* which additionally filters by `isCommType()` -- see topics.ts's `getChatListSnapshot` for why
|
||||
* that filter can't be trusted for this backend's P2P topics. */
|
||||
mapTopics(callback: (topic: Topic, key: string) => void): void;
|
||||
/** Public (not underscore-prefixed) on the real SDK, confirmed reading tinode.dev.js -- removes a
|
||||
* topic from the local cache entirely, same cleanup the SDK's own `{pres what:"gone"}` handling
|
||||
* uses internally. Needed for a topic that was never successfully subscribed at all (a 404 on
|
||||
* first {sub}, e.g. deep-linking into an already-dissolved group) -- `getTopic()` having been
|
||||
* called even once already left a bare, empty stub in the cache by then (see topics.ts's
|
||||
* openConversation/getChatListSnapshot for why that stub alone is enough to show up in the chat
|
||||
* list looking broken), and nothing else ever clears a topic that was never truly attached. */
|
||||
cacheRemTopic(topicName: string): void;
|
||||
|
||||
account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise<unknown>;
|
||||
createAccountBasic(
|
||||
@@ -348,6 +397,27 @@ declare module 'tinode-sdk' {
|
||||
* has stopped entirely (e.g. an explicit disconnect).
|
||||
*/
|
||||
onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise<unknown>) => void;
|
||||
/**
|
||||
* Fires for every incoming `{ctrl}` packet, account-level ones included (verified
|
||||
* tinode.dev.js:6564-6566: `if (this.onCtrlMessage) this.onCtrlMessage(pkt.ctrl)`, unconditional,
|
||||
* runs before the SDK's own internal `code===205` handling). This is the ONLY hook the actual
|
||||
* installed SDK (0.25.3) offers for a server-forced eviction (`code:205, text:"evicted"`) --
|
||||
* there is no `onKick` callback in this version despite 强制下线通知与顶号互踢-后端接口文档.md's
|
||||
* §2.4 claiming the SDK auto-stops reconnect and calls one; read the real bundle to confirm
|
||||
* before relying on that doc's description of client-side behavior again.
|
||||
*/
|
||||
onCtrlMessage?: (ctrl: TinodeCtrl) => void;
|
||||
}
|
||||
|
||||
export interface TinodeCtrl {
|
||||
code: number;
|
||||
text: string;
|
||||
/** Empty/absent for an account-level `{ctrl}` (e.g. forced eviction); a topic-scoped event
|
||||
* (e.g. a plain "removed from this group") carries the topic name here instead. */
|
||||
topic?: string;
|
||||
params?: Record<string, unknown>;
|
||||
ts?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Drafty: {
|
||||
|
||||
@@ -22,6 +22,70 @@ const wiredTopics = new WeakSet<Topic>();
|
||||
const previewPrefetched = new Set<string>();
|
||||
const messageSnapshots = new Map<string, StoredMessage[]>();
|
||||
|
||||
let activeConversationTopic: string | null = null;
|
||||
|
||||
/** Set by ConversationView on mount/unmount -- "is the user genuinely looking at this exact
|
||||
* conversation right now" (topic open AND tab visible), consumed both by lib/notifications/push.ts
|
||||
* (suppress a desktop notification for something already on screen) and by the me.onPres correction
|
||||
* below (don't fight the SDK's auto-read-mark for the ONE conversation actually being watched). Lives
|
||||
* here rather than in push.ts because topics.ts itself now also needs to read it, and push.ts already
|
||||
* depends on topics.ts -- the reverse would be a cycle. */
|
||||
export function setActiveConversationTopic(topicId: string | null) {
|
||||
activeConversationTopic = topicId;
|
||||
}
|
||||
|
||||
export function getActiveConversationTopic(): string | null {
|
||||
return activeConversationTopic;
|
||||
}
|
||||
|
||||
/**
|
||||
* The other party's real `usr...` id for a P2P conversation -- NOT the same as `topic.name` once a
|
||||
* conversation has any real history. Real, live-confirmed bug this fixes: a P2P topic addressed by
|
||||
* the peer's own `usr...` id (the only address that exists before the very first subscribe -- e.g.
|
||||
* straight from a friend's profile) gets renamed by the SDK to a real, opaque `p2p...` name the
|
||||
* instant `subscribe()` succeeds (see openConversation's own doc comment on this same rename). Every
|
||||
* P2P conversation with actual history is addressed by this `p2p...` form from then on -- but reading
|
||||
* tinode.dev.js confirms `Topic.topicType()`'s prefix table has no `p2p` entry at all (only `usr` maps
|
||||
* to the P2P type), so `topic.isP2PType()`/`topic.p2pPeerDesc()` silently return false/undefined for
|
||||
* every one of these topics, and code that assumed "the P2P topic name IS the peer's user id" (true
|
||||
* only for the brief pre-first-subscribe window) breaks the moment that assumption stops holding --
|
||||
* confirmed live: `GET /users/{topicName}/profile` 404s, `friends.find(f => f.user_id === topicName)`
|
||||
* never matches, block/remark/avatar all silently target a user id that doesn't exist.
|
||||
*
|
||||
* Returns `undefined` (never `topic.name` as a guess) when the subscriber list isn't populated yet --
|
||||
* live-confirmed this matters: on the render right after navigating straight to a `p2p...`-named
|
||||
* conversation, `subscribers()` can still be empty for the one or two renders before the subscribe
|
||||
* response fills it in, and returning `topic.name` as a placeholder there just fires the exact same
|
||||
* doomed `GET /users/{p2p...}/profile` request this function exists to prevent, a render later. Every
|
||||
* caller already guards its query with `enabled: !!peerUserId`, so `undefined` correctly makes it wait
|
||||
* instead of firing once with a value it knows is wrong.
|
||||
*/
|
||||
export function getP2PPeerUserId(topic: Topic, myUserId: string | null | undefined): string | undefined {
|
||||
if (topic.name.startsWith('usr')) return topic.name;
|
||||
let peer: string | undefined;
|
||||
topic.subscribers((sub) => {
|
||||
if (!peer && sub.user !== myUserId) peer = sub.user;
|
||||
});
|
||||
return peer;
|
||||
}
|
||||
|
||||
const confirmedReadSeq = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* This app's OWN independent record of "the user has genuinely seen up to `seq` in this topic" --
|
||||
* tracked separately from the SDK's own `read`/`unread` fields because those get silently advanced by
|
||||
* a mechanism this app doesn't want for a conversation that's merely open in a backgrounded tab (see
|
||||
* the me.onPres override in subscribeMeTopic below for the full story). Callers: openConversation
|
||||
* seeds this the moment a conversation is freshly opened (opening one always means "I've seen
|
||||
* everything up to here", regardless of tab visibility, since you can only open one by clicking on it
|
||||
* while looking at the page); ConversationView additionally bumps it whenever it calls noteRead() while
|
||||
* actually visible.
|
||||
*/
|
||||
export function markConfirmedRead(topicName: string, seq: number) {
|
||||
const prev = confirmedReadSeq.get(topicName) || 0;
|
||||
if (seq > prev) confirmedReadSeq.set(topicName, seq);
|
||||
}
|
||||
|
||||
function notifyTopic(topicName: string) {
|
||||
messageSnapshots.delete(topicName);
|
||||
topicListeners.get(topicName)?.forEach((cb) => cb());
|
||||
@@ -64,6 +128,90 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
|
||||
return () => infoListeners.delete(callback);
|
||||
}
|
||||
|
||||
const goneListeners = new Set<(topicName: string) => void>();
|
||||
|
||||
const newGroupListeners = new Set<(topicName: string) => void>();
|
||||
|
||||
/** Fires when reconcileMeSubs() (below) discovers a group topic this client has never seen before --
|
||||
* the "you were just added to a group" case (Zentao/user-reported: "被拉入群时title的消息没有提示").
|
||||
* Consumed by lib/notifications/push.ts to fire a desktop notification -- can't live there itself
|
||||
* since push.ts already depends on topics.ts and the reverse would be a cycle, same reasoning as
|
||||
* setActiveConversationTopic living here instead of in push.ts. */
|
||||
export function subscribeNewGroupDiscovered(callback: (topicName: string) => void): () => void {
|
||||
newGroupListeners.add(callback);
|
||||
return () => newGroupListeners.delete(callback);
|
||||
}
|
||||
|
||||
/** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) --
|
||||
* the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls
|
||||
* `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this,
|
||||
* a member who stays on a group's conversation screen while someone else dissolves it just keeps
|
||||
* looking at a now-defunct view -- `onMetaDesc`/`onPres`/etc. above only invalidate cached snapshots
|
||||
* for re-render, they don't tell the currently-open screen it needs to navigate away entirely. */
|
||||
export function subscribeTopicGone(callback: (topicName: string) => void): () => void {
|
||||
goneListeners.add(callback);
|
||||
return () => goneListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Topics confirmed gone that `getChatListSnapshot` must never show again THIS session, no matter what
|
||||
* re-creates a cache entry for the name afterward -- live-confirmed real race: `openConversation`'s
|
||||
* own cleanup (`closeConversation`, fired by ConversationView's unmount effect as the redirect below
|
||||
* unmounts it) calls `getTinode().getTopic(topicName)` again for its `.leave(false)` -- unconditionally,
|
||||
* with no way to know this specific name should stay forgotten -- which by itself re-creates a fresh,
|
||||
* empty stub the instant `cacheRemTopic` below has removed the old one, silently undoing the cleanup
|
||||
* and leaving the exact same broken sidebar row behind. Excluding by name in the chat-list filter
|
||||
* itself sidesteps that ordering race entirely rather than trying to make cleanup run strictly after
|
||||
* every other thing that might touch this topic's cache entry.
|
||||
*/
|
||||
const goneTopicNames = new Set<string>();
|
||||
|
||||
/**
|
||||
* Shared by every "this topic is confirmed gone" path below -- native `onDeleteTopic` (a real `{pres
|
||||
* what:"gone"}`, which never actually happens for THIS backend's own group dissolve -- see the
|
||||
* `group.dissolved` handling in wireTopicCallbacks' `onInfo` for why) and that custom path itself both
|
||||
* call this, so cache cleanup can't accidentally happen on only one of them. Removing the topic from
|
||||
* the SDK's cache matters even here (not just for the 404-before-ever-subscribing case forgetTopic
|
||||
* covers) -- confirmed live: a passive member watching a group when someone else dissolves it now
|
||||
* correctly gets bounced to /messages the instant it happens, but the group still lingered in their
|
||||
* chat list afterward (raw topic id, "1970/1/1") until this was added, same broken-stub symptom, just
|
||||
* reached via the live path instead of the refresh-into-already-gone one.
|
||||
*
|
||||
* Skips everything (including `goneListeners`) once `goneTopicNames` already has this name -- real,
|
||||
* user-reported bug: leaving a group yourself (GroupDetailColumn's 退出群聊/解散群聊, which calls
|
||||
* `forgetTopic` synchronously the instant its own REST call succeeds, see below) still leaves the
|
||||
* server free to ALSO deliver a native/`group.dissolved` gone-signal for that same topic a moment
|
||||
* later, since from the server's point of view your own subscription really did just end too. Left
|
||||
* unguarded, that late-arriving signal would re-run `goneListeners` a second time for a topic already
|
||||
* handled -- showing ConversationView's "该群聊已解散" toast (wrong wording for a leave you chose
|
||||
* yourself, not a dissolve) on top of whatever that action's own success handler already did.
|
||||
*/
|
||||
function markTopicGoneAndNotify(topicName: string) {
|
||||
if (goneTopicNames.has(topicName)) return;
|
||||
goneTopicNames.add(topicName);
|
||||
getTinode().cacheRemTopic(topicName);
|
||||
notifyTopic(topicName);
|
||||
goneListeners.forEach((cb) => cb(topicName));
|
||||
}
|
||||
|
||||
/**
|
||||
* For any topic THIS client already knows is gone without needing to be told by a server push --
|
||||
* `openConversation()` rejecting with a 404 (see ConversationView's `isTopicGone` handling) because it
|
||||
* was deep-linked/refreshed into after already being dissolved, or GroupDetailColumn's own 退出群聊/
|
||||
* 解散群聊 success handlers calling this immediately rather than waiting on the async gone-signal that
|
||||
* usually follows. Doesn't go through markTopicGoneAndNotify's `goneListeners` trigger -- every caller
|
||||
* already has its own richer, action-specific way to notify+navigate (a 404 catch block with the real
|
||||
* `err`; a mutation's own `onSuccess`), so firing goneListeners too would just re-run that a second
|
||||
* time, and for the leave/disband callers specifically, with the WRONG generic "该群聊已解散" wording.
|
||||
* Adding to `goneTopicNames` here first is exactly what makes markTopicGoneAndNotify's own guard above
|
||||
* effective against whatever late server push for this same topic follows.
|
||||
*/
|
||||
export function forgetTopic(topicName: string): void {
|
||||
goneTopicNames.add(topicName);
|
||||
getTinode().cacheRemTopic(topicName);
|
||||
notifyTopic(topicName);
|
||||
}
|
||||
|
||||
function wireTopicCallbacks(topic: Topic) {
|
||||
if (wiredTopics.has(topic)) return;
|
||||
const notify = () => notifyTopic(topic.name);
|
||||
@@ -71,12 +219,45 @@ function wireTopicCallbacks(topic: Topic) {
|
||||
topic.onInfo = (info) => {
|
||||
notify();
|
||||
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
|
||||
// Real, live-confirmed gap (user-reported: "被解散当时没有" [redirect] -- only a later manual
|
||||
// refresh ever caught it): this backend's own group-dissolve notification (server/
|
||||
// im_group_events.go's publishIMGroupRealtimeEvent) travels as a custom `{info what:"group"
|
||||
// event:"group.dissolved"}` packet, NOT the native Tinode `{pres what:"gone"}` the SDK's own
|
||||
// `_gone()`/`onDeleteTopic` chain below is built around. Confirmed by reading tinode.dev.js's
|
||||
// `_routeInfo`: `what:"group"` matches none of its explicit switch cases (recv/read/kp/kpa/kpv/
|
||||
// call), so nothing native reacts to it -- `onInfo(info)` still fires right after regardless, but
|
||||
// until now nothing here recognized this specific shape either. A member who was ACTIVELY,
|
||||
// PASSIVELY watching a group (never the one who dissolved it themselves -- that flow already
|
||||
// navigates away on its own mutation success, which is why testing only as the dissolver always
|
||||
// looked correct) got no live signal at all: only a later refresh, hitting openConversation's 404
|
||||
// path (see ConversationView's isTopicGone), ever discovered it was gone. Reuses the exact same
|
||||
// goneListeners subscribeTopicGone already exports, so ConversationView needs no changes at all.
|
||||
// Live-confirmed follow-up bug: this path alone (unlike onDeleteTopic below) never actually
|
||||
// cleared the SDK's own topic cache, so the redirect fired correctly but the dissolved group kept
|
||||
// lingering in the sidebar as a broken stub ("g grpMc5uN_H6ASc 2人 1970/1/1") until markTopicGoneAndNotify
|
||||
// was made to cover this path too.
|
||||
const groupEvent = info as { what?: string; event?: string } | undefined;
|
||||
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
|
||||
markTopicGoneAndNotify(topic.name);
|
||||
}
|
||||
};
|
||||
topic.onMetaDesc = notify;
|
||||
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
|
||||
// specifically, regardless of which topic's subscribe triggered it -- confirmed tinode.dev.js:
|
||||
// 4654-4659) -- wiring it here on every topic is a no-op for anything else, same as onSubsUpdated
|
||||
// right below, which already gets this same treatment. Without this, a brand-new P2P conversation
|
||||
// reached by the other user's `usr...` id (no established topic yet) never tells the chat list a
|
||||
// new contact exists the instant the server assigns its real `p2p...` name on first subscribe --
|
||||
// the contact stays invisible in the sidebar, and ConversationView's own `contact` lookup (peer
|
||||
// name, online status) keeps missing until some UNRELATED full resync happens to pick it up.
|
||||
topic.onMetaSub = notify;
|
||||
topic.onSubsUpdated = notify;
|
||||
topic.onPres = notify;
|
||||
topic.onAuxUpdated = notify;
|
||||
topic.onAllMessagesReceived = notify;
|
||||
topic.onDeleteTopic = () => {
|
||||
markTopicGoneAndNotify(topic.name);
|
||||
};
|
||||
wiredTopics.add(topic);
|
||||
}
|
||||
|
||||
@@ -109,6 +290,37 @@ export function subscribeMeTopic(): Promise<unknown> {
|
||||
// doesn't touch it. Without this, the chat list's unread badge/ordering goes stale after reading
|
||||
// a conversation until some unrelated topic event happens to invalidate its cached snapshot.
|
||||
me.onContactUpdate = () => notifyTopic(me.name);
|
||||
// Real, live-confirmed bug (2026-09-16, reported directly): reading tinode-sdk's source, TopicMe's
|
||||
// own `_routePres` handles a `{pres what:"msg"}` push -- the mechanism that updates a topic's unread
|
||||
// count even when you're not actively viewing it -- by calling `cont._updateReceived(pres.seq,
|
||||
// pres.act)`, which unconditionally sets `read = seq` (marks it AS READ) whenever `act` is falsy or
|
||||
// refers to yourself. A conversation that's simply left OPEN (ConversationView mounted, topic
|
||||
// actively subscribed) -- even sitting in a fully backgrounded tab nobody is looking at -- still gets
|
||||
// this same presence push, and gets silently auto-marked read regardless of tab visibility: confirmed
|
||||
// live, a message sent to a conversation left open in a backgrounded tab never bumped the unread
|
||||
// count at all, defeating the tab-title/favicon/notification indicators this same session just built
|
||||
// (ConversationView's own visibility-gated noteRead() call -- see its comment -- only prevents ITS
|
||||
// OWN explicit read-marking; it has no way to intercept this separate, lower-level SDK mechanism).
|
||||
// markConfirmedRead (see its own comment) is this app's independent record of when the user genuinely
|
||||
// saw a topic. Right after the SDK's own (for this app's purposes, wrong) auto-mark runs, correct it
|
||||
// back down to that record for any topic that isn't the one actually being looked at right now.
|
||||
me.onPres = (pres: unknown) => {
|
||||
notifyTopic(me.name);
|
||||
const p = pres as { what?: string; src?: string } | undefined;
|
||||
if (p?.what !== 'msg' || typeof p.src !== 'string') return;
|
||||
const topicName = p.src;
|
||||
const isBeingViewed = topicName === activeConversationTopic && document.visibilityState === 'visible';
|
||||
if (isBeingViewed) return;
|
||||
// Cast for the same reason getChatListSnapshot does -- read/unread/seq are real fields the SDK
|
||||
// sets directly on the Topic instance at runtime, just not declared on this hand-maintained .d.ts.
|
||||
const topic = getTinode().getTopic(topicName) as unknown as Contact;
|
||||
const correctRead = confirmedReadSeq.get(topicName) || 0;
|
||||
if ((topic.read || 0) > correctRead) {
|
||||
topic.read = correctRead;
|
||||
topic.unread = Math.max(0, (topic.seq || 0) - correctRead);
|
||||
notifyTopic(topicName);
|
||||
}
|
||||
};
|
||||
inFlightMeSubscribe = me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).finally(() => {
|
||||
inFlightMeSubscribe = null;
|
||||
});
|
||||
@@ -192,6 +404,16 @@ export function openConversation(topicName: string): Promise<Topic> {
|
||||
}
|
||||
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
|
||||
await topic.subscribe(query.build());
|
||||
// Opening a conversation via a normal SPA click always means "I've seen everything up to here" --
|
||||
// you can only click one while looking at the page. That assumption breaks for a direct reload or
|
||||
// deep link straight to a conversation's URL in a tab that's sitting BACKGROUNDED the whole time
|
||||
// (confirmed live: reloading tab-1 mid-test while it stayed the non-frontmost tab landed here with
|
||||
// the tab genuinely hidden) -- gated on visibility so that case correctly leaves any
|
||||
// already-unread history alone for the me.onPres correction above to keep reporting as unread,
|
||||
// instead of this seeding call silently absolving it the instant the route mounts.
|
||||
if (document.visibilityState === 'visible') {
|
||||
markConfirmedRead(topicName, (topic as unknown as Contact).seq || 0);
|
||||
}
|
||||
return topic;
|
||||
})();
|
||||
inFlightSubscribe.set(topicName, promise);
|
||||
@@ -244,23 +466,51 @@ export function ensureTopicAttached(topicName: string): Promise<Topic> {
|
||||
|
||||
let chatListSnapshot: Contact[] | null = null;
|
||||
|
||||
/**
|
||||
* Real, addressable chat-list topic types this backend actually issues -- deliberately NOT using
|
||||
* `Topic#isCommType()`/`isP2PType()` (confirmed live by reading tinode-sdk's own `Topic.topicType()`
|
||||
* table: it maps prefix `'usr'` to P2P, with NO entry for `'p2p'` at all). That table is right for a
|
||||
* peer ALIAS (`usr<peerId>`, used before a P2P topic has ever been subscribed), but this backend's
|
||||
* REAL, server-assigned P2P topic name is `p2p<hash>` (confirmed via raw `{meta topic:"me"}` capture:
|
||||
* every subscription the server reports uses this form, never the alias) -- so `isCommType()` returns
|
||||
* false for every P2P topic once its real name is known, silently filtering ALL of them out of
|
||||
* `MeTopic.contacts()`. This has nothing to do with account age or how a conversation was opened --
|
||||
* verified by direct fresh-login inspection that even a heavily-used account with many real, working
|
||||
* P2P friendships had ZERO P2P entries in `contacts()`; every P2P row the chat list has ever shown
|
||||
* this session was actually the special-cased `slf` self-chat entry.
|
||||
*/
|
||||
function isChatListTopicName(name: string): boolean {
|
||||
return name.startsWith('p2p') || name.startsWith('grp') || name === 'slf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached the same way getMessagesSnapshot is, but invalidated by ANY topic's change (a new message
|
||||
* on a contact that isn't currently open still needs to move that row and refresh its preview).
|
||||
* Triggers each contact's one-shot preview prefetch as a side effect of being read -- cheap and
|
||||
* idempotent (prefetchPreview no-ops after the first call per topic), so it's fine to do this on
|
||||
* every rebuild rather than needing a separate "wire up previews" pass.
|
||||
*
|
||||
* Uses `Tinode#mapTopics` (every locally-cached topic, unfiltered) + `isChatListTopicName` instead of
|
||||
* `MeTopic#contacts()` -- see that function's own comment for why `contacts()`'s built-in filter can't
|
||||
* be trusted here. `mapTopics` still only sees topics the SDK has locally cached, which `me`'s own
|
||||
* subscribe (`subscribeMeTopic`'s `withLaterSub()`) and any direct `openConversation()` both populate.
|
||||
*/
|
||||
export function getChatListSnapshot(): Contact[] {
|
||||
if (chatListSnapshot) return chatListSnapshot;
|
||||
const byTopic = new Map<string, Contact>();
|
||||
getTinode()
|
||||
.getMeTopic()
|
||||
.contacts((contact) => {
|
||||
if (!contact.topic) return;
|
||||
prefetchPreview(contact.topic);
|
||||
byTopic.set(contact.topic, contact);
|
||||
});
|
||||
getTinode().mapTopics((topic) => {
|
||||
if (!topic.name || !isChatListTopicName(topic.name) || goneTopicNames.has(topic.name)) return;
|
||||
prefetchPreview(topic.name);
|
||||
// `.topic` (what every consumer of this list actually reads) normally arrives via
|
||||
// `_processMetaSubs` merging the server's own `sub.topic` field onto this same cached instance --
|
||||
// but a brand-new P2P topic reached through `openConversation()` is cached here the moment its
|
||||
// OWN subscribe resolves, potentially before `me`'s subscription list has ever reported it. Set
|
||||
// explicitly rather than assuming that merge already happened; kept as the same live instance
|
||||
// (not a clone) so prototype methods like `latestMessage()` survive.
|
||||
const contact = topic as unknown as Contact;
|
||||
contact.topic = topic.name;
|
||||
byTopic.set(topic.name, contact);
|
||||
});
|
||||
const rows = [...byTopic.values()];
|
||||
chatListSnapshot = rows;
|
||||
return rows;
|
||||
@@ -301,3 +551,104 @@ onReauthenticated(() => {
|
||||
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
const ME_RECONCILE_INTERVAL_MS = 30_000;
|
||||
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Best-effort mitigation for a real backend gap that no amount of frontend-only code can fully close:
|
||||
* confirmed by reading im_be's server/im_group_events.go, EVERY group lifecycle event (created, member
|
||||
* added/removed, dissolved, owner transferred, ...) goes through publishIMGroupRealtimeEvent, which
|
||||
* only reaches sessions ALREADY subscribed to that specific group topic. Unlike P2P -- which has a
|
||||
* dedicated force-delivery path straight to a peer's session regardless of subscription state
|
||||
* (deliverIMConversationSystemMessageToP2PSessions) -- groups have no equivalent, so a member who has
|
||||
* never opened a given group gets zero live signal when added to it: no chat-list row, no unread badge,
|
||||
* nothing, indefinitely, until something re-syncs. Confirmed live (2026-09-16): a brand-new group with a
|
||||
* genuinely passive, fully-connected, already-on-/messages member produced no change at all, even
|
||||
* several seconds after a real message was sent into it.
|
||||
*
|
||||
* There is nothing to subscribe to or react to for a topic this client doesn't know exists yet, so the
|
||||
* only thing a client can do is periodically ask "what's new" -- reusing the exact same incremental
|
||||
* `withLaterSub()` semantics subscribeMeTopic()/onReauthenticated() above already rely on, just issued
|
||||
* via `getMeta()` instead of `subscribe()`. `Topic.subscribe()` short-circuits once `_attached` is
|
||||
* already true (the normal, undisturbed state this is guarding, unlike the reconnect case above), but
|
||||
* `getMeta()` sends the `{get}` regardless of attach state -- the same distinction ConversationView's
|
||||
* own P2P desc/presence fix relies on.
|
||||
*/
|
||||
function reconcileMeSubs() {
|
||||
const me = getTinode().getMeTopic();
|
||||
if (!me.isSubscribed()) return;
|
||||
// Snapshot every currently-known topic's read-state fields before the fetch -- see the .then()
|
||||
// below for why the response can't be trusted at face value for topics already tracked (only for
|
||||
// genuinely new ones, which is this mitigation's entire reason to exist).
|
||||
const before = new Map<string, { seq: number; read: number; recv: number; unread: number }>();
|
||||
for (const c of getChatListSnapshot()) {
|
||||
if (c.topic) before.set(c.topic, { seq: c.seq || 0, read: c.read || 0, recv: c.recv || 0, unread: c.unread || 0 });
|
||||
}
|
||||
me.getMeta(me.startMetaQuery().withLaterSub().build())
|
||||
.then(() => {
|
||||
// Real, live-confirmed bug (2026-09-16, reported directly): tinode-sdk's own merge utility
|
||||
// (mergeObj in utils.js) has a defensive "keep the later value" rule for Date fields but NONE
|
||||
// for plain numbers -- seq/read/recv/unread just get blindly overwritten by whatever this
|
||||
// fetch's response says, confirmed by reading the source. If the server's view of a sub's read
|
||||
// cursor is even momentarily behind a `{note read}` the client already sent (e.g. this timer or
|
||||
// a focus/visibility trigger firing shortly after the user opened and read a conversation), this
|
||||
// silently un-reads it: the badge reappears with no new message behind it, and re-focusing the
|
||||
// window doesn't clear it either since nothing about it actually changed to re-trigger a fresh
|
||||
// read-mark -- confirmed reproducing exactly like this live, not a hypothetical race. Since plain
|
||||
// numbers can't defend themselves the way Dates do, restore anything that regressed for topics
|
||||
// already known BEFORE this fetch started, rather than trust the fetch there -- this mitigation
|
||||
// only needs to win for topics that were unknown before it (the actual gap it exists to close),
|
||||
// not overwrite ones a user has already read in the meantime.
|
||||
for (const c of getChatListSnapshot()) {
|
||||
if (!c.topic) continue;
|
||||
const prev = before.get(c.topic);
|
||||
if (!prev) {
|
||||
// Brand-new topic to this session -- exactly what this mitigation is for. A fresh member's
|
||||
// sub record can legitimately come back "already caught up" (read === seq) from the
|
||||
// server's own default, which would otherwise leave being newly added to a group producing
|
||||
// NO unread signal at all whenever it happens to have zero messages so far -- real,
|
||||
// reported UX gap ("被拉入群时title的消息没有提示"): the tab-title/favicon-badge (see lib/
|
||||
// notifications/tabIndicator.ts) are driven by this same unread count, so nothing tells the
|
||||
// user anything happened. Force it to read as genuinely unread from this client's own
|
||||
// perspective regardless of what the server defaulted it to.
|
||||
c.read = 0;
|
||||
c.unread = c.seq || 0;
|
||||
notifyTopic(c.topic);
|
||||
if (c.topic.startsWith('grp')) newGroupListeners.forEach((cb) => cb(c.topic!));
|
||||
continue;
|
||||
}
|
||||
const seq = c.seq || 0;
|
||||
const unread = c.unread || 0;
|
||||
if (seq <= prev.seq && unread > prev.unread) {
|
||||
c.read = prev.read;
|
||||
c.recv = prev.recv;
|
||||
c.unread = prev.unread;
|
||||
notifyTopic(c.topic);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the periodic timer plus tab-focus/visibility triggers (the common "switched away and came
|
||||
* back" case gets an immediate catch-up instead of waiting for the next tick). Call once per
|
||||
* authenticated session (RequireAuth.tsx) and invoke the returned function on logout/unmount -- safe
|
||||
* to start/stop repeatedly (React 19 StrictMode's mount->cleanup->remount included), unlike
|
||||
* subscribeMeTopic() itself, which is guarded separately against being re-fired.
|
||||
*/
|
||||
export function startMeReconciliation(): () => void {
|
||||
meReconcileTimer = setInterval(reconcileMeSubs, ME_RECONCILE_INTERVAL_MS);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') reconcileMeSubs();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('focus', reconcileMeSubs);
|
||||
return () => {
|
||||
if (meReconcileTimer) clearInterval(meReconcileTimer);
|
||||
meReconcileTimer = null;
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('focus', reconcileMeSubs);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@ import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// This app has no service worker of its own, but browsers that visited this origin before the
|
||||
// redesign/desktop-multicolumn rewrite may still have the old one registered (see
|
||||
// public/service-worker.js) -- force an immediate update check rather than waiting for the
|
||||
// browser's own lazy schedule, so it gets replaced by the kill switch right away.
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(regs => {
|
||||
regs.forEach(reg => reg.update())
|
||||
})
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
+21
-7
@@ -10,7 +10,7 @@ import {
|
||||
} 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 { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
|
||||
@@ -152,7 +152,12 @@ export const useCallStore = create<CallState>((set, get) => ({
|
||||
|
||||
hangUp: () => {
|
||||
const { topic, seq, callId, connectedAt } = get();
|
||||
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId);
|
||||
// `rejectCall()` right above already covers the callee-declines-before-accepting case, so
|
||||
// `hangUp()` seeing `!connectedAt` only ever means the CALLER is giving up on their own
|
||||
// still-ringing outgoing call (manual cancel, or the 60s ring-timeout below firing this same
|
||||
// function) -- IM-前后端对接文档.md's own wire example for exactly that case is
|
||||
// `{note event:"hang-up", payload:{reason:"cancelled"}}`, which this never actually sent.
|
||||
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, connectedAt ? undefined : 'cancelled');
|
||||
leaveCall();
|
||||
get()._endWithReason(connectedAt ? 'finished' : 'missed');
|
||||
},
|
||||
@@ -238,12 +243,21 @@ export function initCallSignaling() {
|
||||
subscribeAnyTopicChange(() => {
|
||||
const myUserId = useSessionStore.getState().myUserId;
|
||||
if (!myUserId) return;
|
||||
// NOT `getTinode().getMeTopic().contacts()` -- live-confirmed real bug this fixes (user-reported:
|
||||
// a whole incoming-call test milestone blocked, WS packet capture confirmed the invite genuinely
|
||||
// arrives, but no incoming-call UI ever appeared): `contacts()` reads the `me` topic's OWN
|
||||
// internally-tracked subscription list, populated only once `me`'s `{meta sub}` sync reports this
|
||||
// specific contact -- but a P2P topic can be fully subscribed and sitting in the SDK's topic cache
|
||||
// (able to receive and store a real incoming call invite) well before that sync catches up. This
|
||||
// exact gap is already documented on `getChatListSnapshot`'s own comment (topics.ts) as the reason
|
||||
// that function uses `mapTopics` instead -- this call-signaling listener just never got the same
|
||||
// fix when it was written. Confirmed live: a real two-account call, `me.contacts()` on the callee's
|
||||
// own live session enumerated ONE contact (a group) and silently omitted the caller entirely, while
|
||||
// `getChatListSnapshot()`'s own list correctly showed both.
|
||||
const peerNames = new Map<string, string>();
|
||||
getTinode()
|
||||
.getMeTopic()
|
||||
.contacts((c) => {
|
||||
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
|
||||
});
|
||||
for (const contact of getChatListSnapshot()) {
|
||||
if (contact.topic) peerNames.set(contact.topic, contact.public?.fn || contact.topic);
|
||||
}
|
||||
|
||||
for (const topicName of peerNames.keys()) {
|
||||
const messages = getMessagesSnapshot(topicName);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Replaces `window.confirm()` app-wide (README's own design language never intended a native
|
||||
* browser dialog to show up inside this UI -- it was just the fastest way to gate a destructive
|
||||
* action while the real dialog didn't exist yet). A global store + a single `<ConfirmDialogRoot>`
|
||||
* mounted once at the app root (see App.tsx), not a local `useState` per call site: this needs to be
|
||||
* callable from any event handler, including ones with no natural "own a piece of JSX for this"
|
||||
* home (e.g. GroupFilesModal's per-row delete button), the same reasoning `stores/calls.ts`/
|
||||
* `stores/messageJump.ts` already established for other "any component, any time" actions.
|
||||
*
|
||||
* `requestConfirm()` returns a Promise the same shape `window.confirm()`'s return value has (resolves
|
||||
* true/false, never rejects) so every existing `if (!window.confirm(...)) return;` call site becomes
|
||||
* `if (!(await requestConfirm({ message: ... }))) return;` with the enclosing function marked async --
|
||||
* a mechanical, low-risk edit, not a rewrite of the surrounding logic.
|
||||
*/
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Red confirm button for irreversible/destructive actions (delete, disband, deregister) --
|
||||
* omitted (default blue) for merely consequential ones (log out, switch tenant). */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface PendingConfirm extends ConfirmOptions {
|
||||
resolve: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface ConfirmDialogState {
|
||||
pending: PendingConfirm | null;
|
||||
respond: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useConfirmDialogStore = create<ConfirmDialogState>((set, get) => ({
|
||||
pending: null,
|
||||
respond: (value) => {
|
||||
get().pending?.resolve(value);
|
||||
set({ pending: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export function requestConfirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmDialogStore.setState({ pending: { ...options, resolve } });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Replaces `window.alert()` app-wide, same rationale as `stores/confirmDialog.ts`'s replacement of
|
||||
* `window.confirm()`: a global store + a single `<GlobalToastRoot>` mounted once at the app root
|
||||
* renders the app's own existing `Toast` component (already used everywhere for a mutation's error
|
||||
* state) instead of the browser's native dialog. `notify()` is deliberately fire-and-forget (no
|
||||
* Promise, no return value) -- an alert-style message never blocked on a user response the way
|
||||
* confirm did, so there's nothing to await.
|
||||
*/
|
||||
interface GlobalToastState {
|
||||
message: string;
|
||||
variant: 'success' | 'error';
|
||||
token: number;
|
||||
}
|
||||
|
||||
export const useGlobalToastStore = create<GlobalToastState>(() => ({
|
||||
message: '',
|
||||
variant: 'error',
|
||||
token: 0,
|
||||
}));
|
||||
|
||||
export function notify(message: string, variant: 'success' | 'error' = 'error') {
|
||||
// `token` (not just `message`) is what Toast's own auto-dismiss effect keys off of via the caller
|
||||
// -- see GlobalToastRoot -- so firing the SAME message twice in a row (e.g. two clicks on a
|
||||
// still-disabled "该功能暂未开放" button before the first toast dismisses) restarts the timer
|
||||
// instead of the second call being a no-op change that React skips re-rendering for.
|
||||
useGlobalToastStore.setState((s) => ({ message, variant, token: s.token + 1 }));
|
||||
}
|
||||
|
||||
/** Every existing `window.alert('该功能暂未开放,如需联系客服。')` call site (settings, composer
|
||||
* toolbar, add-friend, group/contact detail panels) used the identical hand-typed Chinese string --
|
||||
* one shared helper instead of that string living independently in five files that could each drift
|
||||
* from the others if the wording ever needs to change. */
|
||||
export function notImplemented() {
|
||||
notify('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Transient cross-component instruction: `lib/notifications/push.ts` lives outside React (it reacts
|
||||
* to raw SDK topic events, same as topics.ts itself) and has no way to call `useNavigate()` directly
|
||||
* when a desktop Notification is clicked. Matches this codebase's established pattern for exactly
|
||||
* this shape of problem (stores/calls.ts, stores/messageJump.ts) rather than threading a navigate
|
||||
* callback down into a non-React module.
|
||||
*/
|
||||
interface NotificationNavState {
|
||||
pendingTopic: string | null;
|
||||
requestNavigate: (topic: string) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useNotificationNavStore = create<NotificationNavState>((set) => ({
|
||||
pendingTopic: null,
|
||||
requestNavigate: (topic) => set({ pendingTopic: topic }),
|
||||
clear: () => set({ pendingTopic: null }),
|
||||
}));
|
||||
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { SecurityChallengeScene } from '@/lib/api/security';
|
||||
|
||||
/**
|
||||
* Prompts for the protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766)
|
||||
* and resolves with the resulting `action_token`, or `null` if the user cancels -- same Promise
|
||||
* shape/global-store-plus-single-root-component pattern as `confirmDialog.ts`'s `requestConfirm`, for
|
||||
* the same reason: this needs to be callable from a mutation's `mutationFn` (GroupDetailColumn's
|
||||
* dissolve flow, currently the only caller) with no natural "own a piece of JSX for this" home.
|
||||
*/
|
||||
export interface SecurityChallengeOptions {
|
||||
scene: SecurityChallengeScene;
|
||||
/** What's about to happen if the user completes verification -- e.g. "解散群聊「群名」". */
|
||||
actionDescription: string;
|
||||
}
|
||||
|
||||
interface PendingChallenge extends SecurityChallengeOptions {
|
||||
resolve: (token: string | null) => void;
|
||||
}
|
||||
|
||||
interface SecurityChallengeState {
|
||||
pending: PendingChallenge | null;
|
||||
respond: (token: string | null) => void;
|
||||
}
|
||||
|
||||
export const useSecurityChallengeStore = create<SecurityChallengeState>((set, get) => ({
|
||||
pending: null,
|
||||
respond: (token) => {
|
||||
get().pending?.resolve(token);
|
||||
set({ pending: null });
|
||||
},
|
||||
}));
|
||||
|
||||
export function requestActionToken(options: SecurityChallengeOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
useSecurityChallengeStore.setState({ pending: { ...options, resolve } });
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@ export default defineConfig({
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 8094,
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 8094,
|
||||
// Same-origin dev proxy for the REST layer only -- avoids CORS entirely (the browser only ever
|
||||
// sees http://localhost:8094) so a resolved tenant's *relative* endpoints ("/v1/im", "/v0/file")
|
||||
// work unmodified in local dev, same idea as the old app's hand-rolled dev-proxy-server.js, just
|
||||
|
||||
Reference in New Issue
Block a user