feat(messaging): implement three-tier reply message lazy loading with offline fallback
Some checks failed
Frontend CI / ota (android) (push) Successful in 2m56s
Frontend CI / ota (ios) (push) Failing after 2m23s
Frontend CI / build-and-push-web (push) Failing after 1m31s
Frontend CI / build-android-apk (push) Successful in 47m22s

Add lazy loading pipeline for reply message previews that checks memory → SQLite → server in order, enabling offline hit and graceful degradation for deleted/inaccessible messages. Includes `getReplyMessage` API endpoint and `jumpToMessageSeq` helper for navigation to referenced messages.

feat(create): extract MomentComposer and LongPostComposer for dual posting modes

Refactor CreatePostScreen shell to delegate content editing to specialized composers based on postMode ('moment' or 'long'), enabling long posts with voting support. Add expandable FAB with mode selection on HomeScreen.

fix(navigation): use navigate instead of push to prevent duplicate chat instances

Replace router.push with router.navigate in notification bootstrap to avoid creating duplicate chat instances when already on the chat screen.

fix(ui): update author badge styling with proper text contrast

Change author badge background to use hex transparency and add dedicated text color for better readability on both light and dark themes.

chore: normalize filename handling in file uploads and improve file segment rendering

Use asset.name as authoritative filename, remove redundant shadows from file cards inside bubbles.
This commit is contained in:
lafay
2026-07-02 23:26:11 +08:00
parent f9b0999112
commit 45df579b72
22 changed files with 1737 additions and 1066 deletions

View File

@@ -46,6 +46,7 @@ import {
} from './types';
import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants';
import { messageRepository } from '@/database';
import { useReplyMessage } from './useReplyMessage';
interface MentionRange {
start: number;
@@ -607,11 +608,16 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const ascendingIndex = messages.findIndex(m => m.seq === targetSeq);
const displayIndex = messages.length - 1 - ascendingIndex;
setTimeout(() => {
flatListRef.current?.scrollToIndex({
index: displayIndex,
animated: false,
viewPosition: 0.5,
});
try {
flatListRef.current?.scrollToIndex({
index: displayIndex,
animated: false,
viewPosition: 0.5,
});
} catch {
// FlashList 远距离跳转可能失败,降级到 offset 滚动
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: false });
}
}, 50);
return;
}
@@ -623,6 +629,45 @@ export const useChatScreen = (props?: ChatScreenProps) => {
}
}, [loading, loadingMore, messages, hasMoreHistory, loadMoreHistory]);
/**
* 跳转到指定 seq 的消息(供引用消息点击跳转复用 scrollToSeq 范式)。
* 设置目标 seq 后由上方 effect 接管:内存命中则 scrollToIndex
* 未命中则循环 loadMoreHistory 直到目标进入视窗或历史耗尽。
* @returns 是否已受理跳转false 表示无更多历史且不在内存,无法定位)
*/
const jumpToMessageSeq = useCallback((seq: number): boolean => {
if (!hasInitialAnchorDoneRef.current) return false;
if (!Number.isFinite(seq) || seq <= 0) return false;
scrollToSeqRef.current = seq;
isBrowsingHistoryRef.current = true;
suppressAutoFollowRef.current = true;
// 目标已在内存直接定位effect 依赖未变不会自动重跑,这里同步处理命中情况)
const ascendingIndex = messages.findIndex(m => m.seq === seq);
if (ascendingIndex >= 0) {
scrollToSeqRef.current = null;
const displayIndex = messages.length - 1 - ascendingIndex;
setTimeout(() => {
try {
flatListRef.current?.scrollToIndex({ index: displayIndex, animated: true, viewPosition: 0.5 });
} catch {
flatListRef.current?.scrollToOffset?.({ offset: 0, animated: true });
}
}, 50);
return true;
}
// 目标不在内存kickstart 历史加载循环,由 scrollToSeq effect 接管后续
if (hasMoreHistory) {
loadMoreHistory();
return true;
}
// 无更多历史且不在内存,无法定位
scrollToSeqRef.current = null;
return false;
}, [messages, hasMoreHistory, loadMoreHistory, flatListRef]);
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
scrollPositionRef.current.contentHeight = contentHeight;
@@ -886,6 +931,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
return map;
}, [messages]);
// 引用消息回填:被引用消息不在已加载内存区间时,懒加载回填预览/跳转所需数据
const { getCached: getCachedReply, ensureReplyMessage } = useReplyMessage();
// 选择@用户
const handleSelectMention = useCallback((member: { user_id: string; nickname?: string; user?: { nickname?: string } }) => {
const currentText = inputTextRef.current;
@@ -1320,7 +1368,9 @@ export const useChatScreen = (props?: ChatScreenProps) => {
const segments = buildFileSegments(
uploaded.url,
uploaded.name || asset.name,
// asset.name 是 DocumentPicker 返回的用户原始文件名(真实名),
// 优先使用它uploaded.name 为后端回传的展示名(已与 asset.name 一致)。
asset.name || uploaded.name || 'file',
uploaded.size ?? asset.size,
uploaded.mime_type ?? asset.mimeType,
replyingTo,
@@ -1643,6 +1693,10 @@ export const useChatScreen = (props?: ChatScreenProps) => {
effectiveGroupName,
otherUserLastReadSeq,
messageMap,
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
getCachedReply,
ensureReplyMessage,
jumpToMessageSeq,
loadingMore,
hasMoreHistory,