- {/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
-
+ {isGroup ? (
+
+ {mine ? '我' : senderName || msg.from}
+
+
+ ) : null}
+ {/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
+ items-center (not -end) so the icon stays vertically centered on the bubble regardless of
+ how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
+ -- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
+ the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
+
{failed ?
: null}
- {isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
+ {isRecalled(msg) ? (
+ '⚠ 消息已删除'
+ ) : callResult ? (
+
+
+ {callResult.text}
+
+ ) : contactCard ? (
+
+ ) : (
+ renderDrafty(msg.content)
+ )}
{failed ? (
diff --git a/web/src/features/messages/MessageContextMenu.tsx b/web/src/features/messages/MessageContextMenu.tsx
index 45eb540b..1c79cafd 100644
--- a/web/src/features/messages/MessageContextMenu.tsx
+++ b/web/src/features/messages/MessageContextMenu.tsx
@@ -90,7 +90,7 @@ export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAc
);
})}
- {trailing.length ?
: null}
+ {trailing.length ?
: null}
{trailing.map((action) => {
const item = ITEMS[action];
return (
diff --git a/web/src/features/messages/MessagesLayout.tsx b/web/src/features/messages/MessagesLayout.tsx
index a5b4f96e..909fd705 100644
--- a/web/src/features/messages/MessagesLayout.tsx
+++ b/web/src/features/messages/MessagesLayout.tsx
@@ -1,38 +1,111 @@
import { useLayoutEffect, useState } from 'react';
-import { Outlet, useParams } from 'react-router-dom';
+import { Outlet, useLocation, useParams } from 'react-router-dom';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
import { DetailColumn } from '@/features/messages/DetailColumn';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { OfflineBanner } from '@/features/messages/OfflineBanner';
+import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
import type { MediaType } from '@/lib/api/messages';
import { useMediaQuery } from '@/lib/hooks';
-import { useChatList } from '@/lib/tinode/hooks';
+import { useChatList, useTinode } from '@/lib/tinode/hooks';
+
+/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
+ * `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
+ * the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
+ * read on every render. */
+function ConversationDetailPanel({
+ isGroup,
+ topicId,
+ peerName,
+ onClose,
+ onOpenHistory,
+}: {
+ isGroup: boolean;
+ topicId: string;
+ peerName: string;
+ onClose?: () => void;
+ onOpenHistory: (tab: 'text' | MediaType) => void;
+}) {
+ return isGroup ? (
+
+ ) : (
+
+ );
+}
+
+const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
+
+function readPersistedDetailPanelOpen(): boolean {
+ try {
+ return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
/**
* The 4-tier responsive split described in the design handoff:
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
* provide one at this tier -- see ConversationView).
- * - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon.
- * - >=1280: list + main + detail all docked side by side.
+ * - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
+ * (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
+ * - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
+ * closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
+ * -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
+ * diagram, not an always-on panel with no way to close it; that was this layout's own bug until
+ * a user report caught it (the "i" button did nothing at this width, and the panel had no close
+ * affordance at all).
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
* to dock or drawer it in without covering the conversation entirely anyway.
*/
export function MessagesLayout() {
const { topicId } = useParams();
+ const location = useLocation();
const [detailOpen, setDetailOpen] = useState(false);
+ const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const isDocked = useMediaQuery('(min-width: 1280px)');
- const showListOnNarrow = !topicId;
+ // Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
+ // three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
+ // `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
+ // tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
+ // are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
+ // the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
+ // the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
+ // and effects for no visible reason, and briefly having two real instances of the same panel alive
+ // is exactly the kind of thing that turns into a real bug the moment either one has a side effect
+ // that isn't safe to run twice.
+ const isMdUp = useMediaQuery('(min-width: 960px)');
+ // `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
+ // it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
+ // to the same list-only view as the bare `/messages` index at narrow widths.
+ const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
const contacts = useChatList();
+ const tinode = useTinode();
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
- // The "i" button should read as active whenever the detail panel is actually visible right now --
- // always true when docked (>=1280px, no close affordance), otherwise whatever the drawer/full-page
- // toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
- const detailVisible = isDocked || detailOpen;
+ const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
+ const detailVisible = isDocked ? detailPanelOpen : detailOpen;
+
+ const toggleDetail = () => {
+ if (isDocked) {
+ setDetailPanelOpen((v) => {
+ const next = !v;
+ try {
+ localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
+ } catch {
+ // Private-browsing/quota errors just mean the preference won't survive a reload -- the
+ // toggle itself still works for the rest of this session.
+ }
+ return next;
+ });
+ } else {
+ setDetailOpen((v) => !v);
+ }
+ };
// MessagesLayout is one long-lived instance reused across every conversation (route is
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
@@ -49,28 +122,42 @@ export function MessagesLayout() {