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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<boolean> | undefined;

readonly config: TwitchModuleConfig = {
name: "channel-info",
Expand All @@ -31,50 +33,82 @@ 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();
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 this.channelId is assigned twice - once in updateNames() and again in the run() method. Since updateNames() is now async, this could lead to a race condition where the channel ID gets overwritten with a different value before the pin toggle handler executes.

Suggested change
this.channelId = await this.getChannelId();
const channelId = await this.getChannelId();
this.channelId = channelId;

this.isPinned = signal(!!(pinnedEnabled && this.channelId && this.isPinnedStreamer(this.channelId)));
render(
<ChannelSectionComponent
displayName={this.currentDisplayName}
login={this.currentLogin}
sites={this.quickAccessLinks}
watchTime={this.watchtimeCounter}
logoUrl={logo}
isPinned={pinnedEnabled && this.channelId ? this.isPinned : undefined}
onTogglePin={
pinnedEnabled && this.channelId
? async () => {
const channelId = this.channelId;
const isPinned = this.isPinned;
if (!channelId || !isPinned) return;
isPinned.value = await this.togglePinnedStreamer(channelId);
Comment on lines +79 to +80
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 condition if (!channelId || !isPinned) return; on line 79 only checks if isPinned is falsy, but doesn't verify if isPinned.value exists before using it on line 80. This could lead to a runtime error if isPinned is a signal object but its value is undefined.

Suggested change
if (!channelId || !isPinned) return;
isPinned.value = await this.togglePinnedStreamer(channelId);
if (!channelId || !isPinned || isPinned.value === undefined) 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");
return true;
}
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);
Expand Down Expand Up @@ -103,4 +137,43 @@ export default class ChannelSectionModule extends TwitchModule {
});
return watchtime?.time ?? 0;
}

private async getChannelId(): Promise<string | undefined> {
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<boolean> {
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Signal<boolean>>();
private pinButtons = new Map<string, HTMLButtonElement>();

private run(elements: Element[]) {
this.hookPersonalSectionsRender();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -111,13 +126,61 @@ 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<string>();
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<HTMLButtonElement>(".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;
const originalFunction = reactComponent.render;
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");
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface ChannelSectionComponentProps {
sites: Signal<QuickAccessLink[]>;
watchTime: Signal<number>;
logoUrl: string;
isPinned?: Signal<boolean>;
onTogglePin?: () => void;
}

export function ChannelSectionComponent({
Expand All @@ -16,6 +18,8 @@ export function ChannelSectionComponent({
sites,
watchTime,
logoUrl,
isPinned,
onTogglePin,
}: ChannelSectionComponentProps) {
const formatWatchTime = (time: number) => {
const hours = time === 0 ? 0 : time / 3600;
Expand All @@ -36,6 +40,11 @@ export function ChannelSectionComponent({
<ChannelName>{displayName.value}</ChannelName>
<RowText>—</RowText>
<RowText>You've watched this channel for {formatWatchTime(watchTime.value)}</RowText>
{isPinned && onTogglePin && (
<PinButton $isPinned={isPinned.value} onClick={onTogglePin}>
<StarIcon $isPinned={isPinned.value}>{isPinned.value ? "★" : "☆"}</StarIcon>
</PinButton>
)}
</ChannelNameRow>
</ChannelDetails>
</ChannelInfo>
Expand Down Expand Up @@ -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;
`;
2 changes: 0 additions & 2 deletions src/shared/utils/common.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/types/platforms/twitch/twitch.events.types.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,6 +8,7 @@ export type TwitchEvents = {
"twitch:chatMessage": (message: TwitchChatMessageEvent) => void | Promise<void>;
"twitch:chatPopupMessage": (message: ChatMessagePopupEvent) => void | Promise<void>;
"twitch:messageMenu": (message: MessageMenuEvent) => void | Promise<void>;
"twitch:pinnedStreamersUpdated": (pinned: string[]) => void | Promise<void>;
} & TwitchSettingsEvents &
CommonEvents;

Expand Down