diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md index d0f3a9dc3..b24a117c9 100644 --- a/CHANGELOG-nightly.md +++ b/CHANGELOG-nightly.md @@ -1,3 +1,7 @@ +### 3.1.24.1000 + +- Added full support for Twitch GIF messages + ### 3.1.23.1000 - Added setting to hide Twitch "Chat" send button under the chat input box diff --git a/CHANGELOG.md b/CHANGELOG.md index f470f3dda..f88ca94b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### 3.1.24 + +- Added full support for Twitch GIF messages + ### 3.1.23 - Added setting to hide Twitch "Chat" send button under the chat input box diff --git a/package.json b/package.json index c39e81db0..f3da765f5 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "7TV", "description": "Improve your viewing experience on Twitch & Kick with new features, emotes, vanity and performance.", "private": true, - "version": "3.1.23", + "version": "3.1.24", "dev_version": "1.0", "scripts": { "start": "cross-env NODE_ENV=dev yarn build:dev && cross-env NODE_ENV=dev vite --mode dev", diff --git a/src/app/chat/MessageTokenGif.vue b/src/app/chat/MessageTokenGif.vue new file mode 100644 index 000000000..74ea9b4d0 --- /dev/null +++ b/src/app/chat/MessageTokenGif.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/app/chat/UserMessage.vue b/src/app/chat/UserMessage.vue index d2e468121..7719c9f50 100644 --- a/src/app/chat/UserMessage.vue +++ b/src/app/chat/UserMessage.vue @@ -66,6 +66,7 @@ {{ token.content.cheerAmount }} + @@ -96,7 +97,7 @@ import { useTimeoutFn } from "@vueuse/shared"; import { SetHexAlpha } from "@/common/Color"; import { log } from "@/common/Logger"; import type { AnyToken, ChatMessage, ChatUser } from "@/common/chat/ChatMessage"; -import { IsEmoteToken, IsLinkToken, IsMentionToken } from "@/common/type-predicates/MessageTokens"; +import { IsEmoteToken, IsGifToken, IsLinkToken, IsMentionToken } from "@/common/type-predicates/MessageTokens"; import { useChannelContext } from "@/composable/channel/useChannelContext"; import { useChatModeration } from "@/composable/chat/useChatModeration"; import { useChatProperties } from "@/composable/chat/useChatProperties"; @@ -107,6 +108,7 @@ import type { TimestampFormatKey } from "@/site/twitch.tv/modules/chat/ChatModul import ModIcons from "@/site/twitch.tv/modules/chat/components/mod/ModIcons.vue"; import Emote from "./Emote.vue"; import EmoteLinkEmbed from "./EmoteLinkEmbed.vue"; +import MessageTokenGif from "./MessageTokenGif.vue"; import MessageTokenLink from "./MessageTokenLink.vue"; import MessageTokenMention from "./MessageTokenMention.vue"; import RichEmbed from "./RichEmbed.vue"; diff --git a/src/assets/svg/icons/ReportIcon.vue b/src/assets/svg/icons/ReportIcon.vue new file mode 100644 index 000000000..73440907b --- /dev/null +++ b/src/assets/svg/icons/ReportIcon.vue @@ -0,0 +1,7 @@ + diff --git a/src/common/chat/ChatMessage.ts b/src/common/chat/ChatMessage.ts index e53f409e3..f9a0ad4c4 100644 --- a/src/common/chat/ChatMessage.ts +++ b/src/common/chat/ChatMessage.ts @@ -55,6 +55,7 @@ export class ChatMessage { public parent: ChatMessageParent | null = null; public wrappedNode: Element | null = null; public nativeEmotes = {} as Record; + public nativeGif: GifToken | null = null; public tokens = new Array(); private tokenizer?: Tokenizer; @@ -189,10 +190,20 @@ export type EmoteToken = ChatMessageToken< cheerColor?: string; } >; +export type GifToken = ChatMessageToken< + "GIF", + { + id: string; + title: string; + url: string; + report?: () => boolean; + canReport?: () => boolean; + } +>; export type VoidToken = ChatMessageToken<"VOID", void>; -export type MessageTokenKind = "TEXT" | "LINK" | "FLAGGED_SEGMENT" | "MENTION" | "EMOTE" | "VOID"; +export type MessageTokenKind = "TEXT" | "LINK" | "FLAGGED_SEGMENT" | "MENTION" | "EMOTE" | "GIF" | "VOID"; export type MessageDeliveryState = "IDLE" | "IN_FLIGHT" | "SENT" | "BOUNCED"; diff --git a/src/common/chat/Tokenizer.ts b/src/common/chat/Tokenizer.ts index 4ec56c7dd..31b5eb0ed 100644 --- a/src/common/chat/Tokenizer.ts +++ b/src/common/chat/Tokenizer.ts @@ -125,6 +125,18 @@ export class Tokenizer { if (!maybeEmote && !!part) lastEmoteToken = undefined; } + const gif = this.msg.nativeGif; + if (gif) { + for (let i = tokens.length - 1; i >= 0; i--) { + const token = tokens[i]; + if (token.range[0] <= gif.range[1] && token.range[1] >= gif.range[0]) { + tokens.splice(i, 1); + } + } + + tokens.push(gif); + } + tokens.sort((a, b) => a.range[0] - b.range[0]); return (this.msg.tokens = tokens); diff --git a/src/common/type-predicates/MessageTokens.ts b/src/common/type-predicates/MessageTokens.ts index 1c76aea1d..acbc96fe1 100644 --- a/src/common/type-predicates/MessageTokens.ts +++ b/src/common/type-predicates/MessageTokens.ts @@ -1,4 +1,4 @@ -import { AnyToken, EmoteToken, LinkToken, MentionToken, TextToken } from "../chat/ChatMessage"; +import { AnyToken, EmoteToken, GifToken, LinkToken, MentionToken, TextToken } from "../chat/ChatMessage"; export function IsTextToken(part: AnyToken): part is TextToken { return part.kind === "TEXT"; @@ -12,6 +12,10 @@ export function IsEmoteToken(part: AnyToken): part is EmoteToken { return part.kind === "EMOTE"; } +export function IsGifToken(part: AnyToken): part is GifToken { + return part.kind === "GIF"; +} + export function IsMentionToken(part: AnyToken): part is MentionToken { return part.kind === "MENTION"; } diff --git a/src/composable/useFrankerFaceZ.ts b/src/composable/useFrankerFaceZ.ts index b6c0601c1..04d6f5fd6 100644 --- a/src/composable/useFrankerFaceZ.ts +++ b/src/composable/useFrankerFaceZ.ts @@ -137,6 +137,40 @@ function setConfig(profileName: keyof typeof data.profile, key: string, value profile.set(key, value); } +function resolveTwitchReport(): ResolvedTwitchReport | null { + if (!data.ffz) return null; + + try { + const site = data.ffz.resolve("site"); + const reportModal = data.ffz + .resolve("site.web_munch") + .getModule("user-report"); + + if (!site.store?.dispatch || !reportModal) return null; + + return { store: site.store, reportModal }; + } catch { + return null; + } +} + +function canOpenTwitchReport(): boolean { + return !!resolveTwitchReport(); +} + +function openTwitchReport(reportContext: TwitchReportContext): boolean { + const resolved = resolveTwitchReport(); + if (!resolved) return false; + + resolved.store.dispatch({ + type: "core.modal.MODAL_SHOWN", + modalComponent: resolved.reportModal, + modalProps: { reportContext }, + }); + + return true; +} + function createDummyAddon() { if (!("FrankerFaceZ" in window)) return; @@ -166,6 +200,8 @@ export function useFrankerFaceZ() { getConfigChanges, setConfig, disableChatProcessing, + canOpenTwitchReport, + openTwitchReport, }); } @@ -241,4 +277,29 @@ export interface FFZAddonsManager extends FFZModule { enabled_addons: string[]; } +interface FFZSite { + store?: { + dispatch: (action: unknown) => unknown; + }; +} + +interface FFZWebMunch { + getModule(key: string): T | null; +} + +interface ResolvedTwitchReport { + store: NonNullable; + reportModal: React.ComponentType; +} + +interface TwitchReportContext { + contentType: "GIF_MESSAGE_REPORT"; + contentID: string; + targetUserID: string; + contentMetadata: { + channelID: string; + }; + trackingContext: "channel_page"; +} + declare const FrankerFaceZ: any; diff --git a/src/site/twitch.tv/index.ts b/src/site/twitch.tv/index.ts index 8a6783939..6ca1fcb0e 100644 --- a/src/site/twitch.tv/index.ts +++ b/src/site/twitch.tv/index.ts @@ -108,6 +108,7 @@ export const enum MessagePartType { EMOTE, CLIPLINK, VIDEOLINK, + GIF, SEVENTVEMOTE = 700, SEVENTVLINK, } diff --git a/src/site/twitch.tv/modules/chat-vod/ChatVod.vue b/src/site/twitch.tv/modules/chat-vod/ChatVod.vue index 44f7f62e7..8933baa51 100644 --- a/src/site/twitch.tv/modules/chat-vod/ChatVod.vue +++ b/src/site/twitch.tv/modules/chat-vod/ChatVod.vue @@ -254,6 +254,19 @@ function createMessageComponentRef(data: CommentData) { msg.body += e.alt; break; } + case MessagePartType.GIF: { + const gif = tok.content as Twitch.ChatMessage.GifPart["content"]; + if (!gif.url) continue; + + const start = msg.body.length; + msg.body += gif.title; + msg.nativeGif = { + kind: "GIF", + content: gif, + range: [start, start + gif.title.length - 1], + }; + break; + } case MessagePartType.MENTION: msg.body += `@${tok.content.recipient}`; break; diff --git a/src/site/twitch.tv/modules/chat/ChatList.vue b/src/site/twitch.tv/modules/chat/ChatList.vue index 6fc0aa514..608f53c4a 100644 --- a/src/site/twitch.tv/modules/chat/ChatList.vue +++ b/src/site/twitch.tv/modules/chat/ChatList.vue @@ -41,6 +41,7 @@ import { useChatHighlights } from "@/composable/chat/useChatHighlights"; import { useChatMessages } from "@/composable/chat/useChatMessages"; import { useChatProperties } from "@/composable/chat/useChatProperties"; import { useChatScroller } from "@/composable/chat/useChatScroller"; +import { useFrankerFaceZ } from "@/composable/useFrankerFaceZ"; import { useConfig } from "@/composable/useSettings"; import { MessagePartType, MessageType, ModerationType } from "@/site/twitch.tv/"; import ChatMessageUnhandled from "./ChatMessageUnhandled.vue"; @@ -63,6 +64,7 @@ const displayedMessages = toRef(messages, "displayed"); const scroller = useChatScroller(ctx); const properties = useChatProperties(ctx); const chatHighlights = useChatHighlights(ctx); +const ffz = useFrankerFaceZ(); const pageVisibility = useDocumentVisibility(); const isHovering = toRef(properties, "hovering"); const pausedByVisibility = ref(false); @@ -330,6 +332,46 @@ function onChatMessage(msg: ChatMessage, msgData: Twitch.AnyMessage, shouldRende msg.body = msg.body.replace(e.originalText, "*".repeat(e.originalText.length)); break; } + case MessagePartType.GIF: { + const gif = part.content as Twitch.ChatMessage.GifPart["content"]; + if (!gif.url) continue; + + const start = gif.title ? msg.body.indexOf(gif.title) : msg.body.length; + if (start < 0) continue; + + const getNativeReportButton = () => + list.value.domNodes[msg.id]?.querySelector( + "[data-a-target=chat-line-message-body] button", + ); + + msg.nativeGif = { + kind: "GIF", + content: { + ...gif, + canReport: () => !!getNativeReportButton() || ffz.canOpenTwitchReport(), + report: () => { + const reportButton = getNativeReportButton(); + + if (reportButton) { + reportButton.click(); + return true; + } + + return ffz.openTwitchReport({ + contentType: "GIF_MESSAGE_REPORT", + contentID: msg.id, + targetUserID: msg.author!.id, + contentMetadata: { + channelID: msg.channelID, + }, + trackingContext: "channel_page", + }); + }, + }, + range: [start, start + gif.title.length - 1], + }; + break; + } } } } @@ -463,7 +505,14 @@ watch( defineFunctionHook(handler, "handleMessage", function (old, msg: Twitch.AnyMessage) { const ok = onMessage(msg); - if (ok) return ""; // message was rendered by the extension + if (ok) { + const parts = IsDisplayableMessage(msg) ? msg.messageParts ?? msg.message?.messageParts ?? [] : []; + if (parts.some((part) => part.type === MessagePartType.GIF)) { + return old?.call(this, msg); + } + + return ""; // message was rendered by the extension + } // message was not rendered by the extension unhandled.set(msg.id, msg); diff --git a/src/types/twitch.messages.d.ts b/src/types/twitch.messages.d.ts index 148143e56..9afdb68ef 100644 --- a/src/types/twitch.messages.d.ts +++ b/src/types/twitch.messages.d.ts @@ -179,6 +179,14 @@ declare namespace Twitch { }; } + export interface GifPart extends Part { + content: { + id: string; + title: string; + url: string; + }; + } + export interface AppPart { type: "text" | "emote" | "twitch-emote" | "link" | "mention"; // eslint-disable-next-line @typescript-eslint/no-explicit-any