Skip to content
Merged
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
43 changes: 28 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/<slug>/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/<slug>/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:

Expand Down
6 changes: 4 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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/*",
Expand Down Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions src/background/clips.js
Original file line number Diff line number Diff line change
@@ -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 <img> 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<object|null>} 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);
30 changes: 26 additions & 4 deletions src/background/kick-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.<chatroom id>.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();
Expand All @@ -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',
Expand Down
Loading