认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
min-[900px]:block hidden
- 企业码输入框补maxLength=64
图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立
顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑
通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底
消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
token),改成语义正确的unread token;录音条文字被连带染红,只保留
指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
不是text-tertiary-3)都和设计稿不符,已修正
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
173 lines
8.4 KiB
TypeScript
173 lines
8.4 KiB
TypeScript
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';
|
|
import { DetailColumn } from '@/features/messages/DetailColumn';
|
|
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
|
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
|
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
|
|
import type { MediaType } from '@/lib/api/messages';
|
|
import { getMe } from '@/lib/api/profile';
|
|
import { useMediaQuery } from '@/lib/hooks';
|
|
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
|
|
|
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
|
|
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
|
|
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
|
|
* read on every render. */
|
|
function ConversationDetailPanel({
|
|
isGroup,
|
|
topicId,
|
|
peerName,
|
|
onClose,
|
|
onOpenHistory,
|
|
}: {
|
|
isGroup: boolean;
|
|
topicId: string;
|
|
peerName: string;
|
|
onClose?: () => void;
|
|
onOpenHistory: (tab: 'text' | MediaType) => void;
|
|
}) {
|
|
return isGroup ? (
|
|
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
|
|
) : (
|
|
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
|
|
);
|
|
}
|
|
|
|
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
|
|
|
|
function readPersistedDetailPanelOpen(): boolean {
|
|
try {
|
|
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The 4-tier responsive split described in the design handoff:
|
|
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
|
|
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
|
|
* provide one at this tier -- see ConversationView).
|
|
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
|
|
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
|
|
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
|
|
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
|
|
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
|
|
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
|
|
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
|
|
* affordance at all).
|
|
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
|
|
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
|
|
* to dock or drawer it in without covering the conversation entirely anyway.
|
|
*/
|
|
export function MessagesLayout() {
|
|
const { topicId } = useParams();
|
|
const location = useLocation();
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
|
|
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
|
|
const isDocked = useMediaQuery('(min-width: 1280px)');
|
|
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
|
|
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
|
|
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
|
|
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
|
|
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
|
|
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
|
|
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
|
|
// and effects for no visible reason, and briefly having two real instances of the same panel alive
|
|
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
|
|
// that isn't safe to run twice.
|
|
const isMdUp = useMediaQuery('(min-width: 960px)');
|
|
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
|
|
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
|
|
// to the same list-only view as the bare `/messages` index at narrow widths.
|
|
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
|
|
const contacts = useChatList();
|
|
const tinode = useTinode();
|
|
// `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;
|
|
|
|
const toggleDetail = () => {
|
|
if (isDocked) {
|
|
setDetailPanelOpen((v) => {
|
|
const next = !v;
|
|
try {
|
|
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
|
|
} catch {
|
|
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
|
|
// toggle itself still works for the rest of this session.
|
|
}
|
|
return next;
|
|
});
|
|
} else {
|
|
setDetailOpen((v) => !v);
|
|
}
|
|
};
|
|
|
|
// MessagesLayout is one long-lived instance reused across every conversation (route is
|
|
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
|
|
// showing results for whichever topic it was opened against, not the one now on screen.
|
|
useLayoutEffect(() => setHistoryTab(null), [topicId]);
|
|
|
|
return (
|
|
<div className="relative flex h-full w-full">
|
|
<div className="relative flex h-full min-w-0 flex-1">
|
|
<div className="absolute inset-x-0 top-0 z-10">
|
|
<OfflineBanner />
|
|
</div>
|
|
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
|
<ChatListColumn />
|
|
</div>
|
|
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
|
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
|
|
</div>
|
|
</div>
|
|
{isDocked && detailPanelOpen && topicId ? (
|
|
<div className="hidden h-full lg:block">
|
|
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
|
|
</div>
|
|
) : null}
|
|
{!isDocked && isMdUp && detailOpen && topicId ? (
|
|
<div className="absolute inset-0 hidden md:block lg:hidden">
|
|
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
|
|
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
|
|
<ConversationDetailPanel
|
|
isGroup={isGroup}
|
|
topicId={topicId}
|
|
peerName={peerName}
|
|
onClose={() => setDetailOpen(false)}
|
|
onOpenHistory={setHistoryTab}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{!isDocked && !isMdUp && detailOpen && topicId ? (
|
|
<div className="fixed inset-0 z-30 bg-white md:hidden">
|
|
<ConversationDetailPanel
|
|
isGroup={isGroup}
|
|
topicId={topicId}
|
|
peerName={peerName}
|
|
onClose={() => setDetailOpen(false)}
|
|
onOpenHistory={setHistoryTab}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{historyTab && topicId ? (
|
|
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|