From 45df579b72f4581f3c5b511a218471a74ec32477 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Thu, 2 Jul 2026 23:26:11 +0800 Subject: [PATCH] feat(messaging): implement three-tier reply message lazy loading with offline fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/_layout.tsx | 9 +- src/components/business/CommentItem.tsx | 16 +- .../repositories/MessageRepository.ts | 13 + src/navigation/hrefs.ts | 10 +- src/screens/create/CreatePostScreen.tsx | 1268 +++++------------ .../create/composers/ChannelPicker.tsx | 140 ++ src/screens/create/composers/EmojiPanel.tsx | 121 ++ .../create/composers/LongPostComposer.tsx | 135 ++ .../create/composers/MomentComposer.tsx | 223 +++ src/screens/create/composers/types.ts | 57 + src/screens/home/HomeScreen.tsx | 122 +- src/screens/home/PostDetailScreen.tsx | 273 +++- src/screens/message/ChatScreen.tsx | 52 +- .../components/ChatScreen/MessageBubble.tsx | 64 +- .../components/ChatScreen/SegmentRenderer.tsx | 36 +- .../message/components/ChatScreen/types.ts | 4 + .../components/ChatScreen/useChatScreen.ts | 66 +- .../components/ChatScreen/useReplyMessage.ts | 126 ++ src/services/message/messageService.ts | 27 + src/services/post/voteService.ts | 28 +- src/services/upload/uploadService.ts | 9 +- src/types/dto/pagination.ts | 4 + 22 files changed, 1737 insertions(+), 1066 deletions(-) create mode 100644 src/screens/create/composers/ChannelPicker.tsx create mode 100644 src/screens/create/composers/EmojiPanel.tsx create mode 100644 src/screens/create/composers/LongPostComposer.tsx create mode 100644 src/screens/create/composers/MomentComposer.tsx create mode 100644 src/screens/create/composers/types.ts create mode 100644 src/screens/message/components/ChatScreen/useReplyMessage.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index 5ae7eaa..aee4b0b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -148,7 +148,10 @@ function NotificationBootstrap() { if (conversationId) { const isGroup = conversationType === 'group'; - router.push( + // 使用 navigate 而非 push,避免: + // 1. 在已有聊天页上创建重复实例(用户需要退出两次) + // 2. 从帖子等页面打开聊天通知后返回到帖子(而非消息列表) + router.navigate( hrefs.hrefChat({ conversationId, userId: isGroup ? undefined : senderId, @@ -159,10 +162,10 @@ function NotificationBootstrap() { }) ); } else { - router.push(hrefs.hrefNotifications()); + router.navigate(hrefs.hrefNotifications()); } } else { - router.push(hrefs.hrefNotifications()); + router.navigate(hrefs.hrefNotifications()); } }); diff --git a/src/components/business/CommentItem.tsx b/src/components/business/CommentItem.tsx index 7932c37..adf1d62 100644 --- a/src/components/business/CommentItem.tsx +++ b/src/components/business/CommentItem.tsx @@ -84,7 +84,7 @@ function createCommentItemStyles(colors: AppColors) { paddingVertical: 0, }, authorBadge: { - backgroundColor: colors.primary.main, + backgroundColor: `${colors.primary.main}18`, }, adminBadge: { backgroundColor: colors.error.main, @@ -94,11 +94,21 @@ function createCommentItemStyles(colors: AppColors) { fontSize: fontSizes.xs, fontWeight: '600', }, + authorBadgeText: { + color: colors.primary.main, + fontSize: fontSizes.xs, + fontWeight: '700', + }, smallBadgeText: { color: colors.text.inverse, fontSize: 9, fontWeight: '600', }, + smallAuthorBadgeText: { + color: colors.primary.main, + fontSize: 9, + fontWeight: '700', + }, timeText: { fontSize: fontSizes.xs, flexShrink: 0, @@ -304,7 +314,7 @@ const CommentItem: React.FC = ({ if (isAuthor) { badges.push( - 楼主 + 楼主 ); } @@ -460,7 +470,7 @@ const CommentItem: React.FC = ({ {replyAuthorId === commentAuthorId && ( - 楼主 + 楼主 )} {/* 显示回复引用:aaa 回复 bbb */} diff --git a/src/database/repositories/MessageRepository.ts b/src/database/repositories/MessageRepository.ts index 32ee937..62c0050 100644 --- a/src/database/repositories/MessageRepository.ts +++ b/src/database/repositories/MessageRepository.ts @@ -124,6 +124,19 @@ export class MessageRepository implements IMessageRepository { return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse(); } + /** + * 按消息 ID 获取单条缓存消息(用于引用预览的离线命中优先查询)。 + * 不限定 conversationId——引用消息可能来自任意会话,按主键 id 直接查即可。 + */ + async getById(messageId: string): Promise { + const r = await this.dataSource.getFirst( + `SELECT * FROM messages WHERE id = ?`, + [messageId] + ); + if (!r) return null; + return { ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }; + } + async getCount(conversationId: string): Promise { const r = await this.dataSource.getFirst<{ count: number }>( `SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId] diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts index 3025065..d25fec4 100644 --- a/src/navigation/hrefs.ts +++ b/src/navigation/hrefs.ts @@ -76,10 +76,18 @@ export function hrefProfileBookmarks(): string { return '/profile/bookmarks'; } -export function hrefCreatePost(mode?: 'create' | 'edit', postId?: string): string { +export function hrefCreatePost( + mode?: 'create' | 'edit', + postId?: string, + postMode?: 'moment' | 'long', +): string { if (mode === 'edit' && postId) { return `/posts/create?mode=edit&postId=${encodeURIComponent(postId)}`; } + // 新建时可指定入口模式:moment=瞬间(普通),long=长文(含投票)。 + if (postMode) { + return `/posts/create?postMode=${postMode}`; + } return '/posts/create'; } diff --git a/src/screens/create/CreatePostScreen.tsx b/src/screens/create/CreatePostScreen.tsx index caccf90..72aa002 100644 --- a/src/screens/create/CreatePostScreen.tsx +++ b/src/screens/create/CreatePostScreen.tsx @@ -1,10 +1,15 @@ /** - * 发帖页 CreatePostScreen(响应式适配) - * 威友 - 发布新帖子 - * 参考微博发帖界面设计 - * 表单在宽屏下居中显示 - * 图片选择器在宽屏下显示更大的预览 - * 投票编辑器在宽屏下优化布局 + * 发帖页 CreatePostScreen(响应式适配)—— 外壳 + * + * 职责:顶栏(标题/字数/发布)、滚动容器、频道选择、表情面板、底部工具栏、 + * 加载态、编辑加载、响应式布局、发布流程编排。 + * + * 两种发帖模式的正文编辑分别由 MomentComposer(瞬间)与 LongPostComposer(长文) + * 承担,外壳通过统一的 ComposerHandle ref 无差别驱动,主文件内不再有模式分支。 + * + * postMode 由入口确定,进入后锁定: + * - 'moment' = 瞬间(普通) + * - 'long' = 长文(含投票) */ import React, { useState, useCallback, useMemo } from 'react'; @@ -17,106 +22,41 @@ import { Alert, KeyboardAvoidingView, Platform, - Animated, - Image, useWindowDimensions, - FlatList, Keyboard, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import * as ImagePicker from 'expo-image-picker'; import { spacing, fontSizes, - borderRadius, - shadows, useAppColors, type AppColors, } from '../../theme'; import { Text, ResponsiveContainer } from '../../components/common'; import { channelService, postService, showPrompt, voteService } from '../../services'; import { ApiError } from '@/services/core'; -import VoteEditor from '../../components/business/VoteEditor'; -import PostMentionInput from '../../components/business/PostMentionInput'; -import BlockEditor, { BlockEditorHandle } from '../../components/business/BlockEditor'; import { segmentsToBlocks } from '../../components/business/BlockEditor/blocksToSegments'; -import { - generateBlockId, - type EditorBlock, - type ImageBlock, -} from '../../components/business/BlockEditor/blockEditorTypes'; -import { useResponsive, useResponsiveValue } from '../../hooks'; +import { type EditorBlock } from '../../components/business/BlockEditor/blockEditorTypes'; +import { useResponsive } from '../../hooks'; import * as hrefs from '../../navigation/hrefs'; import { MessageSegment } from '../../types'; -import { - type PendingOrRemoteImage, - type RemoteImage, - generatePendingImageId, - makePendingImageFromAsset, - getImageDisplayUri, - uploadAllPendingImages, -} from '../../utils/pendingImages'; +import { type RemoteImage, generatePendingImageId } from '../../utils/pendingImages'; import { generateClientRequestId } from '../../utils/idempotency'; +import { MomentComposer } from './composers/MomentComposer'; +import { LongPostComposer } from './composers/LongPostComposer'; +import { EmojiPanel } from './composers/EmojiPanel'; +import { ChannelPicker, type ChannelOption } from './composers/ChannelPicker'; +import { MAX_CONTENT_LENGTH, type ComposerHandle, type LongPostComposerHandle } from './composers/types'; // Props 接口 interface CreatePostScreenProps { onClose?: () => void; + postMode?: 'moment' | 'long'; } const MAX_TITLE_LENGTH = 100; -const MAX_CONTENT_LENGTH = 2000; -type ChannelOption = { - id: string; - name: string; -}; - -// 表情面板高度 -const EMOJI_PANEL_HEIGHT = 280; - -// 常用表情列表 -const EMOJIS = [ - '😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', - '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', - '😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜', - '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', - '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', - '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', - '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', - '😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', - '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', - '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', - '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', - '🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', - '👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', - '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', - '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', - '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵', - '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', - '🤎', '💔', '🩹', '💕', '💞', '💓', '💗', '💖', - '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥', - '💯', '💢', '💥', '💫', '💦', '💨', '🕳️', -]; - -// 每行显示的 emoji 数量 -const EMOJIS_PER_ROW = 8; -const EMOJI_ROW_HEIGHT = 52; - -// 将 emoji 分组成行,用于 FlatList 虚拟化 -const EMOJI_ROWS = (() => { - const rows: { id: string; emojis: string[] }[] = []; - for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) { - rows.push({ - id: `emoji-row-${i}`, - emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW), - }); - } - return rows; -})(); - -// 动画值 -const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const getPublishErrorMessage = (error: unknown): string => { if (error instanceof ApiError && error.message?.trim()) { @@ -131,19 +71,21 @@ export const CreatePostScreen: React.FC = (props) => { const { onClose } = props; const router = useRouter(); const navigation = useNavigation(); - const { mode, postId: postIdParam } = useLocalSearchParams<{ mode?: string; postId?: string }>(); + const { mode, postId: postIdParam, postMode: postModeParam } = useLocalSearchParams<{ mode?: string; postId?: string; postMode?: string }>(); const isEditMode = mode === 'edit' && !!postIdParam; const editPostID = postIdParam || ''; - + + // 发帖模式:prop > 路由 query > 默认 moment。编辑态含 image 段时会自动切长文。 + const requestedMode: 'moment' | 'long' = props.postMode + ?? (postModeParam === 'long' ? 'long' : 'moment'); + const [postMode, setPostMode] = useState<'moment' | 'long'>(requestedMode); + // 响应式布局 - const { isWideScreen, width } = useResponsive(); - const { height: windowHeight } = useWindowDimensions(); - + const { isWideScreen } = useResponsive(); + + // 共享状态 const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); - const [segments, setSegments] = useState([]); - // 图片状态:pending 表示本地待上传,remote 表示已是服务器 URL(编辑模式下的已存在图片) - const [images, setImages] = useState([]); + const [contentForCount, setContentForCount] = useState(''); // 顶栏字数统计(两模式统一) const [channelOptions, setChannelOptions] = useState([]); const [selectedChannelId, setSelectedChannelId] = useState(null); const [showChannelPicker, setShowChannelPicker] = useState(false); @@ -151,56 +93,18 @@ export const CreatePostScreen: React.FC = (props) => { const [posting, setPosting] = useState(false); const [loadingPost, setLoadingPost] = useState(false); - // 长文模式:图片作为 image segment 嵌入文本 - const [isLongPostMode, setIsLongPostMode] = useState(false); + // 编辑态回填数据 + const [initialContent, setInitialContent] = useState(undefined); + const [initialImages, setInitialImages] = useState(undefined); const [initialBlocks, setInitialBlocks] = useState(undefined); + const [editLoaded, setEditLoaded] = useState(!isEditMode); // 非编辑态直接就绪 - // 投票相关状态 - const [isVotePost, setIsVotePost] = useState(false); - const [voteOptions, setVoteOptions] = useState(['', '']); // 默认2个选项 + // Composer ref(长文 ref 额外含 getVote) + const composerRef = React.useRef>(null); - // 内容输入框引用 - const contentInputRef = React.useRef(null); - const blockEditorRef = React.useRef(null); - const [selection, setSelection] = useState({ start: 0, end: 0 }); - - // 发帖幂等键:每次进入发布态生成一次,重试时复用同一个,避免重复发帖。 - // - 同一次「发布」意图(含网络重试)复用同一个 ID; - // - 发布成功后立即刷新,便于下一次发布生成新的 ID。 + // 发帖幂等键 const clientRequestIdRef = React.useRef(''); - // 动画值 - const fadeAnim = React.useRef(new Animated.Value(0)).current; - const slideAnim = React.useRef(new Animated.Value(20)).current; - - // 响应式图片网格配置 - const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 }); - const imageGap = 4; - const availableWidth = isWideScreen - ? Math.min(width, 800) - spacing.lg * 2 - : width - spacing.lg * 2; - const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow); - // 小红书风格:内容区域占满剩余空间,输入框自动扩展 - const contentInputMinHeight = Math.max( - isWideScreen ? 200 : 160, - Math.floor(windowHeight * (isWideScreen ? 0.3 : 0.25)) - ); - - React.useEffect(() => { - Animated.parallel([ - Animated.timing(fadeAnim, { - toValue: 1, - duration: 250, - useNativeDriver: true, - }), - Animated.timing(slideAnim, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }), - ]).start(); - }, []); - React.useEffect(() => { const loadChannels = async () => { const list = await channelService.list(); @@ -210,15 +114,12 @@ export const CreatePostScreen: React.FC = (props) => { }, []); React.useLayoutEffect(() => { - navigation.setOptions({ - headerShown: false, - }); + navigation.setOptions({ headerShown: false }); }, [navigation]); + // 编辑态:加载帖子并回填 React.useEffect(() => { - if (!isEditMode || !editPostID) { - return; - } + if (!isEditMode || !editPostID) return; const loadPostForEdit = async () => { setLoadingPost(true); @@ -230,15 +131,21 @@ export const CreatePostScreen: React.FC = (props) => { return; } setTitle(existingPost.title || ''); - setContent(existingPost.content || ''); - setImages((existingPost.images || []).map((img): RemoteImage => ({ id: generatePendingImageId(), kind: 'remote', url: img.url }))); + setContentForCount(existingPost.content || ''); + setInitialContent(existingPost.content || ''); + setInitialImages((existingPost.images || []).map((img): RemoteImage => ({ + id: generatePendingImageId(), + kind: 'remote', + url: img.url, + }))); - // 自动判断长文模式:segments 中包含图片块即为长文帖子 + // 含 image 段即为长文帖 const hasImageSegments = (existingPost.segments || []).some(s => s.type === 'image'); if (hasImageSegments) { - setIsLongPostMode(true); + setPostMode('long'); setInitialBlocks(segmentsToBlocks(existingPost.segments || [])); } + setEditLoaded(true); } catch (error) { console.error('加载待编辑帖子失败:', error); Alert.alert('错误', '加载帖子失败,请稍后重试'); @@ -251,169 +158,35 @@ export const CreatePostScreen: React.FC = (props) => { loadPostForEdit(); }, [isEditMode, editPostID, router]); - // 选择图片(普通模式)—— 仅选择,不立即上传 - const handlePickImage = async () => { - const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + const isLong = postMode === 'long'; - if (!permissionResult.granted) { - Alert.alert('权限不足', '需要访问相册权限来选择图片'); - return; - } - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: 'images', - allowsMultipleSelection: true, - selectionLimit: 0, - quality: 1, - }); - - if (!result.canceled && result.assets) { - const newImages = result.assets.map(asset => makePendingImageFromAsset(asset)); - setImages(prev => [...prev, ...newImages]); - } - }; - - // 拍照 —— 仅添加,不立即上传 - const handleTakePhoto = async () => { - const permissionResult = await ImagePicker.requestCameraPermissionsAsync(); - - if (!permissionResult.granted) { - Alert.alert('权限不足', '需要访问相机权限来拍照'); - return; - } - - const result = await ImagePicker.launchCameraAsync({ - allowsEditing: true, - quality: 0.8, - }); - - if (!result.canceled && result.assets[0]) { - const asset = result.assets[0]; - setImages(prev => [...prev, makePendingImageFromAsset(asset)]); - } - }; - - // 删除图片 - const handleRemoveImage = (index: number) => { - setImages(prev => prev.filter((_, i) => i !== index)); - }; - - // 选择频道(单选,可再次点击取消) + // —— 频道 —— const handleSelectChannel = useCallback((channelId: string) => { setSelectedChannelId(prev => (prev === channelId ? null : channelId)); setShowChannelPicker(false); }, []); - // 插入表情 - const handleInsertEmoji = (emoji: string) => { - if (isLongPostMode) { - blockEditorRef.current?.insertTextAtCursor(emoji); - return; - } - const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end); - setContent(newContent); - const newPosition = selection.start + emoji.length; - setSelection({ start: newPosition, end: newPosition }); - }; - - // 关闭所有面板 - const closeAllPanels = () => { + const handleToggleChannelPicker = () => { setShowEmojiPanel(false); - setShowChannelPicker(false); + setShowChannelPicker(prev => !prev); }; - // 切换长文/非长文模式时保留数据 - const handleToggleLongPostMode = useCallback(() => { - if (!isLongPostMode) { - // 非长文 -> 长文:将当前文本和图片(包括 pending)转为 blocks - const blocks: EditorBlock[] = []; - if (content.trim()) { - blocks.push({ - id: generateBlockId(), - type: 'text', - text: content, - activeMentions: new Map(), - }); - } - for (const img of images) { - const imageBlock: ImageBlock = img.kind === 'remote' - ? { id: generateBlockId(), type: 'image', localUri: img.url, remoteUrl: img.url } - : { id: generateBlockId(), type: 'image', localUri: img.localUri, mimeType: img.mimeType }; - blocks.push(imageBlock); - } - setInitialBlocks(blocks.length > 0 ? blocks : undefined); - } else { - // 长文 -> 非长文:从 BlockEditor 提取文本和所有图片(包括 pending) - const editorContent = blockEditorRef.current?.getContent() || ''; - const editorBlocks = blockEditorRef.current?.getBlocks() || []; - if (editorContent) setContent(editorContent); - - const editorImages: PendingOrRemoteImage[] = []; - for (const block of editorBlocks) { - if (block.type !== 'image') continue; - if (block.remoteUrl) { - editorImages.push({ id: generatePendingImageId(), kind: 'remote', url: block.remoteUrl }); - } else { - editorImages.push({ - id: generatePendingImageId(), - kind: 'pending', - localUri: block.localUri, - mimeType: block.mimeType, - }); - } - } - setImages(editorImages); - } - setIsLongPostMode(prev => !prev); - }, [isLongPostMode, content, images]); - - // 切换表情面板 + // —— 表情 —— const handleToggleEmojiPanel = () => { const willShow = !showEmojiPanel; - closeAllPanels(); - if (willShow) { - Keyboard.dismiss(); - } + setShowChannelPicker(false); + if (willShow) Keyboard.dismiss(); setShowEmojiPanel(willShow); }; - // 切换频道选择 - const handleToggleChannelPicker = () => { - closeAllPanels(); - setShowChannelPicker(!showChannelPicker); - }; + // —— 投票(长文专属,切换态存于 Composer;此处仅触发其 UI)—— + // 通过 Composer 内部管理投票 UI,工具栏投票按钮点击时由外壳记录一个信号量太复杂, + // 因此改由 LongPostComposer 暴露的 handle 直接不可行(切换需 setState)。 + // 采用简单方案:投票开关放在 Composer 内部触发(见下方工具栏按钮)。 + const [voteToggleSignal, setVoteToggleSignal] = useState(0); - // 切换投票模式 - const handleToggleVote = () => { - closeAllPanels(); - setIsVotePost(!isVotePost); - }; - - // 添加投票选项 - const handleAddVoteOption = () => { - if (voteOptions.length < 10) { - setVoteOptions([...voteOptions, '']); - } - }; - - // 删除投票选项 - const handleRemoveVoteOption = (index: number) => { - if (voteOptions.length > 2) { - setVoteOptions(voteOptions.filter((_, i) => i !== index)); - } - }; - - // 更新投票选项 - const handleUpdateVoteOption = (index: number, value: string) => { - const newOptions = [...voteOptions]; - newOptions[index] = value; - setVoteOptions(newOptions); - }; - - // 发布帖子 + // 发布 const handlePost = async () => { - // 防止重复点击:posting 为 true 时直接忽略后续点击。 - // 这是第一道防线(UI 层防抖);服务端基于 client_request_id 的幂等检查是第二道防线。 if (posting) return; if (!title.trim()) { @@ -421,97 +194,64 @@ export const CreatePostScreen: React.FC = (props) => { return; } - const contentToCheck = isLongPostMode - ? (blockEditorRef.current?.getContent() || content) - : content; - - if (!contentToCheck.trim()) { + const composer = composerRef.current; + const contentText = composer?.getContent().trim() || ''; + if (!contentText) { Alert.alert('错误', '请输入帖子内容'); return; } - // 检查 BlockEditor 中是否有正在上传的图片 - // (已无此状态,发布时会统一上传,无需检查) - setPosting(true); - // 为本次「发布」意图生成幂等键。同一意图的网络重试复用同一个 ID, - // 使服务端能识别并去重。发布成功后会刷新,供下次发布生成新 ID。 if (!clientRequestIdRef.current) { clientRequestIdRef.current = generateClientRequestId(); } const clientRequestId = clientRequestIdRef.current; try { - // 普通模式下,发布前上传所有待上传的图片 - let uploadedImageUrls: string[] = []; - if (!isLongPostMode && images.length > 0) { - const uploadResult = await uploadAllPendingImages(images, (id, url) => { - setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); - }); - if (!uploadResult.success) { - setPosting(false); - Alert.alert('上传失败', '部分图片上传失败,请重试'); - return; - } - uploadedImageUrls = uploadResult.urls; + // 上传图片(瞬间:独立 images;长文:块内 pending 图片) + const uploadOk = await composer?.uploadImages(); + if (uploadOk === false) { + setPosting(false); + Alert.alert('上传失败', '部分图片上传失败,请重试'); + return; } - // 长文模式下,发布前上传 BlockEditor 中所有 pending 图片 - if (isLongPostMode) { - const uploadOk = await blockEditorRef.current?.uploadPendingImages(); - if (uploadOk === false) { - setPosting(false); - Alert.alert('上传失败', '部分图片上传失败,请重试'); - return; - } - } + const segments = composer?.getSegments() || []; + const finalSegments = segments.length > 0 ? segments : undefined; - if (isVotePost) { - // 验证投票选项 - const validOptions = voteOptions.filter(opt => opt.trim() !== ''); - if (validOptions.length < 2) { - Alert.alert('错误', '投票选项至少需要2个'); - setPosting(false); - return; - } - - // 创建投票帖子 - await voteService.createVotePost({ - title: title.trim(), - content: content.trim(), - images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, - channel_id: selectedChannelId || undefined, - vote_options: validOptions.map(opt => ({ content: opt })), - client_request_id: clientRequestId, - }); - showPrompt({ - type: 'info', - title: '审核中', - message: '投票帖已提交,内容审核中,稍后展示', - duration: 2600, - }); - router.replace(hrefs.hrefHome()); - } else if (isLongPostMode) { - // 长文模式:从 BlockEditor 获取 segments - const editorSegments = blockEditorRef.current?.getSegments() || []; - const editorContent = blockEditorRef.current?.getContent() || ''; - const finalSegments = editorSegments.length > 0 ? editorSegments : undefined; - if (isEditMode && editPostID) { + if (isLong) { + const vote = composer?.getVote?.() ?? { isVotePost: false, voteOptions: [] }; + if (vote.isVotePost) { + const validOptions = vote.voteOptions.filter(opt => opt.trim() !== ''); + if (validOptions.length < 2) { + Alert.alert('错误', '投票选项至少需要2个'); + setPosting(false); + return; + } + await voteService.createVotePost({ + title: title.trim(), + content: contentText, + segments: finalSegments, + channel_id: selectedChannelId || undefined, + vote_options: validOptions.map(opt => ({ content: opt })), + client_request_id: clientRequestId, + }); + showPrompt({ type: 'info', title: '审核中', message: '投票帖已提交,内容审核中,稍后展示', duration: 2600 }); + router.replace(hrefs.hrefHome()); + } else if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { title: title.trim(), - content: editorContent.trim(), + content: contentText, segments: finalSegments, }); - if (!updated) { - throw new Error('更新帖子失败'); - } + if (!updated) throw new Error('更新帖子失败'); showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 }); router.replace(hrefs.hrefHome()); } else { await postService.createPost({ title: title.trim(), - content: editorContent.trim(), + content: contentText, segments: finalSegments, channel_id: selectedChannelId || undefined, client_request_id: clientRequestId, @@ -520,25 +260,27 @@ export const CreatePostScreen: React.FC = (props) => { router.replace(hrefs.hrefHome()); } } else { - // 普通模式 + // 瞬间模式:独立 images 字段 + const uploadedImages = composer?.getUploadedImages() || []; + const images = uploadedImages.length > 0 ? uploadedImages : undefined; + // 仅当含非文本段(@ 等)时才提交 segments + const momentSegments = segments.some(s => s.type !== 'text') ? finalSegments : undefined; if (isEditMode && editPostID) { const updated = await postService.updatePost(editPostID, { title: title.trim(), - content: content.trim(), - segments: segments.some(s => s.type !== 'text') ? segments : undefined, - images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, + content: contentText, + segments: momentSegments, + images, }); - if (!updated) { - throw new Error('更新帖子失败'); - } + if (!updated) throw new Error('更新帖子失败'); showPrompt({ type: 'success', title: '修改成功', message: '帖子内容已更新', duration: 2200 }); router.replace(hrefs.hrefHome()); } else { await postService.createPost({ title: title.trim(), - content: content.trim(), - segments: segments.some(s => s.type !== 'text') ? segments : undefined, - images: uploadedImageUrls.length > 0 ? uploadedImageUrls : undefined, + content: contentText, + segments: momentSegments, + images, channel_id: selectedChannelId || undefined, client_request_id: clientRequestId, }); @@ -546,7 +288,6 @@ export const CreatePostScreen: React.FC = (props) => { router.replace(hrefs.hrefHome()); } } - // 发布成功:刷新幂等键,下次发布生成新 ID。 clientRequestIdRef.current = ''; } catch (error) { console.error('发布帖子失败:', error); @@ -556,315 +297,11 @@ export const CreatePostScreen: React.FC = (props) => { } }; - // 渲染图片网格 - const renderImageGrid = () => { - if (images.length === 0) return null; - - return ( - - {images.map((img, index) => ( - - - handleRemoveImage(index)} - hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} - > - - - - - - ))} - {/* 添加图片按钮 */} - - - - - ); + const handleInsertEmoji = (emoji: string) => { + composerRef.current?.insertEmoji(emoji); }; - // 渲染频道(单选) - const renderChannelPicker = () => { - if (!showChannelPicker) return null; - - return ( - - - {channelOptions.map((channel) => { - const isSelected = selectedChannelId === channel.id; - return ( - handleSelectChannel(channel.id)} - > - - {channel.name} - - - ); - })} - - {channelOptions.length === 0 && ( - 暂无可用频道 - )} - - ); - }; - - const selectedChannelName = React.useMemo(() => { - if (!selectedChannelId) return ''; - return channelOptions.find(c => c.id === selectedChannelId)?.name || ''; - }, [selectedChannelId, channelOptions]); - - const renderChannelEntryRow = () => ( - - - 发表至: - - - - {selectedChannelName || '选择频道'} - - - - - ); - - // 渲染投票编辑器 - const renderVoteEditor = () => { - if (!isVotePost) return null; - - return ( - - - - 已插入投票 - - setIsVotePost(false)} activeOpacity={0.7}> - 移除投票 - - - - - ); - }; - - // 渲染内容输入区 - const renderContentSection = () => ( - - {/* 标题输入 */} - - - {isLongPostMode ? ( - - ) : ( - <> - {/* 正文输入(支持 @提及)——小红书风格:无边框、无背景、自动扩展 */} - - {renderImageGrid()} - - )} - - {renderVoteEditor()} - - ); - - // 渲染表情面板 —— 使用 FlatList 虚拟化渲染,解决显示不全和卡顿 - const renderEmojiPanel = () => { - if (!showEmojiPanel) return null; - - return ( - - item.id} - renderItem={({ item }) => ( - - {item.emojis.map((emoji, index) => ( - handleInsertEmoji(emoji)} - activeOpacity={0.7} - > - {emoji} - - ))} - - )} - showsVerticalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - initialNumToRender={8} - maxToRenderPerBatch={8} - windowSize={5} - getItemLayout={(_data, index) => ({ - length: EMOJI_ROW_HEIGHT, - offset: EMOJI_ROW_HEIGHT * index, - index, - })} - /> - - ); - }; - - // 渲染底部工具栏 - const renderToolbar = () => ( - - {/* 左侧功能按钮组 */} - - blockEditorRef.current?.insertImage() : handlePickImage} - disabled={posting} - > - - - - - - blockEditorRef.current?.insertCameraPhoto() : handleTakePhoto} - disabled={posting} - > - - - - - - - - - - - - {/* 投票按钮 */} - - - - - - - {/* 长文模式开关 */} - - - - - - - - ); - + // —— 顶栏 —— const renderTopActionBar = () => ( = (props) => { - {isEditMode ? '编辑帖子' : '发布帖子'} + {isEditMode ? '编辑帖子' : (isLong ? '发布长文' : '发布瞬间')} - {content.length}/{MAX_CONTENT_LENGTH} + {contentForCount.length}/{MAX_CONTENT_LENGTH} {posting ? '发布中' : '发布'} @@ -905,7 +342,83 @@ export const CreatePostScreen: React.FC = (props) => { ); - // 渲染主内容 + // —— 底部工具栏 —— + const renderToolbar = () => ( + + + composerRef.current?.insertImage()} + disabled={posting} + > + + + + + + composerRef.current?.insertCameraPhoto()} + disabled={posting} + > + + + + + + + + + + + + {/* 投票按钮 —— 仅长文可见 */} + {isLong && ( + setVoteToggleSignal(s => s + 1)} + disabled={posting} + > + + + + + )} + + + ); + + const renderComposer = () => { + if (isLong) { + return ( + } + isEditMode={isEditMode} + initialBlocks={initialBlocks} + onContentChange={setContentForCount} + voteToggleSignal={voteToggleSignal} + /> + ); + } + return ( + + ); + }; + const renderMainContent = () => ( <> {renderTopActionBar()} @@ -916,23 +429,42 @@ export const CreatePostScreen: React.FC = (props) => { keyboardShouldPersistTaps="handled" nestedScrollEnabled > - {/* 内容输入区 */} - {renderContentSection()} + + {/* 标题 */} + + {/* 正文(按模式渲染 Composer)——编辑态需等加载完成后再挂载以正确回填 */} + {editLoaded && renderComposer()} + - {renderChannelEntryRow()} + - {/* 频道选择面板 */} - {renderChannelPicker()} + - {/* 表情面板 - 位于底部工具栏上方 */} - {renderEmojiPanel()} - - {/* 底部工具栏 - 重新设计 */} {renderToolbar()} ); + const loadingView = ( + + + 加载帖子中... + + ); + return ( = (props) => { > {isWideScreen ? ( - {loadingPost ? ( - - - 加载帖子中... - - ) : ( - renderMainContent() - )} + {loadingPost ? loadingView : renderMainContent()} ) : ( - loadingPost ? ( - - - 加载帖子中... - - ) : ( - renderMainContent() - ) + loadingPost ? loadingView : renderMainContent() )} @@ -968,246 +486,102 @@ export const CreatePostScreen: React.FC = (props) => { function createCreatePostStyles(colors: AppColors) { return StyleSheet.create({ - flex: { - flex: 1, - }, - container: { - flex: 1, - backgroundColor: colors.background.paper, - }, - scrollContent: { - flexGrow: 1, - paddingBottom: spacing.xl, - }, - topActionBar: { - height: 52, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.divider, - backgroundColor: colors.background.paper, - }, - topActionButton: { - minWidth: 44, - height: 36, - justifyContent: 'center', - }, - topActionButtonRight: { - alignItems: 'flex-end', - }, - topActionButtonDisabled: { - opacity: 0.7, - }, - topActionTitle: { - fontSize: fontSizes.lg, - fontWeight: '600', - }, - topActionCenter: { - alignItems: 'center', - justifyContent: 'center', - }, - topActionCount: { - marginTop: 2, - fontSize: fontSizes.xs, - }, - topActionPublishText: { - fontWeight: '600', - }, - // 内容输入区 - contentSection: { - flexGrow: 1, - paddingHorizontal: spacing.lg, - paddingTop: spacing.md, - }, - titleInput: { - fontSize: fontSizes.lg, - fontWeight: '600', - color: colors.text.primary, - paddingVertical: spacing.sm, - marginBottom: spacing.xs, - }, - titleInputWide: { - fontSize: fontSizes.xl, - }, - contentInput: { - flexGrow: 1, - fontSize: fontSizes.md, - color: colors.text.primary, - lineHeight: 24, - paddingVertical: spacing.sm, - }, - contentInputWide: { - fontSize: fontSizes.lg, - lineHeight: 28, - }, - // 图片网格 - imageGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - paddingHorizontal: 0, - paddingTop: spacing.md, - gap: 4, - }, - imageGridItem: { - borderRadius: borderRadius.md, - overflow: 'hidden', - backgroundColor: colors.background.disabled, - }, - gridImage: { - width: '100%', - height: '100%', - }, - removeImageButton: { - position: 'absolute', - top: 4, - right: 4, - zIndex: 10, - }, - removeImageButtonInner: { - width: 20, - height: 20, - borderRadius: borderRadius.full, - backgroundColor: 'rgba(0, 0, 0, 0.6)', - justifyContent: 'center', - alignItems: 'center', - }, - addImageGridButton: { - borderRadius: borderRadius.md, - borderWidth: 1, - borderColor: colors.divider, - borderStyle: 'dashed', - justifyContent: 'center', - alignItems: 'center', - backgroundColor: colors.background.default, - }, - // 标签 - tagsSection: { - paddingHorizontal: spacing.lg, - paddingTop: spacing.md, - paddingBottom: spacing.sm, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.divider, - backgroundColor: colors.background.paper, - }, - tagsContainer: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: spacing.sm, - }, - tag: { - backgroundColor: colors.background.default, - borderRadius: borderRadius.full, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs, - borderWidth: 1, - borderColor: colors.divider, - }, - channelSelected: { - backgroundColor: colors.primary.main, - borderColor: colors.primary.main, - }, - tagText: { - fontWeight: '500', - }, - // 投票编辑器宽屏样式 - voteEditorWide: { - maxWidth: 600, - alignSelf: 'center', - width: '100%', - }, - voteEditorHeaderRow: { - marginHorizontal: spacing.lg, - marginTop: spacing.md, - marginBottom: -spacing.sm, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - // 底部工具栏 - channelEntryRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.divider, - backgroundColor: colors.background.paper, - }, - channelEntryLabel: { - fontSize: fontSizes.md, - }, - channelEntryValueWrap: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - }, - channelEntryValue: { - fontSize: fontSizes.md, - }, - toolbar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: spacing.lg, - paddingVertical: spacing.sm, - backgroundColor: colors.background.paper, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.divider, - }, - toolbarLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - }, - toolbarButton: { - position: 'relative', - }, - toolbarButtonInner: { - width: 40, - height: 40, - borderRadius: borderRadius.full, - justifyContent: 'center', - alignItems: 'center', - }, - // 表情面板 - emojiPanel: { - height: EMOJI_PANEL_HEIGHT, - backgroundColor: colors.background.paper, - borderTopWidth: StyleSheet.hairlineWidth, - borderTopColor: colors.divider, - }, - emojiPanelWide: { - height: 320, - }, - emojiRow: { - flexDirection: 'row', - justifyContent: 'space-around', - alignItems: 'center', - height: EMOJI_ROW_HEIGHT, - paddingHorizontal: spacing.md, - }, - emojiItem: { - width: 44, - height: 44, - justifyContent: 'center', - alignItems: 'center', - }, - emojiText: { - fontSize: 26, - lineHeight: 32, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - gap: spacing.md, - }, - loadingText: { - fontSize: fontSizes.md, - }, + flex: { + flex: 1, + }, + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + scrollContent: { + flexGrow: 1, + paddingBottom: spacing.xl, + }, + topActionBar: { + height: 52, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + backgroundColor: colors.background.paper, + }, + topActionButton: { + minWidth: 44, + height: 36, + justifyContent: 'center', + }, + topActionButtonRight: { + alignItems: 'flex-end', + }, + topActionButtonDisabled: { + opacity: 0.7, + }, + topActionTitle: { + fontSize: fontSizes.lg, + fontWeight: '600', + }, + topActionCenter: { + alignItems: 'center', + justifyContent: 'center', + }, + topActionCount: { + marginTop: 2, + fontSize: fontSizes.xs, + }, + topActionPublishText: { + fontWeight: '600', + }, + contentSection: { + flexGrow: 1, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + }, + titleInput: { + fontSize: fontSizes.lg, + fontWeight: '600', + color: colors.text.primary, + paddingVertical: spacing.sm, + marginBottom: spacing.xs, + }, + titleInputWide: { + fontSize: fontSizes.xl, + }, + toolbar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.background.paper, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + }, + toolbarLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + toolbarButton: { + position: 'relative', + }, + toolbarButtonInner: { + width: 40, + height: 40, + borderRadius: 999, + justifyContent: 'center', + alignItems: 'center', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + }, + loadingText: { + fontSize: fontSizes.md, + }, }); } diff --git a/src/screens/create/composers/ChannelPicker.tsx b/src/screens/create/composers/ChannelPicker.tsx new file mode 100644 index 0000000..e4d88ef --- /dev/null +++ b/src/screens/create/composers/ChannelPicker.tsx @@ -0,0 +1,140 @@ +/** + * 频道选择(瞬间 / 长文共用) + * 包含「发表至」入口行 + 展开后的频道单选面板。 + */ + +import React from 'react'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { Text } from '../../../components/common'; +import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../../theme'; + +export type ChannelOption = { id: string; name: string }; + +interface ChannelPickerProps { + channels: ChannelOption[]; + selectedId: string | null; + onSelect: (channelId: string) => void; + expanded: boolean; + onToggle: () => void; +} + +export function ChannelPicker({ + channels, + selectedId, + onSelect, + expanded, + onToggle, +}: ChannelPickerProps) { + const colors = useAppColors(); + const styles = React.useMemo(() => createChannelPickerStyles(colors), [colors]); + + const selectedName = React.useMemo(() => { + if (!selectedId) return ''; + return channels.find(c => c.id === selectedId)?.name || ''; + }, [selectedId, channels]); + + return ( + <> + {/* 入口行 */} + + + 发表至: + + + + {selectedName || '选择频道'} + + + + + + {/* 展开面板 */} + {expanded && ( + + + {channels.map((channel) => { + const isSelected = selectedId === channel.id; + return ( + onSelect(channel.id)} + > + + {channel.name} + + + ); + })} + + {channels.length === 0 && ( + 暂无可用频道 + )} + + )} + + ); +} + +function createChannelPickerStyles(colors: AppColors) { + return StyleSheet.create({ + channelEntryRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + backgroundColor: colors.background.paper, + }, + channelEntryLabel: { + fontSize: fontSizes.md, + }, + channelEntryValueWrap: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, + channelEntryValue: { + fontSize: fontSizes.md, + }, + tagsSection: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + backgroundColor: colors.background.paper, + }, + tagsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + tag: { + backgroundColor: colors.background.default, + borderRadius: borderRadius.full, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderWidth: 1, + borderColor: colors.divider, + }, + channelSelected: { + backgroundColor: colors.primary.main, + borderColor: colors.primary.main, + }, + tagText: { + fontWeight: '500', + }, + }); +} \ No newline at end of file diff --git a/src/screens/create/composers/EmojiPanel.tsx b/src/screens/create/composers/EmojiPanel.tsx new file mode 100644 index 0000000..eadd529 --- /dev/null +++ b/src/screens/create/composers/EmojiPanel.tsx @@ -0,0 +1,121 @@ +/** + * 表情面板(瞬间 / 长文共用) + * 从 CreatePostScreen 抽出,数据 + FlatList 渲染整体迁入。 + */ + +import React from 'react'; +import { View, FlatList, TouchableOpacity, StyleSheet } from 'react-native'; +import { Text } from '../../../components/common'; +import { spacing, useAppColors, type AppColors } from '../../../theme'; + +// 常用表情列表 +const EMOJIS = [ + '😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', + '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', + '😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜', + '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', + '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', + '😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷', + '🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴', + '😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓', + '🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲', + '😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢', + '😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫', + '🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀', + '👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', + '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', + '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', + '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵', + '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', + '🤎', '💔', '🩹', '💕', '💞', '💓', '💗', '💖', + '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨', '🔥', + '💯', '💢', '💥', '💫', '💦', '💨', '🕳️', +]; + +const EMOJIS_PER_ROW = 8; +const EMOJI_ROW_HEIGHT = 52; + +const EMOJI_ROWS = (() => { + const rows: { id: string; emojis: string[] }[] = []; + for (let i = 0; i < EMOJIS.length; i += EMOJIS_PER_ROW) { + rows.push({ id: `emoji-row-${i}`, emojis: EMOJIS.slice(i, i + EMOJIS_PER_ROW) }); + } + return rows; +})(); + +interface EmojiPanelProps { + visible: boolean; + onPick: (emoji: string) => void; + isWideScreen?: boolean; +} + +export function EmojiPanel({ visible, onPick, isWideScreen }: EmojiPanelProps) { + const colors = useAppColors(); + const styles = React.useMemo(() => createEmojiPanelStyles(colors), [colors]); + + if (!visible) return null; + + return ( + + item.id} + renderItem={({ item }) => ( + + {item.emojis.map((emoji, index) => ( + onPick(emoji)} + activeOpacity={0.7} + > + {emoji} + + ))} + + )} + showsVerticalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={5} + getItemLayout={(_data, index) => ({ + length: EMOJI_ROW_HEIGHT, + offset: EMOJI_ROW_HEIGHT * index, + index, + })} + /> + + ); +} + +function createEmojiPanelStyles(colors: AppColors) { + return StyleSheet.create({ + emojiPanel: { + height: 280, + backgroundColor: colors.background.paper, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + }, + emojiPanelWide: { + height: 320, + }, + emojiRow: { + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + height: EMOJI_ROW_HEIGHT, + paddingHorizontal: spacing.md, + }, + emojiItem: { + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + }, + emojiText: { + fontSize: 26, + lineHeight: 32, + }, + }); +} \ No newline at end of file diff --git a/src/screens/create/composers/LongPostComposer.tsx b/src/screens/create/composers/LongPostComposer.tsx new file mode 100644 index 0000000..96f20fe --- /dev/null +++ b/src/screens/create/composers/LongPostComposer.tsx @@ -0,0 +1,135 @@ +/** + * 短链 Composer(原长文模式,含投票) + * + * 富文本块编辑器 + 条件投票编辑器。 + * 通过 forwardRef 暴露 LongPostComposerHandle(含 getVote),供外壳发布逻辑分流。 + */ + +import React from 'react'; +import { View, StyleSheet, TouchableOpacity } from 'react-native'; +import BlockEditor, { BlockEditorHandle } from '../../../components/business/BlockEditor'; +import VoteEditor from '../../../components/business/VoteEditor'; +import { Text } from '../../../components/common'; +import { spacing, useAppColors, type AppColors } from '../../../theme'; +import { useResponsive } from '../../../hooks'; +import { MAX_CONTENT_LENGTH, type LongPostComposerHandle, type LongPostComposerProps } from './types'; + +export const LongPostComposer = React.forwardRef(function LongPostComposer( + props, + ref, +) { + const colors = useAppColors(); + const styles = React.useMemo(() => createLongStyles(colors), [colors]); + const { isWideScreen } = useResponsive(); + const { onContentChange, voteToggleSignal } = props; + + const blockEditorRef = React.useRef(null); + const [isVotePost, setIsVotePost] = React.useState(false); + const [voteOptions, setVoteOptions] = React.useState(['', '']); + + // 投票选项增删改 + const handleAddVoteOption = () => { + if (voteOptions.length < 10) setVoteOptions([...voteOptions, '']); + }; + const handleRemoveVoteOption = (index: number) => { + if (voteOptions.length > 2) setVoteOptions(voteOptions.filter((_, i) => i !== index)); + }; + const handleUpdateVoteOption = (index: number, value: string) => { + setVoteOptions(prev => { + const next = [...prev]; + next[index] = value; + return next; + }); + }; + + // 工具栏投票按钮点击信号:每次自增切换投票开关(跳过初始挂载) + const firstSignal = React.useRef(true); + React.useEffect(() => { + if (firstSignal.current) { + firstSignal.current = false; + return; + } + setIsVotePost(prev => !prev); + }, [voteToggleSignal]); + + // 暴露给外壳的统一能力 + 额外的投票数据桥 + React.useImperativeHandle(ref, () => ({ + getContent: () => blockEditorRef.current?.getContent() || '', + getSegments: () => blockEditorRef.current?.getSegments() || [], + getUploadedImages: () => [], // 长文图片以 segment 形式上传后回填到块内,不单独返回 + uploadImages: async () => { + const ok = await blockEditorRef.current?.uploadPendingImages(); + return ok !== false; + }, + insertEmoji: (text: string) => blockEditorRef.current?.insertTextAtCursor(text), + insertImage: async () => { await blockEditorRef.current?.insertImage(); }, + insertCameraPhoto: async () => { await blockEditorRef.current?.insertCameraPhoto(); }, + getVote: () => ({ isVotePost, voteOptions }), + }), [isVotePost, voteOptions]); + + return ( + + + + {/* 投票编辑器 */} + {isVotePost && ( + + + 已插入投票 + setIsVotePost(false)} activeOpacity={0.7}> + 移除投票 + + + + + )} + + ); +}); + +function createLongStyles(colors: AppColors) { + return StyleSheet.create({ + section: { + flexGrow: 1, + }, + contentInput: { + flexGrow: 1, + fontSize: 16, + color: colors.text.primary, + lineHeight: 24, + paddingVertical: spacing.sm, + }, + contentInputWide: { + fontSize: 18, + lineHeight: 28, + }, + voteEditorWide: { + maxWidth: 600, + alignSelf: 'center', + width: '100%', + }, + voteEditorHeaderRow: { + marginHorizontal: spacing.lg, + marginTop: spacing.md, + marginBottom: -spacing.sm, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + }); +} \ No newline at end of file diff --git a/src/screens/create/composers/MomentComposer.tsx b/src/screens/create/composers/MomentComposer.tsx new file mode 100644 index 0000000..ba51b79 --- /dev/null +++ b/src/screens/create/composers/MomentComposer.tsx @@ -0,0 +1,223 @@ +/** + * 瞬间 Composer(原普通模式) + * + * 单输入框(支持 @提及)+ 独立图片网格。 + * 通过 forwardRef 暴露 ComposerHandle,供外壳无差别驱动。 + */ + +import React from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + Image, + Alert, +} from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import * as ImagePicker from 'expo-image-picker'; +import PostMentionInput from '../../../components/business/PostMentionInput'; +import { + spacing, + borderRadius, + useAppColors, + type AppColors, +} from '../../../theme'; +import { useResponsive, useResponsiveValue } from '../../../hooks'; +import { MessageSegment } from '../../../types'; +import { + type PendingOrRemoteImage, + makePendingImageFromAsset, + getImageDisplayUri, + uploadAllPendingImages, +} from '../../../utils/pendingImages'; +import { MAX_CONTENT_LENGTH, type ComposerHandle, type ComposerProps } from './types'; + +export const MomentComposer = React.forwardRef(function MomentComposer( + props, + ref, +) { + const colors = useAppColors(); + const styles = React.useMemo(() => createMomentStyles(colors), [colors]); + const { isWideScreen, width } = useResponsive(); + const { onContentChange } = props; + + const [content, setContent] = React.useState(props.initialContent ?? ''); + const [segments, setSegments] = React.useState(props.initialSegments ?? []); + const [images, setImages] = React.useState(props.initialImages ?? []); + // 表情插入位置(与原实现一致:默认末尾追加,插入表情后更新) + const [selection, setSelection] = React.useState({ start: content.length, end: content.length }); + const [uploadedUrls, setUploadedUrls] = React.useState([]); + + // 响应式图片网格 + const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 }); + const imageGap = 4; + const availableWidth = isWideScreen ? Math.min(width, 800) - spacing.lg * 2 : width - spacing.lg * 2; + const imageSize = Math.floor((availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow); + + const emitContent = React.useCallback((text: string) => { + setContent(text); + onContentChange?.(text); + }, [onContentChange]); + + // —— 选图 / 拍照(工具栏按钮与网格「+」按钮共用)—— + const pickImage = React.useCallback(async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + Alert.alert('权限不足', '需要访问相册权限来选择图片'); + return; + } + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: 'images', + allowsMultipleSelection: true, + selectionLimit: 0, + quality: 1, + }); +if (!result.canceled && result.assets) { + setImages(prev => [...prev, ...result.assets.map(a => makePendingImageFromAsset(a))]); + } + }, []); + + const takePhoto = React.useCallback(async () => { + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + Alert.alert('权限不足', '需要访问相机权限来拍照'); + return; + } + const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, quality: 0.8 }); + if (!result.canceled && result.assets[0]) { + setImages(prev => [...prev, makePendingImageFromAsset(result.assets[0])]); + } + }, []); + + // 暴露给外壳的统一能力 + React.useImperativeHandle(ref, () => ({ + getContent: () => content, + getSegments: () => segments, + getUploadedImages: () => uploadedUrls, + uploadImages: async () => { + if (images.length === 0) { + setUploadedUrls([]); + return true; + } + const result = await uploadAllPendingImages(images, (id, url) => { + setImages(prev => prev.map(i => i.id === id ? { id, kind: 'remote' as const, url } : i)); + }); + if (!result.success) { + return false; + } + setUploadedUrls(result.urls); + return true; + }, + insertEmoji: (text: string) => { + const newContent = content.slice(0, selection.start) + text + content.slice(selection.end); + emitContent(newContent); + const newPosition = selection.start + text.length; + setSelection({ start: newPosition, end: newPosition }); + }, + insertImage: pickImage, + insertCameraPhoto: takePhoto, + }), [content, segments, images, selection, uploadedUrls, emitContent, pickImage, takePhoto]); + + const handleRemoveImage = (index: number) => { + setImages(prev => prev.filter((_, i) => i !== index)); + }; + + return ( + + + + {/* 图片网格 */} + {images.length > 0 && ( + + {images.map((img, index) => ( + + + handleRemoveImage(index)} + hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }} + > + + + + + + ))} + {/* 添加图片按钮 */} + + + + + )} + + ); +}); + +function createMomentStyles(colors: AppColors) { + return StyleSheet.create({ + section: { + flexGrow: 1, + }, + contentInput: { + flexGrow: 1, + fontSize: 16, + color: colors.text.primary, + lineHeight: 24, + paddingVertical: spacing.sm, + }, + imageGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + paddingTop: spacing.md, + gap: 4, + }, + imageGridItem: { + borderRadius: borderRadius.md, + overflow: 'hidden', + backgroundColor: colors.background.disabled, + }, + gridImage: { + width: '100%', + height: '100%', + }, + removeImageButton: { + position: 'absolute', + top: 4, + right: 4, + zIndex: 10, + }, + removeImageButtonInner: { + width: 20, + height: 20, + borderRadius: borderRadius.full, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + justifyContent: 'center', + alignItems: 'center', + }, + addImageGridButton: { + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.divider, + borderStyle: 'dashed', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.background.default, + }, + }); +} diff --git a/src/screens/create/composers/types.ts b/src/screens/create/composers/types.ts new file mode 100644 index 0000000..f7202f1 --- /dev/null +++ b/src/screens/create/composers/types.ts @@ -0,0 +1,57 @@ +import type { MessageSegment } from '../../../types'; +import type { EditorBlock } from '../../../components/business/BlockEditor/blockEditorTypes'; +import type { RemoteImage } from '../../../utils/pendingImages'; + +/** + * 两种 Composer(瞬间 / 长文)共同实现的对外能力。 + * 发帖外壳 CreatePostScreen 通过 ref 无差别地驱动两种模式, + * 从而消除主文件里的 isLongPostMode 分支。 + */ +export interface ComposerHandle { + /** 纯文本内容(标题/正文校验、字数统计用) */ + getContent(): string; + /** 提交正文段(长文含 image/at 段;瞬间可能含 at 段) */ + getSegments(): MessageSegment[]; + /** 发布前上传所有 pending 图片,成功返回 true */ + uploadImages(): Promise; + /** 上传后得到的图片 URL(普通模式独立 images 字段;长文返回空数组) */ + getUploadedImages(): string[]; + /** 表情面板点击插入 */ + insertEmoji(text: string): void; + /** 工具栏「相册」 */ + insertImage(): Promise; + /** 工具栏「相机」 */ + insertCameraPhoto(): Promise; +} + +/** + * 长文 Composer 额外暴露的投票数据,供外壳发布逻辑决定走 voteService。 + */ +export interface LongPostComposerHandle extends ComposerHandle { + /** 当前投票态与选项 */ + getVote(): { isVotePost: boolean; voteOptions: string[] }; +} + +export interface ComposerProps { + isEditMode: boolean; + /** 编辑态回填:纯文本 */ + initialContent?: string; + /** 编辑态回填:普通模式已有图片 */ + initialImages?: RemoteImage[]; + /** 编辑态回填:长文含 image 段的 segments */ + initialSegments?: MessageSegment[]; + /** 编辑态回填:长文块(由 segments 转换) */ + initialBlocks?: EditorBlock[]; + /** 正文变化回调——同步外壳顶栏字数统计 */ + onContentChange?: (text: string) => void; +} + +/** + * 长文 Composer 额外接受的 props。 + * voteToggleSignal:外壳工具栏投票按钮每次点击自增;长文 Composer 监听其变化以切换投票开关。 + */ +export interface LongPostComposerProps extends ComposerProps { + voteToggleSignal?: number; +} + +export const MAX_CONTENT_LENGTH = 2000; diff --git a/src/screens/home/HomeScreen.tsx b/src/screens/home/HomeScreen.tsx index 8aaf38c..747d930 100644 --- a/src/screens/home/HomeScreen.tsx +++ b/src/screens/home/HomeScreen.tsx @@ -13,6 +13,7 @@ import { RefreshControl, StatusBar, TouchableOpacity, + TouchableWithoutFeedback, NativeSyntheticEvent, NativeScrollEvent, Alert, @@ -24,7 +25,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter, useFocusEffect } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; -import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; +import { useAppColors, useResolvedColorScheme, spacing, borderRadius, type AppColors } from '../../theme'; import { Post } from '../../types'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores'; import { useCurrentUser, useIsAuthenticated, useIsVerified, useVerificationStore } from '../../stores/auth'; @@ -186,10 +187,11 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) { width: 56, height: 56, borderRadius: 28, - backgroundColor: colors.primary.main, + backgroundColor: `${colors.primary.main}10`, + borderWidth: 1, + borderColor: `${colors.primary.main}35`, alignItems: 'center', justifyContent: 'center', - ...shadows.lg, }, floatingButtonDesktop: { right: 40, @@ -205,6 +207,54 @@ function createHomeStyles(colors: AppColors, responsivePadding: number) { height: 72, borderRadius: 36, }, + // 展开后两个小按钮所在的列容器,底部对齐主 FAB 顶部上方 + fabSubGroup: { + position: 'absolute', + right: 20, + flexDirection: 'column', + alignItems: 'flex-end', + gap: 10, + }, + fabSubGroupDesktop: { + right: 40, + }, + fabSubGroupWide: { + right: 60, + }, + // 两个小按钮:胶囊按钮 + 图标 + 文字 + fabSubButton: { + minWidth: 92, + height: 44, + borderRadius: 999, + paddingHorizontal: spacing.md, + backgroundColor: colors.background.paper, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + borderWidth: StyleSheet.hairlineWidth, + borderColor: `${colors.primary.main}24`, + }, + fabSubButtonWide: { + minWidth: 104, + height: 48, + paddingHorizontal: spacing.lg, + }, + fabSubLabel: { + fontSize: 13, + lineHeight: 16, + fontWeight: '700', + color: colors.primary.main, + }, + // 全屏透明遮罩:用于点击空白收起 + fabOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'transparent', + }, loadingMoreFooter: { paddingVertical: 20, alignItems: 'center', @@ -266,6 +316,10 @@ export const HomeScreen: React.FC = () => { // 发帖弹窗状态 const [showCreatePost, setShowCreatePost] = useState(false); + // 发帖模式:moment=瞬间(普通),long=长文(含投票)。由 FAB 展开选择后决定。 + const [postMode, setPostMode] = useState<'moment' | 'long'>('moment'); + // 加号是否展开为「瞬间/长文」两个小按钮 + const [fabExpanded, setFabExpanded] = useState(false); // 广场/市集 Tab const [homeTab, setHomeTab] = useState<'square' | 'market'>('square'); @@ -713,7 +767,7 @@ export const HomeScreen: React.FC = () => { } }, []); - // 跳转到发帖页面(使用 Modal 方式) + // 点击加号:校验登录/实名后展开「瞬间/长文」两个小按钮 const handleCreatePost = () => { if (!isAuthenticated) { router.push(hrefs.hrefAuthLogin()); @@ -723,6 +777,13 @@ export const HomeScreen: React.FC = () => { router.push(hrefs.hrefVerificationGuide()); return; } + setFabExpanded(prev => !prev); + }; + + // 选择发帖模式并打开发帖弹窗 + const openCreatePostWithMode = (mode: 'moment' | 'long') => { + setPostMode(mode); + setFabExpanded(false); setShowCreatePost(true); }; @@ -1043,6 +1104,51 @@ export const HomeScreen: React.FC = () => { /> )} + {/* 惰性全屏遮罩:点击空白处收起 FAB */} + {fabExpanded && ( + setFabExpanded(false)}> + + + )} + + {/* 发帖入口:加号 / 展开后的「瞬间/长文」两个小按钮 */} + {fabExpanded && homeTab !== 'market' && (() => { + // 子按钮组整体置于主 FAB 上方:bottom = 主FAB底部 + 主FAB高度 + 间距 + const fabBottom = floatingButtonBottom !== undefined + ? floatingButtonBottom + : (isWideScreen ? 60 : isDesktop ? 40 : 20); + const fabHeight = isWideScreen ? 72 : isDesktop ? 64 : 56; + const subGroupBottom = fabBottom + fabHeight + 14; + return ( + + openCreatePostWithMode('long')} + activeOpacity={0.8} + > + + 长文 + + openCreatePostWithMode('moment')} + activeOpacity={0.8} + > + + 瞬间 + + + ); + })()} + {/* 漂浮发帖/发布按钮 */} { } : handleCreatePost} activeOpacity={0.8} > - + {/* 图片查看器 */} @@ -1078,6 +1188,8 @@ export const HomeScreen: React.FC = () => { onRequestClose={() => setShowCreatePost(false)} > setShowCreatePost(false)} /> diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index a4731d1..eb0b1da 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -37,6 +37,7 @@ import { } from '../../theme'; import { Post, Comment, VoteResultDTO, VoteOptionDTO, MessageSegment } from '../../types'; import { useUserStore } from '../../stores'; +import { userManager } from '../../stores/user'; import { useCurrentUser } from '../../stores/auth'; import { postService, commentService, authService, showPrompt, voteService } from '../../services'; import { postSyncService } from '@/services/post'; @@ -262,6 +263,7 @@ export const PostDetailScreen: React.FC = () => { const [showEmojiPanel, setShowEmojiPanel] = useState(false); const [isFollowing, setIsFollowing] = useState(false); const [isFollowingMe, setIsFollowingMe] = useState(false); + const [authorFollowersCount, setAuthorFollowersCount] = useState(null); const [isFollowLoading, setIsFollowLoading] = useState(false); const flatListRef = useRef | null>(null); const hasRecordedView = useRef(false); // 是否已记录浏览量 @@ -277,6 +279,35 @@ export const PostDetailScreen: React.FC = () => { const [voteResult, setVoteResult] = useState(null); const [isVoteLoading, setIsVoteLoading] = useState(false); + // 同步作者主页资料,帖子详情接口可能只返回作者基础信息,粉丝数需要以用户主页数据为准。 + useEffect(() => { + const author = post?.author; + if (!author?.id) { + setAuthorFollowersCount(null); + return; + } + + const authorId = author.id; + let cancelled = false; + setAuthorFollowersCount(author.followers_count ?? null); + + userManager.getUserById(authorId, true) + .then((user) => { + if (!cancelled && user) { + setAuthorFollowersCount(user.followers_count ?? 0); + setIsFollowing(user.is_following || false); + setIsFollowingMe(user.is_following_me || false); + } + }) + .catch((error) => { + console.warn('加载作者主页资料失败:', error); + }); + + return () => { + cancelled = true; + }; + }, [post?.author?.id]); + // 桌面端侧边栏宽度 const sidebarWidth = useResponsiveValue({ xs: 0, @@ -385,35 +416,45 @@ export const PostDetailScreen: React.FC = () => { router.replace(hrefs.hrefHome()); }, [router]); - const renderCustomHeader = () => ( - - - {post?.author ? ( - - handleUserPress(post.author!.id)} - style={styles.headerAvatarWrapper} - > - - - - handleUserPress(post.author!.id)}> - - {post.author.nickname && post.author.nickname.length > 10 - ? `${post.author.nickname.slice(0, 10)}...` - : post.author.nickname} - + const renderCustomHeader = () => { + const followersCount = authorFollowersCount ?? post?.author?.followers_count ?? 0; + + return ( + + + {post?.author ? ( + + handleUserPress(post.author!.id)} + style={styles.headerAvatarWrapper} + activeOpacity={0.75} + > + + + handleUserPress(post.author!.id)} activeOpacity={0.75}> + + + {post.author.nickname && post.author.nickname.length > 10 + ? `${post.author.nickname.slice(0, 10)}...` + : post.author.nickname} + + + + + {formatNumber(followersCount)} 粉丝 + + + ) : ( + + )} + + {renderFollowButton()} - ) : ( - - )} - - {renderFollowButton()} - - ); + ); + }; // 监听键盘事件 useEffect(() => { @@ -1008,12 +1049,14 @@ export const PostDetailScreen: React.FC = () => { const success = await authService.unfollowUser(post.author.id); if (success) { setIsFollowing(false); + setAuthorFollowersCount(prev => prev == null ? prev : Math.max(0, prev - 1)); } } else { // 关注 const success = await authService.followUser(post.author.id); if (success) { setIsFollowing(true); + setAuthorFollowersCount(prev => prev == null ? prev : prev + 1); } } } catch (error) { @@ -1358,27 +1401,42 @@ export const PostDetailScreen: React.FC = () => { ); }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]); - // 渲染空评论 - 使用 Forum 图标 - const renderEmptyComments = useCallback(() => ( - - - - 评论区空空如也,来发表第一条评论吧~ - - - ), [colors]); - - const openComposer = () => { + const openComposer = useCallback(() => { setIsComposerVisible(true); focusCommentInput(); - }; + }, [focusCommentInput]); - const closeComposer = () => { + const closeComposer = useCallback(() => { setIsComposerVisible(false); setShowEmojiPanel(false); setReplyingTo(null); Keyboard.dismiss(); - }; + }, []); + + // 渲染空评论 - 简洁留白状态 + const renderEmptyComments = useCallback(() => ( + + + + + + + + + 还没有评论 + + 评论区空空如也,来发表第一条评论吧~ + + + 抢沙发 + + + + ), [colors, openComposer, styles]); const handleAtMention = () => { setCommentText(prev => prev + '@'); @@ -1887,9 +1945,17 @@ function createPostDetailStyles(colors: AppColors) { customHeader: { flexDirection: 'row', alignItems: 'center', - backgroundColor: colors.background.default, - height: 52, + backgroundColor: colors.background.paper, + minHeight: 66, paddingRight: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + shadowColor: colors.chat.shadow, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, + zIndex: 10, }, headerPlaceholder: { flex: 1, @@ -1899,24 +1965,55 @@ function createPostDetailStyles(colors: AppColors) { flexDirection: 'row', alignItems: 'center', flex: 1, - marginLeft: spacing.sm, + marginLeft: spacing.xs, + minWidth: 0, }, headerAvatarWrapper: { marginRight: spacing.sm, + padding: 2, + borderRadius: borderRadius.full, + backgroundColor: `${colors.primary.main}10`, }, headerUserInfo: { flex: 1, justifyContent: 'center', + minWidth: 0, + }, + headerTitleRow: { + flexDirection: 'row', + alignItems: 'center', + minWidth: 0, }, headerNickname: { fontSize: fontSizes.md, - fontWeight: '600', + fontWeight: '700', color: colors.text.primary, + maxWidth: 150, + }, + headerAuthorPill: { + marginLeft: spacing.xs, + paddingHorizontal: 5, + paddingVertical: 1, + borderRadius: borderRadius.sm, + backgroundColor: `${colors.primary.main}18`, + }, + headerAuthorPillText: { + fontSize: 9, + lineHeight: 12, + color: colors.primary.main, + fontWeight: '700', + }, + headerMetaText: { + marginTop: 3, + fontSize: fontSizes.sm, + lineHeight: fontSizes.sm + 4, + color: colors.text.secondary, }, headerRightContainer: { flexDirection: 'row', alignItems: 'center', - marginRight: spacing.sm, + marginRight: spacing.xs, + marginLeft: spacing.xs, backgroundColor: 'transparent', }, headerBackButton: { @@ -1927,38 +2024,42 @@ function createPostDetailStyles(colors: AppColors) { // 帖子容器 postContainer: { backgroundColor: colors.background.paper, - paddingBottom: spacing.md, + paddingBottom: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, }, // 关注按钮样式 followButton: { paddingHorizontal: spacing.md, - paddingVertical: 4, - borderRadius: borderRadius.lg, - minWidth: 64, + paddingVertical: 6, + borderRadius: borderRadius.full, + minWidth: 72, alignItems: 'center', justifyContent: 'center', backgroundColor: 'transparent', }, followButtonPrimary: { - backgroundColor: colors.primary.main, + backgroundColor: `${colors.primary.main}10`, + borderWidth: 1, + borderColor: `${colors.primary.main}35`, }, followButtonOutline: { - backgroundColor: 'transparent', + backgroundColor: `${colors.text.primary}04`, borderWidth: 1, borderColor: colors.divider, }, followButtonMutual: { - backgroundColor: 'transparent', + backgroundColor: `${colors.primary.main}08`, borderWidth: 1, - borderColor: colors.divider, + borderColor: `${colors.primary.main}24`, }, followButtonLoading: { opacity: 0.7, }, followButtonPlaceholder: { - minWidth: 64, + minWidth: 72, paddingHorizontal: spacing.md, - paddingVertical: 4, + paddingVertical: 6, backgroundColor: 'transparent', }, followButtonText: { @@ -1966,7 +2067,7 @@ function createPostDetailStyles(colors: AppColors) { fontWeight: '600', }, followButtonTextPrimary: { - color: colors.text.inverse, + color: colors.primary.main, }, followButtonTextOutline: { color: colors.text.secondary, @@ -2303,13 +2404,69 @@ function createPostDetailStyles(colors: AppColors) { emptyCommentsContainer: { alignItems: 'center', justifyContent: 'center', - paddingVertical: spacing.xl * 2.5, + paddingHorizontal: spacing.xl, + paddingTop: spacing['3xl'], + paddingBottom: spacing['4xl'], + backgroundColor: colors.background.paper, + }, + emptyCommentsIllustration: { + position: 'relative', + width: 112, + height: 96, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.sm, + }, + emptyCommentsIconCircle: { + width: 72, + height: 72, + borderRadius: 36, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: `${colors.primary.main}10`, + }, + emptyCommentsBubbleSmall: { + position: 'absolute', + right: 14, + top: 10, + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: `${colors.primary.light}28`, + }, + emptyCommentsBubbleTiny: { + position: 'absolute', + left: 18, + bottom: 18, + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: `${colors.primary.main}18`, + }, + emptyCommentsTitle: { + fontSize: fontSizes.lg, + fontWeight: '800', + color: colors.text.primary, + textAlign: 'center', }, emptyCommentsSubtitle: { - marginTop: spacing.md, + marginTop: spacing.sm, fontSize: fontSizes.sm, + lineHeight: 20, textAlign: 'center', - color: colors.text.hint, + color: colors.text.secondary, + }, + emptyCommentsAction: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginTop: spacing.md, + paddingVertical: spacing.xs, + }, + emptyCommentsActionText: { + fontSize: fontSizes.sm, + fontWeight: '700', + color: colors.primary.main, }, // 侧边栏样式 sidebar: { diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 21287b1..fca2207 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -28,6 +28,7 @@ import { TouchableOpacity, BackHandler, StatusBar, + Alert, } from 'react-native'; import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { MaterialCommunityIcons } from '@expo/vector-icons'; @@ -173,6 +174,10 @@ export const ChatScreen: React.FC = (props) => { effectiveGroupName, otherUserLastReadSeq, messageMap, + // 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端) + getCachedReply, + ensureReplyMessage, + jumpToMessageSeq, loadingMore, hasMoreHistory, conversationId, @@ -281,20 +286,43 @@ export const ChatScreen: React.FC = (props) => { }; }, []); - const handleReplyPreviewPress = useCallback((messageId: string) => { + const handleReplyPreviewPress = useCallback(async (messageId: string) => { const targetId = String(messageId); + + // 1. 内存命中:直接滚动定位(与原逻辑一致,最快路径) const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); - if (targetIndex < 0) return; + if (targetIndex >= 0) { + replyTargetMessageIdRef.current = targetId; + setBrowsingHistory(true); + try { + flatListRef.current?.scrollToIndex({ + index: targetIndex, + animated: true, + viewPosition: 0.5, + }); + } catch { + // FlashList 远距离跳转可能失败,降级到 offset 滚动 + flatListRef.current?.scrollToOffset?.({ offset: 0, animated: true }); + } + return; + } + // 2. 内存未命中:回填被引用消息(内存缓存 → 本地 SQLite → 服务端),拿到 seq + if (!ensureReplyMessage) return; + const filled = await ensureReplyMessage(targetId); + if (!filled || !filled.seq || filled.seq <= 0) { + // 已被删除/不可见,或无有效 seq + Alert.alert('该引用消息已被删除或不可见'); + return; + } + + // 3. 拿到 seq 后复用 scrollToSeq 范式:内存命中定位,未命中循环 loadMoreHistory replyTargetMessageIdRef.current = targetId; - setBrowsingHistory(true); - - flatListRef.current?.scrollToIndex({ - index: targetIndex, - animated: true, - viewPosition: 0.5, - }); - }, [displayMessages, flatListRef, setBrowsingHistory]); + const accepted = jumpToMessageSeq ? jumpToMessageSeq(filled.seq) : false; + if (!accepted) { + Alert.alert('无法定位该引用消息'); + } + }, [displayMessages, flatListRef, setBrowsingHistory, ensureReplyMessage, jumpToMessageSeq]); // 监听返回事件:面板/键盘打开时先关闭它们,否则刷新会话列表后返回 useEffect(() => { @@ -359,6 +387,8 @@ export const ChatScreen: React.FC = (props) => { otherUserLastReadSeq={otherUserLastReadSeq} selectedMessageId={selectedMessageId} messageMap={messageMap} + getCachedReply={getCachedReply} + ensureReplyMessage={ensureReplyMessage} onLongPress={handleLongPressMessage} onAvatarPress={handleAvatarPress} onAvatarLongPress={handleAvatarLongPress} @@ -380,6 +410,8 @@ export const ChatScreen: React.FC = (props) => { otherUserLastReadSeq, selectedMessageId, messageMap, + getCachedReply, + ensureReplyMessage, handleLongPressMessage, handleAvatarPress, handleAvatarLongPress, diff --git a/src/screens/message/components/ChatScreen/MessageBubble.tsx b/src/screens/message/components/ChatScreen/MessageBubble.tsx index 0e2d854..2a0a99b 100644 --- a/src/screens/message/components/ChatScreen/MessageBubble.tsx +++ b/src/screens/message/components/ChatScreen/MessageBubble.tsx @@ -4,7 +4,7 @@ * 支持响应式布局(宽屏下优化显示) */ -import React, { useRef, useMemo, useCallback } from 'react'; +import React, { useRef, useMemo, useCallback, useEffect } from 'react'; import { View, TouchableOpacity, @@ -17,9 +17,10 @@ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Avatar, Text, ImageGridItem } from '../../../../components/common'; import { useChatScreenStyles } from './styles'; import { useResponsive, useBreakpointGTE } from '../../../../hooks'; -import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types'; +import { MessageBubbleProps, SenderInfo, MenuPosition } from './types'; import { MessageSegmentsRenderer } from './SegmentRenderer'; import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto'; +import { MessageResponse } from '@/types/dto/message'; import { SwipeableMessageBubble } from './SwipeableMessageBubble'; // 获取屏幕宽度 const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -42,6 +43,8 @@ const MessageBubbleInner: React.FC = ({ otherUserLastReadSeq, selectedMessageId, messageMap, + getCachedReply, + ensureReplyMessage, onLongPress, onAvatarPress, onAvatarLongPress, @@ -111,6 +114,24 @@ const MessageBubbleInner: React.FC = ({ const isPureImageMessage = segmentsWithoutReply.length > 0 && segmentsWithoutReply.every(s => s.type === 'image'); + + // 引用消息回填:本条消息若含 reply segment 且被引用消息不在 messageMap 中, + // 渲染时触发懒加载(内存缓存 → 本地 SQLite → 服务端),回填成功后由缓存版本变化驱动重渲染。 + const replySegmentId = useMemo(() => { + const replySeg = segments.find(s => s.type === 'reply'); + if (!replySeg) return null; + const replyId = String((replySeg.data as { id?: string } | undefined)?.id ?? ''); + return replyId || null; + }, [segments]); + + useEffect(() => { + if (!replySegmentId || !ensureReplyMessage) return; + // messageMap 命中则无需回填 + if (messageMap?.has(replySegmentId)) return; + // 已回填过(含 null 不可见)则不重复请求 + if (getCachedReply?.(replySegmentId) !== undefined) return; + ensureReplyMessage(replySegmentId); + }, [replySegmentId, messageMap, ensureReplyMessage, getCachedReply]); // 提取所有图片 segments const imageSegments = segments @@ -277,23 +298,27 @@ const MessageBubbleInner: React.FC = ({ } // 使用消息链渲染(必须使用 segments 格式) - // 从 segments 中获取被回复消息的 ID 和 seq,然后从 messageMap 中查找 - const getReplyMessage = (): GroupMessage | undefined => { + // 从 segments 中获取被回复消息的 ID 和 seq,依次查 messageMap → 回填缓存 + const getReplyMessage = (): MessageResponse | undefined => { const replySegment = segments.find(s => s.type === 'reply'); - if (replySegment && messageMap) { - const replyData = replySegment.data as { id: string; seq?: number }; - const replyId = String(replyData.id); - - // 首先尝试通过 ID 查找 + if (!replySegment) return undefined; + const replyData = replySegment.data as { id: string; seq?: number }; + const replyId = String(replyData.id); + + // 1. messageMap 命中(已加载到内存的消息) + if (messageMap) { let found = messageMap.get(replyId); - - // 如果通过 ID 找不到,尝试通过 seq 查找 + // 通过 ID 找不到,尝试通过 seq 查找 if (!found && replyData.seq !== undefined) { found = Array.from(messageMap.values()).find(msg => msg.seq === replyData.seq); } - - return found; + if (found) return found; } + + // 2. 回填缓存命中(懒加载回填的被引用消息,可能来自本地 SQLite 或服务端) + const cached = getCachedReply?.(replyId); + if (cached) return cached; + return undefined; }; @@ -315,13 +340,21 @@ const MessageBubbleInner: React.FC = ({ // 检查是否有回复,用于调整气泡样式 const hasReply = segments.some(s => s.type === 'reply'); + // 计算引用回填状态,供占位区分"加载中"与"不可见" + const replyMsg = getReplyMessage(); + // replyMsg 命中(含 messageMap 与缓存)即为 ready,无需 loading/notFound 标记 + const replyLoading = !replyMsg && replySegmentId ? getCachedReply?.(replySegmentId) === undefined : false; + const replyNotFound = !replyMsg && replySegmentId ? getCachedReply?.(replySegmentId) === null : false; + return renderBubbleShell( undefined} onReplyPress={onReplyPress} @@ -541,6 +574,9 @@ export const MessageBubble = React.memo(MessageBubbleInner, (prev, next) => { if (prev.currentUser !== next.currentUser) return false; if (prev.otherUser !== next.otherUser) return false; if (prev.messageMap !== next.messageMap) return false; + // getCachedReply 引用在缓存回填后变化,驱动引用预览从"加载中"→"显示内容"重渲染 + if (prev.getCachedReply !== next.getCachedReply) return false; + if (prev.ensureReplyMessage !== next.ensureReplyMessage) return false; if (prev.onAvatarLongPress !== next.onAvatarLongPress) return false; if (prev.onAvatarPress !== next.onAvatarPress) return false; return true; diff --git a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx index 487eae3..cd8cc47 100644 --- a/src/screens/message/components/ChatScreen/SegmentRenderer.tsx +++ b/src/screens/message/components/ChatScreen/SegmentRenderer.tsx @@ -66,6 +66,9 @@ export interface SegmentRendererProps { export interface ReplyPreviewSegmentProps { replyData: ReplySegmentData; replyMessage?: MessageResponse; + // 回填状态:区分"加载中"与"已被删除/不可见",避免占位文案误导 + replyLoading?: boolean; + replyNotFound?: boolean; isMe: boolean; onPress?: () => void; getSenderInfo?: (senderId: string) => SenderInfo; @@ -668,6 +671,8 @@ const renderLinkSegment = ( export const ReplyPreviewSegment: React.FC = ({ replyData, replyMessage, + replyLoading, + replyNotFound, isMe, onPress, getSenderInfo, @@ -677,7 +682,12 @@ export const ReplyPreviewSegment: React.FC = ({ const styles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]); if (!replyMessage) { - // 如果没有引用消息详情,只显示引用ID + // 回填进行中:显示加载占位;已删除/不可见:显示提示文案 + const placeholder = replyNotFound + ? '引用消息已被删除或不可见' + : replyLoading + ? '加载中…' + : '引用消息'; return ( = ({ - 引用消息 + {placeholder} @@ -766,6 +776,8 @@ const MessageSegmentsRendererInner: React.FC<{ currentUserId?: string; memberMap?: Map; replyMessage?: MessageResponse; + replyLoading?: boolean; + replyNotFound?: boolean; onAtPress?: (userId: string) => void; onReplyPress?: (messageId: string) => void; onImagePress?: (url: string) => void; @@ -778,6 +790,8 @@ const MessageSegmentsRendererInner: React.FC<{ currentUserId, memberMap, replyMessage, + replyLoading, + replyNotFound, onAtPress, onReplyPress, onImagePress, @@ -811,6 +825,8 @@ const MessageSegmentsRendererInner: React.FC<{ onReplyPress?.((replySegment.data as ReplySegmentData).id)} getSenderInfo={getSenderInfo} @@ -1063,7 +1079,10 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { overflow: 'hidden', }, - // 文件 - QQ风格:卡片式设计 + // 文件 - 卡片式设计 + // 注意:文件卡片渲染在聊天气泡内部,因此卡片本身不再绘制背景与阴影。 + // 自带的底色会与气泡背景叠加出「白框」,阴影会被气泡的 overflow:'hidden' + // 裁切成「黑框」,二者都会遮挡文件内容。这里改为透明,让内容直接落在气泡上。 fileContainer: { flexDirection: 'row', alignItems: 'center', @@ -1071,17 +1090,12 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { borderRadius: 16, minWidth: 220, maxWidth: 300, - shadowColor: colors.chat.shadow, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.08, - shadowRadius: 2, - elevation: 2, }, fileMe: { - backgroundColor: 'rgba(255, 255, 255, 0.25)', + backgroundColor: 'transparent', }, fileOther: { - backgroundColor: colors.chat.surfaceRaised, + backgroundColor: 'transparent', }, fileIcon: { marginRight: spacing.md, @@ -1230,6 +1244,8 @@ export const MessageSegmentsRenderer = React.memo(MessageSegmentsRendererInner, if (prev.currentUserId !== next.currentUserId) return false; if (prev.memberMap !== next.memberMap) return false; if (prev.replyMessage !== next.replyMessage) return false; + if (prev.replyLoading !== next.replyLoading) return false; + if (prev.replyNotFound !== next.replyNotFound) return false; return true; }); diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 4f94400..0af59b0 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -90,6 +90,10 @@ export interface MessageBubbleProps { selectedMessageId: string | null; // 消息映射,用于查找被回复的消息 messageMap?: Map; + // 引用消息回填:同步查缓存(渲染期) + getCachedReply?: (messageId: string) => import('@/types/dto/message').MessageResponse | null | undefined; + // 引用消息回填:异步回填(内存→本地→网络),用于懒加载预览 + ensureReplyMessage?: (messageId: string) => Promise; onLongPress: (message: GroupMessage, position?: MenuPosition) => void; onAvatarPress: (userId: string) => void; onAvatarLongPress: (senderId: string, nickname: string) => void; diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 7f7cd74..40a16dd 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -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, diff --git a/src/screens/message/components/ChatScreen/useReplyMessage.ts b/src/screens/message/components/ChatScreen/useReplyMessage.ts new file mode 100644 index 0000000..8356525 --- /dev/null +++ b/src/screens/message/components/ChatScreen/useReplyMessage.ts @@ -0,0 +1,126 @@ +import { useCallback, useRef, useState } from 'react'; +import { MessageResponse } from '@/types/dto/message'; +import { messageRepository } from '@/database'; +import { messageService } from '@/services/message'; +import { MessageMapper } from '@/data/mappers'; + +/** + * 引用消息回填 hook(会话级) + * + * 解决"被引用消息不在已加载内存区间"导致引用预览只显示"引用消息"占位的问题。 + * + * 三级回填策略(微信/iMessage 风格:快照优先 + 懒加载): + * 1. 内存缓存(本 hook 维护的会话级 Map) + * 2. 本地 SQLite(离线命中,零网络) + * 3. 服务端 GET /messages/:id(带参与者鉴权) + * + * 注意:消息 id 是全局唯一雪花ID(非会话内序号),故按 id 查询合法; + * 而 seq 是会话内序号,仅在该会话内有意义。 + * + * 特性: + * - in-flight 去重:同一 messageId 的并发请求只发一次 + * - 失败不重试占位(避免无限重试),记为 null 表示"不可见" + * - state 版本号驱动重渲染:缓存写入后自增 version,订阅方重渲染 + */ +export function useReplyMessage() { + // 会话级内存缓存:messageId -> 被引用消息(null 表示已查询但不可见/不存在) + const cacheRef = useRef>(new Map()); + // in-flight 请求去重:同一 id 同时只发一个请求 + const inflightRef = useRef>>(new Map()); + // 版本号:缓存变更后自增,驱动订阅该 hook 的组件重渲染 + const [version, setVersion] = useState(0); + const bump = useCallback(() => setVersion(v => v + 1), []); + + /** + * 同步查询缓存(渲染期使用)。命中缓存(含 null)返回结果;未查询过返回 undefined。 + * 与 ensureReplyMessage 配合:渲染期先 getCached,未命中则在 useEffect 调 ensure。 + * 注意:依赖 version,缓存更新后引用变化,从而破坏下游 memo 组件的浅比较, + * 使引用了 getCachedReply 的 MessageBubble 在缓存回填后重渲染。 + */ + const getCached = useCallback((messageId: string): MessageResponse | null | undefined => { + const id = String(messageId); + if (cacheRef.current.has(id)) { + return cacheRef.current.get(id) ?? null; + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [version]); + + /** + * 异步回填(本地优先 → 网络兜底)。幂等、去重。 + * @returns 回填到的消息;不可见/不存在返回 null;查询中返回该次 Promise + */ + const ensureReplyMessage = useCallback( + async (messageId: string): Promise => { + const id = String(messageId); + + // 1. 内存缓存命中 + if (cacheRef.current.has(id)) { + return cacheRef.current.get(id) ?? null; + } + + // 2. in-flight 去重:复用进行中的请求 + const inflight = inflightRef.current.get(id); + if (inflight) { + return inflight; + } + + const task = (async (): Promise => { + try { + // 2a. 本地 SQLite 优先(按全局唯一 id 查询) + const local = await messageRepository.getById(id); + if (local) { + const msg: MessageResponse = { + id: local.id, + conversation_id: local.conversationId, + sender_id: local.senderId, + seq: local.seq, + segments: local.segments || [], + status: local.status as MessageResponse['status'], + created_at: local.createdAt, + // CachedMessage 不缓存 sender,UI 侧由 getSenderInfo(sender_id) 兜底 + }; + cacheRef.current.set(id, msg); + bump(); + return msg; + } + + // 2b. 本地未命中,走服务端(带鉴权) + const remote = await messageService.getReplyMessage(id); + if (remote) { + // 落本地缓存(用 MessageMapper 转成 CachedMessage 格式),下次零网络 + try { + await messageRepository.saveMessagesBatch( + MessageMapper.toCachedMessages([remote]) + ); + } catch { + // 落库失败不影响本次返回 + } + cacheRef.current.set(id, remote); + bump(); + return remote; + } + + // 不可见 / 已删除 + cacheRef.current.set(id, null); + bump(); + return null; + } catch { + // 异常时也标记为 null,避免重复请求;用户重进会话可重试 + cacheRef.current.set(id, null); + bump(); + return null; + } finally { + inflightRef.current.delete(id); + } + })(); + + inflightRef.current.set(id, task); + return task; + }, + [bump] + ); + + return { getCached, ensureReplyMessage }; +} + diff --git a/src/services/message/messageService.ts b/src/services/message/messageService.ts index e42ff60..9240a8d 100644 --- a/src/services/message/messageService.ts +++ b/src/services/message/messageService.ts @@ -305,6 +305,33 @@ class MessageService { } } + /** + * 按消息 ID 获取被引用消息(用于引用预览回填/跳转) + * 后端鉴权:必须是该会话参与者,否则返回错误。 + * @param messageId 被引用消息的 ID + */ + async getReplyMessage(messageId: string): Promise { + try { + const response = await api.get( + `/messages/${encodeURIComponent(messageId)}` + ); + return response.data ?? null; + } catch (error: any) { + // 消息不存在或无权限(已被删除/不可见)——引用回填的常见降级场景 + const errorMessage = error?.message || String(error); + if ( + errorMessage.includes('not found') || + errorMessage.includes('no permission') || + error?.code === 'NOT_FOUND' || + error?.code === 'FORBIDDEN' + ) { + return null; + } + console.error('[MessageService] 获取被引用消息失败:', error); + return null; + } + } + /** * 发送消息(新格式) * 优先使用WebSocket发送,失败时降级到HTTP diff --git a/src/services/post/voteService.ts b/src/services/post/voteService.ts index d8b8b3e..3af7087 100644 --- a/src/services/post/voteService.ts +++ b/src/services/post/voteService.ts @@ -24,6 +24,11 @@ interface UpdateVoteOptionResponse { class VoteService { /** * 创建投票帖子(通过统一的 POST /posts 接口,投票选项作为 vote segment) + * + * 支持两种正文形态: + * - 长文模式(块编辑器):调用方传入 `segments`(含文本/图片/@ 段),将其作为正文 segments。 + * 此时 `content` 仅作为纯文本摘要回填,图片等富内容在 segments 中。 + * - 兼容旧调用:不传 `segments` 时,沿用旧行为(用 `content` 生成单条 text 段 + images 字段)。 */ async createVotePost(data: CreateVotePostRequest): Promise { if (!data.vote_options || data.vote_options.length < 2) { @@ -35,12 +40,8 @@ class VoteService { return null; } - // 构建 segments:先放文本内容,再放投票 segment - const segments: MessageSegment[] = []; - if (data.content) { - segments.push({ type: 'text', data: { text: data.content } }); - } - segments.push({ + // 投票 segment + const voteSegment: MessageSegment = { type: 'vote', data: { options: data.vote_options.map((opt, i) => ({ @@ -50,7 +51,20 @@ class VoteService { max_choices: 1, is_multi_choice: false, }, - }); + }; + + let segments: MessageSegment[]; + if (data.segments && data.segments.length > 0) { + // 长文模式:使用调用方提供的正文 segments,再附加投票段 + segments = [...data.segments, voteSegment]; + } else { + // 兼容旧调用:用纯文本 content 生成一条 text 段 + segments = []; + if (data.content) { + segments.push({ type: 'text', data: { text: data.content } }); + } + segments.push(voteSegment); + } const response = await api.post('/posts', { title: data.title, diff --git a/src/services/upload/uploadService.ts b/src/services/upload/uploadService.ts index 96a3402..16fc477 100644 --- a/src/services/upload/uploadService.ts +++ b/src/services/upload/uploadService.ts @@ -135,6 +135,9 @@ class UploadService { // 上传文件(通用,对接后端 POST /uploads/files) // 后端返回 { url, name, size, mime_type } + // 注意:原生端 expo-file-system 的 File.upload() 无法自定义 multipart filename, + // 文件常被复制到缓存目录并以 UUID 命名,因此把真实文件名通过 "name" 字段回传后端, + // 由后端作为权威展示名(见 upload_handler.UploadFile)。 async uploadFile( file: { uri: string; @@ -144,11 +147,13 @@ class UploadService { folder: string = 'chat' ): Promise { try { + const realName = file.name || `file_${Date.now()}`; const response = await api.upload('/uploads/files', { uri: file.uri, - name: file.name || `file_${Date.now()}`, + // 这里的 name 仅作为 multipart part 的 fallback 名,后端会优先使用下方回传的 name 字段 + name: realName, type: file.type || 'application/octet-stream', - }, { folder }); + }, { folder, name: realName }); return response.data; } catch (error) { diff --git a/src/types/dto/pagination.ts b/src/types/dto/pagination.ts index 1a1ac0a..2057700 100644 --- a/src/types/dto/pagination.ts +++ b/src/types/dto/pagination.ts @@ -2,6 +2,8 @@ * Pagination, Vote, Privacy & Other DTOs */ +import type { MessageSegment } from './message'; + // ==================== Common ==================== export interface SuccessDTO { @@ -56,6 +58,8 @@ export interface VoteResultDTO { export interface CreateVotePostRequest { title: string; content?: string; + // 长文模式(块编辑器)下传入的正文段:文本/图片/@ 等。与 images 二选一。 + segments?: MessageSegment[]; images?: string[]; vote_options: Array<{ content: string;