Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added an 'always on' emote autocompletion option #1121

Merged
merged 17 commits into from
Mar 26, 2025
Merged
1 change: 1 addition & 0 deletions CHANGELOG-nightly.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
### 3.1.6.2000

- Added an Always On autocompletion option for emotes
- Fixed an issue where the default highlight sound would not play on Firefox

### 3.1.6.1000
Expand Down
88 changes: 82 additions & 6 deletions src/site/twitch.tv/modules/chat-input/ChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<!-- eslint-disable prettier/prettier -->
<script setup lang="ts">
import { onUnmounted, ref, watch } from "vue";
import { useMagicKeys } from "@vueuse/core";
import { useEventListener, useMagicKeys } from "@vueuse/core";
import { useStore } from "@/store/main";
import { REACT_TYPEOF_TOKEN } from "@/common/Constant";
import { imageHostToSrcset } from "@/common/Image";
Expand All @@ -39,6 +39,19 @@ const props = defineProps<{
instance: HookedInstance<Twitch.ChatAutocompleteComponent>;
}>();

interface AutocompleteResult {
current: string;
type: string;
element: unknown;
replacement: string;
}

const AUTOCOMPLETION_MODE = {
OFF: 0,
COLON: 1,
ALWAYS_ON: 2,
};

const mod = getModule<"TWITCH", "chat-input">("chat-input");
const store = useStore();
const ctx = useChannelContext(props.instance.component.componentRef.props.channelID, true);
Expand All @@ -47,7 +60,7 @@ const emotes = useChatEmotes(ctx);
const cosmetics = useCosmetics(store.identity?.id ?? "");
const ua = useUserAgent();

const shouldUseColonComplete = useConfig("chat_input.autocomplete.colon");
const autocompletionMode = useConfig("chat_input.autocomplete.colon");
const shouldColonCompleteEmoji = useConfig("chat_input.autocomplete.colon.emoji");
const shouldAutocompleteChatters = useConfig("chat_input.autocomplete.chatters");
const shouldRenderAutocompleteCarousel = useConfig("chat_input.autocomplete.carousel");
Expand Down Expand Up @@ -81,6 +94,8 @@ const historyLocation = ref(-1);

const { ctrl: isCtrl, shift: isShift } = useMagicKeys();

useEventListener(window, "keydown", handleCapturedKeyDown, { capture: true });

function findMatchingTokens(str: string, mode: "tab" | "colon" = "tab", limit?: number): TabToken[] {
const usedTokens = new Set<string>();

Expand Down Expand Up @@ -406,14 +421,75 @@ function onKeyDown(ev: KeyboardEvent) {
}
}

function handleCapturedKeyDown(ev: KeyboardEvent) {
// Prevents autocompletion on Enter when completion mode is -> always on
if (ev.key === "Enter") {
const component = props.instance.component as Twitch.ChatAutocompleteComponent;
const activeTray: Twitch.ChatTray = component.props.tray;
const slate = component.componentRef.state?.slateEditor;

// Exit if autocomplete is not always on or anything needed is unavailable
if (
autocompletionMode.value !== AUTOCOMPLETION_MODE.ALWAYS_ON ||
!activeTray ||
(activeTray.type as string) !== "autocomplete-tray" ||
!slate ||
!slate.selection?.anchor
) {
return;
}

// Prevents autocompletion
ev.preventDefault();
ev.stopImmediatePropagation();
ev.stopPropagation();

// Close autocomplete tray by adding a space
const cursorLocation = slate.selection.anchor;

let currentNode: { children: Twitch.ChatSlateLeaf[] } & Partial<Twitch.ChatSlateLeaf> = slate;

for (const index of cursorLocation.path) {
if (!currentNode) break;
currentNode = currentNode.children[index];
}

const currentWordEnd =
currentNode.type === "text" && typeof currentNode.text === "string"
? getSearchRange(currentNode.text, cursorLocation.offset)[1]
: 0;
const newCursor = { path: cursorLocation.path, offset: currentWordEnd };

slate.apply({ type: "set_selection", newProperties: { anchor: newCursor, focus: newCursor } });
slate.apply({
type: "insert_text",
path: cursorLocation.path,
offset: currentWordEnd,
text: " ",
});
}
}

