From 36f6b924b87ff033f8fc9d1def98b3785cacc456 Mon Sep 17 00:00:00 2001 From: maksPodstawski Date: Thu, 25 Sep 2025 19:27:59 +0200 Subject: [PATCH] feat: add pinning functionality for streamers in a channel section --- .../channel-section.module.tsx | 81 ++++++++++++++++++- .../pin-streamer/pin-streamer.module.tsx | 66 ++++++++++++++- .../channel-section.component.tsx | 29 +++++++ src/shared/utils/common.utils.ts | 2 - .../platforms/twitch/twitch.events.types.ts | 3 +- 5 files changed, 173 insertions(+), 8 deletions(-) diff --git a/src/platforms/twitch/modules/channel-section/channel-section.module.tsx b/src/platforms/twitch/modules/channel-section/channel-section.module.tsx index 06c7d5b7..db5d2f7c 100644 --- a/src/platforms/twitch/modules/channel-section/channel-section.module.tsx +++ b/src/platforms/twitch/modules/channel-section/channel-section.module.tsx @@ -3,7 +3,6 @@ import type { QuickAccessLink } from "$types/shared/components/settings.componen 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"; import TwitchModule from "../../twitch.module.ts"; export default class ChannelSectionModule extends TwitchModule { @@ -12,6 +11,9 @@ export default class ChannelSectionModule extends TwitchModule { private currentDisplayName = signal(""); private currentLogin = signal(""); private watchtimeInterval: NodeJS.Timeout | undefined; + private pinnedStreamers: string[] = []; + private channelId: string | undefined; + private isPinned: Signal | undefined; readonly config: TwitchModuleConfig = { name: "channel-info", @@ -31,24 +33,36 @@ export default class ChannelSectionModule extends TwitchModule { this.quickAccessLinks.value = quickAccessLinks; }, }, + { + type: "event", + key: "pinned-streamers-updated", + event: "twitch:pinnedStreamersUpdated", + callback: (pinned: string[]) => { + this.pinnedStreamers = [...pinned]; + }, + }, ], }; async initialize() { const quickAccessLinks = await this.settingsService().getSettingsKey("quickAccessLinks"); this.quickAccessLinks = signal(quickAccessLinks); + this.pinnedStreamers.push(...(await this.settingsService().getSettingsKey("pinnedStreamers"))); } private async run(elements: Element[]) { const wrappers = this.commonUtils().createEmptyElements(this.getId(), elements, "div"); for (const wrapper of wrappers) { - if (this.updateNames()) continue; + if (await this.updateNames()) continue; await this.startWatchtimeUpdates(); const logo = await this.commonUtils().getAssetFile( this.workerService(), "enhancer/logo.svg", "https://enhancer.at/assets/brand/logo.png", ); + const pinnedEnabled = await this.settingsService().getSettingsKey("pinnedStreamersEnabled"); + this.channelId = await this.getChannelId(); + this.isPinned = signal(!!(pinnedEnabled && this.channelId && this.isPinnedStreamer(this.channelId))); render( { + const channelId = this.channelId; + const isPinned = this.isPinned; + if (!channelId || !isPinned) return; + isPinned.value = await this.togglePinnedStreamer(channelId); + } + : undefined + } />, wrapper, ); } } - private updateNames() { + private async updateNames() { const channelInfo = this.twitchUtils().getChannelInfo() || this.twitchUtils().getChannelInfoFromHomeLowerContent(); if (!channelInfo) { this.logger.warn("Channel name not found"); @@ -70,11 +95,20 @@ export default class ChannelSectionModule extends TwitchModule { } this.currentDisplayName.value = channelInfo.displayName; this.currentLogin.value = channelInfo.channelLogin; + try { + this.channelId = this.twitchUtils().getChannelId(); + if (this.isPinned) { + const pinnedEnabled = await this.settingsService().getSettingsKey("pinnedStreamersEnabled"); + this.isPinned.value = !!(pinnedEnabled && this.channelId && this.isPinnedStreamer(this.channelId)); + } + } catch { + this.logger.error("Failed to get channel ID"); + } return false; } private async updateWatchtime() { - if (this.updateNames()) return; + if (await this.updateNames()) return; if (this.currentLogin.value.length < 1) return; try { this.watchtimeCounter.value = await this.getWatchTime(this.currentLogin.value); @@ -103,4 +137,43 @@ export default class ChannelSectionModule extends TwitchModule { }); return watchtime?.time ?? 0; } + + private async getChannelId(): Promise { + let resolvedId: string | undefined; + await this.commonUtils().waitFor( + () => { + try { + const direct = this.twitchUtils().getChannelId(); + if (direct) return direct; + const alt = this.twitchUtils().getChannelInfoFromHomeLowerContent(); + return alt?.channelId; + } catch { + return undefined; + } + }, + async (id) => { + resolvedId = id; + return true; + }, + { maxRetries: 50, delay: 100 }, + ); + return resolvedId; + } + + private isPinnedStreamer(channelId: string): boolean { + return this.pinnedStreamers.includes(channelId); + } + + private async togglePinnedStreamer(channelId: string): Promise { + const isPinned = this.isPinnedStreamer(channelId); + if (isPinned) { + this.pinnedStreamers = this.pinnedStreamers.filter((id) => id !== channelId); + } else { + this.pinnedStreamers.push(channelId); + } + await this.settingsService().updateSettingsKey("pinnedStreamers", this.pinnedStreamers); + this.twitchUtils().getPersonalSections()?.forceUpdate(); + this.emitter.emit("twitch:pinnedStreamersUpdated", this.pinnedStreamers); + return !isPinned; + } } diff --git a/src/platforms/twitch/modules/pin-streamer/pin-streamer.module.tsx b/src/platforms/twitch/modules/pin-streamer/pin-streamer.module.tsx index 7c5b3c67..3374bc15 100644 --- a/src/platforms/twitch/modules/pin-streamer/pin-streamer.module.tsx +++ b/src/platforms/twitch/modules/pin-streamer/pin-streamer.module.tsx @@ -29,12 +29,24 @@ export default class PinStreamerModule extends TwitchModule { key: "pin-streamer-hide-sort-description", once: true, }, + { + type: "event", + key: "pin-streamer-pinned-update", + event: "twitch:pinnedStreamersUpdated", + callback: (pinned: string[]) => { + this.pinnedStreamers = [...pinned]; + this.syncPinsWithState(); + this.forceUpdatePersonalSection(); + }, + }, ], isModuleEnabledCallback: async () => await this.settingsService().getSettingsKey("pinnedStreamersEnabled"), }; private observer: MutationObserver | undefined; private pinnedStreamers: string[] = []; + private pinStates = new Map>(); + private pinButtons = new Map(); private run(elements: Element[]) { this.hookPersonalSectionsRender(); @@ -80,8 +92,11 @@ export default class PinStreamerModule extends TwitchModule { if (!channelID) return; const imageWrapper = channelWrapper.querySelector("div.tw-avatar"); if (!imageWrapper) return; - const isPinned = signal(this.isPinnedStreamer(channelID)); + const existingSignal = this.pinStates.get(channelID); + const isPinned = existingSignal ?? signal(this.isPinnedStreamer(channelID)); + this.pinStates.set(channelID, isPinned); const button = this.commonUtils().createElementByParent("pin-streamer-button", "button", imageWrapper); + this.pinButtons.set(channelID, button as HTMLButtonElement); button.onclick = async (event) => { event.preventDefault(); event.stopPropagation(); @@ -111,6 +126,53 @@ export default class PinStreamerModule extends TwitchModule { this.forceUpdatePersonalSection(); } + private syncPinsWithState() { + for (const [channelId, state] of this.pinStates.entries()) { + const newValue = this.isPinnedStreamer(channelId); + state.value = newValue; + const btn = this.pinButtons.get(channelId); + if (btn) btn.style.display = newValue ? "inline-block" : "none"; + } + } + + private getSideNavGroup(): Element | null { + return document.querySelector("#side-nav .side-nav-section .tw-transition-group"); + } + + private refreshPinsFromDom() { + const container = this.getSideNavGroup(); + if (!container) return; + + const presentIds = new Set(); + const items = container.querySelectorAll( + '#side-nav .side-nav-section .side-nav-card__link[data-test-selector="followed-channel"]', + ); + for (const item of Array.from(items)) { + const el = item as Element; + const channelID = this.twitchUtils().getUserIdBySideElement(el); + if (!channelID) continue; + presentIds.add(channelID); + if (!this.pinStates.has(channelID)) { + this.pinStates.set(channelID, signal(this.isPinnedStreamer(channelID))); + } + const existingButton = el.querySelector(".pin-streamer-button"); + if (existingButton) { + this.pinButtons.set(channelID, existingButton); + const isPinned = this.isPinnedStreamer(channelID); + existingButton.style.display = isPinned ? "inline-block" : "none"; + } else { + this.createPin(el); + } + } + + for (const id of Array.from(this.pinStates.keys())) { + if (!presentIds.has(id)) this.pinStates.delete(id); + } + for (const id of Array.from(this.pinButtons.keys())) { + if (!presentIds.has(id)) this.pinButtons.delete(id); + } + } + private hookPersonalSectionsRender() { const reactComponent = this.twitchUtils().getPersonalSections(); if (!reactComponent) return; @@ -118,6 +180,7 @@ export default class PinStreamerModule extends TwitchModule { reactComponent.render = (...data: any[]) => { this.logger.debug("Rendering personal section channels"); this.updateFollowList(); + this.refreshPinsFromDom(); return originalFunction.apply(reactComponent, data); }; this.logger.debug("Hooked into personal section render function"); @@ -178,6 +241,7 @@ export default class PinStreamerModule extends TwitchModule { this.pinnedStreamers.push(channelId); } await this.settingsService().updateSettingsKey("pinnedStreamers", this.pinnedStreamers); + this.emitter.emit("twitch:pinnedStreamersUpdated", this.pinnedStreamers); return !isPinned; } diff --git a/src/shared/components/channel-section/channel-section.component.tsx b/src/shared/components/channel-section/channel-section.component.tsx index 7c9a1b79..9a92a0fd 100644 --- a/src/shared/components/channel-section/channel-section.component.tsx +++ b/src/shared/components/channel-section/channel-section.component.tsx @@ -8,6 +8,8 @@ interface ChannelSectionComponentProps { sites: Signal; watchTime: Signal; logoUrl: string; + isPinned?: Signal; + onTogglePin?: () => void; } export function ChannelSectionComponent({ @@ -16,6 +18,8 @@ export function ChannelSectionComponent({ sites, watchTime, logoUrl, + isPinned, + onTogglePin, }: ChannelSectionComponentProps) { const formatWatchTime = (time: number) => { const hours = time === 0 ? 0 : time / 3600; @@ -36,6 +40,11 @@ export function ChannelSectionComponent({ {displayName.value} You've watched this channel for {formatWatchTime(watchTime.value)} + {isPinned && onTogglePin && ( + + {isPinned.value ? "★" : "☆"} + + )} @@ -153,3 +162,23 @@ const LinkName = styled.div` overflow: hidden; text-overflow: ellipsis; `; + +const PinButton = styled.button<{ $isPinned: boolean }>` + background: transparent; + border: none; + border-radius: 3px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; +`; + +const StarIcon = styled.div<{ $isPinned: boolean }>` + font-size: 20px; + line-height: 1; + font-weight: normal; + color: #ffffff; +`; diff --git a/src/shared/utils/common.utils.ts b/src/shared/utils/common.utils.ts index b93ddfbf..1f8e5ec9 100644 --- a/src/shared/utils/common.utils.ts +++ b/src/shared/utils/common.utils.ts @@ -1,8 +1,6 @@ import type WorkerService from "$shared/worker/worker.service.ts"; import type { EnhancerBadgeSize } from "$types/apis/enhancer.apis.ts"; -import type { RequestConfig, RequestResponse } from "$types/shared/http-client.types.ts"; import type { WaitForConfig } from "$types/shared/utils/common.utils.types.ts"; -import { defaultAllowedOrigins } from "vite"; export default class CommonUtils { static readonly UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; diff --git a/src/types/platforms/twitch/twitch.events.types.ts b/src/types/platforms/twitch/twitch.events.types.ts index 77e335ed..e19621cb 100644 --- a/src/types/platforms/twitch/twitch.events.types.ts +++ b/src/types/platforms/twitch/twitch.events.types.ts @@ -1,4 +1,4 @@ -import { type MessageMenuEvent, MessageMenuOption } from "$shared/components/message-menu/message-menu.component.tsx"; +import type { MessageMenuEvent } from "$shared/components/message-menu/message-menu.component.tsx"; import type { CommonEvents } from "$types/platforms/common.events.ts"; import type { TwitchSettingsEvents } from "$types/platforms/twitch/twitch.settings.types.ts"; import type { ComponentChildren } from "preact"; @@ -8,6 +8,7 @@ export type TwitchEvents = { "twitch:chatMessage": (message: TwitchChatMessageEvent) => void | Promise; "twitch:chatPopupMessage": (message: ChatMessagePopupEvent) => void | Promise; "twitch:messageMenu": (message: MessageMenuEvent) => void | Promise; + "twitch:pinnedStreamersUpdated": (pinned: string[]) => void | Promise; } & TwitchSettingsEvents & CommonEvents;