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.
140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
/**
|
||
* 频道选择(瞬间 / 长文共用)
|
||
* 包含「发表至」入口行 + 展开后的频道单选面板。
|
||
*/
|
||
|
||
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',
|
||
},
|
||
});
|
||
} |