Skip to content
Open
862 changes: 433 additions & 429 deletions CHANGELOG-nightly.md

Large diffs are not rendered by default.

71 changes: 42 additions & 29 deletions src/app/chat/Emote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,46 @@
<div
ref="boxRef"
class="seventv-emote-box"
:class="{ 'with-border': withBorder }"
:class="{ 'with-border': withBorder, 'seventv-emote-hidden': hidden }"
:ratio="determineRatio(emote)"
@mouseenter="onShowTooltip"
@mouseleave="hide()"
@click="(ev: MouseEvent) => [onShowEmoteCard(ev), emit('emote-click', ev, emote)]"
>
<img
v-if="!emote.unicode && emote.data && emote.data.host"
class="seventv-chat-emote"
:srcset="unload ? '' : processSrcSet(emote)"
:alt="emote.name"
:class="{ blur: hideUnlisted && emote.data?.listed === false }"
loading="lazy"
decoding="async"
@load="onImageLoad"
/>
<SingleEmoji
v-else-if="!unload && emote.id"
:id="emote.id"
:alt="emote.name"
class="seventv-chat-emote seventv-emoji"
:style="{ width: `${scale * 2}rem`, height: `${scale * 2}rem` }"
@mouseenter="onShowTooltip"
@mouseleave="hide()"
/>

<template v-for="e of overlaid" :key="e.id">
<!-- Blacklisted for this channel: render as plain text instead of the image,
but keep this box's click/hover behavior so it still opens the emote card
with the option to show it again. -->
<span v-if="hidden" class="seventv-emote-hidden-text">{{ emote.name }}</span>
<template v-else>
<img
v-if="e.data && e.data.host"
class="seventv-chat-emote zero-width-emote"
:class="{ blur: hideUnlisted && e.data?.listed === false }"
:srcset="processSrcSet(e)"
:alt="' ' + e.name"
v-if="!emote.unicode && emote.data && emote.data.host"
class="seventv-chat-emote"
:srcset="unload ? '' : processSrcSet(emote)"
:alt="emote.name"
:class="{ blur: hideUnlisted && emote.data?.listed === false }"
loading="lazy"
decoding="async"
@load="onImageLoad"
/>
<SingleEmoji
v-else-if="!unload && emote.id"
:id="emote.id"
:alt="emote.name"
class="seventv-chat-emote seventv-emoji"
:style="{ width: `${scale * 2}rem`, height: `${scale * 2}rem` }"
@mouseenter="onShowTooltip"
@mouseleave="hide()"
/>

<template v-for="e of overlaid" :key="e.id">
<img
v-if="e.data && e.data.host"
class="seventv-chat-emote zero-width-emote"
:class="{ blur: hideUnlisted && e.data?.listed === false }"
:srcset="processSrcSet(e)"
:alt="' ' + e.name"
/>
</template>
</template>

<template v-if="showEmoteCard">
Expand Down Expand Up @@ -77,6 +83,7 @@ const props = withDefaults(
unload?: boolean;
scale?: number;
withBorder?: boolean;
hidden?: boolean;
}>(),
{ unload: false, scale: 1, withBorder: false },
);
Expand All @@ -95,8 +102,9 @@ const imgEl = ref<HTMLImageElement>();

const src = ref("");

const baseWidth = ref(0);
const baseHeight = ref(0);
const initialSize = props.emote.data?.host?.files?.at(-1);
const baseWidth = ref(initialSize?.width ? Math.round(initialSize.width / props.scale) : 0);
const baseHeight = ref(initialSize?.height ? Math.round(initialSize.height / props.scale) : 0);

const onImageLoad = (event: Event) => {
if (!(event.target instanceof HTMLImageElement)) return;
Expand Down Expand Up @@ -151,6 +159,11 @@ onBeforeUnmount(hide);
overflow: clip;
}

.seventv-emote-hidden-text {
cursor: pointer;
color: inherit;
}

