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
1 change: 1 addition & 0 deletions public/assets/brands/kick.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/assets/brands/twitch.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/assets/brands/twitch.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/platforms/kick/kick.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ export const KICK_DEFAULT_SETTINGS: KickSettings = {
streamLatencyEnabled: true,
realVideoTimeEnabled: true,
realVideoTimeFormat12h: false,
shareFollowsToOtherPlatforms: false,
showFollowsFromOtherPlatforms: true,
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The default value for showFollowsFromOtherPlatforms is set to true, which means users will automatically see follows from other platforms without explicitly opting in. This could be surprising behavior for users. Consider setting this to false by default to make it an opt-in feature, especially since shareFollowsToOtherPlatforms is defaulted to false.

Suggested change
showFollowsFromOtherPlatforms: true,
showFollowsFromOtherPlatforms: false,

Copilot uses AI. Check for mistakes.
channelSection: true,
};
4 changes: 3 additions & 1 deletion src/platforms/kick/kick.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type KickApi from "$kick/apis/kick.api.ts";
import type KickUtils from "$kick/kick.utils.ts";
import type EnhancerApi from "$shared/apis/enhancer.api.ts";
import Module from "$shared/module/module.ts";
import type CommonDataService from "$shared/settings/common.service.ts";
import type SettingsService from "$shared/settings/settings.service.ts";
import type StorageRepository from "$shared/storage/storage-repository.ts";
import type UtilsRepository from "$shared/utils/utils.repository.ts";
Expand All @@ -16,13 +17,14 @@ export default abstract class KickModule extends Module<KickEvents, KickStorage,
emitter: Emitter<KickEvents>,
storageRepository: StorageRepository<KickStorage>,
settingsService: SettingsService<KickSettings>,
commonDataService: CommonDataService,
utilsRepository: UtilsRepository,
enhancerApi: EnhancerApi,
workerApi: WorkerService,
private readonly _kickUtils: KickUtils,
private readonly _kickApi: KickApi,
) {
super(emitter, storageRepository, settingsService, utilsRepository, enhancerApi, workerApi);
super(emitter, storageRepository, settingsService, commonDataService, utilsRepository, enhancerApi, workerApi);
}

protected kickUtils() {
Expand Down
5 changes: 3 additions & 2 deletions src/platforms/kick/kick.platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import ChatAttachmentsModule from "$kick/modules/chat-attachments/chat-attachmen
import ChatBadgesModule from "$kick/modules/chat-badges/chat-badges.module.tsx";
import ChatFixAutoScrollModule from "$kick/modules/chat-fix-auto-scroll/chat-fix-auto-scroll.module.tsx";
import ChatHighlightUserModule from "$kick/modules/chat-highlight-user/chat-highlight-user.module.tsx";
import ChatMessageMenuModule from "$kick/modules/chat-message-menu/chat-message-menu.module.tsx";
import MessageMenuModule from "$kick/modules/chat-message-menu/message-menu.module.tsx";
import ChatMessagePopupModule from "$kick/modules/chat-message-popup/chat-message-popup.module.tsx";
import ChatNicknameCustomizationModule from "$kick/modules/chat-nickname-customization/chat-nickname-customization.module.ts";
import ChatModule from "$kick/modules/chat/chat.module.ts";
import LocalWatchtimeCounterModule from "$kick/modules/local-watchtime-counter/local-watchtime-counter.module.tsx";
import RealVideoTimeModule from "$kick/modules/real-video-time/real-video-time.module.tsx";
import SettingsButtonModule from "$kick/modules/settings-button/settings-button.module.tsx";
import SettingsModule from "$kick/modules/settings/settings.module.tsx";
import SharedFollowsModule from "$kick/modules/shared-follows/shared-follows.module.tsx";
import StreamLatencyModule from "$kick/modules/stream-latency/stream-latency.module.tsx";
import Platform from "$shared/platform/platform.ts";
import type { KickEvents } from "$types/platforms/kick/kick.events.types.ts";
Expand All @@ -35,6 +34,7 @@ export default class KickPlatform extends Platform<KickModule, KickEvents, KickS
this.emitter,
this.storageRepository,
this.settingsService,
this.commonDataService,
this.utilsRepository,
this.enhancerApi,
this.workerApi,
Expand All @@ -58,6 +58,7 @@ export default class KickPlatform extends Platform<KickModule, KickEvents, KickS
// new ChatMessageMenuModule(...dependencies),
new AdditionalFontsModule(...dependencies),
new ChatFixAutoScrollModule(...dependencies),
new SharedFollowsModule(...dependencies),
];
}

