diff --git a/.prettierignore b/.prettierignore index 0bf45acfa..b49bd7a09 100644 --- a/.prettierignore +++ b/.prettierignore @@ -76,3 +76,5 @@ tools/i18n/fixtures/ # Generated measurement tables (tools/translate-eval/roundtrip.ts); column padding would multiply their size. tools/translate-eval/results/ +# generated by test/formal/handshake/gen.sh +test/formal/handshake/handshake.traces.json 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/App.vue b/client/components/App.vue index 67bbea21e..f16f1e4bc 100644 --- a/client/components/App.vue +++ b/client/components/App.vue @@ -27,6 +27,7 @@ +
@@ -47,6 +48,7 @@ import ImageViewer from "./ImageViewer.vue"; import ContextMenu from "./ContextMenu.vue"; import ConfirmDialog from "./ConfirmDialog.vue"; import UploadPreview from "./UploadPreview.vue"; +import BanDialog from "./BanDialog.vue"; import OperActionDialog from "./Oper/ActionDialog.vue"; import InvestigateCard from "./Oper/InvestigateCard.vue"; import AlertBuilder from "./Oper/AlertBuilder.vue"; @@ -81,6 +83,7 @@ export default defineComponent({ ConfirmDialog, UploadPreview, OperActionDialog, + BanDialog, InvestigateCard, AlertBuilder, Mentions, diff --git a/client/components/BanDialog.vue b/client/components/BanDialog.vue new file mode 100644 index 000000000..0939ef2a8 --- /dev/null +++ b/client/components/BanDialog.vue @@ -0,0 +1,670 @@ + + + + + diff --git a/client/components/Chat.vue b/client/components/Chat.vue index 7a3154379..5018a1283 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 }} + + - + + + @@ -252,7 +301,7 @@ id="submit" type="submit" :aria-label="sendLabel" - :disabled="!canSend || outgoingBusy" + :disabled="!canSend" @mousedown.prevent /> @@ -290,6 +339,7 @@ import { findEditableAfter, findEditableBefore, findLastEditable, + editText, startEdit, } from "../js/helpers/compose"; import {hasVirtualKeyboard} from "../js/helpers/device"; @@ -308,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, @@ -333,9 +386,20 @@ 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"; +import ReactionPicker from "./ReactionPicker.vue"; +import { + closedShortcode, + emojiToken, + replaceToken, + type EmojiToken, +} from "../js/helpers/emojiTrigger"; +import {emojiForName} from "../js/helpers/emoji"; // How long a conversation's network has to be down before the strip above // the composer says so. @@ -368,7 +432,7 @@ const bracketWraps = { export default defineComponent({ name: "ChatInput", - components: {SourceLanguagePicker, TypingIndicator, QuotePreview}, + components: {ComposerLanguageSheet, TypingIndicator, QuotePreview, ReactionPicker}, props: { network: {type: Object as PropType, required: true}, channel: {type: Object as PropType, required: true}, @@ -445,6 +509,129 @@ export default defineComponent({ } }; + // ---- The emoji picker over the composer ------------------------ + // Typing `:` and two letters of a name opens the message toolbar's + // picker with its search seeded (helpers/emojiTrigger.ts). The + // composer keeps the keyboard, so typing on is never lost to the + // picker (`:wq then` stays text): each keystroke follows into the + // picker's search, and ArrowUp/Down, Enter, Tab and Escape are handed + // to it (`onComposerKeydown`); Enter with nothing to insert sends as + // typed, and Shift+Enter stays the composer's line break. A space, or + // the colon gone, closes it; so do sending and another channel. Escape + // or a click away leaves the `:name` as typed, and that same token does + // not open it again; the emoji picked replaces it. Typing the closing + // colon of a shortcode (`:tada:`) puts its emoji there at once. Only + // typing opens it: a paste that ends in `:name` does not. + const emojiPick = ref(null); + const emojiPicker = ref<{navigate(key: string, shift?: boolean): boolean} | null>(null); + let emojiDismissedAt = -1; + let emojiPicked = false; + + const followEmojiToken = (el: HTMLTextAreaElement, typed: boolean) => { + const closed = + typed && el.selectionStart === el.selectionEnd + ? closedShortcode(el.value, el.selectionStart) + : null; + const emoji = closed ? emojiForName(closed.term) : undefined; + + if (closed && emoji) { + const next = replaceToken(el.value, closed, emoji); + + el.value = next.text; + el.setSelectionRange(next.caret, next.caret); + props.channel.pendingMessage = next.text; + setInputSize(); + emojiDismissedAt = -1; + emojiPick.value = null; + return; + } + + const token = + el.selectionStart === el.selectionEnd + ? emojiToken(el.value, el.selectionStart) + : null; + + if (!token) { + emojiDismissedAt = -1; + emojiPick.value = null; + return; + } + + if (token.start === emojiDismissedAt || (!typed && !emojiPick.value)) { + emojiPick.value = null; + return; + } + + emojiPick.value = token; + }; + + const onComposerKeydown = (e: KeyboardEvent) => { + const picker = emojiPicker.value; + + if ( + !emojiPick.value || + !picker || + e.isComposing || + e.altKey || + e.ctrlKey || + e.metaKey + ) { + return; + } + + if (!["ArrowUp", "ArrowDown", "Enter", "Escape", "Tab"].includes(e.key)) { + return; + } + + // Shift+Enter is the composer's line break, never a pick. + if (e.shiftKey) { + return; + } + + if (picker.navigate(e.key)) { + // Not Mousetrap's arrows (input history), not the Return that + // sends, not the Escape that blurs the composer. + e.preventDefault(); + e.stopImmediatePropagation(); + } + }; + + const insertEmoji = (emoji: string) => { + const token = emojiPick.value; + const el = input.value; + + if (!token || !el) { + return; + } + + const next = replaceToken(props.channel.pendingMessage, token, emoji); + + emojiPicked = true; + props.channel.pendingMessage = next.text; + el.value = next.text; + setInputSize(); + + // After the picker has gone and handed the focus back. + void nextTick(() => { + el.focus(); + el.setSelectionRange(next.caret, next.caret); + }); + }; + + const closeEmojiPick = () => { + if (!emojiPicked && emojiPick.value) { + emojiDismissedAt = emojiPick.value.start; + } + + emojiPicked = false; + 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; @@ -461,29 +648,21 @@ 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 setInputSize(); reportTyping(); + const kind = (e as InputEvent).inputType ?? ""; + followEmojiToken(el, kind === "insertText" || kind === "insertCompositionText"); }; 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 ""; @@ -614,6 +793,9 @@ export default defineComponent({ () => props.channel.id, () => { sendRefused.value = false; + // The picker's token is a place in the other channel's draft. + emojiPick.value = null; + emojiDismissedAt = -1; } ); @@ -652,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()); @@ -670,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 @@ -799,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; @@ -868,10 +1084,10 @@ export default defineComponent({ // What this brings lands in the present, so that is where to be. closePast(props.channel); - store.commit("searchReturn", null); props.channel.inputHistoryPosition = 0; props.channel.pendingMessage = ""; + forgetPreview(); clearInput(); setInputSize(); @@ -919,7 +1135,7 @@ export default defineComponent({ } // An edit keeps the parent of the message it replaces; the IRC layer - // only honours `reply`/`edit` for plain text (and `reply` for /me). + // honours `reply`/`edit` for plain text and /me. const reply = editing ? editing.replyTo : replyTo?.msgid; const edit = editing?.msgid; @@ -934,15 +1150,24 @@ export default defineComponent({ props.channel.editing = null; }; - const onSubmit = (fromEnterKey = false) => { + const onSubmit = (fromEnterKey = false, queued = false) => { if (!input.value) { return; } + // Sending ends a `:name` being picked for (a phone's Return, the + // send button): it goes as typed. + emojiPick.value = null; + emojiDismissedAt = -1; + // 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 @@ -977,7 +1202,7 @@ export default defineComponent({ const editing = props.channel.editing; // Editing to the identical text is a no-op: just leave edit mode. - if (editing && text === editing.text) { + if (editing && text === editText(editing)) { cancelCompose(props.channel); clearInput(); setInputSize(); @@ -995,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; } @@ -1057,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" && @@ -1188,7 +1425,10 @@ export default defineComponent({ ? !!props.channel.pendingMessage : focused?.matches("input, textarea, [contenteditable]"); - if (layer === null && props.channel.past && !fieldHasIt) { + // With search open, Escape is search's (Windows/SearchResults.vue). + const searchHasIt = !!document.querySelector("#search-panel:not(.tucked)"); + + if (layer === null && props.channel.past && !fieldHasIt && !searchHasIt) { closePast(props.channel); return; } @@ -1218,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 @@ -1250,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; @@ -1268,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; @@ -1314,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 -- @@ -1328,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); @@ -1337,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(); @@ -1357,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(); } ); @@ -1389,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(); + } } ); @@ -1443,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; } @@ -1481,12 +1953,19 @@ export default defineComponent({ return rowOf(el.selectionStart) === rowOf(key === "up" ? 0 : el.value.length); }; - // Text the arrows bring in — a history entry, an own message to edit — - // gets the caret on the edge it was reached from: at the start after - // ArrowUp, at the end after ArrowDown, so holding either key keeps - // stepping instead of climbing through every row of a long entry. - const showStepped = (el: HTMLTextAreaElement, text: string, key: string) => { - const caret = key === "up" ? 0 : text.length; + // A history entry the arrows bring in gets the caret on the edge it + // was reached from: at the start after ArrowUp, at the end after + // ArrowDown, so holding either key keeps stepping instead of climbing + // through every row of a long entry. An own message to edit always + // gets it at the end (`edge: "end"`), where an edit is usually + // made; ArrowUp on a wrapped one climbs its rows before it steps on. + const showStepped = ( + el: HTMLTextAreaElement, + text: string, + key: string, + edge: "stepped" | "end" = "stepped" + ) => { + const caret = edge === "stepped" && key === "up" ? 0 : text.length; el.value = text; el.setSelectionRange(caret, caret); @@ -1524,7 +2003,7 @@ export default defineComponent({ if (last) { startEdit(props.channel, last); - showStepped(el, props.channel.pendingMessage, key); + showStepped(el, props.channel.pendingMessage, key, "end"); return false; } } @@ -1537,7 +2016,7 @@ export default defineComponent({ // never thrown away by an arrow key. ArrowDown past the newest // leaves edit mode, back to the empty input ArrowUp started from. if (channel.editing) { - if (channel.pendingMessage !== (channel.editing.text ?? "")) { + if (channel.pendingMessage !== editText(channel.editing)) { return; // a modified edit: the arrows just move the caret } @@ -1559,7 +2038,12 @@ export default defineComponent({ return; // already at the oldest own message } - showStepped(el, channel.pendingMessage, key); + showStepped( + el, + channel.pendingMessage, + key, + channel.editing ? "end" : "stepped" + ); return false; } @@ -1650,6 +2134,11 @@ export default defineComponent({ composeTarget, showConnectionBar, canSend, + emojiPick, + emojiPicker, + insertEmoji, + closeEmojiPick, + onComposerKeydown, connectionLabel, throttledWait, retryInSeconds, @@ -1663,11 +2152,12 @@ export default defineComponent({ shortReason, outgoingBusy, sendTooltip, - cancelOutgoingNow, + stale, + sendQueuedHere, + sendTargetCode, copiedOutgoing, copyOutgoing, copyLabel, - keepTypingLabel, outgoingSendLabel, translateOnceButton, translateOncePickerOpen, @@ -1679,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/ContextMenu.vue b/client/components/ContextMenu.vue index 8f894fc2d..a7919a1a3 100644 --- a/client/components/ContextMenu.vue +++ b/client/components/ContextMenu.vue @@ -260,6 +260,7 @@ export default defineComponent({ event: MouseEvent; network?: ClientNetwork; channel?: ClientChan; + address?: string; }) => { // The network the nick was shown for (an oper card, a notice), not // the active one: a card can be open for another network, and its @@ -283,7 +284,8 @@ export default defineComponent({ channel.users.find((u) => u.nick === data.user.nick) || { nick: data.user.nick, modes: [], - } + }, + data.address ); open(data.event, newItems); }; diff --git a/client/components/HostAddress.vue b/client/components/HostAddress.vue new file mode 100644 index 000000000..a8c86b045 --- /dev/null +++ b/client/components/HostAddress.vue @@ -0,0 +1,44 @@ + + + diff --git a/client/components/JoinChannel.vue b/client/components/JoinChannel.vue index cf095aa8a..fcd6e52cc 100644 --- a/client/components/JoinChannel.vue +++ b/client/components/JoinChannel.vue @@ -34,12 +34,15 @@ autocomplete="new-password" /> + diff --git a/client/components/MessageTypes/join.vue b/client/components/MessageTypes/join.vue index 18e022c40..608bcdd82 100644 --- a/client/components/MessageTypes/join.vue +++ b/client/components/MessageTypes/join.vue @@ -1,7 +1,13 @@