From 75f747b793b3d15c5fdda782a400ddadc7fcfebd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 22:30:59 +0200 Subject: [PATCH 01/24] fix(deps): bump peter-evans/create-pull-request from 4 to 5 (#577) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/crowdin.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crowdin.yaml b/.github/workflows/crowdin.yaml index 4198d707e..df2f3c7fd 100644 --- a/.github/workflows/crowdin.yaml +++ b/.github/workflows/crowdin.yaml @@ -94,7 +94,7 @@ jobs: - name: Create Pull Request if: ${{ steps.count.outputs.lines != '0' }} - uses: peter-evans/create-pull-request@v4 + uses: peter-evans/create-pull-request@v5 with: title: "[CI] New Crowdin Translations" body: The latest approved translations from Crowdin From e8fd0b18247f5ab2f5272de04d8f7ce7c5497cd0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 20:32:45 +0000 Subject: [PATCH 02/24] fix(deps): bump crowdin/github-action from 1.5.2 to 1.8.0 (#578) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/crowdin.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/crowdin.yaml b/.github/workflows/crowdin.yaml index df2f3c7fd..a66abf602 100644 --- a/.github/workflows/crowdin.yaml +++ b/.github/workflows/crowdin.yaml @@ -24,7 +24,7 @@ jobs: - name: crowdin action if: steps.changes.outputs.crowdin == 'true' - uses: crowdin/github-action@1.5.2 + uses: crowdin/github-action@v1.8.0 with: upload_sources: true upload_translations: false @@ -69,7 +69,7 @@ jobs: git checkout ${{ env.BASE_BRANCH }} - name: Download Crowdin Translations - uses: crowdin/github-action@1.5.2 + uses: crowdin/github-action@v1.8.0 with: upload_sources: false upload_translations: false From 0534f7f41321245b75f9bddb159ee4bea6cf507b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 May 2023 20:34:56 +0000 Subject: [PATCH 03/24] chore(deps-dev): bump @types/node from 20.1.2 to 20.1.3 (#580) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 5ced8fbfa..040a585d6 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@types/dompurify": "^3.0.2", "@types/fs-extra": "^11.0.1", "@types/marked": "^4.3.0", - "@types/node": "^20.1.2", + "@types/node": "^20.1.3", "@types/sharedworker": "^0.0.96", "@types/ua-parser-js": "^0.7.36", "@types/uuid": "^9.0.1", diff --git a/yarn.lock b/yarn.lock index 65add559f..4b292a082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,15 +614,10 @@ resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== -"@types/node@*": - version "18.15.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.2.tgz#0407ceb15647f186318101546d5ae40725b73810" - integrity sha512-sDPHm2wfx2QhrMDK0pOt2J4KLJMAcerqWNvnED0itPRJWvI+bK+uNHzcH1dFsBlf7G3u8tqXmRF3wkvL9yUwMw== - -"@types/node@^20.1.2": - version "20.1.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.1.2.tgz#8fd63447e3f99aba6c3168fd2ec4580d5b97886f" - integrity sha512-CTO/wa8x+rZU626cL2BlbCDzydgnFNgc19h4YvizpTO88MFQxab8wqisxaofQJ/9bLGugRdWIuX/TbIs6VVF6g== +"@types/node@*", "@types/node@^20.1.3": + version "20.1.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.1.3.tgz#bc8e7cd8065a5fc355a3a191a68db8019c58bc00" + integrity sha512-NP2yfZpgmf2eDRPmgGq+fjGjSwFgYbihA8/gK+ey23qT9RkxsgNTZvGOEpXgzIGqesTYkElELLgtKoMQTys5vA== "@types/normalize-package-data@^2.4.0": version "2.4.1" From 3e7e4d3ed5a1214381c61810e098f615ad4db94a Mon Sep 17 00:00:00 2001 From: Anatole Date: Fri, 12 May 2023 22:36:04 +0200 Subject: [PATCH 04/24] ci: remove unused go setup step --- .github/workflows/ci.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 96d9e0290..211546ee2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -54,11 +54,6 @@ jobs: cancel-in-progress: true steps: - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: 1.18.3 - - uses: actions/setup-node@v3 with: node-version: "18" From 46f17fdb0b4684f88c945e7f74cf77368b653aa4 Mon Sep 17 00:00:00 2001 From: Anatole Date: Sun, 14 May 2023 18:37:06 +0200 Subject: [PATCH 05/24] Implement kick.com (#581) --- CHANGELOG-nightly.md | 1 + manifest.config.ts | 6 +- package.json | 4 +- src/assets/style/global.scss | 3 +- src/assets/svg/logos/LogoBrandKick.vue | 10 ++ src/background/background.ts | 9 +- src/common/Constant.ts | 2 + src/common/Tokenize.ts | 108 +++++++++++++++++ src/common/type-predicates/MessageTokens.ts | 2 +- src/composable/channel/useChannelContext.ts | 7 +- src/composable/chat/useChatTools.ts | 2 + src/composable/useModule.ts | 64 +++++++--- .../views/Onboarding/OnboardingPlatforms.vue | 6 +- src/options/views/Popup/Popup.vue | 2 +- src/options/views/Popup/PopupInner.vue | 90 +++++++++++--- src/site/App.vue | 2 + src/site/kick.com/KickSite.vue | 46 ++++++++ src/site/kick.com/index.ts | 19 +++ .../modules/chat/ChatAutocomplete.vue | 90 ++++++++++++++ .../kick.com/modules/chat/ChatController.vue | 74 ++++++++++++ .../kick.com/modules/chat/ChatMessage.vue | 110 ++++++++++++++++++ src/site/kick.com/modules/chat/ChatModule.vue | 64 ++++++++++ .../kick.com/modules/chat/ChatObserver.vue | 65 +++++++++++ .../modules/chat-input/ChatInput.vue | 2 +- .../modules/chat-input/ChatInputModule.vue | 2 +- .../twitch.tv/modules/chat-input/ChatSpam.vue | 6 +- .../twitch.tv/modules/chat/ChatController.vue | 2 +- .../chat/components/message/UserMessage.vue | 2 +- .../modules/chat/components/tray/ChatTray.ts | 2 +- .../modules/emote-menu/EmoteMenu.vue | 6 +- .../modules/mod-logs/ModLogsModule.vue | 4 +- src/types/app.d.ts | 8 +- src/types/kick.module.d.ts | 7 ++ 33 files changed, 765 insertions(+), 62 deletions(-) create mode 100644 src/assets/svg/logos/LogoBrandKick.vue create mode 100644 src/common/Tokenize.ts create mode 100644 src/site/kick.com/KickSite.vue create mode 100644 src/site/kick.com/index.ts create mode 100644 src/site/kick.com/modules/chat/ChatAutocomplete.vue create mode 100644 src/site/kick.com/modules/chat/ChatController.vue create mode 100644 src/site/kick.com/modules/chat/ChatMessage.vue create mode 100644 src/site/kick.com/modules/chat/ChatModule.vue create mode 100644 src/site/kick.com/modules/chat/ChatObserver.vue create mode 100644 src/types/kick.module.d.ts diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md index 53bad0e40..fe343ffe7 100644 --- a/CHANGELOG-nightly.md +++ b/CHANGELOG-nightly.md @@ -2,6 +2,7 @@ **The changes listed here are not assigned to an official release**. +- Added early experimental functionality for a new site: [kick.com](https://kick.com/) - Added a tooltip to show the full message when hovering over replies in chat - Added a "Site Layout" menu where certain features of the Twitch website can be hidden - Added Emojis for the emoji groups diff --git a/manifest.config.ts b/manifest.config.ts index 39c09f2e7..41fc92836 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -65,16 +65,16 @@ export async function getManifest(opt: ManifestOptions): Promise + + + + + diff --git a/src/background/background.ts b/src/background/background.ts index 08a3e67f0..29c19573a 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -1,9 +1,8 @@ +import { HOSTNAME_SUPPORTED_REGEXP } from "@/common/Constant"; import { log } from "@/common/Logger"; import "./messaging"; import "./sync"; -const HOSTNAME_YT_REGEXP = /([a-z0-9]+[.])*youtube[.]com/; - // Connect to Vite server // // (this is only used in dev mode) @@ -15,7 +14,7 @@ function useHotReloading() { const { event } = JSON.parse(e.data); if (event === "defined-match") { log.info("Background files changed, reloading extension..."); - chrome.runtime.reload(); + setTimeout(() => chrome.runtime.reload(), 100); } }; } @@ -76,7 +75,7 @@ if (!chrome.scripting) { } const loc = new URL(t.url); - if (HOSTNAME_YT_REGEXP.test(loc.host)) { + if (HOSTNAME_SUPPORTED_REGEXP.test(loc.host)) { chrome.tabs.executeScript(tabId, { file: "content.js", }); @@ -87,7 +86,7 @@ if (!chrome.scripting) { { id: "seventv-youtube", js: ["content.js"], - matches: ["*://*.youtube.com/*"], + matches: ["*://*.youtube.com/*", "*://*.kick.com/*"], }, ]); } diff --git a/src/common/Constant.ts b/src/common/Constant.ts index 0007c1373..99aa0b947 100644 --- a/src/common/Constant.ts +++ b/src/common/Constant.ts @@ -18,3 +18,5 @@ export const SITE_ACTIVE_WINDOW: InjectionKey = Symbol("seventv-site-act export const UNICODE_TAG_0 = "\u{E0000}"; export const UNICODE_TAG_0_REGEX = new RegExp(UNICODE_TAG_0, "g"); + +export const HOSTNAME_SUPPORTED_REGEXP = /([a-z0-9]+[.])*(youtube|kick)[.]com/; diff --git a/src/common/Tokenize.ts b/src/common/Tokenize.ts new file mode 100644 index 000000000..4c294c542 --- /dev/null +++ b/src/common/Tokenize.ts @@ -0,0 +1,108 @@ +import type { AnyToken, ChatUser, EmoteToken, LinkToken, VoidToken } from "@/common/chat/ChatMessage"; +import { parse as tldParse } from "tldts"; + +const URL_PROTOCOL_REGEXP = /^https?:\/\//i; +const backwardModifierBlacklist = new Set(["w!", "h!", "v!", "z!"]); + +export function tokenize(opt: TokenizeOptions) { + const tokens = [] as AnyToken[]; + + const textParts = opt.body.split(" "); + const getEmote = (name: string) => opt.localEmoteMap?.[name] ?? opt.emoteMap[name]; + const showModifiers = opt.showModifiers; + + let cursor = -1; + let lastEmoteToken: EmoteToken | undefined = undefined; + let parsedUrl: URL | null = null; + + const toVoid = (start: number, end: number) => + ({ + kind: "VOID", + range: [start, end], + content: void 0, + } as VoidToken); + + for (const part of textParts) { + const next = cursor + (part.length + 1); + + // tokenize emote? + const maybeEmote = getEmote(part); + const nextEmote = getEmote(textParts[textParts.indexOf(part) + 1]); + const prevEmote = getEmote(textParts[textParts.indexOf(part) - 1]); + + if (maybeEmote) { + // handle zero width overlaying + if ((maybeEmote.data?.flags ?? 0) & 256 && lastEmoteToken) { + lastEmoteToken.content.overlaid[maybeEmote.name] = maybeEmote; + + // the "void" token is used to hide the text of the zero-width. any text in the void range won't be rendered + tokens.push(toVoid(cursor + 1, next - 1)); + } else { + // regular emote + tokens.push( + (lastEmoteToken = { + kind: "EMOTE", + range: [cursor + 1, next - 1], + content: { + emote: maybeEmote, + overlaid: {}, + ...(maybeEmote.isTwitchCheer + ? { + cheerAmount: maybeEmote.isTwitchCheer.amount, + cheerColor: maybeEmote.isTwitchCheer.color, + } + : {}), + } as EmoteToken["content"], + }), + ); + } + } else if (!showModifiers && nextEmote && backwardModifierBlacklist.has(part)) { + // this is a temporary measure to hide bttv emote modifiers + tokens.push(toVoid(cursor, next - 1)); + } else if (!showModifiers && prevEmote && part.startsWith("ffz") && part.length > 3) { + // this is a temporary measure to hide ffz emote modifiers + tokens.push(toVoid(cursor, next - 1)); + } else if ((parsedUrl = isValidLink(part))) { + tokens.push({ + kind: "LINK", + range: [cursor + 1, next - 1], + content: { + displayText: part, + url: parsedUrl.toString(), + }, + } as LinkToken); + } + + cursor = next; + if (!maybeEmote && !!part) lastEmoteToken = undefined; + } + + tokens.sort((a, b) => a.range[0] - b.range[0]); + + return tokens; +} + +export function isValidLink(message: string): URL | null { + try { + const url = new URL(`https://${message.replace(URL_PROTOCOL_REGEXP, "")}`); + const { isIcann, domain } = tldParse(url.hostname); + + if (domain && isIcann) { + return url; + } + } catch (e) { + void 0; + } + + return null; +} + +export interface TokenizeOptions { + body: string; + chatterMap: Record; + emoteMap: Record; + localEmoteMap?: Record; + filteredWords?: string[]; + actorUsername?: string; + showModifiers?: boolean; +} diff --git a/src/common/type-predicates/MessageTokens.ts b/src/common/type-predicates/MessageTokens.ts index fa5bc5da1..1c76aea1d 100644 --- a/src/common/type-predicates/MessageTokens.ts +++ b/src/common/type-predicates/MessageTokens.ts @@ -8,7 +8,7 @@ export function IsLinkToken(part: AnyToken): part is LinkToken { return part.kind === "LINK"; } -export function IsEmotePart(part: AnyToken): part is EmoteToken { +export function IsEmoteToken(part: AnyToken): part is EmoteToken { return part.kind === "EMOTE"; } diff --git a/src/composable/channel/useChannelContext.ts b/src/composable/channel/useChannelContext.ts index 1ff8947f5..fd25f4bc4 100644 --- a/src/composable/channel/useChannelContext.ts +++ b/src/composable/channel/useChannelContext.ts @@ -16,7 +16,12 @@ export class ChannelContext { loaded = false; setCurrentChannel(channel: CurrentChannel): boolean { - if (this.id === channel.id) return false; + if (this.id === channel.id) { + this.username = channel.username; + this.displayName = channel.displayName; + + return false; + } const oldID = this.id; diff --git a/src/composable/chat/useChatTools.ts b/src/composable/chat/useChatTools.ts index 6bc186102..d64ce6616 100644 --- a/src/composable/chat/useChatTools.ts +++ b/src/composable/chat/useChatTools.ts @@ -6,6 +6,7 @@ interface ChatTools { onShowViewerCard: Twitch.ViewerCardComponent["onShowViewerCard"]; }; YOUTUBE: Record; + KICK: Record; UNKNOWN: Record; } @@ -19,6 +20,7 @@ export function useChatTools(ctx: ChannelContext) { onShowViewerCard: () => void 0, }, YOUTUBE: {}, + KICK: {}, UNKNOWN: {}, }); diff --git a/src/composable/useModule.ts b/src/composable/useModule.ts index 2949314b9..54eef05e0 100644 --- a/src/composable/useModule.ts +++ b/src/composable/useModule.ts @@ -1,38 +1,66 @@ import { Ref, nextTick, onUnmounted, reactive, ref, toRef, watch } from "vue"; import { log } from "@/common/Logger"; +import { KickModuleComponentMap, KickModuleID } from "@/types/kick.module"; import type { TwModuleComponentMap, TwModuleID } from "@/types/tw.module"; +import { YtModuleComponentMap, YtModuleID } from "@/types/yt.module"; const data = reactive({ - modules: {} as Record, + modules: {} as Record>, }); -export function getModule(id: T): Module | null { - return (data.modules[id] ?? null) as Module | null; +export type PlatformModuleID

= P extends "KICK" + ? KickModuleID + : P extends "TWITCH" + ? TwModuleID + : P extends "YOUTUBE" + ? YtModuleID + : string; + +export type PlatformModuleComponentMap

= P extends "KICK" + ? KickModuleComponentMap + : P extends "TWITCH" + ? TwModuleComponentMap + : P extends "YOUTUBE" + ? YtModuleComponentMap + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + any; + +export type AnyModuleID = + | PlatformModuleID<"UNKNOWN"> + | PlatformModuleID<"KICK"> + | PlatformModuleID<"TWITCH"> + | PlatformModuleID<"YOUTUBE">; + +export function getModule

>(id: T): Module | null { + return (data.modules[id] ?? null) as Module | null; } -export function getModuleRef(id: T): Ref> { - const mod = ref(null); +export function getModuleRef

>(id: T): Ref | null> { + const mod = ref | null>(null); if (data.modules[id]) { - mod.value = data.modules[id] as Module; + mod.value = data.modules[id] as Module; } if (!mod.value) { const stop = watch(data.modules, (modules) => { - mod.value = modules[id] as Module | null; + mod.value = modules[id] as Module | null; if (mod.value) stop(); }); } - return mod as Ref>; + return mod as Ref | null>; } -export function declareModule(id: TwModuleID, opt: ModuleOptions) { - data.modules[id] = reactive({ - id, +export function declareModule

= PlatformModuleID

>( + id: Id, + opt: ModuleOptions, +) { + data.modules[id] = reactive>({ + id: id as never, name: opt.name, enabled: true, - depends_on: opt.depends_on, + depends_on: opt.depends_on as never[], instance: null, }); @@ -41,7 +69,7 @@ export function declareModule(id: TwModuleID, opt: ModuleOptions) { const dependenciesMet = ref(false); const depends = mod.value.depends_on; - const promises = [] as Promise[]; + const promises = [] as Promise>[]; // Await dependencies for (const depId of depends) { @@ -49,7 +77,7 @@ export function declareModule(id: TwModuleID, opt: ModuleOptions) { if (!dep) continue; promises.push( - new Promise((resolve) => { + new Promise>((resolve) => { const ok = watch(dep, () => { if (!dep.ready) return; @@ -97,17 +125,17 @@ export function declareModule(id: TwModuleID, opt: ModuleOptions) { interface ModuleOptions { name: string; - depends_on: TwModuleID[]; + depends_on: PlatformModuleID<"UNKNOWN">[]; } -export interface Module { +export interface Module

> { id: T; name: string; enabled: boolean; - depends_on: TwModuleID[]; + depends_on: PlatformModuleID

[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any - instance: InstanceType | null; + instance: InstanceType[T]> | null; configurable?: boolean; ready?: boolean; } diff --git a/src/options/views/Onboarding/OnboardingPlatforms.vue b/src/options/views/Onboarding/OnboardingPlatforms.vue index 0f8e87d7b..d7bd23e10 100644 --- a/src/options/views/Onboarding/OnboardingPlatforms.vue +++ b/src/options/views/Onboarding/OnboardingPlatforms.vue @@ -50,6 +50,7 @@ onDeactivated(() => { const platforms = ref([ { name: "Twitch", icon: markRaw(LogoBrandTwitch), selected: true }, { name: "YouTube", icon: markRaw(LogoBrandYouTube), hosts: ["*://*.youtube.com/*"], selected: true }, + { name: "Kick", icon: markRaw(LogoBrandKick), hosts: ["*://*.kick.com/*"], selected: true }, ]); function toggle(p: PlatformDef) { @@ -59,6 +60,7 @@ function toggle(p: PlatformDef) { diff --git a/src/site/kick.com/index.ts b/src/site/kick.com/index.ts new file mode 100644 index 000000000..8a3410ab1 --- /dev/null +++ b/src/site/kick.com/index.ts @@ -0,0 +1,19 @@ +import { InjectionKey } from "vue"; + +export interface ChatRoom { + chatroom: ChatRoomData | null; + currentChannelSlug: string; + currentMessage: string; +} + +export interface ChatRoomData { + id: number; +} + +export interface KickChannelInfo { + id: string; + username: string; + currentMessage: string; +} + +export const KICK_CHANNEL_KEY = Symbol() as InjectionKey; diff --git a/src/site/kick.com/modules/chat/ChatAutocomplete.vue b/src/site/kick.com/modules/chat/ChatAutocomplete.vue new file mode 100644 index 000000000..15bde6de5 --- /dev/null +++ b/src/site/kick.com/modules/chat/ChatAutocomplete.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/site/kick.com/modules/chat/ChatController.vue b/src/site/kick.com/modules/chat/ChatController.vue new file mode 100644 index 000000000..f09e38f96 --- /dev/null +++ b/src/site/kick.com/modules/chat/ChatController.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/site/kick.com/modules/chat/ChatMessage.vue b/src/site/kick.com/modules/chat/ChatMessage.vue new file mode 100644 index 000000000..37a8ec76c --- /dev/null +++ b/src/site/kick.com/modules/chat/ChatMessage.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/src/site/kick.com/modules/chat/ChatModule.vue b/src/site/kick.com/modules/chat/ChatModule.vue new file mode 100644 index 000000000..39a64fbd6 --- /dev/null +++ b/src/site/kick.com/modules/chat/ChatModule.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/site/kick.com/modules/chat/ChatObserver.vue b/src/site/kick.com/modules/chat/ChatObserver.vue new file mode 100644 index 000000000..e67053e10 --- /dev/null +++ b/src/site/kick.com/modules/chat/ChatObserver.vue @@ -0,0 +1,65 @@ + + + diff --git a/src/site/twitch.tv/modules/chat-input/ChatInput.vue b/src/site/twitch.tv/modules/chat-input/ChatInput.vue index 707935674..a4a6dd762 100644 --- a/src/site/twitch.tv/modules/chat-input/ChatInput.vue +++ b/src/site/twitch.tv/modules/chat-input/ChatInput.vue @@ -44,7 +44,7 @@ const props = defineProps<{ instance: HookedInstance; }>(); -const mod = getModule("chat-input"); +const mod = getModule<"TWITCH", "chat-input">("chat-input"); const store = useStore(); const ctx = useChannelContext(props.instance.component.componentRef.props.channelID); const messages = useChatMessages(ctx); diff --git a/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue b/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue index f825975f5..773ecf57a 100644 --- a/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue +++ b/src/site/twitch.tv/modules/chat-input/ChatInputModule.vue @@ -18,7 +18,7 @@ import { declareConfig, useConfig } from "@/composable/useSettings"; import ChatInput from "./ChatInput.vue"; import ChatSpam from "./ChatSpam.vue"; -const { markAsReady } = declareModule("chat-input", { +const { markAsReady } = declareModule<"TWITCH">("chat-input", { name: "Chat Input", depends_on: [], }); diff --git a/src/site/twitch.tv/modules/chat-input/ChatSpam.vue b/src/site/twitch.tv/modules/chat-input/ChatSpam.vue index e45f30a3e..047444a19 100644 --- a/src/site/twitch.tv/modules/chat-input/ChatSpam.vue +++ b/src/site/twitch.tv/modules/chat-input/ChatSpam.vue @@ -35,7 +35,7 @@ const emit = defineEmits<{ (e: "suggest-answer", answer: string): void; }>(); -const chatModule = getModuleRef("chat"); +const chatModule = getModuleRef<"TWITCH", "chat">("chat"); const rootEl = toRef(props.instance.domNodes, "root"); const suggestContainer = useFloatScreen(rootEl, { enabled: () => props.suggest, @@ -68,7 +68,7 @@ function handleDuplicateMessage(content: string): string { watch( chatModule, (mod) => { - if (!mod.instance) return; + if (!mod || !mod.instance) return; mod.instance.messageSendMiddleware.set("handle-dupe", handleDuplicateMessage); }, @@ -76,7 +76,7 @@ watch( ); onUnmounted(() => { - if (!chatModule.value.instance) return; + if (!chatModule.value?.instance) return; chatModule.value.instance.messageSendMiddleware.delete("handle-dupe"); }); diff --git a/src/site/twitch.tv/modules/chat/ChatController.vue b/src/site/twitch.tv/modules/chat/ChatController.vue index b58a2514b..14a3f0219 100644 --- a/src/site/twitch.tv/modules/chat/ChatController.vue +++ b/src/site/twitch.tv/modules/chat/ChatController.vue @@ -66,7 +66,7 @@ const props = defineProps<{ events?: HookedInstance; }>(); -const mod = getModule("chat")!; +const mod = getModule<"TWITCH", "chat">("chat")!; const { sendMessage: sendWorkerMessage } = useWorker(); const { list, controller, room } = toRefs(props); diff --git a/src/site/twitch.tv/modules/chat/components/message/UserMessage.vue b/src/site/twitch.tv/modules/chat/components/message/UserMessage.vue index a23279f65..0488fde46 100644 --- a/src/site/twitch.tv/modules/chat/components/message/UserMessage.vue +++ b/src/site/twitch.tv/modules/chat/components/message/UserMessage.vue @@ -93,7 +93,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 { IsEmotePart as IsEmoteToken, IsLinkToken, IsMentionToken } from "@/common/type-predicates/MessageTokens"; +import { IsEmoteToken, 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"; diff --git a/src/site/twitch.tv/modules/chat/components/tray/ChatTray.ts b/src/site/twitch.tv/modules/chat/components/tray/ChatTray.ts index faefced9a..a4f77174f 100644 --- a/src/site/twitch.tv/modules/chat/components/tray/ChatTray.ts +++ b/src/site/twitch.tv/modules/chat/components/tray/ChatTray.ts @@ -90,7 +90,7 @@ export function useTray( props?: () => TrayProps, tray?: Twitch.ChatTray, ) { - const mod = getModuleRef("chat-input"); + const mod = getModuleRef<"TWITCH", "chat-input">("chat-input"); function clear(): void { if (!mod.value || typeof mod.value.instance?.setTray !== "function") return; diff --git a/src/site/twitch.tv/modules/emote-menu/EmoteMenu.vue b/src/site/twitch.tv/modules/emote-menu/EmoteMenu.vue index b161dbfbb..fe4daf8bb 100644 --- a/src/site/twitch.tv/modules/emote-menu/EmoteMenu.vue +++ b/src/site/twitch.tv/modules/emote-menu/EmoteMenu.vue @@ -111,10 +111,10 @@ const visibleProviders = reactive>({ const chatModule = getModuleRef("chat"); const placement = useConfig<"regular" | "below" | "hidden">("ui.emote_menu.button_placement"); -const inputModule = getModuleRef("chat-input-controller"); +const inputModule = getModuleRef<"TWITCH", "chat-input-controller">("chat-input-controller"); onMounted(() => { - if (!inputModule.value.instance) return; + if (!inputModule.value?.instance) return; inputModule.value.instance.addButton( "emote-menu", @@ -178,7 +178,7 @@ function handleEmoteUsage(s: string): string { watch( chatModule, (mod) => { - if (!mod.instance) return; + if (!mod || !mod.instance) return; mod.instance.messageSendMiddleware.set("emote-menu-usage", handleEmoteUsage); }, diff --git a/src/site/twitch.tv/modules/mod-logs/ModLogsModule.vue b/src/site/twitch.tv/modules/mod-logs/ModLogsModule.vue index 1e01e166a..5b74c0455 100644 --- a/src/site/twitch.tv/modules/mod-logs/ModLogsModule.vue +++ b/src/site/twitch.tv/modules/mod-logs/ModLogsModule.vue @@ -32,10 +32,10 @@ const com = ref | undefined>(); await until(dependenciesMet).toBe(true); -const inputController = getModule("chat-input-controller"); +const inputController = getModule<"TWITCH", "chat-input-controller">("chat-input-controller"); if (!inputController?.instance) throw new Error("ChatInputController not found"); -const chatController = getModule("chat"); +const chatController = getModule<"TWITCH", "chat">("chat"); if (!chatController?.instance) throw new Error("ChatController not found"); // insert button diff --git a/src/types/app.d.ts b/src/types/app.d.ts index aacd4e64f..3547032b7 100644 --- a/src/types/app.d.ts +++ b/src/types/app.d.ts @@ -337,11 +337,17 @@ declare interface YouTubeIdentity { username: string; } -declare type Platform = "TWITCH" | "YOUTUBE" | "UNKNOWN"; +declare interface KickIdentity { + id: string; + username: string; +} + +declare type Platform = "TWITCH" | "YOUTUBE" | "KICK" | "UNKNOWN"; declare type PlatformIdentity = { TWITCH: TwitchIdentity; YOUTUBE: YouTubeIdentity; + KICK: KickIdentity; UNKNOWN: null; }[T]; diff --git a/src/types/kick.module.d.ts b/src/types/kick.module.d.ts new file mode 100644 index 000000000..588a41392 --- /dev/null +++ b/src/types/kick.module.d.ts @@ -0,0 +1,7 @@ +import ChatModuleVue from "@/site/kick.com/modules/chat/ChatModule.vue"; + +declare type KickModuleID = keyof KickModuleComponentMap; + +declare type KickModuleComponentMap = { + chat: typeof ChatModuleVue; +}; From 1fa464cb98d39de17b27089f123324ff57b38987 Mon Sep 17 00:00:00 2001 From: Anatole Date: Sun, 14 May 2023 20:36:49 +0200 Subject: [PATCH 06/24] User Card (#566) --- .stylelintrc | 3 + .vscode/settings.json | 3 +- CHANGELOG-nightly.md | 1 + locale/en_US.yaml | 63 +- src/assets/gql/tw.chat-bans.gql.ts | 9 +- src/assets/gql/tw.chat-replies.gql.ts | 90 +-- src/assets/gql/tw.emote-card.gql.ts | 134 +--- src/assets/gql/tw.fragment.gql.ts | 289 ++++++++ src/assets/gql/tw.gql.d.ts | 56 +- src/assets/gql/tw.mod-user.gql.ts | 64 ++ src/assets/gql/tw.user-card.gql.ts | 507 ++++++++++++++ src/assets/style/global.scss | 4 +- src/assets/svg/icons/DeleteIcon.vue | 6 + src/assets/svg/icons/GavelIcon.vue | 20 + .../svg/icons/IconUpRightFromSquare.vue | 8 + src/assets/svg/icons/ShieldIcon.vue | 19 + src/common/Transform.ts | 40 +- src/composable/channel/useChannelContext.ts | 6 + src/composable/chat/useChatModeration.ts | 23 +- src/composable/chat/useChatProperties.ts | 4 - src/content/content.ts | 2 +- .../views/Onboarding/OnboardingEnd.vue | 7 +- .../views/Onboarding/OnboardingPromo.vue | 12 +- src/site/global/FloatContext.vue | 2 +- src/site/global/settings/SettingsNode.vue | 12 +- .../twitch.tv/modules/chat-vod/ChatVod.vue | 4 +- .../twitch.tv/modules/chat/ChatController.vue | 14 +- src/site/twitch.tv/modules/chat/ChatList.vue | 8 +- .../twitch.tv/modules/chat/ChatModule.vue | 8 +- .../modules/chat/components/message/Emote.vue | 3 +- .../chat/components/message/UserMessage.vue | 14 +- .../components/message/UserMessageButtons.vue | 2 +- .../chat/components/message/parts/Mention.vue | 29 +- .../modules/chat/components/mod/ModSlider.vue | 4 +- .../types/EmoteSetUpdateMessage.vue | 2 +- .../components/{message => user}/Badge.vue | 0 .../{message => user}/BadgeTooltip.vue | 0 .../modules/chat/components/user/UserCard.vue | 626 ++++++++++++++++++ .../chat/components/user/UserCardActions.vue | 30 + .../components/user/UserCardMessageList.vue | 233 +++++++ .../chat/components/user/UserCardMod.vue | 131 ++++ .../chat/components/user/UserCardTabs.vue | 86 +++ .../components/{message => user}/UserTag.vue | 66 +- .../mod-logs/ModLogsRecentActionsItem.vue | 2 +- src/ui/UiDraggable.vue | 3 +- src/ui/UiFloating.vue | 11 +- src/ui/UiScrollable.vue | 1 + 47 files changed, 2345 insertions(+), 316 deletions(-) create mode 100644 src/assets/gql/tw.fragment.gql.ts create mode 100644 src/assets/gql/tw.mod-user.gql.ts create mode 100644 src/assets/gql/tw.user-card.gql.ts create mode 100644 src/assets/svg/icons/DeleteIcon.vue create mode 100644 src/assets/svg/icons/GavelIcon.vue create mode 100644 src/assets/svg/icons/IconUpRightFromSquare.vue create mode 100644 src/assets/svg/icons/ShieldIcon.vue rename src/site/twitch.tv/modules/chat/components/{message => user}/Badge.vue (100%) rename src/site/twitch.tv/modules/chat/components/{message => user}/BadgeTooltip.vue (100%) create mode 100644 src/site/twitch.tv/modules/chat/components/user/UserCard.vue create mode 100644 src/site/twitch.tv/modules/chat/components/user/UserCardActions.vue create mode 100644 src/site/twitch.tv/modules/chat/components/user/UserCardMessageList.vue create mode 100644 src/site/twitch.tv/modules/chat/components/user/UserCardMod.vue create mode 100644 src/site/twitch.tv/modules/chat/components/user/UserCardTabs.vue rename src/site/twitch.tv/modules/chat/components/{message => user}/UserTag.vue (67%) diff --git a/.stylelintrc b/.stylelintrc index ed0625594..6624b6cc8 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -3,6 +3,9 @@ extends: - "stylelint-config-standard" rules: color-function-notation: "legacy" + declaration-block-no-redundant-longhand-properties: + - true + - ignoreShorthands: ["/grid/"] ignoreFiles: ["locale/*.ts"] overrides: - files: ["**/*.scss"] diff --git a/.vscode/settings.json b/.vscode/settings.json index 3a6e910c6..ef817e711 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,8 @@ "stylelint.configBasedir": "${workspaceFolder}", "stylelint.config": { "rules": { - "color-function-notation": "legacy" + "color-function-notation": "legacy", + "declaration-block-no-redundant-longhand-properties": null }, "overrides": [ { diff --git a/CHANGELOG-nightly.md b/CHANGELOG-nightly.md index fe343ffe7..06aa9e486 100644 --- a/CHANGELOG-nightly.md +++ b/CHANGELOG-nightly.md @@ -3,6 +3,7 @@ **The changes listed here are not assigned to an official release**. - Added early experimental functionality for a new site: [kick.com](https://kick.com/) +- Added a new User Card - Added a tooltip to show the full message when hovering over replies in chat - Added a "Site Layout" menu where certain features of the Twitch website can be hidden - Added Emojis for the emoji groups diff --git a/locale/en_US.yaml b/locale/en_US.yaml index ac3228f13..832f0c7ad 100644 --- a/locale/en_US.yaml +++ b/locale/en_US.yaml @@ -1,5 +1,52 @@ locale: English (United States) +# Chat Messages +# System strings +messages: + anon_timeout_user: "{user} was timed out for {duration}" + anon_ban_user: "{user} was permanently banned" + mod_timeout_user: "{actor} timed out {victim} for {duration}" + mod_timeout_user_reason: "{actor} timed out {victim} for {duration} ({reason})" + mod_undo_timeout_user: "{actor} removed timeout on {victim}" + mod_ban_user: "{actor} banned {victim}" + mod_ban_user_reason: "{actor} banned {victim} ({reason})" + mod_undo_ban_user: "{actor} unbanned {victim}" + +user_card: + add_comment_input_placeholder: Add a comment + account_created_date: Account created on {date} + following_since_date: Following since {date} + subscription_tier: Tier {tier} + subscription_length: Subscribed for {length} months + native: Open Native User Card + no_messages: "{user} has not chatted here" + no_timeouts: "{user} hasn't been timed out before" + no_bans: "{user} hasn't been banned before" + no_comments: "No mod comments have been written for {user}" + timeout_button: "{duration} timeout" + ban_button: Ban + unban_button: Unban + mod_button: Mod + unmod_button: Unmod + +# Emote Menu +# This interface allows the user to pick from available emotes +emote_menu: + native: Open Native Menu + favorite_set: Favorite Emotes + most_used_set: Most Used Emotes + sets: + Global Emotes: Global Emotes + Smileys & Emotion: Smileys & Emotion + People & Body: People & Body + Animals & Nature: Animals & Nature + Food & Drink: Food & Drink + Travel & Places: Travel & Places + Activities: Activities + Objects: Objects + Symbols: Symbols + Flags: Flags + # Onboarding Page # A page that is shown to new users to help them get started with 7TV # @@ -93,19 +140,3 @@ onboarding: button_done: Done button_join: Join button_review: Review - -emote_menu: - native: Open Native Menu - favorite_set: Favorite Emotes - most_used_set: Most Used Emotes - sets: - Global Emotes: Global Emotes - Smileys & Emotion: Smileys & Emotion - People & Body: People & Body - Animals & Nature: Animals & Nature - Food & Drink: Food & Drink - Travel & Places: Travel & Places - Activities: Activities - Objects: Objects - Symbols: Symbols - Flags: Flags diff --git a/src/assets/gql/tw.chat-bans.gql.ts b/src/assets/gql/tw.chat-bans.gql.ts index e99e312da..e5a4ba90b 100644 --- a/src/assets/gql/tw.chat-bans.gql.ts +++ b/src/assets/gql/tw.chat-bans.gql.ts @@ -1,3 +1,4 @@ +import { TwTypeChatBanStatus } from "./tw.gql"; import gql from "graphql-tag"; export const twitchBanUserQuery = gql` @@ -38,9 +39,11 @@ export namespace twitchBanUserQuery { }; } export interface Result { - ban: null | object; - error: null | { - code: string; + banUserFromChatRoom: { + ban: null | TwTypeChatBanStatus; + error: null | { + code: string; + }; }; } } diff --git a/src/assets/gql/tw.chat-replies.gql.ts b/src/assets/gql/tw.chat-replies.gql.ts index cec42d618..7bb734609 100644 --- a/src/assets/gql/tw.chat-replies.gql.ts +++ b/src/assets/gql/tw.chat-replies.gql.ts @@ -1,3 +1,4 @@ +import { twitchMessageFragments } from "./tw.fragment.gql"; import gql from "graphql-tag"; export const twitchChatReplyQuery = gql` @@ -13,92 +14,5 @@ export const twitchChatReplyQuery = gql` } } - fragment messageFields on Message { - id - deletedAt - sentAt - content { - ...messageContent - } - sender { - ...messageSender - } - __typename - } - - fragment messageContent on MessageContent { - text - fragments { - ...messageParticle - } - __typename - } - fragment messageParticle on MessageFragment { - text - content { - ... on CheermoteToken { - ...cheermoteFragment - } - ... on Emote { - ...emoteFragment - } - ... on User { - ...mentionFragment - } - ... on AutoMod { - ...automodFragment - } - __typename - } - __typename - } - fragment cheermoteFragment on CheermoteToken { - bitsAmount - prefix - tier - __typename - } - fragment emoteFragment on Emote { - emoteID: id - setID - token - __typename - } - fragment mentionFragment on User { - id - login - displayName - __typename - } - fragment automodFragment on AutoMod { - topics { - type - weight - __typename - } - __typename - } - - fragment messageSender on User { - id - login - chatColor - displayName - displayBadges(channelID: $channelID) { - ...badge - } - __typename - } - - fragment badge on Badge { - id - setID - version - title - image1x: imageURL(size: NORMAL) - image2x: imageURL(size: DOUBLE) - image4x: imageURL(size: QUADRUPLE) - clickAction - clickURL - } + ${twitchMessageFragments} `; diff --git a/src/assets/gql/tw.emote-card.gql.ts b/src/assets/gql/tw.emote-card.gql.ts index 2b9510251..2d2742658 100644 --- a/src/assets/gql/tw.emote-card.gql.ts +++ b/src/assets/gql/tw.emote-card.gql.ts @@ -1,3 +1,4 @@ +import { twitchSubProductFragment, twitchSubProductsFragments } from "./tw.fragment.gql"; import { TwTypeEmote } from "./tw.gql"; import { gql } from "graphql-tag"; @@ -76,136 +77,9 @@ export const emoteCardQuery = gql` type } } - fragment subSummary on SubscriptionSummary { - id - name - offers { - id - currency - exponent - price - promoDescription - } - emotes { - id - token - subscriptionTier - } - url - tier - modifiers { - code - name - subscriptionTier - } - self { - subscribedTier - cumulativeTenure - } - } - fragment subProduct on SubscriptionProduct { - id - url - price - name - tier - interval { - unit - } - state - emotes { - id - setID - token - } - offers { - ...subProductOfferFragment - } - emoteModifiers { - ...subscriptionProductEmoteModifier - } - self { - cumulativeTenure: subscriptionTenure(tenureMethod: CUMULATIVE) { - months - } - benefit { - id - tier - } - } - owner { - id - displayName - login - subscriptionProducts { - id - tier - url - price - emotes { - id - token - } - emoteModifiers { - ...subscriptionProductEmoteModifier - } - } - stream { - id - type - } - } - } - fragment subProductOfferFragment on Offer { - id - tplr - platform - eligibility { - benefitsStartAt - isEligible - } - giftType - listing { - chargeModel { - internal { - previewPrice { - id - currency - exponent - price - total - discount { - price - total - } - } - plan { - interval { - duration - unit - } - } - } - } - } - promotion { - id - name - promoDisplay { - discountPercent - discountType - } - priority - } - quantity { - min - max - } - } - fragment subscriptionProductEmoteModifier on EmoteModifier { - code - name - } + + ${twitchSubProductFragment} + ${twitchSubProductsFragments} `; export namespace emoteCardQuery { diff --git a/src/assets/gql/tw.fragment.gql.ts b/src/assets/gql/tw.fragment.gql.ts new file mode 100644 index 000000000..f9d1af333 --- /dev/null +++ b/src/assets/gql/tw.fragment.gql.ts @@ -0,0 +1,289 @@ +import gql from "graphql-tag"; + +export const twitchBadgeFragment = gql` + fragment badge on Badge { + id + setID + version + title + image1x: imageURL(size: NORMAL) + image2x: imageURL(size: DOUBLE) + image4x: imageURL(size: QUADRUPLE) + clickAction + clickURL + } +`; + +export const twitchMessageSenderFragment = gql` + fragment messageSender on User { + id + login + chatColor + displayName + displayBadges(channelID: $channelID) { + ...badge + } + __typename + } + + ${twitchBadgeFragment} +`; + +export const twitchMessageFragments = gql` + fragment messageFields on Message { + id + deletedAt + sentAt + content { + ...messageContent + } + sender { + ...messageSender + } + __typename + } + + fragment messageContent on MessageContent { + text + fragments { + ...messageParticle + } + __typename + } + fragment messageParticle on MessageFragment { + text + content { + ... on CheermoteToken { + ...cheermoteFragment + } + ... on Emote { + ...emoteFragment + } + ... on User { + ...mentionFragment + } + ... on AutoMod { + ...automodFragment + } + __typename + } + __typename + } + fragment cheermoteFragment on CheermoteToken { + bitsAmount + prefix + tier + __typename + } + fragment emoteFragment on Emote { + emoteID: id + setID + token + __typename + } + fragment mentionFragment on User { + id + login + displayName + __typename + } + fragment automodFragment on AutoMod { + topics { + type + weight + __typename + } + __typename + } + + ${twitchMessageSenderFragment} + ${twitchBadgeFragment} +`; + +export const twitchSubProductsFragments = gql` + fragment subSummary on SubscriptionSummary { + id + name + offers { + id + currency + exponent + price + promoDescription + } + emotes { + id + token + subscriptionTier + } + url + tier + modifiers { + code + name + subscriptionTier + } + self { + subscribedTier + cumulativeTenure + } + } +`; + +export const twitchSubSummaryFragment = gql` + fragment subSummary on SubscriptionSummary { + id + name + offers { + id + currency + exponent + price + promoDescription + } + emotes { + id + token + subscriptionTier + } + url + tier + modifiers { + code + name + subscriptionTier + } + self { + subscribedTier + cumulativeTenure + } + } +`; + +export const twitchSubProductFragment = gql` + fragment subProduct on SubscriptionProduct { + id + url + price + name + tier + interval { + unit + } + state + emotes { + id + setID + token + } + offers { + id + tplr + platform + eligibility { + benefitsStartAt + isEligible + } + giftType + listing { + chargeModel { + internal { + previewPrice { + id + currency + exponent + price + total + discount { + price + total + } + } + plan { + interval { + duration + unit + } + } + } + } + } + promotion { + id + name + promoDisplay { + discountPercent + discountType + } + priority + } + quantity { + min + max + } + } + emoteModifiers { + ...subscriptionProductEmoteModifier + } + self { + cumulativeTenure: subscriptionTenure(tenureMethod: CUMULATIVE) { + months + } + benefit { + id + tier + } + } + owner { + id + displayName + login + subscriptionProducts { + id + tier + url + price + emotes { + id + token + } + emoteModifiers { + ...subscriptionProductEmoteModifier + } + } + stream { + id + type + } + } + } + + fragment subscriptionProductEmoteModifier on EmoteModifier { + code + name + } +`; + +export const twitchModCommentFragment = gql` + fragment modComment on ModLogsComment { + id + timestamp + text + author { + ...modCommentUser + } + channel { + ...modCommentUser + } + target { + ...modCommentUser + } + } + + fragment modCommentUser on User { + id + login + displayName + chatColor + } +`; diff --git a/src/assets/gql/tw.gql.d.ts b/src/assets/gql/tw.gql.d.ts index 8933a6cac..3e590a763 100644 --- a/src/assets/gql/tw.gql.d.ts +++ b/src/assets/gql/tw.gql.d.ts @@ -17,10 +17,29 @@ export interface TwTypeUser { displayBadges: TwTypeBadge[]; chatColor: string; profileImageURL: string; - stream: unknown; + bannerImageURL?: string; channel: TwTypeChannel; self: TwTypeUserSelfConnection; blockedUsers: TwTypeUser[]; + moderationSettings: { + canAccessViewerCardModLogs: boolean; + }; + isModerator: boolean; + relationship?: { + followedAt: string; + cumulativeTenure: null | string; + subscriptionBenefits: null | string; + }; + stream?: TwTypeStream; +} + +export interface TwTypeStream { + id: string; + viewersCount: number; + game?: { + id: string; + displayName: string; + }; } export interface TwTypeBadge { @@ -51,6 +70,41 @@ export interface TwTypeMessage { }; } +export interface TwTypeModComment { + id: string; + author: TwTypeUser; + channel: TwTypeUser; + text: string; + target: TwTypeUser; + timestamp: string; +} + +export interface TwTypeModEntry { + id: string; + action: "TIMEOUT_USER" | "UNTIMEOUT_USER" | "BAN_USER" | "UNBAN_USER"; + details: TwTypeModActionDetails; + timestamp: string; + channel: Pick; + target: Pick; + user: Pick; +} + +export interface TwTypeModActionDetails { + bannedAt: string; + durationSeconds: number; + expiresAt: string; + reason: string | null; +} + +export interface TwTypeChatBanStatus { + bannedUser: TwTypeUser; + createdAt: string; + expiresAt: string; + isPermanent: boolean; + moderator: TwTypeUser; + reason: string; +} + export interface TwTypeChannel { id: string; localEmoteSets: TwTypeEmoteSet[]; diff --git a/src/assets/gql/tw.mod-user.gql.ts b/src/assets/gql/tw.mod-user.gql.ts new file mode 100644 index 000000000..5b2427368 --- /dev/null +++ b/src/assets/gql/tw.mod-user.gql.ts @@ -0,0 +1,64 @@ +import { TwTypeUser } from "./tw.gql"; +import gql from "graphql-tag"; + +export const twitchModUserMut = gql` + mutation ModUser($input: ModUserInput!) { + result: modUser(input: $input) { + channel { + id + login + } + target { + id + login + } + error { + code + } + } + } +`; + +export const twitchUnmodUserMut = gql` + mutation UnmodUser($input: UnmodUserInput!) { + result: unmodUser(input: $input) { + channel { + id + login + } + target { + id + login + } + error { + code + } + } + } +`; + +export namespace ModOrUnmodUser { + export interface Variables { + input: { + channelID: string; + targetID?: string; + targetLogin?: string; + }; + } + + export interface Response { + result: { + channel: Pick; + target: Pick; + error: { + code: + | "FORBIDDEN" + | "TARGET_NOT_FOUND" + | "CHANNEL_NOT_FOUND" + | "TARGET_NOT_MOD" + | "TARGET_ALREADY_MOD" + | "TARGET_IS_CHAT_BANNED"; + }; + }; + } +} diff --git a/src/assets/gql/tw.user-card.gql.ts b/src/assets/gql/tw.user-card.gql.ts new file mode 100644 index 000000000..4912b5f26 --- /dev/null +++ b/src/assets/gql/tw.user-card.gql.ts @@ -0,0 +1,507 @@ +import { twitchBadgeFragment, twitchModCommentFragment, twitchSubProductFragment } from "./tw.fragment.gql"; +import { + TwTypeBadge, + TwTypeChatBanStatus, + TwTypeMessage, + TwTypeModComment, + TwTypeModEntry, + TwTypeUser, +} from "./tw.gql"; +import gql from "graphql-tag"; + +export const twitchUserCardQuery = gql` + query ViewerCard( + $channelID: ID! + $channelIDStr: String! + $channelLogin: String! + $targetLogin: String! + $isViewerBadgeCollectionEnabled: Boolean! + ) { + activeTargetUser: user(login: $targetLogin) { + id + } + targetUser: user(login: $targetLogin, lookupType: ALL) { + id + login + bannerImageURL + displayName + displayBadges(channelID: $channelID) { + ...badge + description + } + profileImageURL(width: 70) + createdAt + relationship(targetUserID: $channelID) { + cumulativeTenure: subscriptionTenure(tenureMethod: CUMULATIVE) { + months + daysRemaining + } + followedAt + subscriptionBenefit { + id + tier + purchasedWithPrime + gift { + isGift + } + } + } + isModerator(channelID: $channelIDStr) + stream { + id + game { + id + displayName + } + viewersCount + } + } + channelUser: user(login: $channelLogin) { + id + login + displayName + subscriptionProducts { + ...subProduct + } + self { + banStatus { + isPermanent + } + isModerator + } + } + currentUser { + login + id + } + channelViewer(userLogin: $targetLogin, channelLogin: $channelLogin) { + id + earnedBadges @include(if: $isViewerBadgeCollectionEnabled) { + ...badge + description + } + } + channel(id: $channelID) { + id + moderationSettings { + canAccessViewerCardModLogs + } + } + } + + ${twitchSubProductFragment} + ${twitchBadgeFragment} +`; + +export namespace twitchUserCardQuery { + export interface Variables { + channelID: string; + channelIDStr: string; + channelLogin: string; + targetLogin: string; + isViewerBadgeCollectionEnabled: boolean; + withStandardGifting: boolean; + } + + export interface Response { + activeTargetUser: Pick; + targetUser: TwTypeUser; + channelUser: Pick & { + subscriptionProducts: { + id: string; + displayName: string; + tier: string; + name: string; + url: string; + emotes: { + id: string; + token: string; + }[]; + priceInfo: { + id: string; + currency: string; + price: number; + }; + }[]; + self: { + banStatus: { + isPermanent: boolean; + }; + isModerator: boolean; + }; + }; + currentUser: { + login: string; + id: string; + blockedUsers: { + id: string; + }[]; + }; + channelViewer: { + id: string; + earnedBadges: TwTypeBadge[]; + }; + channel: Pick; + } +} + +export const twitchUserCardModLogsQuery = gql` + query ViewerCardModLogs($channelLogin: String!, $channelID: ID!, $targetID: ID!) { + targetUser: user(id: $targetID) { + id + login + } + channelUser: user(login: $channelLogin) { + id + login + modLogs { + ...modLogs + } + } + currentUser { + login + id + } + banStatus: chatRoomBanStatus(channelID: $channelID, userID: $targetID) { + bannedUser { + id + login + displayName + } + createdAt + expiresAt + isPermanent + moderator { + id + login + displayName + } + reason + } + viewerCardModLogs(targetID: $targetID, channelID: $channelID) { + comments(first: 100) { + ... on ModLogsCommentConnection { + edges { + cursor + node { + ...modComment + } + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + } + } + + fragment modLogs on ModLogs { + messages: messagesBySender( + senderID: $targetID + first: 1 + includeMessageCount: true + includeTargetedActions: true + includeAutoModCaughtMessages: true + ) { + messageCount + } + bans: targetedModActions(targetID: $targetID, actionType: BAN_USER) { + edges { + cursor + node { + ...targetedModAction + } + } + actionCount + pageInfo { + hasNextPage + hasPreviousPage + } + } + timeouts: targetedModActions(targetID: $targetID, actionType: TIMEOUT_USER) { + edges { + cursor + node { + ...targetedModAction + } + } + actionCount + pageInfo { + hasNextPage + hasPreviousPage + } + } + } + + fragment targetedModAction on ModLogsTargetedModActionsEntry { + id + action + timestamp + channel { + id + login + } + target { + id + login + } + user { + id + login + } + details { + ...targetedModActionDetails + } + } + + fragment targetedModActionDetails on TargetedModActionDetails { + bannedAt + durationSeconds + expiresAt + reason + } + + ${twitchModCommentFragment} +`; + +export namespace twitchUserCardModLogsQuery { + export interface Variables { + channelID: string; + channelLogin: string; + targetID: string; + } + + export interface Response { + targetUser: { + id: string; + login: string; + }; + channelUser: { + id: string; + login: string; + modLogs: { + messages: { + messageCount: number; + }; + bans: { + edges: { + cursor: string; + node: TwTypeModEntry; + }[]; + actionCount: number; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }; + timeouts: { + edges: { + cursor: string; + node: TwTypeModEntry; + }[]; + actionCount: number; + pageInfo: { + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }; + }; + }; + currentUser: { + login: string; + id: string; + }; + banStatus: TwTypeChatBanStatus; + viewerCardModLogs: { + comments: { + edges: { + cursor: string; + node: TwTypeModComment; + }[]; + }; + }; + } +} + +export const twitchUserCardMessagesQuery = gql` + query UserCardMessagesBySender($senderID: ID!, $channelLogin: String!, $cursor: Cursor) { + channel: user(login: $channelLogin) { + id + logs: modLogs { + bySender: messagesBySender( + senderID: $senderID + first: 50 + order: DESC + includeMessageCount: false + includeTargetedActions: true + includeAutoModCaughtMessages: true + after: $cursor + ) { + edges { + cursor + node { + ...modLogsMessageFields + ...autoModCaughtMessage + ...targetedModAction + } + } + pageInfo { + hasNextPage + } + } + } + } + } + + fragment modLogsMessageFields on ModLogsMessage { + id + sentAt + sender { + ...sender + } + content { + text + } + } + + fragment autoModCaughtMessage on AutoModCaughtMessage { + id + category + modLogsMessage { + id + sentAt + content { + text + } + sender { + ...sender + } + } + resolvedAt + resolver { + ...sender + } + status + } + + fragment targetedModAction on ModLogsTargetedModActionsEntry { + id + action + timestamp + channel { + id + login + } + target { + id + login + } + user { + id + login + } + details { + ...targetedModActionDetails + } + } + + fragment targetedModActionDetails on TargetedModActionDetails { + bannedAt + durationSeconds + expiresAt + reason + } + + fragment sender on User { + id + login + displayName + chatColor + displayBadges { + ...badge + } + } + + ${twitchBadgeFragment} +`; + +export namespace twitchUserCardMessagesQuery { + export interface Variables { + senderID: string; + channelLogin: string; + cursor?: string; + } + + export interface Response { + channel: { + id: string; + logs: { + bySender: { + edges: { + cursor: string; + node: TwTypeMessage; + }[]; + pageInfo: { + hasNextPage: boolean; + }; + }; + }; + }; + } +} + +export const twitchUserCardCreateModCommentMut = gql` + mutation createModComment($input: CreateModeratorCommentInput!) { + createModeratorComment(input: $input) { + comment { + ...modComment + } + } + } + + ${twitchModCommentFragment} +`; + +export const twitchUserCardDeleteModCommentMut = gql` + mutation deleteModeratorComment($input: DeleteModeratorCommentInput!) { + deleteModeratorComment(input: $input) { + comment { + ...modComment + } + } + } + + ${twitchModCommentFragment} +`; + +export namespace twitchUserCardCreateModCommentMut { + export interface Variables { + input: { + channelID: string; + targetID: string; + text: string; + }; + } + + export interface Response { + createModeratorComment: { + comment: TwTypeModComment; + }; + } +} + +export namespace twitchUserCardDeleteModCommentMut { + export interface Variables { + input: { + ID: string; + channelID: string; + }; + } + + export interface Response { + deleteModeratorComment: { + comment: TwTypeModComment; + }; + } +} diff --git a/src/assets/style/global.scss b/src/assets/style/global.scss index 9b988c2a0..416651439 100644 --- a/src/assets/style/global.scss +++ b/src/assets/style/global.scss @@ -19,7 +19,7 @@ body[seventv-kick="true"] { --seventv-embed-background-highlight: #26262b; --seventv-embed-border: #121214; --seventv-highlight-neutral-1: #8080803b; - --seventv-background-transparent-1: #161616; + --seventv-background-transparent-1: #171717; --seventv-background-lesser-transparent-1: #191919; --seventv-background-transparent-2: #131313; --seventv-background-transparent-3: #111; @@ -33,7 +33,7 @@ body[seventv-kick="true"] { --seventv-muted: #999; &.seventv-transparent { - --seventv-background-transparent-1: #161616c4; + --seventv-background-transparent-1: #171717c4; --seventv-background-lesser-transparent-1: #161616f2; --seventv-background-transparent-2: #13131397; --seventv-background-transparent-3: #111111b5; diff --git a/src/assets/svg/icons/DeleteIcon.vue b/src/assets/svg/icons/DeleteIcon.vue new file mode 100644 index 000000000..ccc5ea099 --- /dev/null +++ b/src/assets/svg/icons/DeleteIcon.vue @@ -0,0 +1,6 @@ + diff --git a/src/assets/svg/icons/GavelIcon.vue b/src/assets/svg/icons/GavelIcon.vue new file mode 100644 index 000000000..9dff43409 --- /dev/null +++ b/src/assets/svg/icons/GavelIcon.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/assets/svg/icons/IconUpRightFromSquare.vue b/src/assets/svg/icons/IconUpRightFromSquare.vue new file mode 100644 index 000000000..42d3ee282 --- /dev/null +++ b/src/assets/svg/icons/IconUpRightFromSquare.vue @@ -0,0 +1,8 @@ + diff --git a/src/assets/svg/icons/ShieldIcon.vue b/src/assets/svg/icons/ShieldIcon.vue new file mode 100644 index 000000000..38d1f93a3 --- /dev/null +++ b/src/assets/svg/icons/ShieldIcon.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/common/Transform.ts b/src/common/Transform.ts index d5811731b..00b71ad05 100644 --- a/src/common/Transform.ts +++ b/src/common/Transform.ts @@ -1,4 +1,4 @@ -import { TwTypeMessage, TwTypeUser } from "@/assets/gql/tw.gql"; +import { TwTypeBadge, TwTypeMessage, TwTypeUser } from "@/assets/gql/tw.gql"; import { imageHostToSrcset } from "./Image"; import { ChatMessage, ChatUser } from "./chat/ChatMessage"; @@ -318,14 +318,40 @@ export function convertFfzBadges(data: FFZ.BadgesResponse): SevenTV.Cosmetic<"BA return badges; } +export function convertTwitchBadge(data: TwTypeBadge): SevenTV.Cosmetic<"BADGE"> { + const sp = data.image1x.slice(6).split("/"); + const baseURL = sp.slice(0, sp.length - 1).join("/"); + + return { + id: data.setID + ":" + data.version, + kind: "BADGE", + provider: "TWITCH", + data: { + name: data.title, + host: { + url: baseURL, + files: [ + { name: "1", format: "PNG" }, + { name: "2", format: "PNG" }, + { name: "3", format: "PNG" }, + ], + }, + tooltip: data.title, + }, + }; +} + export function convertTwitchMessage(d: TwTypeMessage): ChatMessage { const msg = new ChatMessage(d.id); - msg.body = d.content.text; - msg.author = convertTwitchUser(d.sender); - msg.badges = d.sender.displayBadges.reduce((con, badge) => { - con[badge.setID] = badge.version; - return con; - }, {} as Record); + msg.body = d.content?.text ?? ""; + msg.author = d.sender ? convertTwitchUser(d.sender) : null; + msg.badges = + d.sender && Array.isArray(d.sender.displayBadges) + ? d.sender.displayBadges.reduce((con, badge) => { + con[badge.setID] = badge.version; + return con; + }, {} as Record) + : {}; msg.timestamp = new Date(d.sentAt).getTime(); return msg; diff --git a/src/composable/channel/useChannelContext.ts b/src/composable/channel/useChannelContext.ts index fd25f4bc4..80c6f306e 100644 --- a/src/composable/channel/useChannelContext.ts +++ b/src/composable/channel/useChannelContext.ts @@ -7,6 +7,8 @@ export const CHANNEL_CTX = Symbol("seventv-channel-context"); const { sendMessage, target } = useWorker(); +export type ChannelRole = "BROADCASTER" | "EDITOR" | "MODERATOR" | "VIP" | "SUBSCRIBER" | "FOLLOWER"; + export class ChannelContext { platform: Platform = "UNKNOWN"; id = ""; @@ -15,6 +17,10 @@ export class ChannelContext { user: SevenTV.User | null = null; loaded = false; + actor = { + roles: new Set(), + }; + setCurrentChannel(channel: CurrentChannel): boolean { if (this.id === channel.id) { this.username = channel.username; diff --git a/src/composable/chat/useChatModeration.ts b/src/composable/chat/useChatModeration.ts index bb8e23fb0..378cb66a7 100644 --- a/src/composable/chat/useChatModeration.ts +++ b/src/composable/chat/useChatModeration.ts @@ -1,5 +1,6 @@ import { twitchBanUserQuery, twitchUnbanUserQuery } from "@/assets/gql/tw.chat-bans.gql"; import { twitchPinMessageQuery } from "@/assets/gql/tw.chat-pin.gql"; +import { ModOrUnmodUser, twitchModUserMut, twitchUnmodUserMut } from "@/assets/gql/tw.mod-user.gql"; import { useChatMessages } from "./useChatMessages"; import { ChannelContext } from "../channel/useChannelContext"; import { useApollo } from "../useApollo"; @@ -17,7 +18,7 @@ export function useChatModeration(ctx: ChannelContext, victim: string) { */ function banUserFromChat(expiresIn: string | null, reason?: string) { const apollo = useApollo(); - if (!apollo) return null; + if (!apollo) return Promise.reject("Missing Apollo"); return apollo.mutate({ mutation: twitchBanUserQuery, @@ -40,7 +41,7 @@ export function useChatModeration(ctx: ChannelContext, victim: string) { */ function unbanUserFromChat() { const apollo = useApollo(); - if (!apollo) return null; + if (!apollo) return Promise.reject("Missing Apollo"); return apollo.mutate({ mutation: twitchUnbanUserQuery, @@ -55,7 +56,7 @@ export function useChatModeration(ctx: ChannelContext, victim: string) { function pinChatMessage(msgID: string, duration: number) { const apollo = useApollo(); - if (!apollo) return null; + if (!apollo) return Promise.reject("Missing Apollo"); return apollo.mutate({ mutation: twitchPinMessageQuery, @@ -70,6 +71,21 @@ export function useChatModeration(ctx: ChannelContext, victim: string) { }); } + function setUserModerator(victimID: string, mod: boolean) { + const apollo = useApollo(); + if (!apollo) return Promise.reject("Missing Apollo"); + + return apollo.mutate({ + mutation: !mod ? twitchModUserMut : twitchUnmodUserMut, + variables: { + input: { + channelID: ctx.id, + targetID: victimID, + }, + }, + }); + } + function deleteChatMessage(msgID: string) { messages.sendMessage(`/delete ${msgID}`); } @@ -79,5 +95,6 @@ export function useChatModeration(ctx: ChannelContext, victim: string) { unbanUserFromChat, pinChatMessage, deleteChatMessage, + setUserModerator, }; } diff --git a/src/composable/chat/useChatProperties.ts b/src/composable/chat/useChatProperties.ts index a3f9718ce..45da0e08e 100644 --- a/src/composable/chat/useChatProperties.ts +++ b/src/composable/chat/useChatProperties.ts @@ -2,8 +2,6 @@ import { reactive } from "vue"; import { ChannelContext } from "../channel/useChannelContext"; interface ChatProperties { - isModerator: boolean; - isVIP: boolean; isDarkTheme: number; primaryColorHex: string | null; useHighContrastColors: boolean; @@ -25,8 +23,6 @@ export function useChatProperties(ctx: ChannelContext) { let data = m.get(ctx); if (!data) { data = reactive({ - isModerator: false, - isVIP: false, isDarkTheme: 1, primaryColorHex: null as string | null, useHighContrastColors: true, diff --git a/src/content/content.ts b/src/content/content.ts index 989794b33..a119aeeba 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -46,7 +46,7 @@ const bc = new BroadcastChannel(APP_BROADCAST_CHANNEL); const btn = document.querySelector(selector); if (!btn) return; - btn.addEventListener("click", () => { + btn.addEventListener("switch", () => { chrome.runtime.sendMessage( { type: "permission-request", diff --git a/src/options/views/Onboarding/OnboardingEnd.vue b/src/options/views/Onboarding/OnboardingEnd.vue index 6e6d47040..206f52dc6 100644 --- a/src/options/views/Onboarding/OnboardingEnd.vue +++ b/src/options/views/Onboarding/OnboardingEnd.vue @@ -134,10 +134,11 @@ export const step: OnboardingStepRoute = { main.onboarding-end { width: 100%; display: grid; - grid-template: + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr; + grid-template-areas: "header header header" - "discord rate social" - ". . ."; + "discord rate social"; place-items: center; > div:not(.header) { diff --git a/src/options/views/Onboarding/OnboardingPromo.vue b/src/options/views/Onboarding/OnboardingPromo.vue index 8610849aa..1d1e04bfa 100644 --- a/src/options/views/Onboarding/OnboardingPromo.vue +++ b/src/options/views/Onboarding/OnboardingPromo.vue @@ -90,10 +90,16 @@ export const step: OnboardingStepRoute = { diff --git a/src/site/twitch.tv/modules/chat/components/message/UserMessageButtons.vue b/src/site/twitch.tv/modules/chat/components/message/UserMessageButtons.vue index a9a3d7c53..d27ea24c1 100644 --- a/src/site/twitch.tv/modules/chat/components/message/UserMessageButtons.vue +++ b/src/site/twitch.tv/modules/chat/components/message/UserMessageButtons.vue @@ -55,11 +55,11 @@ import { useTimeoutFn } from "@vueuse/shared"; import type { ChatMessage } from "@/common/chat/ChatMessage"; import { useFloatScreen } from "@/composable/useFloatContext"; import { useConfig } from "@/composable/useSettings"; +import UserTag from "@/site/twitch.tv/modules/chat/components/user/UserTag.vue"; import CopyIcon from "@/assets/svg/icons/CopyIcon.vue"; import PinIcon from "@/assets/svg/icons/PinIcon.vue"; import ReplyIcon from "@/assets/svg/icons/ReplyIcon.vue"; import TwChatReply from "@/assets/svg/twitch/TwChatReply.vue"; -import UserTag from "./UserTag.vue"; import UiConfirmPrompt from "@/ui/UiConfirmPrompt.vue"; import UiCopiedMessageToast from "@/ui/UiCopiedMessageToast.vue"; import { useTray } from "../tray/ChatTray"; diff --git a/src/site/twitch.tv/modules/chat/components/message/parts/Mention.vue b/src/site/twitch.tv/modules/chat/components/message/parts/Mention.vue index 3560846ba..6d7c49ccd 100644 --- a/src/site/twitch.tv/modules/chat/components/message/parts/Mention.vue +++ b/src/site/twitch.tv/modules/chat/components/message/parts/Mention.vue @@ -1,9 +1,16 @@