1172 lines
43 KiB
TypeScript
1172 lines
43 KiB
TypeScript
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 },
|
||
})
|
||
}
|