Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_DISABLE_AUTO_UPLOAD: ${{ github.event_name == 'pull_request' && 'true' || '' }}
steps:
- uses: actions/checkout@v6

Expand Down
19 changes: 18 additions & 1 deletion app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export default function SettingsScreen() {
const { t } = useTranslation()

const { settings, hasBiometrics, updateSettings, lock } = useAuth()
const { notifications, setNotification, locale, setLocale } = useSettings()
const { notifications, setNotification, locale, setLocale, showMessageTimestamps, setShowMessageTimestamps } =
useSettings()
const [osGranted, setOsGranted] = useState<boolean | null>(null)
const [telemetryUpdating, setTelemetryUpdating] = useState(false)

Expand Down Expand Up @@ -233,6 +234,22 @@ export default function SettingsScreen() {
)}
</SettingSection>

<SettingSection title={t("settings.sections.display")} isDark={isDark}>
<SettingRow
icon="time-outline"
label={t("settings.display.messageTimestamps.label")}
description={t("settings.display.messageTimestamps.description")}
isDark={isDark}
right={
<Switch
value={showMessageTimestamps}
onValueChange={setShowMessageTimestamps}
trackColor={{ false: "#767577", true: "#22c55e" }}
/>
}
/>
</SettingSection>

<SettingSection title={t("settings.sections.privacy")} isDark={isDark}>
<SettingRow
icon="shield-checkmark"
Expand Down
6 changes: 5 additions & 1 deletion app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { useSessions } from "../../src/stores/sessions"
import { useEvents, refreshPending } from "../../src/stores/events"
import { useConnections } from "../../src/stores/connections"
import { useAuth } from "../../src/stores/auth"
import { useSettings } from "../../src/stores/settings"
import { useCatalog } from "../../src/stores/catalog"
import { useSpeech } from "../../src/lib/speech"
import { messagePartsToSelectableText } from "../../src/lib/message-text"
Expand Down Expand Up @@ -90,6 +91,8 @@ export default function SessionScreen() {
const isDark = colorScheme === "dark"
const insets = useSafeAreaInsets()
const { t } = useTranslation()
const { locale, showMessageTimestamps } = useSettings()
const timestampLocale = locale === "system" ? undefined : locale

const flatListRef = useRef<FlatList>(null)
const modelSheetRef = useRef<BottomSheet>(null)
Expand Down Expand Up @@ -805,6 +808,8 @@ export default function SessionScreen() {
message={item.message}
parts={item.parts}
isDark={isDark}
showTimestamp={showMessageTimestamps}
timestampLocale={timestampLocale}
onSelectText={openSelectTextSheet}
onEdit={startEditFlow}
/>
Expand Down Expand Up @@ -941,7 +946,6 @@ export default function SessionScreen() {
editable={!speech.listening}
multiline
scrollEnabled
maxLength={10000}
testID="chat-message-input"
/>
{/* Stop button: only when busy and no input */}
Expand Down
16 changes: 15 additions & 1 deletion src/components/chat/MessageBubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ToolCallCard } from "./ToolCallCard"
import { ReasoningBlock } from "./ReasoningBlock"
import type { Message, Part } from "../../lib/sdk"
import { hasSelectableContent } from "../../lib/message-text"
import { formatMessageTimestamp } from "../../lib/message-timestamp"

const SCREEN_WIDTH = Dimensions.get("window").width

Expand All @@ -17,6 +18,8 @@ interface Props {
message: Message
parts: Part[]
isDark: boolean
showTimestamp?: boolean
timestampLocale?: string
// Message actions arrive as buttons in the bubble header (see the header
// comment in the render). There is deliberately no bubble-wide press or
// long-press: it competed with horizontal scrolling in code blocks and with
Expand All @@ -30,8 +33,9 @@ interface Props {
// TODO: Replace with streamdown-rn once React 19 types PR lands - it has
// built-in block-level memoization that eliminates re-renders for stable blocks
export const MessageBubble = memo(
function MessageBubble({ message, parts, isDark, onSelectText, onEdit }: Props) {
function MessageBubble({ message, parts, isDark, showTimestamp = false, timestampLocale, onSelectText, onEdit }: Props) {
const isUser = message.role === "user"
const timestamp = showTimestamp ? formatMessageTimestamp(message.time.created, timestampLocale) : undefined

const textParts = parts.filter((p) => p.type === "text")
const reasoningParts = parts.filter((p) => p.type === "reasoning")
Expand Down Expand Up @@ -95,6 +99,12 @@ export const MessageBubble = memo(
)}
</View>

{timestamp && (
<Text style={[s.timestamp, isDark && s.timestampDark]} testID={`message-timestamp-${message.id}`}>
{timestamp}
</Text>
)}

{/* Image attachments */}
{fileParts.length > 0 && (
<ScrollView
Expand Down Expand Up @@ -170,6 +180,8 @@ export const MessageBubble = memo(
// unchanged (completed) messages during other messages' streaming.
if (prev.message !== next.message) return false
if (prev.isDark !== next.isDark) return false
if (prev.showTimestamp !== next.showTimestamp) return false
if (prev.timestampLocale !== next.timestampLocale) return false
if (prev.onSelectText !== next.onSelectText) return false
if (prev.onEdit !== next.onEdit) return false
if (prev.parts.length !== next.parts.length) return false
Expand All @@ -191,6 +203,8 @@ const s = StyleSheet.create({
role: { fontSize: 13, fontWeight: "600", color: "#666666" },
roleUser: { color: "#0a0a0a" },
textWhite: { color: "#ffffff" },
timestamp: { alignSelf: "flex-end", marginTop: -4, marginBottom: 6, fontSize: 11, color: "#737373" },
timestampDark: { color: "#a3a3a3" },

actions: {
// Pushes the group to the right edge of the header row; the chips inside it
Expand Down
17 changes: 17 additions & 0 deletions src/components/message-bubble-selection.regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,20 @@ test("the session screen wires the buttons to single-implementation handlers", (
assert.doesNotMatch(src, /setTextSheet\(sheetText\)/)
assert.equal(src.split("revertToMessage(").length - 1, 1, "the revert flow must exist once, in startEditFlow")
})

test("the chat composer does not impose a client-side message length cap", () => {
const src = readFileSync(path.join(here, "..", "..", "app", "session", "[id].tsx"), "utf8")
const input = src.slice(src.indexOf('testID="chat-message-input"') - 800, src.indexOf('testID="chat-message-input"'))
assert.match(input, /multiline/)
assert.doesNotMatch(input, /maxLength\s*=/, "the server should decide whether a prompt is too long")
})

test("the persisted timestamp preference reaches every session message bubble", () => {
const session = readFileSync(path.join(here, "..", "..", "app", "session", "[id].tsx"), "utf8")
const bubble = read("chat/MessageBubble.tsx")
assert.match(session, /showTimestamp=\{showMessageTimestamps\}/)
assert.match(session, /timestampLocale=\{timestampLocale\}/)
assert.match(bubble, /showTimestamp \? formatMessageTimestamp\(message\.time\.created, timestampLocale\)/)
assert.match(bubble, /testID=\{`message-timestamp-\$\{message\.id\}`\}/)
assert.match(bubble, /prev\.showTimestamp !== next\.showTimestamp/)
})
7 changes: 7 additions & 0 deletions src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"sections": {
"security": "Security",
"notifications": "Notifications",
"display": "Display",
"privacy": "Privacy",
"about": "About"
},
Expand All @@ -34,6 +35,12 @@
"notifications": {
"disabledNotice": "Notifications are disabled at the system level. Enable them in Settings to receive alerts."
},
"display": {
"messageTimestamps": {
"label": "Message timestamps",
"description": "Show the date and time each message was sent"
}
},
"privacy": {
"crashReporting": {
"label": "Crash Reports & Usage Analytics",
Expand Down
7 changes: 7 additions & 0 deletions src/lib/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"sections": {
"security": "Безопасность",
"notifications": "Уведомления",
"display": "Отображение",
"privacy": "Конфиденциальность",
"about": "О приложении"
},
Expand All @@ -34,6 +35,12 @@
"notifications": {
"disabledNotice": "Уведомления отключены на уровне системы. Включите их в настройках, чтобы получать оповещения."
},
"display": {
"messageTimestamps": {
"label": "Время сообщений",
"description": "Показывать дату и время отправки каждого сообщения"
}
},
"privacy": {
"crashReporting": {
"label": "Отчёты об ошибках и аналитика",
Expand Down
7 changes: 7 additions & 0 deletions src/lib/i18n/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"sections": {
"security": "安全",
"notifications": "通知",
"display": "显示",
"privacy": "隐私",
"about": "关于"
},
Expand All @@ -34,6 +35,12 @@
"notifications": {
"disabledNotice": "通知已在系统层面关闭。请在系统设置中启用通知以接收提醒。"
},
"display": {
"messageTimestamps": {
"label": "消息时间戳",
"description": "显示每条消息发送的日期和时间"
}
},
"privacy": {
"crashReporting": {
"label": "崩溃报告与使用分析",
Expand Down
19 changes: 19 additions & 0 deletions src/lib/message-timestamp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { formatMessageTimestamp } from "./message-timestamp.ts"

test("formats message timestamps with date and time in the requested locale", () => {
const timestamp = Date.UTC(2026, 10, 8, 20, 5)
const expected = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeStyle: "short" }).format(
new Date(timestamp),
)

assert.equal(formatMessageTimestamp(timestamp, "en-US"), expected)
assert.notEqual(formatMessageTimestamp(timestamp, "ru"), formatMessageTimestamp(timestamp, "zh-Hans"))
})

test("does not render missing or invalid message timestamps", () => {
assert.equal(formatMessageTimestamp(0), undefined)
assert.equal(formatMessageTimestamp(Number.NaN), undefined)
assert.equal(formatMessageTimestamp(Number.POSITIVE_INFINITY), undefined)
})
12 changes: 12 additions & 0 deletions src/lib/message-timestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Format a message creation time in the device's local time zone. */
export function formatMessageTimestamp(timestamp: number, locale?: string): string | undefined {
if (!Number.isFinite(timestamp) || timestamp <= 0) return undefined

const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return undefined

return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(date)
}
9 changes: 8 additions & 1 deletion src/lib/settings-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { clampPageSize, mergeStoredSettings } from "./settings-merge.ts"
const DEFAULTS = {
pageSize: 25,
notifications: { idle: true, error: true, permission: false },
showMessageTimestamps: false,
}

test("clampPageSize keeps in-range values unchanged", () => {
Expand All @@ -26,11 +27,17 @@ test("empty stored settings yield the defaults", () => {
})

test("stored values override defaults", () => {
const merged = mergeStoredSettings(DEFAULTS, { pageSize: 50 })
const merged = mergeStoredSettings(DEFAULTS, { pageSize: 50, showMessageTimestamps: true })
assert.equal(merged.pageSize, 50)
assert.equal(merged.showMessageTimestamps, true)
assert.deepEqual(merged.notifications, DEFAULTS.notifications)
})

test("settings from before message timestamps default the new preference to off", () => {
const merged = mergeStoredSettings(DEFAULTS, { pageSize: 50 })
assert.equal(merged.showMessageTimestamps, false)
})

test("upgrade path: a category missing from storage gets its default", () => {
// Stored data predates the "permission" category — it must come back as the default (false),
// not undefined, while the user's stored choices are preserved.
Expand Down
15 changes: 14 additions & 1 deletion src/stores/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ interface Settings {
pageSize: number
notifications: Record<Category, boolean>
locale: LocalePreference
showMessageTimestamps: boolean
}

const DEFAULTS: Settings = {
pageSize: 25,
notifications: { ...defaultPreferences },
locale: "system",
showMessageTimestamps: false,
}

interface SettingsState extends Settings {
Expand All @@ -25,10 +27,16 @@ interface SettingsState extends Settings {
setPageSize: (size: number) => Promise<void>
setNotification: (category: Category, enabled: boolean) => Promise<void>
setLocale: (locale: LocalePreference) => Promise<void>
setShowMessageTimestamps: (show: boolean) => Promise<void>
}

function snapshot(get: () => SettingsState): Settings {
return { pageSize: get().pageSize, notifications: get().notifications, locale: get().locale }
return {
pageSize: get().pageSize,
notifications: get().notifications,
locale: get().locale,
showMessageTimestamps: get().showMessageTimestamps,
}
}

async function persist(settings: Settings) {
Expand Down Expand Up @@ -69,4 +77,9 @@ export const useSettings = create<SettingsState>((set, get) => ({
setAppLocale(locale) // applies immediately
await persist({ ...snapshot(get), locale })
},

setShowMessageTimestamps: async (showMessageTimestamps) => {
set({ showMessageTimestamps })
await persist({ ...snapshot(get), showMessageTimestamps })
},
}))
Loading