diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md index 72ddf750..c94952b5 100644 --- a/CHANGELOG-nightly.md +++ b/CHANGELOG-nightly.md @@ -1,3 +1,7 @@ +### 3.1.26.1000 + +- Added EloWard League of Legends rank badges module + ## 3.1.25.1000 - Fixed Kick Emote Menu position diff --git a/src/app/chat/UserTag.vue b/src/app/chat/UserTag.vue index bd73b71a..16241a38 100644 --- a/src/app/chat/UserTag.vue +++ b/src/app/chat/UserTag.vue @@ -8,7 +8,8 @@ @@ -36,6 +37,9 @@ type="app" /> + + + @@ -74,6 +78,10 @@ import { useChannelContext } from "@/composable/channel/useChannelContext"; import { useChatProperties } from "@/composable/chat/useChatProperties"; import { useCosmetics } from "@/composable/useCosmetics"; import { useConfig } from "@/composable/useSettings"; +import EloWardBadge from "@/site/twitch.tv/modules/eloward/components/EloWardBadge.vue"; +import { useEloWardRanks } from "@/site/twitch.tv/modules/eloward/composables/useEloWardRanks"; +import type { EloWardBadge as EloWardBadgeType } from "@/site/twitch.tv/modules/eloward/composables/useEloWardRanks"; +import { useGameDetection } from "@/site/twitch.tv/modules/eloward/composables/useGameDetection"; import Badge, { TwitchChatBadgeWithData } from "./Badge.vue"; import UserCard from "./UserCard.vue"; import UiDraggable from "@/ui/UiDraggable.vue"; @@ -116,6 +124,11 @@ const twitchBadges = ref([]); const twitchBadgeSets = toRef(properties, "twitchBadgeSets"); const mentionStyle = useConfig("chat.colored_mentions"); +// EloWard integration +const elowardRanks = useEloWardRanks(); +const gameDetection = useGameDetection(); +const elowardEnabled = useConfig("eloward.enabled"); + const tagRef = ref(); const showUserCard = ref(false); const cardHandle = ref(); @@ -184,6 +197,63 @@ const stop = watch( }, { immediate: true }, ); + +// EloWard badge integration +const elowardBadge = ref(null); + +// Detect official EloWard Chrome extension +function detectOfficialExtension(): boolean { + const bodyAttr = document.body?.getAttribute("data-eloward-chrome-ext"); + const htmlAttr = document.documentElement?.getAttribute("data-eloward-chrome-ext"); + + return bodyAttr === "active" || htmlAttr === "active"; +} + +const initializeEloWardBadge = () => { + // Skip if official EloWard Chrome extension is detected + if (detectOfficialExtension()) { + elowardBadge.value = null; + return; + } + + // Badges are never shown for mentions or the user card header, so don't look them up + if (props.hideBadges || !elowardEnabled.value || !gameDetection.isLeagueStream.value) { + elowardBadge.value = null; + return; + } + + const username = props.user.username; + if (!username) { + elowardBadge.value = null; + return; + } + + const cachedData = elowardRanks.getCachedRankData(username); + + if (cachedData !== undefined) { + elowardBadge.value = cachedData ? elowardRanks.getRankBadge(cachedData) : null; + return; + } + + elowardRanks + .fetchRankData(username) + .then((rankData) => { + // The stream's category or the setting can change while the request is in flight + if (username !== props.user.username || !elowardEnabled.value || !gameDetection.isLeagueStream.value) + return; + + elowardBadge.value = rankData ? elowardRanks.getRankBadge(rankData) : null; + }) + .catch(() => { + elowardBadge.value = null; + }); +}; + +initializeEloWardBadge(); + +watch(() => props.user.username, initializeEloWardBadge); +watch(elowardEnabled, initializeEloWardBadge); +watch(() => gameDetection.isLeagueStream.value, initializeEloWardBadge); diff --git a/src/site/twitch.tv/modules/eloward/components/EloWardTooltip.vue b/src/site/twitch.tv/modules/eloward/components/EloWardTooltip.vue new file mode 100644 index 00000000..ea365d49 --- /dev/null +++ b/src/site/twitch.tv/modules/eloward/components/EloWardTooltip.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/src/site/twitch.tv/modules/eloward/composables/useEloWardRanks.ts b/src/site/twitch.tv/modules/eloward/composables/useEloWardRanks.ts new file mode 100644 index 00000000..0af49783 --- /dev/null +++ b/src/site/twitch.tv/modules/eloward/composables/useEloWardRanks.ts @@ -0,0 +1,331 @@ +import { type Ref, ref } from "vue"; +import { useConfig } from "@/composable/useSettings"; + +const API_BASE_URL = "https://eloward-ranks.unleashai.workers.dev/api/ranks/lol"; +const CDN_BASE_URL = "https://eloward-cdn.unleashai.workers.dev/lol"; +const DEFAULT_CACHE_DURATION = 60 * 60 * 1000; +const NEGATIVE_CACHE_DURATION = 15 * 60 * 1000; +const MAX_CACHE_SIZE = 500; +const BADGE_CACHE_VERSION = "3"; + +interface CacheEntry { + data: EloWardRankData | null; // null for negative cache + timestamp: number; +} + +class LRUCache { + private cache: Map; + private maxSize: number; + + constructor(maxSize = 500) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + get(username: string): EloWardRankData | null | undefined { + const key = username.toLowerCase(); + const entry = this.cache.get(key); + + if (!entry) return undefined; // Not in cache + + // Check if cache is expired + const now = Date.now(); + const cacheDuration = + entry.data === null + ? NEGATIVE_CACHE_DURATION // Shorter duration for negative cache + : DEFAULT_CACHE_DURATION; + + if (now - entry.timestamp > cacheDuration) { + this.cache.delete(key); + return undefined; + } + + // Move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, entry); + + return entry.data; // Can be null (negative cache) or data + } + + set(username: string, data: EloWardRankData | null) { + const key = username.toLowerCase(); + + // Remove oldest entry if at max size + if (this.cache.size >= this.maxSize && !this.cache.has(key)) { + const firstKey = this.cache.keys().next().value; + if (firstKey) this.cache.delete(firstKey); + } + + this.cache.set(key, { + data, + timestamp: Date.now(), + }); + } + + clear() { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } +} + +// Global cache instance +const rankCache = new LRUCache(MAX_CACHE_SIZE); + +const pendingRequests = new Map>(); + +const RANK_TIERS = new Set([ + "iron", + "bronze", + "silver", + "gold", + "platinum", + "emerald", + "diamond", + "master", + "grandmaster", + "challenger", + "unranked", +]); + +function getImageUrl(tier: string, isAnimated: boolean): string { + const extension = isAnimated ? ".webp" : ".png"; + const suffix = isAnimated ? "_premium" : ""; + return `${CDN_BASE_URL}/${tier}${suffix}${extension}?v=${BADGE_CACHE_VERSION}`; +} + +export interface EloWardRankData { + tier: string; + division?: string; + leaguePoints?: number; + summonerName?: string; + region?: string; + animate_badge?: boolean; +} + +export interface EloWardBadge { + id: string; + tier: string; + division?: string; + imageUrl: string; + animated: boolean; + summonerName?: string; + region?: string; + leaguePoints?: number; +} + +let cachedEnabledConfig: ReturnType> | null = null; +let cachedIsLoading: Ref | null = null; + +export function useEloWardRanks() { + if (!cachedEnabledConfig) { + cachedEnabledConfig = useConfig("eloward.enabled"); + cachedIsLoading = ref(false); + } + + const enabled = cachedEnabledConfig!; + const isLoading = cachedIsLoading!; + + function getCachedRankData(username: string): EloWardRankData | null | undefined { + if (!enabled.value || !username) return undefined; + return rankCache.get(username.toLowerCase()); + } + + async function fetchRankData(username: string): Promise { + if (!enabled.value || !username) { + return null; + } + + const normalizedUsername = username.toLowerCase(); + + const cached = rankCache.get(normalizedUsername); + if (cached !== undefined) { + return cached; + } + + if (pendingRequests.has(normalizedUsername)) { + return pendingRequests.get(normalizedUsername)!; + } + + const requestPromise = (async () => { + try { + isLoading.value = true; + + const response = await fetch(`${API_BASE_URL}/${encodeURIComponent(normalizedUsername)}`, { + method: "GET", + headers: { + Accept: "application/json", + }, + signal: AbortSignal.timeout(5000), + }); + + if (response.status === 404) { + rankCache.set(normalizedUsername, null); + return null; + } + + // Cache failures too, otherwise every new message retries while the API is down + if (!response.ok) { + rankCache.set(normalizedUsername, null); + return null; + } + + const data = await response.json(); + + if (!data.rank_tier || !RANK_TIERS.has(data.rank_tier.toLowerCase())) { + rankCache.set(normalizedUsername, null); + return null; + } + + const rankData: EloWardRankData = { + tier: data.rank_tier, + division: data.rank_division, + leaguePoints: data.lp, + summonerName: data.riot_id, + region: data.region, + animate_badge: data.animate_badge, + }; + + rankCache.set(normalizedUsername, rankData); + return rankData; + } catch (error) { + rankCache.set(normalizedUsername, null); + return null; + } finally { + isLoading.value = false; + pendingRequests.delete(normalizedUsername); + } + })(); + + pendingRequests.set(normalizedUsername, requestPromise); + return requestPromise; + } + + function getRankBadge(rankData: EloWardRankData): EloWardBadge | null { + if (!rankData?.tier) return null; + + const tier = rankData.tier.toLowerCase(); + if (!RANK_TIERS.has(tier)) return null; + + const shouldAnimate = Boolean(rankData.animate_badge); + const imageUrl = getImageUrl(tier, shouldAnimate); + + return { + id: `eloward-${tier}${rankData.division ? `-${rankData.division}` : ""}`, + tier: rankData.tier.toUpperCase(), + division: rankData.division, + imageUrl, + animated: shouldAnimate, + summonerName: rankData.summonerName, + region: rankData.region, + leaguePoints: rankData.leaguePoints, + }; + } + + /** + * Format rank text for display + */ + function formatRankText(rankData: EloWardRankData): string { + if (!rankData?.tier) return "UNRANKED"; + + const tierUpper = rankData.tier.toUpperCase(); + if (tierUpper === "UNRANKED") return "UNRANKED"; + + let rankText = tierUpper; + + if (rankData.division && !["MASTER", "GRANDMASTER", "CHALLENGER"].includes(tierUpper)) { + rankText += ` ${rankData.division}`; + } + + if (rankData.leaguePoints !== undefined && rankData.leaguePoints !== null) { + rankText += ` - ${rankData.leaguePoints} LP`; + } + + return rankText; + } + + /** + * Get region display name + */ + function getRegionDisplay(region?: string): string { + if (!region) return ""; + + const regionMap: Record = { + na1: "NA", + euw1: "EUW", + eun1: "EUNE", + kr: "KR", + br1: "BR", + jp1: "JP", + la1: "LAN", + la2: "LAS", + oc1: "OCE", + tr1: "TR", + ru: "RU", + ph2: "PH", + sg2: "SG", + th2: "TH", + tw2: "TW", + vn2: "VN", + me1: "ME", + sea: "SEA", + }; + + return regionMap[region.toLowerCase()] || region.toUpperCase(); + } + + /** + * Build OP.GG URL for a player + */ + function getOpGGUrl(rankData: EloWardRankData): string | null { + if (!rankData?.summonerName || !rankData?.region) return null; + + const regionMapping: Record = { + na1: "na", + euw1: "euw", + eun1: "eune", + kr: "kr", + br1: "br", + jp1: "jp", + la1: "lan", + la2: "las", + oc1: "oce", + tr1: "tr", + ru: "ru", + ph2: "ph", + sg2: "sg", + th2: "th", + tw2: "tw", + vn2: "vn", + me1: "me", + }; + + const opGGRegion = regionMapping[rankData.region.toLowerCase()]; + if (!opGGRegion) return null; + + const [summonerName, tagLine] = rankData.summonerName.split("#"); + const encodedName = encodeURIComponent(summonerName); + const tag = tagLine || rankData.region.toUpperCase(); + + return `https://op.gg/lol/summoners/${opGGRegion}/${encodedName}-${tag}`; + } + + function clearCache() { + rankCache.clear(); + pendingRequests.clear(); + } + + return { + fetchRankData, + getCachedRankData, + getRankBadge, + formatRankText, + getRegionDisplay, + getOpGGUrl, + clearCache, + isLoading, + cacheSize: () => rankCache.size(), + }; +} diff --git a/src/site/twitch.tv/modules/eloward/composables/useGameDetection.ts b/src/site/twitch.tv/modules/eloward/composables/useGameDetection.ts new file mode 100644 index 00000000..5582209c --- /dev/null +++ b/src/site/twitch.tv/modules/eloward/composables/useGameDetection.ts @@ -0,0 +1,39 @@ +import { ref } from "vue"; + +const LEAGUE_OF_LEGENDS_SLUG = "league-of-legends"; +const CHECK_INTERVAL = 2000; + +const isLeagueStream = ref(false); +let checkInterval: number | null = null; + +/** + * Get the category slug of the stream or VOD being watched + * Uses the link href (/directory/category/league-of-legends) because the link text is localized + */ +function getCategorySlug(): string | null { + const selector = window.location.pathname.includes("/videos/") + ? "a[data-a-target='video-info-game-boxart-link']" + : "a[data-a-target='stream-game-link']"; + + const href = document.querySelector(selector)?.getAttribute("href"); + const match = href?.match(/\/directory\/category\/([^/?#]+)/i); + + return match ? match[1].toLowerCase() : null; +} + +function updateGame() { + isLeagueStream.value = getCategorySlug() === LEAGUE_OF_LEGENDS_SLUG; +} + +export function useGameDetection() { + // Twitch navigates client-side, renders the header late and streamers can change category mid-stream, + // so keep re-checking instead of detecting once + if (checkInterval === null) { + updateGame(); + checkInterval = window.setInterval(updateGame, CHECK_INTERVAL); + } + + return { + isLeagueStream, + }; +} diff --git a/src/types/tw.module.d.ts b/src/types/tw.module.d.ts index c1cb2311..646d2e67 100644 --- a/src/types/tw.module.d.ts +++ b/src/types/tw.module.d.ts @@ -4,6 +4,7 @@ import type ChatInputControllerComponent from "@/site/twitch.tv/modules/chat-inp import type ChatInputModuleVue from "@/site/twitch.tv/modules/chat-input/ChatInputModule.vue"; import type ChatVodModuleVue from "@/site/twitch.tv/modules/chat-vod/ChatVodModule.vue"; import type ChatModuleVue from "@/site/twitch.tv/modules/chat/ChatModule.vue"; +import type EloWardModuleVue from "@/site/twitch.tv/modules/eloward/EloWardModule.vue"; import type EmoteMenuModuleVue from "@/site/twitch.tv/modules/emote-menu/EmoteMenuModule.vue"; import type HiddenElementsModuleVue from "@/site/twitch.tv/modules/hidden-elements/HiddenElementsModule.vue"; import type ModLogsModule from "@/site/twitch.tv/modules/mod-logs/ModLogsModule.vue"; @@ -24,6 +25,7 @@ declare type TwModuleComponentMap = { autoclaim: typeof AutoclaimModuleVue; avatars: typeof AvatarsModuleVue; chat: typeof ChatModuleVue; + eloward: typeof EloWardModuleVue; player: PlayerModule; settings: typeof SettingsModuleVue; };