.with-border {
background: hsla(0deg, 0%, 50%, 6%);
border-radius: 0.25rem;
Expand Down
6 changes: 6 additions & 0 deletions src/app/chat/UserMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
:format="properties.imageFormat"
:overlaid="token.content.overlaid"
:clickable="true"
:hidden="token.content.hidden"
:scale="Number(emoteScale)"
/>
<span v-if="token.content" :style="{ color: token.content.cheerColor }">
Expand Down Expand Up @@ -102,6 +103,7 @@ import { useChannelContext } from "@/composable/channel/useChannelContext";
import { useChatModeration } from "@/composable/chat/useChatModeration";
import { useChatProperties } from "@/composable/chat/useChatProperties";
import { useChatTools } from "@/composable/chat/useChatTools";
import { useEmoteBlacklist } from "@/composable/chat/useEmoteBlacklist";
import { useCosmetics } from "@/composable/useCosmetics";
import { useConfig } from "@/composable/useSettings";
import type { TimestampFormatKey } from "@/site/twitch.tv/modules/chat/ChatModule.vue";
Expand Down Expand Up @@ -146,6 +148,7 @@ const { openViewerCard } = useChatTools(ctx);
const { pinChatMessage } = useChatModeration(ctx, msg.value.author?.username ?? "");

const emoteScale = useConfig<number>("chat.emote_scale");
const { hiddenEmotes } = useEmoteBlacklist();

// TODO: css variables
const meStyle = useConfig<number>("chat.slash_me_style");
Expand Down Expand Up @@ -176,6 +179,7 @@ function doTokenize() {
emoteMap: props.emotes ?? {},
localEmoteMap: { ...cosmetics.emotes, ...props.msg.nativeEmotes },
showModifiers: showModifiers.value,
hiddenEmotes: hiddenEmotes.value,
});

const result: MessageTokenOrText[] = [];
Expand Down Expand Up @@ -204,6 +208,8 @@ function doTokenize() {
tokens.value = result;
}

watch(hiddenEmotes, () => doTokenize());

function doPinMessage(): void {
pinChatMessage(msg.value.id, 1200)?.catch((err) => log.error("failed to pin chat message", err));
}
Expand Down
6 changes: 6 additions & 0 deletions src/app/settings/SettingsViewConfigCat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
<template v-for="n of nodes" :key="n.key">
<SettingsNode :node="n" :unseen="unseen.has(n.key)" @seen="onMarkAsSeen(n.key)" />
</template>

<!-- The per-channel emote blacklist manages a dynamic list, not a single config
node, so it can't be expressed as a SettingsNode. It's folded into the existing
Chat > Emotes subcategory instead of a separate sidebar entry. -->
<SettingsViewEmoteBlacklist v-if="ctx.category === 'Chat' && name === 'Emotes'" />
</div>
</template>

Expand All @@ -14,6 +19,7 @@ import { ref } from "vue";
import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsMenu } from "./Settings";
import SettingsNode from "./SettingsNode.vue";
import SettingsViewEmoteBlacklist from "./SettingsViewEmoteBlacklist.vue";

const props = defineProps<{
name: string;
Expand Down
224 changes: 224 additions & 0 deletions src/app/settings/SettingsViewEmoteBlacklist.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
<template>
<div class="seventv-settings-emote-blacklist">
<p class="seventv-settings-emote-blacklist-intro">
Hidden 7TV emotes, per channel. Click an emote's hover card in chat and choose
<em>"Hide in this channel"</em> to add one here — the message stays visible, only the emote is swapped for
plain text.
</p>

<div v-if="entries.length === 0" class="seventv-settings-emote-blacklist-empty">
<p>Nothing hidden yet.</p>
</div>

<section v-for="entry in entries" :key="entry.channel" class="seventv-settings-emote-blacklist-channel">
<header>
<h4>
#{{ entry.channel }}
<span class="seventv-settings-emote-blacklist-count">{{ entry.emotes.length }}</span>
</h4>
<UiButton class="danger" @click="clearChannel(entry.channel)">Clear all</UiButton>
</header>

<ul class="seventv-settings-emote-blacklist-list">
<li v-for="name in entry.emotes" :key="name">
<span class="emote-name">{{ name }}</span>
<button
class="remove"
:title="`Show ${name} again in ${entry.channel}`"
@click="removeEmote(entry.channel, name)"
>
<TwClose />
<span>Show again</span>
</button>
</li>
</ul>
</section>

<div v-if="entries.length > 0" class="seventv-settings-emote-blacklist-footer">
<UiButton class="danger" @click="clearAll">Reset everything</UiButton>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useConfig } from "@/composable/useSettings";
import TwClose from "@/assets/svg/twitch/TwClose.vue";
import UiButton from "@/ui/UiButton.vue";

