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