diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 2b5086d217e2..07c6745534c8 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -1256,7 +1256,7 @@ for (const delivery of ["steer", "queue"] as const) { if (colors) { await expect(bubble).toHaveAttribute("data-delivery-marker", "pending") - await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model\s*ยท\s*Just now$/, { + await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Queue Model$/, { useInnerText: true, }) await expect(bubble).toHaveCSS("background-color", colors.delivered.background) diff --git a/packages/app/e2e/regression/timeline-geometry.spec.ts b/packages/app/e2e/regression/timeline-geometry.spec.ts index 0ba5bcb5ca15..e1d41a7cf6c0 100644 --- a/packages/app/e2e/regression/timeline-geometry.spec.ts +++ b/packages/app/e2e/regression/timeline-geometry.spec.ts @@ -747,13 +747,13 @@ test("groups consecutive sent prompts under the last one's meta line, with each return Math.round(below.y - above.y - above.height) }) .toBe(4) - await expect(first.locator('[data-slot="user-message-meta-tail"]')).toBeHidden() - await expect(last.locator('[data-slot="user-message-meta-tail"]')).toBeVisible() + await expect(first.locator('[data-slot="user-message-copy-wrapper"]')).toBeHidden() + await expect(last.locator('[data-slot="user-message-copy-wrapper"]')).toBeVisible() // The first message keeps its own actions, shown beside its bubble on hover. await bubble(first).hover() await expect(first.locator('[data-slot="user-message-run-actions"]')).toHaveCSS("opacity", "1") - await expect(first.getByRole("button", { name: "Copy message" })).toHaveCount(1) + await expect(first.getByRole("button", { name: "More actions" })).toHaveCount(1) }) test("leaves a long prompt unfolded while Fold long messages is off", async ({ page }) => { diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts index 8c4ea8354914..707e47552e0c 100644 --- a/packages/app/e2e/regression/timeline-stream.spec.ts +++ b/packages/app/e2e/regression/timeline-stream.spec.ts @@ -3134,11 +3134,11 @@ test("reaches the quote action from the keyboard and blocks a selection over the await page.keyboard.press("Escape") await short.evaluate((element) => window.getSelection()?.selectAllChildren(element)) await expect(toolbar).toBeVisible() - const copy = page.getByRole("button", { name: "Copy message" }) - await copy.focus() + const more = page.getByRole("button", { name: "More actions" }) + await more.focus() await expect(toolbar).toBeVisible() await page.keyboard.press("Tab") - await expect(copy).not.toBeFocused() + await expect(more).not.toBeFocused() expect(await page.evaluate(() => !!document.activeElement?.closest('[data-component="timeline-quote"]'))).toBe(false) await expect(composer).toHaveCount(1) }) diff --git a/packages/session-ui/component-tests/session-message-projection.spec.ts b/packages/session-ui/component-tests/session-message-projection.spec.ts index 28c7fc6211e3..03ca9604ed56 100644 --- a/packages/session-ui/component-tests/session-message-projection.spec.ts +++ b/packages/session-ui/component-tests/session-message-projection.spec.ts @@ -1,47 +1,57 @@ import { expect, story } from "../../stories/playwright/story" -story.describe("message timestamp", () => { +story.describe("message actions menu", () => { story.use({ timezoneId: "UTC" }) - story("shows the full creation date, time, and time zone on hover and focus", async ({ mount, page }) => { + story("shows the creation time and disables actions the message cannot take", async ({ mount, page }) => { const timeline = await mount("current-prompt-message--pending") - const timestamp = timeline.locator('[data-slot="user-message-meta-tail"]') - await expect(timestamp.locator("time")).toHaveText("Jan 1, 2025") - await timeline.getByLabel("Delivery state").selectOption("delivered") const message = timeline.locator('[data-component="user-message"]') + const more = timeline.getByRole("button", { name: "More actions", exact: true }) + const fork = page.getByRole("menuitem", { name: "Fork from here", exact: true }) + const copy = page.getByRole("menuitem", { name: "Copy message", exact: true }) + + // The meta line keeps agent and model; the time moved into the menu. + await expect(timeline.locator('[data-slot="user-message-meta"] time')).toHaveCount(0) + + await timeline.getByLabel("Delivery state").selectOption("delivered") await message.hover() - await expect(timestamp).toHaveCSS("opacity", "1") - await page.clock.install() - await page.clock.pauseAt(new Date(Date.now() + 1000)) - await timestamp.locator("time").hover() - const tooltip = page.getByRole("tooltip") - await page.clock.runFor(799) - await expect(tooltip).toBeHidden() - await page.clock.runFor(1) - await expect(tooltip).toHaveText("Wednesday, January 1, 2025 at 12:00:00 AM UTC") - await page.clock.resume() + await expect(timeline.getByRole("button", { name: "Rewind to here", exact: true })).toBeVisible() + await more.click() + const menu = page.getByRole("menu") + await expect(menu.locator("time")).toHaveText("Jan 1, 2025, 12:00 AM") + await expect(fork).toBeEnabled() + await expect(copy).toBeEnabled() + // An open menu keeps the footer in view after the pointer leaves the message. await page.mouse.move(0, 0) - await expect(tooltip).toBeHidden() - await expect(timestamp).toHaveCSS("opacity", "0") - await timestamp.locator('[data-component="tooltip-v2-trigger"]').focus() - await expect(tooltip).toBeVisible() + await expect(timeline.locator('[data-slot="user-message-copy-wrapper"]')).toHaveCSS("opacity", "1") + await page.keyboard.press("Escape") + await expect(menu).toBeHidden() + // A pending steer cannot fork, and a message without text has nothing to copy. + await timeline.getByLabel("Delivery state").selectOption("steering") + await timeline.getByLabel("Prompt text", { exact: true }).uncheck() + await more.click() + await expect(fork).toHaveAttribute("aria-disabled", "true") + await expect(copy).toHaveAttribute("aria-disabled", "true") + await page.keyboard.press("Escape") + + await timeline.getByLabel("Prompt text", { exact: true }).check() + await timeline.getByLabel("Delivery state").selectOption("delivered") await page.evaluate(() => document.fonts.ready.then(() => undefined)) for (const width of [1000, 390]) { await page.setViewportSize({ width, height: 844 }) const bubble = await timeline.locator('[data-slot="user-message-text"]').boundingBox() - const time = await timestamp.boundingBox() + const trigger = await more.boundingBox() const footer = await timeline.locator('[data-slot="user-message-copy-wrapper"]').boundingBox() - const message = await timeline.locator('[data-component="user-message"]').boundingBox() + const box = await message.boundingBox() - if (!bubble || !time || !footer || !message) throw new Error("Message geometry is unavailable") - expect(Math.abs(bubble.x + bubble.width - message.x - message.width)).toBeLessThan(1) + if (!bubble || !trigger || !footer || !box) throw new Error("Message geometry is unavailable") + expect(Math.abs(bubble.x + bubble.width - box.x - box.width)).toBeLessThan(1) expect(footer.y).toBeGreaterThanOrEqual(bubble.y + bubble.height) expect(footer.x + footer.width).toBeLessThanOrEqual(bubble.x + bubble.width + 1) - expect(time.x).toBeGreaterThanOrEqual(footer.x) - expect(time.x + time.width).toBeLessThanOrEqual(footer.x + footer.width) - expect(Math.abs(time.y + time.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1) + expect(Math.abs(trigger.x + trigger.width - footer.x - footer.width)).toBeLessThan(1) + expect(Math.abs(trigger.y + trigger.height / 2 - footer.y - footer.height / 2)).toBeLessThan(1) } }) }) @@ -50,13 +60,11 @@ story("drops a pending footer at once on delivery with and without hover", async const timeline = await mount("current-prompt-message--pending") const message = timeline.locator('[data-component="user-message"]') const footer = timeline.locator('[data-slot="user-message-copy-wrapper"]') - const timestamp = timeline.locator('[data-slot="user-message-meta-tail"]') const pending = timeline.locator('[data-slot="user-message-transition"]:has([data-slot="user-message-pending"])') await page.mouse.move(0, 0) await expect(footer).toHaveCSS("height", "20px") - await expect(footer).toHaveCSS("margin-top", "4px") - await expect(timestamp).toHaveCSS("opacity", "0") + await expect(footer).toHaveCSS("margin-top", "8px") for (const hover of [true, false]) { await timeline.getByLabel("Delivery state").selectOption("steering") @@ -69,8 +77,6 @@ story("drops a pending footer at once on delivery with and without hover", async if (hover) await message.hover() if (!hover) await page.mouse.move(0, 0) - // Pending stands in for the unsent time, so the actions follow the labels without a gap held for it. - await expect(timestamp).toBeHidden() const label = await timeline.locator('[data-slot="user-message-meta-item"]').last().boundingBox() const actions = await timeline.locator('[data-slot="user-message-actions"]').boundingBox() @@ -99,7 +105,6 @@ story("drops a pending footer at once on delivery with and without hover", async await expect(pending).toBeHidden() await expect(footer).toHaveCSS("opacity", hover ? "1" : "0") - await expect(timestamp).toHaveCSS("opacity", hover ? "1" : "0") } await page.emulateMedia({ reducedMotion: "reduce" }) @@ -145,10 +150,8 @@ story("marks pending metadata without badging delivered, starting, or queued pro "Claude Sonnet 4", "Balanced", ]) - await expect(timeline.locator('[data-slot="user-message-meta-item"][data-variant]')).toHaveCSS("opacity", "0.66") const separator = timeline.locator('[data-slot="user-message-meta-label"] [data-slot="user-message-meta-separator"]') await expect(separator).toHaveText("\u00B7") - await expect(separator).toHaveCSS("opacity", "0.66") await expect(timeline.locator('[data-slot="user-message-meta-label"]')).toHaveCSS("gap", "4px") await timeline.getByLabel("Delivery state").selectOption("steering") @@ -156,33 +159,14 @@ story("marks pending metadata without badging delivered, starting, or queued pro const voice = timeline.getByRole("img", { name: "Voice", exact: true }) await expect(voice).toBeVisible() await expect(indicator).toBeVisible() - await expect(indicator).toHaveAttribute("aria-hidden", "true") + await expect(indicator.locator("svg")).toHaveCount(0) await expect(voice.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-wave-audio") - await expect(indicator.locator("use")).toHaveAttribute("href", "#opencode-v2-icon-outline-steer") - - const icon = await indicator.locator("svg").boundingBox() - const label = await timeline.locator('[data-slot="user-message-meta"]').boundingBox() - - if (!icon || !label) throw new Error("Pending metadata geometry is unavailable") - expect(Math.abs(icon.y + icon.height / 2 - label.y - label.height / 2)).toBeLessThan(0.5) - await expect(voice).toHaveCSS("width", "20px") await expect(voice).toHaveCSS("height", "20px") await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px") await expect(voice.locator('[data-slot="icon-svg"]')).toHaveCSS("height", "14px") - const faint = await indicator.evaluate((element) => { - const probe = document.createElement("span") - probe.style.color = "var(--v2-icon-icon-faint)" - element.append(probe) - const color = getComputedStyle(probe).color - probe.remove() - - return color - }) - - await expect(indicator).toHaveCSS("color", faint) await expect(timeline.locator('[data-slot="user-message-meta"]')).toHaveText(/^Pending/) await timeline.getByLabel("Grouped prompts", { exact: true }).check() await expect(timeline.locator('[data-run-folded] [data-slot="user-message-copy-wrapper"]')).toBeHidden() diff --git a/packages/session-ui/component-tests/session-timeline-mobile.spec.ts b/packages/session-ui/component-tests/session-timeline-mobile.spec.ts index 32a633efd400..a5cbec2a427e 100644 --- a/packages/session-ui/component-tests/session-timeline-mobile.spec.ts +++ b/packages/session-ui/component-tests/session-timeline-mobile.spec.ts @@ -8,7 +8,7 @@ story.describe("touch timeline", () => { expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true) for (const action of [ - { slot: "user-message-copy-wrapper", name: "Copy message" }, + { slot: "user-message-copy-wrapper", name: "More actions" }, { slot: "text-part-copy-wrapper", name: "Copy response" }, ]) { const actions = timeline.locator(`[data-slot="${action.slot}"]`) @@ -18,10 +18,9 @@ story.describe("touch timeline", () => { await expect(actions.getByRole("button", { name: action.name, exact: true })).toBeVisible() } - // Agent and model are omitted on mobile, leaving only the time and duration. + // Agent and model are omitted on mobile, leaving only the response duration. await expect(timeline.locator('[data-slot="user-message-meta-item"]')).toHaveCount(0) await expect(timeline.locator('[data-slot="user-message-pending"]')).toBeHidden() - await expect(timeline.locator('[data-slot="user-message-meta-tail"]')).not.toBeEmpty() const responseMeta = timeline.locator('[data-slot="text-part-meta"]') await expect(responseMeta).not.toBeEmpty() await expect(responseMeta).not.toContainText("Build") @@ -62,7 +61,7 @@ story("desktop message actions still appear on hover and keyboard focus", async expect(await page.evaluate(() => matchMedia("(hover: hover)").matches)).toBe(true) for (const action of [ - { slot: "user-message-copy-wrapper", name: "Copy message" }, + { slot: "user-message-copy-wrapper", name: "More actions" }, { slot: "text-part-copy-wrapper", name: "Copy response" }, ]) { const actions = timeline.locator(`[data-slot="${action.slot}"]`) @@ -72,25 +71,29 @@ story("desktop message actions still appear on hover and keyboard focus", async await actions.locator("..").hover() await expect(actions).toHaveCSS("opacity", "1") await expect(actions).toHaveCSS("pointer-events", "auto") + if (action.slot === "text-part-copy-wrapper") { await expect(actions).toHaveCSS("height", "20px") - await expect(actions).toHaveCSS("margin-top", "4px") - await expect(actions).toHaveCSS("gap", "8px") + await expect(actions).toHaveCSS("margin-top", "8px") + await expect(actions).toHaveCSS("gap", "4px") const meta = actions.locator('[data-slot="text-part-meta"]') await expect(meta).toHaveCSS("font-size", "12px") - await expect(meta).toHaveCSS("line-height", "16px") + await expect(meta).toHaveCSS("line-height", "18px") await expect(meta).toHaveCSS("gap", "4px") await expect(meta.locator('[data-slot="text-part-meta-item"]')).toHaveText(["Build", "Claude Sonnet 4", "4s"]) const separators = meta.locator('[data-slot="text-part-meta-separator"]') await expect(separators).toHaveCount(2) + for (const separator of await separators.all()) { await expect(separator).toHaveText("\u00B7") - await expect(separator).toHaveCSS("opacity", "0.66") } + const copy = actions.getByRole("button", { name: action.name, exact: true }) await expect(copy).toHaveCSS("width", "20px") await expect(copy).toHaveCSS("height", "20px") + await expect(copy.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "14px") } + await page.mouse.move(0, 0) await expect(actions).toHaveCSS("opacity", "0") await actions.getByRole("button", { name: action.name, exact: true }).focus() diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 6588a6eaa003..1d3d782da61f 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -168,16 +168,6 @@ color: var(--v2-icon-icon-muted); } - [data-slot="user-message-pending"] { - display: inline-flex; - align-items: center; - justify-content: center; - flex: none; - width: 14px; - height: 16px; - color: var(--v2-icon-icon-faint); - } - [data-slot="user-message-text"] { display: inline-block; white-space: pre-wrap; @@ -280,7 +270,7 @@ [data-slot="user-message-copy-wrapper"] { height: calc(20px * var(--user-run-meta, 1)); - margin-top: calc(4px * var(--user-run-meta, 1)); + margin-top: calc(8px * var(--user-run-meta, 1)); display: flex; align-items: center; justify-content: flex-end; @@ -326,16 +316,14 @@ overflow: hidden; text-overflow: ellipsis; - /* The variant reads as a qualifier of the model before it, so it is dimmer and never the part that truncates. */ + /* The variant reads as a qualifier of the model before it, so it is never the part that truncates. */ &[data-variant] { flex: none; - opacity: 0.66; } } [data-slot="user-message-meta-separator"] { flex: none; - opacity: 0.66; } [data-slot="user-message-meta-wrap"], @@ -343,7 +331,7 @@ font-family: var(--v2-font-family-sans); font-size: 12px; font-weight: 440; - line-height: var(--line-height-compact); + line-height: 18px; letter-spacing: 0.05px; color: var(--v2-text-text-faint); cursor: default; @@ -504,11 +492,11 @@ [data-slot="text-part-copy-wrapper"] { height: 20px; - margin-top: 4px; + margin-top: 8px; display: flex; align-items: center; justify-content: flex-start; - gap: 8px; + gap: 4px; /* Hover actions show and hide at once, without a fade. */ @media (hover: hover) { @@ -521,6 +509,16 @@ flex: none; width: fit-content; } + + [data-component="icon-button-v2"] [data-slot="icon-svg"] { + width: 14px; + height: 14px; + } + + /* Pulls the glyph toward the reply's text edge. */ + [data-component="tooltip-v2-trigger"]:first-child { + margin-inline-start: -2px; + } } [data-slot="text-part-meta"] { @@ -533,7 +531,7 @@ font-family: var(--v2-font-family-sans); font-size: 12px; font-weight: 440; - line-height: var(--line-height-compact); + line-height: 18px; letter-spacing: 0.05px; color: var(--v2-text-text-faint); cursor: default; @@ -547,19 +545,16 @@ &[data-variant] { flex: none; - opacity: 0.66; } } [data-slot="text-part-meta-separator"] { flex: none; - opacity: 0.66; } [data-slot="text-part-copy-wrapper"][data-interrupted] { width: 100%; justify-content: flex-end; - gap: 8px; } :root[data-motion="reduce"] & [data-slot="text-part-copy-wrapper"] { diff --git a/packages/session-ui/src/message/message-content.tsx b/packages/session-ui/src/message/message-content.tsx index 6c0dbe28679a..469d074c5022 100644 --- a/packages/session-ui/src/message/message-content.tsx +++ b/packages/session-ui/src/message/message-content.tsx @@ -51,7 +51,6 @@ import type { } from "@opencode/client/promise" import type { SessionUserActions, SessionUserAttachmentReference, SessionUserComment } from "../actions" import { agentLabel } from "./agent-label" -import { relativeTime, useNow } from "./relative-time" import { attached, imageAttachmentPlaceholderStyle, typeLabel } from "../components/message-file" export async function writeClipboard(text: string): Promise { @@ -84,13 +83,7 @@ export async function writeClipboard(text: string): Promise { type MetaItem = { label: string; variant?: boolean } -const RELATIVE_TIME_KEYS = { - minute: "ui.message.time.minutesAgo", - hour: "ui.message.time.hoursAgo", - day: "ui.message.time.daysAgo", -} as const - -// The variant follows the model name without a separator and renders dimmer, so the model leads. +// The variant follows the model name without a separator, so the model leads. function modelItems(data: ReturnType, model: SessionMessageAssistant["model"]): MetaItem[] { const name = data.store.provider?.all?.get(model.providerID)?.models?.[model.id]?.name ?? model.id @@ -327,7 +320,6 @@ export function CurrentUserMessageDisplay(props: { const i18n = useI18n() const [state, setState] = createStore({ - copied: false, reverting: false, forking: false, updating: false, @@ -351,10 +343,12 @@ export function CurrentUserMessageDisplay(props: { const inlineFiles = createMemo(() => files().filter((file) => !!file.mention)) const agents = createMemo(() => props.message.agents ?? []) const comments = createMemo(() => props.comments ?? []) - const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale())) - const datefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { dateStyle: "medium" })) - const fulltimefmt = createMemo(() => new Intl.DateTimeFormat(i18n.locale(), { dateStyle: "full", timeStyle: "long" })) - const now = useNow() + + const sent = createMemo(() => + new Intl.DateTimeFormat(i18n.locale(), { dateStyle: "medium", timeStyle: "short" }).format( + props.message.time.created, + ), + ) // Mobile shows the current agent and model in the composer and switch notices, so repeating them per message is noise. const mobile = createMediaQuery("(max-width: 767px)") @@ -367,17 +361,6 @@ export function CurrentUserMessageDisplay(props: { const metaLabels = createMemo(() => (mobile() ? [] : labels())) - const stamp = createMemo(() => { - const created = props.message.time.created - const relative = relativeTime(created, now()) - - if (relative.unit === "now") return i18n.t("ui.message.time.justNow") - - if (relative.unit === "date") return datefmt().format(created) - - return i18n.t(RELATIVE_TIME_KEYS[relative.unit], { count: numfmt().format(relative.count) }) - }) - // In a run, only the last message keeps the meta line; the others offer their actions beside the bubble. // Bubble corners tighten where two bubbles meet, as in the iOS app's grouped corners. const folded = createMemo(() => !!props.run?.below && !!props.text) @@ -423,12 +406,6 @@ export function CurrentUserMessageDisplay(props: { ), ) - const copy = async (text: string) => { - if (!text || !(await writeClipboard(text))) return - setState("copied", true) - setTimeout(() => setState("copied", false), 2000) - } - const revert = async () => { if (!props.actions?.revert || state.reverting) return setState("reverting", true) @@ -506,71 +483,41 @@ export function CurrentUserMessageDisplay(props: { /> - - - + + } size="small" - label={i18n.t("ui.message.forkMessage")} - disabled={state.forking} - onMouseDown={(event) => event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void fork() - }} - aria-label={i18n.t("ui.message.forkMessage")} + variant="ghost-muted" + onMouseDown={(event: MouseEvent) => event.preventDefault()} + onClick={(event: MouseEvent) => event.stopPropagation()} + aria-label={i18n.t("ui.message.moreActions")} /> - - - {/* Like the iOS app, a Voice message's copy also offers the full prompt the session received, on right click. */} - - event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void copy(props.text) - }} - aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - - } - > - - - } - size="small" - variant="ghost-muted" - onMouseDown={(event) => event.preventDefault()} - onClick={(event) => { - event.stopPropagation() - void copy(props.text) - }} - aria-label={state.copied ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - - - - void copy(props.text)}>{i18n.t("ui.message.copyMessage")} - void copy(props.message.text)}> + + + + + + + + + + void fork()}> + {i18n.t("ui.message.forkMessage")} + + void writeClipboard(props.text)}> + {i18n.t("ui.message.copyMessage")} + + {/* Like the iOS app, a Voice message also offers the full prompt the session received. */} + + void writeClipboard(props.message.text)}> {i18n.t("ui.message.copyFullMessage")} - - - - + + + + ) @@ -765,10 +712,7 @@ export function CurrentUserMessageDisplay(props: { - - {i18n.t("ui.message.pending")} + {i18n.t("ui.message.pending")} 0}> - {/* A pending steer has not been sent, so Pending stands in for the time instead of reserving its width. */} - - - 0}> - - - - - - - {renderActions()} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index bdedfd69038f..33ed25e46074 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -284,6 +284,7 @@ const source = { "ui.message.copyFullMessage": "Copy full message", "ui.message.forkMessage": "Fork from here", "ui.message.revertMessage": "Rewind to here", + "ui.message.moreActions": "More actions", "ui.message.moveToQueue": "Move to queue", "ui.message.pending": "Pending", "ui.message.voice": "Voice",