type BlacklistMap = Record<string, string[]>;

const blacklist = useConfig<BlacklistMap>("chat.emote_blacklist_per_channel");

const entries = computed(() =>
Object.entries(blacklist.value ?? {})
.filter(([, list]) => Array.isArray(list) && list.length > 0)
.map(([channel, emotes]) => ({
channel,
emotes: [...emotes].sort((a, b) => a.localeCompare(b)),
}))
.sort((a, b) => a.channel.localeCompare(b.channel)),
);

function writeMap(next: BlacklistMap) {
blacklist.value = next; // reassign to trigger reactivity in settings store
}

function removeEmote(channel: string, name: string) {
const current = { ...(blacklist.value ?? {}) };
const list = (current[channel] ?? []).filter((n) => n !== name);
if (list.length > 0) current[channel] = list;
else delete current[channel];
writeMap(current);
}

function clearChannel(channel: string) {
const current = { ...(blacklist.value ?? {}) };
delete current[channel];
writeMap(current);
}

function clearAll() {
writeMap({});
}
</script>

<style scoped lang="scss">
.seventv-settings-emote-blacklist {
display: flex;
flex-direction: column;
gap: 1rem;
margin: 0.5rem 1rem 1rem;
color: var(--seventv-text-color-normal);

&-intro {
color: var(--seventv-text-color-secondary);
line-height: 1.5;
margin: 0;

em {
color: var(--seventv-text-color-normal);
font-style: normal;
font-weight: 400;
}
}

&-empty {
padding: 1rem 1.25rem;
background: var(--seventv-background-transparent-1);
border: 0.1em dashed var(--seventv-border-transparent-1);
border-radius: 0.4rem;

p {
margin: 0;
color: var(--seventv-text-color-secondary);
font-weight: 600;
}
}

&-channel {
background: var(--seventv-background-transparent-1);
outline: 0.1em solid var(--seventv-border-transparent-1);
border-radius: 0.5rem;
padding: 0.75rem 1rem;

header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.65rem;

h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.05rem;
font-weight: 700;
}
}
}

&-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.35rem;
padding: 0 0.4rem;
border-radius: 999px;
background: var(--seventv-input-background);
outline: 0.08em solid var(--seventv-input-border);
color: var(--seventv-text-color-secondary);
font-size: 0.75rem;
font-weight: 700;
}

&-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;

li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.65rem 0.65rem 1rem;
background: var(--seventv-input-background);
outline: 0.08em solid var(--seventv-input-border);
border-radius: 0.35rem;
transition: outline-color 120ms ease-in-out;

&:hover {
outline-color: var(--seventv-warning);
}

.emote-name {
font-family: var(--seventv-font-monospace, monospace);
font-size: 1.2rem;
font-weight: 700;
color: var(--seventv-text-color-normal);
word-break: break-all;
}

.remove {
all: unset;
flex-shrink: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
color: var(--seventv-text-color-secondary);
transition:
color 120ms ease-in-out,
background 120ms ease-in-out;

svg {
width: 0.7rem;
height: 0.7rem;
}

&:hover {
color: var(--seventv-background-shade-1);
background: var(--seventv-warning);
}
}
}
}

&-footer {
display: flex;
justify-content: flex-end;
}
}
</style>
Loading