feat(web): pagination, quote/reply, favorite, and single-message forward
- ConversationView: infinite-scroll "加载更多" -- scroll-to-40px triggers
topic.msgHasMoreMessages()+getMessagesPage() to fill exactly the missing
seq ranges (not naive since/before paging, since server-side deletions can
punch holes in an otherwise-contiguous range), with scroll position
preserved across the prepend.
- lib/tinode/send.ts: sendReply() ports the old app's exact quote
construction (Drafty.replyContent truncates, Drafty.quote wraps with the
sender's name, Drafty.append inlines the whole quote block ahead of the
new text -- the receiving side never needs to look up the original
message). forwardMessage() sends a single message directly to one picked
target topic (ForwardPickerModal) -- simplified from the old app's
stage-then-compose flow, trading that nicety for a much smaller dialog.
- lib/messages/drafty.tsx: added the 'QQ' Drafty style-tag case ported
against the SDK's actual Drafty.quote() output shape (no `key`/`data` of
its own -- verified by reading tinode.dev.js, not guessed).
- lib/api/messages.ts: favoriteMessages() -- a single message is just a
one-item batch against POST /v1/im/favorites/batch (confirmed in
docs/IM-前后端对接文档.md:1935).
- Context menu now shows 引用/复制/收藏/转发/多选/[撤回]/删除 in spec order
(README §3.5); 翻译 still deliberately absent (no real backend for it,
confirmed by research).
Verified end to end: sent a real quote reply and confirmed the exact wire
packet (head.reply, QQ fmt span, MN entity) and that it renders correctly
via the new QQ formatter case; favorite POSTed and returned created_count:1;
forward POSTed the expected {head:{forwarded:"topic:seq"}} packet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bccbdff9b2
commit
6fcd779530
@@ -5,14 +5,17 @@ import type { StoredMessage } from 'tinode-sdk';
|
|||||||
import { Drafty } from 'tinode-sdk';
|
import { Drafty } from 'tinode-sdk';
|
||||||
|
|
||||||
import { Icon } from '@/app/Icon';
|
import { Icon } from '@/app/Icon';
|
||||||
|
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||||
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
||||||
import { MessageRow } from '@/features/messages/MessageBubble';
|
import { MessageRow } from '@/features/messages/MessageBubble';
|
||||||
import { useBootstrap } from '@/lib/api/hooks';
|
import { useBootstrap } from '@/lib/api/hooks';
|
||||||
import { recallMessage } from '@/lib/api/messages';
|
import { favoriteMessages, recallMessage } from '@/lib/api/messages';
|
||||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
|
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||||
import { availableActions, type MessageAction } from '@/lib/messages/actions';
|
import { availableActions, type MessageAction } from '@/lib/messages/actions';
|
||||||
|
import { renderDrafty } from '@/lib/messages/drafty';
|
||||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||||
import { sendText } from '@/lib/tinode/send';
|
import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send';
|
||||||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
@@ -39,7 +42,11 @@ export function ConversationView() {
|
|||||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
||||||
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
||||||
|
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
||||||
const streamRef = useRef<HTMLDivElement>(null);
|
const streamRef = useRef<HTMLDivElement>(null);
|
||||||
|
const autoScrollRef = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!topicId) return undefined;
|
if (!topicId) return undefined;
|
||||||
@@ -51,10 +58,13 @@ export function ConversationView() {
|
|||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
setSelectedSeqs(new Set());
|
setSelectedSeqs(new Set());
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
|
setReplyTo(null);
|
||||||
}, [topicId]);
|
}, [topicId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (autoScrollRef.current) {
|
||||||
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
||||||
|
}
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,11 +77,52 @@ export function ConversationView() {
|
|||||||
const peerName = contact?.public?.fn || topicId;
|
const peerName = contact?.public?.fn || topicId;
|
||||||
const isGroup = topic.isGroupType();
|
const isGroup = topic.isGroupType();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "加载更多" (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
|
||||||
|
* (topic.msgHasMoreMessages) and fills exactly those gaps (topic.getMessagesPage), since messages
|
||||||
|
* can have server-side deletions punching holes in an otherwise-contiguous range.
|
||||||
|
*/
|
||||||
|
const loadOlder = async () => {
|
||||||
|
if (loadingOlder) return;
|
||||||
|
const el = streamRef.current;
|
||||||
|
const min = topic.minMsgSeq();
|
||||||
|
const max = topic.maxMsgSeq();
|
||||||
|
const gaps = topic.msgHasMoreMessages(min, max, false);
|
||||||
|
if (gaps.length === 0) return;
|
||||||
|
setLoadingOlder(true);
|
||||||
|
const prevHeight = el?.scrollHeight ?? 0;
|
||||||
|
const prevTop = el?.scrollTop ?? 0;
|
||||||
|
try {
|
||||||
|
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
|
||||||
|
} finally {
|
||||||
|
setLoadingOlder(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const el = streamRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||||
|
if (el.scrollTop <= FETCH_PAGE_TRIGGER) {
|
||||||
|
loadOlder();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const text = draft.trim();
|
const text = draft.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
setDraft('');
|
setDraft('');
|
||||||
|
if (replyTo) {
|
||||||
|
const label = myUserId && replyTo.from === myUserId ? '我' : peerName;
|
||||||
|
sendReply(topic, text, replyTo, label);
|
||||||
|
setReplyTo(null);
|
||||||
|
} else {
|
||||||
sendText(topic, text);
|
sendText(topic, text);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@@ -125,6 +176,15 @@ export function ConversationView() {
|
|||||||
setSelectMode(true);
|
setSelectMode(true);
|
||||||
setSelectedSeqs(new Set([msg.seq]));
|
setSelectedSeqs(new Set([msg.seq]));
|
||||||
break;
|
break;
|
||||||
|
case 'quote':
|
||||||
|
setReplyTo(msg);
|
||||||
|
break;
|
||||||
|
case 'favorite':
|
||||||
|
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch(() => {});
|
||||||
|
break;
|
||||||
|
case 'forward':
|
||||||
|
setForwardMsg(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -178,7 +238,8 @@ export function ConversationView() {
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div ref={streamRef} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
<div ref={streamRef} onScroll={handleScroll} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
||||||
|
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={msg.seq}
|
key={msg.seq}
|
||||||
@@ -204,6 +265,16 @@ export function ConversationView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||||
|
{replyTo ? (
|
||||||
|
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
|
||||||
|
<span className="truncate">
|
||||||
|
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||||||
|
<Icon name="close" className="text-[16px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center gap-1 px-1 pb-1">
|
<div className="flex items-center gap-1 px-1 pb-1">
|
||||||
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
<Icon name="sentiment_satisfied_alt" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||||
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
<Icon name="image" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||||
@@ -241,6 +312,16 @@ export function ConversationView() {
|
|||||||
onClose={() => setMenu(null)}
|
onClose={() => setMenu(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{forwardMsg ? (
|
||||||
|
<ForwardPickerModal
|
||||||
|
onClose={() => setForwardMsg(null)}
|
||||||
|
onPick={(targetTopic) => {
|
||||||
|
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic));
|
||||||
|
setForwardMsg(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon } from '@/app/Icon';
|
||||||
|
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||||
|
import { useChatList } from '@/lib/tinode/hooks';
|
||||||
|
|
||||||
|
interface ForwardPickerModalProps {
|
||||||
|
onPick: (targetTopic: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified single-target contact picker for the message context menu's "转发". The full design
|
||||||
|
* (README §3.6) supports multi-select + a merge/separate-forward toggle for the multi-select bulk
|
||||||
|
* bar's forward action -- this is the smaller single-message version, one tap sends immediately.
|
||||||
|
*/
|
||||||
|
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||||
|
const contacts = useChatList();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||||
|
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<header className="flex h-14 flex-none items-center justify-between border-b border-list-item px-5">
|
||||||
|
<strong className="text-[15px] font-semibold text-text-heading">转发到</strong>
|
||||||
|
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="flex-none px-4 pt-3">
|
||||||
|
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||||
|
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索会话或联系人"
|
||||||
|
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
|
{filtered.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.topic}
|
||||||
|
type="button"
|
||||||
|
onClick={() => c.topic && onPick(c.topic)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
|
||||||
|
>
|
||||||
|
{avatarLetter(c.public?.fn)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,10 @@ interface MenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS: Record<MessageAction, MenuItem> = {
|
const ITEMS: Record<MessageAction, MenuItem> = {
|
||||||
|
quote: { action: 'quote', label: '引用', icon: 'format_quote' },
|
||||||
copy: { action: 'copy', label: '复制', icon: 'content_copy' },
|
copy: { action: 'copy', label: '复制', icon: 'content_copy' },
|
||||||
|
favorite: { action: 'favorite', label: '收藏', icon: 'inventory_2' },
|
||||||
|
forward: { action: 'forward', label: '转发', icon: 'reply' },
|
||||||
recall: { action: 'recall', label: '撤回', icon: 'undo' },
|
recall: { action: 'recall', label: '撤回', icon: 'undo' },
|
||||||
select: { action: 'select', label: '多选', icon: 'select_all' },
|
select: { action: 'select', label: '多选', icon: 'select_all' },
|
||||||
delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true },
|
delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true },
|
||||||
|
|||||||
@@ -68,3 +68,15 @@ export function getTopicMedia(topic: string, type: MediaType, cursor = '') {
|
|||||||
if (cursor) qs.set('cursor', cursor);
|
if (cursor) qs.set('cursor', cursor);
|
||||||
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single message's "收藏" context-menu action is really just a one-item batch (docs/
|
||||||
|
* IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->
|
||||||
|
* `{"created_count","existing_count","failed":[]}`. `seq` travels as a string per the documented
|
||||||
|
* example, not a number.
|
||||||
|
*/
|
||||||
|
export function favoriteMessages(items: Array<{ topic: string; seq: number }>) {
|
||||||
|
return imPost<{ created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }>('/favorites/batch', {
|
||||||
|
messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
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 { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||||
|
|
||||||
/** 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). */
|
||||||
const LOCAL_SEQID = 0xfffffff;
|
const LOCAL_SEQID = 0xfffffff;
|
||||||
|
|
||||||
export type MessageAction = 'copy' | 'recall' | 'delete' | 'select';
|
export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall' | 'delete' | 'select';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
||||||
* 引用/收藏/翻译/转发 are deliberately not in this list yet: quote and forward need their own
|
* 翻译 is deliberately never in this list -- confirmed via research that the old app has zero
|
||||||
* composer-state flows, and there's no confirmed single-message favorite endpoint yet -- adding
|
* backend call or handler for it anywhere, only a decorative id in its mock; there's nothing real
|
||||||
* them here before they actually work would just be dead menu items.
|
* to wire up.
|
||||||
*/
|
*/
|
||||||
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
|
||||||
const actions: MessageAction[] = ['copy'];
|
if (isServerDeleted(msg) || isRecalled(msg)) return [];
|
||||||
|
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
|
||||||
const mine = !!myUserId && msg.from === myUserId;
|
const mine = !!myUserId && msg.from === myUserId;
|
||||||
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
|
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
|
||||||
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
|
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ function formatNode(
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case 'QQ':
|
||||||
|
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style
|
||||||
|
// span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`.
|
||||||
|
return (
|
||||||
|
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <Fragment key={key}>{children}</Fragment>;
|
return <Fragment key={key}>{children}</Fragment>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
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';
|
||||||
|
|
||||||
|
function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent {
|
||||||
|
if (content == null) return Drafty.init('');
|
||||||
|
return typeof content === 'string' ? Drafty.init(content) : content;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -30,3 +37,35 @@ export function retryMessage(topic: Topic, seq: number): Promise<unknown> | unde
|
|||||||
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
|
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
|
||||||
return !!myUserId && msg.from === myUserId;
|
return !!myUserId && msg.from === myUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply/quote (old app's messages-view.jsx handlePickReply/sendMessage): the quoted body is
|
||||||
|
* truncated (Drafty.replyContent, QUOTED_REPLY_LENGTH) and wrapped with the sender's name
|
||||||
|
* (Drafty.quote), then the whole quote block is INLINED into the outgoing content ahead of the
|
||||||
|
* new text (Drafty.append) -- the receiving side never has to look up the original message to show
|
||||||
|
* a quote preview. `head.reply` carries the original seq separately, purely so a click on the quote
|
||||||
|
* block can scroll back to it.
|
||||||
|
*/
|
||||||
|
export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise<unknown> {
|
||||||
|
const trimmed = Drafty.replyContent(toDraftyContent(quoted.content), QUOTED_REPLY_LENGTH);
|
||||||
|
const quoteBlock = Drafty.quote(quotedSenderLabel, quoted.from || '', trimmed);
|
||||||
|
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
|
||||||
|
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
|
||||||
|
const msg = topic.createMessage(finalContent, false);
|
||||||
|
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq) });
|
||||||
|
return topic.publishDraft(msg, Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-message forward, sent directly to one target topic. Simplified from the old app's own
|
||||||
|
* flow (which stages the forwarded content and lets the user add a comment via the normal composer
|
||||||
|
* before sending) -- this sends immediately on picking a target, trading that one nicety for a
|
||||||
|
* much smaller "pick a contact" dialog. `head.forwarded` records the source for traceability; it
|
||||||
|
* has no reader anywhere yet, matching the old app (no confirmed call site reads it back either).
|
||||||
|
*/
|
||||||
|
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
|
||||||
|
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
|
||||||
|
const newMsg = target.createMessage(content, false);
|
||||||
|
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}` });
|
||||||
|
return target.publishDraft(newMsg, Promise.resolve());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user