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 submitAbuseReport = body => imPost('/reports/abuse', body);
|
||||||
export const getFeedbackConfig = () => imGet('/feedback/config');
|
export const getFeedbackConfig = () => imGet('/feedback/config');
|
||||||
export const submitFeedback = body => imPost('/feedback', {client_context: clientContext(), ...body});
|
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 { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
|
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
|
// 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
|
// 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 {
|
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() {
|
render() {
|
||||||
|
const {contactItems} = this.state;
|
||||||
return (
|
return (
|
||||||
<div className="scrollable-panel">
|
<div className="scrollable-panel">
|
||||||
<div className="panel-form-column">
|
<div className="panel-form-column">
|
||||||
<FlatLink href={LINK_CONTACT_US} icon="email">
|
{contactItems.length > 0 ?
|
||||||
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
|
contactItems.map((item, idx) => {
|
||||||
</FlatLink>
|
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">
|
<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" />
|
<FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
|
||||||
</FlatLink>
|
</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 { 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 { AppShell } from '@/app/AppShell';
|
||||||
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
|
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
|
||||||
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
|
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
|
||||||
|
import { Icon } from '@/app/Icon';
|
||||||
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
|
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
|
||||||
import { RequireAuth } from '@/app/RequireAuth';
|
import { RequireAuth } from '@/app/RequireAuth';
|
||||||
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
|
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
|
||||||
@@ -34,6 +35,30 @@ import { SystemMessagesView } from '@/features/system/SystemMessagesView';
|
|||||||
|
|
||||||
const queryClient = new QueryClient();
|
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([
|
const router = createBrowserRouter([
|
||||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||||
{ path: 'login', element: <LoginPage /> },
|
{ path: 'login', element: <LoginPage /> },
|
||||||
@@ -45,6 +70,7 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
|
errorElement: <RouteErrorBoundary />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/messages" replace /> },
|
{ index: true, element: <Navigate to="/messages" replace /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect } from 'react';
|
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 { Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { UserCardRoot } from '@/app/UserCardRoot';
|
||||||
import { CallRoot } from '@/features/calls/CallRoot';
|
import { CallRoot } from '@/features/calls/CallRoot';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
import { useBootstrap } from '@/lib/api/hooks';
|
||||||
import { getMe } from '@/lib/api/profile';
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
|
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
|
||||||
import { useChatList } from '@/lib/tinode/hooks';
|
import { useChatList } from '@/lib/tinode/hooks';
|
||||||
|
import { subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||||
import { initCallSignaling } from '@/stores/calls';
|
import { initCallSignaling } from '@/stores/calls';
|
||||||
import { useNotificationNavStore } from '@/stores/notificationNav';
|
import { useNotificationNavStore } from '@/stores/notificationNav';
|
||||||
|
|
||||||
@@ -35,6 +37,23 @@ export function AppShell() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initCallSignaling();
|
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
|
// "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
|
// 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:
|
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
|
||||||
@@ -80,6 +99,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CallRoot />
|
<CallRoot />
|
||||||
|
<UserCardRoot />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -20,14 +20,28 @@ interface AvatarProps {
|
|||||||
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
|
* 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
|
* 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 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
|
* `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape
|
||||||
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
|
* `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat
|
||||||
* used for chat attachments) expects.
|
* 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) {
|
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) {
|
||||||
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
|
const isAbsoluteUrl = !!avatarFileId && /^https?:\/\//.test(avatarFileId);
|
||||||
if (remote.url) {
|
const remote = useAuthedBlobUrl(avatarFileId && !isAbsoluteUrl ? `/v0/file/s/${avatarFileId}` : undefined);
|
||||||
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
|
const resolvedUrl = isAbsoluteUrl ? avatarFileId : remote.url;
|
||||||
|
if (resolvedUrl) {
|
||||||
|
return <img src={resolvedUrl} alt="" className={`object-cover ${className}`} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
|
<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
|
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
||||||
* unmounted between full-screen and minimized). */
|
* unmounted between full-screen and minimized). */
|
||||||
function MinimizedCallBar() {
|
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);
|
const elapsed = useElapsedSeconds(connectedAt);
|
||||||
return (
|
return (
|
||||||
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
||||||
@@ -141,7 +141,10 @@ function MinimizedCallBar() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex min-w-0 flex-col">
|
<span className="flex min-w-0 flex-col">
|
||||||
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
<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>
|
</span>
|
||||||
</button>
|
</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">
|
<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,
|
phase,
|
||||||
peerName,
|
peerName,
|
||||||
connectedAt,
|
connectedAt,
|
||||||
|
mediaJoined,
|
||||||
muted,
|
muted,
|
||||||
cameraOff,
|
cameraOff,
|
||||||
hasRemoteVideo,
|
hasRemoteVideo,
|
||||||
@@ -189,7 +193,11 @@ function CallWindow() {
|
|||||||
|
|
||||||
if (minimized) return <MinimizedCallBar />;
|
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
|
// 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 "挂断"
|
// "发起方取消发送 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.
|
// 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>
|
<strong className="text-base font-semibold">{peerName}</strong>
|
||||||
<span className="flex items-center gap-1 text-xs text-white/70">
|
<span className="flex items-center gap-1 text-xs text-white/70">
|
||||||
{phase === 'connected' ? (
|
{phase === 'connected' ? (
|
||||||
<>
|
mediaJoined ? (
|
||||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
<>
|
||||||
</>
|
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'正在连接中…'
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
'正在等待对方接听…'
|
'正在等待对方接听…'
|
||||||
)}
|
)}
|
||||||
@@ -294,7 +306,7 @@ function CallWindow() {
|
|||||||
>
|
>
|
||||||
{avatarLetter(peerName)}
|
{avatarLetter(peerName)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
|
<span className="text-sm text-white/70">{phase === 'connected' && mediaJoined ? '对方已关闭摄像头' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
||||||
@@ -307,12 +319,12 @@ function CallWindow() {
|
|||||||
</span>
|
</span>
|
||||||
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
||||||
<span className="mt-1 text-sm text-white/70">
|
<span className="mt-1 text-sm text-white/70">
|
||||||
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
|
{phase === 'connected' ? (mediaJoined ? formatElapsed(elapsed) : '正在连接中…') : '正在等待对方接听…'}
|
||||||
</span>
|
</span>
|
||||||
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
{/* 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
|
above); mobile silently dropped it, showing only the elapsed-time line with no
|
||||||
indication of WHY there's no video. */}
|
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>
|
</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
|
// 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
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{item.snapshot.thumbnail_file_id ? (
|
{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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/app/Toast';
|
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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||||
import { useEscapeKey } from '@/lib/hooks';
|
import { useEscapeKey } from '@/lib/hooks';
|
||||||
@@ -24,12 +24,15 @@ interface GroupAnnouncementModalProps {
|
|||||||
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
|
* 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
|
* 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.
|
* -- 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
|
* When nothing has been published yet, the endpoint still returns `200` with every field
|
||||||
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
|
* zero-valued, never `404`/`null` -- checked below via `content` emptiness, not object existence (a
|
||||||
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
|
* real bug this file had before this fix: `!query.data` was never true, so it rendered a blank
|
||||||
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
|
* "announcement" -- empty name, empty date -- instead of the empty state).
|
||||||
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
|
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
|
||||||
* of the empty state).
|
* 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) {
|
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
|
||||||
const queryClient = useQueryClient();
|
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 membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||||
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
|
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({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
||||||
onSuccess: (data) => {
|
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>
|
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal
|
|||||||
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
||||||
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
||||||
import {
|
import {
|
||||||
|
canRemoveMember,
|
||||||
disbandGroup,
|
disbandGroup,
|
||||||
getGroup,
|
getGroup,
|
||||||
getMyGroupProfile,
|
getMyGroupProfile,
|
||||||
@@ -19,18 +20,22 @@ import {
|
|||||||
getGroupSettings,
|
getGroupSettings,
|
||||||
getGroupSummary,
|
getGroupSummary,
|
||||||
leaveGroup,
|
leaveGroup,
|
||||||
|
updateGroup,
|
||||||
updateGroupMemberSettings,
|
updateGroupMemberSettings,
|
||||||
updateMyGroupNickname,
|
updateMyGroupNickname,
|
||||||
type GroupMember,
|
type GroupMember,
|
||||||
|
type GroupProfile,
|
||||||
type GroupSettings,
|
type GroupSettings,
|
||||||
} from '@/lib/api/groups';
|
} from '@/lib/api/groups';
|
||||||
import type { MediaType } from '@/lib/api/messages';
|
import type { MediaType } from '@/lib/api/messages';
|
||||||
|
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||||
import { useTinode } from '@/lib/tinode/hooks';
|
import { useTinode } from '@/lib/tinode/hooks';
|
||||||
import { forgetTopic } from '@/lib/tinode/topics';
|
import { forgetTopic } from '@/lib/tinode/topics';
|
||||||
import { requestConfirm } from '@/stores/confirmDialog';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
import { notify } from '@/stores/globalToast';
|
import { notify } from '@/stores/globalToast';
|
||||||
import { requestActionToken } from '@/stores/securityChallenge';
|
import { requestActionToken } from '@/stores/securityChallenge';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { openUserCard } from '@/stores/userCard';
|
||||||
|
|
||||||
interface GroupDetailColumnProps {
|
interface GroupDetailColumnProps {
|
||||||
topicId: string;
|
topicId: string;
|
||||||
@@ -68,6 +73,7 @@ function RoleCorner({ role }: { role: GroupMember['role'] }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
|
* 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,
|
* (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 myUserId = useSessionStore((s) => s.myUserId);
|
||||||
const [nicknameEditing, setNicknameEditing] = useState(false);
|
const [nicknameEditing, setNicknameEditing] = useState(false);
|
||||||
const [nicknameDraft, setNicknameDraft] = useState('');
|
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 [errorToast, setErrorToast] = useState('');
|
||||||
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
||||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
setNicknameEditing(false);
|
setNicknameEditing(false);
|
||||||
setNicknameDraft('');
|
setNicknameDraft('');
|
||||||
|
setNameEditing(false);
|
||||||
|
setNameDraft('');
|
||||||
setErrorToast('');
|
setErrorToast('');
|
||||||
setOpenModal(null);
|
setOpenModal(null);
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
@@ -152,6 +167,25 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
onError: (err) => showError(err, '昵称保存失败'),
|
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
|
// 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
|
// 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
|
// 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 () => {
|
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 {
|
try {
|
||||||
await tinode.getTopic(topicId).delMessagesAll(false);
|
await tinode.getTopic(topicId).delMessagesAll(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -209,6 +252,14 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
|
|
||||||
const members = membersQuery.data?.items || [];
|
const members = membersQuery.data?.items || [];
|
||||||
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
|
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 (
|
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]">
|
<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}
|
avatarFileId={groupQuery.data?.avatar_file_id}
|
||||||
className="h-13 w-13 flex-none rounded-xl text-xl"
|
className="h-13 w-13 flex-none rounded-xl text-xl"
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
{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">
|
<span className="text-xs text-text-tertiary-2">
|
||||||
群号 {topicId} · {memberCount} 人
|
群号 {topicId} · {memberCount} 人
|
||||||
</span>
|
</span>
|
||||||
@@ -290,7 +382,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
</button>
|
</button>
|
||||||
<div className="grid grid-cols-5 gap-2 md:mt-2">
|
<div className="grid grid-cols-5 gap-2 md:mt-2">
|
||||||
{members.slice(0, 9).map((m) => (
|
{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
|
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
|
||||||
desktop's 40px (unconditional here before this fix). */}
|
desktop's 40px (unconditional here before this fix). */}
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -301,7 +398,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
/>
|
/>
|
||||||
<RoleCorner role={m.role} />
|
<RoleCorner role={m.role} />
|
||||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
{canInvite ? (
|
{canInvite ? (
|
||||||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
// 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>
|
<span className="text-[11px] text-text-secondary">邀请</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
|
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
|
||||||
查看全部成员 ({memberCount})
|
查看全部成员 ({memberCount})
|
||||||
@@ -450,6 +563,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
|
|||||||
<GroupMembersModal
|
<GroupMembersModal
|
||||||
topicId={topicId}
|
topicId={topicId}
|
||||||
canInvite={canInvite}
|
canInvite={canInvite}
|
||||||
|
myUserId={myUserId}
|
||||||
|
myRole={myRole || 'member'}
|
||||||
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -219,7 +219,13 @@ export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupF
|
|||||||
isPdf={pdf}
|
isPdf={pdf}
|
||||||
open={openMenuId === f.group_file_id}
|
open={openMenuId === f.group_file_id}
|
||||||
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : 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)}
|
onDownload={() => downloadFile(f)}
|
||||||
onOpen={() => openFileInNewTab(f)}
|
onOpen={() => openFileInNewTab(f)}
|
||||||
onDelete={async () => {
|
onDelete={async () => {
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { Icon } from '@/app/Icon';
|
|||||||
import { Toast } from '@/app/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
import { fetchAllPages } from '@/lib/api/client';
|
import { fetchAllPages } from '@/lib/api/client';
|
||||||
import { getFriends } from '@/lib/api/friends';
|
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 { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { useEscapeKey } from '@/lib/hooks';
|
import { useEscapeKey } from '@/lib/hooks';
|
||||||
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
|
import { openUserCard } from '@/stores/userCard';
|
||||||
|
|
||||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
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
|
// 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
|
* 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. */
|
* what silently made that governance toggle a no-op before this was wired through. */
|
||||||
canInvite: boolean;
|
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 --
|
/** "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
|
* 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 "邀请"
|
* 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
|
/** 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
|
* `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
|
* an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no
|
||||||
* no documented REST or WS operation for removing a group member at all (grepped the whole
|
* documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added
|
||||||
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
|
* `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`
|
/** 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"/
|
* (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
|
* "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: '邀请失败',
|
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 queryClient = useQueryClient();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [inviting, setInviting] = useState(initialView === 'invite');
|
const [inviting, setInviting] = useState(initialView === 'invite');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
|
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);
|
useEscapeKey(onClose);
|
||||||
|
|
||||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
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 : '邀请失败'),
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
<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()}>
|
<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) => (
|
filtered.map((m) => (
|
||||||
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
|
<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)}`}>
|
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||||
{avatarLetter(m.display_name)}
|
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||||
</span>
|
{avatarLetter(m.display_name)}
|
||||||
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
</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} />
|
<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>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks';
|
||||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||||
|
|
||||||
interface ImageData {
|
interface ImageData {
|
||||||
@@ -10,12 +10,156 @@ interface ImageData {
|
|||||||
mime?: string;
|
mime?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: 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 inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
|
||||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||||
const src = inline || remote.url;
|
const src = inline || remote.url;
|
||||||
|
const [viewing, setViewing] = useState(false);
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return (
|
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.
|
// 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 {
|
interface FileData {
|
||||||
@@ -36,7 +189,11 @@ interface FileData {
|
|||||||
size?: number;
|
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 style = fileTypeStyle(data.name, data.mime);
|
||||||
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
|
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;
|
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}
|
href={href}
|
||||||
download={data.name}
|
download={data.name}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (remote.error) {
|
if (locked) {
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (remote.error) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
remote.retry();
|
remote.retry();
|
||||||
} else if (!href) {
|
} else if (!href) {
|
||||||
@@ -79,6 +238,7 @@ interface VideoData {
|
|||||||
ref?: string;
|
ref?: string;
|
||||||
mime?: string;
|
mime?: string;
|
||||||
preview?: string;
|
preview?: string;
|
||||||
|
previewRef?: string;
|
||||||
duration?: number;
|
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
|
* 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,
|
* 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
|
* 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. */
|
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
|
||||||
export function VideoAttachment({ data }: { data: VideoData }) {
|
*
|
||||||
|
* `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 [playing, setPlaying] = useState(false);
|
||||||
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
|
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 videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
|
||||||
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
|
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;
|
const videoSrc = videoInline || remote.url;
|
||||||
|
|
||||||
if (playing && videoSrc) {
|
if (playing && videoSrc) {
|
||||||
@@ -107,15 +278,18 @@ export function VideoAttachment({ data }: { data: VideoData }) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPlaying(true)}
|
onClick={() => {
|
||||||
|
if (locked) return;
|
||||||
|
setPlaying(true);
|
||||||
|
}}
|
||||||
disabled={!videoSrc}
|
disabled={!videoSrc}
|
||||||
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
||||||
>
|
>
|
||||||
{posterInline ? (
|
{posterUrl ? (
|
||||||
<img src={posterInline} alt="" className="h-full w-full object-cover" />
|
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid h-full w-full place-items-center">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
{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;
|
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 inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
||||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||||
const src = inline || remote.url;
|
const src = inline || remote.url;
|
||||||
@@ -154,6 +332,7 @@ export function AudioAttachment({ data }: { data: AudioData }) {
|
|||||||
}, [audioEl]);
|
}, [audioEl]);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
if (locked) return;
|
||||||
if (!audioEl || !src) return;
|
if (!audioEl || !src) return;
|
||||||
if (playing) {
|
if (playing) {
|
||||||
audioEl.pause();
|
audioEl.pause();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
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 { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
|
||||||
import type { StoredMessage } from 'tinode-sdk';
|
import type { StoredMessage } from 'tinode-sdk';
|
||||||
import { Drafty } 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 { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
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 { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
|
||||||
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
|
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
|
||||||
import { getMe } from '@/lib/api/profile';
|
import { getMe } from '@/lib/api/profile';
|
||||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||||
import { useClickOutside } from '@/lib/hooks';
|
import { useClickOutside } from '@/lib/hooks';
|
||||||
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
import { availableActions, isConfirmedSeq, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||||
import { isRecalled } from '@/lib/messages/dispatch';
|
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
|
||||||
import { renderDrafty } from '@/lib/messages/drafty';
|
|
||||||
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
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 { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||||
import { isTopicGone } from '@/lib/tinode/errors';
|
import { isTopicGone } from '@/lib/tinode/errors';
|
||||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
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 {
|
import {
|
||||||
closeConversation,
|
closeConversation,
|
||||||
ensureTopicAttached,
|
ensureTopicAttached,
|
||||||
@@ -541,6 +542,38 @@ export function ConversationView() {
|
|||||||
enabled: isGroup,
|
enabled: isGroup,
|
||||||
});
|
});
|
||||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
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 myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
|
||||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||||
const groupSublineParts = [
|
const groupSublineParts = [
|
||||||
@@ -568,10 +601,22 @@ export function ConversationView() {
|
|||||||
// second, divergent resolution path.
|
// second, divergent resolution path.
|
||||||
const quotedSenderLabel = (fromUserId: string | undefined) => {
|
const quotedSenderLabel = (fromUserId: string | undefined) => {
|
||||||
if (myUserId && fromUserId === myUserId) return '我';
|
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;
|
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
|
* "加载更多" (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
|
* 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
|
// 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
|
// 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.
|
// 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) {
|
if (pendingMentions.length > 0) {
|
||||||
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
|
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
|
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
|
||||||
// actually said first.
|
// actually said first.
|
||||||
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
||||||
for (const targetTopic of targetTopics) {
|
// A message can be recalled by its sender AFTER it was already selected (the checkbox itself
|
||||||
// A target that isn't the conversation currently open (i.e. almost any real forward target)
|
// refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled
|
||||||
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
|
// mid-session) -- forwarding null content would silently send an empty bubble with a misleading
|
||||||
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
|
// "forwarded from" tag rather than the message the user actually picked. Checkbox selection also
|
||||||
// edge case.
|
// doesn't refuse a still-sending/failed message (its placeholder seq is a plain `number`, same as
|
||||||
ensureTopicAttached(targetTopic)
|
// a real one) -- that message has no server-side (topic, seq) yet for forwardMessages() to find.
|
||||||
.then((target) => {
|
const sources = orderedSeqs
|
||||||
for (const seq of orderedSeqs) {
|
.map((seq) => topic.findMessage(seq))
|
||||||
const msg = topic.findMessage(seq);
|
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg) && isConfirmedSeq(msg))
|
||||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox
|
.map((msg) => ({ topic: topicId, seq: msg.seq as number }));
|
||||||
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
|
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
|
||||||
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
|
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
|
||||||
// with a misleading "forwarded from" tag rather than the message the user actually picked.
|
// `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing
|
||||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
// 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).
|
||||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
//
|
||||||
})
|
// The comment send is chained AFTER this promise settles, not fired in parallel -- confirmed
|
||||||
.catch((err) => showError(err, '转发失败'));
|
// 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);
|
setBulkForwardOpen(false);
|
||||||
exitSelectMode();
|
exitSelectMode();
|
||||||
@@ -801,9 +876,22 @@ export function ConversationView() {
|
|||||||
setMenu(null);
|
setMenu(null);
|
||||||
if (!msg || typeof msg.seq !== 'number') return;
|
if (!msg || typeof msg.seq !== 'number') return;
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'copy':
|
case 'copy': {
|
||||||
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
|
// 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;
|
break;
|
||||||
|
}
|
||||||
case 'recall':
|
case 'recall':
|
||||||
recallMessage(topicId, msg.seq)
|
recallMessage(topicId, msg.seq)
|
||||||
.then(() => topic.delMessagesList([msg.seq], true))
|
.then(() => topic.delMessagesList([msg.seq], true))
|
||||||
@@ -833,13 +921,42 @@ export function ConversationView() {
|
|||||||
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = '';
|
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 handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = '';
|
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 (
|
return (
|
||||||
@@ -943,8 +1060,13 @@ export function ConversationView() {
|
|||||||
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
|
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 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 (
|
return (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={msg.seq}
|
key={msg.seq}
|
||||||
@@ -953,7 +1075,7 @@ export function ConversationView() {
|
|||||||
mine={mine}
|
mine={mine}
|
||||||
isGroup={isGroup}
|
isGroup={isGroup}
|
||||||
peerName={peerName}
|
peerName={peerName}
|
||||||
senderName={sender?.display_name}
|
senderName={sender?.display_name || staleSender?.display_name}
|
||||||
senderRole={sender?.role}
|
senderRole={sender?.role}
|
||||||
avatarFileId={rowAvatarFileId}
|
avatarFileId={rowAvatarFileId}
|
||||||
selectMode={selectMode}
|
selectMode={selectMode}
|
||||||
@@ -962,6 +1084,7 @@ export function ConversationView() {
|
|||||||
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
|
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||||
|
resolveQuotedName={quotedSenderLabel}
|
||||||
onError={setErrorToast}
|
onError={setErrorToast}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1002,7 +1125,7 @@ export function ConversationView() {
|
|||||||
{replyTo ? (
|
{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">
|
<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">
|
<span className="truncate">
|
||||||
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
|
回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||||||
<Icon name="close" className="text-[16px]" />
|
<Icon name="close" className="text-[16px]" />
|
||||||
@@ -1097,7 +1220,8 @@ export function ConversationView() {
|
|||||||
onCancel={() => setRecording(false)}
|
onCancel={() => setRecording(false)}
|
||||||
onSend={(blob, durationMs) => {
|
onSend={(blob, durationMs) => {
|
||||||
setRecording(false);
|
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}
|
value={draft}
|
||||||
onChange={handleDraftChange}
|
onChange={handleDraftChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handleComposerPaste}
|
||||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
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"
|
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
|
<ForwardPickerModal
|
||||||
onClose={() => setForwardMsg(null)}
|
onClose={() => setForwardMsg(null)}
|
||||||
onPick={(targetTopics, comment) => {
|
onPick={(targetTopics, comment) => {
|
||||||
for (const targetTopic of targetTopics) {
|
// Comment is chained AFTER the forward call settles, not fired in parallel -- see
|
||||||
ensureTopicAttached(targetTopic)
|
// `bulkForward`'s own comment above for why this matters (confirmed live: firing both
|
||||||
.then((target) => {
|
// at once lets the comment's plain WS send outrace the forward endpoint's own async
|
||||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
// "pending" persistence, landing the comment BEFORE the forwarded content it's meant to
|
||||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
// follow).
|
||||||
})
|
const forwardDone =
|
||||||
.catch((err) => showError(err, '转发失败'));
|
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);
|
setForwardMsg(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -135,7 +135,15 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
|
|
||||||
const blockMutation = useMutation({
|
const blockMutation = useMutation({
|
||||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
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, '设置失败,请重试'),
|
onError: (err) => showError(err, '设置失败,请重试'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,7 +160,19 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
|
|||||||
});
|
});
|
||||||
|
|
||||||
const clearHistory = async () => {
|
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);
|
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
|
* 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
|
* 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
|
* 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
|
* the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message
|
||||||
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
|
* forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side
|
||||||
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
|
* in one batched call -- this component only collects the selection and comment; callers send.
|
||||||
* so callers loop `targetTopics` themselves -- this component only collects the selection.
|
|
||||||
*/
|
*/
|
||||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||||
const contacts = useChatList();
|
const contacts = useChatList();
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem
|
|||||||
import { checkUrlSafety } from '@/lib/api/security';
|
import { checkUrlSafety } from '@/lib/api/security';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||||
|
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||||||
import { renderDrafty } from '@/lib/messages/drafty';
|
import { renderDrafty } from '@/lib/messages/drafty';
|
||||||
|
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||||
import { requestConfirm } from '@/stores/confirmDialog';
|
import { requestConfirm } from '@/stores/confirmDialog';
|
||||||
import { notify } from '@/stores/globalToast';
|
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
|
* 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-
|
* `{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
|
* 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 }) {
|
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||||
const content = item.snapshot?.content;
|
const content = item.snapshot?.content;
|
||||||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
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>;
|
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 senderLabel = (from: string): string => {
|
||||||
const full = normalizeSender(from);
|
const full = normalizeSender(from);
|
||||||
if (myUserId && full === myUserId) return '我';
|
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;
|
return peerName;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,28 +50,38 @@ export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
|
|||||||
<span className="text-sm text-text-body">所有人</span>
|
<span className="text-sm text-text-body">所有人</span>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{items.map((m) => (
|
{items.map((m) => {
|
||||||
<button
|
// Real, reported bug: `display_name` here silently prefers the CURRENT viewer's own
|
||||||
key={m.user_id}
|
// private remark for this member (see GroupMember.display_name's own doc comment) --
|
||||||
type="button"
|
// fine for a list only the viewer sees, wrong for an @mention, whose label gets baked
|
||||||
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
|
// into the message's shared content once and read by every OTHER member from then on,
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
// 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
|
||||||
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
// correct for whoever else ends up reading the mention.
|
||||||
{avatarLetter(m.display_name)}
|
const label = m.nickname || m.display_name || m.user_id;
|
||||||
</span>
|
return (
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
<button
|
||||||
{m.role !== 'member' ? (
|
key={m.user_id}
|
||||||
<span
|
type="button"
|
||||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
|
onClick={() => onPick({ userId: m.user_id, label })}
|
||||||
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
|
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)}`}>
|
||||||
{ROLE_LABEL[m.role]}
|
{avatarLetter(label)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{label}</span>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,25 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Avatar } from '@/app/Avatar';
|
import { Avatar } from '@/app/Avatar';
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
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 { 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 { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
||||||
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
||||||
|
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||||
import { retryMessage } from '@/lib/tinode/send';
|
import { retryMessage } from '@/lib/tinode/send';
|
||||||
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||||
|
import { openUserCard } from '@/stores/userCard';
|
||||||
|
|
||||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
* 与 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
|
/** 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
|
* 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. */
|
* 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;
|
const name = content.display_name || content.public_account || content.user_id;
|
||||||
return (
|
const inner = (
|
||||||
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
<>
|
||||||
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
|
<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="flex min-w-0 flex-col">
|
||||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
<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}
|
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
|
||||||
</span>
|
</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>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,6 +87,10 @@ interface MessageRowProps {
|
|||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
onToggleSelect?: (seq: number) => void;
|
onToggleSelect?: (seq: number) => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => 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
|
/** 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
|
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
|
||||||
* even made. */
|
* even made. */
|
||||||
@@ -85,6 +112,7 @@ export function MessageRow({
|
|||||||
highlighted,
|
highlighted,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
|
resolveQuotedName,
|
||||||
onError,
|
onError,
|
||||||
}: MessageRowProps) {
|
}: MessageRowProps) {
|
||||||
if (isServerDeleted(msg)) {
|
if (isServerDeleted(msg)) {
|
||||||
@@ -110,6 +138,10 @@ export function MessageRow({
|
|||||||
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
|
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
|
||||||
const callResult = callResultDisplay(msg, mine);
|
const callResult = callResultDisplay(msg, mine);
|
||||||
const contactCard = contactCardContentOf(msg);
|
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 = () => {
|
const retry = () => {
|
||||||
if (typeof msg.seq !== 'number') return;
|
if (typeof msg.seq !== 'number') return;
|
||||||
@@ -122,8 +154,19 @@ export function MessageRow({
|
|||||||
.catch(() => onError?.('重试发送失败'));
|
.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 (
|
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 ? (
|
{selectMode ? (
|
||||||
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
|
// 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
|
// 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) ? (
|
isRecalled(msg) ? (
|
||||||
<div className="h-5 w-5 flex-none" />
|
<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} />
|
<SelectionCheckbox selected={selected} />
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
<div
|
||||||
@@ -152,14 +197,14 @@ export function MessageRow({
|
|||||||
relationship isn't a case this app has ever needed to support. Not during `selectMode`
|
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,
|
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. */}
|
not navigate away and lose whatever's already been picked. */}
|
||||||
{isGroup && !mine && !selectMode && msg.from ? (
|
{!mine && !selectMode && msg.from ? (
|
||||||
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
|
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
|
||||||
<Avatar id={msg.from} name={senderName || msg.from} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
||||||
</Link>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Avatar
|
<Avatar
|
||||||
id={msg.from || (isGroup ? '' : peerName)}
|
id={msg.from || (isGroup ? '' : peerName)}
|
||||||
name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
|
name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : peerName}
|
||||||
avatarFileId={avatarFileId}
|
avatarFileId={avatarFileId}
|
||||||
fallbackBgClass={mine ? 'bg-accent' : undefined}
|
fallbackBgClass={mine ? 'bg-accent' : undefined}
|
||||||
className="h-9 w-9 flex-none rounded-lg text-sm"
|
className="h-9 w-9 flex-none rounded-lg text-sm"
|
||||||
@@ -169,11 +214,11 @@ export function MessageRow({
|
|||||||
{isGroup ? (
|
{isGroup ? (
|
||||||
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||||
{!mine && !selectMode && msg.from ? (
|
{!mine && !selectMode && msg.from ? (
|
||||||
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
|
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
|
||||||
{senderName || msg.from}
|
{groupSenderLabel(senderName)}
|
||||||
</Link>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span>{mine ? '我' : senderName || msg.from}</span>
|
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
|
||||||
)}
|
)}
|
||||||
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
||||||
</div>
|
</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'
|
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
} ${isRecalled(msg) ? '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) ? (
|
{isRecalled(msg) ? (
|
||||||
'⚠ 消息已删除'
|
'⚠ 消息已删除'
|
||||||
) : callResult ? (
|
) : callResult ? (
|
||||||
@@ -198,14 +246,31 @@ export function MessageRow({
|
|||||||
{callResult.text}
|
{callResult.text}
|
||||||
</span>
|
</span>
|
||||||
) : contactCard ? (
|
) : 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>
|
||||||
</div>
|
</div>
|
||||||
{failed ? (
|
{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>
|
</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 { Icon } from '@/app/Icon';
|
||||||
import { Toast } from '@/app/Toast';
|
import { Toast } from '@/app/Toast';
|
||||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||||
|
import { ImApiError } from '@/lib/api/client';
|
||||||
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
|
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
|
||||||
import { uploadFile } from '@/lib/api/upload';
|
import { uploadFile } from '@/lib/api/upload';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
@@ -87,9 +88,20 @@ export function ProfileSettingsPage() {
|
|||||||
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
|
// 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
|
// 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
|
// 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) => {
|
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'] });
|
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
|
// 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.
|
// toast, so it doesn't look like the button silently did nothing.
|
||||||
const [invitationError, setInvitationError] = useState<Record<string, string>>({});
|
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({
|
const acceptInvitationMutation = useMutation({
|
||||||
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
|
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
|
||||||
onSuccess: (data, invitationId) => {
|
onSuccess: (data, invitationId) => {
|
||||||
@@ -125,10 +136,12 @@ export function SystemMessagesView() {
|
|||||||
delete next[invitationId];
|
delete next[invitationId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' }));
|
||||||
navigate(`/messages/${data.topic}`);
|
navigate(`/messages/${data.topic}`);
|
||||||
},
|
},
|
||||||
onError: (err, invitationId) => {
|
onError: (err, invitationId) => {
|
||||||
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
|
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}
|
{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' ? (
|
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
|
||||||
<div className="mt-1 flex flex-col items-start gap-1.5">
|
<div className="mt-1 flex flex-col items-start gap-1.5">
|
||||||
{/* A REAL nested button, unlike the plain-text action label above -- accepting
|
{resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
|
||||||
is a distinct action from "open this card" (which only marks it read; see
|
<span className="text-xs text-text-tertiary-4">已加入群聊</span>
|
||||||
actionRoute's own comment for why this event type has no plain navigation
|
) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
|
||||||
at all), so stopPropagation is the right tool here rather than avoiding the
|
<span className="text-xs text-text-tertiary-4">邀请已失效</span>
|
||||||
nesting the way the plain-text case does. */}
|
) : (
|
||||||
<button
|
// A REAL nested button, unlike the plain-text action label above -- accepting
|
||||||
type="button"
|
// is a distinct action from "open this card" (which only marks it read; see
|
||||||
disabled={acceptInvitationMutation.isPending}
|
// actionRoute's own comment for why this event type has no plain navigation
|
||||||
onClick={(e) => {
|
// at all), so stopPropagation is the right tool here rather than avoiding the
|
||||||
e.stopPropagation();
|
// nesting the way the plain-text case does.
|
||||||
if (isUnread) readMutation.mutate(item.id);
|
<button
|
||||||
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
|
type="button"
|
||||||
}}
|
disabled={acceptInvitationMutation.isPending}
|
||||||
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
|
onClick={(e) => {
|
||||||
>
|
e.stopPropagation();
|
||||||
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
|
if (isUnread) readMutation.mutate(item.id);
|
||||||
? '处理中…'
|
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
|
||||||
: '接受邀请'}
|
}}
|
||||||
</button>
|
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] ? (
|
{invitationError[item.action.invitation_id] ? (
|
||||||
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
|
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -147,7 +147,23 @@ export type GroupRole = 'owner' | 'admin' | 'member';
|
|||||||
|
|
||||||
export interface GroupMember {
|
export interface GroupMember {
|
||||||
user_id: string;
|
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;
|
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;
|
avatar_file_id?: string;
|
||||||
role: GroupRole;
|
role: GroupRole;
|
||||||
mentionable?: boolean;
|
mentionable?: boolean;
|
||||||
@@ -186,6 +202,29 @@ export function getAllGroupMembers(
|
|||||||
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
|
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 {
|
export interface GroupMemberProfile {
|
||||||
topic: string;
|
topic: string;
|
||||||
user_id: 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
|
/** 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
|
* 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`/
|
* list for a display name/role, same as any other bare user id in this API. When nothing has been
|
||||||
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
|
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
|
||||||
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
|
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
|
||||||
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
|
* `content` for emptiness, not object existence.
|
||||||
* 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 {
|
export interface GroupAnnouncement {
|
||||||
announcement_id: string;
|
announcement_id: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
@@ -422,6 +465,9 @@ export interface GroupAnnouncement {
|
|||||||
published_at: string;
|
published_at: string;
|
||||||
author: string;
|
author: string;
|
||||||
permissions?: { can_edit?: boolean };
|
permissions?: { can_edit?: boolean };
|
||||||
|
read_count?: number;
|
||||||
|
member_count?: number;
|
||||||
|
read_summary?: { read_count: number; member_count: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLatestGroupAnnouncement(topic: string) {
|
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
|
// 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).
|
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
|
||||||
content?: DraftyContent | string;
|
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) })),
|
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 { StoredMessage } from 'tinode-sdk';
|
||||||
|
|
||||||
import type { MessagePolicy } from '@/lib/api/bootstrap';
|
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
|
/** 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). */
|
* 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 {
|
export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null {
|
||||||
const mine = !!myUserId && msg.from === myUserId;
|
const mine = !!myUserId && msg.from === myUserId;
|
||||||
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
|
|
||||||
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
|
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);
|
const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000);
|
||||||
return remaining > 0 ? remaining : null;
|
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,
|
* 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).
|
* 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[] {
|
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
||||||
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
||||||
// 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on
|
// Contact-card and call-result messages are flat, non-Drafty custom objects dispatched by
|
||||||
// contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/
|
// head['x-im-type'] (dispatch.ts), not bodies Drafty.toPlainText can handle -- "copy" stays off
|
||||||
// forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an
|
// both. Quote/forward differ since the 2026-09-23 x-im-reply snapshot protocol represents the
|
||||||
// object it doesn't recognize, producing empty/malformed output rather than an error a user would
|
// quoted message by {seq,type,text,from} rather than parsing its content: a contact card now
|
||||||
// notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types.
|
// quotes fine ([名片] snapshot). Call bubbles remain favorite-only -- a transient call result
|
||||||
const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
|
// has no meaningful "forward/quote" semantics anywhere in the cross-client spec.
|
||||||
const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward'];
|
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
|
// 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
|
// 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.
|
// 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;
|
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 {
|
export function formatCallDuration(ms: number): string {
|
||||||
const totalSeconds = Math.round(ms / 1000);
|
const totalSeconds = Math.round(ms / 1000);
|
||||||
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
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
|
* 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
|
* 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
|
* 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
|
* caller) exactly the way this app already threads that prop through MessageRow. `busy` WAS assumed
|
||||||
* exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite
|
* to never appear in a persisted message (only ever seen on this app's own live, ephemeral
|
||||||
* the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can
|
* `sendCallHangup(...,'busy')` signal) -- disproven by reading the real iOS/Android source
|
||||||
* produce it yet.
|
* (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 {
|
export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null {
|
||||||
const content = callContentOf(msg);
|
const content = callContentOf(msg);
|
||||||
@@ -114,6 +179,8 @@ export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: st
|
|||||||
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
|
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
return { icon: 'phone_missed', danger: true, text: '异常结束' };
|
return { icon: 'phone_missed', danger: true, text: '异常结束' };
|
||||||
|
case 'busy':
|
||||||
|
return { icon: 'phone_missed', danger: true, text: '对方忙线中' };
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
case 'started':
|
case 'started':
|
||||||
return { icon, danger: false, text: '通话中' };
|
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 { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
||||||
import { SafeLink } from '@/features/messages/SafeLink';
|
import { SafeLink } from '@/features/messages/SafeLink';
|
||||||
import { flatTextContentOf } from '@/lib/messages/dispatch';
|
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
|
* 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,
|
* feature for user-authored highlight formatting, not search-match highlighting). Global,
|
||||||
* case-insensitive, one `<mark>` per match. */
|
* case-insensitive, one `<mark>` per match. */
|
||||||
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
|
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();
|
const needle = query.trim().toLowerCase();
|
||||||
if (!needle) return [text];
|
if (!needle) return [text];
|
||||||
const haystack = text.toLowerCase();
|
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),
|
* 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
|
* 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. */
|
* 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(
|
return function formatNode(
|
||||||
style: string | undefined,
|
style: string | undefined,
|
||||||
data: Record<string, unknown> | undefined,
|
data: Record<string, unknown> | undefined,
|
||||||
@@ -72,12 +81,27 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
|||||||
);
|
);
|
||||||
case 'BR':
|
case 'BR':
|
||||||
return <br key={key} />;
|
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 (
|
return (
|
||||||
<span key={key} className="text-accent">
|
<span key={key} className="text-accent">
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'HL':
|
case 'HL':
|
||||||
return (
|
return (
|
||||||
<mark key={key} className="bg-warn-bg">
|
<mark key={key} className="bg-warn-bg">
|
||||||
@@ -86,6 +110,16 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
|||||||
);
|
);
|
||||||
case 'LN': {
|
case 'LN': {
|
||||||
const url = typeof data?.url === 'string' ? data.url : undefined;
|
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 (
|
return (
|
||||||
<SafeLink key={key} href={url}>
|
<SafeLink key={key} href={url}>
|
||||||
{children}
|
{children}
|
||||||
@@ -93,13 +127,13 @@ function makeFormatNode(highlightQuery: string | undefined) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'IM':
|
case 'IM':
|
||||||
return <ImageAttachment key={key} data={data || {}} />;
|
return <ImageAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||||
case 'EX':
|
case 'EX':
|
||||||
return <FileAttachment key={key} data={data || {}} />;
|
return <FileAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||||
case 'AU':
|
case 'AU':
|
||||||
return <AudioAttachment key={key} data={data || {}} />;
|
return <AudioAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||||
case 'VD':
|
case 'VD':
|
||||||
return <VideoAttachment key={key} data={data || {}} />;
|
return <VideoAttachment key={key} data={data || {}} locked={!interactive} />;
|
||||||
case 'QQ':
|
case 'QQ':
|
||||||
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
|
// 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`.
|
// 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
|
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
|
||||||
* as before; only HistorySearchModal's search results pass one. */
|
* as before; only HistorySearchModal's search results pass one. Same for `interactive` (default
|
||||||
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
|
* 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 (content == null) return null;
|
||||||
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
|
||||||
const flatText = flatTextContentOf(content);
|
const flatText = flatTextContentOf(content);
|
||||||
@@ -131,5 +167,5 @@ export function renderDrafty(content: DraftyContent | string | null | undefined,
|
|||||||
const text = Drafty.toPlainText(content);
|
const text = Drafty.toPlainText(content);
|
||||||
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
|
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 { 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
|
* 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}[名片]`;
|
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
|
||||||
const callResult = callResultDisplay(msg, msg.from === myUserId);
|
const callResult = callResultDisplay(msg, msg.from === myUserId);
|
||||||
if (callResult) return `${prefix}${callResult.text}`;
|
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
|
// 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
|
// `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
|
// 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 { uploadFile, upgradeToHttps } from '@/lib/api/upload';
|
||||||
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
|
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> {
|
function fileToBase64(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
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
|
* 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
|
* 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,
|
* 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.
|
* 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 { width, height } = await imageDimensions(file);
|
||||||
const content =
|
const content =
|
||||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||||
@@ -50,18 +59,18 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
});
|
});
|
||||||
const msg = topic.createMessage(content, false);
|
const msg = topic.createMessage(content, false);
|
||||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
|
msg.head = Object.assign(msg.head || {}, mediaHead('image', reply));
|
||||||
return topic.publishDraft(msg, Promise.resolve());
|
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 =
|
const content =
|
||||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
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, 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 });
|
: 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);
|
const msg = topic.createMessage(content, false);
|
||||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
|
msg.head = Object.assign(msg.head || {}, mediaHead('file', reply));
|
||||||
return topic.publishDraft(msg, Promise.resolve());
|
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>
|
* field this app actually displays from doesn't depend on it, only a browser's native <audio>
|
||||||
* scrubber would).
|
* 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 file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
|
||||||
const content =
|
const content =
|
||||||
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
file.size <= MAX_INBAND_ATTACHMENT_SIZE
|
||||||
@@ -85,6 +94,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
});
|
});
|
||||||
const msg = topic.createMessage(content, false);
|
const msg = topic.createMessage(content, false);
|
||||||
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
|
msg.head = Object.assign(msg.head || {}, mediaHead('voice', reply));
|
||||||
return topic.publishDraft(msg, Promise.resolve());
|
return publishWithReattach(topic, msg);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
|
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
|
* 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
|
/** `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. */
|
* 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';
|
* `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 {
|
export function callContentOf(msg: StoredMessage): CallContent | null {
|
||||||
const webrtc = msg.head?.webrtc;
|
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.
|
// 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);
|
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() });
|
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);
|
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) {
|
function setStatus(next: ConnectionStatus) {
|
||||||
if (status === next) return;
|
if (status === next) return;
|
||||||
if (next === 'authenticated') wasAuthenticated = true;
|
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
|
* Two backend protocol extensions the vendored JS SDK has no API for -- both fields the packet
|
||||||
* (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private,
|
* builders (#initPacket/login(), both private) never write, so the only usable hook for either is
|
||||||
* so the only usable hook is wrapping the connection's raw sendText and injecting the field into the
|
* wrapping the connection's raw sendText and injecting the field into the already-serialized JSON:
|
||||||
* already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is a
|
* - {hi}.tenant (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). Ported as-is
|
||||||
* real backend protocol extension, not an old-codebase-specific workaround.
|
* 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;
|
const conn = t._connection;
|
||||||
if (!conn || conn.__tenantPatched) return;
|
if (!conn || conn.__tenantPatched) return;
|
||||||
const original = conn.sendText.bind(conn);
|
const original = conn.sendText.bind(conn);
|
||||||
@@ -152,10 +188,28 @@ function installTenantHiPatch(t: Tinode) {
|
|||||||
const parsed = JSON.parse(msg);
|
const parsed = JSON.parse(msg);
|
||||||
if (parsed.hi) {
|
if (parsed.hi) {
|
||||||
const tenant = useSessionStore.getState().tenant;
|
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) {
|
if (tenant?.code) {
|
||||||
parsed.hi.tenant = 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 {
|
} catch {
|
||||||
// Not valid JSON -- send as is.
|
// 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 });
|
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
|
||||||
t.setHumanLanguage('zh');
|
t.setHumanLanguage('zh');
|
||||||
t.enableLogging(LOGGING_ENABLED, true);
|
t.enableLogging(LOGGING_ENABLED, true);
|
||||||
installTenantHiPatch(t);
|
installOutgoingPacketPatches(t);
|
||||||
|
|
||||||
t.onConnect = () => {
|
t.onConnect = () => {
|
||||||
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
|
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
|
* 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. */
|
* TenantEntryPage.tsx's "切换企业" flow racing a slow-to-respond old host. */
|
||||||
export function resetTinode() {
|
export function resetTinode() {
|
||||||
|
resetListeners.forEach((cb) => cb());
|
||||||
tinode?.disconnect();
|
tinode?.disconnect();
|
||||||
tinode = null;
|
tinode = null;
|
||||||
connectPromise = null;
|
connectPromise = null;
|
||||||
restorePromise = null;
|
restorePromise = null;
|
||||||
wasAuthenticated = false;
|
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');
|
setStatus('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +395,24 @@ export function loginBasic(login: string, password: string): Promise<LoginCtrl>
|
|||||||
loginPromise = (async () => {
|
loginPromise = (async () => {
|
||||||
const t = getTinode();
|
const t = getTinode();
|
||||||
if (!t.isConnected()) await connect();
|
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(() => {
|
})().finally(() => {
|
||||||
loginPromise = null;
|
loginPromise = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export function loginErrorMessage(err: unknown): string {
|
|||||||
const known = translateCtrlError(err);
|
const known = translateCtrlError(err);
|
||||||
if (known) return known;
|
if (known) return known;
|
||||||
const code = (err as { code?: number })?.code;
|
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 '账号或密码错误';
|
if (code === 401 || code === 403) return '账号或密码错误';
|
||||||
return (err as Error)?.message || '登录失败,请重试';
|
return (err as Error)?.message || '登录失败,请重试';
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-40
@@ -1,11 +1,6 @@
|
|||||||
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
|
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
|
||||||
|
|
||||||
import { QUOTED_REPLY_LENGTH } from '@/lib/config';
|
import { buildReplyInfo } from '@/lib/messages/reply';
|
||||||
|
|
||||||
function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent {
|
|
||||||
if (content == null) return Drafty.init('');
|
|
||||||
return typeof content === 'string' ? Drafty.init(content) : content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
|
* 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)}`;
|
: `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
|
* 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
|
* 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 content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||||
const msg = topic.createMessage(content, false);
|
const msg = topic.createMessage(content, false);
|
||||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
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> {
|
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
|
||||||
const msg = topic.createMessage(content, false);
|
const msg = topic.createMessage(content, false);
|
||||||
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
|
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).
|
* cache (already flushed, or never existed).
|
||||||
*
|
*
|
||||||
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
|
* 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
|
* newClientMid()'s own comment above for why a retry specifically must not get a new id. The whole
|
||||||
* to a fresh one only for a locally-cached failed message from before this field existed at all
|
* original head is copied verbatim, which also keeps `x-im-reply` identical across retries (the
|
||||||
* (there's no original id to recover in that one case).
|
* 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 {
|
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
|
||||||
const failed = topic.flushMessage(seq);
|
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, {
|
msg.head = Object.assign(msg.head || {}, failed.head, {
|
||||||
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
|
'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 {
|
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
|
* Quoted reply (cross-client `x-im-reply` contract, aligned with iOS 2026-09-23): the quote is a
|
||||||
* truncated (Drafty.replyContent, QUOTED_REPLY_LENGTH) and wrapped with the sender's name
|
* lightweight SNAPSHOT carried in `head["x-im-reply"]` (`{seq,type,text,from}`), NOT a Drafty QQ
|
||||||
* (Drafty.quote), then the whole quote block is INLINED into the outgoing content ahead of the
|
* block inlined into the content -- content stays the clean reply body. The receiving side renders
|
||||||
* new text (Drafty.append) -- the receiving side never has to look up the original message to show
|
* its own quote bar and locally re-fetches thumbnails/durations by seq. Builds the snapshot from
|
||||||
* a quote preview. `head.reply` carries the original seq separately, purely so a click on the quote
|
* the quoted message via buildReplyInfo(); throws when the message isn't quotable (failed/pending/
|
||||||
* block can scroll back to it.
|
* system notice), which the UI gates before arming the reply state.
|
||||||
*/
|
*/
|
||||||
export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise<unknown> {
|
export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Promise<unknown> {
|
||||||
const trimmed = Drafty.replyContent(toDraftyContent(quoted.content), QUOTED_REPLY_LENGTH);
|
const reply = buildReplyInfo(quoted);
|
||||||
const quoteBlock = Drafty.quote(quotedSenderLabel, quoted.from || '', trimmed);
|
if (!reply) throw new Error('该消息不可引用');
|
||||||
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
|
const parsed = Drafty.parse(text);
|
||||||
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
|
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
|
||||||
const msg = topic.createMessage(finalContent, false);
|
const msg = topic.createMessage(content, false);
|
||||||
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
|
msg.head = Object.assign(msg.head || {}, { 'x-im-reply': reply, 'x-im-client-mid': newClientMid() });
|
||||||
return topic.publishDraft(msg, Promise.resolve());
|
return publishWithReattach(topic, msg);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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());
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+19
-1
@@ -183,6 +183,24 @@ declare module 'tinode-sdk' {
|
|||||||
getMeta(query: unknown): Promise<unknown>;
|
getMeta(query: unknown): Promise<unknown>;
|
||||||
startMetaQuery(): MetaGetBuilder;
|
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`. */
|
/** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */
|
||||||
createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage;
|
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. */
|
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
|
||||||
onData?: (data?: StoredMessage) => void;
|
onData?: (data?: StoredMessage) => void;
|
||||||
onAllMessagesReceived?: (count: number) => 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;
|
onMetaDesc?: (desc: unknown) => void;
|
||||||
/** `me`-topic-only in practice (verified tinode.dev.js:4654-4657): fired with the just-subscribed
|
/** `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
|
* 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.
|
// getParams -- so this always leaves first when already subscribed, then resubscribes.
|
||||||
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
|
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
|
||||||
|
|
||||||
|
import { getGroup } from '@/lib/api/groups';
|
||||||
import { MESSAGES_PAGE } from '@/lib/config';
|
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 topicListeners = new Map<string, Set<() => void>>();
|
||||||
const anyTopicListeners = new 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);
|
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) {
|
function notifyTopic(topicName: string) {
|
||||||
messageSnapshots.delete(topicName);
|
messageSnapshots.delete(topicName);
|
||||||
topicListeners.get(topicName)?.forEach((cb) => cb());
|
pendingTopicNotifications.add(topicName);
|
||||||
anyTopicListeners.forEach((cb) => cb());
|
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 {
|
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
|
||||||
@@ -110,7 +166,7 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RawTopicInfo {
|
export interface RawTopicInfo {
|
||||||
what: 'read' | 'recv' | 'kp' | 'call';
|
what: 'read' | 'recv' | 'kp' | 'call' | 'im';
|
||||||
seq?: number;
|
seq?: number;
|
||||||
from?: string;
|
from?: string;
|
||||||
event?: string;
|
event?: string;
|
||||||
@@ -128,6 +184,35 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
|
|||||||
return () => infoListeners.delete(callback);
|
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 goneListeners = new Set<(topicName: string) => void>();
|
||||||
|
|
||||||
const newGroupListeners = 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') {
|
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
|
||||||
markTopicGoneAndNotify(topic.name);
|
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;
|
topic.onMetaDesc = notify;
|
||||||
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
|
// 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. */
|
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
|
||||||
export function subscribeMeTopic(): Promise<unknown> {
|
export function subscribeMeTopic(): Promise<unknown> {
|
||||||
if (inFlightMeSubscribe) return inFlightMeSubscribe;
|
if (inFlightMeSubscribe) return inFlightMeSubscribe;
|
||||||
const me = getTinode().getMeTopic();
|
const tinode = getTinode();
|
||||||
|
installUncachedInfoFallback(tinode);
|
||||||
|
const me = tinode.getMeTopic();
|
||||||
wireTopicCallbacks(me);
|
wireTopicCallbacks(me);
|
||||||
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
|
// 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
|
// 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;
|
const ME_RECONCILE_INTERVAL_MS = 30_000;
|
||||||
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
|
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@@ -626,6 +769,24 @@ function reconcileMeSubs() {
|
|||||||
c.unread = prev.unread;
|
c.unread = prev.unread;
|
||||||
notifyTopic(c.topic);
|
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(() => {});
|
.catch(() => {});
|
||||||
|
|||||||
+32
-12
@@ -35,11 +35,17 @@ interface CallState {
|
|||||||
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
|
// 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.
|
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
|
||||||
hasLocalVideo: boolean;
|
hasLocalVideo: boolean;
|
||||||
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
|
// Set once `joinCall()` actually resolves (mic track created and published, or for video, mic+
|
||||||
// comment) so it's deliberately NOT part of `CallWebrtcState` -- but it IS a real value this app's
|
// camera) -- distinct from `phase==='connected'`, which flips the instant the SIGNALING accept
|
||||||
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
|
// arrives, before any media has actually joined. `CallWindow`'s status line previously showed a
|
||||||
// caller receives, which flows straight into this same field.
|
// ticking duration + "网络良好" from the moment `phase` became 'connected', which live-confirmed
|
||||||
endReason: CallWebrtcState | 'busy' | null;
|
// 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 --
|
/** 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
|
* 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. */
|
* callResultDisplay already does for the permanent chat-bubble record of the same call. */
|
||||||
@@ -58,7 +64,7 @@ interface CallState {
|
|||||||
switchCamera: () => Promise<void>;
|
switchCamera: () => Promise<void>;
|
||||||
setHasRemoteVideo: (v: boolean) => void;
|
setHasRemoteVideo: (v: boolean) => void;
|
||||||
_reset: () => void;
|
_reset: () => void;
|
||||||
_endWithReason: (reason: CallWebrtcState | 'busy') => void;
|
_endWithReason: (reason: CallWebrtcState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDLE = {
|
const IDLE = {
|
||||||
@@ -74,6 +80,7 @@ const IDLE = {
|
|||||||
connectedAt: null,
|
connectedAt: null,
|
||||||
hasRemoteVideo: false,
|
hasRemoteVideo: false,
|
||||||
hasLocalVideo: false,
|
hasLocalVideo: false,
|
||||||
|
mediaJoined: false,
|
||||||
endReason: null,
|
endReason: null,
|
||||||
wasCaller: false,
|
wasCaller: false,
|
||||||
mediaError: null,
|
mediaError: null,
|
||||||
@@ -123,7 +130,7 @@ export const useCallStore = create<CallState>((set, get) => ({
|
|||||||
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
|
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
|
||||||
if (get().callId !== callId) return;
|
if (get().callId !== callId) return;
|
||||||
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
|
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 {
|
} catch {
|
||||||
// Same guard the structurally-identical caller-side accept handler below already has, for the
|
// 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,
|
// 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.
|
// just only when THIS call is still the live one.
|
||||||
if (get().callId !== callId) return;
|
if (get().callId !== callId) return;
|
||||||
const { topic: currentTopic, seq: currentSeq } = get();
|
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();
|
leaveCall();
|
||||||
get()._endWithReason('disconnected');
|
get()._endWithReason('disconnected');
|
||||||
}
|
}
|
||||||
@@ -321,7 +335,7 @@ export function initCallSignaling() {
|
|||||||
.then(async (join) => {
|
.then(async (join) => {
|
||||||
if (useCallStore.getState().callId !== callId) return;
|
if (useCallStore.getState().callId !== callId) return;
|
||||||
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
|
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(() => {
|
.catch(() => {
|
||||||
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
|
// 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
|
// 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.
|
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
|
||||||
if (useCallStore.getState().callId !== callId) return;
|
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();
|
leaveCall();
|
||||||
useCallStore.getState()._endWithReason('disconnected');
|
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),
|
// 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 "通话已结束"
|
// 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
|
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
|
||||||
// local hangUp() above already makes for its own no-reason-given case.
|
// local hangUp() above already makes for its own no-reason-given case. `'disconnected'` is the
|
||||||
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
|
// 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
|
// straight to the tenant's real host regardless (confirmed working this way throughout the old
|
||||||
// app's development too).
|
// app's development too).
|
||||||
proxy: {
|
proxy: {
|
||||||
'/v1/im': { 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: 'https://api.118im.com', changeOrigin: true, secure: true },
|
'/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user