Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 295 additions & 0 deletions src/platforms/twitch/modules/emote-bar/emote-bar.module.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import TwitchModule from "$twitch/twitch.module.ts";
import type { TwitchChatMessageEvent } from "$types/platforms/twitch/twitch.events.types.ts";
import type { EmoteItem } from "$types/platforms/twitch/twitch.utils.types.ts";
import type { TwitchModuleConfig } from "$types/shared/module/module.types.ts";
import { type Signal, signal } from "@preact/signals";
import { render } from "preact";
import styled from "styled-components";

export default class EmoteBarModule extends TwitchModule {
private emotes: Signal<EmoteItem[]> = signal([]);

private ctrlWindowStartTs: number | null = null;
private ctrlAppendCount = 0;
private readonly INVISIBLE_CHAR = "\u034F";

readonly config: TwitchModuleConfig = {
name: "emote-bar",
appliers: [
{
type: "selector",
selectors: [".chat-list--default"],
callback: this.run.bind(this),
key: "emote-bar",
once: true,
},
{
type: "event",
key: "emote-bar",
event: "twitch:chatMessage",
callback: this.handleMessage.bind(this),
},
],
isModuleEnabledCallback: async () => await this.settingsService().getSettingsKey("emoteBarEnabled"),
};

private async handleMessage(event: TwitchChatMessageEvent) {
if (event.message.user.userLogin !== this.twitchUtils().getScrollableChat()?.props.currentUserLogin) return;
const emoteImages = event.element.querySelectorAll<HTMLImageElement>(
"img.chat-line__message--emote, img.seventv-chat-emote",
);
if (emoteImages.length === 0) return;

const newEmotes: EmoteItem[] = Array.from(emoteImages)
.map((img) => {
const src = this.resolveImageSource(img);
if (!src) return null;
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const isWide = h > 0 && w / h > 1;
return { src, alt: img.alt || "", isWide } as EmoteItem;
})
.filter((e): e is EmoteItem => e !== null);
await this.appendEmotes(newEmotes);
}

private resolveImageSource(img: HTMLImageElement): string {
const { src } = img;
if (src) return src;

const current = img.currentSrc;
if (current) return current;
const srcset = img.getAttribute("srcset");
if (srcset) {
const parts = srcset.split(",")[0]?.trim().split(/\s+/);
if (parts?.[0]) {
const url = parts[0];
return url.startsWith("//") ? `${window.location.protocol}${url}` : url;
}
}
return "";
}

private async appendEmotes(newEmotes: EmoteItem[]) {
const MAX_SLOTS = 18;
if (newEmotes.length === 0) return;

const uniqueNew = this.getUniqueEmotes(newEmotes);
const current = [...this.emotes.value];

const replacedAlts = this.updateExistingEmotes(current, uniqueNew);

let usedSlots = this.countUsedSlots(current);

if (usedSlots < MAX_SLOTS) {
for (const emote of uniqueNew) {
if (this.shouldSkipEmote(emote, current, replacedAlts)) continue;

const cost = emote.isWide ? 2 : 1;
if (usedSlots + cost > MAX_SLOTS) break;

current.push(emote);
usedSlots += cost;
}
this.emotes.value = current;
await this.persist();
return;
}

const trulyNew = uniqueNew.filter((e) => !this.shouldSkipEmote(e, current, replacedAlts));

if (trulyNew.length === 0) return;

const combined = [
...trulyNew,
...current.filter((e) => !trulyNew.some((n) => n.src === e.src || (n.alt && e.alt === n.alt))),
];

const result: EmoteItem[] = [];
let used = 0;
let column = 0;

for (const item of combined) {
const weight = item.isWide ? 2 : 1;
if (used + weight > MAX_SLOTS) break;

if (weight === 2 && column % 9 === 8) continue;
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The value 9 appears to be a magic number representing the number of columns in the emote grid. Consider extracting this to a named constant like COLUMNS_PER_ROW = 9 to make the code more maintainable and the logic clearer.

Copilot uses AI. Check for mistakes.

result.push(item);
used += weight;
column += weight;
}

this.emotes.value = result;
await this.persist();
}

private async run(elements: Element[]) {
const wrappers = this.commonUtils().createEmptyElements(this.getId(), elements, "div");

await this.commonUtils().waitFor(
() => this.getChannelKey(),
async () => {
await this.loadPersisted();
return true;
},
{ maxRetries: 10, delay: 100 },
);

wrappers.forEach((element) => {
render(
<EmoteBar
emotes={this.emotes}
onInsert={(name) => this.twitchUtils().addTextToChatInput(name)}
onSend={(name) => this.sendEmote(name)}
/>,
element,
);
});
}

private getChannelKey(): string {
return this.twitchUtils().getScrollableChat()?.props.channelID || "";
}

private async loadPersisted() {
const storage = (await this.localStorage().get("emoteBarByChannel")) || ({} as Record<string, EmoteItem[]>);
const key = this.getChannelKey();
Comment on lines +156 to +157
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Possible Bug
The getChannelKey() is called after loading storage data. If the channel changes between these operations, it could lead to loading and filtering emotes for the wrong channel.

Suggested change
const storage = (await this.localStorage().get("emoteBarByChannel")) || ({} as Record<string, EmoteItem[]>);
const key = this.getChannelKey();
const key = this.getChannelKey();
const storage = (await this.localStorage().get("emoteBarByChannel")) || ({} as Record<string, EmoteItem[]>);

const loaded = storage[key] || [];

let filtered = loaded;
try {
filtered = await this.filterEmotesAgainstChannel(loaded);
} catch {}
Comment on lines +161 to +163
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Possible Bug
The empty catch block silently swallows any errors from filterEmotesAgainstChannel. This could hide critical issues like network failures or invalid data. Errors should be logged or handled appropriately.

Suggested change
try {
filtered = await this.filterEmotesAgainstChannel(loaded);
} catch {}
try {
filtered = await this.filterEmotesAgainstChannel(loaded);
} catch (error) {
console.error('Failed to filter emotes:', error);
filtered = loaded;
}

Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty catch block silently swallows errors during emote filtering. Consider at least logging the error to help with debugging, especially since this affects the user experience when switching channels.

Suggested change
} catch {}
} catch (error) {
this.logger.warn("Failed to filter persisted emotes for channel", key, error);
}

Copilot uses AI. Check for mistakes.

const changed = loaded.length !== filtered.length || loaded.some((e) => !filtered.some((f) => f.src === e.src));

this.emotes.value = filtered;
if (changed) {
await this.persist();
}
}

private async filterEmotesAgainstChannel(emotes: EmoteItem[]): Promise<EmoteItem[]> {
if (emotes.length === 0) return emotes;

const info = this.twitchUtils().getChannelInfo() || this.twitchUtils().getChannelInfoFromHomeLowerContent();
const username = info?.channelLogin || this.twitchUtils().getCurrentChannelByUrl();
if (!username) return emotes;
const allowed = new Set();
try {
const globals = await this.enhancerApi().getGlobalEmotes();
const channel = await this.enhancerApi().getChannelEmotes(username);
for (const code of globals) allowed.add(code);
for (const code of channel) allowed.add(code);
} catch (error) {
this.logger.warn("Failed to fetch global emotes:", error);
}
Comment on lines +185 to +187
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Possible Bug
The error message in the catch block only mentions 'global emotes' but the try block fetches both global and channel emotes. This could hide channel emote fetch failures and make debugging more difficult.

Suggested change
} catch (error) {
this.logger.warn("Failed to fetch global emotes:", error);
}
} catch (error) {
this.logger.warn("Failed to fetch emotes:", error);
}

