From 680315db8ba60c92658d06d27d7d37943f296bc3 Mon Sep 17 00:00:00 2001 From: JRBlaze <40746670+JRBlaze@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:12:47 -0400 Subject: [PATCH] Draw Kick's badges, preview linked clips, say when there is an update, and let the worker ask Kick who you are MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kick still did not recognise a moderator, and the reason was one header. `channels//me` is answered only to the signed-in web session, and only when that session arrives as a bearer token — the way Kick's own site sends it. The cookie jar alone gets `401 Unauthenticated`, which is what the content script's same-origin fetch was getting, silently. The page now reads kick.com's `session_token` cookie and sends it as the Authorization header, and the worker does the same through the new `cookies` permission, which is what makes the answer available with Kick merged into a Twitch tab, where there is no kick.com page to ask. `/me` never names whose standing it is, so the account name is read from `/api/v1/user` and the guard against acting as somebody else has something to compare again. Kick history was never replayed: the messages endpoint is keyed by the channel's id, not the chatroom's, and asking with the wrong one is answered `200 OK` with an empty list rather than an error. The channel id is kept on the connection now and history asks with it. Kick sends a role badge as a type and a caption and no picture — its site draws them from icons of its own — so the feed spelled them out. They are drawn now: the channel's own subscriber badges by months, the pictures on Kick's newer `badges_v2` list, and icons for the roles, with the label only for a type none of those cover. A clip link says nothing about what is behind it. The feed finds Twitch and Kick clip addresses in a message, the worker asks the platform, and a card with the thumbnail, title, channel and length lands under the row. A newer release is now said at the top of the overlay as well as on the toolbar icon, which is only there for people who pinned it. Co-Authored-By: Claude Fable 5.1 --- README.md | 43 ++-- manifest.json | 6 +- src/background/clips.js | 98 +++++++++ src/background/kick-source.js | 30 ++- src/background/service-worker.js | 119 +++++++--- src/content/boot.js | 66 +++++- src/content/overlay.css | 88 ++++++++ src/content/overlay.js | 128 ++++++++++- src/content/render.js | 161 +++++++++++++- src/options/options.html | 8 + src/options/options.js | 2 +- src/shared/clips.js | 88 ++++++++ src/shared/constants.js | 4 + src/shared/kick-events.js | 62 +++++- tests/background.js | 17 ++ tests/endtoend.js | 2 +- tests/harness.html | 61 +++++- tests/run.js | 362 ++++++++++++++++++++++++++++++- 18 files changed, 1273 insertions(+), 72 deletions(-) create mode 100644 src/background/clips.js create mode 100644 src/shared/clips.js diff --git a/README.md b/README.md index 94afb71..68c368e 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,10 @@ feed. Open a Kick channel and it works the other way round. - **Every emote you can actually use.** Twitch global, channel, subscriber, follower, bits-tier, hype-train, rewards and Prime emotes; Kick's channel, global and emoji sets; and 7TV, BetterTTV and FrankerFaceZ on both platforms. They are grouped by where they came from in the picker, and - the Twitch list is the one Twitch itself says your account may send. Twitch badge images and - Kick badge labels render inline. + the Twitch list is the one Twitch itself says your account may send. Badges render inline on + both platforms: Twitch's from its badge images, Kick's from the channel's own subscriber + badges, the pictures Kick sends (the level badge), and drawn icons for the roles Kick sends + only as a word — moderator, VIP, OG, founder, verified, gifter and the rest. - **Recent history on join,** with the original timestamps, so you are not staring at an empty panel when you arrive mid-stream. - **Events**: subs, resubs, gifted subs, raids, cheers, hype trains, redemptions, timeouts and @@ -136,6 +138,10 @@ feed. Open a Kick channel and it works the other way round. Twitch's own GIF keyboard — the one place a GIF can be sent from, and the place Twitch applies its own rules about who may. The overlay knows your tier here and says so. See [GIFs in chat](#gifs-in-chat). +- **Clip previews.** When somebody links a Twitch or Kick clip, the clip's thumbnail, title, + channel and length appear as a card under the message, the whole card a link to the clip. A + slug is four random words and a Kick id is a string of letters, so the address alone says + nothing about what is behind it. *Clip previews* in the settings turns the cards off. - **Watch streaks, and the prompts Twitch draws for you alone.** Somebody's watch streak arrives over IRC now and is shown as the event it is. The things Twitch asks *you* to do in its own chat — share your watch streak, share your resub and how long you have subscribed — were drawn under @@ -154,7 +160,7 @@ feed. Open a Kick channel and it works the other way round. There is nothing to build and nothing to install first — Chrome loads the folder as it is. **[⬇ Download the latest release](../../releases/latest)** — grab -`FriendlyChatExtension-v1.17.1.zip` from the Assets list, then follow the steps below. +`FriendlyChatExtension-v1.18.0.zip` from the Assets list, then follow the steps below. (You can also use the green **Code → Download ZIP** button, but that gives you the whole repository — tests, the Cloudflare worker, and a folder named `FriendlyChatExtension-main`. The @@ -192,9 +198,12 @@ so its button is always visible for quick settings. Chrome only updates extensions it installed itself, and it did not install this one. So the extension watches for you: it asks GitHub for the latest release every six hours, and when there -is one newer than the version running, the toolbar icon gets a dot. Open the popup and it names -the version, offers the zip and offers `chrome://extensions` to drop it on. Dismissing it hides -that one version, not every future one. *Check for updates* in the popup's footer asks now. +is one newer than the version running, the toolbar icon gets a dot and a one-line strip appears +at the top of the overlay — the icon is only there for people who pinned it, and the overlay is +where everyone else is looking. The strip names the version and links the zip; the popup names +it too, offers the zip and offers `chrome://extensions` to drop it on. Dismissing it in either +place hides that one version, not every future one. *Check for updates* in the popup's footer +asks now. Nothing here can install the update. An extension cannot replace itself, and no permission changes that — what this removes is having to remember to go and look. @@ -528,15 +537,19 @@ and carries your badges for that room, so the answer is already in the chat sock **Kick will only tell the browser session that asks.** Its channel record — the thing the extension fetches to find the chatroom — describes the *channel*, not the person reading it, so it can settle exactly one case: the broadcaster, whose name is the channel's name. That is why an -ordinary moderator saw no tools at all. The answer lives at `channels//me`, which reads the -kick.com session cookie and answers `Unauthenticated` to anything else — including a valid OAuth -token for Kick's public API, which is a different thing entirely, and including the extension's -own background requests, because Chrome withholds a `SameSite` cookie from an extension's -cross-site fetch. - -So the page is asked. The content script is already running on kick.com, its fetches carry the -session you are actually signed in with, and it reports the answer back to the worker — the same -arrangement the Kick emote list already uses when Cloudflare refuses the background request. +ordinary moderator saw no tools at all. The answer lives at `channels//me`, and it is +answered only to the signed-in web session — and only when that session arrives as a bearer +token, the way Kick's own site sends it. A valid OAuth token for Kick's public API is a different +thing entirely and gets `Unauthenticated`; so does the session cookie on its own, which is all a +plain fetch carries, and which is why the answer stayed `401` even when the page was the one +asking. + +The session lives in kick.com's `session_token` cookie, which is not HttpOnly. The extension +holds the `cookies` permission so the background worker can read it and send it as the +`Authorization` header Kick reads — one request, and it works from a Twitch tab with Kick merged +in, where there is no kick.com page to ask. The cookie goes to kick.com and nowhere else. When +the worker cannot find out — no cookie, or Kick declining — the content script on kick.com asks +instead, reading the same cookie from the page, which needs no permission at all. Three things follow, and each one says so in the feed rather than leaving you guessing: diff --git a/manifest.json b/manifest.json index e083e3d..750cc75 100644 --- a/manifest.json +++ b/manifest.json @@ -1,14 +1,15 @@ { "manifest_version": 3, "name": "Friendly Chat Extension", - "version": "1.17.1", + "version": "1.18.0", "description": "Overlays a merged Twitch + Kick chat on the channel you are watching, and offers to connect the other platform when the streamer is live on both.", "minimum_chrome_version": "116", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0vdefhQX2a3wYoBWqcRPP5DrXABtsBjLzSDmZrqnG3T4vpBLOsgUGShsxCHkMB9DRJFujPYi/aizVVVIcvtFEcpVDmtnavLpIw8iBAoOhiAesi8Kt1OAvXps0CvmVJN3KCNWDf27uWqHxqChDZHPaZXKaqmy/fyZSAb79W+2OPXCs51IBzE8uUUEiv7t1Qg8RMdeXSIWBFBCqHagebuKXf4HlkzdsGXJ0jmMewK3TktyCRu8Js85oU66nsdkxUkdh43e/98s1QZAJpzlQ/CHkEGZFZAFTBbGDSdqvx/O/SEnseyMsR40dO9is6SK5OY3MpFKUBemnnvt4g1Xa1F0sQIDAQAB", "permissions": [ "storage", "alarms", - "identity" + "identity", + "cookies" ], "host_permissions": [ "*://*.twitch.tv/*", @@ -41,6 +42,7 @@ "src/shared/irc.js", "src/shared/emote-parsers.js", "src/shared/kick-events.js", + "src/shared/clips.js", "src/content/render.js", "src/content/feed.js", "src/content/emote-input.js", diff --git a/src/background/clips.js b/src/background/clips.js new file mode 100644 index 0000000..57f69e7 --- /dev/null +++ b/src/background/clips.js @@ -0,0 +1,98 @@ +// What a linked clip is, for the card drawn under the message that linked it. +(function (FCM) { + 'use strict'; + + // A clip does not change once made, so an answer is good for a long while, + // and one that came back empty — deleted, private, never existed — is worth + // remembering too, or every repeat of the link would ask again. + const TTL_MS = 60 * 60 * 1000; + const CACHE_LIMIT = 300; + const cache = new Map(); // "platform:id" -> { at, clip } + + function remember(key, clip) { + if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value); + cache.set(key, { at: Date.now(), clip }); + return clip; + } + + // Only ever a picture from the platform's own hosts, over https. The address + // goes into an in the feed, and a platform answering with something + // else is not something the feed should draw. + const THUMB_HOSTS = /^https:\/\/([a-z0-9-]+\.)*(jtvnw\.net|twitch\.tv|kick\.com)\//i; + const thumb = (url) => (THUMB_HOSTS.test(String(url || '')) ? String(url) : ''); + + const TWITCH_CLIP_QUERY = 'query($s:ID!){clip(slug:$s){slug title thumbnailURL durationSeconds' + + ' viewCount broadcaster{displayName login}}}'; + + // Twitch answers this for anyone: the same signed-out GQL route the channel + // lookup uses. + async function twitchClip(slug) { + const body = await FCM.getJson(FCM.TWITCH_GQL_URL, { + method: 'POST', + headers: { 'Client-Id': FCM.TWITCH_GQL_CLIENT_ID, 'Content-Type': 'application/json' }, + credentials: 'omit', + body: JSON.stringify({ query: TWITCH_CLIP_QUERY, variables: { s: slug } }), + }); + const clip = body && body.data && body.data.clip; + if (!clip || typeof clip !== 'object') return null; + const who = clip.broadcaster || {}; + const id = String(clip.slug || slug); + return { + platform: 'twitch', + id, + url: `https://clips.twitch.tv/${encodeURIComponent(id)}`, + title: String(clip.title || ''), + thumbnail: thumb(clip.thumbnailURL), + duration: Number(clip.durationSeconds) || 0, + channel: String(who.displayName || who.login || ''), + views: Number(clip.viewCount) || 0, + }; + } + + // Kick's clip record is public. The thumbnail lives on a sharded host that + // cannot be derived from the id, which is why this has to ask at all. + async function kickClip(id) { + const body = await FCM.getJson( + `https://kick.com/api/v2/clips/${encodeURIComponent(id)}`, + { headers: { Accept: 'application/json' }, credentials: 'omit' } + ); + const clip = body && (body.clip || body); + if (!clip || typeof clip !== 'object' || !clip.id) return null; + const channel = clip.channel || {}; + const slug = String(channel.slug || channel.username || '').toLowerCase(); + const clipId = encodeURIComponent(String(clip.id)); + return { + platform: 'kick', + id: String(clip.id), + url: slug + ? `https://kick.com/${encodeURIComponent(slug)}/clips/${clipId}` + : `https://kick.com/clips/${clipId}`, + title: String(clip.title || ''), + thumbnail: thumb(clip.thumbnail_url), + duration: Number(clip.duration) || 0, + channel: String(channel.username || channel.slug || ''), + views: Number(clip.view_count || clip.views) || 0, + }; + } + + /** + * @returns {Promise} the clip, or null when the platform has + * nothing to say about it — which the feed treats as "no card", never as + * an error worth a row. + */ + FCM.lookupClip = async function (platform, id) { + const clean = String(id || '').trim(); + if (!clean || !/^[A-Za-z0-9_-]+$/.test(clean)) return null; + const key = `${platform}:${clean}`; + const hit = cache.get(key); + if (hit && Date.now() - hit.at < TTL_MS) return hit.clip; + let clip = null; + try { + if (platform === 'twitch') clip = await twitchClip(clean); + else if (platform === 'kick') clip = await kickClip(clean); + } catch (e) { + clip = null; + } + return remember(key, clip); + }; +})(self.FCM); diff --git a/src/background/kick-source.js b/src/background/kick-source.js index d5a1aa3..409e93c 100644 --- a/src/background/kick-source.js +++ b/src/background/kick-source.js @@ -81,6 +81,14 @@ } const channelId = info.id || info.channel_id || null; conn.chatroomId = chatroomId; + // Kept because the history endpoint is keyed by it and by nothing else + // this connection holds; see fetchHistory for why the two ids are not + // interchangeable. + conn.channelId = channelId; + // What a subscriber's badge looks like here, by months. Sent to the view + // once the room is joined; it is the one badge Kick draws from a picture + // the channel chose rather than an icon of its own. + conn.subscriberBadges = FCM.kickSubscriberBadges(info); // A late resolve must not open a socket the caller has already dropped. if (conn.forceClose || !current()) return; @@ -181,7 +189,7 @@ const text = payload.content || ''; if (!text) return; - const badges = (payload.sender && payload.sender.identity && payload.sender.identity.badges) || []; + const badges = FCM.kickBadgeList(payload.sender && payload.sender.identity); // Emotes seen in a live message top up the store, so one posted before // the full list finishes loading still renders as an image. @@ -266,10 +274,24 @@ if (conn.ws) { try { conn.ws.close(); } catch (e) { /* already gone */ } conn.ws = null; } }, - async fetchHistory(chatroomId, sink, limit) { + /** + * The last few messages of a channel's chat, replayed into the feed. + * + * Keyed by the channel's own id — the `id` on the channel record — and not + * by the chatroom's, which is a different number and the one everything + * else here uses: the Pusher room is `chatrooms..v2`, so the + * chatroom id was the id in hand and the one this asked with. Kick answers + * that with `200 OK` and an empty list rather than an error, so there was + * nothing to notice: no history arrived, nothing said why, and the feed + * after a reload started blank while Twitch's filled in beside it. + * + * @param {number|string} channelId the channel's id, not the chatroom's + */ + async fetchHistory(channelId, sink, limit) { + if (!channelId) return; try { const r = await fetch( - `https://kick.com/api/v2/channels/${encodeURIComponent(chatroomId)}/messages?limit=${limit}` + `https://kick.com/api/v2/channels/${encodeURIComponent(channelId)}/messages?limit=${limit}` ); if (!r.ok) return; const data = await r.json(); @@ -282,7 +304,7 @@ const text = msg.content || ''; if (!text) return; const sender = msg.sender || (msg.metadata && msg.metadata.sender) || {}; - const badges = (sender.identity && sender.identity.badges) || []; + const badges = FCM.kickBadgeList(sender.identity); rows.push({ platform: 'kick', author: sender.username || 'unknown', diff --git a/src/background/service-worker.js b/src/background/service-worker.js index c7efa1d..d3f7297 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -11,6 +11,7 @@ importScripts( '/src/shared/irc.js', '/src/shared/emote-parsers.js', '/src/shared/kick-events.js', + '/src/shared/clips.js', '/src/background/discovery.js', '/src/background/emotes.js', '/src/background/twitch-source.js', @@ -20,6 +21,7 @@ importScripts( '/src/background/moderation.js', '/src/background/profile.js', '/src/background/emote-cache.js', + '/src/background/clips.js', '/src/background/updates.js' ); @@ -38,6 +40,8 @@ function newConn(platform) { retryTimer: null, pingTimer: null, chatroomId: null, + channelId: null, + subscriberBadges: null, roomId: null, auth: null, canModerate: false, @@ -163,18 +167,18 @@ function makeSink(session, platform, generation) { }, joined: (chatroomId) => { if (!current()) return; - // Kept, not just passed on: it is what a later replay needs to ask Kick - // for this channel's history again, and there is no second chance to - // learn it without reopening the socket. + // Kept rather than only passed on: a later replay — a tab reloading onto + // a chat that is already connected — has no second chance to learn it + // without reopening the socket. if (chatroomId) conn.chatroomId = chatroomId; - onJoined(session, platform, chatroomId, mine); + onJoined(session, platform, mine); }, }; } // Everything that should happen once, after a channel is actually joined: // history replay and the emote sets the renderer needs. -async function onJoined(session, platform, chatroomId, generation) { +async function onJoined(session, platform, generation) { const conn = session.conns[platform]; const channel = conn.channel; if (!channel) return; @@ -188,7 +192,9 @@ async function onJoined(session, platform, chatroomId, generation) { if (platform === 'twitch') { FCM.twitchSource.fetchHistory(channel, sink, FCM.TWITCH_HISTORY_LIMIT); } else { - FCM.kickSource.fetchHistory(chatroomId || conn.chatroomId, sink, FCM.KICK_HISTORY_LIMIT); + // The channel's id, not the chatroom's — they are different numbers and + // the history endpoint only answers to the first. + FCM.kickSource.fetchHistory(conn.channelId, sink, FCM.KICK_HISTORY_LIMIT); } } @@ -201,6 +207,11 @@ async function onJoined(session, platform, chatroomId, generation) { }); loadTwitchEmotes(session, platform, sink.generation).catch(() => {}); } else { + // The channel's own subscriber badges, read off its record on connect. A + // straight swap in the view, like Twitch's, so only this channel's. + send(session, { + type: 'badges', platform: 'kick', badges: { subscriber: conn.subscriberBadges || [] }, + }); FCM.emoteLoader.kickNative(channel).then(async (store) => { if (!sink.current()) return; if (Object.keys(store).length) { @@ -366,37 +377,70 @@ function applyKickStanding(session, sink, standing, record) { /** * Whether this viewer moderates the Kick channel that has just been joined. * - * Kick answers this only to a signed-in browser session. The extension's own - * token is an OAuth token for Kick's public API, which that endpoint does not - * read, so the worker can rarely find out by itself — Chrome withholds a - * SameSite cookie from an extension's cross-site request. The tab is on - * kick.com and its fetches carry the session, so the tab is asked. + * Kick answers this only to the signed-in web session, and only when that + * session arrives as a bearer token. The extension's own token is an OAuth + * token for Kick's public API, which that endpoint does not read. What it does + * read is the `session_token` cookie kick.com sets, sent back as an + * Authorization header — which is what Kick's own site does. The cookie is + * not HttpOnly, and the `cookies` permission lets the worker read it, so the + * worker can ask for itself: one request, and the answer is the same one the + * tab would get. * - * The worker still tries first. It costs one request, and it is the only route - * that can work when Kick is merged into a Twitch tab, where there is no - * kick.com page to ask. + * This is the route that works when Kick is merged into a Twitch tab, where + * there is no kick.com page to ask. The tab is still asked when the worker + * cannot find out — no cookie, or Kick declining — because a content script + * on kick.com reads the same cookie without needing any permission at all. */ async function loadKickStanding(session, generation) { const conn = session.conns.kick; if (!conn || !conn.channel) return; const channel = conn.channel; const sink = makeSink(session, 'kick', generation); - const record = await FCM.auth.get('kick'); + const [record, headers] = await Promise.all([FCM.auth.get('kick'), kickSessionHeaders()]); if (!sink.current() || conn.channel !== channel) return; - if (record && record.accessToken) { - const data = await FCM.getJson(kickStandingUrl(channel), { - headers: { Accept: 'application/json' }, - credentials: 'include', - }); - if (!sink.current() || conn.channel !== channel) return; - const standing = FCM.readKickStanding(data); - if (standing.known) { applyKickStanding(session, sink, standing, record); return; } + const data = await FCM.getJson(kickStandingUrl(channel), { headers, credentials: 'include' }); + if (!sink.current() || conn.channel !== channel) return; + const standing = FCM.readKickStanding(data); + if (standing.known) { + // `/me` never says whose standing it is, and the guard against acting as + // somebody else needs the name. Only worth a request when there are tools + // to guard; a failure leaves the name unknown, which is where it was. + if (!standing.username && standing.canModerate && headers.Authorization) { + const who = await FCM.getJson('https://kick.com/api/v1/user', { headers, credentials: 'include' }); + if (!sink.current() || conn.channel !== channel) return; + standing.username = String(FCM.usernameFrom(who) || ''); + } + applyKickStanding(session, sink, standing, record); + return; } send(session, { type: 'needKickModerator', channel }); } +/** + * The headers a request to kick.com's own API needs to be answered as the + * signed-in viewer: the session cookie, as the bearer token Kick reads it as. + * Just the Accept header when there is no session to send. + */ +async function kickSessionHeaders() { + const headers = { Accept: 'application/json' }; + try { + if (!chrome.cookies || !chrome.cookies.get) return headers; + const cookie = await chrome.cookies.get({ url: 'https://kick.com/', name: 'session_token' }); + const value = cookie && cookie.value ? String(cookie.value) : ''; + if (value) headers.Authorization = `Bearer ${decodeURIComponentSafe(value)}`; + } catch (e) { + // No cookie access, or no cookie: the tab is asked instead. + } + return headers; +} + +// A cookie value that was not percent-encoded is still a cookie value. +function decodeURIComponentSafe(value) { + try { return decodeURIComponent(value); } catch (e) { return value; } +} + /** * The Cheermotes this channel accepts: the prefixes, so a Cheer can be told * apart from an ordinary word ending in digits before the message is sent, and @@ -671,6 +715,8 @@ function leaveChannel(session, platform, { silent = false } = {}) { const had = conn.channel; conn.channel = null; conn.chatroomId = null; + conn.channelId = null; + conn.subscriberBadges = null; conn.roomId = null; conn.auth = null; conn.state = 'idle'; @@ -849,6 +895,11 @@ chrome.runtime.onConnect.addListener((port) => { break; case 'hello': { + // What the last update check found, so a panel opening on a new page + // shows the strip at once. The popup reads the same stored answer. + FCM.updateStatus().then((status) => { + if (status && status.available) post(session, { type: 'update', status }); + }).catch(() => {}); const site = msg.site; const channel = FCM.normalizeChannel(msg.channel); const changedChannel = session.hostChannel !== channel || session.site !== site; @@ -903,7 +954,7 @@ chrome.runtime.onConnect.addListener((port) => { const live = session.conns[p]; if (!live.channel || live.state !== 'connected') return; live.announcedEmotes = false; - Promise.resolve(onJoined(session, p, live.chatroomId)).catch(() => {}); + Promise.resolve(onJoined(session, p)).catch(() => {}); }); } @@ -970,6 +1021,15 @@ chrome.runtime.onConnect.addListener((port) => { break; } + case 'clip': { + // What a clip somebody linked actually is, for the card under the + // row. Answered with null when the platform has nothing to say, and + // the row simply stays a link. + const clip = await FCM.lookupClip(msg.platform, msg.clipId); + send(session, { type: 'clip', id: msg.id, clip }); + break; + } + case 'recheck': await refreshCounterpart(session, { announce: true }); break; @@ -1213,7 +1273,16 @@ function syncHeartbeat() { // Registered at the top level either way, so a wake from an alarm that is // already scheduled always finds a handler. chrome.alarms.onAlarm.addListener((alarm) => { - if (FCM.isUpdateAlarm(alarm.name)) { FCM.checkForUpdate().catch(() => {}); return; } + if (FCM.isUpdateAlarm(alarm.name)) { + // Told to every open overlay as well as painted on the toolbar icon: the + // icon is only there for people who pinned it, and the overlay is where + // everyone else is looking. + FCM.checkForUpdate().then((status) => { + if (!status || !status.available) return; + sessions.forEach((session) => post(session, { type: 'update', status })); + }).catch(() => {}); + return; + } if (alarm.name !== 'fcm-heartbeat') return; sessions.forEach((session) => { FCM.PLATFORMS.forEach((p) => { diff --git a/src/content/boot.js b/src/content/boot.js index 3537db9..9923e05 100644 --- a/src/content/boot.js +++ b/src/content/boot.js @@ -148,6 +148,8 @@ case 'badges': overlay.setBadges(msg.platform, msg.badges); break; case 'cheermotes': overlay.setCheermotes(msg.prefixes, msg.tiers); break; case 'profile': overlay.profileResult(msg.id, msg.platform, msg.username, msg.profile); break; + case 'clip': overlay.clipResult(msg.id, msg.clip); break; + case 'update': overlay.updateNotice(msg.status); break; case 'deleteMsg': overlay.deleteMessage(msg.platform, msg.messageId); break; case 'deleteUser': overlay.deleteUser(msg.platform, msg.username); break; @@ -321,10 +323,19 @@ * Asks Kick whether this viewer moderates the channel, from the page's own * origin — the only place Kick will answer it. * - * The worker cannot: Kick reads the browser session for this, not the OAuth - * token the extension holds, and Chrome withholds a SameSite cookie from an + * The worker cannot: this endpoint does not read the OAuth token the + * extension holds, and Chrome withholds a SameSite cookie from an * extension's cross-site request. A fetch from here is same-origin and - * carries the session the viewer is actually signed in with. + * carries what the viewer is actually signed in with. + * + * Being same-origin is necessary but not sufficient. Kick answers `/me` to a + * bearer token, not to the cookie jar: sending the session cookie alone — + * which is all a plain same-origin fetch does — is answered + * "Unauthenticated." with a 401, and that 401 is exactly why an ordinary + * moderator still saw no tools after the page was the one asking. Kick's own + * site reads its `session_token` cookie and puts it in an Authorization + * header, so that is what happens here. The cookie is not HttpOnly, which is + * what makes it readable at all, and it goes nowhere but back to kick.com. * * Nothing is posted back unless Kick actually answered. Not signed in, a * challenge page, or a body that is not JSON all leave the worker exactly @@ -335,11 +346,14 @@ const slug = FCM.normalizeChannel(channel || ''); if (!slug) return; const epoch = navEpoch; + const headers = { Accept: 'application/json' }; + const token = readCookie('session_token'); + if (token) headers.Authorization = `Bearer ${token}`; let standing = null; try { const res = await fetch( `https://kick.com/api/v2/channels/${encodeURIComponent(slug)}/me`, - { headers: { Accept: 'application/json' }, credentials: 'include' } + { headers, credentials: 'include' } ); if (!res.ok) return; standing = FCM.readKickStanding(await res.json()); @@ -349,14 +363,56 @@ // The channel can change inside that fetch, and this answer is about the // one it was asked for. if (!standing || !standing.known || epoch !== navEpoch) return; + // `/me` describes a standing without ever naming whose it is, and the + // worker wants the name: it declines to offer tools when this browser is + // signed in as one person and the extension holds a token for another, + // because the moderation calls would go out as the other one and be + // refused. Asked only when there is something to compare, and a failure + // here just leaves the name unknown — which is where it was before. + let username = standing.username; + if (!username && standing.canModerate && token) { + username = await readKickAccountName(headers); + if (epoch !== navEpoch) return; + } post({ cmd: 'kickModerator', channel: slug, canModerate: standing.canModerate, - username: standing.username, + username, }); } + /** The account kick.com is signed in as in this browser, or '' if unknown. */ + async function readKickAccountName(headers) { + try { + const res = await fetch('https://kick.com/api/v1/user', { headers, credentials: 'include' }); + if (!res.ok) return ''; + return String(FCM.usernameFrom(await res.json()) || ''); + } catch (e) { + return ''; + } + } + + /** One cookie of this page's, decoded. '' when the page has no such cookie. */ + function readCookie(name) { + // Split rather than matched: a name is being compared, not searched for, + // and a regex built from one would have to escape it. + const pairs = String(document.cookie || '').split(';'); + for (let i = 0; i < pairs.length; i += 1) { + const at = pairs[i].indexOf('='); + if (at < 0) continue; + if (pairs[i].slice(0, at).trim() !== name) continue; + const raw = pairs[i].slice(at + 1).trim(); + try { + return decodeURIComponent(raw); + } catch (e) { + // A cookie that is not percent-encoded is still a cookie. + return raw; + } + } + return ''; + } + async function fetchKickEmotesFromPage(channel) { if (site.id !== 'kick' || !overlay) return; const slug = FCM.normalizeChannel(channel || ''); diff --git a/src/content/overlay.css b/src/content/overlay.css index a2e9304..fd2f38b 100644 --- a/src/content/overlay.css +++ b/src/content/overlay.css @@ -45,6 +45,16 @@ --badge-vip: #ffcc00; --badge-gift: #ff9fbc; --badge-verified: #c4b8ff; + /* Kick's role badges, one colour each, and the gifter's ladder. */ + --badge-host: #ff6b6b; + --badge-og: #2ed3c0; + --badge-founder: #ffb347; + --badge-side: #6db3ff; + --gift-1: #8fb8ff; + --gift-25: #c6a5ff; + --gift-50: #ff9fbc; + --gift-100: #ffb347; + --gift-200: #ff6b6b; --link: #7c9fff; --link-hover: #a8bfff; /* Status and event rows used to sit in a well darker than the feed itself, @@ -92,6 +102,15 @@ --badge-vip: #7a5400; --badge-gift: #9c1039; --badge-verified: #4133bd; + --badge-host: #c62828; + --badge-og: #0a7d70; + --badge-founder: #a35a00; + --badge-side: #0b5fbd; + --gift-1: #1d5fb8; + --gift-25: #5c1eb0; + --gift-50: #9c1039; + --gift-100: #a35a00; + --gift-200: #c62828; --link: #315ed8; --link-hover: #1f46b8; --sys-bg: #e6ebf4; @@ -216,6 +235,25 @@ second line. Nothing is clipped and nothing is ellipsised at any width, which is the point — a channel name that has been cut off is not a channel name, it is a guess. */ +/* One line under the header when a newer release exists. The toolbar icon + carries a dot for the same thing, but only for people who pinned it. */ +.fcm-update { + display: flex; align-items: center; gap: 8px; + padding: 5px 9px 5px 11px; + background: var(--surface); border-bottom: 1px solid var(--border); + border-left: 3px solid var(--accent); + font-size: 12px; color: var(--text); flex-shrink: 0; +} +.fcm-update-text { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fcm-update-link { color: var(--link); font-weight: 600; text-decoration: none; white-space: nowrap; } +.fcm-update-link:hover { color: var(--link-hover); text-decoration: underline; } +.fcm-update-close { + display: inline-flex; width: 18px; height: 18px; padding: 2px; flex-shrink: 0; + border: 0; border-radius: 4px; background: none; color: var(--text-muted); cursor: pointer; +} +.fcm-update-close:hover { background: var(--surface3); color: var(--text); } +.fcm-update-close svg { width: 100%; height: 100%; } + .fcm-chips { display: flex; align-items: center; @@ -598,6 +636,38 @@ .fcm-replyto .fcm-gif-img, .fcm-um-history .fcm-gif-img { max-height: 40px; } +/* A clip somebody linked: thumbnail, title and whose channel, under the row, + the whole card a link to the clip. Sized to read as a preview and not a + player — a chat is not the place to watch it. */ +.fcm-clip { + display: flex; align-items: center; gap: 8px; + margin: 4px 0 2px; padding: 5px; max-width: 340px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--surface2); color: var(--text); text-decoration: none; +} +.fcm-clip:hover { border-color: var(--border2); background: var(--surface3); } +.fcm-clip-kick { border-left: 3px solid var(--kick); } +.fcm-clip-twitch { border-left: 3px solid var(--twitch); } +.fcm-clip-thumb { + width: 96px; height: 54px; flex-shrink: 0; border-radius: 4px; + object-fit: cover; background: var(--surface3); +} +.fcm-clip-thumb-none { + display: flex; align-items: center; justify-content: center; + color: var(--text-muted); font-size: 18px; +} +.fcm-clip-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.fcm-clip-title { + font-weight: 600; font-size: 0.95em; line-height: 1.25; overflow: hidden; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; +} +.fcm-clip-sub { + font-size: 0.8em; color: var(--text-muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +/* Previews switched off: the link in the row still says where it goes. */ +.fcm-root[data-clips="false"] .fcm-clip { display: none; } + /* A Cheer: the Cheermote and the amount it was worth, drawn as the one thing they are so a line break can never strand the number away from its picture. The colour is the tier's own and arrives inline with the message, because it @@ -709,6 +779,24 @@ .fcm-badges { display: inline-flex; align-items: center; gap: 3px; margin-right: 4px; vertical-align: middle; } .fcm-badge-img { width: 16px; height: 16px; border-radius: 3px; vertical-align: middle; object-fit: cover; } +/* Kick's role badges, drawn from the icons in render.js: the same box a badge + picture gets, coloured per role. A cut-out inside a shape is painted in the + surface so it reads as a hole in it. */ +.fcm-kbadge-icon { width: 16px; height: 16px; vertical-align: middle; flex-shrink: 0; fill: currentColor; } +.fcm-kbadge-icon .fcm-kbadge-cut { fill: var(--surface); } +.fcm-kbadge-icon-moderator, .fcm-kbadge-icon-verified { color: var(--kick-text); } +.fcm-kbadge-icon-broadcaster { color: var(--badge-host); } +.fcm-kbadge-icon-vip { color: var(--badge-vip); } +.fcm-kbadge-icon-og { color: var(--badge-og); } +.fcm-kbadge-icon-founder { color: var(--badge-founder); } +.fcm-kbadge-icon-staff { color: var(--badge-verified); } +.fcm-kbadge-icon-subscriber { color: var(--badge-sub); } +.fcm-kbadge-icon-sidekick { color: var(--badge-side); } +.fcm-kbadge-icon-sub_gifter { color: var(--gift-1); } +.fcm-kbadge-icon-sub_gifter[data-tier="25"] { color: var(--gift-25); } +.fcm-kbadge-icon-sub_gifter[data-tier="50"] { color: var(--gift-50); } +.fcm-kbadge-icon-sub_gifter[data-tier="100"] { color: var(--gift-100); } +.fcm-kbadge-icon-sub_gifter[data-tier="200"] { color: var(--gift-200); } .fcm-kbadge, .fcm-chip { display: inline-flex; align-items: center; height: 15px; padding: 0 5px; diff --git a/src/content/overlay.js b/src/content/overlay.js index cdd1397..b543aa8 100644 --- a/src/content/overlay.js +++ b/src/content/overlay.js @@ -102,6 +102,11 @@ const pendingSends = new Map(); // Profile lookups still waiting on the worker, keyed the same way. const pendingProfiles = new Map(); + // Clip lookups: the ones still waiting, and the answers already had, so a + // clip linked twice in an evening is asked about once. + const pendingClips = new Map(); + const clipAnswers = new Map(); + const CLIP_ANSWERS_LIMIT = 300; let sendSeq = 0; let compose = null; let themeWatcher = null; @@ -188,6 +193,8 @@ +
+
@@ -235,6 +242,7 @@ const launcher = $('.fcm-launcher'); const chipsEl = $('.fcm-chips'); const promptEl = $('.fcm-prompt'); + const updateEl = $('.fcm-update'); const feedEl = $('.fcm-feed'); const inputEl = $('.fcm-input'); const sendEl = $('.fcm-send'); @@ -1890,6 +1898,10 @@ +
+ + +
${hostPlatform === 'twitch' ? `
+
+
+ + When someone links a Twitch or Kick clip, its title and thumbnail under the + message. Off, the link is a link like any other. +
+ +
diff --git a/src/options/options.js b/src/options/options.js index 8219bb4..9d95764 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -7,7 +7,7 @@ 'autoOpen', 'autoConnectHost', 'startCollapsed', 'hideNativeChat', 'watchWhenLive', 'revealHighlights', 'showNativeStats', 'autoClaimBonus', 'showHistory', 'showEvents', 'thirdPartyEmotes', 'timestamps', 'showBadges', - 'animations', 'showGifs', 'showShareReminders', 'modHoverTools', + 'animations', 'showGifs', 'showShareReminders', 'modHoverTools', 'showClipPreviews', ]; const SELECTS = ['crossPromptMode', 'theme', 'kickRedirect']; const RANGES = [ diff --git a/src/shared/clips.js b/src/shared/clips.js new file mode 100644 index 0000000..7ee71b8 --- /dev/null +++ b/src/shared/clips.js @@ -0,0 +1,88 @@ +// Links to clips, found in what somebody typed. +// +// A clip link is the one kind of link a chat sees a lot of and nobody can +// judge from its text: a Twitch slug is four random words and a Kick id is a +// string of letters, so "look at this" with a bare address under it says +// nothing about what is on the other side. The feed asks the platform for the +// title and thumbnail and draws them under the row. This is the half that +// decides which links are worth asking about; the asking is in the worker. +(function (FCM) { + 'use strict'; + + // The punctuation a link gets wrapped in when it is written into a sentence. + const LEAD = /^[([{<"']+/; + const TRAIL = /[.,!?;:'")\]}>]+$/; + + // Everything Twitch's share button and address bar produce for a clip, with + // or without the scheme, www. or m.: + // clips.twitch.tv/ + // clips.twitch.tv/embed?clip= + // twitch.tv//clip/ + // A slug is letters, digits, hyphens and underscores. What follows it — a + // query, a fragment — is Twitch's own tracking and not part of the id. The + // embed form is tried first, or "embed" would be read as the slug. + const TWITCH = [ + /^(?:https?:\/\/)?(?:(?:www|m)\.)?clips\.twitch\.tv\/embed\?(?:[^#]*&)?clip=([A-Za-z0-9_-]+)/i, + /^(?:https?:\/\/)?(?:(?:www|m)\.)?clips\.twitch\.tv\/([A-Za-z0-9_-]+)(?:[?#]|$)/i, + /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/[A-Za-z0-9_]+\/clip\/([A-Za-z0-9_-]+)(?:[?#]|$)/i, + ]; + + // Kick's two: the clip's own page, and the channel page with the clip opened + // over it, which is what its share button copies. + // kick.com//clips/clip_ + // kick.com/?clip=clip_ + const KICK = [ + /^(?:https?:\/\/)?(?:www\.)?kick\.com\/([A-Za-z0-9_-]+)\/clips\/(clip_[A-Za-z0-9]+)(?:[?#]|$)/i, + /^(?:https?:\/\/)?(?:www\.)?kick\.com\/([A-Za-z0-9_-]+)\/?\?(?:[^#]*&)?clip=(clip_[A-Za-z0-9]+)/i, + ]; + + // More than this in one message is a list, not a share, and a list of cards + // would push the next message off the screen. + const PER_MESSAGE = 3; + + function clipFor(word) { + for (let i = 0; i < TWITCH.length; i += 1) { + const m = TWITCH[i].exec(word); + if (m) { + return { platform: 'twitch', id: m[1], url: `https://clips.twitch.tv/${m[1]}` }; + } + } + for (let i = 0; i < KICK.length; i += 1) { + const m = KICK[i].exec(word); + if (m) { + return { + platform: 'kick', + id: m[2], + url: `https://kick.com/${m[1].toLowerCase()}/clips/${m[2]}`, + }; + } + } + return null; + } + + /** + * The clips linked in a message, in the order they were written, each once. + * + * The address given back is the clip's canonical page rather than whatever + * was typed, so the card opens the clip and not, say, the channel page with + * a tracking query on it. + * + * @returns {{platform: string, id: string, url: string}[]} + */ + FCM.findClipLinks = function (text) { + const out = []; + const seen = new Set(); + String(text || '').split(/\s+/).forEach((raw) => { + if (out.length >= PER_MESSAGE) return; + const word = raw.replace(LEAD, '').replace(TRAIL, ''); + if (!word || word.indexOf('.') === -1) return; + const clip = clipFor(word); + if (!clip) return; + const key = `${clip.platform}:${clip.id}`; + if (seen.has(key)) return; + seen.add(key); + out.push(clip); + }); + return out; + }; +})(self.FCM); diff --git a/src/shared/constants.js b/src/shared/constants.js index 1948399..94f551d 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -171,6 +171,10 @@ // GIFs posted to Twitch chat by Tier 2 and Tier 3 subscribers, drawn as the // pictures they are. Off, and each one is a small "GIF" link instead. showGifs: true, + // A card under any message that links a Twitch or Kick clip — the clip's + // title and thumbnail, so a bare address says what is behind it. Off, the + // link is a link like any other. + showClipPreviews: true, // The prompts Twitch draws in its own chat for this viewer alone — share // your watch streak, share your resub — which the panel would otherwise // cover. Surfaced as a row with the site's own Share button behind it. diff --git a/src/shared/kick-events.js b/src/shared/kick-events.js index 3a14175..4143bb5 100644 --- a/src/shared/kick-events.js +++ b/src/shared/kick-events.js @@ -19,12 +19,14 @@ * What Kick says about this viewer's own standing in a channel, out of the * `channels//me` record. * - * Kick will only answer that question to the browser session that asks it — - * an OAuth token from its public API is not what that endpoint reads, and a - * request carrying no session cookie is answered "Unauthenticated". Which is - * why the channel record the worker already fetches cannot say it: that - * record describes the channel, not the person looking at it. See - * `loadKickStanding` in the worker for who asks, and from where. + * Kick will only answer that question to the signed-in web session, and only + * when that session arrives as a bearer token: an OAuth token from its + * public API is not what the endpoint reads, and a same-origin request + * carrying the session as a cookie alone is still answered + * "Unauthenticated". Which is also why the channel record the worker already + * fetches cannot say it: that record describes the channel, not the person + * looking at it. See `reportKickStandingFromPage` in the content script for + * who asks, and from where. * * Kick documents none of these field names, so every plausible spelling is * checked — and the answer is only ever used to *offer* the moderation @@ -75,6 +77,54 @@ }; }; + /** + * The badges beside a Kick chatter's name, out of both the lists Kick sends. + * + * `badges` is the older list: a type, a caption, and for a subscriber the + * months. No picture — Kick's own chat draws these from icons of its own, + * and so does this one. `badges_v2` is newer and carries a picture: today + * the chatter's level badge, tomorrow whatever else Kick gives a picture to. + * Both are kept, role badges first, which is the order Kick draws them in. + */ + FCM.kickBadgeList = function (identity) { + const id = (identity && typeof identity === 'object') ? identity : {}; + const roles = Array.isArray(id.badges) + ? id.badges.filter((b) => b && typeof b === 'object') : []; + const pictured = (Array.isArray(id.badges_v2) ? id.badges_v2 : []).map((b) => { + if (!b || typeof b !== 'object') return null; + if (typeof b.image_url !== 'string' || !b.image_url) return null; + const name = String(b.name || b.badge_type || 'badge'); + const level = b.metadata && b.metadata.level; + const caption = name.charAt(0).toUpperCase() + name.slice(1); + return { + type: name, + text: (level === undefined || level === null) ? caption : `${caption} ${level}`, + image_url: b.image_url, + }; + }).filter(Boolean); + return roles.concat(pictured); + }; + + /** + * The channel's own subscriber badges, by the months they are earned at, + * largest first — so the badge a subscriber of N months wears is simply the + * first one N reaches. + * + * @returns {{months: number, url: string}[]} + */ + FCM.kickSubscriberBadges = function (info) { + const list = info && Array.isArray(info.subscriber_badges) ? info.subscriber_badges : []; + return list.map((b) => { + if (!b || typeof b !== 'object') return null; + const months = Number(b.months); + const image = b.badge_image; + const url = typeof image === 'string' ? image : (image && image.src) || ''; + if (!Number.isFinite(months) || months < 0 || typeof url !== 'string') return null; + if (!/^https:\/\//i.test(url)) return null; + return { months, url }; + }).filter(Boolean).sort((a, b) => b.months - a.months); + }; + FCM.kickBadgeClass = function (badges = []) { if (!Array.isArray(badges) || !badges.length) return null; const types = badges.map((b) => String((b && b.type) || '').toLowerCase()); diff --git a/tests/background.js b/tests/background.js index 2e16bb7..1411e7d 100644 --- a/tests/background.js +++ b/tests/background.js @@ -90,6 +90,12 @@ function bootWorker(opts = {}) { }, }, identity: { getRedirectURL: () => 'https://ext.chromiumapp.org/' }, + // The browser's cookie jar, as far as the worker can see it: only what a + // test put there under `opts.cookies`, keyed by name. + cookies: { + get: async ({ name }) => (opts.cookies && opts.cookies[name] !== undefined + ? { name, value: String(opts.cookies[name]) } : null), + }, }; const defaultFetch = async (url, init) => { @@ -98,6 +104,11 @@ function bootWorker(opts = {}) { if (u.includes('gql.twitch.tv')) { const body = JSON.parse(init.body); const query = Array.isArray(body) ? '' : String(body.query || ''); + if (query.includes('clip(slug:')) { + const slug = body.variables && body.variables.s; + const known = opts.twitchClips && opts.twitchClips[slug]; + return { ok: true, json: async () => ({ data: { clip: known || null } }) }; + } if (query.includes('badges')) { return { ok: true, json: async () => ({ data: { badges: [], user: { broadcastBadges: [] } } }) }; } @@ -125,6 +136,12 @@ function bootWorker(opts = {}) { }), }; } + if (/kick\.com\/api\/v2\/clips\/([^/?]+)$/.test(u)) { + const id = u.match(/clips\/([^/?]+)$/)[1]; + const known = opts.kickClips && opts.kickClips[id]; + if (!known) return { ok: false, status: 404, json: async () => ({ message: '' }) }; + return { ok: true, json: async () => ({ clip: known }) }; + } if (u.includes('/messages?limit=')) { return { ok: true, json: async () => ({ data: { messages: opts.kickHistory || [] } }) }; } diff --git a/tests/endtoend.js b/tests/endtoend.js index 5f61aff..df3c763 100644 --- a/tests/endtoend.js +++ b/tests/endtoend.js @@ -230,7 +230,7 @@ function bootPair(startPath, opts = {}) { [ 'src/shared/namespace.js', 'src/shared/constants.js', 'src/shared/util.js', - 'src/shared/irc.js', 'src/shared/emote-parsers.js', 'src/shared/kick-events.js', + 'src/shared/irc.js', 'src/shared/emote-parsers.js', 'src/shared/kick-events.js', 'src/shared/clips.js', // render.js before sites.js, the order the manifest loads them in: boot.js // calls into render.js when a channel is left. 'src/content/render.js', 'src/content/sites.js', diff --git a/tests/harness.html b/tests/harness.html index 806fcdb..1bc0c58 100644 --- a/tests/harness.html +++ b/tests/harness.html @@ -254,6 +254,8 @@

ExampleStreamer

+ +