-
Notifications
You must be signed in to change notification settings - Fork 21
Feat/emote bar #95
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
base: master
Are you sure you want to change the base?
Feat/emote bar #95
Changes from all commits
cc6f27b
d0e9fb0
645f36e
27dea95
8f1abf0
50915a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🐛 Possible Bug
Suggested change
|
||||||||||||||||||||||||||||
| const loaded = storage[key] || []; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| let filtered = loaded; | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| filtered = await this.filterEmotesAgainstChannel(loaded); | ||||||||||||||||||||||||||||
| } catch {} | ||||||||||||||||||||||||||||
|
Comment on lines
+161
to
+163
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🐛 Possible Bug
Suggested change
|
||||||||||||||||||||||||||||
| } catch {} | |
| } catch (error) { | |
| this.logger.warn("Failed to filter persisted emotes for channel", key, error); | |
| } |
There was a problem hiding this comment.
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.
| } catch (error) { | |
| this.logger.warn("Failed to fetch global emotes:", error); | |
| } | |
| } catch (error) { | |
| this.logger.warn("Failed to fetch emotes:", error); | |
| } |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
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
AI
Dec 28, 2025
There was a problem hiding this comment.
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.
| 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
AI
Dec 28, 2025
There was a problem hiding this comment.
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.
| // Note: Your 'EmoteItem' type no longer needs the 'isWide' property |
Copilot
AI
Dec 28, 2025
There was a problem hiding this comment.
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.
| /* The inline 'style' prop is gone! */ |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||
|
||||||||||||
| (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. |
There was a problem hiding this comment.
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 = 9to make the code more maintainable and the logic clearer.