Expand Down
16 changes: 16 additions & 0 deletions src/platforms/kick/modules/settings/settings.module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ export default class SettingsModule extends KickModule {
discord: await this.commonUtils().getAssetFile(this.workerService(), "brands/discord.svg"),
} as const;
this.SETTING_DEFINITIONS = [
{
id: "showFollowsFromOtherPlatforms",
title: "Enable follow list from other platforms",
description: "Shows your followed channels from other platforms in the follow list.",
type: "toggle",
tabIndex: 0,
requiresRefreshToDisable: true,
},
{
id: "shareFollowsToOtherPlatforms",
title: "Share followed channels",
description: "Expose your followed list to Enhancer so it can be used across platforms.",
type: "toggle",
tabIndex: 0,
requiresRefreshToDisable: false,
},
{
id: "streamLatencyEnabled",
title: "Enable Stream Latency",
Expand Down
79 changes: 79 additions & 0 deletions src/platforms/kick/modules/shared-follows/kick.follow-syncer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { HttpClient } from "$shared/http/http-client.ts";
import FollowSyncer from "$shared/module/shared-follows/follow-syncer.ts";
import type CommonDataService from "$shared/settings/common.service.ts";
import type CommonUtils from "$shared/utils/common.utils.ts";
import type { FollowedChannelsResponse } from "$types/platforms/kick/kick.api.types.ts";

export default class KickFollowSyncer extends FollowSyncer {
private readonly http: HttpClient = new HttpClient(this.logger);
private syncInProgress = false;

constructor(
private readonly commonDataService: CommonDataService,
private readonly commonUtils: CommonUtils,
) {
super("kick");
}

async getFollows() {
if (this.syncInProgress) {
this.logger.warn("Sync already in progress, skipping new request");
return;
}
this.syncInProgress = true;
try {
const followed = await this.fetchAllFollowed();
await this.commonDataService.updateCommonNestedKey("sharedFollows", "kick", followed);
this.logger.info(`Synced ${followed.length} followed channels`);
} catch (err) {
this.logger.error("Failed to sync follows", err);
} finally {
this.syncInProgress = false;
}
}

async clearFollows() {
await this.commonDataService.updateCommonNestedKey("sharedFollows", "kick", []);
}

private async fetchAllFollowed(): Promise<string[]> {
const collected = new Set<string>();
await this.fetchFollowedRecursive(0, collected);
return Array.from(collected);
}

private async fetchFollowedRecursive(cursor: number, collected: Set<string>) {
const authorization = this.getAuthHeader();
if (!authorization) return;

try {
const url = new URL("https://kick.com/api/v2/channels/followed");
url.searchParams.set("cursor", String(cursor));

const { data } = await this.http.request<FollowedChannelsResponse>(url.href, {
method: "GET",
headers: { Authorization: authorization },
});

(data.channels ?? []).forEach((channel) => {
const name = (channel.channel_slug || channel.user_username || "").toString().trim();
if (name) {
collected.add(name.toLowerCase());
}
});

if (typeof data.nextCursor === "number") {
await this.fetchFollowedRecursive(data.nextCursor, collected);
}
Comment on lines +45 to +67
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The recursive fetching doesn't have a maximum depth limit or safeguard against infinite loops. If the API returns an unexpected cursor value or enters a cycle, this could lead to infinite recursion. Consider adding a maximum depth parameter or iteration counter to prevent potential stack overflow or excessive API calls.

Copilot uses AI. Check for mistakes.
} catch (error) {
this.logger.warn(`Failed to fetch followed channels at cursor ${cursor}`, error);
return;
}
}

private getAuthHeader(): string | undefined {
const token = this.commonUtils.getCookie("session_token");
if (!token) return;
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The authorization token is retrieved from cookies without any validation or error handling. If the session_token cookie is missing, undefined is returned and the sync silently fails. Consider logging a more informative message when the token is missing to help users understand why follow syncing isn't working.

Suggested change
if (!token) return;
if (!token) {
this.logger.warn("Kick follow sync: 'session_token' cookie is missing; cannot fetch followed channels.");
return;
}

Copilot uses AI. Check for mistakes.
return `Bearer ${token}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import KickModule from "$kick/kick.module.ts";
import KickFollowSyncer from "$kick/modules/shared-follows/kick.follow-syncer.ts";
import type { KickModuleConfig } from "$types/shared/module/module.types.ts";

export default class SharedFollowsModule extends KickModule {
private readonly kickFollowsSyncer = new KickFollowSyncer(this.commonDataService(), this.commonUtils());

config: KickModuleConfig = {
name: "shared-follows",
appliers: [
{
type: "event",
event: "kick:settings:shareFollowsToOtherPlatforms",
callback: async (value) => {
if (value) await this.startSyncTimer();
else {
await this.kickFollowsSyncer.clearFollows();
this.stopSyncTimer();
}
},
key: "share-follows",
},
],
};

private syncFollowsTimer: NodeJS.Timeout | undefined;

async initialize(): Promise<void> {
const shareFollowsToOtherPlatforms = await this.settingsService().getSettingsKey("shareFollowsToOtherPlatforms");
if (shareFollowsToOtherPlatforms) await this.startSyncTimer();
}

private async startSyncTimer() {
this.stopSyncTimer();
this.syncFollowsTimer = setInterval(() => this.kickFollowsSyncer.getFollows(), 120000); // 2 mins
await this.kickFollowsSyncer.getFollows();
}

private stopSyncTimer() {
if (this.syncFollowsTimer) clearInterval(this.syncFollowsTimer);
}
}
Loading