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;