function getMatchesHook(this: unknown, native: ((...args: unknown[]) => object[]) | null, str: string, ...args: []) {
if (!str.startsWith(":") || str.length < 3) return;
if (!shouldUseColonComplete.value) return;
if (autocompletionMode.value === AUTOCOMPLETION_MODE.OFF) return;

const results = native?.call(this, str, ...args) ?? [];
if (autocompletionMode.value === AUTOCOMPLETION_MODE.COLON && !str.startsWith(":")) return;

const search = str.startsWith(":") ? str.substring(1) : str;

if (search.length < 2) {
return;
}

const results = (native?.call(this, `:${search}`, ...args) ?? []) as AutocompleteResult[];

if (autocompletionMode.value === AUTOCOMPLETION_MODE.ALWAYS_ON) {
results.map((r) => (r.current = str));
}

const allEmotes = { ...cosmetics.emotes, ...emotes.active, ...emotes.emojis };
const tokens = findMatchingTokens(str.substring(1), "colon", 25);

const tokens = findMatchingTokens(search, "colon", 25);

for (let i = tokens.length - 1; i > -1; i--) {
const token = tokens[i].token;
Expand Down
37 changes: 25 additions & 12 deletions src/site/twitch.tv/modules/chat-input/ChatInputModule.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,32 @@ markAsReady();

<script lang="ts">
export const config = [
declareConfig("chat_input.autocomplete.colon", "TOGGLE", {
declareConfig("chat_input.autocomplete.colon", "DROPDOWN", {
path: ["Chat", "Autocompletion"],
label: "Colon-completion",
hint: "Allows the use of a colon (:) to open a list of partially matching emotes",
defaultValue: true,
label: "Autocompletion",
hint: "Enables a list of partially matching emotes when writing a message (Setting this to 'Always on' will disable the Tab-completion Carousel)",
options: [
["Disabled", 0],
["Require the ':' prefix", 1],
["Always on", 2],
],
transform(v) {
return v === true ? 1 : 0;
},
defaultValue: 1,
}),
declareConfig("chat_input.autocomplete.colon.emoji", "TOGGLE", {
path: ["Chat", "Autocompletion"],
label: "Colon-completion: Emoji",
disabledIf: () => !useConfig("chat_input.autocomplete.colon").value,
hint: "Whether or not to also include emojis in the colon-completion list (This may impact performance)",
label: "Autocompletion: Emoji",
disabledIf: () => useConfig("chat_input.autocomplete.colon").value === 0,
hint: "Whether or not to also include emojis in the autocompletion list (This may impact performance)",
defaultValue: false,
}),
declareConfig("chat_input.autocomplete.colon.mode", "DROPDOWN", {
path: ["Chat", "Autocompletion"],
label: "Colon-completion: Mode",
disabledIf: () => !useConfig("chat_input.autocomplete.colon").value,
hint: "What emotes should be displayed in the colon-completion list",
label: "Autocompletion: Mode",
disabledIf: () => useConfig("chat_input.autocomplete.colon").value === 0,
hint: "What emotes should be displayed in the autocompletion list",
options: [
["Must start with input", 0],
["Must include input", 1],
Expand All @@ -157,20 +165,25 @@ export const config = [
declareConfig("chat_input.autocomplete.carousel", "TOGGLE", {
path: ["Chat", "Autocompletion"],
label: "Tab-completion Carousel",
disabledIf: () => useConfig("chat_input.autocomplete.colon").value === 2,
hint: "Show a carousel visualization of previous and next tab-completion matches",
defaultValue: true,
}),
declareConfig("chat_input.autocomplete.carousel_arrow_keys", "TOGGLE", {
path: ["Chat", "Autocompletion"],
label: "Tab-completion Carousel: Arrow Keys",
disabledIf: () => !useConfig("chat_input.autocomplete.carousel").value,
disabledIf: () =>
!useConfig("chat_input.autocomplete.carousel").value ||
useConfig("chat_input.autocomplete.colon").value === 2,
hint: "Whether or not to allow using left/right arrow keys to navigate the tab-completion carousel",
defaultValue: true,
}),
declareConfig("chat_input.autocomplete.carousel.mode", "DROPDOWN", {
path: ["Chat", "Autocompletion"],
label: "Tab-completion: Mode",
disabledIf: () => !useConfig("chat_input.autocomplete.carousel").value,
disabledIf: () =>
!useConfig("chat_input.autocomplete.carousel").value ||
useConfig("chat_input.autocomplete.colon").value === 2,
hint: "What emotes should be displayed in the tab-completion carousel",
options: [
["Must start with input", 0],
Expand Down