import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Alert, KeyboardAvoidingView, Modal, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, TextInput, View, } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { Meeting, MeetingDocument, MeetingMemo, UserTemplate } from '@d3ro/api-client' import { useI18n } from '@d3ro/i18n' import { useAuth } from '../lib/auth-context' import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import ContentReportSheet, { type ContentReportTarget, } from '../components/ContentReportSheet' import { prepareMeetingExportFile, sharePreparedPortableFile, type MeetingDocumentFormat, type PreparedPortableFile, } from '../features/data-portability' import { clearGenerationIdempotencyKey, generateMeetingDocument, getOrCreateGenerationIdempotencyKey, loadTemplateLibrary, TemplateServiceError, } from '../features/templates' import { buildMeetingTranscriptView, createMeetingMemo, deleteMeetingDocumentRevisionSafe, deleteMeetingMemoConflictSafe, deleteMeetingRevisionSafe, getMeetingDetail, MeetingServiceError, subscribeToMeetingDetail, updateMeetingDocumentRevisionSafe, updateMeetingMemoConflictSafe, updateMeetingTitleRevisionSafe, type MeetingDetail, } from '../features/meetings/meetings-service' interface MeetingDetailScreenProps { route: { params: { meetingId: string } } navigation: { goBack: () => void navigate: ( name: 'Main' | 'Templates', params?: | { screen: 'Record'; params: { meetingId: string; meetingTitle?: string; meetingLanguage?: string } } | { initialKind: 'meeting_document' }, ) => void } } type EditorState = | { kind: 'meeting'; current: Meeting } | { kind: 'memo'; current: MeetingMemo } | { kind: 'document'; current: MeetingDocument } | null export default function MeetingDetailScreen({ route, navigation, }: MeetingDetailScreenProps): React.ReactElement { const insets = useSafeAreaInsets() const { t, formatDate } = useI18n() const { user, session } = useAuth() const { palette } = useMobilePreferences() const styles = useMemo(() => createStyles(palette), [palette]) const refreshTimer = useRef | null>(null) const requestGeneration = useRef(0) const templateGeneration = useRef(0) const [detail, setDetail] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [conflict, setConflict] = useState(false) const [realtimeConnected, setRealtimeConnected] = useState(false) const [busyKey, setBusyKey] = useState(null) const [memoDraft, setMemoDraft] = useState('') const [expandedDocumentId, setExpandedDocumentId] = useState(null) const [editor, setEditor] = useState(null) const [editorTitle, setEditorTitle] = useState('') const [editorContent, setEditorContent] = useState('') const [editorError, setEditorError] = useState(null) const [editorBusy, setEditorBusy] = useState(false) const [selectedDocumentTemplate, setSelectedDocumentTemplate] = useState(null) const [reportTarget, setReportTarget] = useState(null) const load = useCallback(async ( pullToRefresh = false, keepCurrent = false, ): Promise => { const generation = ++requestGeneration.current if (user === null) { setDetail(null) setError(t('mobile.meetings.loginRequired')) setLoading(false) setRefreshing(false) return } if (pullToRefresh) setRefreshing(true) else if (!keepCurrent) setLoading(true) if (!keepCurrent) setError(null) try { const loaded = await getMeetingDetail(user.id, route.params.meetingId) if (generation !== requestGeneration.current) return setDetail(loaded) setError(null) setConflict(false) } catch (requestError) { if (generation !== requestGeneration.current) return setError(meetingErrorMessage(requestError, t)) } finally { if (generation === requestGeneration.current) { setLoading(false) setRefreshing(false) } } }, [route.params.meetingId, t, user]) const loadSelectedDocumentTemplate = useCallback(async ( meetingTemplateId: string | null, ): Promise => { const generation = ++templateGeneration.current if (user === null) { setSelectedDocumentTemplate(null) return } try { const library = await loadTemplateLibrary(user.id, 'meeting_document') if (generation !== templateGeneration.current) return const selectedId = meetingTemplateId ?? library.selections.meeting_document?.template_id setSelectedDocumentTemplate( library.templates.find((template) => template.id === selectedId) ?? (meetingTemplateId === null ? library.templates.find((template) => template.is_builtin) : undefined) ?? null, ) } catch (requestError) { if (generation !== templateGeneration.current) return setSelectedDocumentTemplate(null) setError(templateErrorMessage(requestError, t)) } }, [t, user]) useFocusEffect(useCallback(() => { void load(false, false) return () => { requestGeneration.current += 1 } }, [load])) useEffect(() => { void loadSelectedDocumentTemplate(detail?.meeting.template_id ?? null) return () => { templateGeneration.current += 1 } }, [detail?.meeting.template_id, loadSelectedDocumentTemplate]) useEffect(() => { if (user === null) return undefined const subscription = subscribeToMeetingDetail( route.params.meetingId, () => { if (refreshTimer.current !== null) clearTimeout(refreshTimer.current) refreshTimer.current = setTimeout(() => { void load(false, true) }, 250) }, (status) => setRealtimeConnected(status === 'SUBSCRIBED'), ) return () => { if (refreshTimer.current !== null) { clearTimeout(refreshTimer.current) refreshTimer.current = null } void subscription.unsubscribe() } }, [load, route.params.meetingId, user]) const openMeetingEditor = (): void => { if (detail === null) return setEditor({ kind: 'meeting', current: detail.meeting }) setEditorTitle(detail.meeting.title ?? '') setEditorContent('') setEditorError(null) } const openMemoEditor = (memo: MeetingMemo): void => { setEditor({ kind: 'memo', current: memo }) setEditorTitle('') setEditorContent(memo.content) setEditorError(null) } const openDocumentEditor = (document: MeetingDocument): void => { setEditor({ kind: 'document', current: document }) setEditorTitle(document.title) setEditorContent(document.content) setEditorError(null) } const closeEditor = (): void => { if (editorBusy) return setEditor(null) setEditorTitle('') setEditorContent('') setEditorError(null) } const saveEditor = async (): Promise => { if (editor === null || editorBusy) return setEditorBusy(true) setEditorError(null) try { if (editor.kind === 'meeting') { const updated = await updateMeetingTitleRevisionSafe(editor.current, editorTitle) setDetail((current) => current === null ? null : { ...current, meeting: updated }) } else if (editor.kind === 'memo') { if (user === null) return const updated = await updateMeetingMemoConflictSafe(user.id, editor.current, editorContent) setDetail((current) => current === null ? null : { ...current, memos: current.memos.map((memo) => memo.id === updated.id ? updated : memo), }) } else { const updated = await updateMeetingDocumentRevisionSafe( editor.current, editorTitle, editorContent, ) setDetail((current) => current === null ? null : { ...current, documents: current.documents.map((document) => ( document.id === updated.id ? updated : document )), }) } setEditor(null) setConflict(false) setError(null) } catch (requestError) { const isConflict = requestError instanceof MeetingServiceError && requestError.code === 'conflict' setConflict(isConflict) setEditorError(meetingErrorMessage(requestError, t)) } finally { setEditorBusy(false) } } const addMemo = async (): Promise => { if (user === null || detail === null || busyKey !== null) return setBusyKey('memo-create') setError(null) try { const created = await createMeetingMemo(user.id, detail.meeting, memoDraft) setDetail((current) => current === null ? null : { ...current, memos: [...current.memos, created].sort(compareMemos), }) setMemoDraft('') } catch (requestError) { setError(meetingErrorMessage(requestError, t)) } finally { setBusyKey(null) } } const confirmDeleteMeeting = (): void => { if (detail === null) return Alert.alert( t('mobile.meetings.deleteTitle'), t('mobile.meetings.deleteBody'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('mobile.meetings.delete'), style: 'destructive', onPress: () => { void deleteMeeting() }, }, ], ) } const deleteMeeting = async (): Promise => { if (user === null || detail === null || busyKey !== null) return setBusyKey('meeting-delete') setError(null) try { await deleteMeetingRevisionSafe(user.id, detail.meeting) navigation.goBack() } catch (requestError) { handleMutationError(requestError) setBusyKey(null) } } const confirmDeleteMemo = (memo: MeetingMemo): void => { Alert.alert( t('mobile.meetings.memoDeleteTitle'), t('mobile.meetings.memoDeleteBody'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('mobile.meetings.delete'), style: 'destructive', onPress: () => { void deleteMemo(memo) }, }, ], ) } const deleteMemo = async (memo: MeetingMemo): Promise => { if (user === null || busyKey !== null) return setBusyKey(`memo-${memo.id}`) setError(null) try { await deleteMeetingMemoConflictSafe(user.id, memo) setDetail((current) => current === null ? null : { ...current, memos: current.memos.filter((candidate) => candidate.id !== memo.id), }) } catch (requestError) { handleMutationError(requestError) } finally { setBusyKey(null) } } const confirmDeleteDocument = (document: MeetingDocument): void => { Alert.alert( t('mobile.meetings.documentDeleteTitle'), t('mobile.meetings.documentDeleteBody'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('mobile.meetings.delete'), style: 'destructive', onPress: () => { void deleteDocument(document) }, }, ], ) } const deleteDocument = async (document: MeetingDocument): Promise => { if (user === null || busyKey !== null) return setBusyKey(`document-${document.id}`) setError(null) try { await deleteMeetingDocumentRevisionSafe(user.id, document) setDetail((current) => current === null ? null : { ...current, documents: current.documents.filter((candidate) => candidate.id !== document.id), }) setExpandedDocumentId((current) => current === document.id ? null : current) } catch (requestError) { handleMutationError(requestError) } finally { setBusyKey(null) } } const handleMutationError = (requestError: unknown): void => { const isConflict = requestError instanceof MeetingServiceError && requestError.code === 'conflict' setConflict(isConflict) setError(meetingErrorMessage(requestError, t)) } const exportMeeting = async (format: MeetingDocumentFormat): Promise => { if (detail === null || busyKey !== null) return const key = `meeting-export-${format}` let prepared: PreparedPortableFile | null = null setBusyKey(key) setError(null) try { prepared = await prepareMeetingExportFile({ meeting: detail.meeting, transcripts: detail.transcripts, memos: detail.memos, documents: detail.documents, }, format) await sharePreparedPortableFile(prepared, t('mobile.meetings.exportShareTitle')) } catch { await prepared?.dispose().catch(() => undefined) setError(t('mobile.meetings.exportFailed')) } finally { setBusyKey((current) => current === key ? null : current) } } const generateSelectedDocument = async (): Promise => { if ( user === null || session === null || detail === null || selectedDocumentTemplate === null || busyKey !== null ) return const meetingId = detail.meeting.id const templateId = selectedDocumentTemplate.id setBusyKey('document-generate') setError(null) try { const idempotencyKey = await getOrCreateGenerationIdempotencyKey( user.id, meetingId, templateId, ) const title = `${detail.meeting.title?.trim() || t('mobile.meetings.untitled')} · ${selectedDocumentTemplate.name}` .slice(0, 160) const generated = await generateMeetingDocument({ accessToken: session.access_token, meetingId, templateId, idempotencyKey, title, }) setDetail((current) => { if (current === null) return null const existing = current.documents.some((document) => document.id === generated.document.id) return { ...current, documents: existing ? current.documents.map((document) => ( document.id === generated.document.id ? generated.document : document )) : [...current.documents, generated.document], } }) setExpandedDocumentId(generated.document.id) await clearGenerationIdempotencyKey(user.id, meetingId, templateId).catch(() => undefined) } catch (requestError) { setError(templateErrorMessage(requestError, t)) } finally { setBusyKey(null) } } const transcript = detail === null ? null : buildMeetingTranscriptView(detail.meeting, detail.transcripts) return ( ‹ {t('mobile.meetings.back')} {t('mobile.meetings.detail').toUpperCase()} {loading && detail === null ? ( {t('mobile.meetings.loading')} ) : detail === null ? ( {error ?? t('mobile.meetings.errorNotFound')} { void load(false, false) }} testID="meeting-detail-retry" /> ) : ( { void load(true, false) }} tintColor={palette.accent.main} colors={[palette.accent.main]} /> )} testID="meeting-detail-scroll" > {error !== null && ( {error} {conflict && ( { void load(false, false) }} testID="meeting-detail-reload" /> )} )} {!realtimeConnected && detail.meeting.status === 'recording' && ( {t('mobile.meetings.realtimeWaiting')} )} {detail.meeting.title ?? t('mobile.meetings.untitled')} {formatDate(new Date(detail.meeting.started_at), { dateStyle: 'medium', timeStyle: 'short', })} {detail.meeting.duration_ms !== null && ( {t('mobile.meetings.duration')}: {formatElapsedMs(detail.meeting.duration_ms)} )} {detail.meeting.error_message !== null && ( {detail.meeting.error_message} )} {detail.meeting.user_id === user?.id && ( navigation.navigate('Main', { screen: 'Record', params: { meetingId: detail.meeting.id, meetingTitle: detail.meeting.title ?? undefined, meetingLanguage: detail.meeting.language ?? undefined, }, })} testID="meeting-detail-record" /> )} {detail.meeting.user_id === user?.id && ( )}
{t('mobile.meetings.exportDescription')} { void exportMeeting('md') }} style={styles.flexButton} testID="meeting-export-md" /> { void exportMeeting('txt') }} style={styles.flexButton} testID="meeting-export-txt" /> { void exportMeeting('pdf') }} style={styles.flexButton} testID="meeting-export-pdf" /> { void exportMeeting('docx') }} style={styles.flexButton} testID="meeting-export-docx" />
{transcript?.source === 'segments' ? detail.transcripts.map((segment) => ( {formatElapsedMs(segment.timestamp_ms)} {segment.speaker !== null && ( {segment.speaker} )} {segment.edited && ( {t('mobile.meetings.edited')} )} {segment.text} )) : transcript?.source !== 'empty' ? ( {transcript?.text} ) : ( {t('mobile.meetings.transcriptEmpty')} )}
{detail.meeting.minutes_markdown?.trim() ? ( {detail.meeting.minutes_markdown} ) : detail.meeting.minutes_json !== null ? ( {formatMinutesJson(detail.meeting.minutes_json)} ) : ( {t('mobile.meetings.summaryEmpty')} {t('mobile.meetings.generationUnavailable')} )}
{detail.memos.length === 0 ? ( {t('mobile.meetings.memosEmpty')} ) : detail.memos.map((memo) => { const owned = memo.user_id === user?.id const busy = busyKey === `memo-${memo.id}` return ( {formatElapsedMs(memo.timestamp_ms)} {memo.content} {owned && ( openMemoEditor(memo)} testID={`meeting-memo-edit-${memo.id}`} /> confirmDeleteMemo(memo)} testID={`meeting-memo-delete-${memo.id}`} /> {busy && } )} ) })} { void addMemo() }} testID="meeting-memo-add" />
{selectedDocumentTemplate === null ? t('mobile.meetings.documentTemplateMissing') : t('mobile.meetings.documentTemplateSelected', { name: selectedDocumentTemplate.name })} navigation.navigate('Templates', { initialKind: 'meeting_document' })} style={styles.flexButton} testID="meeting-manage-templates" /> { void generateSelectedDocument() }} style={styles.flexButton} testID="meeting-generate-document" /> {detail.documents.length === 0 ? ( {t('mobile.meetings.documentsEmpty')} {t('mobile.meetings.generationUnavailable')} ) : detail.documents.map((document) => { const expanded = expandedDocumentId === document.id const owned = document.user_id === user?.id const busy = busyKey === `document-${document.id}` return ( setExpandedDocumentId(expanded ? null : document.id)} style={styles.documentHeader} testID={`meeting-document-toggle-${document.id}`} > {document.title} {document.template_type} · {formatDate(new Date(document.updated_at), { dateStyle: 'medium' })} {expanded ? '−' : '+'} {expanded && ( {document.content || '—'} openDocumentEditor(document)} style={styles.flexButton} testID={`meeting-document-edit-${document.id}`} /> {owned && document.llm_model !== null && ( setReportTarget({ sourceType: 'meeting_document', generationId: document.id, snapshot: document.content.trim().slice(0, 4_000), })} style={styles.flexButton} testID={`meeting-document-report-${document.id}`} /> )} {owned && ( confirmDeleteDocument(document)} style={styles.flexButton} testID={`meeting-document-delete-${document.id}`} /> )} )} ) })}
)} {editor?.kind === 'meeting' ? t('mobile.meetings.editTitle') : editor?.kind === 'memo' ? t('mobile.meetings.memoEditTitle') : t('mobile.meetings.documentEditTitle')} {(editor?.kind === 'meeting' || editor?.kind === 'document') && ( )} {(editor?.kind === 'memo' || editor?.kind === 'document') && ( )} {editorError !== null && ( {editorError} {conflict && ( {t('mobile.meetings.conflictNotice')} )} )} { void saveEditor() }} style={styles.flexButton} testID="meeting-detail-editor-save" /> setReportTarget(null)} />
) } function Section({ title, count, children, }: { title: string count?: number children: React.ReactNode }): React.ReactElement { return ( {title.toUpperCase()} {count !== undefined && ( {String(count)} )} {children} ) } function MeetingStatus({ status }: { status: Meeting['status'] }): React.ReactElement { const { t } = useI18n() const { palette } = useMobilePreferences() const label = status === 'recording' ? t('mobile.meetings.statusRecording') : status === 'processing' ? t('mobile.meetings.statusProcessing') : status === 'completed' ? t('mobile.meetings.statusCompleted') : t('mobile.meetings.statusError') const color = status === 'completed' ? palette.tag.green : status === 'error' ? palette.tag.red : palette.tag.orange return ( {label} ) } function editorCanSave( editor: EditorState, title: string, content: string, ): boolean { if (editor === null) return false if (editor.kind === 'meeting') return title.trim().length > 0 if (editor.kind === 'memo') return content.trim().length > 0 return title.trim().length > 0 } function compareMemos(left: MeetingMemo, right: MeetingMemo): number { const byTimestamp = left.timestamp_ms - right.timestamp_ms return byTimestamp !== 0 ? byTimestamp : left.created_at.localeCompare(right.created_at) } export function formatElapsedMs(value: number): string { const totalSeconds = Math.max(0, Math.floor(value / 1_000)) const hours = Math.floor(totalSeconds / 3_600) const minutes = Math.floor((totalSeconds % 3_600) / 60) const seconds = totalSeconds % 60 const base = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` return hours > 0 ? `${hours}:${base}` : base } export function formatMinutesJson(value: Record): string { try { return JSON.stringify(value, null, 2) } catch { return '{}' } } type Translate = ReturnType['t'] function meetingErrorMessage(error: unknown, t: Translate): string { if (!(error instanceof MeetingServiceError)) return t('mobile.meetings.errorServer') if (error.code === 'auth') return t('mobile.meetings.loginRequired') if (error.code === 'conflict') return t('mobile.meetings.errorConflict') if (error.code === 'forbidden') return t('mobile.meetings.errorForbidden') if (error.code === 'network') return t('mobile.meetings.errorNetwork') if (error.code === 'not-found') return t('mobile.meetings.errorNotFound') if (error.code === 'validation') return t('mobile.meetings.errorValidation') return t('mobile.meetings.errorServer') } function templateErrorMessage(error: unknown, t: Translate): string { if (!(error instanceof TemplateServiceError)) return t('mobile.templates.error.server') if (error.code === 'auth') return t('mobile.templates.error.auth') if (error.code === 'forbidden') return t('mobile.templates.error.forbidden') if (error.code === 'in-progress') return t('mobile.templates.error.inProgress') if (error.code === 'network') return t('mobile.templates.error.network') if (error.code === 'not-found') return t('mobile.templates.error.notFound') if (error.code === 'provider') return t('mobile.templates.error.provider') if (error.code === 'quota') return t('mobile.templates.error.quota') if (error.code === 'validation') return t('mobile.templates.error.validation') return t('mobile.templates.error.server') } type Palette = ReturnType['palette'] const stylesStatic = StyleSheet.create({ section: { gap: 12 }, sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, sectionTitle: { letterSpacing: 2.4 }, status: { minHeight: 30, justifyContent: 'center', paddingHorizontal: 9, borderWidth: 1, borderRadius: 999, }, }) function createStyles(palette: Palette): ReturnType { return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, header: { minHeight: 72, paddingHorizontal: 16, paddingBottom: 10, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: palette.border.subtle, }, headerButton: { minWidth: 88, minHeight: 48, justifyContent: 'center' }, headerLabel: { letterSpacing: 2 }, headerSpacer: { width: 88 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 14, padding: 32 }, centerText: { textAlign: 'center' }, content: { padding: 20, gap: 14 }, errorBanner: { padding: 12, flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderColor: palette.tag.red, borderRadius: 10, backgroundColor: palette.bg.card, }, errorText: { flex: 1 }, warningBanner: { padding: 12, borderRadius: 10, backgroundColor: palette.bg.inset, borderWidth: 1, borderColor: palette.border.subtle, }, heroCard: { gap: 9 }, heroTop: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, heroTitle: { flex: 1 }, actions: { flexDirection: 'row', gap: 10, marginTop: 6 }, flexButton: { flex: 1 }, transcriptSegment: { gap: 6, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: palette.border.subtle, }, segmentMeta: { flexDirection: 'row', alignItems: 'center', gap: 10 }, readingText: { lineHeight: 23 }, codeText: { lineHeight: 21, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', }, emptySection: { gap: 6 }, memoRow: { paddingVertical: 10, gap: 8, borderBottomWidth: 1, borderBottomColor: palette.border.subtle, }, memoBody: { gap: 5 }, rowActions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }, memoInput: { minHeight: 96, paddingHorizontal: 12, paddingVertical: 10, borderWidth: 1, borderColor: palette.border.default, borderRadius: 10, backgroundColor: palette.bg.inset, color: palette.text.primary, fontSize: 14, }, documentRow: { borderWidth: 1, borderColor: palette.border.subtle, borderRadius: 10, overflow: 'hidden', }, documentHeader: { minHeight: 58, padding: 12, flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: palette.bg.inset, }, documentTitleWrap: { flex: 1, gap: 4 }, documentContent: { padding: 12, gap: 14 }, modalBackdrop: { flex: 1, padding: 20, justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.62)', }, modalCard: { gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' }, editorInput: { minHeight: 52, paddingHorizontal: 12, paddingVertical: 10, borderWidth: 1, borderColor: palette.border.strong, borderRadius: 10, backgroundColor: palette.bg.inset, color: palette.text.primary, fontSize: 14, }, editorMultiline: { minHeight: 220, maxHeight: 360 }, editorError: { gap: 5 }, }) }