mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
4.39.0
* Added: Option to change the size of Message Hover actions. * Added: New chat action appearance type "Emote" that makes it easy to use an emote image for an action. * Changed: Do not show the "Pin" action on messages with no message body. * Changed: Use Twitch's API for embeds/tooltips of Twitch URLs. This now makes use of clip embed data being sent via PubSub, notably. * Fixed: Multiple emotes with the same name being listed in tab-completion. * Experiment: There's a new chat line render method available. This is not currently enabled for any users, but it will be enabled after more internal testing. The new method is not necessarily faster, though it should not be slower. The main purpose of the rewrite is code de-duplication and making the renderer easier to maintain. * API Added: `chat.addLinkProvider(provider);` to register a handler for link data. * API Fixed: Do not allow duplicate registration of tokenizers or rich embed handlers for chat.
This commit is contained in:
parent
4001d15b18
commit
14400e16bc
24 changed files with 2404 additions and 451 deletions
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.38.2",
|
||||
"version": "4.39.0",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
|
|
|
@ -48,6 +48,8 @@ export default class ExperimentManager extends Module {
|
|||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.get = this.getAssignment;
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.settings.addUI('experiments', {
|
||||
|
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"line_renderer": {
|
||||
"name": "Modular Chat Line Rendering",
|
||||
"description": "Enable a newer, modular chat line renderer.",
|
||||
"groups": [
|
||||
{"value": true, "weight": 0},
|
||||
{"value": false, "weight": 100}
|
||||
]
|
||||
},
|
||||
"api_load": {
|
||||
"name": "New API Stress Testing",
|
||||
"description": "Send duplicate requests to the new API server for load testing.",
|
||||
|
|
40
src/modules/chat/actions/components/edit-emote.vue
Normal file
40
src/modules/chat/actions/components/edit-emote.vue
Normal file
|
@ -0,0 +1,40 @@
|
|||
<template lang="html">
|
||||
<div class="tw-flex tw-align-items-start">
|
||||
<label class="tw-mg-y-05">
|
||||
{{ t('setting.actions.emote', 'Emote') }}
|
||||
</label>
|
||||
|
||||
<emote-picker
|
||||
:value="val"
|
||||
class="tw-full-width"
|
||||
@input="change"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
props: ['value'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
val: this.value
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
value() {
|
||||
this.val = this.value;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
change(val) {
|
||||
this.val = val;
|
||||
this.$emit('input', this.val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
5
src/modules/chat/actions/components/preview-emote.vue
Normal file
5
src/modules/chat/actions/components/preview-emote.vue
Normal file
|
@ -0,0 +1,5 @@
|
|||
<template functional>
|
||||
<figure class="mod-icon__image">
|
||||
<img :src="props.data.src">
|
||||
</figure>
|
||||
</template>
|
|
@ -38,6 +38,18 @@ export default class Actions extends Module {
|
|||
}
|
||||
});
|
||||
|
||||
this.settings.add('chat.actions.hover-size', {
|
||||
default: 30,
|
||||
ui: {
|
||||
path: 'Chat > Actions > Message Hover >> Appearance',
|
||||
title: 'Action Size',
|
||||
description: "How tall hover actions should be, in pixels. This may be affected by your browser's zoom and font size settings.",
|
||||
component: 'setting-text-box',
|
||||
process: 'to_int',
|
||||
bounds: [1]
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('chat.actions.reasons', {
|
||||
default: [
|
||||
{v: {text: 'One-Man Spam', i18n: 'chat.reasons.spam'}},
|
||||
|
|
|
@ -37,6 +37,21 @@ export const text = {
|
|||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Emote
|
||||
// ============================================================================
|
||||
|
||||
export const emote = {
|
||||
title: 'Emote',
|
||||
title_i18n: 'setting.actions.appearance.emote',
|
||||
|
||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-emote.vue'),
|
||||
|
||||
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-emote.vue'),
|
||||
render(data, createElement) {
|
||||
return <figure class="mod-icon__image"><img src={data.src} /></figure>;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Icon
|
||||
|
|
|
@ -46,6 +46,10 @@ export const pin = {
|
|||
|
||||
if ( ! line.props.isPinnable || ! line.onPinMessageClick )
|
||||
return true;
|
||||
|
||||
// If the message is empty or deleted, we can't pin it.
|
||||
if ( ! message.message || ! message.message.length || message.deleted )
|
||||
return true;
|
||||
},
|
||||
|
||||
click(event, data) {
|
||||
|
|
|
@ -20,6 +20,7 @@ import Room from './room';
|
|||
import User from './user';
|
||||
import * as TOKENIZERS from './tokenizers';
|
||||
import * as RICH_PROVIDERS from './rich_providers';
|
||||
import * as LINK_PROVIDERS from './link_providers';
|
||||
|
||||
import Actions from './actions';
|
||||
import { getFontsList } from 'src/utilities/fonts';
|
||||
|
@ -99,6 +100,9 @@ export default class Chat extends Module {
|
|||
this.rich_providers = {};
|
||||
this.__rich_providers = [];
|
||||
|
||||
this.link_providers = {};
|
||||
this.__link_providers = [];
|
||||
|
||||
this._hl_reasons = {};
|
||||
this.addHighlightReason('mention', 'Mentioned');
|
||||
this.addHighlightReason('user', 'Highlight User');
|
||||
|
@ -1241,6 +1245,8 @@ export default class Chat extends Module {
|
|||
onEnable() {
|
||||
this.socket = this.resolve('socket');
|
||||
|
||||
this.on('site.subpump:pubsub-message', this.onPubSub, this);
|
||||
|
||||
if ( this.context.get('chat.filtering.color-mentions') )
|
||||
this.createColorCache().then(() => this.emit(':update-line-tokens'));
|
||||
|
||||
|
@ -1251,6 +1257,47 @@ export default class Chat extends Module {
|
|||
for(const key in RICH_PROVIDERS)
|
||||
if ( has(RICH_PROVIDERS, key) )
|
||||
this.addRichProvider(RICH_PROVIDERS[key]);
|
||||
|
||||
for(const key in LINK_PROVIDERS)
|
||||
if ( has(LINK_PROVIDERS, key) )
|
||||
this.addLinkProvider(LINK_PROVIDERS[key]);
|
||||
}
|
||||
|
||||
|
||||
onPubSub(event) {
|
||||
if ( event.prefix === 'stream-chat-room-v1' && event.message.type === 'chat_rich_embed' ) {
|
||||
const data = event.message.data,
|
||||
url = data.request_url,
|
||||
|
||||
providers = this.__link_providers;
|
||||
|
||||
// Don't re-cache.
|
||||
if ( this._link_info[url] )
|
||||
return;
|
||||
|
||||
for(const provider of providers) {
|
||||
const match = provider.test.call(this, url);
|
||||
if ( match ) {
|
||||
const processed = provider.receive ? provider.receive.call(this, match, data) : data;
|
||||
let result = provider.process.call(this, match, processed);
|
||||
|
||||
if ( !(result instanceof Promise) )
|
||||
result = Promise.resolve(result);
|
||||
|
||||
result.then(value => {
|
||||
// If something is already running, don't override it.
|
||||
let info = this._link_info[url];
|
||||
if ( info )
|
||||
return;
|
||||
|
||||
// Save the value.
|
||||
this._link_info[url] = [true, Date.now() + 120000, value];
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -1855,6 +1902,11 @@ export default class Chat extends Module {
|
|||
|
||||
addTokenizer(tokenizer) {
|
||||
const type = tokenizer.type;
|
||||
if ( has(this.tokenizers, type) ) {
|
||||
this.log.warn(`Tried adding tokenizer of type '${type}' when one was already present.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.tokenizers[type] = tokenizer;
|
||||
if ( tokenizer.priority == null )
|
||||
tokenizer.priority = 0;
|
||||
|
@ -1894,8 +1946,48 @@ export default class Chat extends Module {
|
|||
return tokenizer;
|
||||
}
|
||||
|
||||
addLinkProvider(provider) {
|
||||
const type = provider.type;
|
||||
if ( has(this.link_providers, type) ) {
|
||||
this.log.warn(`Tried adding link provider of type '${type}' when one was already present.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.link_providers[type] = provider;
|
||||
if ( provider.priority == null )
|
||||
provider.priority = 0;
|
||||
|
||||
this.__link_providers.push(provider);
|
||||
this.__link_providers.sort((a,b) => {
|
||||
if ( a.priority > b.priority ) return -1;
|
||||
if ( a.priority < b.priority ) return 1;
|
||||
return a.type < b.type;
|
||||
});
|
||||
}
|
||||
|
||||
removeLinkProvider(provider) {
|
||||
let type;
|
||||
if ( typeof provider === 'string' ) type = provider;
|
||||
else type = provider.type;
|
||||
|
||||
provider = this.link_providers[type];
|
||||
if ( ! provider )
|
||||
return null;
|
||||
|
||||
const idx = this.__link_providers.indexOf(provider);
|
||||
if ( idx !== -1 )
|
||||
this.__link_providers.splice(idx, 1);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
addRichProvider(provider) {
|
||||
const type = provider.type;
|
||||
if ( has(this.rich_providers, type) ) {
|
||||
this.log.warn(`Tried adding rich provider of type '${type}' when one was already present.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.rich_providers[type] = provider;
|
||||
if ( provider.priority == null )
|
||||
provider.priority = 0;
|
||||
|
@ -2108,6 +2200,17 @@ export default class Chat extends Module {
|
|||
cbs[success ? 0 : 1](data);
|
||||
}
|
||||
|
||||
// Try using a link provider.
|
||||
for(const lp of this.__link_providers) {
|
||||
const match = lp.test.call(this, url);
|
||||
if ( match ) {
|
||||
timeout(lp.process.call(this, match), 15000)
|
||||
.then(data => handle(true, data))
|
||||
.catch(err => handle(false, err));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let provider = this.settings.get('debug.link-resolver.source');
|
||||
if ( provider == null )
|
||||
provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket';
|
||||
|
|
459
src/modules/chat/link_providers.js
Normal file
459
src/modules/chat/link_providers.js
Normal file
|
@ -0,0 +1,459 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Rich Content Providers
|
||||
// ============================================================================
|
||||
|
||||
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-_=]+)(?:\/)?(\w+)?(?:\/edit)?/i;
|
||||
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-_=]+)/i;
|
||||
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
|
||||
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
|
||||
|
||||
const BAD_USERS = [
|
||||
'directory', '_deck', 'p', 'downloads', 'jobs', 'turbo', 'settings', 'friends',
|
||||
'subscriptions', 'inventory', 'wallet'
|
||||
];
|
||||
|
||||
import GET_CLIP from './clip_info.gql';
|
||||
import GET_VIDEO from './video_info.gql';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Clips
|
||||
// ============================================================================
|
||||
|
||||
export const Clip = {
|
||||
type: 'clip',
|
||||
|
||||
test(url) {
|
||||
const match = CLIP_URL.exec(url) || NEW_CLIP_URL.exec(url);
|
||||
if ( match && match[1] && match[1] !== 'create' )
|
||||
return match[1];
|
||||
},
|
||||
|
||||
receive(match, data) {
|
||||
const cd = data?.twitch_metadata?.clip_metadata;
|
||||
if ( ! cd )
|
||||
return;
|
||||
|
||||
return {
|
||||
id: cd.id,
|
||||
slug: cd.slug,
|
||||
title: data.title,
|
||||
thumbnailURL: data.thumbnail_url,
|
||||
curator: {
|
||||
id: cd.curator_id,
|
||||
displayName: data.author_name
|
||||
},
|
||||
broadcaster: {
|
||||
id: cd.broadcaster_id,
|
||||
displayName: cd.channel_display_name
|
||||
},
|
||||
game: {
|
||||
displayName: cd.game
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async process(match, received) {
|
||||
let clip = received;
|
||||
|
||||
if ( ! clip ) {
|
||||
const apollo = this.resolve('site.apollo');
|
||||
if ( ! apollo )
|
||||
return null;
|
||||
|
||||
const result = await apollo.client.query({
|
||||
query: GET_CLIP,
|
||||
variables: {
|
||||
slug: match
|
||||
}
|
||||
});
|
||||
|
||||
clip = result?.data?.clip;
|
||||
}
|
||||
|
||||
if ( ! clip || ! clip.broadcaster )
|
||||
return null;
|
||||
|
||||
const game = clip.game,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
let user = {
|
||||
type: 'style', weight: 'semibold', color: 'alt-2',
|
||||
content: clip.broadcaster.displayName
|
||||
};
|
||||
|
||||
if ( clip.broadcaster.login )
|
||||
user = {
|
||||
type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
|
||||
content: user
|
||||
};
|
||||
|
||||
const subtitle = game_display ? {
|
||||
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
|
||||
user,
|
||||
game: {type: 'style', weight: 'semibold', content: game_display}
|
||||
}
|
||||
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
|
||||
|
||||
let curator = clip.curator ? {
|
||||
type: 'style', color: 'alt-2',
|
||||
content: clip.curator.displayName
|
||||
} : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'};
|
||||
|
||||
if ( clip.curator?.login )
|
||||
curator = {
|
||||
type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`,
|
||||
content: curator
|
||||
};
|
||||
|
||||
let extra;
|
||||
|
||||
if ( clip.viewCount > 0 )
|
||||
extra = {
|
||||
type: 'i18n', key: 'clip.desc.2',
|
||||
phrase: 'Clipped by {curator} — {views, plural, one {# View} other {# Views}}',
|
||||
content: {
|
||||
curator,
|
||||
views: clip.viewCount
|
||||
}
|
||||
};
|
||||
else
|
||||
extra = {
|
||||
type: 'i18n', key: 'clip.desc.no-views',
|
||||
phrase: 'Clipped by {curator}',
|
||||
content: {
|
||||
curator
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
accent: '#6441a4',
|
||||
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: clip.thumbnailURL, sfw: true, aspect: 16/9},
|
||||
title: clip.title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Users
|
||||
// ============================================================================
|
||||
|
||||
export const User = {
|
||||
type: 'user',
|
||||
|
||||
test(url) {
|
||||
const match = USER_URL.exec(url);
|
||||
if ( match && ! BAD_USERS.includes(match[1]) )
|
||||
return match[1];
|
||||
},
|
||||
|
||||
async process(match) {
|
||||
const twitch_data = this.resolve('site.twitch_data'),
|
||||
user = twitch_data ? await twitch_data.getUser(null, match) : null;
|
||||
|
||||
if ( ! user || ! user.id )
|
||||
return null;
|
||||
|
||||
const game = user.broadcastSettings?.game?.displayName,
|
||||
stream_id = user.stream?.id;
|
||||
|
||||
const fragments = {
|
||||
avatar: {
|
||||
type: 'image',
|
||||
url: user.profileImageURL,
|
||||
rounding: -1,
|
||||
aspect: 1
|
||||
},
|
||||
desc: user.description,
|
||||
title: [user.displayName]
|
||||
};
|
||||
|
||||
if ( stream_id && game )
|
||||
fragments.game = {type: 'style', weight: 'semibold', content: game};
|
||||
|
||||
if ( user.displayName.trim().toLowerCase() !== user.login )
|
||||
fragments.title.push({
|
||||
type: 'style', color: 'alt-2',
|
||||
content: [' (', user.login, ')']
|
||||
});
|
||||
|
||||
if ( user.roles?.isPartner )
|
||||
fragments.title.push({
|
||||
type: 'style', color: 'link',
|
||||
content: {type: 'icon', name: 'verified'}
|
||||
});
|
||||
|
||||
const full = [
|
||||
{
|
||||
type: 'header',
|
||||
image: {type: 'ref', name: 'avatar'},
|
||||
title: {type: 'ref', name: 'title'},
|
||||
},
|
||||
{
|
||||
type: 'box',
|
||||
'mg-y': 'small',
|
||||
wrap: 'pre-wrap',
|
||||
lines: 5,
|
||||
content: {
|
||||
type: 'ref',
|
||||
name: 'desc'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
if ( stream_id && game ) {
|
||||
const thumb_url = user.stream.previewImageURL
|
||||
? user.stream.previewImageURL
|
||||
.replace('{width}', '320')
|
||||
.replace('{height}', '180')
|
||||
: null;
|
||||
|
||||
full.push({
|
||||
type: 'link',
|
||||
url: `https://www.twitch.tv/${user.login}`,
|
||||
embed: true,
|
||||
interactive: true,
|
||||
tooltip: false,
|
||||
content: [
|
||||
{
|
||||
type: 'conditional',
|
||||
media: true,
|
||||
content: {
|
||||
type: 'gallery',
|
||||
items: [
|
||||
{
|
||||
type: 'image',
|
||||
url: thumb_url,
|
||||
aspect: 16/9
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'box',
|
||||
'mg-y': 'small',
|
||||
lines: 2,
|
||||
content: user.broadcastSettings.title
|
||||
},
|
||||
{
|
||||
type: 'ref',
|
||||
name: 'game'
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
full.push({
|
||||
type: 'header',
|
||||
compact: true,
|
||||
subtitle: [
|
||||
{
|
||||
type: 'icon',
|
||||
name: 'twitch'
|
||||
},
|
||||
' Twitch'
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
v: 5,
|
||||
|
||||
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
|
||||
fragments,
|
||||
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'ref', name: 'avatar'},
|
||||
title: {type: 'ref', name: 'title'},
|
||||
subtitle: {type: 'ref', name: 'desc'},
|
||||
extra: stream_id ? {
|
||||
type: 'i18n',
|
||||
key: 'cards.user.streaming',
|
||||
phrase: 'streaming {game}',
|
||||
content: {
|
||||
game: {type: 'ref', name: 'game'}
|
||||
}
|
||||
} : null
|
||||
},
|
||||
|
||||
full
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Videos
|
||||
// ============================================================================
|
||||
|
||||
export const Video = {
|
||||
type: 'video',
|
||||
|
||||
test(url) {
|
||||
const match = VIDEO_URL.exec(url);
|
||||
if ( match )
|
||||
return match[1];
|
||||
},
|
||||
|
||||
async process(match) {
|
||||
const apollo = this.resolve('site.apollo');
|
||||
if ( ! apollo )
|
||||
return null;
|
||||
|
||||
const result = await apollo.client.query({
|
||||
query: GET_VIDEO,
|
||||
variables: {
|
||||
id: match
|
||||
}
|
||||
});
|
||||
|
||||
if ( ! result || ! result.data || ! result.data.video || ! result.data.video.owner )
|
||||
return null;
|
||||
|
||||
const video = result.data.video,
|
||||
game = video.game,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
const fragments = {
|
||||
title: video.title,
|
||||
thumbnail: {
|
||||
type: 'image',
|
||||
url: video.previewThumbnailURL,
|
||||
aspect: 16/9
|
||||
}
|
||||
};
|
||||
|
||||
const user = {
|
||||
type: 'link',
|
||||
url: `https://www.twitch.tv/${video.owner.login}`,
|
||||
content: {
|
||||
type: 'style',
|
||||
weight: 'semibold',
|
||||
color: 'alt-2',
|
||||
content: video.owner.displayName
|
||||
}
|
||||
};
|
||||
|
||||
fragments.subtitle = video.game?.displayName
|
||||
? {
|
||||
type: 'i18n',
|
||||
key: 'video.desc.1.playing',
|
||||
phrase: 'Video of {user} playing {game}',
|
||||
content: {
|
||||
user,
|
||||
game: {
|
||||
type: 'style',
|
||||
weight: 'semibold',
|
||||
content: video.game.displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
type: 'i18n',
|
||||
key: 'video.desc.1',
|
||||
phrase: 'Video of {user}',
|
||||
content: {
|
||||
user
|
||||
}
|
||||
};
|
||||
|
||||
let length = video.lengthSeconds;
|
||||
|
||||
return {
|
||||
v: 5,
|
||||
|
||||
fragments,
|
||||
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'ref', name: 'thumbnail'},
|
||||
title: {type: 'ref', name: 'title'},
|
||||
subtitle: {type: 'ref', name: 'subtitle'},
|
||||
extra: {
|
||||
type: 'i18n',
|
||||
key: 'video.desc.2',
|
||||
phrase: '{length,duration} — {views,number} Views — {date,datetime}',
|
||||
content: {
|
||||
length,
|
||||
views: video.viewCount,
|
||||
date: video.publishedAt
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
full: [
|
||||
{
|
||||
type: 'header',
|
||||
image: {
|
||||
type: 'image',
|
||||
url: video.owner.profileImageURL,
|
||||
rounding: -1,
|
||||
aspect: 1
|
||||
},
|
||||
title: {type: 'ref', name: 'title'},
|
||||
subtitle: {type: 'ref', name: 'subtitle'}
|
||||
},
|
||||
{
|
||||
type: 'box',
|
||||
'mg-y': 'small',
|
||||
lines: 5,
|
||||
wrap: 'pre-wrap',
|
||||
content: video.description
|
||||
},
|
||||
{
|
||||
type: 'conditional',
|
||||
media: true,
|
||||
content: {
|
||||
type: 'gallery',
|
||||
items: [
|
||||
{
|
||||
type: 'overlay',
|
||||
content: {type: 'ref', name: 'thumbnail'},
|
||||
'top-left': {
|
||||
type: 'format',
|
||||
format: 'duration',
|
||||
value: length
|
||||
},
|
||||
'bottom-left': {
|
||||
type: 'i18n',
|
||||
key: 'video.views',
|
||||
phrase: '{views,number} views',
|
||||
content: {
|
||||
views: video.viewCount
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'header',
|
||||
compact: true,
|
||||
subtitle: [
|
||||
{
|
||||
type: 'icon',
|
||||
name: 'twitch'
|
||||
},
|
||||
" Twitch • ",
|
||||
{
|
||||
type: 'format',
|
||||
format: 'datetime',
|
||||
value: video.publishedAt
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -4,24 +4,6 @@
|
|||
// Rich Content Providers
|
||||
// ============================================================================
|
||||
|
||||
//const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
|
||||
//const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
|
||||
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-_=]+)(?:\/)?(\w+)?(?:\/edit)?/i;
|
||||
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-_=]+)/i;
|
||||
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
|
||||
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
|
||||
|
||||
const BAD_USERS = [
|
||||
'directory', '_deck', 'p', 'downloads', 'jobs', 'turbo', 'settings', 'friends',
|
||||
'subscriptions', 'inventory', 'wallet'
|
||||
];
|
||||
|
||||
import GET_CLIP from './clip_info.gql';
|
||||
import GET_VIDEO from './video_info.gql';
|
||||
|
||||
import {truncate} from 'utilities/object';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// General Links
|
||||
// ============================================================================
|
||||
|
@ -32,10 +14,18 @@ export const Links = {
|
|||
priority: -10,
|
||||
|
||||
test(token) {
|
||||
if ( ! this.context.get('chat.rich.all-links') && ! token.force_rich )
|
||||
if ( token.type !== 'link' )
|
||||
return false;
|
||||
|
||||
return token.type === 'link'
|
||||
const url = token.url;
|
||||
|
||||
// Link providers always result in embeds.
|
||||
for(const provider of this.__link_providers) {
|
||||
if ( provider.test.call(this, url) )
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.context.get('chat.rich.all-links') || token.force_rich;
|
||||
},
|
||||
|
||||
process(token, want_mid) {
|
||||
|
@ -70,279 +60,3 @@ export const Links = {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Users
|
||||
// ============================================================================
|
||||
|
||||
export const Users = {
|
||||
type: 'user',
|
||||
can_hide_token: true,
|
||||
|
||||
test(token) {
|
||||
if ( token.type !== 'link' || (! this.context.get('chat.rich.all-links') && ! token.force_rich) )
|
||||
return false;
|
||||
|
||||
return USER_URL.test(token.url);
|
||||
},
|
||||
|
||||
process(token) {
|
||||
const match = USER_URL.exec(token.url),
|
||||
twitch_data = this.resolve('site.twitch_data');
|
||||
|
||||
if ( ! twitch_data || ! match || BAD_USERS.includes(match[1]) )
|
||||
return;
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
|
||||
getData: async () => {
|
||||
const user = await twitch_data.getUser(null, match[1]);
|
||||
if ( ! user || ! user.id )
|
||||
return null;
|
||||
|
||||
const game = user.broadcastSettings?.game?.displayName,
|
||||
stream_id = user.stream?.id;
|
||||
|
||||
let subtitle
|
||||
if ( stream_id && game )
|
||||
subtitle = {
|
||||
type: 'i18n',
|
||||
key: 'cards.user.streaming', phrase: 'streaming {game}', content: {
|
||||
game: {type: 'style', weight: 'semibold', content: game}
|
||||
}
|
||||
};
|
||||
|
||||
const extra = truncate(user.description);
|
||||
const title = [user.displayName];
|
||||
|
||||
if ( user.displayName.trim().toLowerCase() !== user.login )
|
||||
title.push({
|
||||
type: 'style', color: 'alt-2',
|
||||
content: [' (', user.login, ')']
|
||||
});
|
||||
|
||||
if ( user.roles?.isPartner )
|
||||
title.push({
|
||||
type: 'style', color: 'link',
|
||||
content: {type: 'icon', name: 'verified'}
|
||||
});
|
||||
|
||||
/*const full = [{
|
||||
type: 'header',
|
||||
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
|
||||
title,
|
||||
subtitle,
|
||||
extra: stream_id ? extra : null
|
||||
}];
|
||||
|
||||
if ( stream_id ) {
|
||||
full.push({type: 'box', 'mg-y': 'small', lines: 1, content: user.broadcastSettings.title});
|
||||
full.push({type: 'conditional', content: {
|
||||
type: 'gallery', items: [{
|
||||
type: 'image', aspect: 16/9, sfw: false, url: user.stream.previewImageURL
|
||||
}]
|
||||
}});
|
||||
} else
|
||||
full.push({type: 'box', 'mg-y': 'small', wrap: 'pre-wrap', lines: 5, content: truncate(user.description, 1000, undefined, undefined, false)})
|
||||
|
||||
full.push({
|
||||
type: 'fieldset',
|
||||
fields: [
|
||||
{
|
||||
name: {type: 'i18n', key: 'embed.twitch.views', phrase: 'Views'},
|
||||
value: {type: 'format', format: 'number', value: user.profileViewCount},
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: {type: 'i18n', key: 'embed.twitch.followers', phrase: 'Followers'},
|
||||
value: {type: 'format', format: 'number', value: user.followers?.totalCount},
|
||||
inline: true
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
full.push({
|
||||
type: 'header',
|
||||
subtitle: [{type: 'icon', name: 'twitch'}, ' Twitch']
|
||||
});*/
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
|
||||
title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Clips
|
||||
// ============================================================================
|
||||
|
||||
export const Clips = {
|
||||
type: 'clip',
|
||||
can_hide_token: true,
|
||||
|
||||
test(token) {
|
||||
if ( token.type !== 'link' )
|
||||
return false;
|
||||
|
||||
return CLIP_URL.test(token.url) || NEW_CLIP_URL.test(token.url);
|
||||
},
|
||||
|
||||
process(token) {
|
||||
let match = CLIP_URL.exec(token.url);
|
||||
if ( ! match )
|
||||
match = NEW_CLIP_URL.exec(token.url);
|
||||
|
||||
const apollo = this.resolve('site.apollo');
|
||||
if ( ! apollo || ! match || match[1] === 'create' )
|
||||
return;
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
|
||||
getData: async () => {
|
||||
const result = await apollo.client.query({
|
||||
query: GET_CLIP,
|
||||
variables: {
|
||||
slug: match[1]
|
||||
}
|
||||
});
|
||||
|
||||
if ( ! result || ! result.data || ! result.data.clip || ! result.data.clip.broadcaster )
|
||||
return null;
|
||||
|
||||
const clip = result.data.clip,
|
||||
game = clip.game,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
const user = {
|
||||
type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
|
||||
content: {
|
||||
type: 'style', weight: 'semibold', color: 'alt-2',
|
||||
content: clip.broadcaster.displayName
|
||||
}
|
||||
};
|
||||
|
||||
const subtitle = game_display ? {
|
||||
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
|
||||
user,
|
||||
game: {type: 'style', weight: 'semibold', content: game_display}
|
||||
}
|
||||
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
|
||||
|
||||
const curator = clip.curator ? {
|
||||
type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`,
|
||||
content: {
|
||||
type: 'style', color: 'alt-2',
|
||||
content: clip.curator.displayName
|
||||
}
|
||||
} : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'};
|
||||
|
||||
const extra = {
|
||||
type: 'i18n', key: 'clip.desc.2',
|
||||
phrase: 'Clipped by {curator} — {views, plural, one {# View} other {# Views}}',
|
||||
content: {
|
||||
curator,
|
||||
views: clip.viewCount
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
accent: '#6441a4',
|
||||
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: clip.thumbnailURL, sfw: true, aspect: 16/9},
|
||||
title: clip.title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const Videos = {
|
||||
type: 'video',
|
||||
can_hide_token: true,
|
||||
|
||||
test(token) {
|
||||
return token.type === 'link' && VIDEO_URL.test(token.url)
|
||||
},
|
||||
|
||||
process(token) {
|
||||
const match = VIDEO_URL.exec(token.url),
|
||||
apollo = this.resolve('site.apollo');
|
||||
|
||||
if ( ! apollo || ! match )
|
||||
return;
|
||||
|
||||
return {
|
||||
getData: async () => {
|
||||
const result = await apollo.client.query({
|
||||
query: GET_VIDEO,
|
||||
variables: {
|
||||
id: match[1]
|
||||
}
|
||||
});
|
||||
|
||||
if ( ! result || ! result.data || ! result.data.video || ! result.data.video.owner )
|
||||
return null;
|
||||
|
||||
const video = result.data.video,
|
||||
game = video.game,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
const user = {
|
||||
type: 'link', url: `https://www.twitch.tv/${video.owner.login}`,
|
||||
content: {
|
||||
type: 'style', weight: 'semibold', color: 'alt-2',
|
||||
content: video.owner.displayName
|
||||
}
|
||||
};
|
||||
|
||||
const subtitle = game_display ? {
|
||||
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
|
||||
user,
|
||||
game: {type: 'style', weight: 'semibold', content: game_display}
|
||||
}
|
||||
} : {type: 'i18n', key: 'video.desc.1', phrase: 'Video of {user}', content: {user}};
|
||||
|
||||
const extra = {
|
||||
type: 'i18n', key: 'video.desc.2',
|
||||
phrase: '{length,duration} — {views,number} Views — {date,datetime}', content: {
|
||||
length: video.lengthSeconds,
|
||||
views: video.viewCount,
|
||||
date: video.publishedAt
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: video.previewThumbnailURL, sfw: true, aspect: 16/9},
|
||||
title: video.title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@ query FFZ_GetVideoInfo($id: ID!) {
|
|||
video(id: $id) {
|
||||
id
|
||||
title
|
||||
previewThumbnailURL(width: 86, height: 45)
|
||||
previewThumbnailURL(width: 320, height: 180)
|
||||
lengthSeconds
|
||||
publishedAt
|
||||
viewCount
|
||||
|
@ -14,6 +14,7 @@ query FFZ_GetVideoInfo($id: ID!) {
|
|||
id
|
||||
login
|
||||
displayName
|
||||
profileImageURL(width: 50)
|
||||
}
|
||||
}
|
||||
}
|
699
src/modules/main_menu/components/chat-tester.vue
Normal file
699
src/modules/main_menu/components/chat-tester.vue
Normal file
|
@ -0,0 +1,699 @@
|
|||
<template>
|
||||
<div class="ffz--chat-tester">
|
||||
<div v-if="context.exclusive" class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-2">
|
||||
<h3 class="ffz-i-attention">
|
||||
{{ t('debug.chat-tester.exclusive', "Hey! This won't work here!") }}
|
||||
</h3>
|
||||
<markdown :source="t('debug.chat-tester.exclusive-explain', 'This feature does not work when the FFZ Control Center is popped out. It needs to be used in a window where you can see chat.')" />
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-align-items-start">
|
||||
<label for="selector" class="tw-mg-y-05">
|
||||
{{ t('debug.chat-tester.message', 'Test Message') }}
|
||||
</label>
|
||||
|
||||
<div class="tw-flex tw-flex-column tw-mg-05 tw-full-width">
|
||||
<select
|
||||
id="selector"
|
||||
ref="selector"
|
||||
class="tw-full-width tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05"
|
||||
@change="onSelectChange"
|
||||
>
|
||||
<option :selected="is_custom" value="custom">
|
||||
{{ t('setting.combo-box.custom', 'Custom') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(sample, idx) in samples"
|
||||
:key="idx"
|
||||
:selected="sample.data === message"
|
||||
:value="sample.data"
|
||||
>
|
||||
{{ sample.name }}
|
||||
</option>
|
||||
</select>
|
||||
<textarea
|
||||
ref="message"
|
||||
class="tw-block tw-font-size-6 tw-full-width ffz-textarea ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium"
|
||||
rows="10"
|
||||
@blur="updateMessage"
|
||||
@input="onMessageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-mg-t-1 tw-flex tw-align-items-center">
|
||||
<div class="tw-flex-grow-1" />
|
||||
|
||||
<div class="tw-pd-x-1 ffz-checkbox">
|
||||
<input
|
||||
id="replay_fix"
|
||||
ref="replay_fix"
|
||||
:checked="replay_fix"
|
||||
type="checkbox"
|
||||
class="ffz-checkbox__input"
|
||||
@change="onCheck"
|
||||
>
|
||||
|
||||
<label for="replay_fix" class="ffz-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.chat-tester.replay-fix', 'Fix ID and Channel') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="tw-mg-l-1 tw-button tw-button--text"
|
||||
@click="playMessage"
|
||||
>
|
||||
<span class="tw-button__text ffz-i-play">
|
||||
{{ t('debug.chat-tester.play', 'Play Message') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tw-pd-t-1 tw-border-t tw-mg-t-1 tw-flex tw-mg-b-1 tw-align-items-center">
|
||||
<div class="tw-flex-grow-1" />
|
||||
|
||||
<div class="tw-pd-x-1 ffz-checkbox">
|
||||
<input
|
||||
id="capture_chat"
|
||||
ref="capture_chat"
|
||||
:checked="capture_chat"
|
||||
type="checkbox"
|
||||
class="ffz-checkbox__input"
|
||||
@change="onCheck"
|
||||
>
|
||||
|
||||
<label for="capture_chat" class="ffz-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.chat-tester.capture-chat', 'Capture Chat') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="tw-pd-x-1 ffz-checkbox">
|
||||
<input
|
||||
id="ignore_privmsg"
|
||||
ref="ignore_privmsg"
|
||||
:checked="ignore_privmsg"
|
||||
type="checkbox"
|
||||
class="ffz-checkbox__input"
|
||||
@change="onCheck"
|
||||
>
|
||||
|
||||
<label for="ignore_privmsg" class="ffz-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.chat-tester.ignore-privmsg', 'Ignore PRIVMSG') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="tw-pd-x-1 ffz-checkbox">
|
||||
<input
|
||||
id="capture_pubsub"
|
||||
ref="capture_pubsub"
|
||||
:checked="capture_pubsub"
|
||||
type="checkbox"
|
||||
class="ffz-checkbox__input"
|
||||
@change="onCheck"
|
||||
>
|
||||
|
||||
<label for="capture_pubsub" class="ffz-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.chat-tester.capture-pubsub', 'Capture PubSub') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="tw-mg-l-1 tw-button tw-button--text"
|
||||
@click="clearLog"
|
||||
>
|
||||
<span class="tw-button__text ffz-i-trash">
|
||||
{{ t('debug.chat-tester.clear-log', 'Clear Log') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in log"
|
||||
:key="item._id"
|
||||
class="tw-elevation-1 tw-border tw-pd-y-05 tw-pd-r-1 tw-mg-y-05 tw-flex tw-flex-nowrap tw-align-items-center"
|
||||
:class="{'tw-c-background-base': item.pubsub, 'tw-c-background-alt-2': !item.pubsub}"
|
||||
>
|
||||
<time class="tw-mg-l-05 tw-mg-r-1 tw-flex-shrink-0">
|
||||
{{ tTime(item.timestamp, 'HH:mm:ss') }}
|
||||
</time>
|
||||
<div v-if="item.pubsub" class="tw-flex-grow-1">
|
||||
<div class="tw-mg-b-05 tw-border-b tw-pd-b-05">{{ item.topic }}</div>
|
||||
<div v-html="highlightJson(item.data)" />
|
||||
</div>
|
||||
<div v-else-if="item.chat" class="tw-flex-grow-1">
|
||||
<div v-if="item.tags" class="ffz-ct--tags">
|
||||
@<template v-for="(tag, key) in item.tags"><span class="ffz-ct--tag">{{ key }}</span>=<span class="ffz-ct--tag-value">{{ tag }}</span>;</template>
|
||||
</div>
|
||||
<div class="ffz-ct--prefix">
|
||||
<template v-if="item.prefix">:<span v-if="item.user" class="ffz-ct--user">{{ item.user }}</span><span class="ffz-ct--prefix">{{ item.prefix }}</span></template>
|
||||
<span class="ffz-ct--command">{{ item.command }}</span>
|
||||
<template v-if="item.channel">#<span class="ffz-ct--channel">{{ item.channel }}</span></template>
|
||||
</div>
|
||||
<div v-if="item.last_param" class="ffz-ct--params">
|
||||
<span v-for="para in item.params" class="ffz-ct--param">{{ para }}</span>
|
||||
:<span class="ffz-ct--param">{{ item.last_param }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tw-flex-grow-1">
|
||||
{{ item.data }}
|
||||
</div>
|
||||
<div class="tw-mg-l-1 tw-flex tw-flex-wrap tw-flex-column tw-justify-content-start tw-align-items-start">
|
||||
<button
|
||||
v-if="item.chat || item.pubsub"
|
||||
class="tw-button tw-button--text"
|
||||
@click="replayItem(item)"
|
||||
>
|
||||
<span class="tw-button__text ffz-i-arrows-cw">
|
||||
{{ t('debug.chat-tester.replay', 'Replay') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="tw-button tw-button--text"
|
||||
@click="copyItem(item)"
|
||||
>
|
||||
<span class="tw-button__text ffz-i-docs">
|
||||
{{ t('setting.copy-json', 'Copy') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { sanitize } from 'src/utilities/dom';
|
||||
import { DEBUG, SERVER } from 'utilities/constants';
|
||||
import { deep_copy, generateUUID } from 'utilities/object';
|
||||
import { getBuster } from 'utilities/time';
|
||||
|
||||
import SAMPLES from '../sample-chat-messages.json'; // eslint-disable-line no-unused-vars
|
||||
|
||||
const IGNORE_COMMANDS = [
|
||||
'PONG',
|
||||
'PING',
|
||||
'366',
|
||||
'353'
|
||||
];
|
||||
|
||||
let LOADED_SAMPLES = [
|
||||
{
|
||||
"name": "Ping",
|
||||
"data": "PING :tmi.twitch.tv"
|
||||
}
|
||||
];
|
||||
|
||||
let has_loaded_samples = false;
|
||||
|
||||
export default {
|
||||
props: ['item', 'context'],
|
||||
|
||||
data() {
|
||||
const state = window.history.state;
|
||||
const samples = deep_copy(LOADED_SAMPLES);
|
||||
const message = state?.ffz_ct_message ?? samples[0].data;
|
||||
|
||||
let is_custom = true;
|
||||
for(const item of samples) {
|
||||
if (item.data === message) {
|
||||
is_custom = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
has_client: false,
|
||||
|
||||
samples,
|
||||
is_custom,
|
||||
message,
|
||||
|
||||
replay_fix: state?.ffz_ct_replay ?? true,
|
||||
ignore_privmsg: state?.ffz_ct_privmsg ?? false,
|
||||
capture_chat: state?.ffz_ct_chat ?? false,
|
||||
capture_pubsub: state?.ffz_ct_pubsub ?? false,
|
||||
|
||||
log: [],
|
||||
logi: 0
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
message() {
|
||||
if ( ! this.is_custom )
|
||||
this.$refs.message.value = this.message;
|
||||
},
|
||||
|
||||
capture_chat() {
|
||||
if ( this.capture_chat )
|
||||
this.listenChat();
|
||||
else
|
||||
this.unlistenChat();
|
||||
},
|
||||
|
||||
capture_pubsub() {
|
||||
if ( this.capture_pubsub )
|
||||
this.listenPubsub();
|
||||
else
|
||||
this.unlistenPubsub();
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.loadSamples();
|
||||
|
||||
this.chat = this.item.getChat();
|
||||
|
||||
this.client = this.chat.ChatService.first?.client;
|
||||
this.has_client = !!this.client;
|
||||
|
||||
if ( this.capture_chat )
|
||||
this.listenChat();
|
||||
|
||||
if ( this.capture_pubsub )
|
||||
this.listenPubsub();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.unlistenChat();
|
||||
this.unlistenPubsub();
|
||||
|
||||
this.client = null;
|
||||
this.chat = null;
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$refs.message.value = this.message;
|
||||
},
|
||||
|
||||
methods: {
|
||||
highlightJson(object, depth = 1) {
|
||||
if ( depth > 10 )
|
||||
return `<span class="ffz-ct--obj-literal"><nested>`;
|
||||
|
||||
if (object == null)
|
||||
return `<span class="ffz-ct--literal" depth="${depth}">null</span>`;
|
||||
|
||||
if ( typeof object === 'number' || typeof object === 'boolean' )
|
||||
return `<span class="ffz-ct--literal" depth="${depth}">${object}</span>`;
|
||||
|
||||
if ( typeof object === 'string' )
|
||||
return `<span class=ffz-ct--string depth="${depth}">"${sanitize(object)}"</span>`;
|
||||
|
||||
if ( Array.isArray(object) )
|
||||
return `<span class="ffz-ct--obj-open" depth="${depth}">[</span>`
|
||||
+ object.map(x => this.highlightJson(x, depth + 1)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
|
||||
+ `<span class="ffz-ct--obj-close" depth="${depth}">]</span>`;
|
||||
|
||||
const out = [];
|
||||
|
||||
for(const [key, val] of Object.entries(object)) {
|
||||
if ( out.length > 0 )
|
||||
out.push(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`);
|
||||
|
||||
out.push(`<span class="ffz-ct--obj-key" depth="${depth}">"${sanitize(key)}"</span><span class="ffz-ct--obj-key-sep" depth="${depth}">: </span>`);
|
||||
out.push(this.highlightJson(val, depth + 1));
|
||||
}
|
||||
|
||||
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
|
||||
},
|
||||
|
||||
// Samples
|
||||
async loadSamples() {
|
||||
if ( has_loaded_samples )
|
||||
return;
|
||||
|
||||
const values = await fetch(DEBUG ? SAMPLES : `${SERVER}/script/sample-chat-messages.json?_=${getBuster()}`).then(r => r.ok ? r.json() : null);
|
||||
if ( Array.isArray(values) && values.length > 0 ) {
|
||||
has_loaded_samples = true;
|
||||
|
||||
for(const item of values) {
|
||||
if ( Array.isArray(item.data) )
|
||||
item.data = item.data.join('\n\n');
|
||||
}
|
||||
|
||||
LOADED_SAMPLES = values;
|
||||
this.samples = deep_copy(values);
|
||||
|
||||
let is_custom = true;
|
||||
for(const item of this.samples) {
|
||||
if (item.data === this.message) {
|
||||
is_custom = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.is_custom = is_custom;
|
||||
}
|
||||
},
|
||||
|
||||
// Chat
|
||||
listenChat() {
|
||||
if ( this.listening_chat )
|
||||
return;
|
||||
|
||||
// Ensure we have the chat client.
|
||||
if ( ! this.has_client ) {
|
||||
this.client = this.chat.ChatService.first?.client;
|
||||
this.has_client = !!this.client;
|
||||
|
||||
if ( ! this.has_client )
|
||||
return;
|
||||
}
|
||||
|
||||
// Hook into the connection.
|
||||
const conn = this.client.connection;
|
||||
|
||||
if ( ! conn.ffzOnSocketMessage )
|
||||
conn.ffzOnSocketMessage = conn.onSocketMessage;
|
||||
|
||||
conn.onSocketMessage = event => {
|
||||
try {
|
||||
this.handleChat(event);
|
||||
} catch(err) {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
return conn.ffzOnSocketMessage(event);
|
||||
}
|
||||
|
||||
if ( conn.ws )
|
||||
conn.ws.onmessage = conn.onSocketMessage;
|
||||
|
||||
this.addLog("Started capturing chat.");
|
||||
|
||||
this.listening_chat = true;
|
||||
},
|
||||
|
||||
unlistenChat() {
|
||||
if ( ! this.listening_chat )
|
||||
return;
|
||||
|
||||
const conn = this.client.connection;
|
||||
|
||||
conn.onSocketMessage = conn.ffzOnSocketMessage;
|
||||
|
||||
if ( conn.ws )
|
||||
conn.ws.onmessage = conn.onSocketMessage;
|
||||
|
||||
this.addLog("Stopped capturing chat.");
|
||||
|
||||
this.listening_chat = false;
|
||||
},
|
||||
|
||||
handleChat(event) {
|
||||
for(const raw of event.data.split(/\r?\n/g)) {
|
||||
const msg = this.parseChat(raw);
|
||||
if ( msg ) {
|
||||
if ( this.ignore_privmsg && msg.command === 'PRIVMSG' )
|
||||
continue;
|
||||
|
||||
if ( IGNORE_COMMANDS.includes(msg.command) )
|
||||
continue;
|
||||
|
||||
this.addLog(msg);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseChat(raw) {
|
||||
const msg = this.client.parser.msg(raw);
|
||||
msg.chat = true;
|
||||
|
||||
if ( Object.keys(msg.tags).length === 0 )
|
||||
msg.tags = null;
|
||||
|
||||
if ( msg.params.length > 0 && msg.params[0].startsWith('#') )
|
||||
msg.channel = msg.params.shift().slice(1);
|
||||
|
||||
if ( msg.params.length > 0 )
|
||||
msg.last_param = msg.params.pop();
|
||||
|
||||
const idx = msg.prefix ? msg.prefix.indexOf('!') : -1;
|
||||
|
||||
if ( idx === -1 )
|
||||
msg.user = null;
|
||||
else {
|
||||
msg.user = msg.prefix.substr(0, idx);
|
||||
msg.prefix = msg.prefix.substr(idx);
|
||||
}
|
||||
|
||||
return msg;
|
||||
},
|
||||
|
||||
// Pubsub
|
||||
listenPubsub() {
|
||||
if ( this.listening_pubsub )
|
||||
return;
|
||||
|
||||
this.chat.on('site.subpump:pubsub-message', this.handlePubsub, this);
|
||||
this.addLog("Started capturing PubSub.");
|
||||
|
||||
this.listening_pubsub = true;
|
||||
},
|
||||
|
||||
unlistenPubsub() {
|
||||
if ( ! this.listening_pubsub )
|
||||
return;
|
||||
|
||||
this.chat.off('site.subpump:pubsub-message', this.handlePubsub, this);
|
||||
this.addLog("Stopped capturing PubSub.");
|
||||
|
||||
this.listening_pubsub = false;
|
||||
},
|
||||
|
||||
handlePubsub(event) {
|
||||
|
||||
if ( event.prefix === 'video-playback-by-id' )
|
||||
return;
|
||||
|
||||
this.addLog({
|
||||
pubsub: true,
|
||||
topic: event.topic,
|
||||
data: deep_copy(event.message)
|
||||
});
|
||||
},
|
||||
|
||||
// State
|
||||
|
||||
saveState() {
|
||||
try {
|
||||
window.history.replaceState({
|
||||
...window.history.state,
|
||||
ffz_ct_replay: this.replay_fix,
|
||||
ffz_ct_message: this.message,
|
||||
ffz_ct_chat: this.capture_chat,
|
||||
ffz_ct_pubsub: this.capture_pubsub,
|
||||
ffz_ct_privmsg: this.ignore_privmsg
|
||||
}, document.title);
|
||||
|
||||
} catch(err) {
|
||||
/* no-op */
|
||||
}
|
||||
},
|
||||
|
||||
// Event Handlers
|
||||
|
||||
onSelectChange() {
|
||||
const raw_value = this.$refs.selector.value;
|
||||
|
||||
if ( raw_value && raw_value !== 'custom' ) {
|
||||
this.message = raw_value;
|
||||
this.is_custom = false;
|
||||
} else
|
||||
this.is_custom = true;
|
||||
},
|
||||
|
||||
updateMessage() {
|
||||
const value = this.$refs.message.value;
|
||||
|
||||
let is_custom = true;
|
||||
for(const item of this.samples) {
|
||||
if (item.data === value) {
|
||||
is_custom = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.is_custom = is_custom;
|
||||
if ( this.is_custom )
|
||||
this.message = value;
|
||||
},
|
||||
|
||||
onMessageChange() {
|
||||
this.updateMessage();
|
||||
},
|
||||
|
||||
onCheck() {
|
||||
this.replay_fix = this.$refs.replay_fix.checked;
|
||||
this.capture_chat = this.$refs.capture_chat.checked;
|
||||
this.capture_pubsub = this.$refs.capture_pubsub.checked;
|
||||
this.ignore_privmsg = this.$refs.ignore_privmsg.checked;
|
||||
|
||||
this.saveState();
|
||||
},
|
||||
|
||||
// Log
|
||||
|
||||
addLog(msg) {
|
||||
if ( typeof msg !== 'object' )
|
||||
msg = {
|
||||
data: msg
|
||||
};
|
||||
|
||||
msg.timestamp = Date.now();
|
||||
msg._id = this.logi++;
|
||||
|
||||
this.log.unshift(msg);
|
||||
const extra = this.log.length - 100;
|
||||
if ( extra > 0 )
|
||||
this.log.splice(100, extra);
|
||||
},
|
||||
|
||||
clearLog() {
|
||||
this.log = [];
|
||||
this.addLog('Cleared log.');
|
||||
},
|
||||
|
||||
// Item Actions
|
||||
|
||||
copyItem(item) {
|
||||
let value;
|
||||
if ( item.raw )
|
||||
value = item.raw;
|
||||
else if ( item.data )
|
||||
value = item.data;
|
||||
else
|
||||
value = item;
|
||||
|
||||
if ( typeof value !== 'string' )
|
||||
value = JSON.stringify(value);
|
||||
|
||||
navigator.clipboard.writeText(value);
|
||||
},
|
||||
|
||||
playMessage() {
|
||||
const msgs = [];
|
||||
const parts = this.message.split(/\r?\n/g);
|
||||
|
||||
for(const part of parts) {
|
||||
try {
|
||||
if ( part && part.length > 0 )
|
||||
msgs.push(this.parseChat(part));
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Unable to parse message.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for(const msg of msgs)
|
||||
this.replayItem(msg);
|
||||
},
|
||||
|
||||
replayItem(item) {
|
||||
if ( item.chat ) {
|
||||
// While building the string, also build colors for the console log.
|
||||
const out = [];
|
||||
const colors = [];
|
||||
|
||||
if ( item.tags ) {
|
||||
out.push('@');
|
||||
colors.push('gray');
|
||||
|
||||
for(const [key, val] of Object.entries(item.tags)) {
|
||||
let v = val;
|
||||
|
||||
// If the tag is "id", return a new id so the message
|
||||
// won't be deduplicated automatically.
|
||||
if ( key === 'id' && this.replay_fix )
|
||||
v = generateUUID();
|
||||
|
||||
out.push(key);
|
||||
out.push('=');
|
||||
out.push(`${v}`);
|
||||
out.push(';');
|
||||
colors.push('orange');
|
||||
colors.push('gray');
|
||||
colors.push('white');
|
||||
colors.push('gray');
|
||||
}
|
||||
}
|
||||
|
||||
if ( item.user || item.prefix ) {
|
||||
if ( out.length ) {
|
||||
out.push(' ');
|
||||
colors.push('');
|
||||
}
|
||||
|
||||
out.push(':');
|
||||
colors.push('gray');
|
||||
if (item.user) {
|
||||
out.push(item.user);
|
||||
colors.push('green');
|
||||
}
|
||||
if (item.prefix) {
|
||||
out.push(item.prefix);
|
||||
colors.push('gray');
|
||||
}
|
||||
}
|
||||
|
||||
if ( out.length ) {
|
||||
out.push(' ');
|
||||
colors.push('');
|
||||
}
|
||||
|
||||
out.push(item.command);
|
||||
colors.push('orange');
|
||||
|
||||
// If there's a channel, use the current channel rather
|
||||
// than the logged channel.
|
||||
if ( item.channel ) {
|
||||
out.push(` #`);
|
||||
colors.push('gray');
|
||||
out.push(this.replay_fix ? this.chat.ChatService.first?.props?.channelLogin ?? item.channel : item.channel);
|
||||
colors.push('green');
|
||||
}
|
||||
|
||||
for(const para of item.params) {
|
||||
out.push(` ${para}`);
|
||||
colors.push('skyblue');
|
||||
}
|
||||
|
||||
if ( item.last_param ) {
|
||||
out.push(` :`);
|
||||
colors.push('gray');
|
||||
out.push(item.last_param);
|
||||
colors.push('skyblue');
|
||||
}
|
||||
|
||||
const msg = out.join(''),
|
||||
conn = this.client.connection,
|
||||
handler = conn.ffzOnSocketMessage ?? conn.onSocketMessage;
|
||||
|
||||
const log_msg = out.join('%c'),
|
||||
log_colors = colors.map(x => x?.length ? `color: ${x};` : '');
|
||||
|
||||
this.chat.log.debugColor(`Injecting chat message: %c${log_msg}`, log_colors);
|
||||
|
||||
handler.call(conn, {
|
||||
data: msg
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
|
@ -366,15 +366,15 @@ export default {
|
|||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.chat.off('chat:update-link-resolver', this.checkRefreshRaw, this);
|
||||
this.settings.off(':changed:debug.link-resolver.source', this.changeProvider, this);
|
||||
this.chat = null;
|
||||
this.settings = null;
|
||||
|
||||
if (this.es) {
|
||||
this.es.close();
|
||||
this.es = null;
|
||||
}
|
||||
|
||||
this.chat.off('chat:update-link-resolver', this.checkRefreshRaw, this);
|
||||
this.settings.off(':changed:debug.link-resolver.source', this.changeProvider, this);
|
||||
this.chat = null;
|
||||
this.settings = null;
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
29
src/modules/main_menu/sample-chat-messages.json
Normal file
29
src/modules/main_menu/sample-chat-messages.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
[
|
||||
{
|
||||
"name": "Cheer",
|
||||
"data": "@bits=42069;badge-info=subscriber/69;badges=subscriber/3024,bits/100;color=#FF526F;display-name=SirStendec;id=813a1ef5-f8dd-406c-a2dd-f74d99db2799 :sirstendec!sirstendec@sirstendec.tmi.twitch.tv PRIVMSG #sirstendec :Hello. cheer42069"
|
||||
},
|
||||
{
|
||||
"name": "Ping",
|
||||
"data": "PING :tmi.twitch.tv"
|
||||
},
|
||||
{
|
||||
"name": "Subscribe (Tier 1, No Message)",
|
||||
"data": "@badge-info=subscriber/1;badges=subscriber/0,premium/1;color=#007ECC;display-name=Kerokai;emotes=;flags=;id=80b7174c-830b-487b-8bce-99bab02b6378;login=kerokai;mod=0;msg-id=sub;msg-param-cumulative-months=1;msg-param-months=0;msg-param-multimonth-duration=1;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(maxisgoatlord);msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=42490770;subscriber=1;system-msg=Kerokai\\ssubscribed\\sat\\sTier\\s1.;tmi-sent-ts=1671231043209;user-id=32357552;user-type=; :tmi.twitch.tv USERNOTICE #maximum"
|
||||
},
|
||||
{
|
||||
"name": "Resubscribe (Tier 1, 17 Months, Message)",
|
||||
"data": "@badge-info=subscriber/17;badges=subscriber/12,moments/1;color=#2E8B57;display-name=FaustDaimos;emotes=;flags=;id=cdb378a9-4e56-4933-a78b-f4bcf2a3961a;login=faustdaimos;mod=0;msg-id=resub;msg-param-cumulative-months=17;msg-param-months=0;msg-param-multimonth-duration=0;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Baka\\sBrigade!;msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=24761645;subscriber=1;system-msg=FaustDaimos\\ssubscribed\\sat\\sTier\\s1.\\sThey've\\ssubscribed\\sfor\\s17\\smonths!;tmi-sent-ts=1671231277242;user-id=37613709;user-type= :tmi.twitch.tv USERNOTICE #cirno_tv :when do we try it out"
|
||||
},
|
||||
{
|
||||
"name": "Mass Gift Sub (Tier 1, 5 Total)",
|
||||
"data": [
|
||||
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=d0e4a1d9-75c7-406d-8423-cfa3dfb514b5;login=hellbirdza;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=5;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-sender-count=15;msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sis\\sgifting\\s5\\sTier\\s1\\sSubs\\sto\\sAsmongold's\\scommunity!\\sThey've\\sgifted\\sa\\stotal\\sof\\s15\\sin\\sthe\\schannel!;tmi-sent-ts=1671302976346;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
|
||||
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=182675e7-db1b-49d3-9650-54c31d938203;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=buddyunique1;msg-param-recipient-id=431251927;msg-param-recipient-user-name=buddyunique1;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sbuddyunique1!;tmi-sent-ts=1671302976704;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
|
||||
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=594ce86d-f956-43cd-8b5d-1b7e8499dca1;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=tartarin_e;msg-param-recipient-id=144049812;msg-param-recipient-user-name=tartarin_e;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\startarin_e!;tmi-sent-ts=1671302976722;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
|
||||
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=527abc39-e599-4c1d-a480-e724a9c69823;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=haaryho_stracene_vlasy;msg-param-recipient-id=96664018;msg-param-recipient-user-name=haaryho_stracene_vlasy;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\shaaryho_stracene_vlasy!;tmi-sent-ts=1671302976759;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
|
||||
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=f694b0fc-0b5e-4adf-8002-03dae340e9b5;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=corette0;msg-param-recipient-id=149790291;msg-param-recipient-user-name=corette0;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\scorette0!;tmi-sent-ts=1671302976767;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
|
||||
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=5893d8a8-5eb3-46d6-9737-f1b2b76400d4;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=2;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=yo_adg;msg-param-recipient-id=465861822;msg-param-recipient-user-name=yo_adg;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\syo_adg!;tmi-sent-ts=1671302976798;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold"
|
||||
]
|
||||
}
|
||||
]
|
|
@ -20,6 +20,7 @@ import SettingsMenu from './settings_menu';
|
|||
import EmoteMenu from './emote_menu';
|
||||
import Input from './input';
|
||||
import ViewerCards from './viewer_card';
|
||||
import { isHighlightedReward } from './points';
|
||||
|
||||
|
||||
/*const REGEX_EMOTES = {
|
||||
|
@ -275,6 +276,13 @@ export default class ChatHook extends Module {
|
|||
|
||||
// Settings
|
||||
|
||||
this.settings.addUI('debug.chat-test', {
|
||||
path: 'Debugging > Chat >> Chat',
|
||||
component: 'chat-tester',
|
||||
getChat: () => this,
|
||||
force_seen: true
|
||||
});
|
||||
|
||||
this.settings.add('chat.filtering.blocked-types', {
|
||||
default: [],
|
||||
type: 'array_merge',
|
||||
|
@ -751,11 +759,15 @@ export default class ChatHook extends Module {
|
|||
|
||||
const width = this.chat.context.get('chat.effective-width'),
|
||||
action_size = this.chat.context.get('chat.actions.size'),
|
||||
hover_action_size = this.chat.context.get('chat.actions.hover-size'),
|
||||
ts_size = this.chat.context.get('chat.timestamp-size'),
|
||||
size = this.chat.context.get('chat.font-size'),
|
||||
emote_alignment = this.chat.context.get('chat.lines.emote-alignment'),
|
||||
lh = Math.round((20/12) * size);
|
||||
|
||||
const hover_action_icon = Math.round(hover_action_size * (2/3)),
|
||||
hover_action_padding = hover_action_size - hover_action_icon;
|
||||
|
||||
let font = this.chat.context.get('chat.font-family') || 'inherit';
|
||||
const [processed, unloader] = useFont(font);
|
||||
font = processed;
|
||||
|
@ -774,6 +786,8 @@ export default class ChatHook extends Module {
|
|||
this.css_tweaks.delete('ts-size');
|
||||
|
||||
this.css_tweaks.setVariable('chat-actions-size', `${action_size/10}rem`);
|
||||
this.css_tweaks.setVariable('chat-actions-hover-size', `${hover_action_icon/10}rem`);
|
||||
this.css_tweaks.setVariable('chat-actions-hover-padding', `${hover_action_padding/20}rem`);
|
||||
this.css_tweaks.setVariable('chat-font-size', `${size/10}rem`);
|
||||
this.css_tweaks.setVariable('chat-line-height', `${lh/10}rem`);
|
||||
this.css_tweaks.setVariable('chat-font-family', font);
|
||||
|
@ -899,6 +913,7 @@ export default class ChatHook extends Module {
|
|||
this.chat.context.on('changed:chat.effective-width', this.updateChatCSS, this);
|
||||
this.settings.main_context.on('changed:chat.use-width', this.updateChatCSS, this);
|
||||
this.chat.context.on('changed:chat.actions.size', this.updateChatCSS, this);
|
||||
this.chat.context.on('changed:chat.actions.hover-size', this.updateChatCSS, this);
|
||||
this.chat.context.on('changed:chat.font-size', this.updateChatCSS, this);
|
||||
this.chat.context.on('changed:chat.timestamp-size', this.updateChatCSS, this);
|
||||
this.chat.context.on('changed:chat.font-family', this.updateChatCSS, this);
|
||||
|
@ -1332,6 +1347,7 @@ export default class ChatHook extends Module {
|
|||
type: this.chat_types.Message,
|
||||
ffz_type: 'points',
|
||||
ffz_reward: reward,
|
||||
ffz_reward_highlight: isHighlightedReward(reward),
|
||||
messageParts: [],
|
||||
user: {
|
||||
id: data.user.id,
|
||||
|
@ -2465,6 +2481,7 @@ export default class ChatHook extends Module {
|
|||
|
||||
out.ffz_type = 'points';
|
||||
out.ffz_reward = reward;
|
||||
out.ffz_reward_highlight = isHighlightedReward(reward);
|
||||
|
||||
return i.postMessageToCurrentChannel(e, out);
|
||||
}
|
||||
|
|
|
@ -722,6 +722,7 @@ export default class Input extends Module {
|
|||
return {emotes: [], length: 0};
|
||||
|
||||
const out = [],
|
||||
seen = new Set,
|
||||
anim = this.chat.context.get('chat.emotes.animated') > 0,
|
||||
hidden_sets = this.settings.provider.get('emote-menu.hidden-sets'),
|
||||
has_hidden = Array.isArray(hidden_sets) && hidden_sets.length > 0,
|
||||
|
@ -760,9 +761,11 @@ export default class Input extends Module {
|
|||
const id = emote.id,
|
||||
token = KNOWN_CODES[emote.token] || emote.token;
|
||||
|
||||
if ( ! token )
|
||||
if ( ! token || seen.has(token) )
|
||||
continue;
|
||||
|
||||
seen.add(token);
|
||||
|
||||
const replacement = REPLACEMENTS[id];
|
||||
let srcSet;
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import Twilight from 'site';
|
|||
import Module from 'utilities/module';
|
||||
|
||||
import RichContent from './rich_content';
|
||||
import { has } from 'utilities/object';
|
||||
import { has, maybe_call } from 'utilities/object';
|
||||
import { KEYS, RERENDER_SETTINGS, UPDATE_BADGE_SETTINGS, UPDATE_TOKEN_SETTINGS } from 'utilities/constants';
|
||||
import { print_duration } from 'utilities/time';
|
||||
import { FFZEvent } from 'utilities/events';
|
||||
|
@ -37,113 +37,285 @@ export default class ChatLine extends Module {
|
|||
this.inject('chat.actions');
|
||||
this.inject('chat.overrides');
|
||||
|
||||
/*this.line_types = {};
|
||||
this.line_types = {};
|
||||
|
||||
this.line_types.sub_mystery = (msg, u, r, inst, e) => {
|
||||
const mystery = msg.mystery;
|
||||
if ( mystery )
|
||||
mystery.line = this;
|
||||
this.line_types.cheer = {
|
||||
renderNotice: (msg, current_user, room, inst, e) => {
|
||||
return this.i18n.tList(
|
||||
'chat.bits-message',
|
||||
'Cheered {count, plural, one {# Bit} other {# Bits}}',
|
||||
{
|
||||
count: msg.bits || 0
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sub_msg = this.i18n.tList('chat.sub.gift', "{user} is gifting {count, plural, one {# Tier {tier} Sub} other {# Tier {tier} Subs}} to {channel}'s community! ", {
|
||||
user: (msg.sub_anon || msg.user.username === 'ananonymousgifter') ?
|
||||
this.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
|
||||
e('span', {
|
||||
this.line_types.points = {
|
||||
getClass: (msg) => {
|
||||
const highlight = msg.ffz_reward_highlight && this.chat.context.get('chat.points.allow-highlight') === 2;
|
||||
|
||||
return `ffz--points-line tw-pd-l-1 tw-pd-r-2 ${highlight ? 'ffz-custom-color ffz--points-highlight' : ''}`;
|
||||
},
|
||||
|
||||
renderNotice: (msg, current_user, room, inst, e) => {
|
||||
if ( ! msg.ffz_reward )
|
||||
return null;
|
||||
|
||||
// We need to get the message's tokens to see if it has a message or not.
|
||||
const user = msg.user,
|
||||
tokens = msg.ffz_tokens = msg.ffz_tokens || this.chat.tokenizeMessage(msg, current_user),
|
||||
has_message = tokens.length > 0;
|
||||
|
||||
// Elements for the reward and cost with nice formatting.
|
||||
const reward = e('span', {className: 'ffz--points-reward'}, getRewardTitle(msg.ffz_reward, this.i18n)),
|
||||
cost = e('span', {className: 'ffz--points-cost'}, [
|
||||
e('span', {className: 'ffz--points-icon'}),
|
||||
this.i18n.formatNumber(getRewardCost(msg.ffz_reward))
|
||||
]);
|
||||
|
||||
if (! has_message)
|
||||
return this.i18n.tList('chat.points.user-redeemed', '{user} redeemed {reward} {cost}', {
|
||||
reward, cost,
|
||||
user: e('span', {
|
||||
role: 'button',
|
||||
className: 'chatter-name',
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
onContextMenu: this.actions.handleUserContext
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, user.displayName))
|
||||
});
|
||||
|
||||
return this.i18n.tList('chat.points.redeemed', 'Redeemed {reward} {cost}', {reward, cost});
|
||||
}
|
||||
};
|
||||
|
||||
this.line_types.resub = {
|
||||
getClass: () => `ffz--subscribe-line tw-pd-r-2`,
|
||||
|
||||
renderNotice: (msg, current_user, room, inst, e) => {
|
||||
const months = msg.sub_cumulative || msg.sub_months,
|
||||
setting = this.chat.context.get('chat.subs.show');
|
||||
|
||||
if ( !(setting === 3 || (setting === 1 && out && months > 1) || (setting === 2 && months > 1)) )
|
||||
return null;
|
||||
|
||||
const user = msg.user,
|
||||
plan = msg.sub_plan || {},
|
||||
tier = SUB_TIERS[plan.plan] || 1;
|
||||
|
||||
const sub_msg = this.i18n.tList('chat.sub.main', '{user} subscribed {plan}. ', {
|
||||
user: e('span', {
|
||||
role: 'button',
|
||||
className: 'chatter-name',
|
||||
onClick: inst.ffz_user_click_handler
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
onContextMenu: this.actions.handleUserContext
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, msg.user.displayName)),
|
||||
count: msg.sub_count,
|
||||
tier: SUB_TIERS[msg.sub_plan] || 1,
|
||||
channel: msg.roomLogin
|
||||
});
|
||||
}, user.displayName)),
|
||||
plan: plan.prime ?
|
||||
this.i18n.t('chat.sub.twitch-prime', 'with Prime Gaming') :
|
||||
this.i18n.t('chat.sub.plan', 'at Tier {tier}', {tier})
|
||||
});
|
||||
|
||||
if ( msg.sub_total === 1 )
|
||||
sub_msg.push(this.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!"));
|
||||
else if ( msg.sub_total > 1 )
|
||||
sub_msg.push(this.i18n.t('chat.sub.gift-total', "They've gifted {count} Subs in the channel!", {
|
||||
count: msg.sub_total
|
||||
}));
|
||||
if ( msg.sub_share_streak && msg.sub_streak > 1 ) {
|
||||
sub_msg.push(this.i18n.t(
|
||||
'chat.sub.cumulative-months',
|
||||
"They've subscribed for {cumulative,number} months, currently on a {streak,number} month streak!",
|
||||
{
|
||||
cumulative: msg.sub_cumulative,
|
||||
streak: msg.sub_streak
|
||||
}
|
||||
));
|
||||
|
||||
if ( ! inst.ffz_click_expand )
|
||||
inst.ffz_click_expand = () => {
|
||||
inst.setState({
|
||||
ffz_expanded: ! inst.state.ffz_expanded
|
||||
} else if ( months > 1 ) {
|
||||
sub_msg.push(this.i18n.t(
|
||||
'chat.sub.months',
|
||||
"They've subscribed for {count,number} months!",
|
||||
{
|
||||
count: months
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
if ( ! this.chat.context.get('chat.subs.compact') )
|
||||
sub_msg.ffz_icon = e('span', {
|
||||
className: `ffz-i-${plan.prime ? 'crown' : 'star'} tw-mg-r-05`
|
||||
});
|
||||
|
||||
return sub_msg;
|
||||
}
|
||||
};
|
||||
|
||||
this.line_types.ritual = {
|
||||
getClass: () => `ffz--ritual-line tw-pd-r-2`,
|
||||
|
||||
renderNotice: (msg, current_user, room, inst, e) => {
|
||||
const user = msg.user;
|
||||
|
||||
if ( msg.ritual === 'new_chatter' ) {
|
||||
return this.i18n.tList('chat.ritual', '{user} is new here. Say hello!', {
|
||||
user: e('span', {
|
||||
role: 'button',
|
||||
className: 'chatter-name',
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
onContextMenu: this.actions.handleUserContext
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, user.displayName))
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const expanded = this.chat.context.get('chat.subs.merge-gifts-visibility') ?
|
||||
! inst.state.ffz_expanded : inst.state.ffz_expanded;
|
||||
this.line_types.sub_gift = {
|
||||
getClass: () => 'ffz--subscribe-line',
|
||||
|
||||
let sub_list = null;
|
||||
if( expanded && mystery && mystery.recipients && mystery.recipients.length > 0 ) {
|
||||
const the_list = [];
|
||||
for(const x of mystery.recipients) {
|
||||
if ( the_list.length )
|
||||
the_list.push(', ');
|
||||
renderNotice: (msg, current_user, room, inst, e) => {
|
||||
const user = msg.user,
|
||||
|
||||
the_list.push(e('span', {
|
||||
plan = msg.sub_plan || {},
|
||||
months = msg.sub_months || 1,
|
||||
tier = SUB_TIERS[plan.plan] || 1;
|
||||
|
||||
let sub_msg;
|
||||
|
||||
const bits = {
|
||||
months,
|
||||
user: (msg.sub_anon || user.username === 'ananonymousgifter') ?
|
||||
this.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
|
||||
e('span', {
|
||||
role: 'button',
|
||||
className: 'chatter-name',
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
onContextMenu: this.actions.handleUserContext
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, user.displayName)),
|
||||
plan: plan.plan === 'custom' ? '' :
|
||||
this.i18n.t('chat.sub.gift-plan', 'Tier {tier}', {tier}),
|
||||
recipient: e('span', {
|
||||
role: 'button',
|
||||
className: 'ffz--giftee-name',
|
||||
className: 'chatter-name',
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
'data-user': JSON.stringify(x)
|
||||
'data-user': JSON.stringify(msg.sub_recipient)
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, x.displayName)));
|
||||
}, msg.sub_recipient.displayName))
|
||||
};
|
||||
|
||||
if ( months <= 1 )
|
||||
sub_msg = this.i18n.tList('chat.sub.mystery', '{user} gifted a {plan} Sub to {recipient}! ', bits);
|
||||
else
|
||||
sub_msg = this.i18n.tList('chat.sub.gift-months', '{user} gifted {months, plural, one {# month} other {# months}} of {plan} Sub to {recipient}!', bits);
|
||||
|
||||
if ( msg.sub_total === 1 )
|
||||
sub_msg.push(this.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!"));
|
||||
else if ( msg.sub_total > 1 )
|
||||
sub_msg.push(this.i18n.t('chat.sub.gift-total', "They've gifted {count,number} Subs in the channel!", {
|
||||
count: msg.sub_total
|
||||
}));
|
||||
|
||||
if ( ! this.chat.context.get('chat.subs.compact') )
|
||||
sub_msg.ffz_icon = e('span', {
|
||||
className: `ffz-i-${plan.prime ? 'crown' : 'star'} tw-mg-r-05`
|
||||
});
|
||||
|
||||
return sub_msg;
|
||||
}
|
||||
}
|
||||
|
||||
this.line_types.sub_mystery = {
|
||||
|
||||
getClass: () => 'ffz--subscribe-line',
|
||||
|
||||
renderNotice: (msg, user, room, inst, e) => {
|
||||
const mystery = msg.mystery;
|
||||
if ( mystery )
|
||||
mystery.line = inst;
|
||||
|
||||
const sub_msg = this.i18n.tList('chat.sub.gift', "{user} is gifting {count, plural, one {# Tier {tier} Sub} other {# Tier {tier} Subs}} to {channel}'s community! ", {
|
||||
user: (msg.sub_anon || msg.user.username === 'ananonymousgifter') ?
|
||||
this.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') :
|
||||
e('span', {
|
||||
role: 'button',
|
||||
className: 'chatter-name',
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
onContextMenu: this.actions.handleUserContext
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, msg.user.displayName)),
|
||||
count: msg.sub_count,
|
||||
tier: SUB_TIERS[msg.sub_plan] || 1,
|
||||
channel: msg.roomLogin
|
||||
});
|
||||
|
||||
if ( msg.sub_total === 1 )
|
||||
sub_msg.push(this.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!"));
|
||||
else if ( msg.sub_total > 1 )
|
||||
sub_msg.push(this.i18n.t('chat.sub.gift-total', "They've gifted {count} Subs in the channel!", {
|
||||
count: msg.sub_total
|
||||
}));
|
||||
|
||||
if ( ! inst.ffz_click_expand )
|
||||
inst.ffz_click_expand = () => {
|
||||
inst.setState({
|
||||
ffz_expanded: ! inst.state.ffz_expanded
|
||||
});
|
||||
}
|
||||
|
||||
const expanded = this.chat.context.get('chat.subs.merge-gifts-visibility') ?
|
||||
! inst.state.ffz_expanded : inst.state.ffz_expanded;
|
||||
|
||||
let sub_list = null;
|
||||
if( expanded && mystery && mystery.recipients && mystery.recipients.length > 0 ) {
|
||||
const the_list = [];
|
||||
for(const x of mystery.recipients) {
|
||||
if ( the_list.length )
|
||||
the_list.push(', ');
|
||||
|
||||
the_list.push(e('span', {
|
||||
role: 'button',
|
||||
className: 'ffz--giftee-name',
|
||||
onClick: inst.ffz_user_click_handler,
|
||||
'data-user': JSON.stringify(x)
|
||||
}, e('span', {
|
||||
className: 'tw-c-text-base tw-strong'
|
||||
}, x.displayName)));
|
||||
}
|
||||
|
||||
sub_list = e('div', {
|
||||
className: 'tw-mg-t-05 tw-border-t tw-pd-t-05 tw-c-text-alt-2'
|
||||
}, the_list);
|
||||
}
|
||||
|
||||
sub_list = e('div', {
|
||||
className: 'tw-mg-t-05 tw-border-t tw-pd-t-05 tw-c-text-alt-2'
|
||||
}, the_list);
|
||||
}
|
||||
const target = [
|
||||
sub_msg
|
||||
];
|
||||
|
||||
const extra_ts = this.chat.context.get('chat.extra-timestamps');
|
||||
if ( mystery )
|
||||
target.push(e('span', {
|
||||
className: `tw-pd-l-05 tw-font-size-4 ffz-i-${expanded ? 'down' : 'right'}-dir`
|
||||
}));
|
||||
|
||||
return inst.ffzDrawLine(
|
||||
msg,
|
||||
`ffz-notice-line user-notice-line tw-pd-y-05 ffz--subscribe-line`,
|
||||
[
|
||||
const out = [
|
||||
e('div', {
|
||||
className: 'tw-flex tw-c-text-alt-2',
|
||||
className: 'tw-full-width tw-c-text-alt-2',
|
||||
onClick: inst.ffz_click_expand
|
||||
}, [
|
||||
this.chat.context.get('chat.subs.compact') ? null :
|
||||
e('figure', {
|
||||
className: `ffz-i-star${msg.sub_anon ? '-empty' : ''} tw-mg-r-05`
|
||||
}),
|
||||
e('div', null, [
|
||||
extra_ts && (inst.props.showTimestamps || inst.props.isHistorical) && e('span', {
|
||||
className: 'chat-line__timestamp'
|
||||
}, this.chat.formatTime(msg.timestamp)),
|
||||
msg.sub_anon ? null : this.actions.renderInline(msg, inst.props.showModerationIcons, u, r, e),
|
||||
sub_msg
|
||||
]),
|
||||
mystery ? e('div', {
|
||||
className: 'tw-pd-l-05 tw-font-size-4'
|
||||
}, e('figure', {
|
||||
className: `ffz-i-${expanded ? 'down' : 'right'}-dir tw-pd-y-1`
|
||||
})) : null
|
||||
]),
|
||||
}, target),
|
||||
sub_list
|
||||
],
|
||||
[
|
||||
e('button', {
|
||||
className: 'tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-button-icon ffz-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip ffz-tooltip--no-mouse',
|
||||
'data-title': 'Test'
|
||||
}, e('span', {
|
||||
className: 'tw-button-icon__icon'
|
||||
}, e('figure', {className: 'ffz-i-threads'}))),
|
||||
e('button', {
|
||||
className: 'tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-button-icon ffz-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip ffz-tooltip--no-mouse',
|
||||
'data-title': 'Thing'
|
||||
}, e('span', {
|
||||
className: 'tw-button-icon__icon'
|
||||
}, e('figure', {className: 'ffz-i-cog'})))
|
||||
],
|
||||
null
|
||||
);
|
||||
}*/
|
||||
];
|
||||
|
||||
if ( ! this.chat.context.get('chat.subs.compact') )
|
||||
out.ffz_icon = e('span', {
|
||||
className: `ffz-i-star${msg.sub_anon ? '-empty' : ''} tw-mg-r-05`
|
||||
});
|
||||
|
||||
out.ffz_target = target;
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
this.ChatLine = this.fine.define(
|
||||
'chat-line',
|
||||
|
@ -172,6 +344,21 @@ export default class ChatLine extends Module {
|
|||
this.on('chat:update-line-badges', this.updateLineBadges, this);
|
||||
this.on('i18n:update', this.rerenderLines, this);
|
||||
|
||||
this.on('experiments:changed:line_renderer', () => {
|
||||
const value = this.experiments.get('line_renderer'),
|
||||
cls = this.ChatLine._class;
|
||||
|
||||
this.log.debug('Changing line renderer:', value ? 'new' : 'old');
|
||||
|
||||
if (cls) {
|
||||
cls.prototype.render = this.experiments.get('line_renderer')
|
||||
? cls.prototype.ffzNewRender
|
||||
: cls.prototype.ffzOldRender;
|
||||
|
||||
this.rerenderLines();
|
||||
}
|
||||
});
|
||||
|
||||
for(const setting of RERENDER_SETTINGS)
|
||||
this.chat.context.on(`changed:${setting}`, this.rerenderLines, this);
|
||||
|
||||
|
@ -441,43 +628,40 @@ export default class ChatLine extends Module {
|
|||
]);
|
||||
}
|
||||
|
||||
cls.prototype.ffzDrawLine = function(msg, cls, out, hover_actions, bg_css) {
|
||||
const anim_hover = t.chat.context.get('chat.emotes.animated') === 2;
|
||||
|
||||
if (hover_actions) {
|
||||
cls = `${cls} tw-relative`;
|
||||
out = [
|
||||
e('div', {
|
||||
className: 'chat-line__message-highlight tw-absolute tw-border-radius-medium tw-top-0 tw-bottom-0 tw-right-0 tw-left-0',
|
||||
'data-test-selector': 'chat-message-highlight'
|
||||
}),
|
||||
e('div', {
|
||||
className: 'chat-line__message-container tw-relative'
|
||||
}, out),
|
||||
e('div', {
|
||||
className: 'chat-line__reply-icon tw-absolute tw-border-radius-medium tw-c-background-base tw-elevation-1'
|
||||
}, hover_actions)
|
||||
];
|
||||
}
|
||||
|
||||
return e('div', {
|
||||
className: `${cls}${msg.mentioned ? ' ffz-mentioned' : ''}${bg_css ? ' ffz-custom-color' : ''}`,
|
||||
style: {backgroundColor: bg_css},
|
||||
'data-room-id': msg.roomId,
|
||||
'data-room': msg.roomLogin,
|
||||
'data-user-id': msg.user.userID,
|
||||
'data-user': msg.user.userLogin && msg.user.userLogin.toLowerCase(),
|
||||
onMouseOver: anim_hover ? t.chat.emotes.animHover : null,
|
||||
onMouseOut: anim_hover ? t.chat.emotes.animLeave : null
|
||||
}, out);
|
||||
}
|
||||
|
||||
/*cls.prototype.new_render = function() { try {
|
||||
cls.prototype.ffzNewRender = function() { try {
|
||||
this._ffz_no_scan = true;
|
||||
|
||||
const msg = t.chat.standardizeMessage(this.props.message),
|
||||
reply_mode = t.chat.context.get('chat.replies.style');
|
||||
override_mode = t.chat.context.get('chat.filtering.display-deleted');
|
||||
|
||||
// Before anything else, check to see if the deleted message view is set
|
||||
// to BRIEF and the message is deleted. In that case we can exit very
|
||||
// early.
|
||||
let mod_mode = this.props.deletedMessageDisplay;
|
||||
if ( override_mode )
|
||||
mod_mode = override_mode;
|
||||
else if ( ! this.props.isCurrentUserModerator && mod_mode === 'DETAILED' )
|
||||
mod_mode = 'LEGACY';
|
||||
|
||||
if ( mod_mode === 'BRIEF' && msg.deleted ) {
|
||||
const deleted_count = this.props.deletedCount;
|
||||
if ( deleted_count == null )
|
||||
return null;
|
||||
|
||||
return e(
|
||||
'div', {
|
||||
className: 'chat-line__status'
|
||||
},
|
||||
t.i18n.t('chat.deleted-messages', `{count,plural,
|
||||
one {One message was deleted by a moderator.}
|
||||
other {# messages were deleted by a moderator.}
|
||||
}`, {
|
||||
count: deleted_count
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Get the current room id and login. We might need to look these up.
|
||||
let room = msg.roomLogin ? msg.roomLogin : msg.channel ? msg.channel.slice(1) : undefined,
|
||||
room_id = msg.roomId ? msg.roomId : this.props.channelID;
|
||||
|
||||
|
@ -493,21 +677,23 @@ export default class ChatLine extends Module {
|
|||
room_id = msg.roomId = r.id;
|
||||
}
|
||||
|
||||
const u = t.site.getUser(),
|
||||
r = {id: room_id, login: room};
|
||||
// Construct the current room and current user objects.
|
||||
const current_user = t.site.getUser(),
|
||||
current_room = {id: room_id, login: room};
|
||||
|
||||
const has_replies = this.props && !!(this.props.hasReply || this.props.reply || ! this.props.replyRestrictedReason),
|
||||
const reply_mode = t.chat.context.get('chat.replies.style'),
|
||||
has_replies = this.props && !!(this.props.hasReply || this.props.reply || ! this.props.replyRestrictedReason),
|
||||
can_replies = has_replies && msg.message && ! msg.deleted && ! this.props.disableReplyClick,
|
||||
can_reply = can_replies && u && u.login !== msg.user?.login && ! msg.reply,
|
||||
twitch_clickable = reply_mode === 1 && can_replies && (!!msg.reply || can_reply);
|
||||
can_reply = can_replies && (has_replies || (current_user && current_user.login !== msg.user?.login));
|
||||
|
||||
if ( u ) {
|
||||
u.moderator = this.props.isCurrentUserModerator;
|
||||
u.staff = this.props.isCurrentUserStaff;
|
||||
u.reply_mode = reply_mode;
|
||||
u.can_reply = can_reply;
|
||||
if ( current_user ) {
|
||||
current_user.moderator = this.props.isCurrentUserModerator;
|
||||
current_user.staff = this.props.isCurrentUserStaff;
|
||||
current_user.reply_mode = reply_mode;
|
||||
current_user.can_reply = can_reply;
|
||||
}
|
||||
|
||||
// Set up our click handlers as necessary.
|
||||
if ( ! this.ffz_open_reply )
|
||||
this.ffz_open_reply = this.ffzOpenReply.bind(this);
|
||||
|
||||
|
@ -524,7 +710,7 @@ export default class ChatLine extends Module {
|
|||
if ( ds && ds.user ) {
|
||||
try {
|
||||
target_user = JSON.parse(ds.user);
|
||||
} catch(err) { /* nothing~! * / }
|
||||
} catch(err) { /* nothing~! */ }
|
||||
}
|
||||
|
||||
const fe = new FFZEvent({
|
||||
|
@ -532,7 +718,7 @@ export default class ChatLine extends Module {
|
|||
event,
|
||||
message: msg,
|
||||
user: target_user,
|
||||
room: r
|
||||
room: current_room
|
||||
});
|
||||
|
||||
t.emit('chat:user-click', fe);
|
||||
|
@ -546,11 +732,316 @@ export default class ChatLine extends Module {
|
|||
this.ffz_user_click_handler = this.openViewerCard || this.usernameClickHandler; //event => event.ctrlKey ? this.usernameClickHandler(event) : t.viewer_cards.openCard(r, user, event);
|
||||
}
|
||||
|
||||
// Do we have a special renderer?
|
||||
if ( msg.ffz_type && t.line_types[msg.ffz_type] )
|
||||
return t.line_types[msg.ffz_type](msg, u, r, this, e);
|
||||
let notice;
|
||||
let klass;
|
||||
let bg_css = null;
|
||||
|
||||
return this.ffz_old_render();
|
||||
// Do we have a special renderer?
|
||||
let type = msg.ffz_type && t.line_types[msg.ffz_type];
|
||||
if ( ! type && msg.bits > 0 && t.chat.context.get('chat.bits.cheer-notice') )
|
||||
type = t.line_types.cheer;
|
||||
|
||||
if ( type ) {
|
||||
if ( type.render )
|
||||
return type.render(msg, current_user, current_room, this, e);
|
||||
|
||||
if ( type.renderNotice )
|
||||
notice = type.renderNotice(msg, current_user, current_room, this, e);
|
||||
|
||||
if ( type.getClass )
|
||||
klass = type.getClass(msg, current_user, current_room, this, e);
|
||||
}
|
||||
|
||||
// Render the line.
|
||||
const user = msg.user,
|
||||
anim_hover = t.chat.context.get('chat.emotes.animated') === 2;
|
||||
|
||||
// Cache the lower login
|
||||
if ( user && ! user.lowerLogin && user.userLogin )
|
||||
user.lowerLogin = user.userLogin.toLowerCase();
|
||||
|
||||
// Ensure we have a string for klass.
|
||||
klass = klass || '';
|
||||
|
||||
// RENDERING: Start~
|
||||
|
||||
// First, check how we should handle a deleted message.
|
||||
let show;
|
||||
let deleted;
|
||||
let mod_action = null;
|
||||
|
||||
if ( mod_mode === 'BRIEF' ) {
|
||||
// We already handle msg.deleted for BRIEF earlier than this.
|
||||
show = true;
|
||||
deleted = false;
|
||||
|
||||
} else if ( mod_mode === 'DETAILED' ) {
|
||||
show = true;
|
||||
deleted = msg.deleted;
|
||||
|
||||
} else {
|
||||
show = this.state?.alwaysShowMessage || ! msg.deleted;
|
||||
deleted = false;
|
||||
}
|
||||
|
||||
if ( msg.deleted ) {
|
||||
const show_mode = t.chat.context.get('chat.filtering.display-mod-action');
|
||||
if ( show_mode === 2 || (show_mode === 1 && mod_mode === 'DETAILED') ) {
|
||||
const action = msg.modActionType;
|
||||
if ( action === 'timeout' )
|
||||
mod_action = t.i18n.t('chat.mod-action.timeout',
|
||||
'{duration} Timeout'
|
||||
, {
|
||||
duration: print_duration(msg.duration || 1)
|
||||
});
|
||||
else if ( action === 'ban' )
|
||||
mod_action = t.i18n.t('chat.mod-action.ban', 'Banned');
|
||||
else if ( action === 'delete' || ! action )
|
||||
mod_action = t.i18n.t('chat.mod-action.delete', 'Deleted');
|
||||
|
||||
if ( mod_action && msg.modLogin )
|
||||
mod_action = t.i18n.t('chat.mod-action.by', '{action} by {login}', {
|
||||
login: msg.modLogin,
|
||||
action: mod_action
|
||||
});
|
||||
|
||||
if ( mod_action )
|
||||
mod_action = e('span', {
|
||||
className: 'tw-pd-l-05',
|
||||
'data-test-selector': 'chat-deleted-message-attribution'
|
||||
}, `(${mod_action})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check to see if we have message content to render.
|
||||
const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, current_user),
|
||||
has_message = tokens.length > 0 || ! notice;
|
||||
|
||||
let message;
|
||||
|
||||
if ( has_message ) {
|
||||
// Let's calculate some remaining values that we need.
|
||||
const reply_tokens = (reply_mode === 2 || (reply_mode === 1 && this.props.repliesAppearancePreference && this.props.repliesAppearancePreference !== 'expanded'))
|
||||
? (msg.ffz_reply = msg.ffz_reply || t.chat.tokenizeReply(this.props.reply))
|
||||
: null;
|
||||
|
||||
const is_action = t.parent.message_types && t.parent.message_types.Action === msg.messageType,
|
||||
action_style = is_action ? t.chat.context.get('chat.me-style') : 0,
|
||||
action_italic = action_style >= 2,
|
||||
action_color = action_style === 1 || action_style === 3;
|
||||
|
||||
const raw_color = t.overrides.getColor(user.id) || user.color,
|
||||
color = t.parent.colors.process(raw_color);
|
||||
|
||||
const rich_content = show && FFZRichContent && t.chat.pluckRichContent(tokens, msg);
|
||||
|
||||
// First, render the user block.
|
||||
const username = t.chat.formatUser(user, e),
|
||||
override_name = t.overrides.getName(user.id);
|
||||
|
||||
const user_props = {
|
||||
className: `chat-line__username notranslate${override_name ? ' ffz--name-override tw-relative ffz-il-tooltip__container' : ''} ${msg.ffz_user_class ?? ''}`,
|
||||
role: 'button',
|
||||
style: { color },
|
||||
onClick: this.ffz_user_click_handler,
|
||||
onContextMenu: t.actions.handleUserContext
|
||||
};
|
||||
|
||||
if ( msg.ffz_user_props )
|
||||
Object.assign(user_props, msg.ffz_user_props);
|
||||
|
||||
if ( msg.ffz_user_style )
|
||||
Object.assign(user_props.style, msg.ffz_user_style);
|
||||
|
||||
const user_block = e(
|
||||
'span',
|
||||
user_props,
|
||||
override_name
|
||||
? [
|
||||
e('span', {
|
||||
className: 'chat-author__display-name'
|
||||
}, override_name),
|
||||
e('div', {
|
||||
className: 'ffz-il-tooltip ffz-il-tooldip--down ffz-il-tooltip--align-center'
|
||||
}, username)
|
||||
]
|
||||
: username
|
||||
);
|
||||
|
||||
// The timestamp.
|
||||
const timestamp = (this.props.showTimestamps || this.props.isHistorical)
|
||||
? e('span', { className: 'chat-line__timestamp' }, t.chat.formatTime(msg.timestamp))
|
||||
: null;
|
||||
|
||||
// The reply token for FFZ style.
|
||||
const reply_token = show && has_replies && reply_tokens
|
||||
? t.chat.renderTokens(reply_tokens, e)
|
||||
: null;
|
||||
|
||||
// Check for a Twitch-style points highlight.
|
||||
const twitch_highlight = msg.ffz_reward_highlight && t.chat.context.get('chat.points.allow-highlight') === 1;
|
||||
|
||||
// The reply element for Twitch style.
|
||||
const twitch_reply = reply_mode === 1 && this.props.reply && this.props.repliesAppearancePreference && this.props.repliesAppearancePreference === 'expanded'
|
||||
? this.renderReplyLine()
|
||||
: null;
|
||||
|
||||
// Now assemble the pieces.
|
||||
message = [
|
||||
twitch_reply,
|
||||
|
||||
// The preamble
|
||||
timestamp,
|
||||
t.actions.renderInline(msg, this.props.showModerationIcons, current_user, current_room, e, this),
|
||||
this.renderInlineHighlight ? this.renderInlineHighlight() : null,
|
||||
|
||||
// Badges
|
||||
e('span', {
|
||||
className: 'chat-line__message--badges'
|
||||
}, t.chat.badges.render(msg, e)),
|
||||
|
||||
// User
|
||||
user_block,
|
||||
|
||||
// The separator
|
||||
e('span', {'aria-hidden': true}, is_action ? ' ' : ': '),
|
||||
|
||||
// Reply Token
|
||||
reply_token,
|
||||
|
||||
// Message
|
||||
show
|
||||
? e(
|
||||
'span',
|
||||
{
|
||||
className: `message ${action_italic ? 'chat-line__message-body--italicized' : ''} ${twitch_highlight ? 'chat-line__message-body--highlighted' : ''}`,
|
||||
style: action_color ? { color} : null
|
||||
},
|
||||
t.chat.renderTokens(
|
||||
tokens, e, (reply_mode !== 0 && has_replies) ? this.props.reply : null
|
||||
)
|
||||
)
|
||||
: e(
|
||||
'span',
|
||||
{
|
||||
className: 'chat-line__message--deleted'
|
||||
},
|
||||
e('a', {
|
||||
href: '',
|
||||
onClick: this.alwaysShowMessage
|
||||
}, t.i18n.t('chat.message-deleted', '<message deleted>'))
|
||||
),
|
||||
|
||||
// Moderation Action
|
||||
mod_action,
|
||||
|
||||
// Rich Content
|
||||
rich_content
|
||||
? e(FFZRichContent, rich_content)
|
||||
: null
|
||||
];
|
||||
}
|
||||
|
||||
// Is there a notice?
|
||||
let out;
|
||||
|
||||
if ( notice ) {
|
||||
const is_raw = Array.isArray(notice.ffz_target);
|
||||
|
||||
if ( ! message ) {
|
||||
const want_ts = t.chat.context.get('chat.extra-timestamps'),
|
||||
timestamp = want_ts && (this.props.showTimestamps || this.props.isHistorical)
|
||||
? e('span', { className: 'chat-line__timestamp' }, t.chat.formatTime(msg.timestamp))
|
||||
: null;
|
||||
|
||||
const actions = t.actions.renderInline(msg, this.props.showModerationIcons, current_user, current_room, e, this);
|
||||
|
||||
if ( is_raw )
|
||||
notice.ffz_target.unshift(notice.ffz_icon ?? null, timestamp, actions);
|
||||
|
||||
else
|
||||
notice = [
|
||||
notice.ffz_icon ?? null,
|
||||
timestamp,
|
||||
actions,
|
||||
notice
|
||||
];
|
||||
|
||||
} else {
|
||||
if ( notice.ffz_icon )
|
||||
notice = [
|
||||
notice.ffz_icon,
|
||||
notice
|
||||
];
|
||||
|
||||
message = e(
|
||||
'div',
|
||||
{
|
||||
className: 'chat-line--inline chat-line__message',
|
||||
'data-room-id': msg.roomId ?? current_room.id,
|
||||
'data-room': msg.roomLogin,
|
||||
'data-user-id': user?.userID,
|
||||
'data-user': user?.lowerLogin,
|
||||
},
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
klass = `${klass} ffz-notice-line user-notice-line tw-pd-y-05`;
|
||||
|
||||
if ( ! is_raw )
|
||||
notice = e('div', {
|
||||
className: 'tw-c-text-alt-2'
|
||||
}, notice);
|
||||
|
||||
if ( message )
|
||||
out = [notice, message];
|
||||
else
|
||||
out = notice;
|
||||
|
||||
} else {
|
||||
klass = `${klass} chat-line__message`;
|
||||
out = message;
|
||||
}
|
||||
|
||||
// Check for hover actions, as those require we wrap the output in a few extra elements.
|
||||
const hover_actions = (user && msg.id)
|
||||
? t.actions.renderHover(msg, this.props.showModerationIcons, current_user, current_room, e, this)
|
||||
: null;
|
||||
|
||||
if ( hover_actions ) {
|
||||
klass = `${klass} tw-relative`;
|
||||
|
||||
out = [
|
||||
e('div', {
|
||||
className: 'chat-line__message-highlight tw-absolute tw-border-radius-medium tw-top-0 tw-bottom-0 tw-right-0 tw-left-0',
|
||||
'data-test-selector': 'chat-message-highlight'
|
||||
}),
|
||||
e('div', {
|
||||
className: 'chat-line__message-container tw-relative'
|
||||
}, out),
|
||||
hover_actions
|
||||
];
|
||||
}
|
||||
|
||||
// If we don't have an override background color, try to assign
|
||||
// a value based on the mention.
|
||||
if (bg_css == null)
|
||||
bg_css = msg.mentioned && msg.mention_color
|
||||
? t.parent.inverse_colors.process(msg.mention_color)
|
||||
: null;
|
||||
|
||||
// Now, return the final chat line color.
|
||||
return e('div', {
|
||||
className: `${klass}${deleted ? ' ffz--deleted-message' : ''}${msg.mentioned ? ' ffz-mentioned' : ''}${bg_css ? ' ffz-custom-color' : ''}`,
|
||||
style: {backgroundColor: bg_css},
|
||||
'data-room-id': msg.roomId ?? current_room.id,
|
||||
'data-room': msg.roomLogin,
|
||||
'data-user-id': user?.userID,
|
||||
'data-user': user?.lowerLogin,
|
||||
onMouseOver: anim_hover ? t.chat.emotes.animHover : null,
|
||||
onMouseOut: anim_hover ? t.chat.emotes.animLeave : null
|
||||
}, out);
|
||||
|
||||
} catch(err) {
|
||||
t.log.error(err);
|
||||
|
@ -573,9 +1064,9 @@ export default class ChatLine extends Module {
|
|||
|
||||
return 'An error occurred rendering this chat line.';
|
||||
}
|
||||
} };*/
|
||||
} };
|
||||
|
||||
cls.prototype.render = function() { try {
|
||||
cls.prototype.ffzOldRender = function() { try {
|
||||
this._ffz_no_scan = true;
|
||||
|
||||
const types = t.parent.message_types || {},
|
||||
|
@ -1123,15 +1614,6 @@ other {# messages were deleted by a moderator.}
|
|||
return null;
|
||||
|
||||
if ( twitch_clickable ) {
|
||||
let icon, title;
|
||||
if ( can_reply ) {
|
||||
icon = e('figure', {className: 'ffz-i-reply'});
|
||||
title = t.i18n.t('chat.actions.reply', 'Reply to Message');
|
||||
} else {
|
||||
icon = e('figure', {className: 'ffz-i-threads'});
|
||||
title = t.i18n.t('chat.actions.reply.thread', 'Open Thread');
|
||||
}
|
||||
|
||||
out = [
|
||||
e('div', {
|
||||
className: 'chat-line__message-highlight tw-absolute tw-border-radius-medium tw-top-0 tw-bottom-0 tw-right-0 tw-left-0',
|
||||
|
@ -1182,6 +1664,10 @@ other {# messages were deleted by a moderator.}
|
|||
}
|
||||
} }
|
||||
|
||||
cls.prototype.render = this.experiments.get('line_renderer')
|
||||
? cls.prototype.ffzNewRender
|
||||
: cls.prototype.ffzOldRender;
|
||||
|
||||
// Do this after a short delay to hopefully reduce the chance of React
|
||||
// freaking out on us.
|
||||
setTimeout(() => this.ChatLine.forceUpdate());
|
||||
|
|
|
@ -460,8 +460,10 @@
|
|||
top: -1rem;
|
||||
display: none;
|
||||
|
||||
--ffz-chat-actions-size: 2rem;
|
||||
--ffz-chat-actions-size: var(--ffz-chat-actions-hover-size);
|
||||
--ffz-chat-actions-padding: var(--ffz-chat-actions-hover-padding);
|
||||
|
||||
.ffz-notice-line:hover &,
|
||||
.chat-line__message:hover & {
|
||||
display: block;
|
||||
}
|
||||
|
@ -477,12 +479,16 @@
|
|||
background-color: var(--color-background-base);
|
||||
border-radius: var(--border-radius-medium);
|
||||
|
||||
figure {
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
&.ffz-has-modifier {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > .ffz-mod-icon {
|
||||
padding: 0.5rem;
|
||||
padding: var(--ffz-chat-actions-padding);
|
||||
|
||||
background-color: var(--color-background-button-text-default);
|
||||
color: var(--color-fill-button-icon);
|
||||
|
|
259
src/std-components/emote-picker.vue
Normal file
259
src/std-components/emote-picker.vue
Normal file
|
@ -0,0 +1,259 @@
|
|||
<template lang="html">
|
||||
<div v-on-clickaway="close" class="ffz--emote-picker tw-relative">
|
||||
<div class="tw-search-input tw-full-width">
|
||||
<label v-if="isOpen" :for="'emote-search$' + id" class="tw-hide-accessible">{{ t('setting.emote.search', 'Search for Emote') }}</label>
|
||||
<div class="tw-relative">
|
||||
<div class="tw-absolute tw-align-items-center tw-c-text-alt-2 tw-flex tw-full-height ffz-input__icon tw-justify-content-center tw-left-0 tw-top-0 tw-z-default">
|
||||
<figure class="tw-mg-y-05 tw-mg-x-05">
|
||||
<img class="ffz-preview-emote" v-if="val.src" :src="val.src" />
|
||||
</figure>
|
||||
</div>
|
||||
<input
|
||||
:id="'emote-search$' + id"
|
||||
ref="input"
|
||||
:placeholder="t('setting.emote.search', 'Search for Emote')"
|
||||
:value="isOpen ? search : valName"
|
||||
:class="[clearable ? 'tw-pd-r-5' : 'tw-pd-r-1']"
|
||||
type="text"
|
||||
class="tw-block tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-l-3 tw-pd-y-05"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
@input="update"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown.escape="open = false"
|
||||
/>
|
||||
<button
|
||||
v-if="clearable"
|
||||
class="tw-absolute tw-right-0 tw-top-0 tw-button tw-button--text ffz-il-tooltip__container"
|
||||
@click="change('', false)"
|
||||
@keydown.escape="open = false"
|
||||
@focus="onFocus(false)"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<span class="tw-button__text ffz-i-trash" />
|
||||
<div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
|
||||
{{ t('setting.icon.clear', 'Clear') }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<balloon v-if="isOpen" :dir="direction" color="background-base">
|
||||
<div ref="list">
|
||||
<simplebar classes="scrollable-area--suppress-scroll-x ffz--emote-picker__list">
|
||||
<div v-if="visible.length" role="radiogroup" class="tw-pd-1 tw-flex tw-flex-wrap tw-justify-content-between">
|
||||
<div
|
||||
v-for="i of visible"
|
||||
:key="`${i.provider}:${i.id}`"
|
||||
:aria-checked="val.provider === i.provider && val.id === i.id"
|
||||
:class="{'ffz-interactable--selected': val.provider === i.provider && val.id === i.id}"
|
||||
:data-provider="i.provider"
|
||||
:data-id="i.id"
|
||||
:data-set="i.set_id"
|
||||
:data-name="i.name"
|
||||
class="ffz-tooltip ffz-icon ffz-interactable ffz-interactable--hover-enabled ffz-interactable--default tw-interactive"
|
||||
role="radio"
|
||||
tabindex="0"
|
||||
data-tooltip-type="emote"
|
||||
@keydown.space.stop.prevent=""
|
||||
@keyup.space="change(i)"
|
||||
@keyup.enter="change(i)"
|
||||
@click="change(i)"
|
||||
@focus="onFocus(false)"
|
||||
@blur="onBlur"
|
||||
>
|
||||
<figure :class="`tw-mg-y-05 tw-mg-x-1`">
|
||||
<img :src="i.src" />
|
||||
</figure>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="! emotes.length" class="tw-align-center tw-pd-1 tw-c-text-alt-2">
|
||||
{{ t('setting.emote.none', 'unable to load emote data') }}
|
||||
<div class="tw-mg-t-05">
|
||||
{{ t('setting.emote.none-about', 'Please make sure you have the FFZ Emote Menu enabled, and that you use this from a page that loads chat.') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="tw-align-center tw-pd-1 tw-c-text-alt-2">
|
||||
{{ t('setting.actions.empty-search', 'no results') }}
|
||||
</div>
|
||||
</simplebar>
|
||||
</div>
|
||||
</balloon>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { debounce } from 'utilities/object';
|
||||
|
||||
let id = 0;
|
||||
|
||||
function readEmoteMenuEmotes(input, out, seen) {
|
||||
if ( Array.isArray(input) ) {
|
||||
for(const item of input)
|
||||
readEmoteMenuEmotes(item, out, seen);
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! Array.isArray(input?.emotes) )
|
||||
return;
|
||||
|
||||
for(const emote of input.emotes) {
|
||||
if ( emote.locked || seen.has(emote.name) )
|
||||
continue;
|
||||
|
||||
seen.add(emote.name);
|
||||
|
||||
out.push({
|
||||
provider: emote.provider,
|
||||
id: emote.id,
|
||||
set_id: emote.set_id,
|
||||
name: emote.name,
|
||||
lname: emote.name && emote.name.toLowerCase(),
|
||||
src: emote.src
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Object,
|
||||
alwaysOpen: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'down'
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
id: id++,
|
||||
open: false,
|
||||
val: this.value,
|
||||
search: '',
|
||||
emotes: []
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
visible() {
|
||||
if ( ! this.search || ! this.search.length )
|
||||
return this.emotes;
|
||||
|
||||
const search = this.search.toLowerCase();
|
||||
return this.emotes.filter(x => x.lname && x.lname.indexOf(search) !== -1);
|
||||
},
|
||||
|
||||
valName() {
|
||||
return this.val?.name;
|
||||
},
|
||||
|
||||
isOpen() {
|
||||
return this.alwaysOpen || this.open
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
value() {
|
||||
this.val = this.value;
|
||||
},
|
||||
|
||||
isOpen() {
|
||||
if ( ! this.isOpen ) {
|
||||
requestAnimationFrame(() => {
|
||||
const ffz = FrankerFaceZ.get();
|
||||
if ( ffz )
|
||||
ffz.emit('tooltips:cleanup');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeLoadEmotes();
|
||||
|
||||
this.$nextTick(() => {
|
||||
if ( this.val ) {
|
||||
const root = this.$refs.list,
|
||||
el = root && root.querySelector('.ffz-interactable--selected');
|
||||
|
||||
if ( el )
|
||||
el.scrollIntoViewIfNeeded();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.maybeClose = debounce(this.maybeClose, 10);
|
||||
},
|
||||
|
||||
methods: {
|
||||
maybeLoadEmotes() {
|
||||
if ( ! this.emotes || ! this.emotes.length ) {
|
||||
const emotes = [],
|
||||
seen = new Set,
|
||||
menu = window.ffz_menu,
|
||||
state = menu?.state;
|
||||
|
||||
if ( menu ) {
|
||||
menu.loadData();
|
||||
readEmoteMenuEmotes(state?.channel_sets, emotes, seen);
|
||||
readEmoteMenuEmotes(state?.all_sets, emotes, seen);
|
||||
}
|
||||
|
||||
this.emotes = emotes;
|
||||
}
|
||||
},
|
||||
|
||||
update() {
|
||||
if ( this.isOpen )
|
||||
this.search = this.$refs.input.value;
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
change(val, close = true) {
|
||||
this.val = {
|
||||
type: 'emote',
|
||||
provider: val.provider,
|
||||
id: val.id,
|
||||
name: val.name,
|
||||
src: val.src
|
||||
},
|
||||
this.$emit('input', this.val);
|
||||
if ( close )
|
||||
this.open = false;
|
||||
},
|
||||
|
||||
onFocus(open = true) {
|
||||
this.focused = true;
|
||||
if ( open )
|
||||
this.open = true;
|
||||
},
|
||||
|
||||
onBlur() {
|
||||
this.focused = false;
|
||||
this.maybeClose();
|
||||
},
|
||||
|
||||
maybeClose() {
|
||||
if ( ! this.focused )
|
||||
this.open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
|
@ -69,26 +69,50 @@ export class Logger {
|
|||
return this.invoke(Logger.VERBOSE, args);
|
||||
}
|
||||
|
||||
verboseColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.VERBOSE, msg, colors, args);
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
return this.invoke(Logger.DEBUG, args);
|
||||
}
|
||||
|
||||
debugColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.DEBUG, msg, colors, args);
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
return this.invoke(Logger.INFO, args);
|
||||
}
|
||||
|
||||
infoColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.INFO, msg, colors, args);
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
return this.invoke(Logger.WARN, args);
|
||||
}
|
||||
|
||||
warnColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.WARN, msg, colors, args);
|
||||
}
|
||||
|
||||
warning(...args) {
|
||||
return this.invoke(Logger.WARN, args);
|
||||
}
|
||||
|
||||
warningColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.WARN, msg, colors, args);
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
return this.invoke(Logger.ERROR, args);
|
||||
}
|
||||
|
||||
errorColor(msg, colors, ...args) {
|
||||
return this.invokeColor(Logger.ERROR, msg, colors, args);
|
||||
}
|
||||
|
||||
crumb(...args) {
|
||||
if ( this.raven )
|
||||
return this.raven.captureBreadcrumb(...args);
|
||||
|
@ -107,6 +131,62 @@ export class Logger {
|
|||
return this.error(...args);
|
||||
}
|
||||
|
||||
invokeColor(level, msg, colors, args) {
|
||||
if ( ! this.enabled || level < this.level )
|
||||
return;
|
||||
|
||||
if ( ! Array.isArray(colors) )
|
||||
colors = [colors];
|
||||
|
||||
const message = args ? Array.prototype.slice.call(args) : [];
|
||||
|
||||
if ( level !== Logger.VERBOSE ) {
|
||||
const out = msg.replace(/%c/g, '') + ' ' + message.join(' ');
|
||||
|
||||
if ( this.root.init )
|
||||
this.root.captured_init.push({
|
||||
time: Date.now(),
|
||||
category: this.name,
|
||||
message: out,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
|
||||
this.crumb({
|
||||
message: out,
|
||||
category: this.name,
|
||||
level: RAVEN_LEVELS[level] || level
|
||||
});
|
||||
}
|
||||
|
||||
message.unshift(msg);
|
||||
|
||||
if ( this.name ) {
|
||||
message[0] = `%c${this.root.label} [%c${this.name}%c]:%c ${message[0]}`;
|
||||
colors.unshift('color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', '');
|
||||
|
||||
} else {
|
||||
message[0] = `%c${this.root.label}:%c ${message[0]}`;
|
||||
colors.unshift('color:#755000; font-weight:bold', '');
|
||||
}
|
||||
|
||||
message.splice(1, 0, ...colors);
|
||||
|
||||
if ( level === Logger.DEBUG || level === Logger.VERBOSE )
|
||||
console.debug(...message);
|
||||
|
||||
else if ( level === Logger.INFO )
|
||||
console.info(...message);
|
||||
|
||||
else if ( level === Logger.WARN )
|
||||
console.warn(...message);
|
||||
|
||||
else if ( level === Logger.ERROR )
|
||||
console.error(...message);
|
||||
|
||||
else
|
||||
console.log(...message);
|
||||
}
|
||||
|
||||
/* eslint no-console: "off" */
|
||||
invoke(level, args) {
|
||||
if ( ! this.enabled || level < this.level )
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
.ffz-mod-icon .ffz-i-reply {
|
||||
.ffz--inline-actions .ffz-mod-icon .ffz-i-reply {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,8 @@
|
|||
@import "./widgets/color-picker.scss";
|
||||
@import "./widgets/icon-picker.scss";
|
||||
|
||||
@import "./widgets/chat-tester.scss";
|
||||
|
||||
@import "./widgets/check-box.scss";
|
||||
|
||||
.tw-display-inline { display: inline !important }
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
.ffz--emote-picker__list,
|
||||
.ffz--icon-picker__list {
|
||||
max-height: 15rem;
|
||||
font-size: 1.6rem;
|
||||
|
@ -9,4 +10,12 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--emote-picker {
|
||||
.ffz-preview-emote {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue