feat(i18n): extract remaining screens into en/zh-Hans catalogs; full Simplified Chinese coverage (closes #68) (#103)
Extends the i18n infra from #97 (Settings screen only) to the rest of the app: session chat screen, connection add/edit/list screens, sessions list (incl. directory grouping from #96), navigation titles, notifications category metadata, error boundary, telemetry consent modal, auth gate, and every chat UI component (permission/question prompts, status indicator, model/variant pickers, directory switcher/browser, reasoning block, tool call card, session info). - 244 new keys added to en.json/zh-Hans.json with reviewed, natural Simplified Chinese (not machine-garbage), keeping key sets identical. - User content, server URLs, code snippets, log/error-detail text, and diagnostics-classify.ts (pure dependency-free module feeding Sentry/ support reports) are intentionally left untranslated per scope. - Interpolation used for counts/names (e.g. reconnect attempt, files count, connection name in delete confirmations); categoryMeta/ CONNECTION_TYPES switched to labelKey indirection since they're module-level constants evaluated before i18next is guaranteed ready. - Added src/lib/i18n/catalog-parity.test.ts (node --test) asserting en.json/zh-Hans.json expose identical key sets and no empty values, to catch future locale drift. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { Tabs } from "expo-router"
|
||||
import { useColorScheme } from "react-native"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@@ -24,21 +26,21 @@ export default function TabLayout() {
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Sessions",
|
||||
title: t("nav.sessionsTab"),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="chatbubbles-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="connections"
|
||||
options={{
|
||||
title: "Connections",
|
||||
title: t("nav.connectionsTab"),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="server-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
title: t("nav.settingsTab"),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { View, Text, FlatList, TouchableOpacity, StyleSheet, useColorScheme, Alert } from "react-native"
|
||||
import { router } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import { useSettings } from "../../src/stores/settings"
|
||||
import type { ServerConnection } from "../../src/lib/types"
|
||||
@@ -22,13 +23,14 @@ function ConnectionItem({
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const typeIcon = connection.type === "local" ? "wifi" : connection.type === "tunnel" ? "globe" : "cloud"
|
||||
|
||||
const handleLongPress = () => {
|
||||
Alert.alert(connection.name, "What would you like to do?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Edit", onPress: onEdit },
|
||||
{ text: "Delete", style: "destructive", onPress: onDelete },
|
||||
Alert.alert(connection.name, t("connectionsList.actionsAlert.message"), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("connectionsList.actionsAlert.edit"), onPress: onEdit },
|
||||
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
|
||||
])
|
||||
}
|
||||
|
||||
@@ -60,7 +62,9 @@ function ConnectionItem({
|
||||
</Text>
|
||||
{isActive && (
|
||||
<View style={[styles.activeBadge, isDark && styles.activeBadgeDark]}>
|
||||
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>Active</Text>
|
||||
<Text style={[styles.activeBadgeText, isDark && styles.activeBadgeTextDark]}>
|
||||
{t("connectionsList.activeBadge")}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -72,7 +76,7 @@ function ConnectionItem({
|
||||
</Text>
|
||||
{connection.lastConnected && (
|
||||
<Text style={[styles.connectionMeta, isDark && styles.metaDark]}>
|
||||
Last connected: {new Date(connection.lastConnected).toLocaleDateString()}
|
||||
{t("connectionsList.lastConnected", { date: new Date(connection.lastConnected).toLocaleDateString() })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
@@ -86,19 +90,24 @@ function ConnectionItem({
|
||||
export default function ConnectionsScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { connections, activeConnection, setActiveConnection, removeConnection } = useConnections()
|
||||
const { pageSize, setPageSize } = useSettings()
|
||||
|
||||
const handleDelete = (connection: ServerConnection) => {
|
||||
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => removeConnection(connection.id),
|
||||
},
|
||||
])
|
||||
Alert.alert(
|
||||
t("connection.edit.alerts.deleteTitle"),
|
||||
t("connection.edit.alerts.deleteMessage", { name: connection.name }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: () => removeConnection(connection.id),
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -119,24 +128,28 @@ export default function ConnectionsScreen() {
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connections</Text>
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("connectionsList.empty.title")}</Text>
|
||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||
Add a connection to your OpenCode server
|
||||
{t("connectionsList.empty.subtitle")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<View style={[styles.header, isDark && styles.headerDark]}>
|
||||
<Text style={[styles.headerText, isDark && styles.metaDark]}>Tap to switch, long press for options</Text>
|
||||
<Text style={[styles.headerText, isDark && styles.metaDark]}>{t("connectionsList.header")}</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<View style={[styles.settingsSection, isDark && styles.settingsSectionDark]}>
|
||||
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>Preferences</Text>
|
||||
<Text style={[styles.settingsTitle, isDark && styles.textDark]}>
|
||||
{t("connectionsList.preferences.title")}
|
||||
</Text>
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingLabel}>
|
||||
<Ionicons name="layers-outline" size={18} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.settingText, isDark && styles.textDark]}>Messages per page</Text>
|
||||
<Text style={[styles.settingText, isDark && styles.textDark]}>
|
||||
{t("connectionsList.preferences.pageSizeLabel")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.pagePicker}>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
@@ -164,7 +177,7 @@ export default function ConnectionsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.settingHint, isDark && styles.metaDark]}>
|
||||
How many messages to load when opening a session. Lower = faster.
|
||||
{t("connectionsList.preferences.pageSizeHint")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "react-native"
|
||||
import { router, useFocusEffect } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSessions } from "../../src/stores/sessions"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import { useEvents } from "../../src/stores/events"
|
||||
@@ -27,15 +28,15 @@ import { DirectorySwitcher, DirectoryBrowserSheet } from "../../src/components/c
|
||||
import { groupByDirectory } from "../../src/lib/session-grouping"
|
||||
import { nameOf } from "../../src/lib/path-utils"
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
function formatTime(timestamp: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000) return "Just now"
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`
|
||||
if (diff < 60000) return t("sessionsList.time.justNow")
|
||||
if (diff < 3600000) return t("sessionsList.time.minutesAgo", { count: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t("sessionsList.time.hoursAgo", { count: Math.floor(diff / 3600000) })
|
||||
if (diff < 604800000) return t("sessionsList.time.daysAgo", { count: Math.floor(diff / 86400000) })
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
@@ -51,6 +52,8 @@ function SessionItem({
|
||||
onRename: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onPress = () => {
|
||||
router.push({
|
||||
pathname: `/session/[id]`,
|
||||
@@ -59,10 +62,10 @@ function SessionItem({
|
||||
}
|
||||
|
||||
const onLongPress = () => {
|
||||
Alert.alert(session.title || "Untitled Session", undefined, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Rename", onPress: onRename },
|
||||
{ text: "Delete", style: "destructive", onPress: onDelete },
|
||||
Alert.alert(session.title || t("sessionsList.untitledSession"), undefined, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("sessionsList.actions.rename"), onPress: onRename },
|
||||
{ text: t("common.delete"), style: "destructive", onPress: onDelete },
|
||||
])
|
||||
}
|
||||
|
||||
@@ -79,16 +82,17 @@ function SessionItem({
|
||||
<View style={styles.sessionContent}>
|
||||
<View style={styles.sessionHeader}>
|
||||
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
|
||||
{session.title || "Untitled Session"}
|
||||
{session.title || t("sessionsList.untitledSession")}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.sessionMetaRow}>
|
||||
<Text style={[styles.sessionMeta, isDark && styles.metaDark]}>
|
||||
{formatTime(session.time.updated)}
|
||||
{formatTime(session.time.updated, t)}
|
||||
{/* summary is always present but files defaults to 0 until the
|
||||
server populates it — only show the count when it's meaningful,
|
||||
matching the SessionInfo panel's `summary.files > 0` guard (#55) */}
|
||||
{session.summary && session.summary.files > 0 && ` · ${session.summary.files} files`}
|
||||
{session.summary && session.summary.files > 0 &&
|
||||
` · ${t("sessionsList.filesCount", { count: session.summary.files })}`}
|
||||
</Text>
|
||||
{shortDir && (
|
||||
<View style={styles.sessionDirBadge}>
|
||||
@@ -153,6 +157,7 @@ function getShortPath(
|
||||
export default function SessionsScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const { t } = useTranslation()
|
||||
const [showNewSession, setShowNewSession] = useState(false)
|
||||
const [customDir, setCustomDir] = useState("")
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
@@ -277,31 +282,35 @@ export default function SessionsScreen() {
|
||||
loadSessions()
|
||||
} catch (err) {
|
||||
console.error("Rename failed:", err)
|
||||
Alert.alert("Rename failed", "Could not rename the session. Please try again.")
|
||||
Alert.alert(t("sessionsList.alerts.renameFailedTitle"), t("sessionsList.alerts.renameFailedMessage"))
|
||||
} finally {
|
||||
renamingInFlight.current = false
|
||||
}
|
||||
}, [renaming, renameText, client, clientForDirectory, loadSessions])
|
||||
}, [renaming, renameText, client, clientForDirectory, loadSessions, t])
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(session: Session) => {
|
||||
Alert.alert("Delete Session", `Delete "${session.title || "Untitled Session"}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err)
|
||||
Alert.alert("Delete failed", "Could not delete the session. Please try again.")
|
||||
}
|
||||
Alert.alert(
|
||||
t("sessionsList.alerts.deleteTitle"),
|
||||
t("sessionsList.alerts.deleteMessage", { title: session.title || t("sessionsList.untitledSession") }),
|
||||
[
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err)
|
||||
Alert.alert(t("sessionsList.alerts.deleteFailedTitle"), t("sessionsList.alerts.deleteFailedMessage"))
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
],
|
||||
)
|
||||
},
|
||||
[deleteSession],
|
||||
[deleteSession, t],
|
||||
)
|
||||
|
||||
const onCreateSession = async () => {
|
||||
@@ -341,7 +350,7 @@ export default function SessionsScreen() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create session in directory:", error)
|
||||
Alert.alert("Error", "Failed to create session in that directory.")
|
||||
Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage"))
|
||||
setIsCreating(false)
|
||||
}
|
||||
return
|
||||
@@ -413,14 +422,18 @@ export default function SessionsScreen() {
|
||||
return (
|
||||
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
||||
<Ionicons name="server-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>No Connection</Text>
|
||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>Add a server connection to get started</Text>
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.noConnectionTitle")}</Text>
|
||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||
{t("sessionsList.empty.noConnectionSubtitle")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||
onPress={() => router.push("/connection/add")}
|
||||
testID="add-connection-button"
|
||||
>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>
|
||||
{t("sessionsList.empty.addConnectionButton")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
@@ -434,9 +447,9 @@ export default function SessionsScreen() {
|
||||
return (
|
||||
<View style={[styles.emptyContainer, isDark && styles.containerDark]}>
|
||||
<Ionicons name="lock-closed-outline" size={64} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>Authentication Failed</Text>
|
||||
<Text style={[styles.emptyTitle, isDark && styles.textDark]}>{t("sessionsList.empty.authFailedTitle")}</Text>
|
||||
<Text style={[styles.emptySubtitle, isDark && styles.metaDark]}>
|
||||
{activeConnection.name} rejected your credentials. Check the username and password to reconnect.
|
||||
{t("sessionsList.empty.authFailedSubtitle", { name: activeConnection.name })}
|
||||
</Text>
|
||||
<View style={styles.authErrorButtonRow}>
|
||||
<TouchableOpacity
|
||||
@@ -444,7 +457,9 @@ export default function SessionsScreen() {
|
||||
onPress={() => router.push(`/connection/${activeConnection.id}`)}
|
||||
testID="fix-connection-button"
|
||||
>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Check Credentials</Text>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>
|
||||
{t("sessionsList.empty.checkCredentialsButton")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||
@@ -456,7 +471,7 @@ export default function SessionsScreen() {
|
||||
}}
|
||||
testID="retry-connection-button"
|
||||
>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Retry</Text>
|
||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>{t("common.retry")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -523,7 +538,7 @@ export default function SessionsScreen() {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyList}>
|
||||
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>No sessions yet</Text>
|
||||
<Text style={[styles.emptyListText, isDark && styles.metaDark]}>{t("sessionsList.empty.noSessions")}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -547,7 +562,7 @@ export default function SessionsScreen() {
|
||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setShowNewSession(false)} />
|
||||
<View style={[styles.modalContent, isDark && styles.modalContentDark]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={[styles.modalTitle, isDark && styles.textDark]}>New Session</Text>
|
||||
<Text style={[styles.modalTitle, isDark && styles.textDark]}>{t("sessionsList.newSessionModal.title")}</Text>
|
||||
<TouchableOpacity onPress={() => setShowNewSession(false)}>
|
||||
<Ionicons name="close" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
</TouchableOpacity>
|
||||
@@ -555,7 +570,9 @@ export default function SessionsScreen() {
|
||||
|
||||
<ScrollView style={styles.modalScrollBody} keyboardShouldPersistTaps="handled">
|
||||
{/* Current directory — tapping creates session immediately */}
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>Current Project</Text>
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark]}>
|
||||
{t("sessionsList.newSessionModal.currentProjectLabel")}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalDirBox, isDark && styles.modalDirBoxDark]}
|
||||
onPress={() => onCreateInDirectory()}
|
||||
@@ -563,7 +580,7 @@ export default function SessionsScreen() {
|
||||
>
|
||||
<Ionicons name="folder" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||
<Text style={[styles.modalDirText, isDark && styles.textDark]} numberOfLines={2}>
|
||||
{currentProject?.path?.absolute || activeConnection?.directory || "Server default"}
|
||||
{currentProject?.path?.absolute || activeConnection?.directory || t("sessionsList.newSessionModal.serverDefault")}
|
||||
</Text>
|
||||
<Ionicons name="arrow-forward-circle" size={20} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||
</TouchableOpacity>
|
||||
@@ -572,7 +589,7 @@ export default function SessionsScreen() {
|
||||
{recentDirectories.length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||
Recent Projects
|
||||
{t("sessionsList.newSessionModal.recentProjectsLabel")}
|
||||
</Text>
|
||||
{recentDirectories.map((dir) => {
|
||||
const short = dir.split("/").filter(Boolean).pop() || dir
|
||||
@@ -620,7 +637,7 @@ export default function SessionsScreen() {
|
||||
{serverProjects.filter((p) => p.path?.absolute !== currentProject?.path?.absolute).length > 0 && (
|
||||
<>
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||
Server Projects
|
||||
{t("sessionsList.newSessionModal.serverProjectsLabel")}
|
||||
</Text>
|
||||
{serverProjects
|
||||
.filter((p) => p.path?.absolute !== currentProject?.path?.absolute)
|
||||
@@ -661,9 +678,11 @@ export default function SessionsScreen() {
|
||||
>
|
||||
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||
<View style={styles.projectRowContent}>
|
||||
<Text style={[styles.projectRowName, isDark && styles.textDark]}>Browse Folders…</Text>
|
||||
<Text style={[styles.projectRowName, isDark && styles.textDark]}>
|
||||
{t("sessionsList.newSessionModal.browseFoldersLabel")}
|
||||
</Text>
|
||||
<Text style={[styles.projectRowPath, isDark && styles.metaDark]}>
|
||||
Explore the server's filesystem
|
||||
{t("sessionsList.newSessionModal.browseFoldersHint")}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={16} color={isDark ? "#666666" : "#999999"} />
|
||||
@@ -671,7 +690,7 @@ export default function SessionsScreen() {
|
||||
|
||||
{/* Manual path input fallback */}
|
||||
<Text style={[styles.modalLabel, isDark && styles.metaDark, { marginTop: 16 }]}>
|
||||
Enter Path Manually
|
||||
{t("sessionsList.newSessionModal.enterPathLabel")}
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||
@@ -726,7 +745,9 @@ export default function SessionsScreen() {
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||
Create in {customDir.split("/").filter(Boolean).pop() || customDir}
|
||||
{t("sessionsList.newSessionModal.createInButton", {
|
||||
dir: customDir.split("/").filter(Boolean).pop() || customDir,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -745,7 +766,7 @@ export default function SessionsScreen() {
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||
Create Session
|
||||
{t("sessionsList.newSessionModal.createSessionButton")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -763,7 +784,7 @@ export default function SessionsScreen() {
|
||||
>
|
||||
<TouchableOpacity style={styles.modalDismiss} activeOpacity={1} onPress={() => setRenaming(null)} />
|
||||
<View style={[styles.renameCard, isDark && styles.renameCardDark]}>
|
||||
<Text style={[styles.renameTitle, isDark && styles.textDark]}>Rename Session</Text>
|
||||
<Text style={[styles.renameTitle, isDark && styles.textDark]}>{t("sessionsList.renameModal.title")}</Text>
|
||||
<TextInput
|
||||
style={[styles.modalInput, isDark && styles.modalInputDark]}
|
||||
value={renameText}
|
||||
@@ -777,14 +798,16 @@ export default function SessionsScreen() {
|
||||
/>
|
||||
<View style={styles.renameActions}>
|
||||
<TouchableOpacity style={[styles.renameBtn, styles.renameBtnCancel]} onPress={() => setRenaming(null)}>
|
||||
<Text style={styles.renameBtnCancelText}>Cancel</Text>
|
||||
<Text style={styles.renameBtnCancelText}>{t("common.cancel")}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.renameBtn, styles.modalButtonPrimary, isDark && styles.modalButtonPrimaryDark]}
|
||||
onPress={submitRename}
|
||||
disabled={!renameText.trim()}
|
||||
>
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>Save</Text>
|
||||
<Text style={[styles.modalButtonTextPrimary, isDark && styles.modalButtonTextPrimaryDark]}>
|
||||
{t("common.save")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -191,8 +191,8 @@ export default function SettingsScreen() {
|
||||
<SettingRow
|
||||
key={category}
|
||||
icon={meta.icon as keyof typeof Ionicons.glyphMap}
|
||||
label={meta.label}
|
||||
description={meta.description}
|
||||
label={t(meta.labelKey)}
|
||||
description={t(meta.descriptionKey)}
|
||||
isDark={isDark}
|
||||
right={
|
||||
<Switch
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useColorScheme, View, ActivityIndicator } from "react-native"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler"
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"
|
||||
import { I18nextProvider } from "react-i18next"
|
||||
import { I18nextProvider, useTranslation } from "react-i18next"
|
||||
import i18n from "../src/lib/i18n/config"
|
||||
import { useAuth } from "../src/stores/auth"
|
||||
import { useConnections } from "../src/stores/connections"
|
||||
@@ -25,6 +25,7 @@ const queryClient = new QueryClient()
|
||||
function RootLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { initialize: initAuth, isLoading: authLoading } = useAuth()
|
||||
const { loadConnections, isLoading: connectionsLoading, client } = useConnections()
|
||||
@@ -129,21 +130,21 @@ function RootLayout() {
|
||||
<Stack.Screen
|
||||
name="session/[id]"
|
||||
options={{
|
||||
title: "Session",
|
||||
title: t("session.titleFallback"),
|
||||
presentation: "card",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="connection/add"
|
||||
options={{
|
||||
title: "Add Connection",
|
||||
title: t("nav.addConnectionTitle"),
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="connection/[id]"
|
||||
options={{
|
||||
title: "Edit Connection",
|
||||
title: t("nav.editConnectionTitle"),
|
||||
presentation: "modal",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "react-native"
|
||||
import { router, useLocalSearchParams } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import { useEvents } from "../../src/stores/events"
|
||||
import type { ConnectionType } from "../../src/lib/types"
|
||||
@@ -19,20 +20,24 @@ import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||
|
||||
// labelKey (not literal text): this is a module-level constant evaluated
|
||||
// before i18next is guaranteed ready, so the label is resolved with t() at
|
||||
// render time — same pattern as categoryMeta in src/lib/notifications.ts.
|
||||
const CONNECTION_TYPES: Array<{
|
||||
type: ConnectionType
|
||||
label: string
|
||||
labelKey: string
|
||||
icon: keyof typeof Ionicons.glyphMap
|
||||
}> = [
|
||||
{ type: "local", label: "Local", icon: "wifi" },
|
||||
{ type: "tunnel", label: "Tunnel", icon: "globe" },
|
||||
{ type: "cloud", label: "Cloud", icon: "cloud" },
|
||||
{ type: "local", labelKey: "connection.shared.types.local", icon: "wifi" },
|
||||
{ type: "tunnel", labelKey: "connection.shared.types.tunnel", icon: "globe" },
|
||||
{ type: "cloud", labelKey: "connection.shared.types.cloud", icon: "cloud" },
|
||||
]
|
||||
|
||||
export default function EditConnectionScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { connections, updateConnection, removeConnection, testConnection } = useConnections()
|
||||
|
||||
@@ -60,18 +65,18 @@ export default function EditConnectionScreen() {
|
||||
if (!connection) {
|
||||
return (
|
||||
<View style={[styles.container, isDark && styles.containerDark, styles.center]}>
|
||||
<Text style={[styles.errorText, isDark && styles.textDark]}>Connection not found</Text>
|
||||
<Text style={[styles.errorText, isDark && styles.textDark]}>{t("connection.edit.notFound")}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!url.trim()) {
|
||||
Alert.alert("Error", "Please enter a server URL")
|
||||
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
|
||||
return
|
||||
}
|
||||
if (!parseUrl(url).valid) {
|
||||
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
||||
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,7 +96,7 @@ export default function EditConnectionScreen() {
|
||||
|
||||
if (result.ok) {
|
||||
setIsTesting(false)
|
||||
Alert.alert("Success", "Connection successful!")
|
||||
Alert.alert(t("connection.edit.alerts.successTitle"), t("connection.edit.alerts.successMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,23 +108,30 @@ export default function EditConnectionScreen() {
|
||||
captureDiagnostic(report)
|
||||
setIsTesting(false)
|
||||
|
||||
Alert.alert("Connection Failed", `${report.summary}\n\n(${result.error || "no detail"})`, [
|
||||
{ text: "OK", style: "cancel" },
|
||||
{ text: "Share report", onPress: () => shareReport(report) },
|
||||
])
|
||||
Alert.alert(
|
||||
t("connection.shared.alerts.connectionFailedTitle"),
|
||||
t("connection.edit.alerts.connectionFailedMessage", {
|
||||
summary: report.summary,
|
||||
detail: result.error || t("connection.edit.alerts.noDetail"),
|
||||
}),
|
||||
[
|
||||
{ text: t("common.ok"), style: "cancel" },
|
||||
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Error", "Please enter a connection name")
|
||||
Alert.alert(t("common.error"), t("connection.shared.alerts.enterName"))
|
||||
return
|
||||
}
|
||||
if (!url.trim()) {
|
||||
Alert.alert("Error", "Please enter a server URL")
|
||||
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
|
||||
return
|
||||
}
|
||||
if (!parseUrl(url).valid) {
|
||||
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
||||
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -143,10 +155,10 @@ export default function EditConnectionScreen() {
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Connection", `Are you sure you want to delete "${connection.name}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(t("connection.edit.alerts.deleteTitle"), t("connection.edit.alerts.deleteMessage", { name: connection.name }), [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: t("common.delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await removeConnection(connection.id)
|
||||
@@ -163,50 +175,50 @@ export default function EditConnectionScreen() {
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Connection Type */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.connectionType")}</Text>
|
||||
<View style={styles.typeContainer}>
|
||||
{CONNECTION_TYPES.map((t) => (
|
||||
{CONNECTION_TYPES.map((opt) => (
|
||||
<TouchableOpacity
|
||||
key={t.type}
|
||||
key={opt.type}
|
||||
style={[
|
||||
styles.typeOption,
|
||||
isDark && styles.typeOptionDark,
|
||||
type === t.type && styles.typeOptionSelected,
|
||||
type === t.type && isDark && styles.typeOptionSelectedDark,
|
||||
type === opt.type && styles.typeOptionSelected,
|
||||
type === opt.type && isDark && styles.typeOptionSelectedDark,
|
||||
]}
|
||||
onPress={() => setType(t.type)}
|
||||
onPress={() => setType(opt.type)}
|
||||
>
|
||||
<Ionicons
|
||||
name={t.icon}
|
||||
name={opt.icon}
|
||||
size={20}
|
||||
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||
color={type === opt.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeLabel,
|
||||
isDark && styles.textDark,
|
||||
type === t.type && styles.typeLabelSelected,
|
||||
type === t.type && isDark && styles.typeLabelSelectedDark,
|
||||
type === opt.type && styles.typeLabelSelected,
|
||||
type === opt.type && isDark && styles.typeLabelSelectedDark,
|
||||
]}
|
||||
>
|
||||
{t.label}
|
||||
{t(opt.labelKey)}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.name")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="My Server"
|
||||
placeholder={t("connection.shared.namePlaceholder")}
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
{/* URL */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="http://192.168.1.100:4096"
|
||||
@@ -219,7 +231,7 @@ export default function EditConnectionScreen() {
|
||||
/>
|
||||
|
||||
{/* Directory */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.directoryOptional")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="/path/to/project"
|
||||
@@ -229,14 +241,12 @@ export default function EditConnectionScreen() {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||
Leave empty to use the server's current directory. Sessions will be created in this folder.
|
||||
</Text>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>{t("connection.edit.directoryHint")}</Text>
|
||||
|
||||
{/* Auth */}
|
||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>{t("connection.shared.authentication")}</Text>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.username")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="admin"
|
||||
@@ -247,7 +257,7 @@ export default function EditConnectionScreen() {
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password (leave empty to keep existing)</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.edit.passwordLabel")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="••••••••"
|
||||
@@ -269,7 +279,7 @@ export default function EditConnectionScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="pulse" size={20} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
<Text style={[styles.testButtonText, isDark && styles.textDark]}>Test Connection</Text>
|
||||
<Text style={[styles.testButtonText, isDark && styles.textDark]}>{t("connection.edit.testButton")}</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -282,13 +292,15 @@ export default function EditConnectionScreen() {
|
||||
{isSaving ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>Save Changes</Text>
|
||||
<Text style={[styles.saveButtonText, isDark && styles.saveButtonTextDark]}>
|
||||
{t("connection.edit.saveButton")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.deleteButton} onPress={handleDelete}>
|
||||
<Ionicons name="trash-outline" size={20} color="#ef4444" />
|
||||
<Text style={styles.deleteButtonText}>Delete Connection</Text>
|
||||
<Text style={styles.deleteButtonText}>{t("connection.edit.deleteButton")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "react-native"
|
||||
import { router } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useConnections } from "../../src/stores/connections"
|
||||
import type { ConnectionType } from "../../src/lib/types"
|
||||
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||
@@ -24,6 +25,7 @@ import { submitWaitlistSignup, buildWaitlistMailtoUrl } from "../../src/lib/wait
|
||||
export default function AddConnectionScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { addConnection, testConnection } = useConnections()
|
||||
|
||||
@@ -66,7 +68,7 @@ export default function AddConnectionScreen() {
|
||||
const handleQuickConnect = async () => {
|
||||
const serverUrl = buildUrl()
|
||||
if (!serverUrl) {
|
||||
Alert.alert("Error", "Please enter your computer's IP address")
|
||||
Alert.alert(t("common.error"), t("connection.add.alerts.enterIp"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ export default function AddConnectionScreen() {
|
||||
const result = await testConnection(
|
||||
{
|
||||
id: "",
|
||||
name: name || "My Server",
|
||||
name: name || t("connection.shared.namePlaceholder"),
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
username: username.trim() || undefined,
|
||||
@@ -90,7 +92,7 @@ export default function AddConnectionScreen() {
|
||||
// Save and go back
|
||||
await addConnection(
|
||||
{
|
||||
name: name.trim() || "My Server",
|
||||
name: name.trim() || t("connection.shared.namePlaceholder"),
|
||||
type: "local",
|
||||
url: serverUrl,
|
||||
username: username.trim() || undefined,
|
||||
@@ -108,11 +110,15 @@ export default function AddConnectionScreen() {
|
||||
captureDiagnostic(report)
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
"Connection Failed",
|
||||
`${report.summary}\n\nTarget: ${serverUrl}\nError: ${result.error || "Unknown error"}`,
|
||||
t("connection.shared.alerts.connectionFailedTitle"),
|
||||
t("connection.add.alerts.connectionFailedMessage", {
|
||||
summary: report.summary,
|
||||
target: serverUrl,
|
||||
error: result.error || t("connection.shared.alerts.unknownError"),
|
||||
}),
|
||||
[
|
||||
{ text: "OK", style: "cancel" },
|
||||
{ text: "Share report", onPress: () => shareReport(report) },
|
||||
{ text: t("common.ok"), style: "cancel" },
|
||||
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -120,15 +126,15 @@ export default function AddConnectionScreen() {
|
||||
|
||||
const handleAdvancedSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert("Error", "Please enter a connection name")
|
||||
Alert.alert(t("common.error"), t("connection.shared.alerts.enterName"))
|
||||
return
|
||||
}
|
||||
if (!url.trim()) {
|
||||
Alert.alert("Error", "Please enter a server URL")
|
||||
Alert.alert(t("common.error"), t("connection.shared.alerts.enterUrl"))
|
||||
return
|
||||
}
|
||||
if (!parseUrl(url).valid) {
|
||||
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
||||
Alert.alert(t("connection.shared.alerts.invalidUrlTitle"), t("connection.shared.alerts.invalidUrlMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -179,11 +185,15 @@ export default function AddConnectionScreen() {
|
||||
captureDiagnostic(report)
|
||||
setIsConnecting(false)
|
||||
Alert.alert(
|
||||
"Connection Failed",
|
||||
`${report.summary}\n\nTarget: ${url.trim()}\nError: ${result.error || "Unknown error"}`,
|
||||
t("connection.shared.alerts.connectionFailedTitle"),
|
||||
t("connection.add.alerts.connectionFailedMessage", {
|
||||
summary: report.summary,
|
||||
target: url.trim(),
|
||||
error: result.error || t("connection.shared.alerts.unknownError"),
|
||||
}),
|
||||
[
|
||||
{ text: "OK", style: "cancel" },
|
||||
{ text: "Share report", onPress: () => shareReport(report) },
|
||||
{ text: t("common.ok"), style: "cancel" },
|
||||
{ text: t("common.shareReport"), onPress: () => shareReport(report) },
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -204,10 +214,10 @@ export default function AddConnectionScreen() {
|
||||
await Linking.openURL(buildWaitlistMailtoUrl(result.email))
|
||||
} catch {
|
||||
// No mail app either — tell the user instead of failing silently.
|
||||
Alert.alert("Join Waitlist", "Could not reach the signup service or open an email app. Please email support@agentlabs.cc with subject \"OpenCode Connect Waitlist\".")
|
||||
Alert.alert(t("connection.add.waitlist.alertTitle"), t("connection.add.waitlist.fallbackMessage"))
|
||||
}
|
||||
} else {
|
||||
Alert.alert("Join Waitlist", result.error)
|
||||
Alert.alert(t("connection.add.waitlist.alertTitle"), result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +231,12 @@ export default function AddConnectionScreen() {
|
||||
>
|
||||
<View style={styles.quickHeader}>
|
||||
<Ionicons name="wifi" size={48} color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||
<Text style={[styles.quickTitle, isDark && styles.textDark]}>Connect to OpenCode</Text>
|
||||
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>
|
||||
Enter your computer's IP address to connect
|
||||
</Text>
|
||||
<Text style={[styles.quickTitle, isDark && styles.textDark]}>{t("connection.add.quick.title")}</Text>
|
||||
<Text style={[styles.quickSubtitle, isDark && styles.hintDark]}>{t("connection.add.quick.subtitle")}</Text>
|
||||
</View>
|
||||
|
||||
{/* IP Address */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>IP Address</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.add.quick.ipAddressLabel")}</Text>
|
||||
<View style={styles.ipRow}>
|
||||
<TextInput
|
||||
style={[styles.input, styles.ipInput, isDark && styles.inputDark]}
|
||||
@@ -254,20 +262,20 @@ export default function AddConnectionScreen() {
|
||||
</View>
|
||||
|
||||
{/* Optional name */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name (optional)</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.add.quick.nameOptionalLabel")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="My Mac"
|
||||
placeholder={t("connection.add.quick.namePlaceholder")}
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
{/* Password if needed */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password (if set on server)</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.add.quick.passwordIfSetLabel")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="Leave empty if none"
|
||||
placeholder={t("connection.add.quick.passwordPlaceholder")}
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
@@ -275,11 +283,13 @@ export default function AddConnectionScreen() {
|
||||
testID="connect-password-input"
|
||||
/>
|
||||
<Text style={[styles.usernameHint, isDark && styles.hintDark]}>
|
||||
Username defaults to <Text style={styles.code}>opencode</Text>. Custom username? Use{" "}
|
||||
{t("connection.add.quick.usernameHintPrefix")}
|
||||
<Text style={styles.code}>opencode</Text>
|
||||
{t("connection.add.quick.usernameHintMiddle")}
|
||||
<Text style={styles.usernameHintLink} onPress={() => setMode("advanced")}>
|
||||
Advanced options
|
||||
{t("connection.add.quick.advancedOptionsLink")}
|
||||
</Text>
|
||||
.
|
||||
{t("connection.add.quick.usernameHintSuffix")}
|
||||
</Text>
|
||||
|
||||
{/* Connect button */}
|
||||
@@ -294,30 +304,38 @@ export default function AddConnectionScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="flash" size={20} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Connect</Text>
|
||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>
|
||||
{t("connection.add.quick.connectButton")}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Help text */}
|
||||
<View style={[styles.helpBox, isDark && styles.helpBoxDark]}>
|
||||
<Text style={[styles.helpTitle, isDark && styles.textDark]}>How to find your IP:</Text>
|
||||
<Text style={[styles.helpTitle, isDark && styles.textDark]}>{t("connection.add.quick.helpTitle")}</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark]}>
|
||||
On your Mac, run:{"\n"}
|
||||
{t("connection.add.quick.helpMacPrefix")}
|
||||
{"\n"}
|
||||
<Text style={styles.code}>ipconfig getifaddr en0</Text>
|
||||
</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||
Tailscale examples:{"\n"}
|
||||
{t("connection.add.quick.helpTailscalePrefix")}
|
||||
{"\n"}
|
||||
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
||||
{"\n"}
|
||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
||||
</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||
Use <Text style={styles.code}>http://</Text> for local and Tailscale addresses unless TLS is configured,
|
||||
then use <Text style={styles.code}>https://</Text>.
|
||||
{t("connection.add.quick.helpProtocolPrefix")}
|
||||
<Text style={styles.code}>http://</Text>
|
||||
{t("connection.add.quick.helpProtocolMiddle")}
|
||||
<Text style={styles.code}>https://</Text>
|
||||
{t("connection.add.quick.helpProtocolSuffix")}
|
||||
</Text>
|
||||
<Text style={[styles.helpText, isDark && styles.hintDark, { marginTop: 8 }]}>
|
||||
Make sure OpenCode is running:{"\n"}
|
||||
{t("connection.add.quick.helpRunningPrefix")}
|
||||
{"\n"}
|
||||
<Text style={styles.code}>opencode serve --hostname 0.0.0.0</Text>
|
||||
</Text>
|
||||
</View>
|
||||
@@ -327,21 +345,22 @@ export default function AddConnectionScreen() {
|
||||
<View style={styles.connectCardHeader}>
|
||||
<Ionicons name="cloud-done-outline" size={28} color="#6366f1" />
|
||||
<View style={styles.connectCardTitles}>
|
||||
<Text style={[styles.connectCardTitle, isDark && styles.textDark]}>OpenCode Connect</Text>
|
||||
<Text style={[styles.connectCardTitle, isDark && styles.textDark]}>
|
||||
{t("connection.add.quick.connectCardTitle")}
|
||||
</Text>
|
||||
<View style={styles.connectCardBadge}>
|
||||
<Text style={styles.connectCardBadgeText}>Coming Soon</Text>
|
||||
<Text style={styles.connectCardBadgeText}>{t("connection.add.quick.connectCardBadge")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.connectCardDesc, isDark && styles.hintDark]}>
|
||||
Bridge your phone to your opencode server — no tunnel setup, no firewall config. One-tap connect from
|
||||
anywhere.
|
||||
{t("connection.add.quick.connectCardDesc")}
|
||||
</Text>
|
||||
{waitlistState === "joined" ? (
|
||||
<View style={styles.waitlistSuccess} testID="waitlist-success">
|
||||
<Ionicons name="checkmark-circle" size={20} color="#22c55e" />
|
||||
<Text style={[styles.waitlistSuccessText, isDark && styles.textDark]}>
|
||||
You're on the list — we'll email you when OpenCode Connect is ready.
|
||||
{t("connection.add.waitlist.successText")}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
@@ -369,7 +388,7 @@ export default function AddConnectionScreen() {
|
||||
) : (
|
||||
<>
|
||||
<Ionicons name="mail-outline" size={16} color="#ffffff" />
|
||||
<Text style={styles.waitlistButtonText}>Join Waitlist</Text>
|
||||
<Text style={styles.waitlistButtonText}>{t("connection.add.waitlist.joinButton")}</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
@@ -380,7 +399,7 @@ export default function AddConnectionScreen() {
|
||||
{/* Advanced mode link */}
|
||||
<TouchableOpacity style={styles.advancedLink} onPress={() => setMode("advanced")}>
|
||||
<Text style={[styles.advancedLinkText, isDark && styles.hintDark]}>
|
||||
Advanced options (tunnels, cloud, auth)
|
||||
{t("connection.add.quick.advancedLink")}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||
</TouchableOpacity>
|
||||
@@ -397,58 +416,58 @@ export default function AddConnectionScreen() {
|
||||
>
|
||||
<TouchableOpacity style={styles.backToQuick} onPress={() => setMode("quick")}>
|
||||
<Ionicons name="chevron-back" size={16} color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>Simple mode</Text>
|
||||
<Text style={[styles.backToQuickText, isDark && styles.hintDark]}>{t("connection.add.advanced.backToQuick")}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Connection Type */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Connection Type</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.connectionType")}</Text>
|
||||
<View style={styles.typeContainer}>
|
||||
{[
|
||||
{ type: "local" as const, label: "Local", icon: "wifi" as const },
|
||||
{ type: "tunnel" as const, label: "Tunnel", icon: "globe" as const },
|
||||
{ type: "cloud" as const, label: "Cloud", icon: "cloud" as const },
|
||||
].map((t) => (
|
||||
{ type: "local" as const, label: t("connection.shared.types.local"), icon: "wifi" as const },
|
||||
{ type: "tunnel" as const, label: t("connection.shared.types.tunnel"), icon: "globe" as const },
|
||||
{ type: "cloud" as const, label: t("connection.shared.types.cloud"), icon: "cloud" as const },
|
||||
].map((opt) => (
|
||||
<TouchableOpacity
|
||||
key={t.type}
|
||||
key={opt.type}
|
||||
style={[
|
||||
styles.typeOption,
|
||||
isDark && styles.typeOptionDark,
|
||||
type === t.type && styles.typeOptionSelected,
|
||||
type === t.type && isDark && styles.typeOptionSelectedDark,
|
||||
type === opt.type && styles.typeOptionSelected,
|
||||
type === opt.type && isDark && styles.typeOptionSelectedDark,
|
||||
]}
|
||||
onPress={() => setType(t.type)}
|
||||
onPress={() => setType(opt.type)}
|
||||
>
|
||||
<Ionicons
|
||||
name={t.icon}
|
||||
name={opt.icon}
|
||||
size={20}
|
||||
color={type === t.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||
color={type === opt.type ? (isDark ? "#0a0a0a" : "#ffffff") : isDark ? "#888888" : "#666666"}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.typeLabel,
|
||||
isDark && styles.textDark,
|
||||
type === t.type && styles.typeLabelSelected,
|
||||
type === t.type && isDark && styles.typeLabelSelectedDark,
|
||||
type === opt.type && styles.typeLabelSelected,
|
||||
type === opt.type && isDark && styles.typeLabelSelectedDark,
|
||||
]}
|
||||
>
|
||||
{t.label}
|
||||
{opt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Name</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.name")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="My Server"
|
||||
placeholder={t("connection.shared.namePlaceholder")}
|
||||
placeholderTextColor={isDark ? "#666666" : "#999999"}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
|
||||
{/* URL */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Server URL</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.serverUrl")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder={
|
||||
@@ -466,13 +485,17 @@ export default function AddConnectionScreen() {
|
||||
keyboardType="url"
|
||||
/>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||
Local/Tailscale examples: <Text style={styles.code}>http://100.64.12.34:4096</Text> or{" "}
|
||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>. Use <Text style={styles.code}>https://</Text>
|
||||
only when TLS is configured.
|
||||
{t("connection.add.advanced.urlHintPrefix")}
|
||||
<Text style={styles.code}>http://100.64.12.34:4096</Text>
|
||||
{t("connection.add.advanced.urlHintOr")}
|
||||
<Text style={styles.code}>http://my-mac.tailnet.ts.net:4096</Text>
|
||||
{t("connection.add.advanced.urlHintUse")}
|
||||
<Text style={styles.code}>https://</Text>
|
||||
{t("connection.add.advanced.urlHintSuffix")}
|
||||
</Text>
|
||||
|
||||
{/* Directory */}
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Project Directory (Optional)</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.directoryOptional")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="/path/to/project"
|
||||
@@ -482,14 +505,12 @@ export default function AddConnectionScreen() {
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>
|
||||
Leave empty to use the server's current directory, or specify a path to work in a different folder.
|
||||
</Text>
|
||||
<Text style={[styles.hint, isDark && styles.hintDark]}>{t("connection.add.advanced.directoryHint")}</Text>
|
||||
|
||||
{/* Auth */}
|
||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>Authentication</Text>
|
||||
<Text style={[styles.sectionTitle, isDark && styles.textDark]}>{t("connection.shared.authentication")}</Text>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Username</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.username")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="admin"
|
||||
@@ -500,7 +521,7 @@ export default function AddConnectionScreen() {
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>Password</Text>
|
||||
<Text style={[styles.label, isDark && styles.labelDark]}>{t("connection.shared.password")}</Text>
|
||||
<TextInput
|
||||
style={[styles.input, isDark && styles.inputDark]}
|
||||
placeholder="password"
|
||||
@@ -519,7 +540,9 @@ export default function AddConnectionScreen() {
|
||||
{isConnecting ? (
|
||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||
) : (
|
||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>Save Connection</Text>
|
||||
<Text style={[styles.connectButtonText, isDark && styles.connectButtonTextDark]}>
|
||||
{t("connection.add.advanced.saveButton")}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as ImagePicker from "expo-image-picker"
|
||||
import * as ImageManipulator from "expo-image-manipulator"
|
||||
import * as Clipboard from "expo-clipboard"
|
||||
@@ -76,6 +77,7 @@ export default function SessionScreen() {
|
||||
const colorScheme = useColorScheme()
|
||||
const isDark = colorScheme === "dark"
|
||||
const insets = useSafeAreaInsets()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const flatListRef = useRef<FlatList>(null)
|
||||
const modelSheetRef = useRef<BottomSheet>(null)
|
||||
@@ -190,14 +192,11 @@ export default function SessionScreen() {
|
||||
const applyRevertResult = useCallback((result: Awaited<ReturnType<typeof revertToMessage>>) => {
|
||||
if (!result.ok) {
|
||||
if (result.reason === "unsupported") {
|
||||
Alert.alert(
|
||||
"Not supported",
|
||||
"Editing sent messages needs a newer opencode server. Please update the server and try again.",
|
||||
)
|
||||
Alert.alert(t("session.alerts.notSupportedTitle"), t("session.alerts.notSupportedMessage"))
|
||||
} else if (result.reason === "auth") {
|
||||
Alert.alert("Authentication failed", "Your server credentials were rejected. Check your connection and try again.")
|
||||
Alert.alert(t("session.alerts.revertAuthFailedTitle"), t("session.alerts.revertAuthFailedMessage"))
|
||||
} else {
|
||||
Alert.alert("Edit failed", "Could not revert to this message. Please try again.")
|
||||
Alert.alert(t("session.alerts.editFailedTitle"), t("session.alerts.editFailedMessage"))
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -209,16 +208,16 @@ export default function SessionScreen() {
|
||||
.filter((f): f is typeof f & { url: string; mime: string } => !!f.url && !!f.mime)
|
||||
.map((f) => ({ uri: f.url, mime: f.mime, filename: f.filename })),
|
||||
)
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
// Stable across renders (reads fresh state via getState() rather than
|
||||
// closing over props) so MessageBubble's custom memo comparator can bail
|
||||
// safely without risking a stale handler.
|
||||
const handleMessageLongPress = useCallback((messageID: string) => {
|
||||
Alert.alert("Message actions", undefined, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(t("session.alerts.messageActionsTitle"), undefined, [
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Edit message",
|
||||
text: t("session.actions.editMessage"),
|
||||
onPress: () => {
|
||||
const doRevert = async () => {
|
||||
const result = await useSessions.getState().revertToMessage(messageID)
|
||||
@@ -228,11 +227,11 @@ export default function SessionScreen() {
|
||||
// in-progress unsent draft.
|
||||
if (inputRef.current.trim()) {
|
||||
Alert.alert(
|
||||
"Replace draft?",
|
||||
"You have an unsent message in the composer. Editing this message will replace it.",
|
||||
t("session.alerts.replaceDraftTitle"),
|
||||
t("session.alerts.replaceDraftMessage"),
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Replace", style: "destructive", onPress: doRevert },
|
||||
{ text: t("common.cancel"), style: "cancel" },
|
||||
{ text: t("session.actions.replace"), style: "destructive", onPress: doRevert },
|
||||
],
|
||||
{ cancelable: false },
|
||||
)
|
||||
@@ -242,7 +241,7 @@ export default function SessionScreen() {
|
||||
},
|
||||
},
|
||||
])
|
||||
}, [applyRevertResult])
|
||||
}, [applyRevertResult, t])
|
||||
|
||||
const scrollToBottom = useCallback((animated = true) => {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated })
|
||||
@@ -337,7 +336,7 @@ export default function SessionScreen() {
|
||||
const pickFromCamera = useCallback(async () => {
|
||||
const perm = await ImagePicker.requestCameraPermissionsAsync()
|
||||
if (!perm.granted) {
|
||||
Alert.alert("Permission needed", "Camera access is required to take photos.")
|
||||
Alert.alert(t("session.alerts.cameraPermissionTitle"), t("session.alerts.cameraPermissionMessage"))
|
||||
return
|
||||
}
|
||||
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
|
||||
@@ -345,7 +344,7 @@ export default function SessionScreen() {
|
||||
const a = result.assets[0]
|
||||
const item = await toJpeg(a.uri, a.width, a.height)
|
||||
setAttachments((prev) => [...prev, item])
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
const pasteFromClipboard = useCallback(async () => {
|
||||
// Try image first
|
||||
@@ -376,8 +375,8 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
}
|
||||
Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.")
|
||||
}, [])
|
||||
Alert.alert(t("session.alerts.emptyClipboardTitle"), t("session.alerts.emptyClipboardMessage"))
|
||||
}, [t])
|
||||
|
||||
const removeAttachment = useCallback((index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||
@@ -388,7 +387,7 @@ export default function SessionScreen() {
|
||||
if (!input.trim() && attachments.length === 0) return
|
||||
const authenticated = await authenticateForMessage()
|
||||
if (!authenticated) {
|
||||
Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.")
|
||||
Alert.alert(t("session.alerts.authRequiredTitle"), t("session.alerts.authRequiredMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -424,7 +423,7 @@ export default function SessionScreen() {
|
||||
// Restore the user's text and attachments so their input isn't lost.
|
||||
setInput((prev) => (prev ? prev : text))
|
||||
setAttachments((prev) => (prev.length ? prev : files))
|
||||
Alert.alert("Message not sent", "Could not send your message. Check your connection and try again.")
|
||||
Alert.alert(t("session.alerts.sendFailedTitle"), t("session.alerts.sendFailedMessage"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,7 +479,7 @@ export default function SessionScreen() {
|
||||
useEvents.setState((state) => ({
|
||||
permissions: { ...state.permissions, [sessionID]: snapshot },
|
||||
}))
|
||||
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
|
||||
Alert.alert(t("session.alerts.replyFailedTitle"), t("session.alerts.replyFailedMessage"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +499,7 @@ export default function SessionScreen() {
|
||||
useEvents.setState((state) => ({
|
||||
questions: { ...state.questions, [sessionID]: snapshot },
|
||||
}))
|
||||
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
|
||||
Alert.alert(t("session.alerts.replyFailedTitle"), t("session.alerts.replyFailedMessage"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +519,7 @@ export default function SessionScreen() {
|
||||
useEvents.setState((state) => ({
|
||||
questions: { ...state.questions, [sessionID]: snapshot },
|
||||
}))
|
||||
Alert.alert("Reject Failed", "Could not send your response. Please try again.")
|
||||
Alert.alert(t("session.alerts.rejectFailedTitle"), t("session.alerts.rejectFailedMessage"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +547,7 @@ export default function SessionScreen() {
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: currentSession?.title || "Session",
|
||||
title: currentSession?.title || t("session.titleFallback"),
|
||||
headerRight: () => (
|
||||
<View style={s.headerRight}>
|
||||
{shortDir && (
|
||||
@@ -595,12 +594,12 @@ export default function SessionScreen() {
|
||||
{/* SSE reconnect/connected banner */}
|
||||
{reconnectAttempts > 0 && (
|
||||
<View style={[s.banner, s.bannerReconnecting]}>
|
||||
<Text style={s.bannerText}>Reconnecting\u2026 (attempt {reconnectAttempts})</Text>
|
||||
<Text style={s.bannerText}>{t("session.banners.reconnecting", { attempt: reconnectAttempts })}</Text>
|
||||
</View>
|
||||
)}
|
||||
{showConnectedFlash && reconnectAttempts === 0 && (
|
||||
<View style={[s.banner, s.bannerConnected]}>
|
||||
<Text style={s.bannerText}>Connected ✓</Text>
|
||||
<Text style={s.bannerText}>{t("session.banners.connected")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -608,9 +607,9 @@ export default function SessionScreen() {
|
||||
cleaned up by the next prompt. */}
|
||||
{revertMessageID && (
|
||||
<View style={[s.banner, s.bannerRevert]}>
|
||||
<Text style={s.bannerText}>Message reverted — resend to confirm</Text>
|
||||
<Text style={s.bannerText}>{t("session.banners.reverted")}</Text>
|
||||
<TouchableOpacity onPress={() => unrevertSession()} hitSlop={8}>
|
||||
<Text style={s.bannerAction}>Undo</Text>
|
||||
<Text style={s.bannerAction}>{t("session.banners.undo")}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
@@ -645,7 +644,7 @@ export default function SessionScreen() {
|
||||
loadingMore ? (
|
||||
<View style={s.loadingMore}>
|
||||
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
|
||||
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>{t("session.loadingOlder")}</Text>
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
@@ -655,8 +654,8 @@ export default function SessionScreen() {
|
||||
{messageData.length === 0 && (
|
||||
<View style={s.emptyOverlay} pointerEvents="none">
|
||||
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
|
||||
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
|
||||
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
|
||||
<Text style={[s.emptyText, isDark && s.metaDark]}>{t("session.empty.title")}</Text>
|
||||
<Text style={[s.emptyHint, isDark && s.metaDark]}>{t("session.empty.hint")}</Text>
|
||||
</View>
|
||||
)}
|
||||
{showScrollButton && (
|
||||
@@ -726,7 +725,7 @@ export default function SessionScreen() {
|
||||
>
|
||||
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[s.variantLabel, isDark && s.metaDark, variant && s.variantLabelActive]} numberOfLines={1}>
|
||||
{variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : "Auto"}
|
||||
{variant ? variant.charAt(0).toUpperCase() + variant.slice(1) : t("session.toolbar.auto")}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
@@ -752,7 +751,13 @@ export default function SessionScreen() {
|
||||
|
||||
<TextInput
|
||||
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
|
||||
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
|
||||
placeholder={
|
||||
speech.listening
|
||||
? t("session.input.placeholderListening")
|
||||
: isSending
|
||||
? t("session.input.placeholderFollowUp")
|
||||
: t("session.input.placeholderDefault")
|
||||
}
|
||||
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
|
||||
value={speech.listening ? speech.transcript : input}
|
||||
onChangeText={speech.listening ? undefined : setInput}
|
||||
|
||||
Reference in New Issue
Block a user