diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b045608..f1e737d5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 9fe5e5fc..6b16adfb 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -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(null) const [telemetryUpdating, setTelemetryUpdating] = useState(false) @@ -233,6 +234,22 @@ export default function SettingsScreen() { )} + + + } + /> + + (null) const modelSheetRef = useRef(null) @@ -805,6 +808,8 @@ export default function SessionScreen() { message={item.message} parts={item.parts} isDark={isDark} + showTimestamp={showMessageTimestamps} + timestampLocale={timestampLocale} onSelectText={openSelectTextSheet} onEdit={startEditFlow} /> @@ -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 */} diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 6fec4967..2cac8865 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -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 @@ -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 @@ -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") @@ -95,6 +99,12 @@ export const MessageBubble = memo( )} + {timestamp && ( + + {timestamp} + + )} + {/* Image attachments */} {fileParts.length > 0 && ( { + 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/) +}) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8aa910cf..4cbf82f6 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -13,6 +13,7 @@ "sections": { "security": "Security", "notifications": "Notifications", + "display": "Display", "privacy": "Privacy", "about": "About" }, @@ -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", diff --git a/src/lib/i18n/ru.json b/src/lib/i18n/ru.json index 471f8a76..8bb88944 100644 --- a/src/lib/i18n/ru.json +++ b/src/lib/i18n/ru.json @@ -13,6 +13,7 @@ "sections": { "security": "Безопасность", "notifications": "Уведомления", + "display": "Отображение", "privacy": "Конфиденциальность", "about": "О приложении" }, @@ -34,6 +35,12 @@ "notifications": { "disabledNotice": "Уведомления отключены на уровне системы. Включите их в настройках, чтобы получать оповещения." }, + "display": { + "messageTimestamps": { + "label": "Время сообщений", + "description": "Показывать дату и время отправки каждого сообщения" + } + }, "privacy": { "crashReporting": { "label": "Отчёты об ошибках и аналитика", diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 779244b5..ec50aa45 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -13,6 +13,7 @@ "sections": { "security": "安全", "notifications": "通知", + "display": "显示", "privacy": "隐私", "about": "关于" }, @@ -34,6 +35,12 @@ "notifications": { "disabledNotice": "通知已在系统层面关闭。请在系统设置中启用通知以接收提醒。" }, + "display": { + "messageTimestamps": { + "label": "消息时间戳", + "description": "显示每条消息发送的日期和时间" + } + }, "privacy": { "crashReporting": { "label": "崩溃报告与使用分析", diff --git a/src/lib/message-timestamp.test.ts b/src/lib/message-timestamp.test.ts new file mode 100644 index 00000000..f5bf41a5 --- /dev/null +++ b/src/lib/message-timestamp.test.ts @@ -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) +}) diff --git a/src/lib/message-timestamp.ts b/src/lib/message-timestamp.ts new file mode 100644 index 00000000..8f248374 --- /dev/null +++ b/src/lib/message-timestamp.ts @@ -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) +} diff --git a/src/lib/settings-merge.test.ts b/src/lib/settings-merge.test.ts index 20ea6730..c3108828 100644 --- a/src/lib/settings-merge.test.ts +++ b/src/lib/settings-merge.test.ts @@ -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", () => { @@ -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. diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 8a57b3a0..79bf9e4d 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -11,12 +11,14 @@ interface Settings { pageSize: number notifications: Record locale: LocalePreference + showMessageTimestamps: boolean } const DEFAULTS: Settings = { pageSize: 25, notifications: { ...defaultPreferences }, locale: "system", + showMessageTimestamps: false, } interface SettingsState extends Settings { @@ -25,10 +27,16 @@ interface SettingsState extends Settings { setPageSize: (size: number) => Promise setNotification: (category: Category, enabled: boolean) => Promise setLocale: (locale: LocalePreference) => Promise + setShowMessageTimestamps: (show: boolean) => Promise } 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) { @@ -69,4 +77,9 @@ export const useSettings = create((set, get) => ({ setAppLocale(locale) // applies immediately await persist({ ...snapshot(get), locale }) }, + + setShowMessageTimestamps: async (showMessageTimestamps) => { + set({ showMessageTimestamps }) + await persist({ ...snapshot(get), showMessageTimestamps }) + }, }))