Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975dc77111 | ||
|
|
6bcfc08943 | ||
|
|
3d48abdf73 | ||
|
|
9abae5030b | ||
|
|
e3eed8bdd4 | ||
|
|
aca5f8609b | ||
|
|
1cc6f5068a | ||
|
|
95c0677b55 | ||
|
|
4cd2b33c53 | ||
|
|
e7cb4edf3a | ||
|
|
1d383b4e93 | ||
|
|
ed2920275d | ||
|
|
0ba5c97d78 | ||
|
|
105ff76959 | ||
|
|
eb0a6eba7e | ||
|
|
0ace395cfb | ||
|
|
ef31eb2e88 | ||
|
|
2427a3782c | ||
|
|
05ee23aefa | ||
|
|
2d589cbe6b | ||
|
|
304283c8d8 | ||
|
|
cbb01085c4 | ||
|
|
a8d3504424 | ||
|
|
fb92f4f703 | ||
|
|
4961c0ae6e | ||
|
|
6a65998e42 | ||
|
|
0c38adba78 | ||
|
|
370bf9d5b0 | ||
|
|
1127680425 | ||
|
|
ed029c59ef | ||
|
|
74351af905 | ||
|
|
ca7af8baf9 | ||
|
|
4dadb1bd5a | ||
|
|
1952f0ebcd | ||
|
|
41728017cb | ||
|
|
7cf8e4f4e0 | ||
|
|
0331cb099c | ||
|
|
f4a6ff36b4 | ||
|
|
5748f1290d | ||
|
|
0de8c0b5ac | ||
|
|
f5a512b8c9 | ||
|
|
1a4a96461a | ||
|
|
b4675b70e1 | ||
|
|
74f4e753ef | ||
|
|
7fff1a7b3a |
@@ -834,3 +834,6 @@ export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/re
|
||||
export const submitAbuseReport = body => imPost('/reports/abuse', body);
|
||||
export const getFeedbackConfig = () => imGet('/feedback/config');
|
||||
export const submitFeedback = body => imPost('/feedback', {client_context: clientContext(), ...body});
|
||||
|
||||
// -- Contact us (admin 字典 im_contact_us 透传,含邮箱/电话条目;后台修改后经缓存失效近实时生效). --
|
||||
export const getContactUs = () => imGet('/contact-us');
|
||||
|
||||
@@ -3,6 +3,12 @@ import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
|
||||
import { getContactUs } from '../lib/im-api.js';
|
||||
|
||||
// Admin-managed "contact us" entries (dictionary type im_contact_us): emails get mailto:, phone
|
||||
// numbers get tel:, anything else renders as plain (non-navigating) text.
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const PHONE_PATTERN = /^\+?[0-9][0-9\- ]{4,}$/;
|
||||
|
||||
// Renders as a real link when a destination is configured, or as inert (non-navigating) text
|
||||
// otherwise -- same treatment auth-onboarding-view.jsx already gives its own placeholder
|
||||
@@ -14,13 +20,42 @@ function FlatLink({href, icon, children}) {
|
||||
}
|
||||
|
||||
export default class AccSupportView extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
contactItems: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// Entries come from the im-server /contact-us endpoint (admin dictionary im_contact_us).
|
||||
// On failure the static config fallback below stays in place; no toast for a support page.
|
||||
getContactUs().then(data => {
|
||||
const items = (data && Array.isArray(data.items)) ?
|
||||
data.items.filter(it => it && it.label && it.value) : [];
|
||||
this.setState({contactItems: items});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {contactItems} = this.state;
|
||||
return (
|
||||
<div className="scrollable-panel">
|
||||
<div className="panel-form-column">
|
||||
<FlatLink href={LINK_CONTACT_US} icon="email">
|
||||
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
||||
</FlatLink>
|
||||
{contactItems.length > 0 ?
|
||||
contactItems.map((item, idx) => {
|
||||
const value = item.value;
|
||||
const href = EMAIL_PATTERN.test(value) ? 'mailto:' + value :
|
||||
(PHONE_PATTERN.test(value) ? 'tel:' + value.replace(/[^\d+]/g, '') : null);
|
||||
return <FlatLink key={idx} href={href} icon={href && href.startsWith('tel:') ? 'call' : 'email'}>
|
||||
{item.label}
|
||||
</FlatLink>;
|
||||
})
|
||||
:
|
||||
<FlatLink href={LINK_CONTACT_US} icon="email">
|
||||
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
||||
</FlatLink>
|
||||
}
|
||||
<FlatLink href={LINK_TERMS_OF_SERVICE} icon="description">
|
||||
<FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
|
||||
</FlatLink>
|
||||
|
||||
Generated
+1965
File diff suppressed because it is too large
Load Diff
+27
-1
@@ -1,9 +1,10 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { Navigate, createBrowserRouter, RouterProvider, useRouteError } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
|
||||
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
|
||||
@@ -34,6 +35,30 @@ import { SystemMessagesView } from '@/features/system/SystemMessagesView';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* Without this, an uncaught render-time exception ANYWHERE under `<AppShell>` (any page, any one
|
||||
* bad field in server data) falls through to react-router's own built-in default error boundary --
|
||||
* real, not hypothetical (confirmed live: `node_modules/react-router` DOES implicitly wrap the
|
||||
* outermost matched route the way this codebase never configured one to). That default shows
|
||||
* "Unexpected Application Error!" plus the raw error message/stack to the end user and replaces the
|
||||
* WHOLE logged-in shell, sidebar included, with no way back except a full page reload. Attached to
|
||||
* AppShell (not each leaf route) so it covers every authenticated page with one component, and low
|
||||
* enough that `RequireAuth`/login/tenant-entry are never affected by a bug in, say, 收藏.
|
||||
*/
|
||||
function RouteErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
if (import.meta.env.DEV) console.error(error);
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col items-center justify-center gap-3 bg-surface-page px-6 text-center">
|
||||
<Icon name="error_outline" className="text-[40px] text-text-tertiary-3" />
|
||||
<p className="text-sm text-text-secondary">页面出现异常,请刷新重试</p>
|
||||
<button type="button" onClick={() => window.location.reload()} className="rounded-lg bg-accent px-4 py-2 text-sm text-white">
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
@@ -45,6 +70,7 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
element: <AppShell />,
|
||||
errorElement: <RouteErrorBoundary />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/messages" replace /> },
|
||||
{
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { UserCardRoot } from '@/app/UserCardRoot';
|
||||
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 { subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||
import { initCallSignaling } from '@/stores/calls';
|
||||
import { useNotificationNavStore } from '@/stores/notificationNav';
|
||||
|
||||
@@ -35,6 +37,23 @@ export function AppShell() {
|
||||
useEffect(() => {
|
||||
initCallSignaling();
|
||||
}, []);
|
||||
const queryClient = useQueryClient();
|
||||
// Friend-request realtime signals (Zentao #3): after a request is created/accepted/rejected the
|
||||
// server pushes `{info what:"im" event:"friend.request.*"}` to the recipient's `me` topic
|
||||
// (publishIMRelationEvent -> sessionStore.DeliverToUser). Nothing here used to listen for it --
|
||||
// the SDK's _routeInfo hits its default case for what:"im" and only fires the onInfo chain -- so
|
||||
// the 新朋友 badge/list stayed stale until a manual refresh or relogin. Subscribe once per app
|
||||
// session (same shape as initCallSignaling above) and invalidate the shared ['friend-requests']
|
||||
// query namespace: ContactsListColumn's pending badge (['friend-requests','inbound','pending'])
|
||||
// and NewFriendsView's list (['friend-requests','all']) both refetch on invalidation. Listening
|
||||
// here rather than in those views keeps the badge live no matter which page the user is on.
|
||||
useEffect(() => {
|
||||
return subscribeTopicInfo((_topicName, info) => {
|
||||
if (info?.event?.startsWith('friend.request.')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
||||
}
|
||||
});
|
||||
}, [queryClient]);
|
||||
// "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
|
||||
// in case it ever also shows up as a regular Tinode subscription in `contacts`, which would
|
||||
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
|
||||
@@ -80,6 +99,7 @@ export function AppShell() {
|
||||
</div>
|
||||
</div>
|
||||
<CallRoot />
|
||||
<UserCardRoot />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+20
-6
@@ -20,14 +20,28 @@ interface AvatarProps {
|
||||
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
|
||||
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
|
||||
* avatar produced zero visible change anywhere, including on the very page the upload happened.
|
||||
* `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
|
||||
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
|
||||
* used for chat attachments) expects.
|
||||
* `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape
|
||||
* `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat
|
||||
* attachments) reconstructed -- but live-confirmed (2026-09-18): default-avatar placeholders come
|
||||
* back as an ALREADY-COMPLETE OSS URL in this same field (e.g.
|
||||
* "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/im-assets/default-avatars/male/4.png"). A first
|
||||
* fix attempt stopped re-wrapping that URL into the malformed "/v0/file/s/https://..." shape but
|
||||
* still routed it through `useAuthedBlobUrl`'s authenticated fetch, which then failed when tested
|
||||
* from a local dev origin -- turned out to be a false lead: the OSS bucket's CORS rule (checked
|
||||
* directly in the Aliyun console) DOES allow this app's auth headers, just scoped to the real
|
||||
* `web/web1-5.118im.com` origins, which a local dev server was never going to match regardless of
|
||||
* bucket config (same class of mistake this codebase has hit before testing OSS URLs from
|
||||
* localhost). The actual reason to skip `useAuthedBlobUrl` for an absolute URL isn't "CORS is
|
||||
* broken" -- it's that the auth headers it attaches are meaningless to OSS for a public asset in the
|
||||
* first place, so the fetch-to-blob round trip (plus its preflight) is pure overhead; render the URL
|
||||
* directly as a plain `<img src>` instead, same as any other publicly-loadable image.
|
||||
*/
|
||||
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}`} />;
|
||||
const isAbsoluteUrl = !!avatarFileId && /^https?:\/\//.test(avatarFileId);
|
||||
const remote = useAuthedBlobUrl(avatarFileId && !isAbsoluteUrl ? `/v0/file/s/${avatarFileId}` : undefined);
|
||||
const resolvedUrl = isAbsoluteUrl ? avatarFileId : remote.url;
|
||||
if (resolvedUrl) {
|
||||
return <img src={resolvedUrl} alt="" className={`object-cover ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getUserProfile } from '@/lib/api/friends';
|
||||
import { getMyProfile } from '@/lib/api/profile';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { closeUserCard, useUserCardStore } from '@/stores/userCard';
|
||||
|
||||
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `openUserCard()` (stores/userCard.ts)
|
||||
* most recently asked for. A lighter, faster-to-reach sibling of FriendProfileView's full `/contacts/
|
||||
* :userId` page (same `getUserProfile` data source, same relationship-aware action gating), not a
|
||||
* replacement for it -- "查看完整资料" below still routes there for remark-editing/QR code/etc. */
|
||||
export function UserCardRoot() {
|
||||
const userId = useUserCardStore((s) => s.userId);
|
||||
if (!userId) return null;
|
||||
// key={userId} so switching straight from one card to another (tapping a different @mention
|
||||
// before closing the first) resets the query/local state instead of showing stale data mid-fetch.
|
||||
return <UserCardPanel key={userId} userId={userId} />;
|
||||
}
|
||||
|
||||
function UserCardPanel({ userId }: { userId: string }) {
|
||||
const navigate = useNavigate();
|
||||
// Registered here, not in the always-mounted UserCardRoot above: useEscapeKey's stack (lib/hooks.ts)
|
||||
// orders entries by MOUNT time, so an entry registered once at app boot would sit permanently at
|
||||
// the bottom, below every modal that mounts later -- a real, reproduced bug (open GroupMembersModal
|
||||
// -> tap a member row to open this card on top of it -> Escape closed the member-list modal
|
||||
// underneath instead of this card). UserCardPanel only mounts while a card is genuinely open, same
|
||||
// as every other overlay's own useEscapeKey call, so this now stacks correctly.
|
||||
useEscapeKey(closeUserCard);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const isSelf = !!myUserId && myUserId === userId;
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
|
||||
// Real, live-confirmed bug (2026-09-25 review): `GET /users/{id}/profile` 400s on a SELF lookup,
|
||||
// same self-lookup gap already documented and fixed elsewhere for the group-scoped equivalent
|
||||
// (getMyGroupProfile's own comment, `lib/api/groups.ts`) -- tapping your OWN avatar (reachable from
|
||||
// the member grid/list like anyone else's) always landed on the "加载失败,请重试" branch, retrying
|
||||
// into the same 400 forever. Routes self through the dedicated `GET /me/profile` instead, which has
|
||||
// no such restriction; normalized into the same shape the render below already expects so the two
|
||||
// branches don't need two separate JSX trees.
|
||||
const otherQuery = useQuery({
|
||||
queryKey: ['user-profile', userId],
|
||||
queryFn: () => getUserProfile(userId),
|
||||
enabled: !isSelf,
|
||||
});
|
||||
const selfQuery = useQuery({
|
||||
queryKey: ['my-profile-card', userId],
|
||||
queryFn: () => getMyProfile(),
|
||||
enabled: isSelf,
|
||||
});
|
||||
const query = isSelf ? selfQuery : otherQuery;
|
||||
const profile = isSelf
|
||||
? selfQuery.data
|
||||
? {
|
||||
user_id: userId,
|
||||
display_name: selfQuery.data.nickname,
|
||||
avatar_file_id: selfQuery.data.avatar_file_id,
|
||||
gender: selfQuery.data.gender,
|
||||
region: selfQuery.data.region,
|
||||
signature: selfQuery.data.signature,
|
||||
public_account: selfQuery.data.public_account,
|
||||
relationship: 'self',
|
||||
remark: undefined as string | undefined,
|
||||
capabilities: undefined as { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean } | undefined,
|
||||
}
|
||||
: undefined
|
||||
: otherQuery.data;
|
||||
const name = profile ? profile.remark || profile.display_name : '';
|
||||
// Real bug this fixes: this card's own doc comment always claimed "same relationship-aware action
|
||||
// gating" as FriendProfileView.tsx, but until now it never read `profile.relationship` at all --
|
||||
// tapping a non-friend's avatar (this card is reachable from many more places than the friends-only
|
||||
// surfaces FriendProfileView is, e.g. any group member's avatar or an @mention) showed an enabled
|
||||
// "发消息"/call row with no add-friend affordance anywhere, gated only by `capabilities.can_send`/
|
||||
// `can_call` -- a signal ConversationView.tsx's own comment already documents as unreliable for
|
||||
// exactly this purpose (it 403s from a blocked party's own side instead of reporting false). Mirrors
|
||||
// FriendProfileView's own isFriend/isPendingOutbound/isPendingInbound branching exactly, just at
|
||||
// this card's single (not two-size-variant) button size.
|
||||
const isFriend = profile?.relationship === 'friend';
|
||||
const isPendingOutbound = profile?.relationship === 'pending_outbound';
|
||||
const isPendingInbound = profile?.relationship === 'pending_inbound';
|
||||
|
||||
const goToFullProfile = () => {
|
||||
closeUserCard();
|
||||
navigate(isSelf ? '/settings/profile' : `/contacts/${userId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={closeUserCard}>
|
||||
<div className="w-[300px] overflow-hidden rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeUserCard}
|
||||
className="grid h-7 w-7 place-items-center rounded-full text-text-tertiary-2 hover:bg-surface-input"
|
||||
>
|
||||
<Icon name="close" className="text-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-44 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !profile ? (
|
||||
<div className="flex h-44 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-text-tertiary-4">
|
||||
{query.error instanceof ImApiError && query.error.status === 403 ? (
|
||||
'对方设置了资料保护,暂时无法查看'
|
||||
) : query.error instanceof ImApiError && query.error.status === 404 ? (
|
||||
'未找到该用户'
|
||||
) : (
|
||||
<>
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center px-6 pb-6">
|
||||
<Avatar id={profile.user_id} name={name} avatarFileId={profile.avatar_file_id} className="h-18 w-18 flex-none rounded-2xl text-xl" />
|
||||
<strong className="mt-3 max-w-full truncate text-base font-semibold text-text-heading">{name}</strong>
|
||||
{profile.remark ? <span className="mt-0.5 max-w-full truncate text-[13px] text-text-tertiary-2">昵称:{profile.display_name}</span> : null}
|
||||
{profile.gender || profile.region ? (
|
||||
<span className="mt-0.5 text-[13px] text-text-tertiary-2">
|
||||
{[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
{profile.signature ? <p className="m-0 mt-2 line-clamp-3 text-center text-sm text-text-secondary">{profile.signature}</p> : null}
|
||||
|
||||
{!isSelf ? (
|
||||
<div className="mt-5 flex w-full items-center gap-2">
|
||||
{isFriend ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_send === false}
|
||||
onClick={() => {
|
||||
closeUserCard();
|
||||
navigate(`/messages/${profile.user_id}`);
|
||||
}}
|
||||
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:opacity-50"
|
||||
>
|
||||
<Icon name="chat_bubble" className="text-base" />
|
||||
发消息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase !== 'idle') return;
|
||||
closeUserCard();
|
||||
useCallStore.getState().startCall(profile.user_id, name, 'audio');
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="call" className="text-base" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase !== 'idle') return;
|
||||
closeUserCard();
|
||||
useCallStore.getState().startCall(profile.user_id, name, 'video');
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="videocam" className="text-base" />
|
||||
</button>
|
||||
</>
|
||||
) : isPendingOutbound ? (
|
||||
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
|
||||
等待验证
|
||||
</button>
|
||||
) : isPendingInbound ? (
|
||||
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
|
||||
对方已申请加你
|
||||
</button>
|
||||
) : profile.capabilities?.can_add_friend === false ? (
|
||||
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
|
||||
对方暂不可添加
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingFriend(true)}
|
||||
className="h-9 flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm"
|
||||
>
|
||||
添加好友
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="button" onClick={goToFullProfile} className="mt-3 text-[13px] text-accent">
|
||||
查看完整资料
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* A true sibling of the card's own backdrop above, not nested inside it: AddFriendModal is
|
||||
itself a dismissible `fixed inset-0` overlay with its own backdrop-click-to-close handler,
|
||||
and React events bubble -- nesting it inside the card's backdrop (which ALSO closes on any
|
||||
click) would make clicking outside AddFriendModal close both overlays at once instead of
|
||||
just the top one. */}
|
||||
{addingFriend ? (
|
||||
<AddFriendModal
|
||||
onClose={() => setAddingFriend(false)}
|
||||
initialTarget={
|
||||
profile
|
||||
? { user_id: profile.user_id, display_name: profile.display_name, public_account: profile.public_account, avatar_file_id: profile.avatar_file_id }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ function MobileIncomingCall() {
|
||||
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
||||
* unmounted between full-screen and minimized). */
|
||||
function MinimizedCallBar() {
|
||||
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
||||
const { topic, peerName, connectedAt, mediaJoined, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
return (
|
||||
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
||||
@@ -141,7 +141,10 @@ function MinimizedCallBar() {
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
||||
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
||||
{/* `mediaJoined`, not bare `connectedAt` -- signaling accept flips `connectedAt` before
|
||||
media has actually joined (see stores/calls.ts's own comment on this field), so this
|
||||
used to claim "通话中" for a call whose mic never actually attached. */}
|
||||
<span className="text-xs text-success">{connectedAt && mediaJoined ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
|
||||
@@ -165,6 +168,7 @@ function CallWindow() {
|
||||
phase,
|
||||
peerName,
|
||||
connectedAt,
|
||||
mediaJoined,
|
||||
muted,
|
||||
cameraOff,
|
||||
hasRemoteVideo,
|
||||
@@ -189,7 +193,11 @@ function CallWindow() {
|
||||
|
||||
if (minimized) return <MinimizedCallBar />;
|
||||
|
||||
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
|
||||
// Signaling `connected` and actual media both being up are two different things (see stores/
|
||||
// calls.ts's `mediaJoined` comment) -- a real "网络良好" claim needs the second one too, and the
|
||||
// brief signaling-only window in between gets its own label rather than either overstating the
|
||||
// connection or reusing the pre-answer ringing text.
|
||||
const statusLabel = phase === 'connected' ? (mediaJoined ? `${formatElapsed(elapsed)} · 网络良好` : '正在连接中…') : '正在等待对方接听…';
|
||||
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
|
||||
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
|
||||
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
|
||||
@@ -241,9 +249,13 @@ function CallWindow() {
|
||||
<strong className="text-base font-semibold">{peerName}</strong>
|
||||
<span className="flex items-center gap-1 text-xs text-white/70">
|
||||
{phase === 'connected' ? (
|
||||
<>
|
||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||
</>
|
||||
mediaJoined ? (
|
||||
<>
|
||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||
</>
|
||||
) : (
|
||||
'正在连接中…'
|
||||
)
|
||||
) : (
|
||||
'正在等待对方接听…'
|
||||
)}
|
||||
@@ -294,7 +306,7 @@ function CallWindow() {
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
|
||||
<span className="text-sm text-white/70">{phase === 'connected' && mediaJoined ? '对方已关闭摄像头' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
||||
@@ -307,12 +319,12 @@ function CallWindow() {
|
||||
</span>
|
||||
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-1 text-sm text-white/70">
|
||||
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
|
||||
{phase === 'connected' ? (mediaJoined ? formatElapsed(elapsed) : '正在连接中…') : '正在等待对方接听…'}
|
||||
</span>
|
||||
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
||||
above); mobile silently dropped it, showing only the elapsed-time line with no
|
||||
indication of WHY there's no video. */}
|
||||
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
||||
{phase === 'connected' && mediaJoined ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -144,7 +144,19 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
|
||||
// Only show a second line when it says something the title line doesn't already -- a
|
||||
// hostname alongside a real title/url is useful, but repeating `text` under itself when
|
||||
// that's ALSO what the title line fell back to would just be the same line twice.
|
||||
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text;
|
||||
// `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from
|
||||
// another client's own Drafty implementation or the backend's own extraction logic (docs/
|
||||
// IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single
|
||||
// malformed value here must not crash the whole render (there's no error boundary around this
|
||||
// list short of react-router's own default one, which would replace the entire logged-in UI).
|
||||
let subtitle = title === text ? '' : text;
|
||||
if (item.snapshot.url) {
|
||||
try {
|
||||
subtitle = new URL(item.snapshot.url).hostname;
|
||||
} catch {
|
||||
// fall through to the plain-text fallback computed above
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
{item.snapshot.thumbnail_file_id ? (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getAllGroupMembers, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
||||
import { getAllGroupMembers, getLatestGroupAnnouncement, readGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
@@ -24,12 +24,15 @@ interface GroupAnnouncementModalProps {
|
||||
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
|
||||
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
|
||||
* -- resolved here against the group's member list, same as any other bare user id in this API.
|
||||
* And `read_count`/`member_count` are confirmed ABSENT (not just unconfirmed), so dropped rather
|
||||
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
|
||||
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
|
||||
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
|
||||
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
|
||||
* of the empty state).
|
||||
* When nothing has been published yet, the endpoint still returns `200` with every field
|
||||
* zero-valued, never `404`/`null` -- checked below via `content` emptiness, not object existence (a
|
||||
* real bug this file had before this fix: `!query.data` was never true, so it rendered a blank
|
||||
* "announcement" -- empty name, empty date -- instead of the empty state).
|
||||
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
|
||||
* added them (see groups.ts's own comment on `GroupAnnouncement`), so this now renders "已读N/M" as
|
||||
* README §4.8 originally called for, and calls `readGroupAnnouncement` on open -- that REST endpoint
|
||||
* existed in groups.ts since before this fix but was never actually called from anywhere, so opening
|
||||
* an announcement never told the server "this member has read it" at all.
|
||||
*/
|
||||
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -42,6 +45,22 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
|
||||
|
||||
// Fire-and-forget: viewing the announcement IS "reading" it, matching how the P2P/group message
|
||||
// read receipt (native `{note what:"read"}`) already works elsewhere in this app -- no explicit
|
||||
// "mark as read" button anywhere in the mock. Guarded on a real, non-empty `announcement_id` so
|
||||
// this doesn't fire for the zero-valued "nothing published yet" response, and keyed on that id (not
|
||||
// just "modal opened") so re-opening the SAME announcement doesn't re-send needlessly, though the
|
||||
// endpoint's own idempotency key means it would be harmless either way.
|
||||
const announcementId = query.data?.announcement_id;
|
||||
useEffect(() => {
|
||||
if (!announcementId) return;
|
||||
// Invalidate (not just fire-and-forget) so THIS viewer's own read immediately bumps the
|
||||
// displayed count instead of showing a stale pre-read number until some unrelated refetch.
|
||||
readGroupAnnouncement(topicId, announcementId)
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['group-announcement', topicId] }))
|
||||
.catch(() => {});
|
||||
}, [topicId, announcementId, queryClient]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
||||
onSuccess: (data) => {
|
||||
@@ -120,6 +139,19 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou
|
||||
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
||||
</div>
|
||||
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
|
||||
{/* README §4.8. Both the flat fields and `read_summary` carry the same two numbers
|
||||
(confirmed reading the backend's own payload builder) -- prefer the flat ones,
|
||||
fall back to the nested object in case a future response only sends one shape. */}
|
||||
{(() => {
|
||||
const readCount = query.data.read_count ?? query.data.read_summary?.read_count;
|
||||
const memberCount = query.data.member_count ?? query.data.read_summary?.member_count;
|
||||
if (readCount == null || memberCount == null) return null;
|
||||
return (
|
||||
<p className="m-0 mt-2 text-xs text-text-tertiary-3">
|
||||
已读 {readCount}/{memberCount}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal
|
||||
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
||||
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
||||
import {
|
||||
canRemoveMember,
|
||||
disbandGroup,
|
||||
getGroup,
|
||||
getMyGroupProfile,
|
||||
@@ -19,18 +20,22 @@ import {
|
||||
getGroupSettings,
|
||||
getGroupSummary,
|
||||
leaveGroup,
|
||||
updateGroup,
|
||||
updateGroupMemberSettings,
|
||||
updateMyGroupNickname,
|
||||
type GroupMember,
|
||||
type GroupProfile,
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/groups';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||
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';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
interface GroupDetailColumnProps {
|
||||
topicId: string;
|
||||
@@ -68,6 +73,7 @@ function RoleCorner({ role }: { role: GroupMember['role'] }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
|
||||
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
|
||||
@@ -81,6 +87,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [nicknameEditing, setNicknameEditing] = useState(false);
|
||||
const [nicknameDraft, setNicknameDraft] = useState('');
|
||||
// `updateGroup` (lib/api/groups.ts) has always supported renaming a group -- confirmed live no UI
|
||||
// anywhere ever called it (grep across the whole repo: zero call sites), so a group's name was
|
||||
// fixed forever at whatever was typed into CreateGroupModal's input at creation time, with no way
|
||||
// to fix a typo or rename it later. Owner-only, matching this file's other high-consequence group-
|
||||
// level (as opposed to per-member) actions.
|
||||
const [nameEditing, setNameEditing] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
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);
|
||||
@@ -90,6 +103,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
useLayoutEffect(() => {
|
||||
setNicknameEditing(false);
|
||||
setNicknameDraft('');
|
||||
setNameEditing(false);
|
||||
setNameDraft('');
|
||||
setErrorToast('');
|
||||
setOpenModal(null);
|
||||
}, [topicId]);
|
||||
@@ -152,6 +167,25 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
onError: (err) => showError(err, '昵称保存失败'),
|
||||
});
|
||||
|
||||
const nameMutation = useMutation({
|
||||
mutationFn: (name: string) => updateGroup(topicId, { name, version: groupQuery.data?.version ?? 1 }),
|
||||
// Merge, not replace -- same defensive reasoning as updateSettings above, in case this endpoint
|
||||
// ever turns out to return a partial object like member-settings did.
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
|
||||
setNameEditing(false);
|
||||
// The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from
|
||||
// this REST call either -- only from the `group.settings.updated` push topics.ts listens for,
|
||||
// which this account won't receive for its own action. `data` here is already the fresh REST
|
||||
// response, so apply it locally via `_processMetaDesc` (live-confirmed to reach `me.contacts()`,
|
||||
// see topics.ts's comment on the same call) instead of an extra `getMeta(...withDesc())` round
|
||||
// trip -- that SDK-sanctioned path was live-instrumented and found to reliably time out (504) or
|
||||
// return stale data against this backend, so it's not used anywhere in this fix anymore.
|
||||
tinode.getTopic(topicId)._processMetaDesc({ public: { fn: data.name, name: data.name } });
|
||||
},
|
||||
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
|
||||
@@ -180,7 +214,16 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
});
|
||||
|
||||
const clearHistory = async () => {
|
||||
if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return;
|
||||
// See DetailColumn.tsx's identical clearHistory for why this copy changed: a soft delete
|
||||
// (`delMessagesAll(false)`) is a real, per-account server watermark (im_be's Topic.replyDelMsg),
|
||||
// not a local-only cache wipe a refresh or another device would undo.
|
||||
if (
|
||||
!(await requestConfirm({
|
||||
message: '确定清空本群的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响其他成员。',
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await tinode.getTopic(topicId).delMessagesAll(false);
|
||||
} catch (err) {
|
||||
@@ -209,6 +252,14 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
|
||||
const members = membersQuery.data?.items || [];
|
||||
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
|
||||
// Real gap this fixes: `isAdmin` alone gates the "-移出" tile below, but canRemoveMember (the
|
||||
// per-row check the tile's own destination list uses) is strictly finer -- an admin can't remove
|
||||
// the owner or another admin, only the owner can remove an admin. A group with no plain members
|
||||
// (e.g. [owner, admin(me), admin(other)]) would show the tile yet land on a list with zero "移出"
|
||||
// buttons anywhere, a dead end. Checked against the same preview page the compact grid above
|
||||
// already renders from (not exhaustive for a group with more members than that page holds, but
|
||||
// that's the same tradeoff this component's own member grid already makes, not a new one).
|
||||
const canRemoveAnyone = isAdmin && members.some((m) => canRemoveMember(myUserId, myRole || 'member', m));
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
|
||||
@@ -259,8 +310,49 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
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>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{nameEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||||
disabled={!nameDraft.trim() || nameMutation.isPending}
|
||||
className="flex-none text-sm text-accent disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" onClick={() => setNameEditing(false)} className="flex-none text-sm text-text-tertiary-2">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||||
{/* Owner-only -- renaming a group is a group-level action, not a per-member one
|
||||
like the 我在本群的昵称 row further down, so this follows this file's other
|
||||
owner-gated actions (转让群主/解散群聊) rather than opening it to admins too. */}
|
||||
{isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="修改群名"
|
||||
onClick={() => {
|
||||
setNameDraft(groupQuery.data?.name || '');
|
||||
setNameEditing(true);
|
||||
}}
|
||||
className="flex-none text-text-tertiary-3"
|
||||
>
|
||||
<Icon name="edit" className="text-[16px]" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary-2">
|
||||
群号 {topicId} · {memberCount} 人
|
||||
</span>
|
||||
@@ -290,7 +382,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
</button>
|
||||
<div className="grid grid-cols-5 gap-2 md:mt-2">
|
||||
{members.slice(0, 9).map((m) => (
|
||||
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => openUserCard(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). */}
|
||||
<Avatar
|
||||
@@ -301,7 +398,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
/>
|
||||
<RoleCorner role={m.role} />
|
||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{canInvite ? (
|
||||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
||||
@@ -317,6 +414,22 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
<span className="text-[11px] text-text-secondary">邀请</span>
|
||||
</button>
|
||||
) : null}
|
||||
{canRemoveAnyone ? (
|
||||
// Sibling tile to "+邀请" right above -- same dashed-square treatment, mirrored icon.
|
||||
// A single standalone entry point into the plain member list (not a per-avatar corner
|
||||
// badge, and not a direct remove action) -- the actual "移出" control already lives on
|
||||
// each removable row inside that list. Explicit user correction after a first attempt
|
||||
// put a "-" badge on every removable avatar's own corner: that read as "many separate
|
||||
// remove buttons" when the intent was one "+"-shaped entry point, mirroring "+邀请".
|
||||
// Gated on canRemoveAnyone (not bare isAdmin) so the tile doesn't show in a group made
|
||||
// entirely of owner+admins with nobody actually removable -- a dead end otherwise.
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
|
||||
<Icon name="remove" className="text-[18px]" />
|
||||
</span>
|
||||
<span className="text-[11px] text-text-secondary">移出</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
|
||||
查看全部成员 ({memberCount})
|
||||
@@ -450,6 +563,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
||||
<GroupMembersModal
|
||||
topicId={topicId}
|
||||
canInvite={canInvite}
|
||||
myUserId={myUserId}
|
||||
myRole={myRole || 'member'}
|
||||
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
|
||||
@@ -219,7 +219,13 @@ export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupF
|
||||
isPdf={pdf}
|
||||
open={openMenuId === f.group_file_id}
|
||||
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
|
||||
onClose={() => setOpenMenuId(null)}
|
||||
// Functional update, not a plain `setOpenMenuId(null)` -- this fires from
|
||||
// useClickOutside's window-level listener, which runs AFTER another row's own
|
||||
// onToggle already ran in the same click (React's synthetic onToggle always
|
||||
// precedes a window listener in the bubble order). An unconditional null would
|
||||
// clobber that other row's freshly-opened state; only clear if this row is
|
||||
// still the one actually open.
|
||||
onClose={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : cur))}
|
||||
onDownload={() => downloadFile(f)}
|
||||
onOpen={() => openFileInNewTab(f)}
|
||||
onDelete={async () => {
|
||||
|
||||
@@ -6,9 +6,11 @@ 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 GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
|
||||
import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
|
||||
@@ -28,6 +30,10 @@ 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;
|
||||
/** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember, lib/api/groups.ts),
|
||||
* mirroring the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */
|
||||
myUserId: string | null;
|
||||
myRole: GroupRole;
|
||||
/** "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 "邀请"
|
||||
@@ -40,9 +46,10 @@ interface GroupMembersModalProps {
|
||||
|
||||
/** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by
|
||||
* `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to
|
||||
* an existing group, not just at creation time). No "移出成员" affordance here: this protocol has
|
||||
* no documented REST or WS operation for removing a group member at all (grepped the whole
|
||||
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
|
||||
* an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no
|
||||
* documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added
|
||||
* `DELETE /groups/{topic}/members/{user_id}` (see removeGroupMember's own comment), so this is no
|
||||
* longer a backend gap. */
|
||||
/** 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
|
||||
@@ -58,12 +65,18 @@ const OUTCOME_LABEL: Record<InviteOutcome, string> = {
|
||||
failed: '邀请失败',
|
||||
};
|
||||
|
||||
export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) {
|
||||
|
||||
export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [inviting, setInviting] = useState(initialView === 'invite');
|
||||
const [error, setError] = useState('');
|
||||
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
|
||||
// Per-row "移出中" tracking -- `removeMutation` is one shared mutation instance, so its own
|
||||
// `.variables`/`.isPending` only ever reflects the MOST RECENT call. Removing member A then quickly
|
||||
// also removing member B would make A's button-disabled/"移出中…" state resolve back to normal the
|
||||
// moment B's click fires, even though A's own request may still be in flight.
|
||||
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
@@ -100,6 +113,16 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members',
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupMember(topicId, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '移出失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -173,11 +196,43 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members',
|
||||
) : (
|
||||
filtered.map((m) => (
|
||||
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
</button>
|
||||
<RoleTag role={m.role} />
|
||||
{canRemoveMember(myUserId, myRole, m) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={removingIds.has(m.user_id)}
|
||||
onClick={async () => {
|
||||
const label = m.display_name || m.user_id;
|
||||
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
|
||||
setRemovingIds((prev) => new Set(prev).add(m.user_id));
|
||||
removeMutation.mutate(m.user_id, {
|
||||
onSettled: () =>
|
||||
setRemovingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(m.user_id);
|
||||
return next;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex flex-none cursor-pointer items-center gap-0.5 text-xs text-danger-text hover:underline disabled:pointer-events-none disabled:cursor-default disabled:opacity-60 disabled:hover:no-underline"
|
||||
>
|
||||
{removingIds.has(m.user_id) ? (
|
||||
'移出中…'
|
||||
) : (
|
||||
<>
|
||||
<Icon name="remove_circle" className="text-sm" />
|
||||
移出
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||
import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
|
||||
interface ImageData {
|
||||
@@ -10,12 +10,156 @@ interface ImageData {
|
||||
mime?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Drafty.insertImage's own `filename` field -- present on every image this WEB client sent
|
||||
* (see sendImage in lib/tinode/attachments.ts). */
|
||||
filename?: string;
|
||||
/** mobileAttachmentContentOf's `name` -- the equivalent field on an image sent from the iOS/Android
|
||||
* app, which flows through a completely different (non-Drafty) flat shape than the web client's
|
||||
* own `IM` entity above; MessageBubble passes that content straight through as this same ImageData
|
||||
* shape, so both need a name field here even though the two clients don't call it the same thing. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export function ImageAttachment({ data }: { data: ImageData }) {
|
||||
/** Best-effort filename for the "保存图片" download below -- prefers whatever name the sender's own
|
||||
* client attached (see ImageData's own field comments), falling back to a generic name derived from
|
||||
* the mime type for the inline-base64/mobile-relay cases where neither field is set. */
|
||||
function imageDownloadName(data: ImageData): string {
|
||||
if (data.filename) return data.filename;
|
||||
if (data.name) return data.name;
|
||||
const ext = data.mime?.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
|
||||
return `image.${ext}`;
|
||||
}
|
||||
|
||||
const LIGHTBOX_ZOOM_MIN = 1;
|
||||
const LIGHTBOX_ZOOM_MAX = 4;
|
||||
const LIGHTBOX_ZOOM_STEP = 0.5;
|
||||
|
||||
/** Full-screen "查看大图" overlay opened by tapping a chat image -- `src` is the SAME blob/data URI
|
||||
* ImageAttachment already resolved (real full-resolution image; the 260x170 thumbnail is only a CSS
|
||||
* crop of it, not a separately-fetched smaller asset), so opening this needs no extra network work.
|
||||
* Zoom is CSS transform scale/translate on the `<img>` itself, not a native browser zoom -- wheel
|
||||
* and the +/- buttons zoom around the center; dragging only does anything once zoomed in, otherwise
|
||||
* the image has nowhere to pan to and a drag gesture would just fight the backdrop's click-to-close. */
|
||||
function ImageLightbox({ src, filename, onClose }: { src: string; filename: string; onClose: () => void }) {
|
||||
const [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
|
||||
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const clampZoom = (z: number) => Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, z));
|
||||
const applyZoom = (next: number) => {
|
||||
const clamped = clampZoom(next);
|
||||
setZoom(clamped);
|
||||
if (clamped === LIGHTBOX_ZOOM_MIN) setPan({ x: 0, y: 0 });
|
||||
};
|
||||
const zoomIn = () => applyZoom(zoom + LIGHTBOX_ZOOM_STEP);
|
||||
const zoomOut = () => applyZoom(zoom - LIGHTBOX_ZOOM_STEP);
|
||||
const toggleZoom = () => applyZoom(zoom > LIGHTBOX_ZOOM_MIN ? LIGHTBOX_ZOOM_MIN : LIGHTBOX_ZOOM_MAX);
|
||||
|
||||
// Native listener with `passive:false`, not JSX `onWheel` -- React's synthetic wheel handler isn't
|
||||
// guaranteed non-passive across versions/browsers, and a passive listener's preventDefault() is
|
||||
// silently ignored, which would leave the page scrolling underneath the lightbox while "zooming".
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return undefined;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
applyZoom(zoom + (e.deltaY < 0 ? LIGHTBOX_ZOOM_STEP : -LIGHTBOX_ZOOM_STEP));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-binds each render so onWheel always
|
||||
// closes over the current `zoom`; the listener itself is cheap to re-add and this avoids a stale
|
||||
// closure that would zoom from an outdated level.
|
||||
}, [zoom]);
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom <= LIGHTBOX_ZOOM_MIN) return;
|
||||
e.stopPropagation();
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
|
||||
};
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.startX), y: dragRef.current.panY + (e.clientY - dragRef.current.startY) });
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80" onClick={onClose}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= LIGHTBOX_ZOOM_MIN}
|
||||
aria-label="缩小"
|
||||
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
|
||||
>
|
||||
<Icon name="zoom_out" className="text-[20px]" />
|
||||
</button>
|
||||
<span className="min-w-[3.5em] text-center text-sm text-white">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= LIGHTBOX_ZOOM_MAX}
|
||||
aria-label="放大"
|
||||
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
|
||||
>
|
||||
<Icon name="zoom_in" className="text-[20px]" />
|
||||
</button>
|
||||
{/* Real anchor + `download`, same technique FileAttachment already relies on below -- works
|
||||
for both inline `data:` URIs (small images) and the `blob:` URL useAuthedBlobUrl fetches
|
||||
for larger ones, triggering a normal save-to-disk with no extra round trip. User report
|
||||
(老板, 2026-09-29): "图片也没有保存功能" -- right-click "另存为" technically already works on
|
||||
an `<img>`, but wasn't discoverable enough; this puts an explicit, visible affordance here,
|
||||
same place every mainstream IM's own "查看大图" view puts it. */}
|
||||
<a
|
||||
href={src}
|
||||
download={filename}
|
||||
aria-label="保存图片"
|
||||
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white"
|
||||
>
|
||||
<Icon name="download" className="text-[20px]" />
|
||||
</a>
|
||||
<button type="button" onClick={onClose} aria-label="关闭" className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white">
|
||||
<Icon name="close" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag
|
||||
target inside an already-dismissible dialog; alt="" is correct here, same reasoning as
|
||||
ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom();
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
|
||||
className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same
|
||||
* reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same
|
||||
* thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so
|
||||
* entering select mode doesn't visually change every image bubble in the list. */
|
||||
export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
const [viewing, setViewing] = useState(false);
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
@@ -25,7 +169,16 @@ export function ImageAttachment({ data }: { data: ImageData }) {
|
||||
);
|
||||
}
|
||||
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
|
||||
return <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
|
||||
const thumb = <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
|
||||
if (locked) return thumb;
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setViewing(true)} className="block">
|
||||
{thumb}
|
||||
</button>
|
||||
{viewing ? <ImageLightbox src={src} filename={imageDownloadName(data)} onClose={() => setViewing(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
@@ -36,7 +189,11 @@ interface FileData {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function FileAttachment({ data }: { data: FileData }) {
|
||||
/** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row,
|
||||
* not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in
|
||||
* new tab" aren't specially guarded against, matching this codebase's existing level of rigor for
|
||||
* other locked-during-selectMode controls). */
|
||||
export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) {
|
||||
const style = fileTypeStyle(data.name, data.mime);
|
||||
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
|
||||
@@ -47,7 +204,9 @@ export function FileAttachment({ data }: { data: FileData }) {
|
||||
href={href}
|
||||
download={data.name}
|
||||
onClick={(e) => {
|
||||
if (remote.error) {
|
||||
if (locked) {
|
||||
e.preventDefault();
|
||||
} else if (remote.error) {
|
||||
e.preventDefault();
|
||||
remote.retry();
|
||||
} else if (!href) {
|
||||
@@ -79,6 +238,7 @@ interface VideoData {
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
preview?: string;
|
||||
previewRef?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
@@ -92,12 +252,23 @@ function formatDuration(ms: number | undefined): string {
|
||||
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
|
||||
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
|
||||
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
|
||||
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
|
||||
export function VideoAttachment({ data }: { data: VideoData }) {
|
||||
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
|
||||
*
|
||||
* `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
|
||||
* since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
|
||||
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
|
||||
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
|
||||
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
|
||||
// Mobile-sent videos (see mobileAttachmentContentOf) have no inline base64 poster at all -- their
|
||||
// thumbnail is a separately-hosted file (`previewRef`, the same `/v0/file/s/{id}` shape as the
|
||||
// video body itself), not the base64 `preview` field this component was built around for the web
|
||||
// client's own Drafty `VD` entities (confirmed live, favorites' identical shape). Only fetched when
|
||||
// there's no inline poster, same short-circuit `remote` above already uses for the video body.
|
||||
const posterRemote = useAuthedBlobUrl(posterInline ? undefined : data.previewRef);
|
||||
const posterUrl = posterInline || posterRemote.url;
|
||||
const videoSrc = videoInline || remote.url;
|
||||
|
||||
if (playing && videoSrc) {
|
||||
@@ -107,15 +278,18 @@ export function VideoAttachment({ data }: { data: VideoData }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying(true)}
|
||||
onClick={() => {
|
||||
if (locked) return;
|
||||
setPlaying(true);
|
||||
}}
|
||||
disabled={!videoSrc}
|
||||
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
||||
>
|
||||
{posterInline ? (
|
||||
<img src={posterInline} alt="" className="h-full w-full object-cover" />
|
||||
{posterUrl ? (
|
||||
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
||||
<Icon name={posterRemote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
@@ -133,7 +307,11 @@ interface AudioData {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
/** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
|
||||
* so the click still bubbles to the row toggle instead of being swallowed by the native button.
|
||||
* Ongoing playback started BEFORE selectMode was entered is left alone -- this only blocks a NEW
|
||||
* toggle action. */
|
||||
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
@@ -154,6 +332,7 @@ export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
}, [audioEl]);
|
||||
|
||||
const toggle = () => {
|
||||
if (locked) return;
|
||||
if (!audioEl || !src) return;
|
||||
if (playing) {
|
||||
audioEl.pause();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
@@ -17,20 +17,21 @@ 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, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
|
||||
import { favoriteMessages, forwardMessages, 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';
|
||||
import { isRecalled } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { availableActions, isConfirmedSeq, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
|
||||
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
||||
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
|
||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||
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 { sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import {
|
||||
closeConversation,
|
||||
ensureTopicAttached,
|
||||
@@ -541,6 +542,38 @@ export function ConversationView() {
|
||||
enabled: isGroup,
|
||||
});
|
||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
||||
// `memberByUserId` only ever holds CURRENT members (`getAllGroupMembers`) -- a historical message
|
||||
// from someone who has since left/been removed has no entry there, and used to fall all the way
|
||||
// back to the raw `msg.from` uid string in the bubble (confirmed live, 2026-09-20: a real message
|
||||
// from a member who then left the group rendered as "usrp77kUAkgqpU" instead of "systest02").
|
||||
// Fetching each such uid's standalone profile (same `getUserProfile`/`['user-profile', id]` P2P
|
||||
// already uses above) recovers a real display name/avatar in the common case; `retry:false` and a
|
||||
// swallowed error mean a stranger this account has no visibility into (403, same as the already-
|
||||
// documented `usrW9ijv236hHA` case) just falls back to the uid exactly as before -- never worse.
|
||||
const staleSenderIds = useMemo(() => {
|
||||
if (!isGroup) return [] as string[];
|
||||
const ids = new Set<string>();
|
||||
for (const msg of messages) {
|
||||
if (msg.from && !memberByUserId.has(msg.from)) ids.add(msg.from);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}, [isGroup, messages, memberByUserId]);
|
||||
const staleSenderQueries = useQueries({
|
||||
queries: staleSenderIds.map((uid) => ({
|
||||
queryKey: ['user-profile', uid],
|
||||
queryFn: () => getUserProfile(uid),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
const staleSenderById = useMemo(() => {
|
||||
const map = new Map<string, { display_name: string; avatar_file_id?: string }>();
|
||||
staleSenderIds.forEach((uid, i) => {
|
||||
const data = staleSenderQueries[i]?.data;
|
||||
if (data) map.set(uid, data);
|
||||
});
|
||||
return map;
|
||||
}, [staleSenderIds, staleSenderQueries]);
|
||||
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
|
||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||
const groupSublineParts = [
|
||||
@@ -568,10 +601,22 @@ export function ConversationView() {
|
||||
// second, divergent resolution path.
|
||||
const quotedSenderLabel = (fromUserId: string | undefined) => {
|
||||
if (myUserId && fromUserId === myUserId) return '我';
|
||||
if (isGroup && fromUserId) return memberByUserId.get(fromUserId)?.display_name || fromUserId;
|
||||
if (isGroup && fromUserId) {
|
||||
return groupSenderLabel(memberByUserId.get(fromUserId)?.display_name, staleSenderById.get(fromUserId)?.display_name);
|
||||
}
|
||||
return peerName;
|
||||
};
|
||||
|
||||
/** Arms a media send with the pending quote and clears the composer's reply state in one step:
|
||||
* the quote applies to the NEXT outgoing message only (cross-client rule), and a picked image
|
||||
* or recorded voice is that message. Returns null when nothing is armed or the quoted message
|
||||
* has become unquotable in the meantime (send proceeds as a plain media message). */
|
||||
const consumePendingReply = () => {
|
||||
const pending = replyTo ? buildReplyInfo(replyTo) : null;
|
||||
setReplyTo(null);
|
||||
return pending;
|
||||
};
|
||||
|
||||
/**
|
||||
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
|
||||
* which seq ranges are actually missing from the local cache within the currently-visible window
|
||||
@@ -635,7 +680,7 @@ export function ConversationView() {
|
||||
// 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));
|
||||
return sendReply(target, text, pendingReplyTo);
|
||||
}
|
||||
if (pendingMentions.length > 0) {
|
||||
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
|
||||
@@ -773,24 +818,54 @@ export function ConversationView() {
|
||||
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
|
||||
// actually said first.
|
||||
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
||||
for (const targetTopic of targetTopics) {
|
||||
// A target that isn't the conversation currently open (i.e. almost any real forward target)
|
||||
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
|
||||
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
|
||||
// edge case.
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => {
|
||||
for (const seq of orderedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox
|
||||
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
|
||||
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
|
||||
// with a misleading "forwarded from" tag rather than the message the user actually picked.
|
||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'));
|
||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox itself
|
||||
// refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled
|
||||
// mid-session) -- forwarding null content would silently send an empty bubble with a misleading
|
||||
// "forwarded from" tag rather than the message the user actually picked. Checkbox selection also
|
||||
// doesn't refuse a still-sending/failed message (its placeholder seq is a plain `number`, same as
|
||||
// a real one) -- that message has no server-side (topic, seq) yet for forwardMessages() to find.
|
||||
const sources = orderedSeqs
|
||||
.map((seq) => topic.findMessage(seq))
|
||||
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg) && isConfirmedSeq(msg))
|
||||
.map((msg) => ({ topic: topicId, seq: msg.seq as number }));
|
||||
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
|
||||
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
|
||||
// `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing
|
||||
// subscription/attachment; the server handles every pair in one request with real per-pair
|
||||
// success/failure reporting instead of each WS publish failing silently on its own).
|
||||
//
|
||||
// The comment send is chained AFTER this promise settles, not fired in parallel -- confirmed
|
||||
// live (2026-09-25) that firing both at once is a real, reproducible bug, not a theoretical one:
|
||||
// the endpoint's response comes back with per-item `status:"pending"`/`reason:
|
||||
// "MESSAGE_PERSIST_PENDING"` (it acks the request before the forwarded messages are actually
|
||||
// written server-side), while the comment's own sendText() goes over the plain WS publish path,
|
||||
// which is fast enough to land FIRST. A 2-source bulk forward reordered exactly this way in a
|
||||
// live test before this fix (comment appeared above both forwarded messages). Chaining at least
|
||||
// waits for the request round-trip before starting the comment send, which resolved the
|
||||
// reordering in the same live scenario after this fix -- see this function's own follow-up
|
||||
// verification, not a guarantee for every possible load/latency condition given the endpoint's
|
||||
// own async "pending" semantics.
|
||||
const forwardDone =
|
||||
sources.length > 0
|
||||
? forwardMessages(sources, targetTopics)
|
||||
.then((res) => {
|
||||
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`);
|
||||
// 'sent'/'pending' both mean the server has it; 'uncertain' (e.g. a timeout mid-write)
|
||||
// does not -- silently lumping it in with success would leave the user with no signal
|
||||
// that this particular copy may never actually land in the target conversation.
|
||||
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
|
||||
if (uncertain > 0) notify(`${uncertain} 条转发结果暂不确定,请稍后在目标会话中确认`);
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'))
|
||||
: Promise.resolve();
|
||||
if (comment) {
|
||||
forwardDone.then(() => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => sendText(target, comment))
|
||||
.catch((err) => showError(err, '附言发送失败'));
|
||||
}
|
||||
});
|
||||
}
|
||||
setBulkForwardOpen(false);
|
||||
exitSelectMode();
|
||||
@@ -801,9 +876,22 @@ export function ConversationView() {
|
||||
setMenu(null);
|
||||
if (!msg || typeof msg.seq !== 'number') return;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
|
||||
case 'copy': {
|
||||
// Real, live-confirmed bug: a flat non-Drafty text message (e.g. the group-announcement
|
||||
// auto-post, `{text:"..."}` not `{txt:"..."}`) isn't excluded from 'copy' by availableActions
|
||||
// (it reads fine in the bubble via renderDrafty's own flatTextContentOf fallback -- only this
|
||||
// handler's `Drafty.toPlainText` didn't know about it), so "复制" silently put the literal
|
||||
// string "undefined" on the clipboard instead of the text visibly shown on screen.
|
||||
// Final `?? ''` is real, not paranoia: `availableActions` only excludes 'copy' for a mobile
|
||||
// attachment whose payload passes `mobileAttachmentContentOf`'s own validation (needs a real
|
||||
// `file_id`/`ref`) -- a malformed/partial attachment payload that fails that check falls
|
||||
// through to here with neither a `.text` nor a `.txt` field, and `Drafty.toPlainText` would
|
||||
// return `undefined` again, which `writeText` coerces to the literal string "undefined" same
|
||||
// as the original bug -- this stops that regardless of exactly which shape slips through.
|
||||
const flatText = flatTextContentOf(msg.content);
|
||||
navigator.clipboard.writeText(flatText ?? Drafty.toPlainText(msg.content ?? '') ?? '').catch(() => {});
|
||||
break;
|
||||
}
|
||||
case 'recall':
|
||||
recallMessage(topicId, msg.seq)
|
||||
.then(() => topic.delMessagesList([msg.seq], true))
|
||||
@@ -833,13 +921,42 @@ export function ConversationView() {
|
||||
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) sendImage(topic, file).catch((err) => showError(err, '图片发送失败'));
|
||||
if (file) {
|
||||
const reply = consumePendingReply();
|
||||
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败'));
|
||||
if (file) {
|
||||
const reply = consumePendingReply();
|
||||
sendFile(topic, file, reply).catch((err) => showError(err, '文件发送失败'));
|
||||
}
|
||||
};
|
||||
|
||||
/** User report (老板, 2026-09-29): "没有复制粘贴图片功能,只能保存在电脑里面发" -- e.g. a screenshot
|
||||
* tool, or "复制图片" from a browser/chat app, only ever puts raw pixel data on the clipboard, never
|
||||
* a File the hidden `<input type="file">` above can see; without this, getting that image into a
|
||||
* message required saving it to disk first and picking it via handlePickImage. `item.getAsFile()`
|
||||
* returns a real File, indistinguishable from one chosen through the file picker, so this reuses
|
||||
* the exact same send path. Only preventDefault once an actual image item is found -- an ordinary
|
||||
* text paste (no image on the clipboard at all) is left completely untouched. */
|
||||
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
e.preventDefault();
|
||||
const reply = consumePendingReply();
|
||||
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -943,8 +1060,13 @@ 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 staleSender = isGroup && msg.from && !sender ? staleSenderById.get(msg.from) : undefined;
|
||||
const mine = !!myUserId && msg.from === myUserId;
|
||||
const rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId;
|
||||
const rowAvatarFileId = mine
|
||||
? myAvatarFileId
|
||||
: isGroup
|
||||
? sender?.avatar_file_id || staleSender?.avatar_file_id
|
||||
: peerAvatarFileId;
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
@@ -953,7 +1075,7 @@ export function ConversationView() {
|
||||
mine={mine}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
senderName={sender?.display_name}
|
||||
senderName={sender?.display_name || staleSender?.display_name}
|
||||
senderRole={sender?.role}
|
||||
avatarFileId={rowAvatarFileId}
|
||||
selectMode={selectMode}
|
||||
@@ -962,6 +1084,7 @@ export function ConversationView() {
|
||||
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
|
||||
onToggleSelect={toggleSelect}
|
||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||
resolveQuotedName={quotedSenderLabel}
|
||||
onError={setErrorToast}
|
||||
/>
|
||||
);
|
||||
@@ -1002,7 +1125,7 @@ export function ConversationView() {
|
||||
{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">
|
||||
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
|
||||
回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||||
<Icon name="close" className="text-[16px]" />
|
||||
@@ -1097,7 +1220,8 @@ export function ConversationView() {
|
||||
onCancel={() => setRecording(false)}
|
||||
onSend={(blob, durationMs) => {
|
||||
setRecording(false);
|
||||
sendVoice(topic, blob, durationMs).catch((err) => showError(err, '语音发送失败'));
|
||||
const reply = consumePendingReply();
|
||||
sendVoice(topic, blob, durationMs, reply).catch((err) => showError(err, '语音发送失败'));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -1113,6 +1237,7 @@ export function ConversationView() {
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handleComposerPaste}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
@@ -1147,13 +1272,29 @@ export function ConversationView() {
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwardMsg(null)}
|
||||
onPick={(targetTopics, comment) => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => {
|
||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'));
|
||||
// Comment is chained AFTER the forward call settles, not fired in parallel -- see
|
||||
// `bulkForward`'s own comment above for why this matters (confirmed live: firing both
|
||||
// at once lets the comment's plain WS send outrace the forward endpoint's own async
|
||||
// "pending" persistence, landing the comment BEFORE the forwarded content it's meant to
|
||||
// follow).
|
||||
const forwardDone =
|
||||
isConfirmedSeq(forwardMsg)
|
||||
? forwardMessages([{ topic: topicId, seq: forwardMsg.seq as number }], targetTopics)
|
||||
.then((res) => {
|
||||
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`);
|
||||
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
|
||||
if (uncertain > 0) notify(`${uncertain} 个目标转发结果暂不确定,请稍后在目标会话中确认`);
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'))
|
||||
: Promise.resolve();
|
||||
if (comment) {
|
||||
forwardDone.then(() => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => sendText(target, comment))
|
||||
.catch((err) => showError(err, '附言发送失败'));
|
||||
}
|
||||
});
|
||||
}
|
||||
setForwardMsg(null);
|
||||
}}
|
||||
|
||||
@@ -135,7 +135,15 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||
// `['blocks']` alone left the composer's "你已将对方加入黑名单/对方已将你加入黑名单" banner (driven
|
||||
// by a SEPARATE query, `['conversation-context', topicId]` in ConversationView.tsx) showing the
|
||||
// pre-mutation state until something ELSE happened to refetch it -- live-confirmed: toggling block
|
||||
// then re-checking the same conversation immediately still showed the old banner/composer state,
|
||||
// and it only ever caught up after an incidental `refetchOnWindowFocus` (switching tabs and back).
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['conversation-context', topicId] });
|
||||
},
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
@@ -152,7 +160,19 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
||||
});
|
||||
|
||||
const clearHistory = async () => {
|
||||
if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return;
|
||||
// The old copy here ("此操作仅清除本地记录") was actively misleading -- confirmed by reading
|
||||
// the real backend handler (im_be's Topic.replyDelMsg): a soft delete (`hardDel=false`, what
|
||||
// `delMessagesAll(false)` sends) is NOT a browser-local cache wipe a refresh would undo. The
|
||||
// server records a per-account delete watermark (`t.perUser[asUid].delID`) that persists across
|
||||
// every device/session on this account and isn't reversible through any normal client action --
|
||||
// only "does it affect the other party" (no) was actually true about the old wording.
|
||||
if (
|
||||
!(await requestConfirm({
|
||||
message: '确定清空与该联系人的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响对方。',
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,10 +20,9 @@ interface ForwardPickerModalProps {
|
||||
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
|
||||
* forward -- all three previously shared this component when it only supported picking ONE target
|
||||
* and sending immediately with no comment, meaning forwarding to several people required repeating
|
||||
* the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts
|
||||
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
|
||||
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
|
||||
* so callers loop `targetTopics` themselves -- this component only collects the selection.
|
||||
* the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message
|
||||
* forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side
|
||||
* in one batched call -- this component only collects the selection and comment; callers send.
|
||||
*/
|
||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||
const contacts = useChatList();
|
||||
|
||||
@@ -8,7 +8,9 @@ import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
@@ -67,10 +69,44 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
|
||||
* through rather than picking fields back out of it by hand. The previous version's manual
|
||||
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
||||
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
||||
* search against a real plain-text message showed before this fix. */
|
||||
* search against a real plain-text message showed before this fix.
|
||||
*
|
||||
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
|
||||
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
|
||||
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
|
||||
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
|
||||
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
|
||||
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
|
||||
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
|
||||
* on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape
|
||||
* distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already
|
||||
* document for message bubbles. */
|
||||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||
const content = item.snapshot?.content;
|
||||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
|
||||
const mobileAttachment = mobileAttachmentContentOf(asMessage);
|
||||
if (mobileAttachment) {
|
||||
const label =
|
||||
mobileAttachment.kind === 'image'
|
||||
? '[图片]'
|
||||
: mobileAttachment.kind === 'video'
|
||||
? '[视频]'
|
||||
: mobileAttachment.kind === 'voice'
|
||||
? '[语音]'
|
||||
: `[文件] ${mobileAttachment.name || ''}`;
|
||||
return <span className="text-text-body">{label}</span>;
|
||||
}
|
||||
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
|
||||
// Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted
|
||||
// data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so
|
||||
// `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix
|
||||
// addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns
|
||||
// it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that
|
||||
// something is there but unsupported, same spirit as the `(内容不可用)` placeholder above.
|
||||
if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) {
|
||||
return <span className="text-text-tertiary-4">[暂不支持的消息类型]</span>;
|
||||
}
|
||||
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
||||
}
|
||||
|
||||
@@ -163,7 +199,7 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGr
|
||||
const senderLabel = (from: string): string => {
|
||||
const full = normalizeSender(from);
|
||||
if (myUserId && full === myUserId) return '我';
|
||||
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
|
||||
if (isGroup) return groupSenderLabel(membersQuery.data?.find((m) => m.user_id === full)?.display_name);
|
||||
return peerName;
|
||||
};
|
||||
|
||||
|
||||
@@ -50,28 +50,38 @@ export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
|
||||
<span className="text-sm text-text-body">所有人</span>
|
||||
</button>
|
||||
) : null}
|
||||
{items.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
||||
>
|
||||
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
{m.role !== 'member' ? (
|
||||
<span
|
||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
|
||||
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
|
||||
}`}
|
||||
>
|
||||
{ROLE_LABEL[m.role]}
|
||||
{items.map((m) => {
|
||||
// Real, reported bug: `display_name` here silently prefers the CURRENT viewer's own
|
||||
// private remark for this member (see GroupMember.display_name's own doc comment) --
|
||||
// fine for a list only the viewer sees, wrong for an @mention, whose label gets baked
|
||||
// into the message's shared content once and read by every OTHER member from then on,
|
||||
// who have no idea what the sender's personal remark for this person means. `nickname`
|
||||
// is that member's own real public name, unaffected by anyone's remark, so it stays
|
||||
// correct for whoever else ends up reading the mention.
|
||||
const label = m.nickname || m.display_name || m.user_id;
|
||||
return (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: m.user_id, label })}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
||||
>
|
||||
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{label}</span>
|
||||
{m.role !== 'member' ? (
|
||||
<span
|
||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
|
||||
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
|
||||
}`}
|
||||
>
|
||||
{ROLE_LABEL[m.role]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,25 @@ import { Link } from 'react-router-dom';
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
|
||||
import {
|
||||
callResultDisplay,
|
||||
contactCardContentOf,
|
||||
isRecalled,
|
||||
isServerDeleted,
|
||||
mobileAttachmentContentOf,
|
||||
systemNoticeText,
|
||||
type ContactCardContent,
|
||||
} from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { QuoteBar } from '@/features/messages/QuoteBar';
|
||||
import { parseReplyHead } from '@/lib/messages/reply';
|
||||
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||
import { retryMessage } from '@/lib/tinode/send';
|
||||
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||
@@ -18,15 +31,25 @@ import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
|
||||
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
|
||||
* the real profile page, the one thing a contact-card message is fundamentally for. */
|
||||
function ContactCardBubble({ content }: { content: ContactCardContent }) {
|
||||
function ContactCardBubble({ content, locked }: { content: ContactCardContent; locked?: boolean }) {
|
||||
const name = content.display_name || content.public_account || content.user_id;
|
||||
return (
|
||||
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
||||
const inner = (
|
||||
<>
|
||||
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
// `locked` (selectMode): same reasoning as the avatar/@mention buttons elsewhere in this file --
|
||||
// a tap must only toggle the row, not navigate away and lose whatever's already been picked.
|
||||
if (locked) {
|
||||
return <div className="flex w-[220px] items-center gap-3">{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +87,10 @@ interface MessageRowProps {
|
||||
highlighted?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
/** Resolves a QUOTED message's sender uid (from head["x-im-reply"].from) to a display label.
|
||||
* Owned by ConversationView so group remark/nickname resolution stays in one place; QuoteBar
|
||||
* must never print a raw uid. */
|
||||
resolveQuotedName?: (uid: string | undefined) => string;
|
||||
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
|
||||
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
|
||||
* even made. */
|
||||
@@ -85,6 +112,7 @@ export function MessageRow({
|
||||
highlighted,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
resolveQuotedName,
|
||||
onError,
|
||||
}: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
@@ -110,6 +138,10 @@ export function MessageRow({
|
||||
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
|
||||
const callResult = callResultDisplay(msg, mine);
|
||||
const contactCard = contactCardContentOf(msg);
|
||||
const mobileAttachment = mobileAttachmentContentOf(msg);
|
||||
// Quoted-reply snapshot (x-im-reply). Not rendered for a recalled message -- its whole bubble
|
||||
// collapses to the "消息已删除" placeholder, quote bar included.
|
||||
const replyInfo = isRecalled(msg) ? null : parseReplyHead(msg.head);
|
||||
|
||||
const retry = () => {
|
||||
if (typeof msg.seq !== 'number') return;
|
||||
@@ -122,8 +154,19 @@ export function MessageRow({
|
||||
.catch(() => onError?.('重试发送失败'));
|
||||
};
|
||||
|
||||
// In selectMode, the WHOLE row toggles selection (not just the checkbox) -- confirmed by the user
|
||||
// that tapping only the small checkbox target is the wrong UX for a bulk-select list. A recalled
|
||||
// message renders a spacer instead of a checkbox (see below) and must stay unselectable, so this
|
||||
// guards the same way.
|
||||
const rowSelectable = selectMode && !isRecalled(msg) && typeof msg.seq === 'number';
|
||||
const toggleThisRow = () => rowSelectable && onToggleSelect?.(msg.seq as number);
|
||||
|
||||
return (
|
||||
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
|
||||
<div
|
||||
data-seq={msg.seq}
|
||||
onClick={toggleThisRow}
|
||||
className={`flex items-center gap-2 ${rowSelectable ? 'cursor-pointer' : ''} ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
|
||||
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
|
||||
@@ -132,9 +175,11 @@ 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="flex-none">
|
||||
// No onClick of its own -- the row-level handler above already toggles it, and a second
|
||||
// handler here would double-toggle on every click (select then immediately un-select).
|
||||
<div className="flex-none">
|
||||
<SelectionCheckbox selected={selected} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<div
|
||||
@@ -152,14 +197,14 @@ export function MessageRow({
|
||||
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>
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
|
||||
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<Avatar
|
||||
id={msg.from || (isGroup ? '' : peerName)}
|
||||
name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
|
||||
name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : peerName}
|
||||
avatarFileId={avatarFileId}
|
||||
fallbackBgClass={mine ? 'bg-accent' : undefined}
|
||||
className="h-9 w-9 flex-none rounded-lg text-sm"
|
||||
@@ -169,11 +214,11 @@ export function MessageRow({
|
||||
{isGroup ? (
|
||||
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
|
||||
{senderName || msg.from}
|
||||
</Link>
|
||||
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
|
||||
{groupSenderLabel(senderName)}
|
||||
</button>
|
||||
) : (
|
||||
<span>{mine ? '我' : senderName || msg.from}</span>
|
||||
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
|
||||
)}
|
||||
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
||||
</div>
|
||||
@@ -190,6 +235,9 @@ export function MessageRow({
|
||||
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
||||
>
|
||||
{replyInfo ? (
|
||||
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
|
||||
) : null}
|
||||
{isRecalled(msg) ? (
|
||||
'⚠ 消息已删除'
|
||||
) : callResult ? (
|
||||
@@ -198,14 +246,31 @@ export function MessageRow({
|
||||
{callResult.text}
|
||||
</span>
|
||||
) : contactCard ? (
|
||||
<ContactCardBubble content={contactCard} />
|
||||
<ContactCardBubble content={contactCard} locked={selectMode} />
|
||||
) : mobileAttachment ? (
|
||||
mobileAttachment.kind === 'image' ? (
|
||||
<ImageAttachment data={mobileAttachment} locked={selectMode} />
|
||||
) : mobileAttachment.kind === 'video' ? (
|
||||
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} locked={selectMode} />
|
||||
) : mobileAttachment.kind === 'voice' ? (
|
||||
<AudioAttachment data={mobileAttachment} locked={selectMode} />
|
||||
) : (
|
||||
<FileAttachment data={mobileAttachment} locked={selectMode} />
|
||||
)
|
||||
) : (
|
||||
renderDrafty(msg.content)
|
||||
renderDrafty(msg.content, undefined, !selectMode)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
<button type="button" onClick={retry} className="text-xs text-unread">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retry();
|
||||
}}
|
||||
className="text-xs text-unread"
|
||||
>
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import {
|
||||
placeholderForType,
|
||||
quotedMediaOf,
|
||||
type ReplyInfo,
|
||||
type QuoteMedia,
|
||||
} from '@/lib/messages/reply';
|
||||
import { useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
/**
|
||||
* Quote bar rendered at the top of a bubble, driven exclusively by `head["x-im-reply"]`
|
||||
* (cross-client contract aligned with iOS, 2026-09-23):
|
||||
*
|
||||
* - the head only carries a lightweight snapshot {seq,type,text,from}; the quoted message itself
|
||||
* is re-resolved from the LOCAL message cache by seq, which unlocks real thumbnails / voice
|
||||
* durations and the "该消息已删除" state that no snapshot can express;
|
||||
* - when the quoted message isn't local (fresh device, cleared history, unloaded range), the
|
||||
* snapshot carries rendering alone -- localized placeholder per type, never blank;
|
||||
* - clicking requests a scroll-locate + highlight via the same messageJump store the history
|
||||
* search uses; it transparently fetches the surrounding range when the target isn't loaded.
|
||||
*
|
||||
* Deliberately a plain <button>-free clickable div: it must not submit anything, but must allow
|
||||
* nested long-press/selection behavior of the bubble itself.
|
||||
*/
|
||||
|
||||
function formatVoiceDuration(ms: number | undefined): string {
|
||||
if (typeof ms !== 'number' || ms <= 0) return '';
|
||||
return `${Math.max(1, Math.round(ms / 1000))}″`;
|
||||
}
|
||||
|
||||
/** Small authenticated thumbnail (28x28 box per iOS spec) for image/video quotes. */
|
||||
function QuoteThumb({ media }: { media: { val?: string; ref?: string; mime?: string } }) {
|
||||
const inline = typeof media.val === 'string' ? `data:${media.mime || 'image/jpeg'};base64,${media.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : media.ref);
|
||||
const src = inline || remote.url;
|
||||
if (!src) {
|
||||
return (
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded bg-black/[0.06]">
|
||||
<Icon name="image" className="text-[15px] text-text-tertiary-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img src={src} alt="" className="h-7 w-7 flex-none rounded object-cover" />;
|
||||
}
|
||||
|
||||
function MediaSummary({ media }: { media: QuoteMedia }) {
|
||||
if ((media.kind === 'image' || media.kind === 'video') && media.image) {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-none items-center gap-1">
|
||||
{media.kind === 'video' ? <Icon name="play_circle" className="flex-none text-[14px]" /> : null}
|
||||
<QuoteThumb media={media.image} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (media.kind === 'voice') {
|
||||
const seconds = formatVoiceDuration(media.durationMs);
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
<Icon name="mic_none" className="text-[14px]" />
|
||||
{seconds ? <span>{seconds}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <Icon name="insert_drive_file" className="flex-none text-[14px]" />;
|
||||
}
|
||||
|
||||
interface QuoteBarProps {
|
||||
topicName: string;
|
||||
reply: ReplyInfo;
|
||||
/** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent
|
||||
* already owns this lookup, and must never surface a raw uid to the user. */
|
||||
resolveName: (uid: string | undefined) => string;
|
||||
/** MessageBubble's selectMode: a tap here must only toggle the row's own selection (same reasoning
|
||||
* as the avatar/sender-name buttons going inert during bulk-select), not also jump to the quoted
|
||||
* message -- so `locate` is left unwired and this renders as a plain, non-interactive div instead
|
||||
* of `role="button"`. */
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteBar({ topicName, reply, resolveName, locked }: QuoteBarProps) {
|
||||
const messages = useTopicMessages(topicName);
|
||||
const quoted: StoredMessage | undefined = useMemo(
|
||||
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
|
||||
[messages, reply.seq],
|
||||
);
|
||||
|
||||
const deleted = !!quoted && (isRecalled(quoted) || isServerDeleted(quoted));
|
||||
// Local media is only usable when the quoted message is present AND alive -- a recalled message's
|
||||
// thumbnail must not leak back through the quote bar (the snapshot is the only legal residue).
|
||||
const localMedia = quoted && !deleted ? quotedMediaOf(quoted) : null;
|
||||
const name = resolveName(quoted?.from || reply.from);
|
||||
|
||||
// Per the cross-client spec, only the media/card-like types render as placeholders; any unknown
|
||||
// type is rendered as text using the snapshot body.
|
||||
const MEDIA_LIKE_TYPES = new Set(['image', 'video', 'voice', 'file', 'contact_card', 'forward_bundle', 'sticker']);
|
||||
const isTextLike = !MEDIA_LIKE_TYPES.has(reply.type);
|
||||
// Real media affordances only when the local copy actually carries what they need: a usable image
|
||||
// ref for image/video, a duration for voice. Otherwise fall back to the snapshot placeholder
|
||||
// (exactly the iOS "取不到缩略图 → 名字:[图片]" / "取不到 → 名字:[语音]" behavior).
|
||||
const hasThumb = !!localMedia && (localMedia.kind === 'image' || localMedia.kind === 'video') && !!localMedia.image;
|
||||
const hasVoiceDuration = localMedia?.kind === 'voice' && typeof localMedia.durationMs === 'number' && localMedia.durationMs > 0;
|
||||
|
||||
const locate = () => {
|
||||
useMessageJumpStore.getState().requestJump(topicName, reply.seq);
|
||||
};
|
||||
|
||||
if (deleted) {
|
||||
return (
|
||||
<div
|
||||
role={locked ? undefined : 'button'}
|
||||
tabIndex={locked ? undefined : 0}
|
||||
onClick={locked ? undefined : locate}
|
||||
className="mb-1.5 truncate rounded border-l-2 border-text-tertiary-3 bg-black/[0.04] px-2 py-1 text-xs italic text-text-tertiary-3"
|
||||
>
|
||||
该消息已删除
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={locked ? undefined : 'button'}
|
||||
tabIndex={locked ? undefined : 0}
|
||||
onClick={locked ? undefined : locate}
|
||||
title={locked ? undefined : '查看原消息'}
|
||||
className="mb-1.5 flex max-w-full items-center gap-1.5 rounded border-l-2 border-accent bg-black/[0.04] px-2 py-1 text-xs text-[#4a4f57]"
|
||||
>
|
||||
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
|
||||
{hasThumb || hasVoiceDuration ? <MediaSummary media={localMedia as QuoteMedia} /> : null}
|
||||
{isTextLike ? (
|
||||
<span className="line-clamp-2 min-w-0 break-words">{reply.text}</span>
|
||||
) : (
|
||||
<span className="min-w-0 truncate">{reply.text || placeholderForType(reply.type)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
@@ -87,9 +88,20 @@ export function ProfileSettingsPage() {
|
||||
// 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.
|
||||
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. Worse: even
|
||||
// once `query.data` DOES refetch fresh, `draft` (what's actually on screen and what a follow-up
|
||||
// save would resubmit) was never resynced -- a second "保存" click would then silently succeed
|
||||
// and overwrite whichever OTHER fields changed elsewhere with this tab's own stale values.
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
if (err instanceof ImApiError && err.reason === 'VERSION_CONFLICT') {
|
||||
setError('资料已在其他设备更新,你的修改未保存,请重新编辑');
|
||||
// Resets to null (not the fresh data directly, which isn't available here yet) so the
|
||||
// effect above repopulates `draft` once the invalidated query below refetches -- same path
|
||||
// as the initial load, guaranteeing `draft` never observably lags `query.data`'s version.
|
||||
setDraft(null);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,6 +116,17 @@ export function SystemMessagesView() {
|
||||
// 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>>({});
|
||||
// Real, reported bug: this card's "接受邀请" button stayed live and clickable forever, even for an
|
||||
// invitation already accepted (in an earlier visit to this same list) or whose group had since been
|
||||
// dissolved -- there was no concept of "this one's done" anywhere, on success OR failure, so every
|
||||
// render just re-derived "show the button" from the notification's own static `action` payload,
|
||||
// which the server never updates after the fact. Once EITHER outcome happens for a given
|
||||
// invitation_id, it should never look freshly-clickable again -- a genuinely transient failure
|
||||
// (a network blip) is rare enough, and "already handled"/"group gone" common enough, that treating
|
||||
// every failure as terminal is the safer default. Doesn't survive a page reload (the server's own
|
||||
// notification record has no resolved flag to read back), only this list staying mounted -- a real,
|
||||
// acknowledged gap, not silently pretended away.
|
||||
const [resolvedInvitations, setResolvedInvitations] = useState<Record<string, 'accepted' | 'failed'>>({});
|
||||
const acceptInvitationMutation = useMutation({
|
||||
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
|
||||
onSuccess: (data, invitationId) => {
|
||||
@@ -125,10 +136,12 @@ export function SystemMessagesView() {
|
||||
delete next[invitationId];
|
||||
return next;
|
||||
});
|
||||
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' }));
|
||||
navigate(`/messages/${data.topic}`);
|
||||
},
|
||||
onError: (err, invitationId) => {
|
||||
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
|
||||
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'failed' }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -266,25 +279,31 @@ export function SystemMessagesView() {
|
||||
{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>
|
||||
{resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
|
||||
<span className="text-xs text-text-tertiary-4">已加入群聊</span>
|
||||
) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
|
||||
<span className="text-xs text-text-tertiary-4">邀请已失效</span>
|
||||
) : (
|
||||
// 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}
|
||||
|
||||
@@ -147,7 +147,23 @@ export type GroupRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
/**
|
||||
* Real, live-confirmed gotcha (2026-09-24): this is NOT always the member's own real name.
|
||||
* Reading server/im_http.go's userSummaryPayload confirms `display_name` here unconditionally
|
||||
* prefers the CALLING viewer's own private remark for that member when one is set, falling back
|
||||
* to their real public nickname only when the viewer has no remark for them -- correct and
|
||||
* intended for rendering a list to yourself (README's member list/search), completely wrong for
|
||||
* anything that gets baked into shared message CONTENT once and read by everyone from then on
|
||||
* (an @mention's label): a real reported bug was exactly this -- a group owner's own private
|
||||
* remark for a member ("小号1") ended up as that member's permanent @mention text, which every
|
||||
* OTHER member then saw with no idea who it meant. Use `nickname` (below) instead for that case.
|
||||
*/
|
||||
display_name?: string;
|
||||
/** The member's own real public profile nickname -- same backend response, but from
|
||||
* copyUserNickname(), which reads straight off their public profile and is NEVER shadowed by the
|
||||
* viewer's own remark the way `display_name` above is. This is what stays correct for everyone
|
||||
* once baked into shared content (an @mention's label), not just for the viewer's own screen. */
|
||||
nickname?: string;
|
||||
avatar_file_id?: string;
|
||||
role: GroupRole;
|
||||
mentionable?: boolean;
|
||||
@@ -186,6 +202,29 @@ export function getAllGroupMembers(
|
||||
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists
|
||||
* (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a
|
||||
* plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or
|
||||
* themselves. An earlier pass through the protocol doc found no such operation at all -- that was
|
||||
* true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time.
|
||||
*/
|
||||
export function removeGroupMember(topic: string, userId: string) {
|
||||
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner;
|
||||
* removing a plain member needs admin-or-better, removing an admin needs the owner specifically.
|
||||
* Shared by every surface that renders a per-member remove affordance (GroupMembersModal's full
|
||||
* list, GroupDetailColumn's compact avatar grid) so the rule can't drift between the two. */
|
||||
export function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean {
|
||||
if (!myUserId || member.user_id === myUserId) return false;
|
||||
if (myRole === 'member') return false;
|
||||
if (member.role === 'owner') return false;
|
||||
if (member.role === 'admin' && myRole !== 'owner') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface GroupMemberProfile {
|
||||
topic: string;
|
||||
user_id: string;
|
||||
@@ -409,11 +448,15 @@ export function getGroupSummary(topic: string) {
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
||||
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
||||
* list for a display name/role, same as any other bare user id in this API. `read_count`/
|
||||
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
|
||||
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
|
||||
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
|
||||
* callers must check `content` for emptiness, not object existence. */
|
||||
* list for a display name/role, same as any other bare user id in this API. When nothing has been
|
||||
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
|
||||
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
|
||||
* `content` for emptiness, not object existence.
|
||||
* `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this
|
||||
* type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed
|
||||
* by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three).
|
||||
* Optional here because an old cached response (or a tenant not yet on the new deploy) may still
|
||||
* omit them. */
|
||||
export interface GroupAnnouncement {
|
||||
announcement_id: string;
|
||||
topic: string;
|
||||
@@ -422,6 +465,9 @@ export interface GroupAnnouncement {
|
||||
published_at: string;
|
||||
author: string;
|
||||
permissions?: { can_edit?: boolean };
|
||||
read_count?: number;
|
||||
member_count?: number;
|
||||
read_summary?: { read_count: number; member_count: number };
|
||||
}
|
||||
|
||||
export function getLatestGroupAnnouncement(topic: string) {
|
||||
|
||||
@@ -65,6 +65,14 @@ export interface SearchHit {
|
||||
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
|
||||
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
|
||||
content?: DraftyContent | string;
|
||||
// Confirmed LIVE (2026-09-24, a real mobile-sent flat-text hit): the snapshot also carries the
|
||||
// original message's `head`, same as favorites.ts's own snapshot already documents. Needed to
|
||||
// recognize a mobile-sent flat content shape (`{text:...}`/`{file_id,...}`/etc., tagged only by
|
||||
// `head['x-im-type']`) BEFORE handing `content` to renderDrafty -- that flat shape isn't a real
|
||||
// Drafty document, and used to crash the whole search modal (no `.txt`/`.text` field `renderDrafty`
|
||||
// recognizes -> `Drafty.toPlainText` returns `undefined` -> highlightMatches() called `.toLowerCase()`
|
||||
// on it). See HistorySearchModal's SnapshotContent for the fix.
|
||||
head?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,3 +153,41 @@ export function favoriteMessages(items: Array<{ topic: string; seq: number }>) {
|
||||
messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ForwardResultItem {
|
||||
source?: { topic: string; seq: number };
|
||||
target_topic: string;
|
||||
client_mid: string;
|
||||
seq: number;
|
||||
status: 'sent' | 'pending' | 'uncertain';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ForwardFailureItem {
|
||||
source?: { topic: string; seq: number };
|
||||
target_topic?: string;
|
||||
status: 'failed';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/im/messages/forward` (server/im_http.go's serveIMMessageForward, confirmed by reading
|
||||
* the real Go source directly, not the protocol doc) -- the SAME backend-authoritative forward path
|
||||
* both real mobile clients use (confirmed by reading their real source too: iOS's
|
||||
* ChatDetailViewController+Forward.swift, Android's ImMessageActionApi.forward(), NEITHER ever builds
|
||||
* a `{pub}` packet client-side). One call handles every (source, target) pair at once -- up to 100
|
||||
* sources x 20 targets -- with per-pair success/failure reporting, and the server copies
|
||||
* `msg.Content` verbatim regardless of shape (real Drafty or a mobile-sent flat object), so unlike
|
||||
* the client-side approach this replaces, there is no content-shape detection to get wrong here.
|
||||
* `comment` is deliberately NOT wired here even though the endpoint accepts one -- callers keep
|
||||
* sending an optional forward comment as its own separate, ordinary text message afterward (matching
|
||||
* this app's existing, unchanged UX: the comment appears AFTER the forwarded message(s), whereas the
|
||||
* endpoint's own built-in `comment` would save it BEFORE -- a real behavioral difference that's the
|
||||
* caller's choice to take on, not something to silently opt into here).
|
||||
*/
|
||||
export function forwardMessages(sources: Array<{ topic: string; seq: number }>, targetTopics: string[]) {
|
||||
return imPost<{ items: ForwardResultItem[]; failed: ForwardFailureItem[] }>('/messages/forward', {
|
||||
sources,
|
||||
target_topics: targetTopics,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||||
import { canQuote } from '@/lib/messages/reply';
|
||||
|
||||
/** 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). */
|
||||
@@ -17,13 +18,21 @@ export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall'
|
||||
*/
|
||||
export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null {
|
||||
const mine = !!myUserId && msg.from === myUserId;
|
||||
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
|
||||
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
|
||||
if (!mine || !isConfirmed || recallWindow <= 0 || !msg.ts) return null;
|
||||
if (!mine || !isConfirmedSeq(msg) || recallWindow <= 0 || !msg.ts) return null;
|
||||
const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000);
|
||||
return remaining > 0 ? remaining : null;
|
||||
}
|
||||
|
||||
/** True once the server has actually confirmed this message's `seq` -- false for a still-sending or
|
||||
* failed-to-send message (the SDK's local placeholder seq is a `number`, so a plain `typeof` check
|
||||
* isn't enough). Shared by recall (above) and forward (below): both need the server to already know
|
||||
* about the message -- there's nothing yet at (topic, seq) for a recall or a forwardMessages() call
|
||||
* to locate. */
|
||||
export function isConfirmedSeq(msg: StoredMessage): boolean {
|
||||
return typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which context-menu actions apply to one message. Scoped to P2P for now (no group-admin recall,
|
||||
* no self-topic hard-delete-always -- those need group role/topic-type knowledge Phase 3 will add).
|
||||
@@ -33,13 +42,37 @@ 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 [];
|
||||
// 名片消息和通话结果消息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'];
|
||||
// Contact-card and call-result messages are flat, non-Drafty custom objects dispatched by
|
||||
// head['x-im-type'] (dispatch.ts), not bodies Drafty.toPlainText can handle -- "copy" stays off
|
||||
// both. Quote/forward differ since the 2026-09-23 x-im-reply snapshot protocol represents the
|
||||
// quoted message by {seq,type,text,from} rather than parsing its content: a contact card now
|
||||
// quotes fine ([名片] snapshot). Call bubbles remain favorite-only -- a transient call result
|
||||
// has no meaningful "forward/quote" semantics anywhere in the cross-client spec.
|
||||
const isCard = contactCardContentOf(msg) !== null;
|
||||
const isCall = callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
||||
// Same flat-object reasoning as contact cards -- a mobile-sent image/file/voice/video message has
|
||||
// no `.txt`/`.text` for `copy` to extract (confirmed live: `ConversationView.tsx`'s copy handler
|
||||
// used to put the literal string "undefined" on the clipboard for one of these). Quote/favorite/
|
||||
// forward are unaffected -- quote uses the x-im-reply snapshot same as a contact card, favorite
|
||||
// uses its own snapshot (favorites.ts), and forward now goes through the backend's REST forward
|
||||
// endpoint (lib/api/messages.ts's `forwardMessages`), which copies content verbatim regardless of
|
||||
// shape.
|
||||
const isMobileAttachment = mobileAttachmentContentOf(msg) !== null;
|
||||
let actions: MessageAction[];
|
||||
if (isCall) {
|
||||
actions = ['favorite'];
|
||||
} else if (isCard || isMobileAttachment) {
|
||||
actions = ['quote', 'favorite', 'forward'];
|
||||
} else {
|
||||
actions = ['quote', 'copy', 'favorite', 'forward'];
|
||||
}
|
||||
// Quote is additionally gated on the x-im-reply contract: no failed/pending messages, no
|
||||
// system notices, positive confirmed seq (buildReplyInfo returns null otherwise, and the server
|
||||
// would reject a malformed x-im-reply head outright).
|
||||
if (!canQuote(msg)) actions = actions.filter((action) => action !== 'quote');
|
||||
// A still-sending/failed message has no server-side (topic, seq) yet for forwardMessages() to
|
||||
// locate -- same reasoning as recall's isConfirmedSeq gate above.
|
||||
if (!isConfirmedSeq(msg)) actions = actions.filter((action) => action !== '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.
|
||||
|
||||
@@ -83,6 +83,69 @@ export function contactCardContentOf(msg: StoredMessage): ContactCardContent | n
|
||||
return isContactCardContent(msg.content) ? msg.content : null;
|
||||
}
|
||||
|
||||
export interface MobileAttachmentContent {
|
||||
kind: 'image' | 'file' | 'voice' | 'video';
|
||||
ref: string;
|
||||
previewRef?: string;
|
||||
mime?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const MOBILE_ATTACHMENT_KINDS = new Set(['image', 'file', 'voice', 'video']);
|
||||
|
||||
/**
|
||||
* Real, live-confirmed shape (2026-09-23, a real "seq测试" group with real Android/iOS senders):
|
||||
* this backend's mobile apps send image/file/voice/video messages as a FLAT, non-Drafty content
|
||||
* object straight off the device upload response -- `{file_id, thumbnail_file_id, mime, width,
|
||||
* height, name, size, ref?, preref?}` -- tagged only by `head['x-im-type']` (one of these 4 values),
|
||||
* the exact same "flat object dispatched by head, not by Drafty entity" shape `contactCardContentOf`
|
||||
* above already documents for `contact_card`. The web client's OWN sender (attachments.ts) never
|
||||
* produces this shape -- it always builds a real Drafty `{txt,fmt,ent}` document (`Drafty.insertImage`
|
||||
* etc.) and only uses `x-im-type` as a side-channel category tag, so `renderDrafty`/
|
||||
* `formatMessagePreview` were never built to recognize this flat shape and silently show nothing /
|
||||
* "undefined" for it (confirmed live: 3 real messages from 3 different mobile senders in the same
|
||||
* group rendered as completely blank bubbles, and the chat list's own preview read the literal string
|
||||
* "undefined" for the most recent one) -- checked BEFORE ever calling `renderDrafty`/`Drafty.preview`
|
||||
* on `msg.content`, same as every other flat-content case in this file.
|
||||
*
|
||||
* `ref`/`preref` (a real, already-resolved `/v0/file/s/{id}` path) are present on some real messages
|
||||
* but not others -- confirmed on a real file/voice/video message but absent on 2 of 3 real image
|
||||
* messages in the same group (present on the one from an Android-camera-named file, absent on the
|
||||
* two from UUID-named files, so this looks like a per-platform difference, not random) -- falls back
|
||||
* to constructing the same `/v0/file/s/{file_id}` path this app already uses for avatar_file_id
|
||||
* whenever `ref`/`preref` themselves are missing, rather than assuming they're always there.
|
||||
*/
|
||||
export function mobileAttachmentContentOf(msg: StoredMessage): MobileAttachmentContent | null {
|
||||
const headType = msg.head?.['x-im-type'];
|
||||
if (typeof headType !== 'string' || !MOBILE_ATTACHMENT_KINDS.has(headType)) return null;
|
||||
const c = msg.content as unknown as Record<string, unknown> | null | undefined;
|
||||
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
||||
// A real Drafty document (this app's own sent attachments) always has `ent`/`fmt` -- the mobile
|
||||
// flat shape never does. Guards against a future web-sent message that happens to reuse the same
|
||||
// `x-im-type` tag on a genuine Drafty body (exactly how the web client's own sender uses it today).
|
||||
if ('ent' in c || 'fmt' in c) return null;
|
||||
const fileId = typeof c.file_id === 'string' ? c.file_id : undefined;
|
||||
const ref = typeof c.ref === 'string' ? c.ref : fileId ? `/v0/file/s/${fileId}` : undefined;
|
||||
if (!ref) return null;
|
||||
const thumbId = typeof c.thumbnail_file_id === 'string' ? c.thumbnail_file_id : undefined;
|
||||
const previewRef = typeof c.preref === 'string' ? c.preref : thumbId ? `/v0/file/s/${thumbId}` : undefined;
|
||||
return {
|
||||
kind: headType as MobileAttachmentContent['kind'],
|
||||
ref,
|
||||
previewRef,
|
||||
mime: typeof c.mime === 'string' ? c.mime : undefined,
|
||||
name: typeof c.name === 'string' ? c.name : undefined,
|
||||
size: typeof c.size === 'number' ? c.size : undefined,
|
||||
width: typeof c.width === 'number' ? c.width : undefined,
|
||||
height: typeof c.height === 'number' ? c.height : undefined,
|
||||
duration: typeof c.duration === 'number' ? c.duration : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCallDuration(ms: number): string {
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
@@ -93,10 +156,12 @@ export function formatCallDuration(ms: number): string {
|
||||
* 1953/1954): `missed` alone covers both "未接听" and "已取消" (a callee who let it ring out and a
|
||||
* caller who cancelled before pickup both replace to the SAME wire value) -- direction picks the
|
||||
* text, not the wire value, using `mine` (was the ORIGINAL invite sent by me, i.e. was I the
|
||||
* caller) exactly the way this app already threads that prop through MessageRow. No `busy` value
|
||||
* exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite
|
||||
* the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can
|
||||
* produce it yet.
|
||||
* caller) exactly the way this app already threads that prop through MessageRow. `busy` WAS assumed
|
||||
* to never appear in a persisted message (only ever seen on this app's own live, ephemeral
|
||||
* `sendCallHangup(...,'busy')` signal) -- disproven by reading the real iOS/Android source
|
||||
* (2026-09-25): both platforms' own SDKs declare `busy` as a genuine `head.webrtc` enum member the
|
||||
* SERVER controls, same as every other case here, so a persisted busy result is a real thing this
|
||||
* app can receive, not just something it can locally signal to a live peer.
|
||||
*/
|
||||
export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null {
|
||||
const content = callContentOf(msg);
|
||||
@@ -114,6 +179,8 @@ export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: st
|
||||
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
|
||||
case 'disconnected':
|
||||
return { icon: 'phone_missed', danger: true, text: '异常结束' };
|
||||
case 'busy':
|
||||
return { icon: 'phone_missed', danger: true, text: '对方忙线中' };
|
||||
case 'accepted':
|
||||
case 'started':
|
||||
return { icon, danger: false, text: '通话中' };
|
||||
|
||||
@@ -5,6 +5,8 @@ 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';
|
||||
import { MENTION_ALL_SENTINEL } from '@/lib/messages/mentions';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
/**
|
||||
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
|
||||
@@ -23,6 +25,13 @@ import { flatTextContentOf } from '@/lib/messages/dispatch';
|
||||
* feature for user-authored highlight formatting, not search-match highlighting). Global,
|
||||
* case-insensitive, one `<mark>` per match. */
|
||||
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
|
||||
// Defensive, not decorative: a caller one level up (`renderDrafty`'s plain-text branch) can still
|
||||
// hand this `undefined` for a flat non-Drafty shape it doesn't recognize (a mobile-sent attachment/
|
||||
// contact-card snapshot with no `.text`/`.txt` field) -- live-confirmed to crash the whole app with
|
||||
// no error boundary to catch it (`text.toLowerCase()` on `undefined`) the moment a search keyword
|
||||
// matched one of those. `renderDrafty` itself still degrades to a placeholder-less `undefined`
|
||||
// node for the no-keyword case, which this alone doesn't fix -- see SnapshotContent's own guard.
|
||||
if (text == null) return [];
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [text];
|
||||
const haystack = text.toLowerCase();
|
||||
@@ -49,7 +58,7 @@ function highlightMatches(text: string, query: string, keyPrefix: string | numbe
|
||||
* own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call),
|
||||
* so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally
|
||||
* matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */
|
||||
function makeFormatNode(highlightQuery: string | undefined) {
|
||||
function makeFormatNode(highlightQuery: string | undefined, interactive: boolean) {
|
||||
return function formatNode(
|
||||
style: string | undefined,
|
||||
data: Record<string, unknown> | undefined,
|
||||
@@ -72,12 +81,27 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
||||
);
|
||||
case 'BR':
|
||||
return <br key={key} />;
|
||||
case 'MN':
|
||||
case 'MN': {
|
||||
// The mention's uid lives on the entity's own `data` (Drafty.mention(name, uid) -- SDK
|
||||
// source: `ent:[{tp:'MN', data:{val:uid}}]`), not on `values`/`children` (that's just the
|
||||
// rendered label text). MENTION_ALL_SENTINEL ("所有人") isn't a real user -- nothing to open.
|
||||
// `interactive=false` (MessageBubble's selectMode) falls through to the same inert <span> as
|
||||
// the sentinel case -- a tap during bulk-select must only toggle the row, same reasoning as
|
||||
// the avatar/sender-name buttons right above this component's own JSX going inert there.
|
||||
const uid = typeof data?.val === 'string' ? data.val : undefined;
|
||||
if (interactive && uid && uid !== MENTION_ALL_SENTINEL) {
|
||||
return (
|
||||
<button key={key} type="button" onClick={() => openUserCard(uid)} className="inline border-0 bg-transparent p-0 text-accent hover:underline">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span key={key} className="text-accent">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'HL':
|
||||
return (
|
||||
<mark key={key} className="bg-warn-bg">
|
||||
@@ -86,6 +110,16 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
||||
);
|
||||
case 'LN': {
|
||||
const url = typeof data?.url === 'string' ? data.url : undefined;
|
||||
// Same interactive gating as 'MN' above -- a tap during bulk-select must only toggle the
|
||||
// row, not open a link (SafeLink's <button> doesn't stopPropagation, so an active link would
|
||||
// both open a tab AND toggle the row's checkbox).
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span key={key} className="text-accent underline">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SafeLink key={key} href={url}>
|
||||
{children}
|
||||
@@ -93,13 +127,13 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
||||
);
|
||||
}
|
||||
case 'IM':
|
||||
return <ImageAttachment key={key} data={data || {}} />;
|
||||
return <ImageAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||
case 'EX':
|
||||
return <FileAttachment key={key} data={data || {}} />;
|
||||
return <FileAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||
case 'AU':
|
||||
return <AudioAttachment key={key} data={data || {}} />;
|
||||
return <AudioAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||
case 'VD':
|
||||
return <VideoAttachment key={key} data={data || {}} />;
|
||||
return <VideoAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||
case 'QQ':
|
||||
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
|
||||
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.
|
||||
@@ -121,8 +155,10 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
||||
}
|
||||
|
||||
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
|
||||
* as before; only HistorySearchModal's search results pass one. */
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
|
||||
* as before; only HistorySearchModal's search results pass one. Same for `interactive` (default
|
||||
* true): MessageBubble's selectMode is the only caller that passes `false`, to keep an @mention from
|
||||
* opening a profile card mid-bulk-select (see makeFormatNode's own comment on the MN case). */
|
||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string, interactive = true): ReactNode {
|
||||
if (content == null) return null;
|
||||
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
||||
const flatText = flatTextContentOf(content);
|
||||
@@ -131,5 +167,5 @@ export function renderDrafty(content: DraftyContent | string | null | undefined,
|
||||
const text = Drafty.toPlainText(content);
|
||||
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
||||
}
|
||||
return Drafty.format(content, makeFormatNode(highlightQuery));
|
||||
return Drafty.format(content, makeFormatNode(highlightQuery, interactive));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { callResultDisplay, contactCardContentOf, flatTextContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
import {
|
||||
callResultDisplay,
|
||||
contactCardContentOf,
|
||||
flatTextContentOf,
|
||||
isServerDeleted,
|
||||
mobileAttachmentContentOf,
|
||||
systemNoticeText,
|
||||
} from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
|
||||
@@ -26,6 +33,25 @@ 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, non-Drafty shape mobileAttachmentContentOf documents (mobile-sent image/file/voice/
|
||||
// video) -- without this it falls through to the Drafty-entity branches below the same way the
|
||||
// flat-text case above used to, and produces the same literal "undefined" (confirmed live: the
|
||||
// chat list's own preview for a real mobile-sent image read exactly that).
|
||||
const mobileAttachment = mobileAttachmentContentOf(msg);
|
||||
if (mobileAttachment) {
|
||||
switch (mobileAttachment.kind) {
|
||||
case 'image':
|
||||
return `${prefix}[图片]`;
|
||||
case 'video':
|
||||
return `${prefix}[视频]`;
|
||||
case 'voice': {
|
||||
const totalSeconds = Math.round((mobileAttachment.duration || 0) / 1000);
|
||||
return `${prefix}[语音] ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
case 'file':
|
||||
return `${prefix}[文件] ${mobileAttachment.name || ''}`;
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
|
||||
|
||||
import { flatTextContentOf, isRecalled, isServerDeleted, mobileAttachmentContentOf, systemNoticeText } from '@/lib/messages/dispatch';
|
||||
|
||||
/**
|
||||
* Quoted-reply wire contract (`head["x-im-reply"]`), aligned with the iOS cross-client spec
|
||||
* 《消息引用数据格式-跨端对接说明》 (2026-09-23) and the im_server contract landed in commit
|
||||
* 3e92021 ("feat: support quoted message headers"):
|
||||
*
|
||||
* { "seq": 128, // NUMBER (positive int); a string seq makes iOS drop the quote bar
|
||||
* "type": "text", // quoted message's x-im-type; unknown values render as text
|
||||
* "text": "摘要快照", // full body for text (display side truncates); media = placeholder
|
||||
* "from": "usr..." } // optional quoted sender uid; OMIT the key entirely when empty
|
||||
*
|
||||
* The snapshot lives in `head`, NEVER in `content`. The server validates the shape
|
||||
* (non-empty type/text, positive integer seq, non-empty from when present) and rejects the whole
|
||||
* message with ErrMalformed on violation, so builders here must never emit an empty/placeholder
|
||||
* `from` or an invalid seq.
|
||||
*/
|
||||
export interface ReplyInfo {
|
||||
seq: number;
|
||||
type: string;
|
||||
text: string;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (mirrors
|
||||
* actions.ts's own LOCAL_SEQID; tinode.dev.js LOCAL_SEQID, not exported). */
|
||||
const LOCAL_SEQID = 0xfffffff;
|
||||
|
||||
/** Media types whose snapshot text is a localized placeholder rather than a real body. */
|
||||
const MEDIA_TYPES = new Set(['image', 'video', 'voice', 'file']);
|
||||
|
||||
const PLACEHOLDER_BY_TYPE: Record<string, string> = {
|
||||
image: '[图片]',
|
||||
video: '[视频]',
|
||||
voice: '[语音]',
|
||||
file: '[文件]',
|
||||
contact_card: '[名片]',
|
||||
forward_bundle: '[聊天记录]',
|
||||
link: '[链接]',
|
||||
};
|
||||
|
||||
const PLACEHOLDER_FALLBACK = '[消息]';
|
||||
|
||||
/** Localized placeholder per quoted type (§7 of the cross-client spec). Unknown -> `[消息]`. */
|
||||
export function placeholderForType(type: string): string {
|
||||
return PLACEHOLDER_BY_TYPE[type] ?? PLACEHOLDER_FALLBACK;
|
||||
}
|
||||
|
||||
/** Legacy head `type` values seen in stored messages, mapped onto the product x-im-type enum. */
|
||||
const LEGACY_TYPE_MAP: Record<string, string> = {
|
||||
audio: 'voice',
|
||||
};
|
||||
|
||||
/** The effective product type of a message, read from head (`x-im-type` preferred over legacy
|
||||
* `type`). Falls back to "text" -- a message without an explicit type tag is a plain text body. */
|
||||
export function messageReplyType(msg: StoredMessage): string {
|
||||
const head = msg.head;
|
||||
const product = head?.['x-im-type'];
|
||||
if (typeof product === 'string' && product.trim()) return product.trim();
|
||||
const legacy = head?.['type'];
|
||||
if (typeof legacy === 'string' && legacy.trim()) return LEGACY_TYPE_MAP[legacy.trim()] ?? legacy.trim();
|
||||
return 'text';
|
||||
}
|
||||
|
||||
/** Whether a message may be quoted at all: server-confirmed positive seq, not failed/pending,
|
||||
* not deleted/recalled, and not a centered system notice or a transient call-result bubble. */
|
||||
export function canQuote(msg: StoredMessage): boolean {
|
||||
if (typeof msg.seq !== 'number' || !Number.isInteger(msg.seq) || msg.seq <= 0 || msg.seq >= LOCAL_SEQID) return false;
|
||||
if (isServerDeleted(msg) || isRecalled(msg) || msg.content == null) return false;
|
||||
const type = messageReplyType(msg);
|
||||
if (type === 'system_notice' || type === 'system_action' || type === 'system_event' || type === 'call') return false;
|
||||
// Belt and suspenders: systemNoticeText recognizes centered notices by head tag even if the type
|
||||
// string ever drifts.
|
||||
if (systemNoticeText(msg)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function draftyPlainText(content: StoredMessage['content']): string {
|
||||
if (content == null) return '';
|
||||
if (typeof content === 'string') return content;
|
||||
return Drafty.toPlainText(content as DraftyContent).trim();
|
||||
}
|
||||
|
||||
/** Snapshot text written into `x-im-reply.text` at send time: full text body for text-like
|
||||
* messages (no truncation -- the display side truncates), localized placeholder for media/card. */
|
||||
export function replySnapshotText(msg: StoredMessage, type: string = messageReplyType(msg)): string {
|
||||
if (MEDIA_TYPES.has(type) || type === 'contact_card' || type === 'forward_bundle' || type === 'sticker') {
|
||||
return placeholderForType(type);
|
||||
}
|
||||
const flat = flatTextContentOf(msg.content);
|
||||
if (flat !== null) return flat.trim();
|
||||
const plain = draftyPlainText(msg.content);
|
||||
return plain || PLACEHOLDER_FALLBACK;
|
||||
}
|
||||
|
||||
/** Builds the `x-im-reply` head value for an outgoing message, or null when the quoted message
|
||||
* is not quotable (caller must then refuse to arm the reply state). */
|
||||
export function buildReplyInfo(msg: StoredMessage): ReplyInfo | null {
|
||||
if (!canQuote(msg)) return null;
|
||||
const type = messageReplyType(msg);
|
||||
const info: ReplyInfo = {
|
||||
seq: msg.seq,
|
||||
type,
|
||||
text: replySnapshotText(msg, type),
|
||||
};
|
||||
// OMIT `from` when empty -- the server rejects an empty-string from as malformed.
|
||||
if (typeof msg.from === 'string' && msg.from.trim()) info.from = msg.from.trim();
|
||||
return info;
|
||||
}
|
||||
|
||||
function asPositiveInt(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
|
||||
// Tolerant receive path: the spec suggests accepting numeric strings even though senders must
|
||||
// write a real JSON number (a string seq is exactly what makes iOS fail to parse, but other
|
||||
// clients' history may still carry one).
|
||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||||
const n = Number(value.trim());
|
||||
if (Number.isSafeInteger(n) && n > 0) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseReplyValue(raw: unknown): ReplyInfo | null {
|
||||
let value: unknown = raw;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
value = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const seq = asPositiveInt(obj.seq);
|
||||
if (seq == null) return null;
|
||||
if (typeof obj.type !== 'string' || !obj.type.trim()) return null;
|
||||
if (typeof obj.text !== 'string') return null;
|
||||
const reply: ReplyInfo = { seq, type: obj.type.trim(), text: obj.text };
|
||||
// Unknown types are kept as-is here; the display layer maps them onto text rendering.
|
||||
if (typeof obj.from === 'string' && obj.from.trim()) reply.from = obj.from.trim();
|
||||
return reply;
|
||||
}
|
||||
|
||||
/** Parses a received message's `head["x-im-reply"]`, tolerating both object and JSON-string
|
||||
* carriers (iOS already accepts both) and any malformed shape -> null (no quote, never crash). */
|
||||
export function parseReplyHead(head: Record<string, unknown> | undefined): ReplyInfo | null {
|
||||
if (!head) return null;
|
||||
return parseReplyValue(head['x-im-reply']);
|
||||
}
|
||||
|
||||
/** Media descriptor of a LOCALLY AVAILABLE quoted message, allowing the quote bar to render a
|
||||
* real thumbnail / voice duration instead of the text placeholder. Returns null when the quoted
|
||||
* message isn't in the local cache; the caller then renders the snapshot placeholder. */
|
||||
export interface QuoteMedia {
|
||||
kind: 'image' | 'video' | 'voice' | 'file';
|
||||
/** Image/video box: authenticated inline bits or a remote ref. */
|
||||
image?: { val?: string; ref?: string; mime?: string };
|
||||
/** Voice bubble: duration in milliseconds. */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface DraftyEntityDoc {
|
||||
ent?: Array<{ tp?: string; data?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
function draftyEntityMedia(msg: StoredMessage): QuoteMedia | null {
|
||||
const c = msg.content as DraftyContent | null;
|
||||
if (!c || typeof c !== 'object') return null;
|
||||
const doc = c as Partial<DraftyEntityDoc>;
|
||||
if (!Array.isArray(doc.ent)) return null;
|
||||
// Prefer the first matching media entity -- a quoted message carries one primary attachment.
|
||||
for (const ent of doc.ent) {
|
||||
const data = ent.data || {};
|
||||
const asImage = () => {
|
||||
// This deployment's Drafty media carries its upload URL in `refurl` (both webapp-sent and
|
||||
// script/iOS-sent messages -- see attachments.ts), with tinode's classic `ref`/inline `val`
|
||||
// as the other accepted carriers. Missing all three -> not locally renderable.
|
||||
const ref =
|
||||
typeof data.refurl === 'string' ? data.refurl : typeof data.ref === 'string' ? data.ref : undefined;
|
||||
const val = typeof data.val === 'string' ? data.val : undefined;
|
||||
return ref || val
|
||||
? { image: { ref, val, mime: typeof data.mime === 'string' ? data.mime : undefined } }
|
||||
: null;
|
||||
};
|
||||
switch (ent.tp) {
|
||||
case 'IM': {
|
||||
const image = asImage();
|
||||
if (image) return { kind: 'image', ...image };
|
||||
break;
|
||||
}
|
||||
case 'VD': {
|
||||
const image = asImage();
|
||||
if (image) return { kind: 'video', ...image };
|
||||
break;
|
||||
}
|
||||
case 'AU': {
|
||||
const durationMs = typeof data.duration === 'number' ? data.duration : undefined;
|
||||
return { kind: 'voice', durationMs };
|
||||
}
|
||||
case 'EX':
|
||||
return { kind: 'file' };
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolves locally-renderable media for a quoted message (mobile flat content or web Drafty
|
||||
* entities). Null when the message is text/not locally available in a recognizable shape. */
|
||||
export function quotedMediaOf(msg: StoredMessage | undefined): QuoteMedia | null {
|
||||
if (!msg || msg.content == null) return null;
|
||||
const mobile = mobileAttachmentContentOf(msg);
|
||||
if (mobile) {
|
||||
if (mobile.kind === 'image' || mobile.kind === 'video') {
|
||||
const ref = mobile.previewRef || mobile.ref;
|
||||
return ref ? { kind: mobile.kind, image: { ref, mime: mobile.mime } } : { kind: mobile.kind };
|
||||
}
|
||||
if (mobile.kind === 'voice') return { kind: 'voice', durationMs: mobile.duration };
|
||||
return { kind: 'file' };
|
||||
}
|
||||
return draftyEntityMedia(msg);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Sender display label for group chat surfaces (bubbles, quoted replies, history search).
|
||||
*
|
||||
* Two distinct paths used to leak the raw `usr...` id into the message list (Zentao #32):
|
||||
* - the sender is no longer in the member list (left/was removed) so there is no resolved
|
||||
* name at all, and the row fell back to `msg.from` verbatim;
|
||||
* - the member IS in the list but has no nickname/remark anywhere, so the server's own
|
||||
* display-name fallback in userSummaryPayload is the user id itself.
|
||||
* Both must render a fixed placeholder instead -- a uid is an internal identifier, not a
|
||||
* user-facing name. Membership of a uid is decided by shape (`usr…` followed by id charset),
|
||||
* because the server's fallback means the raw id can arrive *inside* `senderName`.
|
||||
*/
|
||||
const UID_SHAPE = /^usr[A-Za-z0-9_-]+$/;
|
||||
|
||||
export const UNKNOWN_SENDER_LABEL = '未知用户';
|
||||
|
||||
/**
|
||||
* Accepts multiple name candidates in priority order (e.g. the current member list's
|
||||
* `display_name`, then a standalone profile lookup for a sender who has since left the group)
|
||||
* and returns the first one that isn't uid-shaped. A left-group member's own profile can
|
||||
* usually still be resolved by id and is a real name worth showing instead of the placeholder;
|
||||
* a still-present member whose `display_name` IS the uid (the second bug path above) has no
|
||||
* better candidate to fall back to and correctly lands on the placeholder either way.
|
||||
*/
|
||||
export function groupSenderLabel(...candidates: Array<string | undefined>): string {
|
||||
for (const candidate of candidates) {
|
||||
const name = candidate?.trim();
|
||||
if (name && !UID_SHAPE.test(name)) return name;
|
||||
}
|
||||
// No usable name -- never show the raw uid even when it IS available; it's not a user-facing name.
|
||||
return UNKNOWN_SENDER_LABEL;
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import { Drafty, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
|
||||
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
import type { ReplyInfo } from '@/lib/messages/reply';
|
||||
import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -29,6 +30,14 @@ function imageDimensions(file: File): Promise<{ width: number; height: number }>
|
||||
});
|
||||
}
|
||||
|
||||
/** Merges the standard send headers with the optional pending quoted-reply snapshot, so media
|
||||
* messages armed with a quote carry the same `x-im-reply` head as text replies. */
|
||||
function mediaHead(type: string, reply?: ReplyInfo | null): Record<string, unknown> {
|
||||
const head: Record<string, unknown> = { 'x-im-type': type, 'x-im-client-mid': newClientMid() };
|
||||
if (reply) head['x-im-reply'] = reply;
|
||||
return head;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small files go inline as base64 (Drafty.insertImage's `bits` field) -- no upload round trip at
|
||||
* all. Larger ones upload first, then reference the returned URL (`refurl`). Simplified from the
|
||||
@@ -36,7 +45,7 @@ function imageDimensions(file: File): Promise<{ width: number; height: number }>
|
||||
* the upload happen in the background): this awaits the upload before creating the message at all,
|
||||
* trading that "shows immediately while uploading" nicety for meaningfully simpler code.
|
||||
*/
|
||||
export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
export async function sendImage(topic: Topic, file: File, reply?: ReplyInfo | null): Promise<unknown> {
|
||||
const { width, height } = await imageDimensions(file);
|
||||
const content =
|
||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||
@@ -50,18 +59,18 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
||||
size: file.size,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
msg.head = Object.assign(msg.head || {}, mediaHead('image', reply));
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
export async function sendFile(topic: Topic, file: File, reply?: ReplyInfo | null): 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: 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());
|
||||
msg.head = Object.assign(msg.head || {}, mediaHead('file', reply));
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +81,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
|
||||
* field this app actually displays from doesn't depend on it, only a browser's native <audio>
|
||||
* scrubber would).
|
||||
*/
|
||||
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): Promise<unknown> {
|
||||
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number, reply?: ReplyInfo | null): Promise<unknown> {
|
||||
const file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
|
||||
const content =
|
||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||
@@ -85,6 +94,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
|
||||
filename: file.name,
|
||||
});
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
msg.head = Object.assign(msg.head || {}, mediaHead('voice', reply));
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { newClientMid } from '@/lib/tinode/send';
|
||||
import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
|
||||
|
||||
/**
|
||||
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
|
||||
@@ -21,8 +21,13 @@ export interface CallContent {
|
||||
}
|
||||
|
||||
/** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line
|
||||
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */
|
||||
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined';
|
||||
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it.
|
||||
* `busy` was previously excluded here (assumed to be a client-only, never-persisted value -- see
|
||||
* the now-superseded reasoning this replaced in stores/calls.ts/dispatch.ts) until reading the real
|
||||
* iOS/Android app source (2026-09-25 cross-client audit) confirmed it's a genuine enum member both
|
||||
* platforms' own SDKs declare for this exact server-controlled field (`ServerMessages.swift`'s
|
||||
* 7-case enum, `MsgServerData.java`'s `BUSY`), not a web-only invention. */
|
||||
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined' | 'busy';
|
||||
|
||||
export function callContentOf(msg: StoredMessage): CallContent | null {
|
||||
const webrtc = msg.head?.webrtc;
|
||||
@@ -63,7 +68,7 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
|
||||
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
|
||||
const msg = topic.createMessage(content as unknown as DraftyContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
|
||||
return publishWithReattach(topic, msg) as Promise<{ code: number; params?: { seq: number } }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,24 @@ export function onReauthenticated(callback: () => void): () => void {
|
||||
return () => reconnectAuthListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired at the top of resetTinode() (logout/"切换企业"/kicked). Same "plain listener set, not a
|
||||
* direct import" reasoning as onReauthenticated above -- topics.ts's own module-level caches
|
||||
* (confirmedReadSeq, previewPrefetched, messageSnapshots, goneTopicNames, chatListSnapshot) used to
|
||||
* outlive a logout entirely (real, reproduced bug: 'slf' is the same literal topic name for every
|
||||
* account, so confirmedReadSeq['slf'] from account A surviving into account B's session -- switched
|
||||
* to in the SAME tab, no full reload -- silently marked B's genuinely-unread self-chat messages as
|
||||
* read the next time reconcileMeSubs() ticked). This module has no reason to know what "reset" means
|
||||
* for topics.ts's own state, only that resetTinode() is the one place every teardown path (logout,
|
||||
* switch-tenant, kicked) already funnels through.
|
||||
*/
|
||||
const resetListeners = new Set<() => void>();
|
||||
|
||||
export function onTinodeReset(callback: () => void): () => void {
|
||||
resetListeners.add(callback);
|
||||
return () => resetListeners.delete(callback);
|
||||
}
|
||||
|
||||
function setStatus(next: ConnectionStatus) {
|
||||
if (status === next) return;
|
||||
if (next === 'authenticated') wasAuthenticated = true;
|
||||
@@ -136,13 +154,31 @@ export function clearKickedInfo() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* so the only usable hook is wrapping the connection's raw sendText and injecting the field into the
|
||||
* already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is a
|
||||
* real backend protocol extension, not an old-codebase-specific workaround.
|
||||
* Two backend protocol extensions the vendored JS SDK has no API for -- both fields the packet
|
||||
* builders (#initPacket/login(), both private) never write, so the only usable hook for either is
|
||||
* wrapping the connection's raw sendText and injecting the field into the already-serialized JSON:
|
||||
* - {hi}.tenant (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). Ported as-is
|
||||
* from the old app's tnSetup/installTenantHiPatch.
|
||||
* - {login}.login_type: confirmed live (server/user.go validateLoginTenant, called from every
|
||||
* session.login()) that scheme:"basic" logins now hard-require this field -- 0 enterprise /
|
||||
* 1 personal -- and fail with a 422 LOGIN_TYPE_REQUIRED without it (Zentao bug #33). tenant_code
|
||||
* is deliberately left unset: validateLoginTenant falls back to the session's own tenant (already
|
||||
* established by the {hi} patch above) when {login} omits it, for both the personal and
|
||||
* enterprise branches, so login_type is the only field this client needs to add. token-scheme
|
||||
* logins (restoreSession/reconnect re-auth) are untouched -- validateLoginTenant only requires
|
||||
* login_type when scheme is "basic".
|
||||
*/
|
||||
function installTenantHiPatch(t: Tinode) {
|
||||
/** The tenant the server session actually resolves to, captured when {hi} is sent (the server
|
||||
* falls back to 'default' when {hi}.tenant is absent). login_type is decided against THIS value,
|
||||
* not a fresh store read: the two decisions happen at different moments (hi on connect, login on
|
||||
* submit), and a store change in between would make login_type disagree with the session tenant,
|
||||
* which the server rejects with 422 policy violation / TENANT_CODE_FORBIDDEN -- rendered by
|
||||
* clients as "当前操作不符合安全策略要求". */
|
||||
let sessionTenantCode: string | null = null;
|
||||
/** One-shot override used by loginBasic's FORBIDDEN self-heal retry (see there). */
|
||||
let forcedLoginType: 0 | 1 | null = null;
|
||||
|
||||
function installOutgoingPacketPatches(t: Tinode) {
|
||||
const conn = t._connection;
|
||||
if (!conn || conn.__tenantPatched) return;
|
||||
const original = conn.sendText.bind(conn);
|
||||
@@ -152,10 +188,28 @@ function installTenantHiPatch(t: Tinode) {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.hi) {
|
||||
const tenant = useSessionStore.getState().tenant;
|
||||
// Whichever tenant (or absence of one) this {hi} declares, the server fixes the session
|
||||
// tenant from it right now -- so remember exactly that, for login_type decisions later.
|
||||
sessionTenantCode = (tenant?.code || 'default').trim().toLowerCase();
|
||||
if (tenant?.code) {
|
||||
parsed.hi.tenant = tenant.code;
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON -- send as is.
|
||||
}
|
||||
} else if (typeof msg === 'string' && msg.indexOf('"login":') >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
if (parsed.login && parsed.login.scheme === 'basic') {
|
||||
if (forcedLoginType !== null) {
|
||||
parsed.login.login_type = forcedLoginType;
|
||||
} else {
|
||||
const tenantCode = sessionTenantCode ?? (useSessionStore.getState().tenant?.code || 'default');
|
||||
parsed.login.login_type = tenantCode.trim().toLowerCase() === 'default' ? 1 : 0;
|
||||
}
|
||||
msg = JSON.stringify(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON -- send as is.
|
||||
@@ -173,7 +227,7 @@ function createTinode(): Tinode {
|
||||
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
|
||||
t.setHumanLanguage('zh');
|
||||
t.enableLogging(LOGGING_ENABLED, true);
|
||||
installTenantHiPatch(t);
|
||||
installOutgoingPacketPatches(t);
|
||||
|
||||
t.onConnect = () => {
|
||||
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
|
||||
@@ -268,11 +322,17 @@ export function getTinode(): Tinode {
|
||||
* since those are keyed by nothing but "is there currently a pending one" -- confirmed reachable via
|
||||
* TenantEntryPage.tsx's "切换企业" flow racing a slow-to-respond old host. */
|
||||
export function resetTinode() {
|
||||
resetListeners.forEach((cb) => cb());
|
||||
tinode?.disconnect();
|
||||
tinode = null;
|
||||
connectPromise = null;
|
||||
restorePromise = null;
|
||||
wasAuthenticated = false;
|
||||
// Both belong to the torn-down connection's session: the next getTinode() re-derives them from
|
||||
// the freshly-sent {hi}, and a stale non-null sessionTenantCode here would misclassify the very
|
||||
// first login_type of the rebuilt connection (which sends hi before any store read matters).
|
||||
sessionTenantCode = null;
|
||||
forcedLoginType = null;
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
@@ -335,7 +395,24 @@ export function loginBasic(login: string, password: string): Promise<LoginCtrl>
|
||||
loginPromise = (async () => {
|
||||
const t = getTinode();
|
||||
if (!t.isConnected()) await connect();
|
||||
return t.loginBasic(login, password);
|
||||
const ctrl = await t.loginBasic(login, password);
|
||||
// Self-heal the exact failure mode observed live (server validateLoginTenant): an enterprise-
|
||||
// meaning login_type=0 against a session whose tenant is 'default' is rejected with 422 policy
|
||||
// violation, reason=TENANT_CODE_FORBIDDEN -- users see "当前操作不符合安全策略要求". That can
|
||||
// only mean the session tenant is 'default', so retry once with the personal meaning (1) before
|
||||
// giving up; a success also re-syncs sessionTenantCode so later logins on this connection don't
|
||||
// repeat the mistake.
|
||||
if (ctrl.code === 422 && (ctrl.params as { reason?: string } | undefined)?.reason === 'TENANT_CODE_FORBIDDEN') {
|
||||
forcedLoginType = 1;
|
||||
try {
|
||||
const retry = await t.loginBasic(login, password);
|
||||
if (retry.code < 300) sessionTenantCode = 'default';
|
||||
return retry;
|
||||
} finally {
|
||||
forcedLoginType = null;
|
||||
}
|
||||
}
|
||||
return ctrl;
|
||||
})().finally(() => {
|
||||
loginPromise = null;
|
||||
});
|
||||
|
||||
@@ -60,6 +60,15 @@ export function loginErrorMessage(err: unknown): string {
|
||||
const known = translateCtrlError(err);
|
||||
if (known) return known;
|
||||
const code = (err as { code?: number })?.code;
|
||||
// 429 account locked: im_server's ErrAuthLocked puts the user-facing Chinese message (including
|
||||
// the remaining-minutes wording, e.g. "登录错误次数过多,账号已锁定,请 15 分钟后再试") into ctrl.text,
|
||||
// so the raw SDK message is that text with a status-code suffix baked in. Strip the suffix and show
|
||||
// the server copy as-is; the static fallback only fires if the server somehow sent no text. Kept as
|
||||
// a hardcoded zh string (no i18n key) to match every other entry in this file.
|
||||
if (code === 429) {
|
||||
const cleaned = (err as Error)?.message?.replace(/\s*\(\d+\)\s*$/, '').trim();
|
||||
return cleaned || '登录错误次数过多,请稍后再试';
|
||||
}
|
||||
if (code === 401 || code === 403) return '账号或密码错误';
|
||||
return (err as Error)?.message || '登录失败,请重试';
|
||||
}
|
||||
|
||||
+47
-40
@@ -1,11 +1,6 @@
|
||||
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
|
||||
|
||||
import { QUOTED_REPLY_LENGTH } from '@/lib/config';
|
||||
|
||||
function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent {
|
||||
if (content == null) return Drafty.init('');
|
||||
return typeof content === 'string' ? Drafty.init(content) : content;
|
||||
}
|
||||
import { buildReplyInfo } from '@/lib/messages/reply';
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
|
||||
@@ -28,6 +23,30 @@ export function newClientMid(): string {
|
||||
: `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `topic.publishDraft` with a single retry after a forced re-subscribe. Confirmed live,
|
||||
* 2026-09-21, on an independent native-Cluster verification deployment: killing the Pod holding a
|
||||
* topic's Cluster master tears down that topic's server-side proxy session on the surviving nodes
|
||||
* (`server/session.go`'s `s.publish` logs "must attach first" and replies with a 409/`ErrAttachFirst`)
|
||||
* WITHOUT notifying the client -- the SDK's local `_attached` flag is never reset on this path (only
|
||||
* `_gone()`/topic-deleted flows clear it), so `isSubscribed()`/`ensureTopicAttached()` keep reading a
|
||||
* stale `true` and can't catch this in advance. Every already-open conversation kept failing to send
|
||||
* until a fresh `subscribe()` was issued by hand. `publishDraft`'s own `_noForwarding` guard (set on
|
||||
* the first call) makes calling it again on the SAME `msg` object safe -- it re-sends the `{pub}`
|
||||
* without re-inserting a duplicate local echo, and the message's `x-im-client-mid` is unchanged so a
|
||||
* network-level duplicate is still deduped server-side. Only 409 + this exact message is treated as
|
||||
* recoverable; any other rejection (permissions, malformed content, network down) passes through
|
||||
* unchanged so it still surfaces via the caller's existing `showError` handling.
|
||||
*/
|
||||
export function publishWithReattach(topic: Topic, msg: StoredMessage): Promise<unknown> {
|
||||
return topic.publishDraft(msg, Promise.resolve()).catch((err: unknown) => {
|
||||
const code = err instanceof Error ? (err as Error & { code?: number }).code : undefined;
|
||||
const recoverable = code === 409 && err instanceof Error && err.message.includes('attach');
|
||||
if (!recoverable) throw err;
|
||||
return topic.subscribe().then(() => topic.publishDraft(msg, Promise.resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
|
||||
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
|
||||
@@ -38,7 +57,7 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +70,7 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
|
||||
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,9 +80,11 @@ export function sendPrebuiltContent(topic: Topic, content: DraftyContent | strin
|
||||
* cache (already flushed, or never existed).
|
||||
*
|
||||
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
|
||||
* newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back
|
||||
* to a fresh one only for a locally-cached failed message from before this field existed at all
|
||||
* (there's no original id to recover in that one case).
|
||||
* newClientMid()'s own comment above for why a retry specifically must not get a new id. The whole
|
||||
* original head is copied verbatim, which also keeps `x-im-reply` identical across retries (the
|
||||
* server requires head parity for a mid-deduped resend). Falls back to a fresh one only for a
|
||||
* locally-cached failed message from before this field existed at all (no original id to recover
|
||||
* in that one case).
|
||||
*/
|
||||
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
|
||||
const failed = topic.flushMessage(seq);
|
||||
@@ -73,7 +94,7 @@ export function retryMessage(topic: Topic, seq: number): Promise<unknown> | unde
|
||||
msg.head = Object.assign(msg.head || {}, failed.head, {
|
||||
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
|
||||
});
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
|
||||
@@ -81,33 +102,19 @@ export function messageIsMine(msg: StoredMessage, myUserId: string | null): bool
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply/quote (old app's messages-view.jsx handlePickReply/sendMessage): the quoted body is
|
||||
* truncated (Drafty.replyContent, QUOTED_REPLY_LENGTH) and wrapped with the sender's name
|
||||
* (Drafty.quote), then the whole quote block is INLINED into the outgoing content ahead of the
|
||||
* new text (Drafty.append) -- the receiving side never has to look up the original message to show
|
||||
* a quote preview. `head.reply` carries the original seq separately, purely so a click on the quote
|
||||
* block can scroll back to it.
|
||||
* Quoted reply (cross-client `x-im-reply` contract, aligned with iOS 2026-09-23): the quote is a
|
||||
* lightweight SNAPSHOT carried in `head["x-im-reply"]` (`{seq,type,text,from}`), NOT a Drafty QQ
|
||||
* block inlined into the content -- content stays the clean reply body. The receiving side renders
|
||||
* its own quote bar and locally re-fetches thumbnails/durations by seq. Builds the snapshot from
|
||||
* the quoted message via buildReplyInfo(); throws when the message isn't quotable (failed/pending/
|
||||
* system notice), which the UI gates before arming the reply state.
|
||||
*/
|
||||
export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise<unknown> {
|
||||
const trimmed = Drafty.replyContent(toDraftyContent(quoted.content), QUOTED_REPLY_LENGTH);
|
||||
const quoteBlock = Drafty.quote(quotedSenderLabel, quoted.from || '', trimmed);
|
||||
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
|
||||
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
|
||||
const msg = topic.createMessage(finalContent, false);
|
||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
|
||||
return topic.publishDraft(msg, Promise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-message forward, sent directly to one target topic. Simplified from the old app's own
|
||||
* flow (which stages the forwarded content and lets the user add a comment via the normal composer
|
||||
* before sending) -- this sends immediately on picking a target, trading that one nicety for a
|
||||
* much smaller "pick a contact" dialog. `head.forwarded` records the source for traceability; it
|
||||
* has no reader anywhere yet, matching the old app (no confirmed call site reads it back either).
|
||||
*/
|
||||
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
|
||||
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
|
||||
const newMsg = target.createMessage(content, false);
|
||||
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
|
||||
return target.publishDraft(newMsg, Promise.resolve());
|
||||
export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Promise<unknown> {
|
||||
const reply = buildReplyInfo(quoted);
|
||||
if (!reply) throw new Error('该消息不可引用');
|
||||
const parsed = Drafty.parse(text);
|
||||
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||
const msg = topic.createMessage(content, false);
|
||||
msg.head = Object.assign(msg.head || {}, { 'x-im-reply': reply, 'x-im-client-mid': newClientMid() });
|
||||
return publishWithReattach(topic, msg);
|
||||
}
|
||||
|
||||
Vendored
+19
-1
@@ -183,6 +183,24 @@ declare module 'tinode-sdk' {
|
||||
getMeta(query: unknown): Promise<unknown>;
|
||||
startMetaQuery(): MetaGetBuilder;
|
||||
|
||||
/**
|
||||
* Private/internal SDK method (underscore prefix, not part of the documented API) -- normally only
|
||||
* ever called by the SDK itself when a real `{meta desc}` server response arrives. Declared here
|
||||
* and used deliberately, NOT as a first choice: `getMeta(startMetaQuery().withDesc().build())` (the
|
||||
* sanctioned public API for exactly this "re-pull desc from server" need) was live-instrumented
|
||||
* against this backend and found to be genuinely unreliable for group topics -- consistently a
|
||||
* 504 timeout (~5.4s, every attempt, including 10+s after the write) on a brand-new topic, and on
|
||||
* an established topic a fast-but-STALE response whose `public.fn` still reflected the pre-rename
|
||||
* name (confirmed by round-tripping a real REST rename immediately followed by this call). Calling
|
||||
* this directly with data already known-fresh (from a REST response we trust) sidesteps that
|
||||
* broken path entirely -- confirmed by reading the SDK's own source that it merges `e.public` onto
|
||||
* the topic's existing `public` object (other fields like `version` survive) and, for a non-`me`
|
||||
* topic, forwards the update to the `me` topic's own subscription cache via `onMetaSub`/
|
||||
* `onSubsUpdated` -- i.e. the exact same fan-out a genuine server push would trigger. Live-confirmed
|
||||
* this reaches `me.contacts()` (same object reference as `topic.public`, verified live).
|
||||
*/
|
||||
_processMetaDesc(desc: { public?: Partial<TopicDescPublic> }): void;
|
||||
|
||||
/** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */
|
||||
createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage;
|
||||
/**
|
||||
@@ -270,7 +288,7 @@ declare module 'tinode-sdk' {
|
||||
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
|
||||
onData?: (data?: StoredMessage) => void;
|
||||
onAllMessagesReceived?: (count: number) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
|
||||
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call' | 'im'; 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
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
// getParams -- so this always leaves first when already subscribed, then resubscribes.
|
||||
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { getGroup } from '@/lib/api/groups';
|
||||
import { MESSAGES_PAGE } from '@/lib/config';
|
||||
|
||||
import { getTinode, onReauthenticated } from './connection';
|
||||
import { getTinode, onReauthenticated, onTinodeReset } from './connection';
|
||||
|
||||
const topicListeners = new Map<string, Set<() => void>>();
|
||||
const anyTopicListeners = new Set<() => void>();
|
||||
@@ -86,10 +87,65 @@ export function markConfirmedRead(topicName: string, seq: number) {
|
||||
if (seq > prev) confirmedReadSeq.set(topicName, seq);
|
||||
}
|
||||
|
||||
// Live-confirmed root cause of a real reported bug ("聊天消息框滚动到顶部或底部时UI会抖动闪烁"):
|
||||
// `topic.onData = notify` (wireTopicCallbacks below) fires once per INDIVIDUAL message, not once per
|
||||
// batch -- confirmed by instrumenting a real `loadOlder()` page fetch (24 older messages): the DOM
|
||||
// mutated in ~14 separate small steps over ~400ms, each one growing `scrollHeight` a little and each
|
||||
// one racing the OTHER open effect that reads it (loadOlder's own scroll-compensation, and
|
||||
// ConversationView's separate "autoscroll to bottom" effect) -- neither gets a stable, final
|
||||
// `scrollHeight` to work with until the whole page has actually finished arriving, so each of the ~14
|
||||
// intermediate re-renders nudges `scrollTop` again, which is what actually reads as "shaking" rather
|
||||
// than one clean jump. This wasn't reproducible by reasoning about a single `getMessagesPage()` call
|
||||
// in isolation -- it only showed up instrumenting `scrollTop` across a real fetch.
|
||||
// Fix: coalesce any notifications that land within the same animation frame into one flush, so a
|
||||
// burst of N `onData` calls (initial page load, `loadOlder()`, or a fast run of incoming messages)
|
||||
// triggers at most one re-render per frame instead of N -- imperceptible as a delay, but it means
|
||||
// `scrollHeight` is only ever read by a re-render once it reflects everything that arrived so far in
|
||||
// that frame, not some in-between partial state. `messageSnapshots.delete` stays synchronous (it's
|
||||
// pure cache invalidation, not a re-render) so a snapshot read between two notifications in the same
|
||||
// frame still sees the latest data, only the LISTENER callbacks (which is what schedules React's own
|
||||
// re-render) are what get batched.
|
||||
const pendingTopicNotifications = new Set<string>();
|
||||
let pendingAnyNotification = false;
|
||||
let notifyFrameScheduled = false;
|
||||
|
||||
function flushTopicNotifications() {
|
||||
notifyFrameScheduled = false;
|
||||
const topics = [...pendingTopicNotifications];
|
||||
pendingTopicNotifications.clear();
|
||||
const flushAny = pendingAnyNotification;
|
||||
pendingAnyNotification = false;
|
||||
for (const topicName of topics) {
|
||||
topicListeners.get(topicName)?.forEach((cb) => cb());
|
||||
}
|
||||
if (flushAny) {
|
||||
anyTopicListeners.forEach((cb) => cb());
|
||||
}
|
||||
}
|
||||
|
||||
function notifyTopic(topicName: string) {
|
||||
messageSnapshots.delete(topicName);
|
||||
topicListeners.get(topicName)?.forEach((cb) => cb());
|
||||
anyTopicListeners.forEach((cb) => cb());
|
||||
pendingTopicNotifications.add(topicName);
|
||||
pendingAnyNotification = true;
|
||||
if (!notifyFrameScheduled) {
|
||||
notifyFrameScheduled = true;
|
||||
// Live-confirmed real bug (surfaced while testing an unrelated group-rename fix, in a webview whose
|
||||
// frame compositing was paused, i.e. genuinely not painting -- not just an ordinary backgrounded
|
||||
// browser tab): a `requestAnimationFrame` scheduled while the page isn't compositing frames can go
|
||||
// uncalled indefinitely rather than merely deferred-until-visible. Since `notifyFrameScheduled` only
|
||||
// resets to false inside the callback, one dropped frame permanently wedges this guard, silently
|
||||
// freezing EVERY future topic-list update (not just the group name) until a full page reload -- data
|
||||
// kept arriving and updating correctly underneath (confirmed live: `me.contacts()` had the right
|
||||
// data the whole time), only the UI never found out. `document.hidden` costs nothing to check and
|
||||
// `setTimeout` is never silently dropped the way this rAF was, so route through it whenever the page
|
||||
// isn't visible; keep `requestAnimationFrame` for the visible case since that's what actually gives
|
||||
// the intended one-flush-per-paint batching this function exists for.
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
setTimeout(flushTopicNotifications, 0);
|
||||
} else {
|
||||
requestAnimationFrame(flushTopicNotifications);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
|
||||
@@ -110,7 +166,7 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
|
||||
}
|
||||
|
||||
export interface RawTopicInfo {
|
||||
what: 'read' | 'recv' | 'kp' | 'call';
|
||||
what: 'read' | 'recv' | 'kp' | 'call' | 'im';
|
||||
seq?: number;
|
||||
from?: string;
|
||||
event?: string;
|
||||
@@ -128,6 +184,35 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
|
||||
return () => infoListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK only routes an incoming {info} packet into Topic._routeInfo (and from there to
|
||||
* topic.onInfo -> the infoListeners fanout above) when the packet's topic name is already in the
|
||||
* client's topic cache -- tinode.dev.js's dispatcher does `#cacheGet('topic', pkt.info.topic)` and
|
||||
* silently drops the frame on a miss. Friend-request signals (Zentao #3) arrive with `topic` set to
|
||||
* the RECIPIENT'S OWN user id (publishIMRelationEvent -> sessionStore.DeliverToUser, i.e. addressed
|
||||
* to the user's `me` topic), but the me topic is cached under the literal "me" -- so that lookup
|
||||
* misses every time and the frame never reached any info listener. Live-confirmed 2026-09-18: the
|
||||
* SDK's own logger showed the raw `friend.request.rejected` frame arriving on the socket while
|
||||
* AppShell's subscribeTopicInfo callback never fired. The connection-level onInfoMessage hook sees
|
||||
* EVERY info packet regardless of cache state, so forward ONLY the frames the topic-level path will
|
||||
* not deliver (isTopicCached false) to the same infoListeners: cached topics keep flowing through
|
||||
* topic.onInfo exactly once (no duplicates), uncached ones stop being silently lost. Both members
|
||||
* are real SDK runtime members absent from the hand-maintained .d.ts (same cast rationale as the
|
||||
* read/unread reads in getChatListSnapshot). Installed from subscribeMeTopic -- the one point every
|
||||
* login flows through -- and re-installed there after resetTinode() rebuilds the instance.
|
||||
*/
|
||||
function installUncachedInfoFallback(t: unknown) {
|
||||
const sdk = t as {
|
||||
onInfoMessage?: ((info: RawTopicInfo & { topic: string }) => void) | null;
|
||||
isTopicCached?: (topicName: string) => boolean;
|
||||
};
|
||||
sdk.onInfoMessage = (info) => {
|
||||
if (info && sdk.isTopicCached && !sdk.isTopicCached(info.topic)) {
|
||||
infoListeners.forEach((cb) => cb(info.topic, info));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const goneListeners = new Set<(topicName: string) => void>();
|
||||
|
||||
const newGroupListeners = new Set<(topicName: string) => void>();
|
||||
@@ -240,6 +325,43 @@ function wireTopicCallbacks(topic: Topic) {
|
||||
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
|
||||
markTopicGoneAndNotify(topic.name);
|
||||
}
|
||||
// docs/IM-前后端对接文档.md:416,1256: recall/edit push a custom `{info what:"im",
|
||||
// event:"message.recalled"|"message.edited", payload:{seq,version}}` to every other ONLINE
|
||||
// topic member -- explicitly NOT a native Tinode mechanism (`_routeInfo`'s switch has no `"im"`
|
||||
// case, confirmed reading tinode.dev.js the same way the group.dissolved gap above was), so
|
||||
// nothing here ever picked it up before. `notify()` above still fired (it's unconditional), so a
|
||||
// component watching this topic WOULD re-render -- but the SDK's own local message cache was
|
||||
// never told the content changed, so it re-rendered the SAME stale pre-recall/pre-edit bubble.
|
||||
// The doc's own instruction is explicit: "按 topic+seq+version 刷新本地消息,不要等待用户重新
|
||||
// 进入会话才更新" -- live-confirmed this was exactly what was happening instead (recall/edit only
|
||||
// ever showed up for the OTHER party after something unrelated caused a full resync, e.g.
|
||||
// reopening the conversation). Reuses the exact `getMeta(...).withData(seq,seq+1,1)` targeted
|
||||
// single-message refetch ConversationView's own jump-to-message feature already established.
|
||||
if (info?.what === 'im' && (info.event === 'message.recalled' || info.event === 'message.edited')) {
|
||||
const seq = info.payload?.seq;
|
||||
if (typeof seq === 'number') {
|
||||
topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {});
|
||||
}
|
||||
}
|
||||
// docs/IM-前后端对接文档.md:1542 documents this push as `group.profile.updated`, but a real rename,
|
||||
// live-instrumented (logging the raw `info` object as it arrived), showed the actual wire event is
|
||||
// `{what:"group", event:"group.settings.updated"}` -- same doc-vs-wire drift already seen elsewhere
|
||||
// in this backend. Matching the real event name fixes only half the bug: the sidebar/header name
|
||||
// comes from `contact.public?.fn` (native SDK state, see ChatListColumn.tsx), which a REST-only
|
||||
// mutation never touches for anyone, and the "obvious" fix -- `getMeta(startMetaQuery().withDesc()
|
||||
// .build())`, the SDK's own sanctioned way to re-pull desc from the server -- was live-instrumented
|
||||
// too and found to be genuinely broken against this backend: a consistent 504 timeout (~5.4s, every
|
||||
// attempt, still failing 10+s after the write) on a brand-new topic, and on an established topic a
|
||||
// fast-but-stale response whose `public.fn` still held the pre-rename name. Using the already-
|
||||
// reliable REST `getGroup()` instead (confirmed live to return the fresh name immediately after a
|
||||
// rename, every time) and applying it locally via `_processMetaDesc` -- the same internal method the
|
||||
// SDK itself calls when a real `{meta desc}` response arrives, live-confirmed to correctly reach
|
||||
// `me.contacts()` -- sidesteps the broken path entirely instead of routing through it.
|
||||
if (groupEvent?.what === 'group' && groupEvent.event === 'group.settings.updated') {
|
||||
getGroup(topic.name)
|
||||
.then((data) => topic._processMetaDesc({ public: { fn: data.name, name: data.name } }))
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
topic.onMetaDesc = notify;
|
||||
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
|
||||
@@ -283,7 +405,9 @@ let inFlightMeSubscribe: Promise<unknown> | null = null;
|
||||
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
|
||||
export function subscribeMeTopic(): Promise<unknown> {
|
||||
if (inFlightMeSubscribe) return inFlightMeSubscribe;
|
||||
const me = getTinode().getMeTopic();
|
||||
const tinode = getTinode();
|
||||
installUncachedInfoFallback(tinode);
|
||||
const me = tinode.getMeTopic();
|
||||
wireTopicCallbacks(me);
|
||||
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
|
||||
// TopicMe's OWN single-slot callback, separate from onData/onInfo/onPres -- wireTopicCallbacks
|
||||
@@ -552,6 +676,25 @@ onReauthenticated(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Real, reproduced bug (2026-09-25 review): every module-level cache below is scoped by topic NAME,
|
||||
* not by account -- and topic names collide across accounts by construction, not edge case (`'slf'`
|
||||
* is the literal self-chat topic name for every account, `isChatListTopicName` below). Without this,
|
||||
* logging out and into a DIFFERENT account in the same tab (no full reload -- resetTinode() itself
|
||||
* never reloads the page) carries the previous account's cached state forward: `confirmedReadSeq`
|
||||
* specifically was confirmed to silently mark the new account's genuinely-unread 'slf' messages as
|
||||
* read the next time reconcileMeSubs() ticked, since it read the OLD account's higher confirmed seq
|
||||
* as a floor for the new account's much-lower real one. Same reasoning extends to every other cache
|
||||
* here: none of them should outlive the session that populated them.
|
||||
*/
|
||||
onTinodeReset(() => {
|
||||
confirmedReadSeq.clear();
|
||||
previewPrefetched.clear();
|
||||
messageSnapshots.clear();
|
||||
goneTopicNames.clear();
|
||||
chatListSnapshot = null;
|
||||
});
|
||||
|
||||
const ME_RECONCILE_INTERVAL_MS = 30_000;
|
||||
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
@@ -626,6 +769,24 @@ function reconcileMeSubs() {
|
||||
c.unread = prev.unread;
|
||||
notifyTopic(c.topic);
|
||||
}
|
||||
// Separate, real bug this same guard doesn't cover (live-confirmed 2026-09-24, reported
|
||||
// directly): sending your OWN message into a topic that's already open bumps `confirmedReadSeq`
|
||||
// synchronously (ConversationView's noteRead effect), long before the async `{note read}` this
|
||||
// relies on has actually reached the server. A `{get}` already in flight since before that
|
||||
// moment can come back with a real, ADVANCED `seq` (the new message the server just accepted)
|
||||
// paired with a `read` that hasn't caught up server-side yet -- `seq <= prev.seq` above is
|
||||
// false (seq genuinely changed), so that guard correctly doesn't fire, and this spurious
|
||||
// `unread:1` gets applied verbatim even though the user is the one who just sent it. Clamp
|
||||
// `read` to what THIS client already knows it has confirmed seeing, regardless of what the
|
||||
// fetch says -- a floor, never pushes `read` past the server's own value when the server is
|
||||
// genuinely ahead of this record (e.g. read from a different, already-synced device).
|
||||
const confirmed = confirmedReadSeq.get(c.topic) || 0;
|
||||
if (confirmed > (c.read || 0)) {
|
||||
c.read = confirmed;
|
||||
if ((c.recv || 0) < confirmed) c.recv = confirmed;
|
||||
c.unread = Math.max(0, (c.seq || 0) - confirmed);
|
||||
notifyTopic(c.topic);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
+32
-12
@@ -35,11 +35,17 @@ interface CallState {
|
||||
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
|
||||
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
|
||||
hasLocalVideo: boolean;
|
||||
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
|
||||
// comment) so it's deliberately NOT part of `CallWebrtcState` -- but it IS a real value this app's
|
||||
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
|
||||
// caller receives, which flows straight into this same field.
|
||||
endReason: CallWebrtcState | 'busy' | null;
|
||||
// Set once `joinCall()` actually resolves (mic track created and published, or for video, mic+
|
||||
// camera) -- distinct from `phase==='connected'`, which flips the instant the SIGNALING accept
|
||||
// arrives, before any media has actually joined. `CallWindow`'s status line previously showed a
|
||||
// ticking duration + "网络良好" from the moment `phase` became 'connected', which live-confirmed
|
||||
// stays true even when the mic never actually attaches at all (permission denied/no device) -- a
|
||||
// real user reported this as "看起来接通了" for a call where no audio ever worked on either side.
|
||||
mediaJoined: boolean;
|
||||
// `busy` is now part of CallWebrtcState itself (2026-09-25: confirmed against real iOS/Android
|
||||
// source that the server genuinely uses this value, not just this app's own live
|
||||
// `sendCallHangup(..., 'busy')` signal a second caller receives) -- no separate union needed here.
|
||||
endReason: CallWebrtcState | null;
|
||||
/** Was THIS session the one that placed the call (via startCall), not the one that received it --
|
||||
* needed to pick "已取消" vs "未接听" for the same wire value the same way dispatch.ts's
|
||||
* callResultDisplay already does for the permanent chat-bubble record of the same call. */
|
||||
@@ -58,7 +64,7 @@ interface CallState {
|
||||
switchCamera: () => Promise<void>;
|
||||
setHasRemoteVideo: (v: boolean) => void;
|
||||
_reset: () => void;
|
||||
_endWithReason: (reason: CallWebrtcState | 'busy') => void;
|
||||
_endWithReason: (reason: CallWebrtcState) => void;
|
||||
}
|
||||
|
||||
const IDLE = {
|
||||
@@ -74,6 +80,7 @@ const IDLE = {
|
||||
connectedAt: null,
|
||||
hasRemoteVideo: false,
|
||||
hasLocalVideo: false,
|
||||
mediaJoined: false,
|
||||
endReason: null,
|
||||
wasCaller: false,
|
||||
mediaError: null,
|
||||
@@ -123,7 +130,7 @@ export const useCallStore = create<CallState>((set, get) => ({
|
||||
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
|
||||
if (get().callId !== callId) return;
|
||||
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
|
||||
if (get().callId === callId) set({ hasLocalVideo: callType === 'video' });
|
||||
if (get().callId === callId) set({ hasLocalVideo: callType === 'video', mediaJoined: true });
|
||||
} catch {
|
||||
// Same guard the structurally-identical caller-side accept handler below already has, for the
|
||||
// identical reason: `callId` here is the ORIGINAL id this whole acceptCall() call was for,
|
||||
@@ -138,7 +145,14 @@ export const useCallStore = create<CallState>((set, get) => ({
|
||||
// just only when THIS call is still the live one.
|
||||
if (get().callId !== callId) return;
|
||||
const { topic: currentTopic, seq: currentSeq } = get();
|
||||
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId);
|
||||
// `reason: 'disconnected'` (not the default no-reason hang-up) so the OTHER side's own
|
||||
// `info.event==='hang-up'` handler can tell this apart from a normal hang-up too -- without it,
|
||||
// the peer's `connectedAt` is already truthy (set the moment accept/accept-event landed, same
|
||||
// as this side's), so its own reason-mapping fell through to 'finished' and both the live
|
||||
// overlay AND the permanent chat-history record showed a normal "通话时长" for a call where
|
||||
// media never actually worked on either end. Live-confirmed: both sides recorded "通话时长
|
||||
// 00:03" for a call that failed getUserMedia immediately after accept.
|
||||
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId, 'disconnected');
|
||||
leaveCall();
|
||||
get()._endWithReason('disconnected');
|
||||
}
|
||||
@@ -321,7 +335,7 @@ export function initCallSignaling() {
|
||||
.then(async (join) => {
|
||||
if (useCallStore.getState().callId !== callId) return;
|
||||
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
|
||||
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video' });
|
||||
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video', mediaJoined: true });
|
||||
})
|
||||
.catch(() => {
|
||||
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
|
||||
@@ -330,7 +344,9 @@ export function initCallSignaling() {
|
||||
// for a call that never actually connected media at all -- go straight to the honest
|
||||
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
|
||||
if (useCallStore.getState().callId !== callId) return;
|
||||
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId);
|
||||
// `reason: 'disconnected'` -- same fix as acceptCall()'s structurally identical catch,
|
||||
// so the callee's own hang-up handler maps this to 'disconnected' instead of 'finished'.
|
||||
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId, 'disconnected');
|
||||
leaveCall();
|
||||
useCallStore.getState()._endWithReason('disconnected');
|
||||
});
|
||||
@@ -346,8 +362,12 @@ export function initCallSignaling() {
|
||||
// already been sending a real `reason:'declined'` all along, just never read back out here),
|
||||
// and a caller who cancelled before pickup made the CALLEE's overlay wrongly show "通话已结束"
|
||||
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
|
||||
// local hangUp() above already makes for its own no-reason-given case.
|
||||
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
|
||||
// local hangUp() above already makes for its own no-reason-given case. `'disconnected'` is the
|
||||
// same fix, for the media-join-failed case: acceptCall()/the accept-handler's own catch above
|
||||
// now send this explicit reason so the OTHER side shows "连接异常" too, not a fake "通话已结束".
|
||||
useCallStore
|
||||
.getState()
|
||||
._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : reason === 'disconnected' ? 'disconnected' : connectedAt ? 'finished' : 'missed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* "Tap any avatar/name -> profile popup", app-wide (message bubbles, @mentions inside message text,
|
||||
* group member rows/grid, etc.) -- one global store + a single `<UserCardRoot>` mounted once at the
|
||||
* app root (App.tsx), the same shape as `stores/confirmDialog.ts`/`stores/calls.ts`: callable from any
|
||||
* event handler with no natural "own a piece of JSX for this" home (a Drafty-rendered mention span
|
||||
* deep inside message content, for one), not a per-component `useState`.
|
||||
*/
|
||||
interface UserCardState {
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export const useUserCardStore = create<UserCardState>(() => ({ userId: null }));
|
||||
|
||||
export function openUserCard(userId: string) {
|
||||
if (!userId) return;
|
||||
useUserCardStore.setState({ userId });
|
||||
}
|
||||
|
||||
export function closeUserCard() {
|
||||
useUserCardStore.setState({ userId: null });
|
||||
}
|
||||
+2
-2
@@ -27,8 +27,8 @@ export default defineConfig({
|
||||
// straight to the tenant's real host regardless (confirmed working this way throughout the old
|
||||
// app's development too).
|
||||
proxy: {
|
||||
'/v1/im': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
|
||||
'/v0/file': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
|
||||
'/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
||||
'/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user