重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复
本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括: 1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。 2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。 3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。 4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。 5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。 6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。 7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { Settings, Trash2, X } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
@@ -10,7 +8,6 @@ import {
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from 'react-native';
|
||||
@@ -21,6 +18,8 @@ import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { MarkdownRenderer } from '@/features/memoir/markdown-renderer';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks';
|
||||
|
||||
// Life-Echo reading colors (from HTML reference)
|
||||
@@ -39,18 +38,6 @@ type FontSize = 'small' | 'default' | 'large';
|
||||
type FontFamily = 'serif' | 'sans';
|
||||
type BackgroundTheme = 'white' | 'sepia';
|
||||
|
||||
const FONT_SIZES: Record<FontSize, number> = {
|
||||
small: 16,
|
||||
default: 20,
|
||||
large: 24,
|
||||
};
|
||||
|
||||
const LINE_HEIGHTS: Record<FontSize, number> = {
|
||||
small: 30,
|
||||
default: 38,
|
||||
large: 44,
|
||||
};
|
||||
|
||||
const FONT_FAMILIES: Record<FontFamily, string> = {
|
||||
serif:
|
||||
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
|
||||
@@ -68,198 +55,6 @@ const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
|
||||
sepia: READING_COLORS.backgroundSepia,
|
||||
};
|
||||
|
||||
function ChapterContent({
|
||||
sections,
|
||||
coverImageUrl,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
backgroundColor,
|
||||
}: {
|
||||
sections: { content: string; image: { url: string | null } | null }[];
|
||||
coverImageUrl: string | null;
|
||||
fontSize: FontSize;
|
||||
fontFamily: FontFamily;
|
||||
backgroundColor: BackgroundTheme;
|
||||
}) {
|
||||
const { width } = useWindowDimensions();
|
||||
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
|
||||
const heroAspectRatio = 4 / 5;
|
||||
const bodySize = FONT_SIZES[fontSize];
|
||||
const lineHeight = LINE_HEIGHTS[fontSize];
|
||||
const fontFam = FONT_FAMILIES[fontFamily];
|
||||
const bgColor = BACKGROUND_COLORS[backgroundColor];
|
||||
|
||||
const [heroLoadFailed, setHeroLoadFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setHeroLoadFailed(false);
|
||||
}, [coverImageUrl]);
|
||||
const hasCoverImage = !!coverImageUrl && !heroLoadFailed;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero Image: 仅在有封面图且加载成功时显示,避免无图或加载失败时大片空白 */}
|
||||
{hasCoverImage && (
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: heroAspectRatio,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: coverImageUrl! }}
|
||||
alt="Chapter hero"
|
||||
onError={() => setHeroLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', bgColor]}
|
||||
locations={[0.3, 1]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Memoir Content */}
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: ScreenGutter,
|
||||
marginTop: hasCoverImage ? -60 : 0,
|
||||
paddingTop: hasCoverImage ? 0 : 12,
|
||||
paddingBottom: 48,
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ maxWidth: contentWidth, alignSelf: 'center', width: '100%' }}
|
||||
>
|
||||
{(() => {
|
||||
const indent = '\u3000\u3000';
|
||||
let isFirstParagraph = true;
|
||||
|
||||
return sections.flatMap((section, i) => {
|
||||
const text = section.content?.trim() ?? '';
|
||||
const hasImage = !!section.image?.url;
|
||||
if (!text && !hasImage) return [];
|
||||
|
||||
const paragraphs = text
|
||||
? text
|
||||
.split(/\n\n+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
paragraphs.forEach((para, pIdx) => {
|
||||
const firstChar = para.charAt(0);
|
||||
const rest = para.slice(1);
|
||||
const isLastInSection =
|
||||
pIdx === paragraphs.length - 1 && !hasImage;
|
||||
const mb = isLastInSection ? 20 : 16;
|
||||
|
||||
if (isFirstParagraph && firstChar) {
|
||||
const firstParaLineHeight = Math.round(lineHeight * 1.35);
|
||||
nodes.push(
|
||||
<Text
|
||||
key={`${i}-${pIdx}`}
|
||||
selectable
|
||||
style={{
|
||||
fontSize: bodySize,
|
||||
lineHeight: firstParaLineHeight,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontFamily: fontFam,
|
||||
marginBottom: mb,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: bodySize * 2.2,
|
||||
fontWeight: '700',
|
||||
color: READING_COLORS.primary,
|
||||
lineHeight: firstParaLineHeight,
|
||||
fontFamily: fontFam,
|
||||
}}
|
||||
>
|
||||
{firstChar}
|
||||
</Text>
|
||||
{rest}
|
||||
</Text>,
|
||||
);
|
||||
isFirstParagraph = false;
|
||||
} else {
|
||||
nodes.push(
|
||||
<Text
|
||||
key={`${i}-${pIdx}`}
|
||||
selectable
|
||||
style={{
|
||||
fontSize: bodySize,
|
||||
lineHeight,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontFamily: fontFam,
|
||||
marginBottom: mb,
|
||||
}}
|
||||
>
|
||||
{indent}
|
||||
{para}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (hasImage) {
|
||||
nodes.push(
|
||||
<Image
|
||||
key={`${i}-img`}
|
||||
source={{ uri: section.image!.url }}
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 12,
|
||||
marginTop: paragraphs.length > 0 ? 12 : 0,
|
||||
marginBottom: 20,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
});
|
||||
})()}
|
||||
|
||||
{sections.length > 0 && (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 24,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 96,
|
||||
height: 1,
|
||||
backgroundColor: READING_COLORS.divider,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingSettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -290,53 +85,55 @@ function ReadingSettingsModal({
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={readingSettingsStyles.backdrop} onPress={onClose}>
|
||||
<Pressable className="flex-1 justify-end bg-black/40" onPress={onClose}>
|
||||
<Pressable
|
||||
style={[
|
||||
readingSettingsStyles.sheet,
|
||||
{ paddingBottom: insets.bottom + 24 },
|
||||
]}
|
||||
className="rounded-t-2xl bg-card px-screen-gutter pt-2 shadow-lg"
|
||||
style={{ paddingBottom: insets.bottom + 24 }}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<View style={readingSettingsStyles.handle} />
|
||||
<View style={readingSettingsStyles.header}>
|
||||
<Text style={readingSettingsStyles.title}>
|
||||
<View className="mb-4 h-1 w-10 self-center rounded-full bg-black/20" />
|
||||
<View className="mb-5 flex-row items-center justify-between">
|
||||
<Text
|
||||
variant="titleMedium"
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{t('chapterReading.readingSettings')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
hitSlop={12}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.closeBtn,
|
||||
pressed && { opacity: 0.6 },
|
||||
]}
|
||||
className="p-2 active:opacity-60"
|
||||
accessibilityLabel={t('chapterReading.close')}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Icon as={X} size={22} color={READING_COLORS.onSurfaceVariant} />
|
||||
<Icon as={X} size={22} className="text-muted-foreground" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={readingSettingsStyles.section}>
|
||||
<Text style={readingSettingsStyles.label}>
|
||||
<View className="mb-5">
|
||||
<Text
|
||||
variant="sectionTitle"
|
||||
className="mb-2.5 font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{t('chapterReading.fontSize')}
|
||||
</Text>
|
||||
<View style={readingSettingsStyles.segmented}>
|
||||
<View className="flex-row rounded-[10px] bg-muted p-1">
|
||||
{(['small', 'default', 'large'] as const).map((s) => (
|
||||
<Pressable
|
||||
key={s}
|
||||
onPress={() => onFontSizeChange(s)}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.segItem,
|
||||
fontSize === s && readingSettingsStyles.segItemActive,
|
||||
pressed && { opacity: 0.8 },
|
||||
]}
|
||||
className={cn(
|
||||
'min-h-11 flex-1 items-center justify-center rounded-lg',
|
||||
fontSize === s && 'bg-primary',
|
||||
'active:opacity-80',
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
readingSettingsStyles.segText,
|
||||
fontSize === s && readingSettingsStyles.segTextActive,
|
||||
]}
|
||||
variant="bodyMedium"
|
||||
className={cn(
|
||||
'font-medium text-muted-foreground',
|
||||
fontSize === s && 'font-semibold text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
`chapterReading.fontSize${s.charAt(0).toUpperCase() + s.slice(1)}`,
|
||||
@@ -347,27 +144,32 @@ function ReadingSettingsModal({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={readingSettingsStyles.section}>
|
||||
<Text style={readingSettingsStyles.label}>
|
||||
<View className="mb-5">
|
||||
<Text
|
||||
variant="sectionTitle"
|
||||
className="mb-2.5 font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{t('chapterReading.typography')}
|
||||
</Text>
|
||||
<View style={readingSettingsStyles.segmented}>
|
||||
<View className="flex-row rounded-[10px] bg-muted p-1">
|
||||
{(['serif', 'sans'] as const).map((f) => (
|
||||
<Pressable
|
||||
key={f}
|
||||
onPress={() => onFontFamilyChange(f)}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.segItem,
|
||||
fontFamily === f && readingSettingsStyles.segItemActive,
|
||||
pressed && { opacity: 0.8 },
|
||||
]}
|
||||
className={cn(
|
||||
'min-h-11 flex-1 items-center justify-center rounded-lg',
|
||||
fontFamily === f && 'bg-primary',
|
||||
'active:opacity-80',
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
readingSettingsStyles.segText,
|
||||
fontFamily === f && readingSettingsStyles.segTextActive,
|
||||
{ fontFamily: FONT_FAMILIES[f] },
|
||||
]}
|
||||
variant="bodyMedium"
|
||||
style={{ fontFamily: FONT_FAMILIES[f] }}
|
||||
className={cn(
|
||||
'font-medium text-muted-foreground',
|
||||
fontFamily === f &&
|
||||
'font-semibold text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
`chapterReading.font${f.charAt(0).toUpperCase() + f.slice(1)}`,
|
||||
@@ -378,26 +180,28 @@ function ReadingSettingsModal({
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={readingSettingsStyles.section}>
|
||||
<Text style={readingSettingsStyles.label}>
|
||||
<View className="mb-5">
|
||||
<Text
|
||||
variant="sectionTitle"
|
||||
className="mb-2.5 font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{t('chapterReading.backgroundColor')}
|
||||
</Text>
|
||||
<View style={readingSettingsStyles.bgRow}>
|
||||
<View className="flex-row gap-3">
|
||||
{(['white', 'sepia'] as const).map((theme) => (
|
||||
<Pressable
|
||||
key={theme}
|
||||
onPress={() => onBackgroundChange(theme)}
|
||||
style={({ pressed }) => [
|
||||
readingSettingsStyles.bgOption,
|
||||
{ backgroundColor: BACKGROUND_COLORS[theme] },
|
||||
backgroundColor === theme &&
|
||||
readingSettingsStyles.bgOptionActive,
|
||||
pressed && { opacity: 0.9 },
|
||||
]}
|
||||
className={cn(
|
||||
'min-h-16 flex-1 flex-row items-center justify-center gap-2.5 rounded-[10px] border-2 border-transparent',
|
||||
backgroundColor === theme && 'border-primary',
|
||||
'active:opacity-90',
|
||||
)}
|
||||
style={{ backgroundColor: BACKGROUND_COLORS[theme] }}
|
||||
>
|
||||
<View
|
||||
className="h-7 w-7 rounded-md border border-black/10"
|
||||
style={[
|
||||
readingSettingsStyles.bgSwatch,
|
||||
{ backgroundColor: BACKGROUND_COLORS[theme] },
|
||||
theme === 'sepia' && {
|
||||
borderColor: 'rgba(91,77,62,0.5)',
|
||||
@@ -405,12 +209,13 @@ function ReadingSettingsModal({
|
||||
]}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
readingSettingsStyles.bgLabel,
|
||||
theme === 'sepia' && { color: '#5B4D3E' },
|
||||
backgroundColor === theme &&
|
||||
readingSettingsStyles.bgLabelActive,
|
||||
]}
|
||||
variant="bodyMedium"
|
||||
className={cn(
|
||||
'font-medium',
|
||||
theme === 'white' && 'text-foreground',
|
||||
theme === 'sepia' && 'text-[#5B4D3E]',
|
||||
backgroundColor === theme && 'font-semibold',
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
`chapterReading.bg${theme === 'white' ? 'PureWhite' : 'Sepia'}`,
|
||||
@@ -426,117 +231,13 @@ function ReadingSettingsModal({
|
||||
);
|
||||
}
|
||||
|
||||
const readingSettingsStyles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: ScreenGutter,
|
||||
...Platform.select({
|
||||
ios: {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 12,
|
||||
},
|
||||
android: { elevation: 12 },
|
||||
}),
|
||||
},
|
||||
handle: {
|
||||
alignSelf: 'center',
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
marginBottom: 16,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
color: READING_COLORS.onSurface,
|
||||
},
|
||||
closeBtn: { padding: 8 },
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
section: { marginBottom: 20 },
|
||||
segmented: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#eeeef0',
|
||||
borderRadius: 10,
|
||||
padding: 4,
|
||||
},
|
||||
segItem: {
|
||||
flex: 1,
|
||||
minHeight: 44,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
segItemActive: {
|
||||
backgroundColor: READING_COLORS.primary,
|
||||
},
|
||||
segText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
},
|
||||
segTextActive: {
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
bgRow: { flexDirection: 'row', gap: 12 },
|
||||
bgOption: {
|
||||
flex: 1,
|
||||
minHeight: 64,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
borderRadius: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
bgOptionActive: {
|
||||
borderColor: READING_COLORS.primary,
|
||||
},
|
||||
bgSwatch: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0,0,0,0.1)',
|
||||
},
|
||||
bgLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: READING_COLORS.onSurface,
|
||||
},
|
||||
bgLabelActive: { fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default function ChapterScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width } = useWindowDimensions();
|
||||
const { t } = useTranslation('memoir');
|
||||
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
|
||||
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
|
||||
const deleteChapter = useDeleteChapter();
|
||||
|
||||
const [settingsVisible, setSettingsVisible] = useState(false);
|
||||
@@ -602,8 +303,9 @@ export default function ChapterScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const sections = chapter.sections ?? [];
|
||||
const coverImageUrl = chapter.cover_image?.url ?? null;
|
||||
const canonicalMarkdown = (chapter.canonical_markdown ?? '').trim();
|
||||
const renderedAssets = chapter.rendered_assets ?? chapter.images ?? [];
|
||||
|
||||
const handleDeletePress = () => {
|
||||
Alert.alert(
|
||||
@@ -677,12 +379,14 @@ export default function ChapterScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
<ChapterContent
|
||||
sections={sections}
|
||||
<MarkdownRenderer
|
||||
markdown={canonicalMarkdown}
|
||||
renderedAssets={renderedAssets}
|
||||
coverImageUrl={coverImageUrl}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
backgroundColor={backgroundColor}
|
||||
backgroundColor={bgColor}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text as RNText,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native';
|
||||
@@ -350,16 +351,32 @@ function VoiceRecordButton({
|
||||
]}
|
||||
disabled={!enabled}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.voiceRecordLabel,
|
||||
!enabled && styles.voiceRecordLabelDisabled,
|
||||
isRecording && styles.voiceRecordLabelRecording,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{isRecording ? tapToEndLabel : tapToStartLabel}
|
||||
</Text>
|
||||
{isRecording ? (
|
||||
<View style={styles.voiceRecordLabelCenterWrap}>
|
||||
<Text
|
||||
style={[
|
||||
styles.voiceRecordLabel,
|
||||
styles.voiceRecordLabelCenter,
|
||||
styles.voiceRecordLabelRecording,
|
||||
!enabled && styles.voiceRecordLabelDisabled,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tapToEndLabel}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.voiceRecordLabel,
|
||||
!enabled && styles.voiceRecordLabelDisabled,
|
||||
styles.voiceRecordLabelCenter,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{tapToStartLabel}
|
||||
</Text>
|
||||
)}
|
||||
{isRecording && (
|
||||
<View style={styles.voiceRecordPill}>
|
||||
<Animated.View
|
||||
@@ -368,11 +385,16 @@ function VoiceRecordButton({
|
||||
{ transform: [{ scale: pulseAnim }] },
|
||||
]}
|
||||
/>
|
||||
{/* TODO: Duration number centering still broken on Android */}
|
||||
<View style={styles.voiceRecordDurationWrap}>
|
||||
<Text style={styles.voiceRecordDuration}>
|
||||
<RNText
|
||||
style={[
|
||||
styles.voiceRecordDuration,
|
||||
Platform.OS === 'android' && styles.voiceRecordDurationAndroid,
|
||||
]}
|
||||
{...(Platform.OS === 'android' && { includeFontPadding: false })}
|
||||
>
|
||||
{formatRecordingDuration(recordingDuration)}
|
||||
</Text>
|
||||
</RNText>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -505,7 +527,7 @@ function ChatInputBar({
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text
|
||||
style={[styles.sendButtonText, { color: colors.primaryForeground }]}
|
||||
style={[styles.sendButtonText, { color: CHAT_COLORS.onSurface }]}
|
||||
>
|
||||
{sendLabel}
|
||||
</Text>
|
||||
@@ -580,11 +602,21 @@ export default function ConversationScreen() {
|
||||
const [input, setInput] = useState('');
|
||||
const [inputMode, setInputMode] = useState<InputMode>('text');
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const listRef = useRef<FlatList>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onShow = () => setIsKeyboardVisible(true);
|
||||
const onHide = () => setIsKeyboardVisible(false);
|
||||
const onShow = (e: { endCoordinates: { height: number } }) => {
|
||||
setIsKeyboardVisible(true);
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
});
|
||||
};
|
||||
const onHide = () => {
|
||||
setIsKeyboardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
};
|
||||
const subShow = Keyboard.addListener('keyboardDidShow', onShow);
|
||||
const subHide = Keyboard.addListener('keyboardDidHide', onHide);
|
||||
return () => {
|
||||
@@ -621,11 +653,12 @@ export default function ConversationScreen() {
|
||||
|
||||
const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
|
||||
const kavEnabled = inputMode === 'text' && isKeyboardVisible;
|
||||
const kavBehavior = Platform.OS === 'ios' ? 'padding' : 'height';
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={kavEnabled ? 'padding' : undefined}
|
||||
behavior={kavEnabled ? kavBehavior : undefined}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
>
|
||||
<View style={styles.column}>
|
||||
@@ -668,8 +701,14 @@ export default function ConversationScreen() {
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
inputMode === 'text' &&
|
||||
isKeyboardVisible && { paddingBottom: 12 + keyboardHeight },
|
||||
]}
|
||||
data={flattenedData}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
keyExtractor={(item) => item.listKey}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
@@ -784,6 +823,7 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 12,
|
||||
@@ -952,6 +992,14 @@ const styles = StyleSheet.create({
|
||||
color: 'rgba(27, 27, 31, 0.72)',
|
||||
flex: 1,
|
||||
},
|
||||
voiceRecordLabelCenter: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
voiceRecordLabelCenterWrap: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
voiceRecordLabelRecording: {
|
||||
color: 'rgba(27, 27, 31, 0.92)',
|
||||
},
|
||||
@@ -980,8 +1028,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
voiceRecordDuration: {
|
||||
fontSize: 12,
|
||||
lineHeight: 12,
|
||||
color: 'rgba(27, 27, 31, 0.86)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
voiceRecordDurationAndroid: {
|
||||
textAlignVertical: 'center',
|
||||
} as const,
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
|
||||
Reference in New Issue
Block a user