return emotes.filter((e) => !!e.alt && allowed.has(e.alt));
}
Comment on lines +173 to +189
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filterEmotesAgainstChannel method fetches both global and channel emotes on every channel load. Consider caching the emote sets in the module to avoid redundant API calls when switching between channels, as global emotes don't change per channel.

Copilot uses AI. Check for mistakes.

private sendEmote(name: string): void {
const now = Date.now();
if (this.ctrlWindowStartTs === null || now - this.ctrlWindowStartTs > 30_000) {
this.ctrlWindowStartTs = now;
this.ctrlAppendCount = 1;
} else {
this.ctrlAppendCount += 1;
}
const message = `${name} ${this.INVISIBLE_CHAR.repeat(this.ctrlAppendCount)}`;
this.twitchUtils().getChat()?.props.onSendMessage(message);
}

private async persist() {
const key = this.getChannelKey();
const storage = (await this.localStorage().get("emoteBarByChannel")) || ({} as Record<string, EmoteItem[]>);
storage[key] = this.emotes.value;
await this.localStorage().save("emoteBarByChannel", storage);
}

private getUniqueEmotes(emotes: EmoteItem[]): EmoteItem[] {
const seen = new Set<string>();
return emotes.filter((e) => {
const key = e.alt || e.src;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}

private updateExistingEmotes(current: EmoteItem[], newEmotes: EmoteItem[]): Set<string> {
const replaced = new Set<string>();
for (const emote of newEmotes) {
if (!emote.alt) continue;

const idx = current.findIndex((e) => e.alt === emote.alt);
if (idx >= 0) {
if (current[idx].src !== emote.src) {
current[idx] = { ...current[idx], src: emote.src };
}
replaced.add(emote.alt);
}
}
return replaced;
}

private countUsedSlots(emotes: EmoteItem[]): number {
return emotes.reduce((sum, e) => sum + (e.isWide ? 2 : 1), 0);
}

private shouldSkipEmote(emote: EmoteItem, current: EmoteItem[], replaced: Set<string>): boolean {
if (replaced.has(emote.alt || "")) return true;
if (emote.alt && current.some((e) => e.alt === emote.alt)) return true;
return current.some((e) => e.src === emote.src);
}
}

const EmoteBarWrapper = styled.div`
margin: 8px 0;
background: #18181b;
border: 0;
padding: 6px 8px;
color: #efeff1;
display: flex;
flex-wrap: wrap;

align-items: center;
gap: 8px 10px;

/* Still enforces the 2-row limit */
height: 92px; /* 36(row) + 8(gap) + 36(row) + 6(pad) + 6(pad) */
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The height calculation comment "36(row) + 8(gap) + 36(row) + 6(pad) + 6(pad)" equals 92px, but the actual emote height is 28px (line 269), not 36px. This discrepancy suggests the height value may need adjustment or the comment needs correction.

Suggested change
height: 92px; /* 36(row) + 8(gap) + 36(row) + 6(pad) + 6(pad) */
height: 92px; /* Enforces 2 rows: 2 × 28px emotes + 8px row gap + vertical padding/line spacing */

Copilot uses AI. Check for mistakes.
overflow: hidden;

border-top: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
`;

const EmoteImage = styled.img`
/* Width is now 'auto' by default */
height: 28px; /* All images will have this height */
object-fit: contain;
cursor: pointer;
flex-shrink: 0;
`;

function EmoteBar({
emotes,
onInsert,
onSend,
}: { emotes: Signal<EmoteItem[]>; onInsert: (alt: string) => void; onSend: (alt: string) => void }) {
// Note: Your 'EmoteItem' type no longer needs the 'isWide' property

Comment on lines +280 to +281
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "Your 'EmoteItem' type no longer needs the 'isWide' property" but the type still includes it and it's actively used in the code (lines 49, 88, 113, 237). This misleading comment should be removed or updated to reflect the actual implementation.

Suggested change
// Note: Your 'EmoteItem' type no longer needs the 'isWide' property

Copilot uses AI. Check for mistakes.
return (
<EmoteBarWrapper>
{emotes.value.map((item, index) => (
<EmoteImage
key={`${item.src}-${index}`}
src={item.src}
alt={item.alt}
onClick={(e) => (e.ctrlKey ? onSend(item.alt) : onInsert(item.alt))}
/* The inline 'style' prop is gone! */
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "The inline 'style' prop is gone!" but this appears to be leftover from development. This comment should be removed as it doesn't provide meaningful documentation.

Suggested change
/* The inline 'style' prop is gone! */

Copilot uses AI. Check for mistakes.
/>
))}
</EmoteBarWrapper>
);
}
8 changes: 8 additions & 0 deletions src/platforms/twitch/modules/settings/settings.module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,14 @@ export default class SettingsModule extends TwitchModule {
step: 1,
tabIndex: tabIndexes.Chat,
},
{
id: "emoteBarEnabled",
title: "Enable Emote Bar",
description: "Displays a bar with emotes in chat.",
type: "toggle",
tabIndex: tabIndexes.Experimental,
requiresRefreshToDisable: true,
},
{
id: "quickAccessLinks",
title: "Quick Access Links",
Expand Down
1 change: 1 addition & 0 deletions src/platforms/twitch/twitch.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ export const TWITCH_DEFAULT_SETTINGS: TwitchSettings = {
realVideoTimeFormat12h: false,
pinnedStreamersEnabled: true,
xayoWatchtimeEnabled: true,
emoteBarEnabled: false,
channelSection: true,
};
2 changes: 2 additions & 0 deletions src/platforms/twitch/twitch.platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ChatMessageMenuModule from "$twitch/modules/chat-message-menu/chat-messag
import MessageMenuModule from "$twitch/modules/chat-message-menu/message-menu.module.tsx";
import ChatNicknameCustomizationModule from "$twitch/modules/chat-nickname-customization/chat-nickname-customization.module.tsx";
import ChattersModule from "$twitch/modules/chatters/chatters.module.tsx";
import EmoteBarModule from "$twitch/modules/emote-bar/emote-bar.module.tsx";
import LocalWatchtimeCounterModule from "$twitch/modules/local-watchtime-counter/local-watchtime-counter.module.tsx";
import PinStreamerModule from "$twitch/modules/pin-streamer/pin-streamer.module.tsx";
import RealVideoTimeModule from "$twitch/modules/real-video-time/real-video-time.module.tsx";
Expand Down Expand Up @@ -69,6 +70,7 @@ export default class TwitchPlatform extends Platform<TwitchModule, TwitchEvents,
new MessageMenuModule(...dependencies),
new ChatMessageMenuModule(...dependencies),
new ChatMentionSoundModule(...dependencies),
new EmoteBarModule(...dependencies),
new AdditionalFontsModule(...dependencies),
];
}
Expand Down
2 changes: 1 addition & 1 deletion src/platforms/twitch/twitch.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default class TwitchUtils {
const node = this.reactUtils.findReactChildren<Chat>(
this.reactUtils.getReactInstance(document.querySelector(".stream-chat")),
(n) => n.stateNode?.props?.onSendMessage,
Copy link

Copilot AI Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The search depth was increased from 100 to 190 without explanation. Consider adding a comment explaining why this specific depth is needed, especially since it nearly doubles the traversal depth which could impact performance.

Suggested change
(n) => n.stateNode?.props?.onSendMessage,
(n) => n.stateNode?.props?.onSendMessage,
// NOTE: A relatively high depth is required here because the Chat component
// is nested deeply in Twitch's React tree; lower values (e.g. 100) have
// failed to find the node after Twitch layout updates.

Copilot uses AI. Check for mistakes.
100,
190,
);
return node?.stateNode;
}
Expand Down
Loading