d3ro-voice/apps/mobile-rn/src/screens/MeetingDetailScreen.tsx
2026-08-29 18:33:45 +09:00

1172 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ReturnType<typeof setTimeout> | null>(null)
const requestGeneration = useRef(0)
const templateGeneration = useRef(0)
const [detail, setDetail] = useState<MeetingDetail | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState(false)
const [realtimeConnected, setRealtimeConnected] = useState(false)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [memoDraft, setMemoDraft] = useState('')
const [expandedDocumentId, setExpandedDocumentId] = useState<string | null>(null)
const [editor, setEditor] = useState<EditorState>(null)
const [editorTitle, setEditorTitle] = useState('')
const [editorContent, setEditorContent] = useState('')
const [editorError, setEditorError] = useState<string | null>(null)
const [editorBusy, setEditorBusy] = useState(false)
const [selectedDocumentTemplate, setSelectedDocumentTemplate] = useState<UserTemplate | null>(null)
const [reportTarget, setReportTarget] = useState<ContentReportTarget | null>(null)
const load = useCallback(async (
pullToRefresh = false,
keepCurrent = false,
): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + 8 }]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t('mobile.meetings.back')}
onPress={navigation.goBack}
style={styles.headerButton}
testID="meeting-detail-back"
>
<ThemeText color="accent"> {t('mobile.meetings.back')}</ThemeText>
</Pressable>
<ThemeText variant="label" color="muted" style={styles.headerLabel}>
{t('mobile.meetings.detail').toUpperCase()}
</ThemeText>
<View style={styles.headerSpacer} />
</View>
{loading && detail === null ? (
<View style={styles.center} testID="meeting-detail-loading">
<ActivityIndicator size="large" color={palette.accent.main} />
<ThemeText color="muted">{t('mobile.meetings.loading')}</ThemeText>
</View>
) : detail === null ? (
<View style={styles.center} testID="meeting-detail-error">
<ThemeText color="danger" style={styles.centerText}>
{error ?? t('mobile.meetings.errorNotFound')}
</ThemeText>
<ThemeButton
label={t('mobile.meetings.retry')}
variant="secondary"
onPress={() => { void load(false, false) }}
testID="meeting-detail-retry"
/>
</View>
) : (
<ScrollView
contentContainerStyle={[styles.content, { paddingBottom: insets.bottom + 48 }]}
keyboardShouldPersistTaps="handled"
refreshControl={(
<RefreshControl
refreshing={refreshing}
onRefresh={() => { void load(true, false) }}
tintColor={palette.accent.main}
colors={[palette.accent.main]}
/>
)}
testID="meeting-detail-scroll"
>
{error !== null && (
<View style={styles.errorBanner} testID="meeting-detail-message">
<ThemeText color="danger" style={styles.errorText}>{error}</ThemeText>
{conflict && (
<ThemeButton
label={t('mobile.meetings.reloadLatest')}
variant="quiet"
onPress={() => { void load(false, false) }}
testID="meeting-detail-reload"
/>
)}
</View>
)}
{!realtimeConnected && detail.meeting.status === 'recording' && (
<View style={styles.warningBanner} testID="meeting-realtime-warning">
<ThemeText variant="small" color="muted">
{t('mobile.meetings.realtimeWaiting')}
</ThemeText>
</View>
)}
<ThemeCard style={styles.heroCard}>
<View style={styles.heroTop}>
<ThemeText variant="title" style={styles.heroTitle} testID="meeting-detail-title">
{detail.meeting.title ?? t('mobile.meetings.untitled')}
</ThemeText>
<MeetingStatus status={detail.meeting.status} />
</View>
<ThemeText variant="small" color="muted">
{formatDate(new Date(detail.meeting.started_at), {
dateStyle: 'medium',
timeStyle: 'short',
})}
</ThemeText>
{detail.meeting.duration_ms !== null && (
<ThemeText variant="small" color="muted">
{t('mobile.meetings.duration')}: {formatElapsedMs(detail.meeting.duration_ms)}
</ThemeText>
)}
{detail.meeting.error_message !== null && (
<ThemeText color="danger" selectable>{detail.meeting.error_message}</ThemeText>
)}
{detail.meeting.user_id === user?.id && (
<ThemeButton
label={detail.meeting.status === 'processing'
? t('mobile.meetings.processingAudio')
: detail.meeting.status === 'completed'
? t('mobile.meetings.recordAgain')
: t('mobile.meetings.recordNow')}
disabled={busyKey !== null || detail.meeting.status === 'processing'}
onPress={() => navigation.navigate('Main', {
screen: 'Record',
params: {
meetingId: detail.meeting.id,
meetingTitle: detail.meeting.title ?? undefined,
meetingLanguage: detail.meeting.language ?? undefined,
},
})}
testID="meeting-detail-record"
/>
)}
<View style={styles.actions}>
<ThemeButton
label={t('mobile.meetings.edit')}
variant="secondary"
disabled={busyKey !== null}
onPress={openMeetingEditor}
style={styles.flexButton}
testID="meeting-detail-edit"
/>
{detail.meeting.user_id === user?.id && (
<ThemeButton
label={busyKey === 'meeting-delete'
? t('mobile.meetings.deleting')
: t('mobile.meetings.delete')}
variant="danger"
disabled={busyKey !== null}
onPress={confirmDeleteMeeting}
style={styles.flexButton}
testID="meeting-detail-delete"
/>
)}
</View>
</ThemeCard>
<Section title={t('mobile.meetings.export')}>
<ThemeText variant="small" color="muted">
{t('mobile.meetings.exportDescription')}
</ThemeText>
<View style={styles.actions}>
<ThemeButton
label={busyKey === 'meeting-export-md'
? t('mobile.meetings.exporting')
: t('mobile.meetings.exportMarkdown')}
variant="secondary"
disabled={busyKey !== null}
onPress={() => { void exportMeeting('md') }}
style={styles.flexButton}
testID="meeting-export-md"
/>
<ThemeButton
label={busyKey === 'meeting-export-txt'
? t('mobile.meetings.exporting')
: t('mobile.meetings.exportText')}
variant="secondary"
disabled={busyKey !== null}
onPress={() => { void exportMeeting('txt') }}
style={styles.flexButton}
testID="meeting-export-txt"
/>
</View>
<View style={styles.actions}>
<ThemeButton
label={busyKey === 'meeting-export-pdf'
? t('mobile.meetings.exporting')
: t('mobile.meetings.exportPdf')}
variant="secondary"
disabled={busyKey !== null}
onPress={() => { void exportMeeting('pdf') }}
style={styles.flexButton}
testID="meeting-export-pdf"
/>
<ThemeButton
label={busyKey === 'meeting-export-docx'
? t('mobile.meetings.exporting')
: t('mobile.meetings.exportDocx')}
variant="secondary"
disabled={busyKey !== null}
onPress={() => { void exportMeeting('docx') }}
style={styles.flexButton}
testID="meeting-export-docx"
/>
</View>
</Section>
<Section title={t('mobile.meetings.transcript')} count={detail.transcripts.length}>
{transcript?.source === 'segments' ? detail.transcripts.map((segment) => (
<View key={segment.id} style={styles.transcriptSegment} testID={`meeting-transcript-${segment.id}`}>
<View style={styles.segmentMeta}>
<ThemeText variant="label" color="accent">
{formatElapsedMs(segment.timestamp_ms)}
</ThemeText>
{segment.speaker !== null && (
<ThemeText variant="label" color="muted">{segment.speaker}</ThemeText>
)}
{segment.edited && (
<ThemeText variant="label" color="muted">
{t('mobile.meetings.edited')}
</ThemeText>
)}
</View>
<ThemeText selectable style={styles.readingText}>{segment.text}</ThemeText>
</View>
)) : transcript?.source !== 'empty' ? (
<ThemeText selectable style={styles.readingText}>{transcript?.text}</ThemeText>
) : (
<ThemeText color="muted" testID="meeting-transcript-empty">
{t('mobile.meetings.transcriptEmpty')}
</ThemeText>
)}
</Section>
<Section title={t('mobile.meetings.summary')}>
{detail.meeting.minutes_markdown?.trim() ? (
<ThemeText selectable style={styles.readingText} testID="meeting-summary-markdown">
{detail.meeting.minutes_markdown}
</ThemeText>
) : detail.meeting.minutes_json !== null ? (
<ThemeText selectable style={styles.codeText} testID="meeting-summary-json">
{formatMinutesJson(detail.meeting.minutes_json)}
</ThemeText>
) : (
<View style={styles.emptySection}>
<ThemeText color="muted">{t('mobile.meetings.summaryEmpty')}</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.meetings.generationUnavailable')}
</ThemeText>
</View>
)}
</Section>
<Section title={t('mobile.meetings.memos')} count={detail.memos.length}>
{detail.memos.length === 0 ? (
<ThemeText color="muted" testID="meeting-memos-empty">
{t('mobile.meetings.memosEmpty')}
</ThemeText>
) : detail.memos.map((memo) => {
const owned = memo.user_id === user?.id
const busy = busyKey === `memo-${memo.id}`
return (
<View key={memo.id} style={styles.memoRow} testID={`meeting-memo-${memo.id}`}>
<View style={styles.memoBody}>
<ThemeText variant="label" color="accent">
{formatElapsedMs(memo.timestamp_ms)}
</ThemeText>
<ThemeText selectable>{memo.content}</ThemeText>
</View>
{owned && (
<View style={styles.rowActions}>
<ThemeButton
label={t('mobile.meetings.edit')}
variant="quiet"
disabled={busy}
onPress={() => openMemoEditor(memo)}
testID={`meeting-memo-edit-${memo.id}`}
/>
<ThemeButton
label={t('mobile.meetings.delete')}
variant="quiet"
disabled={busy}
onPress={() => confirmDeleteMemo(memo)}
testID={`meeting-memo-delete-${memo.id}`}
/>
{busy && <ActivityIndicator color={palette.accent.main} />}
</View>
)}
</View>
)
})}
<TextInput
accessibilityLabel={t('mobile.meetings.memoPlaceholder')}
value={memoDraft}
onChangeText={setMemoDraft}
placeholder={t('mobile.meetings.memoPlaceholder')}
placeholderTextColor={palette.text.muted}
multiline
maxLength={4_001}
textAlignVertical="top"
style={styles.memoInput}
testID="meeting-memo-input"
/>
<ThemeButton
label={busyKey === 'memo-create'
? t('mobile.meetings.saving')
: t('mobile.meetings.memoAdd')}
disabled={busyKey !== null || memoDraft.trim().length === 0}
onPress={() => { void addMemo() }}
testID="meeting-memo-add"
/>
</Section>
<Section title={t('mobile.meetings.documents')} count={detail.documents.length}>
<ThemeText variant="small" color="muted" testID="meeting-document-template-status">
{selectedDocumentTemplate === null
? t('mobile.meetings.documentTemplateMissing')
: t('mobile.meetings.documentTemplateSelected', { name: selectedDocumentTemplate.name })}
</ThemeText>
<View style={styles.actions}>
<ThemeButton
label={t('mobile.meetings.manageTemplates')}
variant="secondary"
disabled={busyKey !== null}
onPress={() => navigation.navigate('Templates', { initialKind: 'meeting_document' })}
style={styles.flexButton}
testID="meeting-manage-templates"
/>
<ThemeButton
label={busyKey === 'document-generate'
? t('mobile.meetings.generatingDocument')
: t('mobile.meetings.generateDocument')}
disabled={busyKey !== null || selectedDocumentTemplate === null}
onPress={() => { void generateSelectedDocument() }}
style={styles.flexButton}
testID="meeting-generate-document"
/>
</View>
{detail.documents.length === 0 ? (
<View style={styles.emptySection} testID="meeting-documents-empty">
<ThemeText color="muted">{t('mobile.meetings.documentsEmpty')}</ThemeText>
<ThemeText variant="small" color="muted">
{t('mobile.meetings.generationUnavailable')}
</ThemeText>
</View>
) : detail.documents.map((document) => {
const expanded = expandedDocumentId === document.id
const owned = document.user_id === user?.id
const busy = busyKey === `document-${document.id}`
return (
<View key={document.id} style={styles.documentRow} testID={`meeting-document-${document.id}`}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
accessibilityLabel={document.title}
onPress={() => setExpandedDocumentId(expanded ? null : document.id)}
style={styles.documentHeader}
testID={`meeting-document-toggle-${document.id}`}
>
<View style={styles.documentTitleWrap}>
<ThemeText>{document.title}</ThemeText>
<ThemeText variant="label" color="muted">
{document.template_type} · {formatDate(new Date(document.updated_at), { dateStyle: 'medium' })}
</ThemeText>
</View>
<ThemeText color="accent">{expanded ? '' : '+'}</ThemeText>
</Pressable>
{expanded && (
<View style={styles.documentContent}>
<ThemeText selectable style={styles.codeText}>{document.content || '—'}</ThemeText>
<View style={styles.rowActions}>
<ThemeButton
label={t('mobile.meetings.edit')}
variant="secondary"
disabled={busy}
onPress={() => openDocumentEditor(document)}
style={styles.flexButton}
testID={`meeting-document-edit-${document.id}`}
/>
{owned && document.llm_model !== null && (
<ThemeButton
label={t('mobile.report.action')}
variant="quiet"
disabled={busy}
onPress={() => 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 && (
<ThemeButton
label={t('mobile.meetings.delete')}
variant="danger"
disabled={busy}
onPress={() => confirmDeleteDocument(document)}
style={styles.flexButton}
testID={`meeting-document-delete-${document.id}`}
/>
)}
</View>
</View>
)}
</View>
)
})}
</Section>
</ScrollView>
)}
<Modal
visible={editor !== null}
transparent
animationType="fade"
onRequestClose={closeEditor}
statusBarTranslucent
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalBackdrop}
>
<ThemeCard style={styles.modalCard} testID="meeting-detail-editor">
<ThemeText variant="title" accessibilityRole="header">
{editor?.kind === 'meeting'
? t('mobile.meetings.editTitle')
: editor?.kind === 'memo'
? t('mobile.meetings.memoEditTitle')
: t('mobile.meetings.documentEditTitle')}
</ThemeText>
{(editor?.kind === 'meeting' || editor?.kind === 'document') && (
<TextInput
accessibilityLabel={t('mobile.meetings.titleLabel')}
value={editorTitle}
onChangeText={setEditorTitle}
placeholder={t('mobile.meetings.titlePlaceholder')}
placeholderTextColor={palette.text.muted}
maxLength={161}
style={styles.editorInput}
testID="meeting-detail-editor-title"
/>
)}
{(editor?.kind === 'memo' || editor?.kind === 'document') && (
<TextInput
accessibilityLabel={editor.kind === 'memo'
? t('mobile.meetings.memoPlaceholder')
: t('mobile.meetings.documentContent')}
value={editorContent}
onChangeText={setEditorContent}
placeholder={editor.kind === 'memo'
? t('mobile.meetings.memoPlaceholder')
: t('mobile.meetings.documentContent')}
placeholderTextColor={palette.text.muted}
multiline
maxLength={editor.kind === 'memo' ? 4_001 : 100_001}
textAlignVertical="top"
style={[styles.editorInput, styles.editorMultiline]}
testID="meeting-detail-editor-content"
/>
)}
{editorError !== null && (
<View style={styles.editorError}>
<ThemeText color="danger" testID="meeting-detail-editor-error">
{editorError}
</ThemeText>
{conflict && (
<ThemeText variant="small" color="muted">
{t('mobile.meetings.conflictNotice')}
</ThemeText>
)}
</View>
)}
<View style={styles.actions}>
<ThemeButton
label={t('common.cancel')}
variant="secondary"
disabled={editorBusy}
onPress={closeEditor}
style={styles.flexButton}
/>
<ThemeButton
label={editorBusy
? t('mobile.meetings.saving')
: t('mobile.meetings.save')}
disabled={editorBusy || !editorCanSave(editor, editorTitle, editorContent)}
onPress={() => { void saveEditor() }}
style={styles.flexButton}
testID="meeting-detail-editor-save"
/>
</View>
</ThemeCard>
</KeyboardAvoidingView>
</Modal>
<ContentReportSheet
visible={reportTarget !== null}
accessToken={session?.access_token ?? null}
target={reportTarget}
onRequestClose={() => setReportTarget(null)}
/>
</View>
)
}
function Section({
title,
count,
children,
}: {
title: string
count?: number
children: React.ReactNode
}): React.ReactElement {
return (
<ThemeCard style={stylesStatic.section}>
<View style={stylesStatic.sectionHeader}>
<ThemeText variant="label" color="muted" style={stylesStatic.sectionTitle}>
{title.toUpperCase()}
</ThemeText>
{count !== undefined && (
<ThemeText variant="label" color="muted">{String(count)}</ThemeText>
)}
</View>
{children}
</ThemeCard>
)
}
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 (
<View style={[stylesStatic.status, { borderColor: color }]}>
<ThemeText variant="label" style={{ color }}>{label}</ThemeText>
</View>
)
}
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, unknown>): string {
try {
return JSON.stringify(value, null, 2)
} catch {
return '{}'
}
}
type Translate = ReturnType<typeof useI18n>['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<typeof useMobilePreferences>['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<typeof StyleSheet.create> {
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 },
})
}