diff --git a/CONTEXT.md b/CONTEXT.md index e9fa09526..89d884825 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -133,12 +133,12 @@ A channel's outgoing language, set from the panel and persisted alongside the re _Avoid_: send language, outgoing switch **the strip**: -The `.translate-bar` above the input (`ChatInput.vue`) that shows a draft's outgoing translation: the chip, the streaming text, Check/Send/Edit, and the read-back row underneath. A different draft, a channel switch, Escape or the strip's own Edit takes it down. +The `.translate-bar` above the input (`ChatInput.vue`) that shows a draft's outgoing translation while it is typed (the live preview): the "Will send in …" label, the streaming text, Copy, and the read-back row underneath. A changed draft dims it until the next preview; a send, a channel switch or Escape takes it down. _Avoid_: translation bar, preview box -**the first Enter / the second Enter**: -The first Enter on a draft with a write target starts its translation (`translateOutgoing`) instead of sending; the second Enter, once the strip shows a result (or a failure), sends what the strip holds. -_Avoid_: initial submit, confirm submit +**the one-step send**: +Return on a draft with a write target sends what the strip holds; before the preview is done it waits for it and sends then (`sendQueued` in `ChatInput.vue`). After a failure the next Return sends the draft as written. (It replaced the first Enter / second Enter pair.) +_Avoid_: initial submit, confirm submit, second Enter **read back**: The round trip: a done translation translated once more, back toward the user's reading language, shown under the strip so a failed or a subtly wrong translation can be caught before sending. `"auto"` runs it as soon as the translation ends; `"button"` waits for Check. diff --git a/client/components/Chat.vue b/client/components/Chat.vue index 8b0a40b0f..93f8d547e 100644 --- a/client/components/Chat.vue +++ b/client/components/Chat.vue @@ -71,21 +71,6 @@ :aria-label="connectingLabel" /> - - + +
+

{{ suggestionLead }}

+

{{ suggestionNote }}

