+
{{ pastStatus }}
-
- {{ t("chat.past.backToResults") }}
-
-
- ✕
-
();
const topicInput = ref(null);
- const searchBack = computed(() =>
- store.state.searchReturn?.chanId === props.channel.id ? store.state.searchReturn : null
- );
const pastStatus = computed(() => {
if (props.channel.pastOpening) {
return t("chat.past.loading");
@@ -275,23 +222,6 @@ export default defineComponent({
closePast(props.channel);
eventbus.emit("input:focus");
};
- onUnmounted(() => store.commit("searchReturn", null));
- const dismissResults = () => {
- forgetResults();
- store.commit("searchReturn", null);
- };
- const backToResults = () => {
- const back = searchBack.value;
-
- if (back) {
- returnToResults();
- void router.replace({
- name: "SearchResults",
- params: {id: back.windowId},
- query: {q: back.q},
- });
- }
- };
const plainTopic = computed(() => {
const topic = props.channel.topic;
@@ -381,14 +311,10 @@ export default defineComponent({
const specialComponent = computed(() => {
switch (props.channel.special) {
- case SpecialChanType.BANLIST:
- return ListBans as Component;
case SpecialChanType.INVITELIST:
return ListInvites as Component;
case SpecialChanType.EXCEPTLIST:
return ListExcepts as Component;
- case SpecialChanType.CHANNELLIST:
- return ListChannels as Component;
case SpecialChanType.IGNORELIST:
return ListIgnored as Component;
}
@@ -475,95 +401,12 @@ export default defineComponent({
};
const translationPanelOpen = ref(false);
- const translationState = computed(() => channelTranslation(props.network, props.channel));
const translationAvailable = computed(
() =>
(props.channel.type === ChanType.CHANNEL ||
props.channel.type === ChanType.QUERY) &&
translationAvailableNow()
);
- // The light means reading; a write target shows in the tooltip.
- const channelReads = computed(() => translationState.value.read);
- // A touch device has no right-click, so the tap is what opens the
- // panel there and the button says so; the click keeps toggling
- // reading where there is a pointer.
- const touch = hasVirtualKeyboard();
- // Nothing here can translate (router.ts `translationBlock`): the button
- // stays, says so, and opens the panel, whose banner gives the reason.
- const translationBlocked = computed(() => !!currentTranslationBlock());
- const translateLabel = computed(() => {
- // Before the touch label: a tap opens the panel either way, but
- // the button's name should not promise settings when nothing
- // here can translate.
- if (translationBlocked.value) {
- return t("translate.unavailable.lead");
- }
-
- if (touch) {
- return t("translate.header.settings");
- }
-
- // Every language the tooltip names is named in the language the
- // reader reads this channel in (F40), like every other label.
- const name = (code: string) => languageName(code, readingLanguage());
- const {read, write} = translationState.value;
- const paused = store.state.translation.paused;
- // One whole phrase per state (reading on/off × a write target ×
- // paused): the tooltip is never assembled from translated pieces,
- // and every key is a literal the pot check can see.
- const vars = {
- reading: name(readingLanguage()),
- writing: write ? name(write) : "",
- // The engine's own text: a verbatim value, never translated.
- reason: paused ? paused.message : "",
- };
-
- if (paused) {
- if (read) {
- return write
- ? t("translate.header.readWritePaused", vars)
- : t("translate.header.readPaused", vars);
- }
-
- return write
- ? t("translate.header.offWritePaused", vars)
- : t("translate.header.offPaused", vars);
- }
-
- if (read) {
- return write
- ? t("translate.header.readWrite", vars)
- : t("translate.header.read", vars);
- }
-
- return write ? t("translate.header.offWrite", vars) : t("translate.header.off", vars);
- });
-
- const openTranslationPanel = () => {
- translationPanelOpen.value = true;
- };
-
- const toggleTranslation = () => {
- const wasOn = translationState.value.read;
-
- setReading(props.network, props.channel, !wasOn);
-
- // Switching a channel on opens its panel with it: the reader sees
- // which languages they just asked for and can adjust them at once,
- // where the click alone would silently pick the global target.
- // Switching off is the whole of what switching off means.
- if (!wasOn) {
- openTranslationPanel();
- }
- };
-
- const onTranslateClick = () => {
- if (touch || translationBlocked.value) {
- openTranslationPanel();
- } else {
- toggleTranslation();
- }
- };
watch(
() => props.channel.id,
@@ -573,7 +416,7 @@ export default defineComponent({
);
// Translation switched off (or nothing here can run it any more):
- // the panel goes with the button, and does not come back with it.
+ // the panel closes, and does not come back with it.
watch(translationAvailable, (available) => {
if (!available) {
translationPanelOpen.value = false;
@@ -597,7 +440,6 @@ export default defineComponent({
watch(
() => props.channel,
() => {
- store.commit("searchReturn", null);
channelChanged();
}
);
@@ -625,11 +467,8 @@ export default defineComponent({
});
return {
- backToResults,
leavePast,
pastStatus,
- searchBack,
- dismissResults,
store,
t,
messageList,
@@ -657,12 +496,6 @@ export default defineComponent({
openMentions,
translationPanelOpen,
translationAvailable,
- channelReads,
- translationBlocked,
- translateLabel,
- touch,
- onTranslateClick,
- openTranslationPanel,
mentionCount,
mentionBadge,
};
diff --git a/client/components/ChatInput.vue b/client/components/ChatInput.vue
index a9ad4f119..76a93a278 100644
--- a/client/components/ChatInput.vue
+++ b/client/components/ChatInput.vue
@@ -80,22 +80,67 @@
✕
+
+
+
{{ suggestionLead }}
+
{{ suggestionNote }}
+
+
+ {{ t("translate.suggest.accept", {language: suggestionName}) }}
+
+
+ {{ t("translate.suggest.decline") }}
+
+
+
{{
- 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
}}
+
+
-
+ {{
+ sendTargetCode
+ }}
+
+
@@ -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 @@
+
+
+
+
+
+ {{ t("translate.sheet.title") }}
+
+ {{ t("translate.sheet.off") }}
+
+
+ ✕
+
+
+
+
+
+ {{ t("translate.sheet.iWrite") }}
+ {{
+ from ? name(from) : t("translate.sheet.detect")
+ }}
+
+
+ ⇄
+
+
+ {{
+ t("translate.sheet.theyRead")
+ }}
+ {{
+ to ? name(to) : t("translate.sheet.choose")
+ }}
+
+
+
+
{{
+ t("translate.sheet.recent", {channel: channel.name})
+ }}
+
+
+ {{ name(code) }}
+
+
+
+
+
+
+ {{ t("translate.sheet.thisMessageOnly") }}
+
+
+
+
+
+ {{ t("translate.sheet.detect") }}
+ ✓
+
+
+
+
+ {{ row.name }}
+ {{
+ row.endonym
+ }}
+ {{
+ row.status
+ }}
+ ✓
+
+
+
+
+ {{ t("translate.sheet.unavailableHeading") }}
+
+
+
+ {{ row.name }}
+ {{
+ row.endonym
+ }}
+ {{
+ row.status
+ }}
+ ✓
+
+
+
+ {{ t("translate.sheet.noMatch", {query: query.trim()}) }}
+
+
+
+
+
+ {{ blockedLead }}
+ {{ blockedWhy }}
+
+
+
+
+
+
+
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 @@
+
+ {{
+ address
+ }}
+
+
+
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"
/>
{{ t("join.submit") }}
+
+ {{ t("browse.sidebarButton") }}
+
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 @@
- {{ parts[0] }}
- ( )
+ {{ parts[0] }}
+ ( )
[{{ message.account }}]
@@ -15,7 +21,7 @@
diff --git a/client/components/Special/ListChannels.vue b/client/components/Special/ListChannels.vue
deleted file mode 100644
index 0dd9e433a..000000000
--- a/client/components/Special/ListChannels.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-
- {{ channel.data.text }}
-
-
-
- {{ t("special.channels.channel") }}
- {{ t("special.channels.users") }}
- {{ t("special.channels.topic") }}
-
-
-
-
-
- {{ chan.num_users }}
-
-
-
-
-
-
-
diff --git a/client/components/TranslationFlip.vue b/client/components/TranslationFlip.vue
new file mode 100644
index 000000000..0cbdb81d4
--- /dev/null
+++ b/client/components/TranslationFlip.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
diff --git a/client/components/TranslationLine.vue b/client/components/TranslationLine.vue
index 862afc4eb..ec2fe3813 100644
--- a/client/components/TranslationLine.vue
+++ b/client/components/TranslationLine.vue
@@ -1,35 +1,35 @@
-
-
- {{ chipText }}
-
-
-
+
+
+ {{ t("translate.line.applePack", {language: sourceName}) }}
+
+ {{ t("translate.line.applePackGet") }}
+
+
-
+
+ {{ t("translate.line.needsDownload", {size: neededSize}) }}
+
+ {{ t("translate.line.download") }}
+
+
+
+ {{ t("translate.failed") }}
+ {{
+ shortReason(reasonText)
+ }}
-
- {{
- t("translate.unchangedReason")
- }}
-
-
-
- {{ t("translate.failed") }}
- {{
- shortReason(reasonText)
- }}
-
-
+ class="msg-translation-retry"
+ :title="retryLabel"
+ :aria-label="retryLabel"
+ @click.stop="retry"
+ />
-
+
import {computed, defineComponent, PropType, ref} from "vue";
import {writeClipboard} from "../js/clipboard";
-import {devtoolsAvailable} from "../js/devtools";
import eventbus from "../js/eventbus";
import {useI18n} from "../js/i18n";
import {useStore} from "../js/store";
@@ -114,13 +110,17 @@ import {
import {translateWhyText} from "../js/helpers/translateWhy";
import {ANSWERED, DEGENERATE, NARRATION, UNCHANGED} from "../js/translate/outgoing";
import {languageName} from "../js/translate/languages";
-import {directionText} from "../js/translate/labels";
+import {directionText, sourceText} from "../js/translate/labels";
import {loadNoteText} from "../js/helpers/modelLabel";
import {translateErrorText} from "../js/helpers/translateErrors";
import {loadNote} from "../js/translate/service";
import type {ClientChan, ClientMessage, ClientNetwork} from "../js/types";
import ParsedMessage from "./ParsedMessage.vue";
import SourceLanguagePicker from "./SourceLanguagePicker.vue";
+import friendlysize from "../js/helpers/friendlysize";
+import {parseNeedsDownload} from "../js/translate/consent";
+import {packShownOn, takePack} from "../js/translate/applePacks";
+import {activeService, systemTranslator} from "../js/translate";
/** Characters of a failure reason the line shows; the title has all of it. */
const REASON_MAX = 120;
@@ -137,7 +137,6 @@ export default defineComponent({
const store = useStore();
const {t} = useI18n();
const entry = computed(() => store.state.translations[props.message.id]);
- const notTranslatedLabel = computed(() => t("translate.notTranslated"));
const retryLabel = computed(() => t("translate.retry"));
// The failure reason after "Couldn't translate": the classifier's
// verdicts are phrases of our own and come from the pot; any other
@@ -173,8 +172,6 @@ export default defineComponent({
const nameOf = (code: string) => languageName(code, readingLanguage());
// Source → target; `from` is "" when the engine placed the source
// itself, and the detector's contenders are named instead (labels.ts).
- // A skipped line's tag is the language it was taken for, or "?" when
- // the detector could not tell.
const chipText = computed(() => {
const value = entry.value;
@@ -182,11 +179,15 @@ export default defineComponent({
return "";
}
- if (value.status === "skipped") {
- return value.reason === "same" && value.from ? nameOf(value.from) : "?";
+ // An own line's translation is the read-back of what was sent
+ // (sentReadBack.ts): its language is the one it went out in.
+ if (props.message.self && value.from) {
+ return t("translate.chip.sentIn", {language: nameOf(value.from)});
}
- return directionText(value.from, value.to, value.candidates ?? [], nameOf);
+ return t("translate.chip.from", {
+ language: sourceText(value.from, value.to, value.candidates ?? [], nameOf),
+ });
});
const chipLabel = computed(() => {
const value = entry.value;
@@ -195,13 +196,9 @@ export default defineComponent({
return "";
}
- if (value.status === "skipped") {
- return value.reason === "same" && value.from
- ? t("translate.chip.alreadyIn", {language: nameOf(value.from)})
- : t("translate.chip.unsure");
- }
-
- return t("translate.chip.options", {languages: chipText.value});
+ return t("translate.chip.options", {
+ languages: directionText(value.from, value.to, value.candidates ?? [], nameOf),
+ });
});
// Nothing streamed yet and a model of this line's engine downloading:
@@ -232,15 +229,10 @@ export default defineComponent({
// not among them: "Retranslate from German" on a line already read as
// German is just "Retranslate". An explicit source keeps the list, so
// the way back to the detector's own guess is on the menu too.
- // A skipped line leaves the reading language off as well: "Retranslate
- // from English" into English is no translation.
const alternatives = computed(() => {
const value = entry.value;
- return (value?.candidates ?? []).filter(
- (code) =>
- code !== value?.from && !(value?.status === "skipped" && code === value.to)
- );
+ return (value?.candidates ?? []).filter((code) => code !== value?.from);
});
// The context menu positions itself at the pointer, and a keyboard
@@ -285,45 +277,19 @@ export default defineComponent({
},
];
- // A mark has no text to copy, hide or retranslate: what it offers
- // is the translation it did not get.
- if (entry.value?.status === "skipped") {
- eventbus.emit("contextmenu:items", {
- event,
- items: [
- {
- label: t("translate.menu.translateAnyway"),
- type: "item",
- class: "translate-anyway",
- action: () => retranslateFrom(),
- },
- ...sources,
- ],
- });
- return;
- }
-
- // An entry that failed as "unchanged" has no translation to copy,
- // so its menu is the failed row's minus the Copy item.
- const unchanged = entry.value?.status === "failed" && entry.value.error === UNCHANGED;
-
eventbus.emit("contextmenu:items", {
event,
items: [
- ...(unchanged
- ? []
- : [
- {
- label: t("translate.menu.copy"),
- type: "item",
- class: "translate-copy",
- // Like the toolbar's Copy: nowhere to report a
- // refused clipboard, so a failure is silent.
- action() {
- void writeClipboard(entry.value?.text ?? "");
- },
- },
- ]),
+ {
+ label: t("translate.menu.copy"),
+ type: "item",
+ class: "translate-copy",
+ // Like the toolbar's Copy: nowhere to report a refused
+ // clipboard, so a failure is silent.
+ action() {
+ void writeClipboard(entry.value?.text ?? "");
+ },
+ },
{
label: t("translate.menu.retranslate"),
type: "item",
@@ -343,6 +309,45 @@ export default defineComponent({
const retry = () => retryTranslation(props.network, props.channel, props.message);
+ // A download the line needs and nobody agreed to (service.ts
+ // `ResolveOptions.quiet`): the line offers it, and the yes retries it,
+ // the download's progress then shown on the line (`download`).
+ const needed = computed(() => {
+ const error = entry.value?.status === "failed" ? entry.value.error : null;
+
+ return error ? parseNeedsDownload(error) : null;
+ });
+ const neededSize = computed(() => friendlysize(needed.value?.sizeBytes ?? 0));
+ const getDownload = () => {
+ const need = needed.value;
+
+ if (need) {
+ activeService()?.allowDownload(need.id, need.device);
+ retry();
+ }
+ };
+
+ // The system translator could take this line's pair once its pack is
+ // downloaded (applePacks.ts); the sheet opens only on this tap.
+ const applePack = computed(() => {
+ const value = entry.value;
+
+ return (
+ !!value &&
+ value.status === "done" &&
+ packShownOn(props.message.id, value.from, value.to)
+ );
+ });
+ const sourceName = computed(() => (entry.value?.from ? nameOf(entry.value.from) : ""));
+ const getApplePack = () => {
+ const value = entry.value;
+
+ if (value?.from) {
+ takePack(value.from, value.to);
+ void systemTranslator()?.offer(value.from, value.to);
+ }
+ };
+
// Why it failed, after "couldn't translate": an engine's error can be
// long and multi-line, so the line shows the head of it and the title
// carries the whole thing.
@@ -366,11 +371,15 @@ export default defineComponent({
openMenu,
pickSource,
retry,
+ needed,
+ neededSize,
+ getDownload,
+ applePack,
+ sourceName,
+ getApplePack,
shortReason,
t,
UNCHANGED,
- devtoolsAvailable,
- notTranslatedLabel,
retryLabel,
reasonText,
};
diff --git a/client/components/TranslationPanel.vue b/client/components/TranslationPanel.vue
index ade1a5173..664f18cd7 100644
--- a/client/components/TranslationPanel.vue
+++ b/client/components/TranslationPanel.vue
@@ -1,250 +1,263 @@
-
-
+
+
-
- {{ t("translate.panel.heading") }}
- {{ channel.name }}
-
- ✕
-
-
+
diff --git a/client/components/Windows/ChannelBrowser.vue b/client/components/Windows/ChannelBrowser.vue
new file mode 100644
index 000000000..1ddb8f434
--- /dev/null
+++ b/client/components/Windows/ChannelBrowser.vue
@@ -0,0 +1,1172 @@
+
+
+
+
+
+
{{ t("browse.title") }}
+ {{ networkName }}
+
+
+
+
+
+
+
+
+ {{ t("browse.refresh") }}
+
+
+
+
+
+ {{ filterLabel(f) }}
+ {{
+ list.featured.length
+ }}
+
+
+
+ {{ t("browse.sort") }}
+
+ {{ t("browse.sort.people") }}
+ {{ t("browse.sort.name") }}
+
+
+
+
+
+
+
+ {{ t("browse.noNetwork") }}
+
+
+
{{ t("browse.offline") }}
+
{{ t("browse.offlineNote") }}
+
+
+
{{ waitLeft }}
+
{{ t("browse.delayed") }}
+
{{ tCount("browse.delayedNote", waitLeft) }}
+
+
+
{{ t("browse.refused") }}
+
{{ list.note }}
+
+ {{ t("browse.retry") }}
+
+
+
+
+
+
+
+
{{ t("browse.featured") }}
+ {{ t("browse.featuredNote") }}
+
+
+
+ {{ row.name }}
+ {{
+ row.blurb || row.topic || t("browse.noTopic")
+ }}
+
+
+
+
+
+
+
+
+ {{ filter === "featured" ? t("browse.featured") : t("browse.all") }}
+
+
+ {{
+ list.loaded
+ ? tCount("browse.loadingCount", list.loaded)
+ : t("browse.loading")
+ }}
+
+
+ {{ tCount("browse.count", shownRows.length) }}
+ ·
+ {{ t("browse.updated", {when: updatedLabel}) }}
+
+
+
+
+
+
+
+ {{ t("browse.noMatches", {query}) }}
+
+ {{ t("browse.create", {name: creatable}) }}
+
+
+
+ {{ t("browse.empty") }}
+
+
+ {{ t("browse.idle") }}
+
+ {{ t("browse.ask") }}
+
+
+
+
+
+
+
+
+
+ {{ row.name }}
+ ★
+
+ {{ row.topic || t("browse.noTopic") }}
+
+
+
+ {{ tCount("browse.people", row.users) }}
+
+ {{
+ row.joined ? t("browse.open") : t("browse.join")
+ }}
+
+
+
+
+
+ {{
+ t("browse.showing", {
+ shown: visibleRows.length,
+ total: shownRows.length,
+ })
+ }}
+
+ {{ t("browse.showMore") }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/components/Windows/Help.vue b/client/components/Windows/Help.vue
index 8744b62b4..2aff724bf 100644
--- a/client/components/Windows/Help.vue
+++ b/client/components/Windows/Help.vue
@@ -1193,8 +1193,14 @@ export default defineComponent({
const source = computed(() => sourceLinks(links.value.source, build.value));
// Installed PWAs have no reload button; a newer build's worker flags
- // itself in the store (see pwa.ts) and this picks it up.
- const reloadForUpdate = () => window.location.reload();
+ // itself in the store (see pwa.ts) and this picks it up. The route is
+ // the URL's hash, so a plain reload would come back to Help; loading
+ // the bare path lands on the conversation instead (boot.ts picks the
+ // route when there is none). There is no query to keep: boot strips
+ // connect parameters from the address bar as soon as it reads them.
+ const reloadForUpdate = () => {
+ window.location.replace(window.location.pathname);
+ };
const close = () => {
leavePage();
diff --git a/client/components/Windows/SearchResults.vue b/client/components/Windows/SearchResults.vue
index ba5b90c15..e368a8cda 100644
--- a/client/components/Windows/SearchResults.vue
+++ b/client/components/Windows/SearchResults.vue
@@ -1,287 +1,332 @@
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
- {{ heldScopeLabel }}
-
-
-
- {{ chip.label }} {{ chip.value }}
-
-
-
-
-
- {{ t("search.window.filters") }}
+ {{ t("search.window.submit") }}
-
-
-
-
-
-
{{ banner }}
-
- {{ tCount("search.window.loadedInstead", loadedCount) }}
-
-
-
+
+ {{ t("search.window.cancel") }}
+
+
+
+
+
+
+
+ {{ heldScopeLabel }}
- {{ t("search.window.retry") }}
+
+
+
+ {{ chip.label }} {{ chip.value }}
+
+
+
+
+ {{ t("search.window.filters") }}
+
+
+
+
+
+
+
{{ banner }}
+
+ {{ tCount("search.window.loadedInstead", loadedCount) }}
+
-
-
- {{ t("search.window.stale") }}
-
-
-
{{ hitError }}
-
-
-
-
-
- {{ t("search.window.start") }}
-
-
-
- {{ hint }}
+
+ {{ t("search.window.retry") }}
+
+
+
+
+ {{ t("search.window.stale") }}
+
+
+
{{ hitError }}
+
+
+
+
+
+
+ {{ t("search.window.recent") }}
+
+ {{ t("search.window.clearRecent") }}
-
-
-
-
{{ t("search.window.asking") }}
-
-
-
-
{{ t("search.window.failed") }}
-
{{ error }}
-
- {{ t("search.window.retry") }}
-
-
-
-
-
{{ t("search.window.noMatches", {scope: scopeLabel}) }}
-
- {{ t("search.window.noMatchesNote") }}
-
- {{ t("search.window.editFilters") }}
-
-
- {{ t("search.window.everywhere") }}
+
+ {{ q }}
-
-
-
{{ tCount("search.window.allHidden", rows.length) }}
-
- {{ t("search.window.allHiddenNote", {when: oldestLabel}) }}
-
+
+ {{ t("search.window.start") }}
+
+
- {{ t("search.window.older") }}
-
-
- {{ t("search.window.editFilters") }}
+ {{ hint }}
+
+
+
+
{{ t("search.window.asking") }}
+
+
+
+
{{ t("search.window.failed") }}
+
{{ error }}
+
+ {{ t("search.window.retry") }}
+
+
+
+
+
{{ t("search.window.noMatches", {scope: scopeLabel}) }}
+
+ {{ t("search.window.noMatchesNote") }}
+
+
+ {{ t("search.window.editFilters") }}
+
+
+ {{ t("search.window.everywhere") }}
+
+
+
+
+
{{ tCount("search.window.allHidden", rows.length) }}
+
+ {{ t("search.window.allHiddenNote", {when: oldestLabel}) }}
+
+
+ {{ t("search.window.older") }}
+
+
+ {{ t("search.window.editFilters") }}
+
+
-
-
- {{ group.label }}
-
-
-
-
- {{ hit.target }}
- {{
- timeOf(hit.msg)
- }}
- {{ t("search.window.loadedRow") }}
-
-
+
+
+ {{ group.label }}
+
+
+
+
+ {{ hit.target }}
+ {{ hitTime(hit.msg) }}
+ {{ t("search.window.loadedRow") }}
+
+
-
+
+
-
+ {{
+ tCount("search.window.matchCount", visible.length)
+ }}
+
+ · {{ tCount("search.window.hiddenCount", hiddenCount) }}
+
+ · {{ t("search.window.backTo", {when: oldestLabel}) }}
- {{
- tCount("search.window.matchCount", visible.length)
- }}
-
- · {{ tCount("search.window.hiddenCount", hiddenCount) }}
-
- · {{ t("search.window.backTo", {when: oldestLabel}) }}
-
- {{
- olderError
- }}
-
- {{
- loadingOlder
- ? t("search.window.searching")
- : t("search.window.older")
- }}
-
-
- {{ t("search.window.nothingOlder") }}
-
-
+
+
{{
+ olderError
+ }}
+
+ {{
+ loadingOlder
+ ? t("search.window.searching")
+ : t("search.window.older")
+ }}
+
+
+ {{ t("search.window.nothingOlder") }}
+
-
-
-
-
+
+
+
+
-
+