Draw the words a Kick channel talks in
The window half of #195. A 7TV emote is not a token like Discord's or Kick's own - those carry an id and resolve anywhere. This is a bare word that stands for a picture in one channel and is ordinary text in the next, so the substitution needs the channel's own set and is kept per buffer. Whole words only, and case-sensitively. 7TV names are chosen to be typed and collide with ordinary English constantly: a channel with an emote called "ok" or "no" would otherwise turn half the conversation into pictures. Whitespace is the boundary rather than \b, because names routinely contain digits and underscores and \b matches inside those. Looked up through `hasOwnProperty.call` rather than by indexing, which is not pedantry - a message containing the word "toString" or "constructor" would otherwise find a function on the prototype and put it in the page. Checked, along with the rest: "GAMBAx" and "xGAMBA" are left alone, "gamba" is not "GAMBA", and two emotes with one space between them both draw. Closes #195. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: d584492822...5d408bdc93
@@ -141,6 +141,8 @@ export interface RowContext {
|
||||
pinned: string[]
|
||||
/** Colour is how IRC has always been written, and also how a bot shouts. */
|
||||
ircColours: boolean
|
||||
/** Kick's 7TV emotes for this conversation, as word -> picture. */
|
||||
wordEmotes: Record<string, string>
|
||||
}
|
||||
|
||||
/** The above, for one conversation. Called once per list, not once per row. */
|
||||
@@ -151,6 +153,7 @@ export function useRowContext(bufferId: string): RowContext {
|
||||
const kind = useChat((s) => s.buffers).find((b) => b.id === bufferId)?.kind
|
||||
const pinned = useChat((s) => s.pinnedMessages)[bufferId]
|
||||
const [ircColours] = usePref<boolean>('irc.renderColours', true)
|
||||
const wordEmotes = useChat((s) => s.kickWordEmotes)[bufferId]
|
||||
return useMemo(
|
||||
() => ({
|
||||
permissions: permissions ?? {},
|
||||
@@ -158,9 +161,10 @@ export function useRowContext(bufferId: string): RowContext {
|
||||
emoji: emoji ?? [],
|
||||
inDirectMessage: kind === 'dm',
|
||||
pinned: pinned ?? [],
|
||||
ircColours
|
||||
ircColours,
|
||||
wordEmotes: wordEmotes ?? {}
|
||||
}),
|
||||
[permissions, smilies, emoji, kind, pinned, ircColours]
|
||||
[permissions, smilies, emoji, kind, pinned, ircColours, wordEmotes]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -328,7 +332,8 @@ function MessageRowBody({
|
||||
smilies: smilieIndex,
|
||||
emoji: bufferEmoji,
|
||||
inDirectMessage,
|
||||
ircColours
|
||||
ircColours,
|
||||
wordEmotes
|
||||
} = shared
|
||||
const sniffed = useSniffedTypes()
|
||||
|
||||
@@ -404,7 +409,10 @@ function MessageRowBody({
|
||||
channels,
|
||||
// Only IRC's own traffic is drawn with IRC's codes. Elsewhere they
|
||||
// are stripped, which is what the formatter does when not told.
|
||||
ircFormatting: service === 'irc' && ircColours ? 'render' : 'strip'
|
||||
ircFormatting: service === 'irc' && ircColours ? 'render' : 'strip',
|
||||
// Kick's 7TV emotes, which only mean anything in the channel whose
|
||||
// set they came from.
|
||||
wordEmotes
|
||||
}),
|
||||
media,
|
||||
codeBlocks,
|
||||
|
||||
@@ -225,6 +225,13 @@ export interface FormatOptions {
|
||||
* bold.
|
||||
*/
|
||||
ircFormatting?: 'render' | 'strip'
|
||||
/**
|
||||
* Words that stand for pictures in this conversation - Kick's 7TV emotes.
|
||||
*
|
||||
* Per conversation, unlike every other emote here: these carry no id, so a
|
||||
* word is only an emote in the channel whose set contains it.
|
||||
*/
|
||||
wordEmotes?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -420,6 +427,26 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string {
|
||||
stow(`<img src="${kickEmoteUrl(id)}" class="custom-emoji" alt=":${escapeHtml(name)}:">`)
|
||||
)
|
||||
|
||||
// 7TV emotes, which are not tokens at all - a bare word that stands for a
|
||||
// picture in this channel and is ordinary text in the next. So unlike the
|
||||
// two above, this needs the channel's own set, and a word not in it is left
|
||||
// exactly as it was.
|
||||
//
|
||||
// Whole words only, and case-sensitively: 7TV names are chosen to be typed
|
||||
// and collide with ordinary English constantly - a channel with an emote
|
||||
// called "ok" or "no" would otherwise turn half the conversation into
|
||||
// pictures. The boundary is whitespace rather than \b, because names
|
||||
// routinely contain digits and underscores and \b would match inside them.
|
||||
if (opts.wordEmotes && Object.keys(opts.wordEmotes).length > 0) {
|
||||
const words = opts.wordEmotes
|
||||
out = out.replace(/(^|\s)([^\s]{1,64})(?=\s|$)/g, (whole, lead: string, word: string) => {
|
||||
const url = Object.prototype.hasOwnProperty.call(words, word) ? words[word] : undefined
|
||||
return url
|
||||
? `${lead}${stow(`<img src="${url}" class="custom-emoji" alt=":${escapeHtml(word)}:">`)}`
|
||||
: whole
|
||||
})
|
||||
}
|
||||
|
||||
// Discord channel links. What arrives is `<#1393001234568164748>` and
|
||||
// nothing else - no name anywhere in the payload - so this is the only
|
||||
// chance to make it read as the "#general" the sender saw when they typed
|
||||
|
||||
@@ -452,6 +452,14 @@ export interface ChatState {
|
||||
* most of what a viewer wants to know, were nowhere.
|
||||
*/
|
||||
kickStreams: Record<string, KickStream>
|
||||
/**
|
||||
* Kick's 7TV emotes, by buffer, as word -> picture.
|
||||
*
|
||||
* Per buffer because that is what they are: a 7TV emote is a bare word that
|
||||
* stands for a picture in one channel and means nothing in the next, unlike
|
||||
* Kick's own `[emote:id:name]`, which carries its id and resolves anywhere.
|
||||
*/
|
||||
kickWordEmotes: Record<string, Record<string, string>>
|
||||
/** The poll and prediction running in each channel, by `cardSlot`. */
|
||||
livePolls: Record<string, LiveCard>
|
||||
/**
|
||||
@@ -593,6 +601,7 @@ const INITIAL: ChatState = {
|
||||
openThread: null,
|
||||
matrixPermissions: {},
|
||||
kickStreams: {},
|
||||
kickWordEmotes: {},
|
||||
livePolls: {},
|
||||
pinnedByBuffer: {},
|
||||
reviewCard: null,
|
||||
@@ -2386,6 +2395,14 @@ export class ChatStore {
|
||||
})
|
||||
break
|
||||
|
||||
// A Kick channel's 7TV emotes: words that are pictures in this room and
|
||||
// ordinary text everywhere else, which is why they are kept per buffer.
|
||||
case 'kickWordEmotes':
|
||||
this.set({
|
||||
kickWordEmotes: { ...this.state.kickWordEmotes, [data.bufferId]: data.emotes }
|
||||
})
|
||||
break
|
||||
|
||||
// Somebody left this room unread, here or on another client.
|
||||
case 'markedUnread':
|
||||
this.set({
|
||||
|
||||
Reference in New Issue
Block a user