+
+ + +
+
{{ - outgoingChip + outgoing ? outgoingChip : stale?.chip }} + {{ stale.text + }} + {{ t("translate.strip.failed") }}{{ shortReason(outgoing.error) }} - {{ outgoing.text }}{{ outgoingDownload @@ -116,9 +161,8 @@ aria-hidden="true" > - +
+

+ {{ t("translate.strip.sendsWhenReady") }} +

{{ outgoingCheckLabel }} @@ -162,6 +192,7 @@ class="translate-bar-text" dir="auto" :lang="outgoing.check.to ?? undefined" + :title="outgoing.check.text" >{{ outgoing.check.text }} - + + + @@ -264,7 +301,7 @@ id="submit" type="submit" :aria-label="sendLabel" - :disabled="!canSend || outgoingBusy" + :disabled="!canSend" @mousedown.prevent /> @@ -321,13 +358,16 @@ import { pairVerdict, readingLanguage, setChannelOptions, - translationAvailable, + setReading, + translationMayRun, } from "../js/translate/reader"; -import SourceLanguagePicker from "./SourceLanguagePicker.vue"; -import {loadNoteText} from "../js/helpers/modelLabel"; +import ComposerLanguageSheet from "./ComposerLanguageSheet.vue"; +import {loadNoteText, modelLabel} from "../js/helpers/modelLabel"; +import friendlysize from "../js/helpers/friendlysize"; +import {recentLanguages, suggestedLanguage} from "../js/translate/recent"; import {translateErrorText} from "../js/helpers/translateErrors"; import {translateWhyText} from "../js/helpers/translateWhy"; -import {loadNote} from "../js/translate/service"; +import {downloadBytes, loadNote} from "../js/translate/service"; import { cancelOutgoing, lastOnceTarget, @@ -346,6 +386,9 @@ const REASON_MAX = 120; /** How long the strip's Copy button reads "Copied" after a successful copy. */ const COPIED_LABEL_MS = 2000; + +/** The pause in typing after which the draft is translated into the strip. */ +const PREVIEW_PAUSE_MS = 700; import {TypingReporter} from "../js/helpers/typingReporter"; import TypingIndicator from "./TypingIndicator.vue"; import QuotePreview from "./QuotePreview.vue"; @@ -389,7 +432,7 @@ const bracketWraps = { export default defineComponent({ name: "ChatInput", - components: {SourceLanguagePicker, TypingIndicator, QuotePreview, ReactionPicker}, + components: {ComposerLanguageSheet, TypingIndicator, QuotePreview, ReactionPicker}, props: { network: {type: Object as PropType, required: true}, channel: {type: Object as PropType, required: true}, @@ -584,6 +627,11 @@ export default defineComponent({ emojiPick.value = null; }; + // Set by real typing (the input event), so only typing previews: a + // channel switch restoring a saved draft, or ArrowUp through the + // history, costs no translation run nobody asked for. + let typedChange = false; + const setPendingMessage = (e: Event) => { const el = e.target as HTMLTextAreaElement; @@ -600,6 +648,7 @@ export default defineComponent({ enterNewlineDeadline = 0; + typedChange = true; props.channel.pendingMessage = el.value; props.channel.inputHistoryPosition = 0; props.channel.editDismissed = false; // typing re-arms ArrowUp-to-edit @@ -611,20 +660,9 @@ export default defineComponent({ const getInputPlaceholder = (channel: ClientChan) => { if (channel.type === ChanType.CHANNEL || channel.type === ChanType.QUERY) { - const to = writeTarget(props.network, channel); - - // A write target that is the user's own language sends their - // lines corrected (writer.ts), and the placeholder says so. - if (to === readingLanguage()) { - return t("translate.composer.placeholderCorrected", {name: channel.name}); - } - - return to - ? t("translate.composer.placeholder", { - name: channel.name, - language: languageName(to, readingLanguage()), - }) - : t("composer.placeholder", {name: channel.name}); + // The send target is on the translate button ("EN → PT"), not + // here: a language name made the placeholder wrap on a phone. + return t("composer.placeholder", {name: channel.name}); } return ""; @@ -796,6 +834,44 @@ export default defineComponent({ // keeps it like it keeps the draft. const outgoing = computed(() => store.state.outgoingTranslations[props.channel.id]); + let previewTimer: ReturnType | null = null; + const stalePreview = ref<{chanId: number; text: string; chip: string; to: string} | null>( + null + ); + const stale = computed(() => { + const kept = stalePreview.value; + const entry = outgoing.value; + + if (!kept || kept.chanId !== props.channel.id) { + return null; + } + + return !entry || (entry.status === "pending" && entry.text === "") ? kept : null; + }); + // A Return that came before its preview was done: sent once it is. + // `draft` is what the strip translates (a `/me`'s text), `raw` the + // whole draft: "/me waves" and "waves" share a `draft`, not a send. + const sendQueued = ref<{ + chanId: number; + draft: string; + raw: string; + retried?: boolean; + } | null>(null); + const sendQueuedHere = computed(() => sendQueued.value?.chanId === props.channel.id); + + const clearPreviewTimer = () => { + if (previewTimer !== null) { + clearTimeout(previewTimer); + previewTimer = null; + } + }; + + const forgetPreview = () => { + clearPreviewTimer(); + stalePreview.value = null; + sendQueued.value = null; + }; + // The strip's labels name their languages in the language the user // reads this channel in, not the browser's (reader.ts readingLanguage). const readerName = (code: string) => languageName(code, readingLanguage()); @@ -814,18 +890,14 @@ export default defineComponent({ return t("translate.strip.polishChip", {language: readerName(entry.to)}); } - return entry.from - ? `${readerName(entry.from)} → ${readerName(entry.to)}` - : `→ ${readerName(entry.to)}`; + // What the strip is: the line Return will send, and its language + // (a one-off's is on no button). + return t("translate.strip.willSendIn", {language: readerName(entry.to)}); }); // The read-back row's label: the translation's language → the one it // is read back into, the user's reading language. - const outgoingCheckLabel = computed(() => - outgoing.value - ? `${readerName(outgoing.value.to)} → ${readerName(readingLanguage())}` - : "" - ); + const outgoingCheckLabel = computed(() => t("translate.strip.readsBack")); // Which route the strip's text came down, in the chip's title: the // pair as the request asked for it ("auto" where the source was left @@ -943,14 +1015,14 @@ export default defineComponent({ const copyLabel = computed(() => copiedOutgoing.value ? t("translate.strip.copied") : t("translate.strip.copy") ); - const keepTypingLabel = computed(() => t("translate.strip.keepTyping")); const outgoingSendLabel = computed(() => outgoing.value?.status === "failed" ? t("translate.strip.sendAsWritten") : t("translate.strip.sendTranslation") ); - // Send waits for the translation, and for the automatic check. + // A translation or its check still running: the one-off dialog's + // "This message only" waits for it. const outgoingBusy = computed(() => { const entry = outgoing.value; @@ -1015,6 +1087,7 @@ export default defineComponent({ props.channel.inputHistoryPosition = 0; props.channel.pendingMessage = ""; + forgetPreview(); clearInput(); setInputSize(); @@ -1077,7 +1150,7 @@ export default defineComponent({ props.channel.editing = null; }; - const onSubmit = (fromEnterKey = false) => { + const onSubmit = (fromEnterKey = false, queued = false) => { if (!input.value) { return; } @@ -1089,8 +1162,12 @@ export default defineComponent({ // Triggering click event opens the virtual keyboard on mobile // This can only be called from another interactive event (e.g. button click) - input.value.click(); - input.value.focus(); + // -- which a send waiting for its preview is not: it leaves the + // focus wherever the user has gone meanwhile. + if (!queued) { + input.value.click(); + input.value.focus(); + } // No global gate: `/connect` and friends must work from a // disconnected network. Plain text in a conversation on a @@ -1143,7 +1220,11 @@ export default defineComponent({ const gate = draftGate(text, !!editing); if (entry && entry.draft === gate.text) { - if (outgoingBusy.value) { + // One step: a Return while the preview is still coming sends + // it the moment it is done (the watch on `sendQueued`). The + // read-back is for looking at, not a gate. + if (entry.status === "pending") { + sendQueued.value = {chanId: props.channel.id, draft: gate.text, raw: text}; return false; } @@ -1205,6 +1286,14 @@ export default defineComponent({ // went down in the meantime all leave the draft where it is. const startedFor = props.channel.id; + clearPreviewTimer(); + sendQueued.value = { + chanId: startedFor, + draft: gate.text, + raw: text, + retried: queued, + }; + void translateOutgoing(props.network, props.channel, gate.text).then((verdict) => { if ( verdict === "plain" && @@ -1369,19 +1458,21 @@ export default defineComponent({ }); }; - // The translate button is the channel's send target as a toggle, - // independent of the globe (which is reading): off, a click opens - // the dialog and the pick becomes the write target (channelStore - // `write`, the same setting the panel's "Send my messages in" shows) - // until the button is clicked again; the dialog's "This message - // only" makes the pick a one-off instead (writer.ts `translateOnce`), - // the way the toolbar's Translate reads one line. Shown wherever - // translation can run at all. + // The translate button opens the language sheet + // (ComposerLanguageSheet.vue), independent of the globe (which is + // reading): the pick becomes the channel's write target (channelStore + // `write`, the same setting the panel's "Send my messages in" shows), + // lit on the button until the sheet's Off; its "This message only" + // makes the pick a one-off instead (writer.ts `translateOnce`), the + // way the toolbar's Translate reads one line. Shown wherever + // translation can run at all, and while the device probe is still + // out: a button that appeared when it landed shifted the composer on + // every app load. const translateOnceButton = ref(null); const translateOncePickerOpen = ref(false); const translateOnceAvailable = computed( () => - translationAvailable() && + translationMayRun() && (props.channel.type === ChanType.CHANNEL || props.channel.type === ChanType.QUERY) ); // Translation switched off (or no longer able to run here): the @@ -1401,8 +1492,25 @@ export default defineComponent({ !outgoingBusy.value ); }); - // The channel's send target: the button is lit while one is set. + // The channel's send target: the button is lit while one is set, + // and reads it as "EN → PT" (the language the user reads → the target). const sendTarget = computed(() => writeTarget(props.network, props.channel)); + const codeOf = (code: string) => code.split("-")[0].toUpperCase(); + const sendTargetCode = computed(() => { + const to = sendTarget.value; + + if (!to) { + return ""; + } + + // The user's own language: lines go corrected, not translated. + return to === readingLanguage() + ? t("translate.composer.targetCorrected", {language: codeOf(to)}) + : t("translate.composer.targetPair", { + from: codeOf(readingLanguage()), + to: codeOf(to), + }); + }); const translateOnceLabel = computed(() => { const to = sendTarget.value; @@ -1419,43 +1527,56 @@ export default defineComponent({ // Read when the dialog opens: the last pick is plain session memory // (writer.ts), not store state, so nothing would recompute it. const translateOncePreselected = ref(""); - // The dialog's source: the language the user reads. - const translateOnceFrom = computed(() => readingLanguage()); + + // The sheet's "I write": the language the user said they write in + // with the target that is set, else the one they read. Null (detect + // each draft) only where the target was set that way. + const translateOnceFrom = ref(null); + + // Off: the send target goes, and a strip in flight for it with it. + const turnTranslateOff = () => { + setChannelOptions(props.network, props.channel, {write: null, writeFrom: null}); + cancelOutgoing(props.channel); + forgetPreview(); + focusForTyping(); + }; const openTranslateOnce = () => { if (!canSend.value) { return; } - // On: the click turns the send target off; a strip in flight for - // it goes too. Off: the dialog. - if (sendTarget.value) { - setChannelOptions(props.network, props.channel, {write: null}); - cancelOutgoing(props.channel); - focusForTyping(); + if (translateOncePickerOpen.value) { + translateOncePickerOpen.value = false; return; } - // The channel's last pick, else a language the channel speaks, - // else the user's own (the cleanup pass) -- each only where this - // device can serve it from the reading language, so a device - // without the GPU model never opens on a disabled cleanup pass - // (outgoing.ts `onceTargetDefault`). const reading = readingLanguage(); - translateOncePreselected.value = onceTargetDefault({ - reading, - last: lastOnceTarget(props.network, props.channel), - declared: channelTranslation(props.network, props.channel).languages, - fallback: SUPPORTED_LANGUAGES, - routes: (to) => !!pairVerdict(reading, to).route, - }); - translateOncePickerOpen.value = !translateOncePickerOpen.value; + const target = sendTarget.value; + + // The target that is set; else the channel's last pick, a + // language the channel speaks, or the user's own (the cleanup + // pass) -- each only where this device can serve it from the + // reading language, so a device without the GPU model never + // opens on a disabled cleanup pass (outgoing.ts `onceTargetDefault`). + translateOncePreselected.value = + target ?? + onceTargetDefault({ + reading, + last: lastOnceTarget(props.network, props.channel), + declared: channelTranslation(props.network, props.channel).languages, + fallback: SUPPORTED_LANGUAGES, + routes: (to) => !!pairVerdict(reading, to).route, + }); + translateOnceFrom.value = target + ? channelTranslation(props.network, props.channel).writeFrom + : reading; + translateOncePickerOpen.value = true; }; // A same-language pick is a cleanup pass on the GPU model - // (writer.ts, router.ts): the dialog asks the router's own rule + // (writer.ts, router.ts): the sheet asks the router's own rule // (reader.ts `pairVerdict`) and says why when it cannot run. - const translateOnce = (to: string, from: string | null = null, once = false) => { translateOncePickerOpen.value = false; @@ -1465,6 +1586,10 @@ export default defineComponent({ canTranslateOnce.value && (gate.kind === "ok" || gate.kind === "action"); if (once) { + // A Return waiting on the target's preview is not a send of + // the one-off: that strip is for reading first. + forgetPreview(); + // The verdict is checked against the composer it started in, // as the first Enter's is: a "plain" verdict (the draft is // already in that language) leaves the draft where it is -- @@ -1479,7 +1604,31 @@ export default defineComponent({ // The mode: the channel's send target, from here on. A draft // already typed gets the strip now rather than on its Enter. - setChannelOptions(props.network, props.channel, {write: to}); + // With the language the user said they write in: each draft is + // taken as that, not guessed at (a short English line scores as + // Afrikaans often enough). The same pair again changes nothing, + // a finished preview included; a strip for a target it replaces goes. + if ( + to === sendTarget.value && + from === channelTranslation(props.network, props.channel).writeFrom + ) { + focusForTyping(); + return; + } + + cancelOutgoing(props.channel); + forgetPreview(); + setChannelOptions(props.network, props.channel, {write: to, writeFrom: from}); + + // Writing to a channel in another language is reading it too: + // the replies come back in that language. Turning the target + // off later leaves reading as it is. + if ( + to !== readingLanguage() && + !channelTranslation(props.network, props.channel).read + ) { + setReading(props.network, props.channel, true); + } if (translatable) { void translateOutgoing(props.network, props.channel, gate.text); @@ -1488,11 +1637,161 @@ export default defineComponent({ focusForTyping(); }; - const cancelOutgoingNow = () => { - cancelOutgoing(props.channel); + // The suggestion card (spec § Composer): where a channel's recent + // lines from others are mostly in one language that is not the + // user's -- by the reading pipeline's own verdicts, so only with + // reading on -- and nothing is set or declined, offer to write in it. + const suggestion = computed(() => { + if ( + !translateOnceAvailable.value || + props.channel.type !== ChanType.CHANNEL || + sendTarget.value || + props.channel.editing || + channelTranslation(props.network, props.channel).noSuggest + ) { + return null; + } + + const reading = readingLanguage(); + const code = suggestedLanguage( + recentLanguages(props.channel.messages, store.state.translations), + reading + ); + const route = code ? pairVerdict(reading, code).route : null; + + return code && route ? {code, route} : null; + }); + const suggestionName = computed(() => + suggestion.value ? languageName(suggestion.value.code, readingLanguage()) : "" + ); + const suggestionLead = computed(() => + suggestion.value + ? t("translate.suggest.lead", { + channel: props.channel.name, + language: suggestionName.value, + }) + : "" + ); + // What saying yes costs, where it costs a download. + const suggestionNote = computed(() => { + const route = suggestion.value?.route; + const size = route ? downloadBytes(route, store.state.translation.models) : 0; + + return route && size > 0 + ? t("translate.suggest.download", { + model: modelLabel(route.ref), + size: friendlysize(size), + }) + : ""; + }); + const acceptSuggestion = () => { + const code = suggestion.value?.code; + + if (code) { + translateOnce(code, readingLanguage()); + } + }; + const declineSuggestion = () => { + setChannelOptions(props.network, props.channel, {noSuggest: true}); focusForTyping(); }; + // The live preview (spec § Composer): with a send target set, a + // pause in typing translates the draft into the strip, so the Return + // that follows sends what the user has already read. The last + // preview stays up, dimmed, while the draft changes under it. + const runPreview = () => { + previewTimer = null; + const draft = props.channel.pendingMessage ?? ""; + const gate = draftGate(draft, !!props.channel.editing); + const to = writeTarget(props.network, props.channel); + + if ( + !canSend.value || + gate.text.trim() === "" || + !firstEnterTranslates(gate, to, translationOn()) + ) { + stalePreview.value = null; + return; + } + + if (outgoing.value?.draft === gate.text) { + return; + } + + void translateOutgoing(props.network, props.channel, gate.text).then((verdict) => { + // Already in the target language: nothing to preview. + if (verdict === "plain" && !outgoing.value) { + stalePreview.value = null; + } + }); + }; + + const schedulePreview = () => { + clearPreviewTimer(); + + if (!writeTarget(props.network, props.channel)) { + return; + } + + previewTimer = setTimeout(runPreview, PREVIEW_PAUSE_MS); + }; + + watch( + () => [outgoing.value?.status, outgoing.value?.text] as const, + () => { + const entry = outgoing.value; + const queued = sendQueued.value; + + if (!entry) { + // The run ended with no strip: the draft needs none (a + // "plain" verdict), or the target moved under it. A Return + // waiting on it is answered once, by the path a Return with + // no strip takes -- which sends a "plain" draft as typed. + if (queued && queued.chanId === props.channel.id) { + sendQueued.value = null; + + if (!queued.retried && queued.raw === props.channel.pendingMessage) { + void nextTick(() => onSubmit(false, true)); + } + } + + return; + } + + if (entry.status === "done" && entry.text) { + stalePreview.value = { + chanId: props.channel.id, + text: entry.text, + chip: outgoingChip.value, + to: entry.to, + }; + } else if (entry.status === "failed") { + stalePreview.value = null; + } + + if ( + !queued || + queued.chanId !== props.channel.id || + queued.draft !== entry.draft || + queued.raw !== props.channel.pendingMessage + ) { + return; + } + + // Done: it goes. Failed: the strip says so and the next Return + // sends the draft as written, which the user chooses. + if (entry.status === "done") { + sendQueued.value = null; + void nextTick(() => onSubmit(false, true)); + } else if (entry.status === "failed") { + sendQueued.value = null; + } + } + ); + + onUnmounted(clearPreviewTimer); + const onBlur = () => { if (autocompletionRef.value) { autocompletionRef.value.hide(); @@ -1508,6 +1807,9 @@ export default defineComponent({ // A draft left in the previous channel is reported `paused` there. typing.switchTarget(); + forgetPreview(); + // The sheet was opened for the channel left behind. + translateOncePickerOpen.value = false; focusForTyping(); } ); @@ -1540,6 +1842,24 @@ export default defineComponent({ if (entry && typed !== entry.draft) { cancelOutgoing(props.channel); } + + const queued = sendQueued.value; + const raw = + hasVirtualKeyboard() && value.endsWith("\n") ? value.slice(0, -1) : value; + + if (queued && queued.raw !== raw) { + sendQueued.value = null; + } + + const fromTyping = typedChange; + + typedChange = false; + + if (typed.trim() === "") { + forgetPreview(); + } else if (fromTyping && (!entry || typed !== entry.draft)) { + schedulePreview(); + } } ); @@ -1594,8 +1914,9 @@ export default defineComponent({ // (the global handler in App.vue blurs the input otherwise). inputTrap.bind("esc", () => { // A translation strip goes first (spec § Composer 6); the draft stays. - if (store.state.outgoingTranslations[props.channel.id]) { + if (store.state.outgoingTranslations[props.channel.id] || stale.value) { cancelOutgoing(props.channel); + forgetPreview(); return false; } @@ -1831,11 +2152,12 @@ export default defineComponent({ shortReason, outgoingBusy, sendTooltip, - cancelOutgoingNow, + stale, + sendQueuedHere, + sendTargetCode, copiedOutgoing, copyOutgoing, copyLabel, - keepTypingLabel, outgoingSendLabel, translateOnceButton, translateOncePickerOpen, @@ -1847,6 +2169,13 @@ export default defineComponent({ sendTarget, openTranslateOnce, translateOnce, + turnTranslateOff, + suggestion, + suggestionName, + suggestionLead, + suggestionNote, + acceptSuggestion, + declineSuggestion, }; }, }); diff --git a/client/components/ComposerLanguageSheet.vue b/client/components/ComposerLanguageSheet.vue new file mode 100644 index 000000000..b76d6caff --- /dev/null +++ b/client/components/ComposerLanguageSheet.vue @@ -0,0 +1,593 @@ + + + diff --git a/client/components/Mentions.vue b/client/components/Mentions.vue index 78ca8f178..5bd73301f 100644 --- a/client/components/Mentions.vue +++ b/client/components/Mentions.vue @@ -2,13 +2,14 @@