feat(messaging): implement three-tier reply message lazy loading with offline fallback
Add lazy loading pipeline for reply message previews that checks memory → SQLite → server in order, enabling offline hit and graceful degradation for deleted/inaccessible messages. Includes `getReplyMessage` API endpoint and `jumpToMessageSeq` helper for navigation to referenced messages.
feat(create): extract MomentComposer and LongPostComposer for dual posting modes
Refactor CreatePostScreen shell to delegate content editing to specialized composers based on postMode ('moment' or 'long'), enabling long posts with voting support. Add expandable FAB with mode selection on HomeScreen.
fix(navigation): use navigate instead of push to prevent duplicate chat instances
Replace router.push with router.navigate in notification bootstrap to avoid creating duplicate chat instances when already on the chat screen.
fix(ui): update author badge styling with proper text contrast
Change author badge background to use hex transparency and add dedicated text color for better readability on both light and dark themes.
chore: normalize filename handling in file uploads and improve file segment rendering
Use asset.name as authoritative filename, remove redundant shadows from file cards inside bubbles.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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<CommentItemProps> = ({
|
||||
if (isAuthor) {
|
||||
badges.push(
|
||||
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
||||
<Text variant="caption" style={styles.authorBadgeText}>楼主</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -460,7 +470,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
{replyAuthorId === commentAuthorId && (
|
||||
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
|
||||
<Text variant="caption" style={styles.smallBadgeText}>楼主</Text>
|
||||
<Text variant="caption" style={styles.smallAuthorBadgeText}>楼主</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 显示回复引用:aaa 回复 bbb */}
|
||||
|
||||
@@ -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<CachedMessage | null> {
|
||||
const r = await this.dataSource.getFirst<any>(
|
||||
`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<number> {
|
||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
140
src/screens/create/composers/ChannelPicker.tsx
Normal file
140
src/screens/create/composers/ChannelPicker.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
{/* 入口行 */}
|
||||
<TouchableOpacity style={styles.channelEntryRow} onPress={onToggle} activeOpacity={0.8}>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.channelEntryLabel}>
|
||||
发表至:
|
||||
</Text>
|
||||
<View style={styles.channelEntryValueWrap}>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedName ? colors.text.primary : colors.text.hint}
|
||||
style={styles.channelEntryValue}
|
||||
>
|
||||
{selectedName || '选择频道'}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.text.hint} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 展开面板 */}
|
||||
{expanded && (
|
||||
<View style={styles.tagsSection}>
|
||||
<View style={styles.tagsContainer}>
|
||||
{channels.map((channel) => {
|
||||
const isSelected = selectedId === channel.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={channel.id}
|
||||
style={[styles.tag, isSelected && styles.channelSelected]}
|
||||
onPress={() => onSelect(channel.id)}
|
||||
>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={isSelected ? colors.primary.contrast : colors.text.secondary}
|
||||
style={styles.tagText}
|
||||
>
|
||||
{channel.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{channels.length === 0 && (
|
||||
<Text variant="caption" color={colors.text.hint}>暂无可用频道</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
121
src/screens/create/composers/EmojiPanel.tsx
Normal file
121
src/screens/create/composers/EmojiPanel.tsx
Normal file
@@ -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 (
|
||||
<View style={[styles.emojiPanel, isWideScreen && styles.emojiPanelWide]}>
|
||||
<FlatList
|
||||
data={EMOJI_ROWS}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.emojiRow}>
|
||||
{item.emojis.map((emoji, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${item.id}-${index}`}
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onPick(emoji)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{emoji}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
windowSize={5}
|
||||
getItemLayout={(_data, index) => ({
|
||||
length: EMOJI_ROW_HEIGHT,
|
||||
offset: EMOJI_ROW_HEIGHT * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
135
src/screens/create/composers/LongPostComposer.tsx
Normal file
135
src/screens/create/composers/LongPostComposer.tsx
Normal file
@@ -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<LongPostComposerHandle, LongPostComposerProps>(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<BlockEditorHandle>(null);
|
||||
const [isVotePost, setIsVotePost] = React.useState(false);
|
||||
const [voteOptions, setVoteOptions] = React.useState<string[]>(['', '']);
|
||||
|
||||
// 投票选项增删改
|
||||
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 (
|
||||
<View style={styles.section}>
|
||||
<BlockEditor
|
||||
ref={blockEditorRef}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
textStyle={[styles.contentInput, isWideScreen && styles.contentInputWide]}
|
||||
onContentChange={onContentChange}
|
||||
initialBlocks={props.initialBlocks}
|
||||
/>
|
||||
|
||||
{/* 投票编辑器 */}
|
||||
{isVotePost && (
|
||||
<View style={isWideScreen && styles.voteEditorWide}>
|
||||
<View style={styles.voteEditorHeaderRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>已插入投票</Text>
|
||||
<TouchableOpacity onPress={() => setIsVotePost(false)} activeOpacity={0.7}>
|
||||
<Text variant="caption" color={colors.primary.main}>移除投票</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<VoteEditor
|
||||
options={voteOptions}
|
||||
onAddOption={handleAddVoteOption}
|
||||
onRemoveOption={handleRemoveVoteOption}
|
||||
onUpdateOption={handleUpdateVoteOption}
|
||||
maxOptions={10}
|
||||
minOptions={2}
|
||||
maxLength={50}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
223
src/screens/create/composers/MomentComposer.tsx
Normal file
223
src/screens/create/composers/MomentComposer.tsx
Normal file
@@ -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<ComposerHandle, ComposerProps>(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<MessageSegment[]>(props.initialSegments ?? []);
|
||||
const [images, setImages] = React.useState<PendingOrRemoteImage[]>(props.initialImages ?? []);
|
||||
// 表情插入位置(与原实现一致:默认末尾追加,插入表情后更新)
|
||||
const [selection, setSelection] = React.useState({ start: content.length, end: content.length });
|
||||
const [uploadedUrls, setUploadedUrls] = React.useState<string[]>([]);
|
||||
|
||||
// 响应式图片网格
|
||||
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 (
|
||||
<View style={styles.section}>
|
||||
<PostMentionInput
|
||||
value={content}
|
||||
onChangeText={emitContent}
|
||||
onSegmentsChange={setSegments}
|
||||
placeholder="添加正文"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
minHeight={160}
|
||||
autoExpand
|
||||
style={styles.contentInput}
|
||||
/>
|
||||
|
||||
{/* 图片网格 */}
|
||||
{images.length > 0 && (
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img, index) => (
|
||||
<View key={img.id} style={[styles.imageGridItem, { width: imageSize, height: imageSize }]}>
|
||||
<Image
|
||||
source={{ uri: getImageDisplayUri(img) }}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveImage(index)}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeImageButtonInner}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.text.inverse} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{/* 添加图片按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.addImageGridButton, { width: imageSize, height: imageSize }]}
|
||||
onPress={pickImage}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
57
src/screens/create/composers/types.ts
Normal file
57
src/screens/create/composers/types.ts
Normal file
@@ -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<boolean>;
|
||||
/** 上传后得到的图片 URL(普通模式独立 images 字段;长文返回空数组) */
|
||||
getUploadedImages(): string[];
|
||||
/** 表情面板点击插入 */
|
||||
insertEmoji(text: string): void;
|
||||
/** 工具栏「相册」 */
|
||||
insertImage(): Promise<void>;
|
||||
/** 工具栏「相机」 */
|
||||
insertCameraPhoto(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 长文 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;
|
||||
@@ -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 && (
|
||||
<TouchableWithoutFeedback onPress={() => setFabExpanded(false)}>
|
||||
<View style={styles.fabOverlay} />
|
||||
</TouchableWithoutFeedback>
|
||||
)}
|
||||
|
||||
{/* 发帖入口:加号 / 展开后的「瞬间/长文」两个小按钮 */}
|
||||
{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 (
|
||||
<View
|
||||
style={[
|
||||
styles.fabSubGroup,
|
||||
isDesktop && styles.fabSubGroupDesktop,
|
||||
isWideScreen && styles.fabSubGroupWide,
|
||||
{ bottom: subGroupBottom },
|
||||
]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.fabSubButton, isWideScreen && styles.fabSubButtonWide]}
|
||||
onPress={() => openCreatePostWithMode('long')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="text-box" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" style={styles.fabSubLabel}>长文</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.fabSubButton, isWideScreen && styles.fabSubButtonWide]}
|
||||
onPress={() => openCreatePostWithMode('moment')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="lightning-bolt" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" style={styles.fabSubLabel}>瞬间</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 漂浮发帖/发布按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
@@ -1058,7 +1164,11 @@ export const HomeScreen: React.FC = () => {
|
||||
} : handleCreatePost}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
|
||||
<MaterialCommunityIcons
|
||||
name={(fabExpanded && homeTab !== 'market') ? 'close' : 'plus'}
|
||||
size={28}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
@@ -1078,6 +1188,8 @@ export const HomeScreen: React.FC = () => {
|
||||
onRequestClose={() => setShowCreatePost(false)}
|
||||
>
|
||||
<CreatePostScreen
|
||||
key={postMode}
|
||||
postMode={postMode}
|
||||
onClose={() => setShowCreatePost(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [isFollowLoading, setIsFollowLoading] = useState(false);
|
||||
const flatListRef = useRef<FlashListRef<Comment> | null>(null);
|
||||
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
||||
@@ -277,6 +279,35 @@ export const PostDetailScreen: React.FC = () => {
|
||||
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(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 = () => (
|
||||
<View style={styles.customHeader}>
|
||||
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||
{post?.author ? (
|
||||
<View style={styles.headerContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleUserPress(post.author!.id)}
|
||||
style={styles.headerAvatarWrapper}
|
||||
>
|
||||
<Avatar source={post.author.avatar} size={32} name={post.author.nickname} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerUserInfo}>
|
||||
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)}>
|
||||
<Text style={styles.headerNickname} numberOfLines={1}>
|
||||
{post.author.nickname && post.author.nickname.length > 10
|
||||
? `${post.author.nickname.slice(0, 10)}...`
|
||||
: post.author.nickname}
|
||||
</Text>
|
||||
const renderCustomHeader = () => {
|
||||
const followersCount = authorFollowersCount ?? post?.author?.followers_count ?? 0;
|
||||
|
||||
return (
|
||||
<View style={styles.customHeader}>
|
||||
<AppBackButton onPress={handleBackPress} style={styles.headerBackButton} />
|
||||
{post?.author ? (
|
||||
<View style={styles.headerContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleUserPress(post.author!.id)}
|
||||
style={styles.headerAvatarWrapper}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Avatar source={post.author.avatar} size={42} name={post.author.nickname} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerUserInfo}>
|
||||
<TouchableOpacity onPress={() => handleUserPress(post.author!.id)} activeOpacity={0.75}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={styles.headerNickname} numberOfLines={1}>
|
||||
{post.author.nickname && post.author.nickname.length > 10
|
||||
? `${post.author.nickname.slice(0, 10)}...`
|
||||
: post.author.nickname}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerMetaText} numberOfLines={1}>
|
||||
{formatNumber(followersCount)} 粉丝
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerPlaceholder} />
|
||||
)}
|
||||
<View style={styles.headerRightContainer}>
|
||||
{renderFollowButton()}
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.headerPlaceholder} />
|
||||
)}
|
||||
<View style={styles.headerRightContainer}>
|
||||
{renderFollowButton()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
// 监听键盘事件
|
||||
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(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
<MaterialCommunityIcons name="forum-outline" size={48} color={colors.text.disabled} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.emptyCommentsSubtitle}>
|
||||
评论区空空如也,来发表第一条评论吧~
|
||||
</Text>
|
||||
</View>
|
||||
), [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(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
<View style={styles.emptyCommentsIllustration}>
|
||||
<View style={styles.emptyCommentsIconCircle}>
|
||||
<MaterialCommunityIcons name="forum-outline" size={40} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.emptyCommentsBubbleSmall} />
|
||||
<View style={styles.emptyCommentsBubbleTiny} />
|
||||
</View>
|
||||
<Text style={styles.emptyCommentsTitle}>还没有评论</Text>
|
||||
<Text variant="caption" style={styles.emptyCommentsSubtitle}>
|
||||
评论区空空如也,来发表第一条评论吧~
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.emptyCommentsAction}
|
||||
onPress={openComposer}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text style={styles.emptyCommentsActionText}>抢沙发</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
), [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: {
|
||||
|
||||
@@ -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<ChatScreenProps> = (props) => {
|
||||
effectiveGroupName,
|
||||
otherUserLastReadSeq,
|
||||
messageMap,
|
||||
// 引用消息回填(会话级缓存:内存 → 本地 SQLite → 服务端)
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
jumpToMessageSeq,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
conversationId,
|
||||
@@ -281,20 +286,43 @@ export const ChatScreen: React.FC<ChatScreenProps> = (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<ChatScreenProps> = (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<ChatScreenProps> = (props) => {
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
handleLongPressMessage,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
|
||||
@@ -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<MessageBubbleProps> = ({
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
getCachedReply,
|
||||
ensureReplyMessage,
|
||||
onLongPress,
|
||||
onAvatarPress,
|
||||
onAvatarLongPress,
|
||||
@@ -112,6 +115,24 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
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
|
||||
.filter(s => s.type === 'image')
|
||||
@@ -277,23 +298,27 @@ const MessageBubbleInner: React.FC<MessageBubbleProps> = ({
|
||||
}
|
||||
|
||||
// 使用消息链渲染(必须使用 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);
|
||||
if (!replySegment) return undefined;
|
||||
const replyData = replySegment.data as { id: string; seq?: number };
|
||||
const replyId = String(replyData.id);
|
||||
|
||||
// 首先尝试通过 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<MessageBubbleProps> = ({
|
||||
// 检查是否有回复,用于调整气泡样式
|
||||
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(
|
||||
<MessageSegmentsRenderer
|
||||
segments={segments}
|
||||
isMe={isMe}
|
||||
currentUserId={currentUserId}
|
||||
memberMap={memberMap}
|
||||
replyMessage={getReplyMessage()}
|
||||
replyMessage={replyMsg}
|
||||
replyLoading={replyLoading}
|
||||
replyNotFound={replyNotFound}
|
||||
getSenderInfo={getSenderInfo}
|
||||
onAtPress={() => 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;
|
||||
|
||||
@@ -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<ReplyPreviewSegmentProps> = ({
|
||||
replyData,
|
||||
replyMessage,
|
||||
replyLoading,
|
||||
replyNotFound,
|
||||
isMe,
|
||||
onPress,
|
||||
getSenderInfo,
|
||||
@@ -677,7 +682,12 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
const styles = useMemo(() => createSegmentStyles(themeColors, fontSize), [themeColors, fontSize]);
|
||||
|
||||
if (!replyMessage) {
|
||||
// 如果没有引用消息详情,只显示引用ID
|
||||
// 回填进行中:显示加载占位;已删除/不可见:显示提示文案
|
||||
const placeholder = replyNotFound
|
||||
? '引用消息已被删除或不可见'
|
||||
: replyLoading
|
||||
? '加载中…'
|
||||
: '引用消息';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
|
||||
@@ -687,7 +697,7 @@ export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
<View style={styles.replyLine} />
|
||||
<View style={styles.replyContent}>
|
||||
<Text style={styles.replyText} numberOfLines={2}>
|
||||
引用消息
|
||||
{placeholder}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
@@ -766,6 +776,8 @@ const MessageSegmentsRendererInner: React.FC<{
|
||||
currentUserId?: string;
|
||||
memberMap?: Map<string, { nickname: string; user_id: string }>;
|
||||
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<{
|
||||
<ReplyPreviewSegment
|
||||
replyData={replySegment.data as ReplySegmentData}
|
||||
replyMessage={replyMessage}
|
||||
replyLoading={replyLoading}
|
||||
replyNotFound={replyNotFound}
|
||||
isMe={isMe}
|
||||
onPress={() => 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;
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ export interface MessageBubbleProps {
|
||||
selectedMessageId: string | null;
|
||||
// 消息映射,用于查找被回复的消息
|
||||
messageMap?: Map<string, GroupMessage>;
|
||||
// 引用消息回填:同步查缓存(渲染期)
|
||||
getCachedReply?: (messageId: string) => import('@/types/dto/message').MessageResponse | null | undefined;
|
||||
// 引用消息回填:异步回填(内存→本地→网络),用于懒加载预览
|
||||
ensureReplyMessage?: (messageId: string) => Promise<import('@/types/dto/message').MessageResponse | null>;
|
||||
onLongPress: (message: GroupMessage, position?: MenuPosition) => void;
|
||||
onAvatarPress: (userId: string) => void;
|
||||
onAvatarLongPress: (senderId: string, nickname: string) => void;
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
126
src/screens/message/components/ChatScreen/useReplyMessage.ts
Normal file
126
src/screens/message/components/ChatScreen/useReplyMessage.ts
Normal file
@@ -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<Map<string, MessageResponse | null>>(new Map());
|
||||
// in-flight 请求去重:同一 id 同时只发一个请求
|
||||
const inflightRef = useRef<Map<string, Promise<MessageResponse | null>>>(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<MessageResponse | null> => {
|
||||
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<MessageResponse | null> => {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -305,6 +305,33 @@ class MessageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按消息 ID 获取被引用消息(用于引用预览回填/跳转)
|
||||
* 后端鉴权:必须是该会话参与者,否则返回错误。
|
||||
* @param messageId 被引用消息的 ID
|
||||
*/
|
||||
async getReplyMessage(messageId: string): Promise<MessageResponse | null> {
|
||||
try {
|
||||
const response = await api.get<MessageResponse>(
|
||||
`/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
|
||||
|
||||
@@ -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<Post | null> {
|
||||
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<Post>('/posts', {
|
||||
title: data.title,
|
||||
|
||||
@@ -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<UploadResponse | null> {
|
||||
try {
|
||||
const realName = file.name || `file_${Date.now()}`;
|
||||
const response = await api.upload<UploadResponse>('/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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user