Stop drawing 7TV words as pictures

The window half of removing #195. The substitution in the formatter, the
per-buffer map behind it, and the state and context that carried it.

Kick's chat does not render these, so a message that read as pictures here
read as words everywhere else - which made this client's view of a
conversation quietly different from the one the channel was actually having.

Kick's own `[emote:id:name]` is untouched.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-12 13:17:39 -04:00
co-authored by Claude Opus 5
parent b7ebb31b2f
commit 5819345c10
4 changed files with 5 additions and 57 deletions
+1 -1
Submodule nobilis updated: 0a0ee0ff81...7e15621f43
+4 -12
View File
@@ -141,8 +141,6 @@ 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. */
@@ -153,7 +151,6 @@ 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 ?? {},
@@ -161,10 +158,9 @@ export function useRowContext(bufferId: string): RowContext {
emoji: emoji ?? [],
inDirectMessage: kind === 'dm',
pinned: pinned ?? [],
ircColours,
wordEmotes: wordEmotes ?? {}
ircColours
}),
[permissions, smilies, emoji, kind, pinned, ircColours, wordEmotes]
[permissions, smilies, emoji, kind, pinned, ircColours]
)
}
@@ -332,8 +328,7 @@ function MessageRowBody({
smilies: smilieIndex,
emoji: bufferEmoji,
inDirectMessage,
ircColours,
wordEmotes
ircColours
} = shared
const sniffed = useSniffedTypes()
@@ -409,10 +404,7 @@ 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',
// Kick's 7TV emotes, which only mean anything in the channel whose
// set they came from.
wordEmotes
ircFormatting: service === 'irc' && ircColours ? 'render' : 'strip'
}),
media,
codeBlocks,
-27
View File
@@ -225,13 +225,6 @@ 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>
}
/**
@@ -427,26 +420,6 @@ 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
-17
View File
@@ -452,14 +452,6 @@ 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>
/**
@@ -601,7 +593,6 @@ const INITIAL: ChatState = {
openThread: null,
matrixPermissions: {},
kickStreams: {},
kickWordEmotes: {},
livePolls: {},
pinnedByBuffer: {},
reviewCard: null,
@@ -2395,14 +2386,6 @@ 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({