1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
This release implements a massive change to how link tool-tips and embeds work. They might act up a bit while we get the new back-end installed and tweaked.

* Added: Options to use custom formats for dates and times, under `Appearance > Localization`.
* Added: Options to change the colors of tool-tips.
* Changed: Completely rewrite how link information is formatted together with a complete rewrite of the link information service.
* Changed: The FFZ Control Center now remembers you previously open settings if you reload the page.
* Fixed: Update chat lines when i18n data loads.
* Fixed: i18n not correctly formatting certain numbers.
* Fixed: Theater mode automatically enabling on user home pages. (Closes #866)
* Fixed: Theater metadata overlapping chat with Swap Sidebars enabled. (Closes #835)
* API Added: New icons: `location`, `link`, and `volume-off`
* API Fixed: `createElement` not properly handling `<video>` related attributes.
This commit is contained in:
SirStendec 2020-08-04 18:26:11 -04:00
parent eec65551fb
commit 6310a2ed49
49 changed files with 2432 additions and 884 deletions

View file

@ -24,6 +24,10 @@ FrankerFaceZ comes with a local development server that listens on port 8000
and it serves up local development copies of files, falling back to the CDN
when a local copy of a file isn't present.
> **Note:** The local development server uses `webpack-dev-server` internally,
> which self-signs a certificate for hosting content via HTTPS. You will need
> to ensure your browser accepts a self-signed certificate for localhost.
To make FrankerFaceZ load from your local development server, you must set
the local storage variable `ffzDebugMode` to true. Just run the following
in your console on Twitch: `localStorage.ffzDebugMode = true;`

View file

@ -735,6 +735,24 @@
"css": "chat",
"code": 59457,
"src": "fontawesome"
},
{
"uid": "0d08dbb1dd648a43bdea81b7e6c9e036",
"css": "location",
"code": 59458,
"src": "fontawesome"
},
{
"uid": "0ddd3e8201ccc7d41f7b7c9d27eca6c1",
"css": "link",
"code": 59459,
"src": "fontawesome"
},
{
"uid": "8489d61496923b1159b01d8a0a7b2df0",
"css": "volume-off",
"code": 59461,
"src": "elusive"
}
]
}

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.20.21",
"version": "4.20.22",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0",
"scripts": {

Binary file not shown.

View file

@ -138,6 +138,12 @@
<glyph glyph-name="chat" unicode="&#xe841;" d="M786 421q0-77-53-143t-143-104-197-38q-48 0-98 9-70-49-155-72-21-5-48-9h-2q-6 0-12 5t-6 12q-1 1-1 3t1 4 1 3l1 3t2 3 2 3 3 3 2 2q3 3 13 14t15 16 12 17 14 21 11 25q-69 40-108 98t-40 125q0 78 53 144t143 104 197 38 197-38 143-104 53-144z m214-142q0-67-40-126t-108-98q5-14 11-25t14-21 13-16 14-17 13-14q0 0 2-2t3-3 2-3 2-3l1-3t1-3 1-4-1-3q-2-8-7-13t-12-4q-28 4-48 9-86 23-156 72-50-9-98-9-151 0-263 74 32-3 49-3 90 0 172 25t148 72q69 52 107 119t37 141q0 43-13 85 72-39 114-99t42-128z" horiz-adv-x="1000" />
<glyph glyph-name="location" unicode="&#xe842;" d="M429 493q0 59-42 101t-101 42-101-42-42-101 42-101 101-42 101 42 42 101z m142 0q0-61-18-100l-203-432q-9-18-27-29t-37-11-38 11-26 29l-204 432q-18 39-18 100 0 118 84 202t202 84 202-84 83-202z" horiz-adv-x="571.4" />
<glyph glyph-name="link" unicode="&#xe843;" d="M813 171q0 23-16 38l-116 116q-16 16-38 16-24 0-40-18 1-1 10-10t12-12 9-11 7-14 2-15q0-23-16-38t-38-16q-8 0-15 2t-14 7-11 9-12 12-10 10q-19-17-19-40 0-23 16-38l115-116q15-15 38-15 22 0 38 15l82 81q16 16 16 37z m-393 394q0 22-15 38l-115 115q-16 16-38 16-22 0-38-15l-82-82q-16-15-16-37 0-22 16-38l116-116q15-15 38-15 23 0 40 17-2 2-11 11t-12 12-8 10-7 14-2 16q0 22 15 38t38 15q9 0 16-2t14-7 11-8 12-12 10-11q18 17 18 41z m500-394q0-66-48-113l-82-81q-46-47-113-47-68 0-114 48l-115 115q-46 47-46 114 0 68 49 116l-49 49q-48-49-116-49-67 0-114 47l-116 116q-47 47-47 114t47 113l82 82q47 46 114 46 67 0 114-47l115-116q46-46 46-113 0-69-49-117l49-49q48 49 116 49 67 0 114-47l116-116q47-47 47-114z" horiz-adv-x="928.6" />
<glyph glyph-name="volume-off" unicode="&#xe845;" d="M0 551l305 0 345 282 0-965-345 279-229 0z m713-283q0 4 2 6l76 76-76 76q-2 2-2 6t2 6l53 53q2 2 5 2t6-2l76-76 77 76q4 2 6 2t5-2l55-53q2-2 2-6t-2-6l-76-76 76-76q2-4 2-7t-2-5l-55-54q-2-2-5-2t-6 2l-77 78-76-78q-2-2-5-2t-6 2l-53 54q-2 2-2 6z" horiz-adv-x="1000" />
<glyph glyph-name="move" unicode="&#xf047;" d="M1000 350q0-14-11-25l-142-143q-11-11-26-11t-25 11-10 25v72h-215v-215h72q14 0 25-10t11-25-11-25l-143-143q-10-11-25-11t-25 11l-143 143q-11 10-11 25t11 25 25 10h72v215h-215v-72q0-14-10-25t-25-11-25 11l-143 143q-11 11-11 25t11 25l143 143q10 11 25 11t25-11 10-25v-72h215v215h-72q-14 0-25 10t-11 25 11 26l143 142q11 11 25 11t25-11l143-142q11-11 11-26t-11-25-25-10h-72v-215h215v72q0 14 10 25t25 11 26-11l142-143q11-10 11-25z" horiz-adv-x="1000" />
<glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" />

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -37,7 +37,7 @@ export default class AddonManager extends Module {
this._loader = this.loadAddonData();
}
async onEnable() {
onEnable() {
this.settings.addUI('add-ons', {
path: 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch.", "profile_warning": false}',
component: 'addon-list',

View file

@ -163,7 +163,7 @@ export class TranslationManager extends Module {
},
ui: {
path: 'Appearance > Localization >> General',
path: 'Appearance > Localization >> General @{"sort":-100}',
title: 'Language',
description: `FrankerFaceZ is lovingly translated by volunteers from our community. Thank you. If you're interested in helping to translate FrankerFaceZ, please [join our Discord](https://discord.gg/UrAkGhT) and ask about localization.`,
@ -173,6 +173,82 @@ export class TranslationManager extends Module {
changed: val => this.locale = val
});
this.settings.add('i18n.format.date', {
default: 'default',
ui: {
path: 'Appearance > Localization >> Formatting',
title: 'Date Format',
description: 'The default date format. Custom date formats are formated using the [Day.js](https://github.com/iamkun/dayjs#readme) library.',
component: 'setting-combo-box',
data: () => {
const out = [], now = new Date;
for (const [key,fmt] of Object.entries(this._.formats.date)) {
out.push({
value: key, title: `${this.formatDate(now, key)} (${key})`
})
}
return out;
}
},
changed: val => {
this._.defaultDateFormat = val;
this.emit(':update')
}
});
this.settings.add('i18n.format.time', {
default: 'short',
ui: {
path: 'Appearance > Localization >> Formatting',
title: 'Time Format',
description: 'The default time format. Custom time formats are formated using the [Day.js](https://github.com/iamkun/dayjs#readme) library.',
component: 'setting-combo-box',
data: () => {
const out = [], now = new Date;
for (const [key,fmt] of Object.entries(this._.formats.time)) {
out.push({
value: key, title: `${this.formatTime(now, key)} (${key})`
})
}
return out;
}
},
changed: val => {
this._.defaultTimeFormat = val;
this.emit(':update')
}
});
this.settings.add('i18n.format.datetime', {
default: 'medium',
ui: {
path: 'Appearance > Localization >> Formatting',
title: 'Date-Time Format',
description: 'The default combined date-time format. Custom time formats are formated using the [Day.js](https://github.com/iamkun/dayjs#readme) library.',
component: 'setting-combo-box',
data: () => {
const out = [], now = new Date;
for (const [key,fmt] of Object.entries(this._.formats.datetime)) {
out.push({
value: key, title: `${this.formatDateTime(now, key)} (${key})`
})
}
return out;
}
},
changed: val => {
this._.defaultDateTimeFormat = val;
this.emit(':update')
}
});
}
getLocaleOptions(val) {
@ -244,6 +320,9 @@ export class TranslationManager extends Module {
this._ = new NewTransCore({ //TranslationCore({
warn: (...args) => this.log.warn(...args),
defaultDateFormat: this.settings.get('i18n.format.date'),
defaultTimeFormat: this.settings.get('i18n.format.time'),
defaultDateTimeFormat: this.settings.get('i18n.format.datetime')
});
if ( window.BroadcastChannel ) {

View file

@ -3,10 +3,12 @@ query FFZ_GetClipInfo($slug: ID!) {
id
curator {
id
login
displayName
}
broadcaster {
id
login
displayName
}
game {

View file

@ -1,27 +1,39 @@
<script>
import {has, timeout} from 'utilities/object';
import {ALLOWED_ATTRIBUTES, ALLOWED_TAGS} from 'utilities/constants';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
let tokenizer;
export default {
props: ['data', 'url', 'events'],
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia'],
data() {
return {
has_tokenizer: false,
loaded: false,
error: null,
title: this.t('card.loading', 'Loading...'),
title_tokens: null,
desc_1: null,
desc_1_tokens: null,
desc_2: null,
desc_2_tokens: null,
image: null,
image_title: null,
image_square: false,
accent: null
accent: null,
short: null,
full: null,
unsafe: false,
urls: null,
allow_media: false,
allow_unsafe: false
}
},
computed: {
has_full() {
if ( this.full == null )
return false;
if ( this.full?.type === 'media' && ! this.allow_media )
return false;
return true;
}
},
@ -29,26 +41,50 @@ export default {
data() {
this.reset();
this.load();
},
events() {
this.listen();
}
},
created() {
if ( this.events ) {
this._events = this.events;
this._events.on('chat:update-link-resolver', this.checkRefresh, this);
}
this.loadTokenizer();
this.listen();
this.load();
},
beforeDestroy() {
if ( this._events ) {
this._events.off('chat:update-link-resolver', this.checkRefresh, this);
this._events = null;
}
this.unlisten();
},
methods: {
async loadTokenizer() {
if ( tokenizer )
this.has_tokenizer = true;
else {
tokenizer = await import(/* webpack-chunk-name: 'rich_tokens' */ 'utilities/rich_tokens');
this.has_tokenizer = true;
}
},
listen() {
this.unlisten();
if ( this.events?.on ) {
this._es = this.events;
this._es.on('chat:update-link-resolver', this.checkRefresh, this);
}
},
unlisten() {
if ( this._es?.off ) {
this._es.off('chat:update-link-resolver', this.checkRefresh, this);
this._es = null;
}
},
checkRefresh(url) {
if ( ! url || (url && url === this.url) ) {
this.reset();
@ -59,16 +95,13 @@ export default {
reset() {
this.loaded = false;
this.error = null;
this.title = this.t('card.loading', 'Loading...');
this.title_tokens = null;
this.desc_1 = null;
this.desc_1_tokens = null;
this.desc_2 = null;
this.desc_2_tokens = null;
this.image = null;
this.image_title = null;
this.image_square = null;
this.accent = null;
this.short = null;
this.full = null;
this.unsafe = false;
this.urls = null;
this.allow_media = false;
this.allow_unsafe = false;
},
async load() {
@ -83,152 +116,130 @@ export default {
data = await data;
}
if ( ! data )
data = {
error: true,
title: this.t('card.error', 'An error occured.'),
desc_1: this.t('card.empty', 'No data was returned.')
}
} catch(err) {
data = {
error: true,
title: this.t('card.error', 'An error occured.'),
desc_1: String(err)
}
error: String(err)
};
}
if ( ! data )
data = {
error: {type: 'i18n', key: 'card.empty', phrase: 'No data was returned.'}
};
if ( data.error )
data = {
short: {
type: 'header',
logo: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
subtitle: data.error
}
}
this.loaded = true;
this.error = data.error;
this.title = data.title;
this.title_tokens = data.title_tokens;
this.desc_1 = data.desc_1;
this.desc_1_tokens = data.desc_1_tokens;
this.desc_2 = data.desc_2;
this.desc_2_tokens = data.desc_2_tokens;
this.image = data.image;
this.image_square = data.image_square;
this.image_title = data.image_title;
this.accent = data.accent;
this.short = data.short;
this.full = data.full;
this.unsafe = data.unsafe;
this.urls = data.urls;
this.allow_media = data.allow_media;
this.allow_unsafe = data.allow_unsafe;
},
// Rendering
renderCard(h) {
if ( this.data.renderBody )
return [this.data.renderBody(h)];
if ( this.data.renderBody ) {
const out = this.data.renderBody(h);
return Array.isArray(out) ? out : [out];
}
return [
this.renderImage(h),
this.renderDescription(h)
];
this.renderUnsafe(h),
//this.forceFull ? null : this.renderImage(h),
this.renderBody(h)
]
},
renderTokens(tokens, h) {
let out = [];
if ( ! Array.isArray(tokens) )
tokens = [tokens];
renderUnsafe(h) {
if ( ! this.unsafe )
return null;
for(const token of tokens) {
if ( Array.isArray(token) )
out = out.concat(this.renderTokens(token, h));
const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', ');
else if ( typeof token !== 'object' )
out.push(token);
else if ( token.type === 't') {
const content = {};
if ( token.content )
for(const [key,val] of Object.entries(token.content))
content[key] = this.renderTokens(val, h);
out = out.concat(this.tList(token.key, token.phrase, content));
} else {
const tag = token.tag || 'span';
if ( ! ALLOWED_TAGS.includes(tag) ) {
console.log('Skipping disallowed tag', tag);
continue;
}
const attrs = {};
if ( token.attrs ) {
for(const [key,val] of Object.entries(token.attrs)) {
if ( ! ALLOWED_ATTRIBUTES.includes(key) && ! key.startsWith('data-') )
console.log('Skipping disallowed attribute', key);
else
attrs[key] = val;
return h('div', {
class: 'ffz--corner-flag ffz--corner-flag__warn ffz-tooltip ffz-tooltip--no-mouse',
attrs: {
'data-title': this.t(
'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: {reasons}",
{
reasons: reasons.toLowerCase()
}
}
const el = h(tag, {
class: token.class,
attrs
}, this.renderTokens(token.content, h));
out.push(el);
)
}
}
return out;
}, [
h('figure', {
class: 'ffz-i-attention'
})
]);
},
renderDescription(h) {
let title = this.title,
title_tokens = this.title_tokens,
desc_1 = this.desc_1,
desc_1_tokens = this.desc_1_tokens,
desc_2 = this.desc_2,
desc_2_tokens = this.desc_2_tokens;
renderBody(h) {
if ( this.has_tokenizer && this.loaded && (this.forceFull ? this.full : this.short) ) {
return h('div', {
class: 'ffz--card-rich tw-full-width tw-overflow-hidden tw-flex tw-flex-column'
}, tokenizer.renderTokens(this.forceFull ? this.full : this.short, h, {
vue: true,
tList: (...args) => this.tList(...args),
i18n: this.getI18n(),
if ( ! this.loaded ) {
desc_1 = this.t('card.loading', 'Loading...');
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
}
return h('div', {
class: [
'ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex',
desc_2 && 'ffz--two-line'
]
}, [h('div', {class: 'tw-full-width tw-pd-l-1'}, [
h('div', {class: 'chat-card__title tw-ellipsis'},
[h('span', {class: 'tw-strong', attrs: {title}}, title_tokens ? this.renderTokens(title_tokens, h) : title)]),
h('div', {class: 'tw-ellipsis'},
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_1}}, desc_1_tokens ? this.renderTokens(desc_1_tokens, h) : desc_1)]),
desc_2 && h('div', {class: 'tw-ellipsis'},
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_2}}, desc_2_tokens ? this.renderTokens(desc_2_tokens, h) : desc_2)])
])]);
allow_media: this.forceMedia ?? this.allow_media,
allow_unsafe: this.forceUnsafe ?? this.allow_unsafe
}));
} else
return this.renderBasic(h);
},
renderImage(h) {
let content;
if ( this.error )
content = h('img', {
class: 'chat-card__error-img',
attrs: {
src: ERROR_IMAGE
}
});
else {
content = h('div', {
class: 'tw-card-img tw-flex-shrink-0 tw-overflow-hidden'
}, [h('aspect', {
props: {
ratio: 16/9
}
}, [this.loaded && this.image && h('img', {
class: 'tw-image',
attrs: {
src: this.image,
alt: this.image_title ?? this.title
}
})])]);
renderBasic(h) {
let title, description;
if ( this.loaded && this.forceFull && ! this.full ) {
description = 'null';
} else if ( this.error ) {
title = this.t('card.error', 'An error occurred.');
description = this.error;
} else if ( this.loaded && this.has_tokenizer ) {
title = this.title;
description = this.description;
} else {
description = this.t('card.loading', 'Loading...');
}
return h('div', {
class: [
'chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center',
this.image_square && 'square'
]
}, [content])
if ( ! title && ! description )
description = this.t('card.empty', 'No data was returned.');
description = description ? description.split(/\n+/).slice(0,2).map(desc =>
h('div', {
class: 'tw-c-text-alt-2 tw-ellipsis tw-mg-x-05',
attrs:{title: desc}
}, [desc])
) : [];
return [
h('div', {class: 'ffz--header-image'}),
h('div', {
class: 'ffz--card-text tw-full-width tw-overflow-hidden tw-flex tw-flex-column tw-justify-content-center'
}, [
title ? h('div', {class: 'chat-card__title tw-ellipsis tw-mg-x-05'}, [
h('span', {class: 'tw-strong', attrs:{title}}, [title])
]) : null,
...description
])
];
}
},
@ -237,8 +248,9 @@ export default {
class: 'tw-flex tw-flex-nowrap tw-pd-05'
}, this.renderCard(h));
if ( this.url ) {
const tooltip = this.data.card_tooltip;
const tooltip = this.has_full && ! this.forceFull;
if ( this.url )
content = h('a', {
class: [
tooltip && 'ffz-tooltip',
@ -255,10 +267,21 @@ export default {
href: this.url
}
}, [content]);
}
else if ( tooltip )
content = h('div', {
class: 'ffz-tooltip tw-block tw-border-radius-medium tw-full-width',
attrs: {
'data-tooltip-type': 'link',
'data-url': this.url,
'data-is-mail': false,
}
}, [content]);
return h('div', {
class: 'tw-border-radius-medium tw-elevation-1 ffz--chat-card',
class: [
'tw-border-radius-medium tw-elevation-1 ffz--chat-card tw-relative',
this.unsafe ? 'ffz--unsafe' : ''
],
style: {
'--ffz-color-accent': this.accent
}
@ -266,7 +289,6 @@ export default {
class: 'tw-border-radius-medium tw-c-background-base tw-flex tw-full-width'
}, [content])]);
}
}
</script>

View file

@ -24,6 +24,7 @@ import Actions from './actions';
export const SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
const EMOTE_CHARS = /[ .,!]/;
export default class Chat extends Module {
@ -1411,7 +1412,7 @@ export default class Chat extends Module {
const tt = tokenizer.tooltip;
const tk = this.tooltips.types[type] = tt.bind(this);
for(const i of ['interactive', 'delayShow', 'delayHide'])
for(const i of ['interactive', 'delayShow', 'delayHide', 'onShow', 'onHide'])
tk[i] = typeof tt[i] === 'function' ? tt[i].bind(this) : tt[i];
}
@ -1579,6 +1580,8 @@ export default class Chat extends Module {
info = this._link_info[url] = [false, null, [[resolve, reject]]];
const handle = (success, data) => {
data = this.fixLinkInfo(data);
const callbacks = ! info[0] && info[2];
info[0] = true;
info[1] = Date.now() + 120000;
@ -1608,4 +1611,43 @@ export default class Chat extends Module {
}
});
}
fixLinkInfo(data) {
if ( data.error && data.message )
data.error = data.message;
if ( data.error )
data = {
v: 5,
title: this.i18n.t('card.error', 'An error occured.'),
description: data.error,
short: {
type: 'header',
image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occured.'},
subtitle: data.error
}
}
if ( data.v < 5 && ! data.short && ! data.full && (data.title || data.desc_1 || data.desc_2) ) {
const image = data.preview || data.image;
data = {
v: 5,
short: {
type: 'header',
image: image ? {
type: 'image',
url: image,
sfw: data.image_safe ?? false,
} : null,
title: data.title,
subtitle: data.desc_1,
extra: data.desc_2
}
}
}
return data;
}
}

View file

@ -17,6 +17,8 @@ const BAD_USERS = [
import GET_CLIP from './clip_info.gql';
import GET_VIDEO from './video_info.gql';
import {truncate} from 'utilities/object';
// ============================================================================
// General Links
@ -47,31 +49,20 @@ export const Links = {
} catch(err) {
return {
url: token.url,
title: this.i18n.t('card.error', 'An error occurred.'),
desc_1: String(err)
error: String(err)
}
}
if ( ! data )
return {
url: token.url,
title: this.i18n.t('card.error', 'An error occurred.'),
desc_1: this.i18n.t('card.empty', 'No data was returned.')
url: token.url
}
return {
url: token.url,
accent: data.accent,
image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null,
image_title: data.image_title,
image_square: data.image_square,
title: data.title,
title_tokens: data.title_tokens,
desc_1: data.desc_1,
desc_1_tokens: data.desc_1_tokens,
desc_2: data.desc_2,
desc_2_tokens: data.desc_2_tokens
}
...data,
allow_media: this.context.get('tooltip.link-images'),
allow_unsafe: this.context.get('tooltip.link-nsfw-images')
};
}
}
}
@ -108,66 +99,82 @@ export const Users = {
if ( ! user || ! user.id )
return null;
const game = user.broadcastSettings?.game?.displayName;
const game = user.broadcastSettings?.game?.displayName,
stream_id = user.stream?.id;
let desc_1 = null, desc_2 = null, desc_1_tokens = null, desc_2_tokens = null;
if ( user.stream?.id && game ) {
desc_1_tokens = this.i18n.tList('cards.user.streaming', 'streaming {game}', {
game: {class: 'tw-semibold', content: [game]}
});
desc_1 = this.i18n.t('cards.user.streaming', 'streaming {game}', {
game
});
}
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 bits_tokens = this.i18n.tList('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
views: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.profileViewCount || 0)]},
followers: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.followers?.totalCount || 0)]}
}),
bits = this.i18n.t('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
views: user.profileViewCount || 0,
followers: user.followers?.totalCount || 0
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 ( desc_1 ) {
desc_2 = bits;
desc_2_tokens = bits_tokens;
} else {
desc_1 = bits;
desc_1_tokens = bits_tokens;
}
if ( user.roles?.isPartner )
title.push({
type: 'style', color: 'link',
content: {type: 'icon', name: 'verified'}
});
const has_i18n = user.displayName.trim().toLowerCase() !== user.login;
let title = user.displayName, title_tokens = null;
if ( has_i18n ) {
title = `${user.displayName} (${user.login})`;
title_tokens = [
user.displayName,
{class: 'chat-author__intl-login', content: ` (${user.login})`}
];
}
/*const full = [{
type: 'header',
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
title,
subtitle,
extra: stream_id ? extra : null
}];
if ( user.roles?.isPartner ) {
if ( ! title_tokens )
title_tokens = [title];
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)})
title_tokens = {tag: 'div', class: 'tw-flex tw-align-items-center', content: [
{tag: 'div', content: title_tokens},
{tag: 'figure', class: 'tw-mg-l-05 ffz-i-verified tw-c-text-link', content: []}
]};
}
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,
image: user.profileImageURL,
image_square: true,
title,
title_tokens,
desc_1,
desc_1_tokens,
desc_2,
desc_2_tokens
short: {
type: 'header',
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
title,
subtitle,
extra
}
}
}
}
@ -214,53 +221,51 @@ export const Clips = {
return null;
const clip = result.data.clip,
user = clip.broadcaster.displayName,
game = clip.game,
game_name = game && game.name,
game_display = game && game.displayName;
let desc_1, desc_1_tokens;
if ( game_name === 'creative' ) {
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
user: {class: 'tw-semibold', content: user}
});
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
user
});
const user = {
type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
content: {
type: 'style', weight: 'semibold', color: 'alt-2',
content: clip.broadcaster.displayName
}
};
} else if ( game ) {
desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
user: {class: 'tw-semibold', content: user},
game: {class: 'tw-semibold', content: game_display}
});
desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
const subtitle = game_display ? {
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
user,
game: game_display
});
game: {type: 'style', weight: 'semibold', content: game_display}
}
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
} else {
desc_1_tokens = this.i18n.tList('clip.desc.1', 'Clip of {user}', {
user: {class: 'tw-semibold', content: user}
});
desc_1 = this.i18n.t('clip.desc.1', 'Clip of {user}', {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 curator = clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown');
const extra = {
type: 'i18n', key: 'clip.desc.2',
phrase: 'Clipped by {curator} — {views,number} View{views,en_plural}',
content: {
curator,
views: clip.viewCount
}
};
return {
url: token.url,
image: clip.thumbnailURL,
title: clip.title,
desc_1,
desc_1_tokens,
desc_2: this.i18n.t('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
curator,
views: clip.viewCount
}),
desc_2_tokens: this.i18n.tList('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
curator: clip.curator ? {class: 'tw-semibold', content: curator} : curator,
views: {class: 'tw-semibold', content: this.i18n.formatNumber(clip.viewCount)}
})
short: {
type: 'header',
image: {type: 'image', url: clip.thumbnailURL, sfw: false, aspect: 16/9},
title: clip.title,
subtitle,
extra
}
}
}
}
@ -296,49 +301,43 @@ export const Videos = {
return null;
const video = result.data.video,
user = video.owner.displayName,
game = video.game,
game_name = game && game.name,
game_display = game && game.displayName;
let desc_1, desc_1_tokens;
if ( game_name === 'creative' ) {
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
user: {class: 'tw-semibold', content: user}
});
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
user
});
const user = {
type: 'link', url: `https://www.twitch.tv/${video.owner.login}`,
content: {
type: 'style', weight: 'semibold', color: 'alt-2',
content: video.owner.displayName
}
};
} else if ( game ) {
desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
user: {class: 'tw-semibold', content: user},
game: {class: 'tw-semibold', content: game_display}
});
desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
const subtitle = game_display ? {
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
user,
game: game_display
});
game: {type: 'style', weight: 'semibold', content: game_display}
}
} : {type: 'i18n', key: 'video.desc.1', phrase: 'Video of {user}', content: {user}};
} else {
desc_1_tokens = this.i18n.tList('video.desc.1', 'Video of {user}', {
user: {class: 'tw-semibold', content: user}
});
desc_1 = this.i18n.t('video.desc.1', 'Video of {user}', {user});
}
return {
url: token.url,
image: video.previewThumbnailURL,
title: video.title,
desc_1,
desc_1_tokens,
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
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: false, aspect: 16/9},
title: video.title,
subtitle,
extra
}
};
}
}
}

View file

@ -20,7 +20,11 @@ const EMOTE_CLASS = 'chat-image chat-line__message--emote',
// Links
// ============================================================================
const TOOLTIP_VERSION = 4;
function datasetBool(value) {
return value == null ? null : value === 'true';
}
const TOOLTIP_VERSION = 5;
export const Links = {
type: 'link',
@ -47,60 +51,94 @@ export const Links = {
if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})];
const url = target.dataset.url || target.href;
const url = target.dataset.url || target.href,
show_images = datasetBool(target.dataset.forceMedia) ?? this.context.get('tooltip.link-images'),
show_unsafe = datasetBool(target.dataset.forceUnsafe) ?? this.context.get('tooltip.link-nsfw-images');
return this.get_link_info(url).then(data => {
return Promise.all([
import(/* webpack-chunk-name: 'rich_tokens' */ 'utilities/rich_tokens'),
this.get_link_info(url)
]).then(([rich_tokens, data]) => {
if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
return '';
let content = data.content || data.html || '';
const ctx = {
tList: (...args) => this.i18n.tList(...args),
i18n: this.i18n,
allow_media: show_images,
allow_unsafe: show_unsafe,
onload: tip.update
};
// TODO: Replace timestamps.
if ( data.urls && data.urls.length > 1 )
content += (content.length ? '<hr>' : '') +
sanitize(this.i18n.t(
'tooltip.link-destination',
'Destination: {url}',
{url: data.urls[data.urls.length-1][1]}
));
if ( data.unsafe ) {
const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', ');
content = this.i18n.t(
'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: {reasons}",
{reasons: sanitize(reasons.toLowerCase())}
) + (content.length ? `<hr>${content}` : '');
let content;
if ( tip.element ) {
tip.element.classList.add('ffz-rich-tip');
tip.element.classList.add('tw-align-left');
}
const show_image = this.context.get('tooltip.link-images') && (data.image_safe || this.context.get('tooltip.link-nsfw-images'));
if ( data.full ) {
content = rich_tokens.renderTokens(data.full, createElement, ctx);
if ( show_image ) {
if ( data.image && ! data.image_iframe )
content = `<img class="preview-image" src="${sanitize(data.image)}">${content}`
} else {
if ( data.short ) {
content = rich_tokens.renderTokens(data.short, createElement, ctx);
} else
content = this.i18n.t('card.empty', 'No data was returned.');
}
setTimeout(() => {
if ( tip.element ) {
for(const el of tip.element.querySelectorAll('img'))
el.addEventListener('load', tip.update);
if ( ! data.urls )
return content;
for(const el of tip.element.querySelectorAll('video'))
el.addEventListener('loadedmetadata', tip.update);
}
const url_table = [];
for(let i=0; i < data.urls.length; i++) {
const url = data.urls[i];
url_table.push(<tr>
<td>{this.i18n.formatNumber(i + 1)}.</td>
<td class="tw-c-text-alt-2 tw-pd-x-05 tw-word-break-all">{url.url}</td>
<td>{url.flags ? url.flags.map(flag => <span class="tw-pill">{flag.toLowerCase()}</span>) : null}</td>
</tr>);
}
let url_notice;
if ( data.unsafe ) {
const reasons = Array.from(new Set(data.urls.map(url => url.flags).flat())).join(', ');
url_notice = (<div class="ffz-i-attention">
{this.i18n.tList(
'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: {reasons}",
{reasons: reasons.toLowerCase()}
)}
</div>);
} else if ( data.urls.length > 1 )
url_notice = this.i18n.t('tooltip.link-destination', 'Destination: {url}', {
url: data.urls[data.urls.length-1].url
});
} else if ( content.length )
content = content.replace(/<!--MS-->.*<!--ME-->/g, '');
if ( data.tooltip_class )
tip.element.classList.add(data.tooltip_class);
content = (<div>
<div class="ffz--shift-hide">
{content}
{url_notice ? <div class="tw-mg-t-05 tw-border-t tw-pd-t-05 tw-align-center">
{url_notice}
<div class=" tw-font-size-8">
{this.i18n.t('tooltip.shift-detail', '(Shift for Details)')}
</div>
</div> : null}
</div>
<div class="ffz--shift-show tw-align-left">
<div class="tw-semibold tw-mg-b-05 tw-align-center">
{this.i18n.t('tooltip.link.urls', 'Visited URLs')}
</div>
<table>{url_table}</table>
</div>
</div>);
return content;
}).catch(error =>
sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error}))
);
}).catch(error => {
console.error(error);
return sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error}))
});
},
process(tokens) {

View file

@ -12,6 +12,7 @@ query FFZ_GetVideoInfo($id: ID!) {
}
owner {
id
login
displayName
}
}

View file

@ -104,7 +104,8 @@
v-if="addon.website"
:href="addon.website"
:title="addon.website"
class="tw-button ffz-button--hollow tw-mg-r-1"
class="tw-button ffz-button--hollow tw-mg-r-1 ffz-tooltip ffz-tooltip--no-mouse"
data-tooltip-type="link"
target="_blank"
rel="noopener"
>

View file

@ -53,7 +53,8 @@
:href="commit.author.html_url"
target="_blank"
rel="noopener noreferrer"
class="tw-inline-flex tw-align-items-center tw-link tw-link--inherit tw-mg-x-05"
class="tw-inline-flex tw-align-items-center tw-link tw-link--inherit tw-mg-x-05 ffz-tooltip"
data-tooltip-type="link"
>
<figure
v-if="commit.author.avatar_url"
@ -76,7 +77,7 @@
v-if="commit.hash"
class="tw-font-size-8 tw-c-text-alt-2"
>
@<a :href="commit.link" target="_blank" rel="noopener noreferrer" class="tw-link tw-link--inherit">{{ commit.hash }}</a>
@<a :href="commit.link" target="_blank" rel="noopener noreferrer" class="tw-link tw-link--inherit ffz-tooltip" data-tooltip-type="link">{{ commit.hash }}</a>
</div>
<time
v-if="commit.date"

View file

@ -26,15 +26,51 @@
</select>
<input
ref="text"
v-model="raw_url"
:disabled="! isCustomURL"
class="ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input"
@blur="updateText"
@input="onTextChange"
>
</div>
</div>
</div>
<div class="tw-flex tw-mg-b-1">
<div class="tw-flex-grow-1" />
<div class="tw-pd-x-1 tw-checkbox">
<input
id="force_media"
ref="force_media"
:checked="force_media"
type="checkbox"
class="tw-checkbox__input"
@change="onCheck"
>
<label for="force_media" class="tw-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.link-provider.allow.media', 'Allow Media') }}
</span>
</label>
</div>
<div class="tw-pd-x-1 tw-checkbox">
<input
id="force_unsafe"
ref="force_unsafe"
:checked="force_unsafe"
type="checkbox"
class="tw-checkbox__input"
@change="onCheck"
>
<label for="force_unsafe" class="tw-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.link-provider.allow.unsafe', 'Allow NSFW') }}
</span>
</label>
</div>
<button
class="tw-mg-l-1 tw-button tw-button--text"
@click="refresh"
@ -44,6 +80,47 @@
</span>
</button>
</div>
<div class="tw-flex tw-mg-b-1 tw-full-width">
<label>
{{ t('debug.link-provider.link', 'Chat Link') }}
</label>
<div class="tw-full-width tw-overflow-hidden">
<a
v-if="url"
ref="link"
:href="url"
:data-url="url"
class="ffz-tooltip"
data-tooltip-type="link"
data-force-tooltip="true"
:data-force-open="force_tooltip ? 'true' : 'false'"
:data-force-media="force_media ? 'true' : 'false'"
:data-force-unsafe="force_unsafe ? 'true' : 'false'"
data-is-mail="false"
rel="noopener noreferrer"
target="_blank"
>
{{ url }}
</a>
</div>
<div class="tw-pd-x-1 tw-checkbox">
<input
id="force_tooltip"
ref="force_tooltip"
:checked="force_tooltip"
type="checkbox"
class="tw-checkbox__input"
@change="onTooltip"
>
<label for="force_tooltip" class="tw-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.link-provider.force-tooltip', 'Force Tooltip') }}
</span>
</label>
</div>
</div>
<div class="tw-flex tw-mg-b-1 tw-full-width">
<label>
{{ t('debug.link-provider.embed', 'Rich Embed') }}
@ -53,28 +130,26 @@
v-if="rich_data"
:data="rich_data"
:url="url"
:force-media="force_media"
:force-unsafe="force_unsafe"
:events="events"
/>
</div>
</div>
<div class="tw-flex tw-mg-b-1 tw-full-width">
<label>
{{ t('debug.link-provider.link', 'Chat Link') }}
{{ t('debug.link-provider.full-embed', 'Full Embed') }}
</label>
<div class="tw-full-width tw-overflow-hidden">
<a
v-if="url"
:href="url"
:data-url="url"
class="ffz-tooltip"
data-tooltip-type="link"
data-force-tooltip="true"
data-is-mail="false"
rel="noopener noreferrer"
target="_blank"
>
{{ url }}
</a>
<chat-rich
v-if="rich_data"
:data="rich_data"
:url="url"
:force-full="true"
:force-media="force_media"
:force-unsafe="force_unsafe"
:events="events"
/>
</div>
</div>
<div class="tw-flex tw-mg-b-1 tw-full-width">
@ -99,12 +174,19 @@ import { debounce } from '../../../utilities/object';
const STOCK_URLS = [
'https://www.twitch.tv/sirstendec',
'https://www.twitch.tv/videos/42968068',
'https://www.twitch.tv/sirstendec/clip/HedonisticMagnificentSoymilkChocolateRain',
'https://clips.twitch.tv/HedonisticMagnificentSoymilkChocolateRain',
'https://discord.gg/UrAkGhT',
'https://www.youtube.com/watch?v=CAL4WMpBNs0',
'https://xkcd.com/221/',
'https://github.com/FrankerFaceZ/FrankerFaceZ',
'https://twitter.com/frankerfacez',
'https://twitter.com/FrankerFaceZ/status/1240717057630625792'
'https://twitter.com/FrankerFaceZ/status/1240717057630625792',
'http://testsafebrowsing.appspot.com/apiv4/ANY_PLATFORM/MALWARE/URL/',
'https://en.wikipedia.org/wiki/Emoji',
'https://en.wikipedia.org/wiki/Naginata',
'https://www.smbc-comics.com/comic/punishment'
]
export default {
@ -118,13 +200,26 @@ export default {
props: ['item', 'context'],
data() {
const state = window.history.state;
let url = state?.ffz_lt_url,
is_custom = false;
if ( url )
is_custom = ! STOCK_URLS.includes(url);
else
url = STOCK_URLS[Math.floor(Math.random() * STOCK_URLS.length)];
return {
stock_urls: deep_copy(STOCK_URLS),
raw_url: STOCK_URLS[Math.floor(Math.random() * STOCK_URLS.length)],
raw_url: url,
isCustomURL: is_custom,
rich_data: null,
isCustomURL: false,
raw_loading: false,
raw_data: null,
force_media: state.ffz_lt_media ?? true,
force_unsafe: state.ffz_lt_unsafe ?? false,
force_tooltip: state.ffz_lt_tip ?? false,
events: {
on: (...args) => this.item.getChat().on(...args),
off: (...args) => this.item.getChat().off(...args)
@ -143,24 +238,75 @@ export default {
},
watch: {
raw_url() {
if ( ! this.isCustomURL )
this.$refs.text.value = this.raw_url;
},
url() {
this.rebuildData();
this.saveState();
if ( this.force_tooltip ) {
const link = this.$refs.link;
if ( ! link || ! this.chat )
return;
const tips = this.chat.resolve('tooltips')?.tips;
if ( ! tips )
return;
tips._exit(link);
setTimeout(() => tips._enter(link), 250);
}
},
rich_data() {
this.refreshRaw();
},
force_tooltip() {
const link = this.$refs.link;
if ( ! link || ! this.chat )
return;
const tips = this.chat.resolve('tooltips')?.tips;
if ( ! tips )
return;
if ( this.force_tooltip )
tips._enter(link);
else
tips._exit(link);
}
},
created() {
this.rebuildData = debounce(this.rebuildData, 250);
this.refreshRaw = debounce(this.refreshRaw, 250);
this.onTextChange = debounce(this.onTextChange, 500);
},
mounted() {
this.chat = this.item.getChat();
this.chat.on('chat:update-link-resolver', this.checkRefreshRaw, this);
this.rebuildData();
this.$refs.text.value = this.raw_url;
if ( this.force_tooltip ) {
const link = this.$refs.link;
if ( ! link || ! this.chat )
return;
const tips = this.chat.resolve('tooltips')?.tips;
if ( ! tips )
return;
tips._enter(link);
}
},
beforeDestroy() {
@ -169,6 +315,21 @@ export default {
},
methods: {
saveState() {
try {
window.history.replaceState({
...window.history.state,
ffz_lt_url: this.raw_url,
ffz_lt_media: this.force_media,
ffz_lt_unsafe: this.force_unsafe,
ffz_lt_tip: this.force_tooltip
}, document.title);
} catch(err) {
/* no-op */
}
},
checkRefreshRaw(url) {
if ( ! url || (url && url === this.url) )
this.refreshRaw();
@ -220,8 +381,26 @@ export default {
this.isCustomURL = true;
},
updateText() {
if ( this.isCustomURL )
this.raw_url = this.$refs.text.value;
},
onTextChange() {
this.raw_url = this.$refs.text
this.updateText();
},
onCheck() {
this.force_media = this.$refs.force_media.checked;
this.force_unsafe = this.$refs.force_unsafe.checked;
this.saveState();
},
onTooltip() {
this.force_tooltip = this.$refs.force_tooltip.checked;
this.saveState();
}
}

View file

@ -194,6 +194,17 @@ export default {
this.markSeen(item);
this.currentItem = item;
this.restoredItem = true;
try {
window.history.replaceState({
...window.history.state,
ffzcc: item.full_key
}, document.title)
} catch(err) {
/* no-op */
}
let current = item;
while(current = current.parent) // eslint-disable-line no-cond-assign
current.expanded = true;

View file

@ -36,6 +36,7 @@ export default class MainMenu extends Module {
//this.should_enable = true;
this.exclusive = false;
this.new_seen = false;
this._settings_tree = null;
@ -198,6 +199,12 @@ export default class MainMenu extends Module {
this.off('site.menu_button:clicked', this.dialog.toggleVisible, this.dialog);
}
openExclusive() {
this.exclusive = true;
this.dialog.exclusive = true;
this.enable().then(() => this.dialog.show());
}
runFix(amount) {
this.settings.updateContext({
force_chat_fix: (this.settings.get('context.force_chat_fix') || 0) + amount
@ -278,14 +285,41 @@ export default class MainMenu extends Module {
const root = this._vue.$children[0],
item = root.currentItem,
key = item && item.full_key,
wants_old = ! root.restoredItem,
state = window.history.state,
tree = this.getSettingsTree();
root.nav = tree;
root.nav_keys = tree.keys;
root.currentItem = tree.keys[key] || (this._wanted_page && tree.keys[this._wanted_page]) || (this.has_update ?
tree.keys['home.changelog'] :
tree.keys['home']);
let current, restored = true;
if ( this._wanted_page )
current = tree.keys[this._wanted_page];
if ( ! current && wants_old ) {
if ( state?.ffzcc )
current = tree.keys[state.ffzcc];
if ( ! current ) {
const params = new URL(window.location).searchParams,
key = params?.get?.('ffz-settings');
current = key && tree.keys[key];
}
if ( ! current )
restored = false;
}
if ( ! current )
current = tree.keys[key];
if ( ! current )
current = this.has_update ?
tree.keys['home.changelog'] :
tree.keys['home'];
root.currentItem = current;
root.restoredItem = restored;
this._wanted_page = null;
}
@ -813,7 +847,26 @@ export default class MainMenu extends Module {
getData() {
const settings = this.getSettingsTree(),
context = this.getContext(),
current = (this._wanted_page && settings.keys[this._wanted_page]) || (this.has_update ? settings.keys['home.changelog'] : settings.keys['home']);
state = window.history.state;
let current, restored = true;
if ( this._wanted_page )
current = settings.keys[this._wanted_page];
if ( ! current && state?.ffzcc ) {
current = settings.keys[state.ffzcc];
if ( ! current )
restored = false;
} if ( ! current ) {
const params = new URL(window.location).searchParams,
key = params?.get?.('ffz-settings');
current = key && settings.keys[key];
if ( ! current )
restored = false;
}
if ( ! current )
current = this.has_update ?
settings.keys['home.changelog'] :
settings.keys['home'];
this._wanted_page = null;
this.markSeen(current);
@ -834,6 +887,7 @@ export default class MainMenu extends Module {
nav: settings,
currentItem: current,
restoredItem: true, // restored, -- Look into making this smoother later.
nav_keys: settings.keys,
has_unseen,

View file

@ -77,12 +77,12 @@ export default class TooltipProvider extends Module {
this.on(':cleanup', this.cleanup);
}
_createInstance(container, klass = 'ffz-tooltip', default_type) {
return new Tooltip(container, klass, {
html: true,
i18n: this.i18n,
live: true,
check_modifiers: true,
delayHide: this.checkDelayHide.bind(this, default_type),
delayShow: this.checkDelayShow.bind(this, default_type),
@ -117,7 +117,6 @@ export default class TooltipProvider extends Module {
}
onFSChange() {
const tip_element = document.fullscreenElement || this.container;
if ( tip_element !== this.tip_element ) {
@ -132,6 +131,7 @@ export default class TooltipProvider extends Module {
this.tips.cleanup();
}
delegatePopperConfig(default_type, target, tip, pop_opts) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];

View file

@ -126,11 +126,8 @@ export default class Twilight extends BaseSite {
// settings window in exclusive mode.
const params = new URL(window.location).searchParams;
if ( params ) {
if ( params.has('ffz-settings') ) {
const main_menu = this.resolve('main_menu');
main_menu.dialog.exclusive = true;
main_menu.enable();
}
if ( params.has('ffz-settings') )
this.resolve('main_menu').openExclusive();
if ( params.has('ffz-translate') ) {
const translation = this.resolve('translation_ui');

View file

@ -31,6 +31,7 @@ export default class Channel extends Module {
this.inject('metadata');
this.inject('socket');
this.settings.add('channel.panel-tips', {
default: true,
ui: {
@ -39,7 +40,10 @@ export default class Channel extends Module {
component: 'setting-check-box'
},
changed: () => this.updatePanelTips()
changed: val => {
this.updatePanelTips();
this.css_tweaks.toggle('panel-links', val);
}
});
this.settings.add('channel.auto-click-chat', {
@ -107,6 +111,8 @@ export default class Channel extends Module {
onEnable() {
this.updateChannelColor();
this.css_tweaks.toggle('panel-links', this.settings.get('channel.panel-tips'));
this.on('i18n:update', this.updateLinks, this);
this.ChannelPanels.on('mount', this.updatePanelTips, this);

View file

@ -56,6 +56,7 @@ export default class ChatLine extends Module {
async onEnable() {
this.on('chat.overrides:changed', id => this.updateLinesByUser(id), this);
this.on('chat:update-lines', this.updateLines, this);
this.on('i18n:update', this.updateLines, this);
this.chat.context.on('changed:chat.emoji.style', this.updateLines, this);
this.chat.context.on('changed:chat.bits.stack', this.updateLines, this);

View file

@ -6,7 +6,6 @@
import Module from 'utilities/module';
import {timeout, has} from 'utilities/object';
import {ALLOWED_ATTRIBUTES, ALLOWED_TAGS} from 'utilities/constants';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
@ -14,10 +13,21 @@ export default class RichContent extends Module {
constructor(...args) {
super(...args);
this.inject('chat');
this.inject('i18n');
this.inject('site.web_munch');
this.RichContent = null;
this.has_tokenizer = false;
}
async loadTokenizer() {
if ( this.has_tokenizer )
return;
this.tokenizer = await import(/* webpack-chunk-name: 'rich_tokens' */ 'utilities/rich_tokens');
this.has_tokenizer = true;
return this.tokenizer;
}
async onEnable() {
@ -34,8 +44,12 @@ export default class RichContent extends Module {
this.state = {
loaded: false,
error: false
error: false,
has_tokenizer: t.has_tokenizer
}
if ( ! t.has_tokenizer )
t.loadTokenizer().then(() => this.setState({...this.state, has_tokenizer: true}));
}
async load() {
@ -49,16 +63,26 @@ export default class RichContent extends Module {
data = await data;
}
console.log('data', data);
if ( ! data )
data = {
error: true,
title: t.i18n.t('card.error', 'An error occurred.'),
desc_1: t.i18n.t('card.empty', 'No data was returned.')
error: {type: 'i18n', key: 'card.empty', phrase: 'No data was returned.'}
}
if ( data.error )
data = {
short: {
type: 'header',
image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
subtitle: data.error
}
};
this.setState(Object.assign({
loaded: true,
url: this.props.url
url: this.props.url,
}, data));
} catch(err) {
@ -66,11 +90,15 @@ export default class RichContent extends Module {
t.log.capture(err);
this.setState({
has_tokenizer: t.has_tokenizer,
loaded: true,
error: true,
url: this.props.url,
title: t.i18n.t('card.error', 'An error occurred.'),
desc_1: String(err)
short: {
type: 'header',
image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
subtitle: String(err)
}
});
}
}
@ -83,7 +111,8 @@ export default class RichContent extends Module {
reload() {
this.setState({
loaded: false,
error: false
error: false,
has_tokenizer: t.has_tokenizer
}, () => this.load());
}
@ -97,138 +126,83 @@ export default class RichContent extends Module {
t.off('chat:update-link-resolver', this.checkReload, this);
}
renderCardImage() {
return (<div class={`chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center${this.state.image_square ? ' square' : ''}`}>
{this.state.error ?
(<img
class="chat-card__error-img"
src={ERROR_IMAGE}
/>) :
(<div class="tw-card-img tw-flex-shrink-0 tw-overflow-hidden">
<div class="tw-aspect tw-aspect--align-top">
<div class="tw-aspect__spacer" style={{paddingTop: '56.25%'}} />
{this.state.loaded && this.state.image ?
(<img class="tw-image" src={this.state.image} alt={this.state.image_title ?? this.state.title} />)
: null}
</div>
</div>)}
</div>)
}
renderTokens(tokens) {
let out = [];
if ( ! Array.isArray(tokens) )
tokens = [tokens];
for(const token of tokens) {
if ( Array.isArray(token) )
out = out.concat(this.renderTokens(token));
else if ( typeof token !== 'object' )
out.push(token);
else if ( token.type === 't' ) {
const content = {};
if ( token.content )
for(const [key,val] of Object.entries(token.content))
content[key] = this.renderTokens(val);
out = out.concat(t.i18n.tList(token.key, token.phrase, content));
} else {
const tag = token.tag || 'span';
if ( ! ALLOWED_TAGS.includes(tag) ) {
console.log('Skipping disallowed tag', tag);
continue;
}
const attrs = {};
if ( token.attrs ) {
for(const [key,val] of Object.entries(token.attrs)) {
if ( ! ALLOWED_ATTRIBUTES.includes(key) && ! key.startsWith('data-') )
console.log('Skipping disallowed attribute', key);
else
attrs[key] = val;
}
}
const el = createElement(tag, {
className: token.class,
...attrs
}, this.renderTokens(token.content));
out.push(el);
}
}
return out;
}
renderCardDescription() {
let title = this.state.title,
title_tokens = this.state.title_tokens,
desc_1 = this.state.desc_1,
desc_1_tokens = this.state.desc_1_tokens,
desc_2 = this.state.desc_2,
desc_2_tokens = this.state.desc_2_tokens;
if ( ! this.state.loaded ) {
desc_1 = t.i18n.t('card.loading', 'Loading...');
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
}
return (<div class={`ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex${desc_2 ? ' ffz--two-line' : ''}`}>
<div class="tw-full-width tw-pd-l-1">
<div class="chat-card__title tw-ellipsis">
<span
class="tw-strong"
data-test-selector="chat-card-title"
title={title}
>
{title_tokens ? this.renderTokens(title_tokens) : title}
</span>
</div>
<div class="tw-ellipsis">
<span
class="tw-c-text-alt-2"
data-test-selector="chat-card-description"
title={desc_1}
>
{desc_1_tokens ? this.renderTokens(desc_1_tokens) : desc_1}
</span>
</div>
{(desc_2_tokens || desc_2) && (<div class="tw-ellipsis">
<span
class="tw-c-text-alt-2"
data-test-selector="chat-card-description"
title={desc_2}
>
{desc_2_tokens ? this.renderTokens(desc_2_tokens) : desc_2}
</span>
</div>)}
</div>
</div>)
}
renderCard() {
if ( this.props.renderBody )
return this.props.renderBody(this.state, this, createElement);
if ( this.state.html )
return <div dangerouslySetInnerHTML={{__html: this.state.html}} />;
return [
this.renderUnsafe(),
this.renderBody()
];
}
renderUnsafe() {
if ( ! this.state.unsafe )
return null;
const reasons = Array.from(new Set(this.state.urls.map(url => url.flags).flat())).join(', ').toLowerCase();
return (<div
class="ffz--corner-flag ffz--corner-flag__warn ffz-tooltip ffz-tooltip--no-mouse"
data-title={t.i18n.t('tooltip.link-unsafe', "Caution: This URL is on Google's Safe Browsing List for: {reasons}", {reasons})}
>
<figure class="ffz-i-attention" />
</div>);
}
renderBody() {
const doc = this.props.force_full ? this.state.full : this.state.short;
if ( t.has_tokenizer && this.state.loaded && doc ) {
return (<div class="ffz-card-rich tw-full-width tw-overflow-hidden tw-flex tw-flex-column">
{t.tokenizer.renderTokens(doc, createElement, {
vue: false,
tList: (...args) => t.i18n.tList(...args),
i18n: t.i18n,
allow_media: t.chat.context.get('tooltip.link-images'),
allow_unsafe: t.chat.context.get('tooltip.link-nsfw-images')
})}
</div>);
} else
return this.renderBasic();
}
renderBasic() {
let title, description;
if ( this.state.error ) {
title = t.i18n.t('card.error', 'An error occured.');
description = this.state.error;
} else if ( this.state.loaded && this.state.has_tokenizer ) {
title = this.state.title;
description = this.state.description;
} else {
description = t.i18n.t('card.loading', 'Loading...');
}
if ( ! title && ! description )
description = t.i18n.t('card.empty', 'No data was returned.');
description = description ? description.split(/\n+/).slice(0,2).map(desc =>
<div class="tw-c-text-alt-2 tw-ellipsis tw-mg-x-05" title={desc}>{desc}</div>
) : [];
return [
this.renderCardImage(),
this.renderCardDescription()
<div class="ffz--header-image" />,
(<div class="ffz--card-text tw-full-width tw-overflow-hidden tw-flex tw-flex-column tw-justify-content-center">
{title && <div class="chat-card__title tw-ellipsis tw-mg-x-05"><span class="tw-strong" title={title}>{title}</span></div>}
{description}
</div>)
];
}
render() {
let content = <div class="tw-flex tw-flex-nowrap tw-pd-05">{this.renderCard()}</div>;
const tooltip = this.props.card_tooltip && this.state.full && ! this.props.force_full;
if ( this.state.url ) {
const tooltip = this.props.card_tooltip;
content = (<a
class={`${tooltip ? 'ffz-tooltip ' : ''}${this.state.accent ? 'ffz-accent-card ' : ''} tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactable--hover-enabled tw-interactive`}
class={`${tooltip ? 'ffz-tooltip ' : ''}${this.state.accent ? 'ffz-accent-card ' : ''}${this.state.error ? 'tw-interactable--hover-enabled ': ''} tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactive`}
data-tooltip-type="link"
data-url={this.state.url}
data-is-mail={false}
@ -241,7 +215,7 @@ export default class RichContent extends Module {
}
return (<div
class="tw-border-radius-medium tw-elevation-1 ffz--chat-card"
class={`tw-border-radius-medium tw-elevation-1 ffz--chat-card tw-relative${this.state.unsafe ? ' ffz--unsafe' : ''}`}
style={{'--ffz-color-accent': this.state.accent || null}}
>
<div class="tw-border-radius-medium tw-c-background-base tw-flex tw-full-width">

View file

@ -0,0 +1,11 @@
.channel-panels {
.default-panel {
& > .tw-link {
display: block;
& > * {
pointer-events: none;
}
}
}
}

View file

@ -72,7 +72,9 @@ body .whispers--theatre-mode.whispers--right-column-expanded-beside {
right: 0 !important;
}
.channel-root__scroll-area--theatre-mode .channel-info-bar {
left: calc(var(--ffz-chat-width) + 5rem) !important;
right: 25rem !important;
.channel-root__scroll-area--theatre-mode {
.channel-info-content > div:first-child, .channel-info-bar {
left: calc(var(--ffz-chat-width) + 5rem) !important;
right: 40rem !important;
}
}

View file

@ -1517,6 +1517,9 @@ export default class Player extends Module {
if ( ! this.settings.get('player.theatre.auto-enter') || ! inst._ffz_mounted )
return;
if ( this.router.current_name === 'user-home' )
return;
if ( inst.props.channelHomeLive || inst.props.channelHomeCarousel || inst.props.theatreModeEnabled )
return;

View file

@ -53,6 +53,28 @@ export default class ThemeEngine extends Module {
changed: () => this.updateCSS()
});
this.settings.add('theme.color.tooltip.background', {
default: '',
ui: {
path: 'Appearance > Theme >> Colors',
title: 'Tooltip Background',
description: 'If not set, the tooltip settings will be automatically adjusted based on the brightness of the background.',
component: 'setting-color-box',
alpha: true
},
changed: () => this.updateCSS()
});
this.settings.add('theme.color.tooltip.text', {
default: '',
ui: {
path: 'Appearance > Theme >> Colors',
title: 'Tooltip Text',
component: 'setting-color-box'
},
changed: () => this.updateCSS()
});
this.settings.add('theme.dark', {
requires: ['theme.is-dark'],
default: false,
@ -176,6 +198,37 @@ The CSS loaded by this setting is far too heavy and can cause performance issues
bits.push(`--color-text-alt-2: ${hsla._a(alpha - 0.4).toCSS()};`);
}
// Tooltips
let tooltip_bg = Color.RGBA.fromCSS(this.settings.get('theme.color.tooltip.background')),
tooltip_dark;
if ( ! tooltip_bg && background )
tooltip_bg = Color.RGBA.fromCSS(dark ? '#FFF' : '#000');
if ( tooltip_bg ) {
bits.push(`--color-background-tooltip: ${tooltip_bg.toCSS()};`);
const hsla = tooltip_bg.toHSLA(),
luma = hsla.l;
tooltip_dark = luma < 0.5;
} else
tooltip_dark = ! dark;
let tooltip_text = Color.RGBA.fromCSS(this.settings.get('theme.color.tooltip.text'));
const has_tt_text = tooltip_text || tooltip_bg;
if ( ! tooltip_text )
tooltip_text = Color.RGBA.fromCSS(tooltip_dark ? '#FFF' : '#000');
if ( tooltip_text ) {
if ( has_tt_text )
bits.push(`--color-text-tooltip: ${tooltip_text.toCSS()};`);
const hsla = tooltip_text.toHSLA(),
alpha = hsla.a;
bits.push(`--color-text-tooltip-alt: ${hsla._a(alpha - 0.2).toCSS()};`);
bits.push(`--color-text-tooltip-alt-2: ${hsla._a(alpha - 0.4).toCSS()};`);
}
if ( bits.length ) {
this.css_tweaks.set('colors', `body {${bits.join('\n')}}`);

View file

@ -34,18 +34,6 @@
}
}
.channel-panels {
.default-panel {
& > .tw-link {
display: block;
& > * {
pointer-events: none;
}
}
}
}
.tw-root--theme-ffz, .tw-root--theme-ffz.tw-root--theme-dark, .tw-root--theme-dark, body {
.ffz-stat > .tw-button--text,
.ffz-stat.tw-button--text {

View file

@ -66,14 +66,6 @@
border-right: .5rem solid var(--ffz-color-accent);
}
.chat-card__preview-img.square {
width: 4.5rem;
.tw-aspect__spacer {
padding-top: 100% !important;
}
}
.chat-card__title {
max-width: unset;
}

View file

@ -22,8 +22,10 @@ export default {
md.use(MILA, {
attrs: {
class: 'ffz-tooltip',
target: '_blank',
rel: 'noopener'
rel: 'noopener',
'data-tooltip-type': 'link'
}
});

View file

@ -18,17 +18,11 @@ export const LV_SERVER = 'https://cbenni.com/api';
export const LV_SOCKET_SERVER = 'wss://cbenni.com/socket.io/';
export const ALLOWED_TAGS = [
'strong', 'em', 'i', 'b', 'time', 'br', 'hr', 'div', 'span', 'img', 'figure', 'p', 'a', 'video', 'audio', 'blockquote', 'heading', 'section', 'nav', 'footer', 'aside', 'article', 'source', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
];
export const ALLOWED_ATTRIBUTES = [
'datetime', 'src', 'href', 'style', 'alt', 'title', 'height', 'width', 'srcset', 'autoplay', 'volume', 'muted', 'loop', 'poster', 'type'
];
export const KEYS = {
Enter: 13,
Shift: 16,
Control: 17,
Alt: 18,
Escape: 27,
Space: 32,
PageUp: 33,
@ -38,7 +32,9 @@ export const KEYS = {
ArrowLeft: 37,
ArrowUp: 38,
ArrowRight: 39,
ArrowDown: 40
ArrowDown: 40,
Meta: 91,
Context: 93
};

View file

@ -3,11 +3,13 @@ query FFZ_FetchUser($id: ID, $login: String) {
id
login
displayName
description
profileImageURL(width: 50)
profileViewCount
primaryColorHex
broadcastSettings {
id
title
game {
id
displayName
@ -15,6 +17,7 @@ query FFZ_FetchUser($id: ID, $login: String) {
}
stream {
id
previewImageURL
}
followers {
totalCount

View file

@ -13,7 +13,7 @@ const ATTRS = [
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
'minlength', 'media', 'method', 'min', 'multiple', 'muted', 'name',
'minlength', 'media', 'method', 'min', 'multiple', 'name',
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
@ -22,6 +22,10 @@ const ATTRS = [
'title', 'type', 'usemap', 'value', 'width', 'wrap'
];
const BOOLEAN_ATTRS = [
'controls', 'autoplay', 'loop'
];
const range = document.createRange();
@ -95,8 +99,12 @@ export function createElement(tag, props, ...children) {
el.style.cssText = prop;
else
for(const k in prop)
if ( has(prop, k) )
el.style[k] = prop[k];
if ( has(prop, k) ) {
if ( has(el.style, k) )
el.style[k] = prop[k];
else
el.style.setProperty(k, prop[k]);
}
} else if ( lk === 'dataset' ) {
for(const k in prop)
@ -114,11 +122,16 @@ export function createElement(tag, props, ...children) {
else if ( lk.startsWith('data-') )
el.dataset[camelCase(lk.slice(5))] = prop;
else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
else if ( BOOLEAN_ATTRS.includes(lk) ) {
if ( prop && prop !== 'false' )
el.setAttribute(key, prop);
console.log('bool-attr', key, prop);
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
el.setAttribute(key, prop);
else
el[key] = props[key];
el[key] = prop;
}
if ( children )

View file

@ -97,5 +97,8 @@ export default [
"viewers",
"move",
"chat-empty",
"chat"
"chat",
"location",
"link",
"volume-off"
];

View file

@ -540,6 +540,51 @@ export function glob_to_regex(input) {
}
/**
* Truncate a string. Tries to intelligently break the string in white-space
* if possible, without back-tracking. The returned string can be up to
* `ellipsis.length + target + overage` characters long.
* @param {String} str The string to truncate.
* @param {Number} target The target length for the result
* @param {Number} overage Accept up to this many additional characters for a better result
* @param {String} [ellipsis='…'] The string to append when truncating
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
* @returns {String} The truncated string
*/
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
if ( ! str || ! str.length )
return str;
if ( trim )
str = str.trim();
let idx = break_line ? str.indexOf('\n') : -1;
if ( idx === -1 || idx > target )
idx = target;
if ( str.length <= idx )
return str;
let out = str.slice(0, idx).trimRight();
if ( overage > 0 && out.length >= idx ) {
let next_space = str.slice(idx).search(/\s+/);
if ( next_space === -1 && overage + idx > str.length )
next_space = str.length - idx;
if ( next_space !== -1 && next_space <= overage ) {
if ( str.length <= (idx + next_space) )
return str;
out = str.slice(0, idx + next_space);
}
}
return out + ellipsis;
}
export class SourcedSet {
constructor() {
this._cache = [];

View file

@ -0,0 +1,946 @@
'use strict';
// ============================================================================
// Rich Content Tokens
// ============================================================================
import {has} from 'utilities/object';
export const TOKEN_TYPES = {};
const validate = (input, valid) => valid.includes(input) ? input : null;
const VALID_WEIGHTS = ['regular', 'bold', 'semibold'],
VALID_COLORS = ['base', 'alt', 'alt-2', 'link'],
VALID_SIZES = ['1', '2,' ,'3','4','5','6','7','8'],
VALID_WRAPS = ['nowrap', 'pre-wrap'],
VALID_PADDING = {
small: '05',
normal: 'normal',
large: 'large',
huge: 'huge'
};
// ============================================================================
// Render Tokens
// ============================================================================
function applySpacing(term, token, classes, styles) {
for(const mode of ['', '-x','-y','-t','-r','-b','-l']) {
const key = `${term}${mode}`,
value = token[key];
if ( value ) {
if ( VALID_PADDING[value] )
classes.push(`tw-${term}${mode}-${VALID_PADDING[value]}`);
else if ( styles ) {
const thing = term === 'pd' ? 'padding' : 'margin';
if ( mode === '' )
styles[thing] = value;
if ( mode === 'x' || mode === 'l' )
styles[`${thing}-left`] = value;
if ( mode === 'x' || mode === 'r' )
styles[`${thing}-right`] = value;
if ( mode === 'y' || mode === 't' )
styles[`${thing}-top`] = value;
if ( mode === 'y' || mode === 'b' )
styles[`${thing}-bottom`] = value;
}
}
}
}
export function getRoundClass(value) {
let klass;
if ( value === -1 )
klass = 'rounded';
else if ( value === 1 )
klass = 'small';
else if ( value === 2 )
klass = 'medium';
else if ( value === 3 )
klass = 'large';
return klass ? `tw-border-radius-${klass}` : '';
}
// TODO: Mess with this more.
// (It's a function for wrapping React's createElement in a function
// that accepts the same input as Vue's createElement, letting us
// deduplicate a ton of code in here.)
/*export function wrapReactCreate(createElement) {
return (tag, opts, children) => {
if ( typeof tag !== 'string' )
throw new Error('invalid tag');
if ( opts ) {
if ( opts.class ) {
if ( typeof opts.class === 'string' )
opts.className = opts.class;
else if ( Array.isArray(opts.class) )
opts.className = opts.class.join(' ');
else if ( typeof opts.class === 'object' ) {
const bits = [];
for(const [key, val] of Object.entries(opts.class))
if ( val )
bits.push(key);
opts.className = bits.join(' ');
}
opts.class = undefined;
}
if ( opts.attrs ) {
for(const [key, val] of Object.entries(opts.attrs) )
opts[key] = val;
opts.attrs = undefined;
}
if ( opts.props )
throw new Error('props unsupported');
if ( opts.domProps )
throw new Error('domProps unsupported');
if ( opts.nativeOn )
throw new Error('nativeOn unsupported');
if ( opts.on ) {
for(const [key, val] of Object.entries(opts.on) )
opts[`on${key.charAt(0).toUpperCase()}${key.slice(1)}`] = val;
opts.on = undefined;
}
if ( opts.style && typeof opts.style !== 'object' )
opts.style = undefined;
}
return createElement(tag, opts, children);
}
}*/
export function renderWithCapture(tokens, createElement, ctx) {
const old_capture = ctx.text_capture;
ctx.text_capture = [];
const content = renderTokens(tokens, createElement, ctx);
let title = ctx.text_capture.join('').trim();
if ( ! title.length )
title = null;
ctx.text_capture = old_capture;
return {
content,
title
}
}
export function renderTokens(tokens, createElement, ctx) {
if ( tokens == null )
return null;
let out = [];
if ( ! Array.isArray(tokens) )
tokens = [tokens];
for(const token of tokens) {
if ( token == null )
continue;
else if ( Array.isArray(token) )
out = out.concat(renderTokens(token, createElement, ctx));
else if ( typeof token !== 'object' ) {
const val = String(token);
if ( ctx.text_capture )
ctx.text_capture.push(val);
out.push(val);
}
else {
const type = token.type,
handler = TOKEN_TYPES[type];
if ( ! handler ) {
console.warn('Skipping unknown token type', type, token);
continue;
}
const result = handler(token, createElement, ctx);
if ( Array.isArray(result) )
out = out.concat(result);
else if ( result )
out.push(result);
}
}
if ( ! out.length )
return null;
return out;
}
export default renderTokens;
// ============================================================================
// Token Type: Box
// ============================================================================
TOKEN_TYPES.box = function(token, createElement, ctx) {
const classes = [], style = {};
if ( VALID_WRAPS.includes(token.wrap) )
classes.push(`tw-white-space-${token.wrap}`);
if ( token.ellipsis )
classes.push('tw-ellipsis');
if ( token.lines ) {
classes.push('ffz--line-clamp');
style['--ffz-lines'] = token.lines;
}
applySpacing('pd', token, classes, style);
applySpacing('mg', token, classes, style);
const capture = token.ellipsis || token.lines;
let content, title = null;
if ( capture ) {
const out = renderWithCapture(token.content, createElement, ctx);
content = out.content; title = out.title;
} else
content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement('div', {class: classes, style, attrs: {title}}, content);
return createElement('div', {className: classes.join(' '), style, title}, content);
}
// ============================================================================
// Token Type: Conditional
// ============================================================================
TOKEN_TYPES.conditional = function(token, createElement, ctx) {
let passed = true;
if ( has(token, 'media') && token.media != ctx.allow_media )
passed = false;
if ( token.nsfw && ! ctx.allow_unsafe )
passed = false;
if ( passed )
return renderTokens(token.content, createElement, ctx);
return renderTokens(token.alternative, createElement, ctx);
}
// ============================================================================
// Token Type: Fieldset
// ============================================================================
TOKEN_TYPES.fieldset = function(token, createElement, ctx) {
if ( ! Array.isArray(token.fields) )
return null;
const fields = [];
for(const field of token.fields) {
if ( ! field )
continue;
const name = renderTokens(field.name, createElement, ctx),
value = renderTokens(field.value, createElement, ctx);
if ( name == null || value == null )
continue;
if ( ctx.vue )
fields.push(createElement('div', {
class: [
'ffz--field',
field.inline ? 'ffz--field-inline' : false
]
}, [
createElement('div', {
class: 'ffz--field__name tw-semibold'
}, name),
createElement('div', {
class: 'ffz--field__value tw-c-text-alt'
}, value)
]));
else
fields.push(createElement('div', {
className: `ffz--field ${field.inline ? 'ffz--field-inline' : ''}`
}, [
createElement('div', {className: 'ffz--field__name tw-semibold'}, name),
createElement('div', {className: 'ffz--field__value tw-c-text-alt'}, value)
]));
}
if ( ! fields.length )
return null;
if ( ctx.vue )
return createElement('div', {
class: 'ffz--fields'
}, fields);
return createElement('div', {
className: 'ffz--fields'
}, fields);
}
// ============================================================================
// Token Type: Flex
// ============================================================================
const ALIGNMENTS = ['start', 'end', 'center', 'between', 'around'];
TOKEN_TYPES.flex = function(token, createElement, ctx) {
const classes = [], style = {};
if ( token.inline )
classes.push('tw-flex-inline');
else
classes.push('tw-flex');
const overflow = validate(token.overflow, ['hidden', 'auto']);
if ( overflow )
classes.push(`tw-overflow-${overflow}`);
const direction = validate(token.direction, ['column', 'row', 'column-reverse', 'row-reverse']);
if ( direction )
classes.push(`tw-flex-${direction}`);
const wrap = validate(token.wrap, ['wrap', 'nowrap', 'wrap-reverse']);
if ( wrap )
classes.push(`tw-flex-${wrap}`);
let align = validate(token['align-content'], ALIGNMENTS)
if ( align )
classes.push(`tw-align-content-${align}`);
align = validate(token['justify-content'], ALIGNMENTS);
if ( align )
classes.push(`tw-justify-content-${align}`);
align = validate(token['align-items'], ALIGNMENTS)
if ( align )
classes.push(`tw-align-items-${align}`);
align = validate(token['align-self'], ALIGNMENTS)
if ( align )
classes.push(`tw-align-self-${align}`);
applySpacing('pd', token, classes, style);
applySpacing('mg', token, classes, style);
const content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement('div', {class: classes, style}, content);
return createElement('div', {className: classes.join(' '), style}, content);
}
// ============================================================================
// Token Type: Format
// ============================================================================
TOKEN_TYPES.format = function(token, createElement, ctx) {
const type = token.format, val = token.value, opt = token.options;
let out;
if ( type === 'date' )
out = ctx.i18n.formatDate(val, opt);
else if ( type === 'time' )
out = ctx.i18n.formatTime(val, opt);
else if ( type === 'datetime' )
out = ctx.i18n.formatDateTime(val, opt)
else if ( type === 'relative' )
out = ctx.i18n.toRelativeTime(val, opt);
else if ( type === 'duration' )
out = ctx.i18n.formatDuration(val, opt);
else if ( type === 'number' )
out = ctx.i18n.formatNumber(val, opt);
else {
console.warn('Unknown format type:', type);
out = String(val);
}
if ( ctx.text_capture )
ctx.text_capture.push(out);
return out;
}
// ============================================================================
// Token Type: Gallery
// ============================================================================
TOKEN_TYPES.gallery = function(token, createElement, ctx) {
if ( ! token.items )
return null;
let items = token.items.map(item => renderTokens(item, createElement, ctx)).filter(x => x);
if ( ! items.length )
return null;
if ( items.length > 4 )
items = items.slice(0, 4);
const divisions = [],
count = items.length < 4 ? 1 : 2;
divisions.push(ctx.vue ?
createElement('div', {
class: 'ffz--gallery-column',
attrs: {
'data-items': count
}
}, items.slice(0, count)) :
createElement('div', {
className: 'ffz--gallery-column',
'data-items': count
}, items.slice(0, count))
);
if ( items.length > 1 )
divisions.push(ctx.vue ?
createElement('div', {
class: 'ffz--gallery-column',
attrs: {
'data-items': items.length - count
}
}, items.slice(count)) :
createElement('div', {
className: 'ffz--gallery-column',
'data-items': items.length - count
}, items.slice(count))
);
if ( ctx.vue )
return createElement('div', {
class: 'ffz--rich-gallery',
attrs: {
'data-items': items.length
}
}, divisions);
return createElement('div', {
className: 'ffz--rich-gallery',
'data-items': items.length
}, divisions);
}
// ============================================================================
// Token Type: Heading
// ============================================================================
function header_vue(token, h, ctx) {
let content = [];
if ( token.title ) {
const out = renderWithCapture(token.title, h, ctx);
content.push(h('div', {
class: 'tw-ellipsis tw-semibold tw-mg-x-05',
attrs: {
title: out.title
}
}, out.content));
}
if ( token.subtitle ) {
const out = renderWithCapture(token.subtitle, h, ctx);
content.push(h('div', {
class: 'tw-ellipsis tw-c-text-alt-2 tw-mg-x-05',
attrs: {
title: out.title
}
}, out.content));
}
if ( token.extra ) {
const out = renderWithCapture(token.extra, h, ctx);
content.push(h('div', {
class: 'tw-ellipsis tw-c-text-alt-2 tw-mg-x-05',
attrs: {
title: out.title
}
}, out.content));
}
content = h('div', {
class: [
'tw-flex tw-full-width tw-overflow-hidden',
token.compact ? 'ffz--rich-header ffz--compact-header tw-align-items-center' : 'tw-justify-content-center tw-flex-column tw-flex-grow-1'
]
}, content);
if ( token.image ) {
const aspect = token.image.aspect;
let image = render_image({
...token.image,
aspect: undefined
}, h, ctx);
const right = token.image_side === 'right';
if ( image ) {
image = h('div', {
class: [
'ffz--header-image tw-flex-shrink-0 tw-mg-x-05',
aspect ? 'ffz--header-aspect' : null
],
style: {
width: aspect ? `${aspect * (token.compact ? 2.4 : 4.8)}rem` : null
}
}, [image]);
if ( token.compact ) {
if ( right )
content.children.push(image);
else
content.children.unshift(image);
} else {
content = h('div', {
class: 'tw-flex ffz--rich-header'
}, [
right ? content : null,
image,
right ? null : content
])
}
}
}
return content;
}
function header_normal(token, createElement, ctx) {
let content = [];
if ( token.title ) {
const out = renderWithCapture(token.title, createElement, ctx);
content.push(createElement('div', {
className: `tw-ellipsis tw-semibold ${token.compact ? 'tw-mg-r-1' : ''}`,
title: out.title
}, out.content));
}
if ( token.subtitle ) {
const out = renderWithCapture(token.subtitle, createElement, ctx);
content.push(createElement('div', {
className: `tw-ellipsis tw-c-text-alt-2`,
title: out.title
}, out.content));
}
if ( token.extra ) {
const out = renderWithCapture(token.extra, createElement, ctx);
content.push(createElement('div', {
className: 'tw-ellipsis tw-c-text-alt-2',
title: out.title
}, out.content));
}
content = createElement('div', {
className: `tw-flex tw-full-width tw-overflow-hidden ${token.compact ? 'ffz--rich-header ffz--compact-header tw-align-items-center' : 'tw-justify-content-center tw-flex-column tw-flex-grow-1'}`
}, content);
if ( token.image ) {
const aspect = token.image.aspect;
let image = render_image({
...token.image,
aspect: undefined
}, createElement, ctx);
const right = token.image_side === 'right';
if ( image ) {
image = createElement('div', {
className: `ffz--header-image tw-flex-shrink-0 tw-mg-x-05${aspect ? ' ffz--header-aspect' : ''}`,
style: {
width: aspect ? `${aspect * (token.compact ? 2.4 : 4.8)}rem` : null
}
}, image);
if ( token.compact ) {
// We need to do some weird pushy stuff~
// This varies if we're running with React or native.
if ( content instanceof Node ) {
if ( right )
content.appendChild(image);
else
content.insertBefore(image, content.firstChild);
} else {
console.warn('Add React support!');
console.log(content);
}
} else {
content = createElement('div', {
className: 'tw-flex ffz--rich-header'
}, [right ? content : null, image, right ? null : content])
}
}
}
return content;
}
TOKEN_TYPES.header = function(token, createElement, ctx) {
if ( ! token.title && ! token.subtitle && ! token.image && ! token.extra )
return null;
return ctx.vue ?
header_vue(token, createElement, ctx) :
header_normal(token, createElement, ctx);
}
// ============================================================================
// Token Type: Icon
// ============================================================================
TOKEN_TYPES.icon = function(token, createElement, ctx) {
if ( ! token.name )
return null;
return ctx.vue ?
createElement('span', {class: `ffz-i-${token.name}`}) :
createElement('span', {className: `ffz-i-${token.name}`});
}
// ============================================================================
// Token Type: Image
// ============================================================================
function render_image(token, createElement, ctx) {
if ( ! token.url || (has(token, 'sfw') && ! token.sfw && ! ctx.allow_unsafe) )
return null;
const round = getRoundClass(token.rounding);
let aspect;
if ( token.aspect )
aspect = token.aspect
else if ( token.height > 0 && token.width > 0 )
aspect = token.width / token.height;
if ( ctx.vue ) {
const stuff = {
class: [
token.class,
round
],
style: {
width: token.width,
height: token.height
},
attrs: {
src: token.url,
title: token.title
}
};
if ( ctx.onload )
stuff.on = {load: ctx.onload};
const image = createElement('img', stuff);
if ( ! aspect )
return image;
return createElement('aspect', {
props: {
ratio: aspect,
align: 'center'
}
}, [image]);
}
const image = createElement('img', {
className: `${token.class || ''} ${round}`,
src: token.url,
title: token.title || '',
onLoad: ctx.onload
});
if ( ! aspect )
return image;
return createElement('div', {
className: 'tw-aspect tw-aspect--align-center'
}, [
createElement('div', {
className: 'tw-aspect__spacer',
style: {
paddingTop: `${100 * (1 / (aspect || 1))}%`
}
}),
image
]);
}
TOKEN_TYPES.image = render_image;
// ============================================================================
// Token Type: I18n
// ============================================================================
TOKEN_TYPES.i18n = function(token, createElement, ctx) {
if ( ! token.phrase ) {
console.warn('Skipping i18n tag with no phrase');
return null;
}
return renderTokens(
ctx.i18n.tList(token.key, token.phrase, token.content),
createElement,
ctx
);
}
// ============================================================================
// Token Type: Link
// ============================================================================
TOKEN_TYPES.link = function(token, createElement, ctx) {
const content = renderTokens(token.content, createElement, ctx);
const klass = [];
if ( token.interactive )
klass.push(`tw-interactable tw-interactable--hover-enabled tw-interactable--alpha tw-interactive`);
if ( token.tooltip !== false )
klass.push('ffz-tooltip');
if ( token.embed )
klass.push(`tw-block tw-border tw-border-radius-large tw-mg-y-05 tw-pd-05`);
if ( token.no_color )
klass.push(`tw-link--inherit`);
if ( ctx.vue )
return createElement('a', {
class: klass,
attrs: {
rel: 'noopener noreferrer',
target: '_blank',
'data-tooltip-type': 'link',
href: token.url
}
}, content);
return createElement('a', {
className: klass.join(' '),
rel: 'noopener noreferrer',
target: '_blank',
'data-tooltip-type': 'link',
href: token.url
}, content);
}
// ============================================================================
// Token Type: Overlay
// ============================================================================
TOKEN_TYPES.overlay = function(token, createElement, ctx) {
const content = renderTokens(token.content, createElement, ctx);
if ( ! content )
return null;
const corners = [];
for(const corner of ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right']) {
const stuff = renderTokens(token[corner], createElement, ctx);
if ( stuff )
corners.push(ctx.vue ?
createElement('div', {class: `ffz--overlay__bit`, attrs:{'data-side':corner}}, stuff) :
createElement('div', {className: `ffz--overlay__bit`, 'data-side':corner}, stuff)
);
}
if ( ctx.vue )
return createElement('div', {class: 'ffz--overlay'}, [
createElement('div', {class: 'ffz--overlay__content'}, content),
...corners
]);
return createElement('div', {className: 'ffz--overlay'}, [
createElement('div', {className: 'ffz--overlay__content'}, content),
...corners
]);
}
// ============================================================================
// Token Type: Style
// ============================================================================
TOKEN_TYPES.style = function(token, createElement, ctx) {
const classes = [], style = {};
if ( token.weight ) {
if ( VALID_WEIGHTS.includes(token.weight) )
classes.push(`tw-${token.weight}`);
else
style.weight = token.weight;
}
if ( token.italic )
classes.push('tw-italic');
if ( token.strike )
classes.push('tw-strikethrough');
if ( token.underline )
classes.push('tw-underline');
if ( token.tabular )
classes.push('tw-tabular-nums');
if ( token.size ) {
if ( typeof token.size === 'string' ) {
if ( VALID_SIZES.includes(token.size) )
classes.push(`tw-font-size-${token.size}`);
else
style.fontSize = token.size;
} else
style.fontSize = `${token.size}px`;
}
if ( token.color ) {
if ( VALID_COLORS.includes(token.color) )
classes.push(`tw-c-text-${token.color}`);
else
style.color = token.color;
}
if ( VALID_WRAPS.includes(token.wrap) )
classes.push(`tw-white-space-${token.wrap}`);
if ( token.ellipsis )
classes.push('tw-ellipsis');
applySpacing('pd', token, classes, style);
applySpacing('mg', token, classes, style);
const capture = token.ellipsis;
let content, title = null;
if ( capture ) {
const out = renderWithCapture(token.content, createElement, ctx);
content = out.content; title = out.title;
} else
content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement('span', {class: classes, style, attrs: {title}}, content);
return createElement('span', {className: classes.join(' '), style, title}, content);
}
// ============================================================================
// Token Type: Tag (Deprecated)
// ============================================================================
export const ALLOWED_TAGS = [
'a', 'abbr', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'blockquote', 'br',
'caption', 'code', 'col', 'colgroup', 'data', 'dd', 'div', 'dl', 'dt', 'em',
'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
'hr', 'i', 'img', 'li', 'main', 'nav', 'ol', 'p', 'picture', 'pre', 's', 'section',
'source', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot',
'th', 'thead', 'time', 'tr', 'track', 'u', 'ul', 'video', 'wbr'
];
export const ALLOWED_ATTRS = {
a: ['href'],
audio: ['autoplay', 'controls', 'loop', 'muted', 'preload', 'src'],
bdo: ['dir'],
col: ['span'],
colgroup: ['span'],
data: ['value'],
img: ['alt', 'height', 'sizes', 'src', 'srcset', 'width'],
source: ['src', 'srcset', 'type', 'media', 'sizes'],
td: ['colspan', 'headers', 'rowspan'],
th: ['abbr', 'colspan', 'headers', 'rowspan', 'scope'],
time: ['datetime'],
track: ['default', 'kind', 'label', 'src', 'srclang'],
video: ['autoplay', 'controls', 'height', 'loop', 'muted', 'poster', 'preload', 'src', 'width'],
};
export const PROPS = [
'muted'
];
export const GLOBAL_ATTRS = ['style', 'title'];
TOKEN_TYPES.tag = function(token, createElement, ctx) {
const tag = String(token.tag || 'span').toLowerCase();
if ( ! ALLOWED_TAGS.includes(tag) ) {
console.warn('Skipping disallowed tag:', tag);
return null;
}
const attrs = {}, props = {};
if ( token.attrs ) {
const allowed = ALLOWED_ATTRS[tag] || [];
for(const [key, val] of Object.entries(token.attrs)) {
if ( ! allowed.includes(key) && ! key.startsWith('data-') && ! GLOBAL_ATTRS.includes(key) )
console.warn(`Skipping disallowed attribute for tag ${tag}:`, key);
else if ( ctx.vue && PROPS.includes(key) )
props[key] = val;
else
attrs[key] = val;
}
}
if ( tag === 'img' || tag === 'picture' )
attrs.onload = ctx.onload;
if ( tag === 'video' || tag === 'audio' )
attrs.loadedmetadata = ctx.onload;
const content = renderTokens(token.content, createElement, ctx);
if ( ctx.vue )
return createElement(tag, {
class: token.class || '',
domProps: props,
attrs
}, content);
return createElement(tag, {
...attrs,
className: token.class || ''
}, content);
}

View file

@ -42,10 +42,14 @@ export class Tooltip {
this.options = Object.assign({}, DefaultOptions, options);
this.live = this.options.live;
this.check_modifiers = this.options.check_modifiers;
this.parent = parent;
this.cls = cls;
if ( this.check_modifiers )
this.installModifiers();
if ( ! this.live ) {
if ( typeof cls === 'string' )
this.elements = parent.querySelectorAll(cls);
@ -65,16 +69,18 @@ export class Tooltip {
this._accessor = `_ffz_tooltip$${last_id++}`;
this._onMouseOut = e => this._exit(e.target);
this._onMouseOut = e => e.target && e.target.dataset.forceOpen !== 'true' && this._exit(e.target);
if ( this.options.manual ) {
// Do nothing~!
} else if ( this.live ) {
this._onMouseOver = e => {
this.updateShift(e.shiftKey);
const target = e.target;
if ( target && target.classList && target.classList.contains(this.cls) )
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) {
this._enter(target);
}
};
parent.addEventListener('mouseover', this._onMouseOver);
@ -82,9 +88,11 @@ export class Tooltip {
} else {
this._onMouseOver = e => {
this.updateShift(e.shiftKey);
const target = e.target;
if ( this.elements.has(target) )
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
this._enter(e.target);
}
}
if ( this.elements.size <= 5 )
@ -102,6 +110,8 @@ export class Tooltip {
}
destroy() {
this.removeModifiers();
if ( this.options.manual ) {
// Do nothing~!
} else if ( this.live || this.elements.size > 5 ) {
@ -128,6 +138,43 @@ export class Tooltip {
}
installModifiers() {
if ( this._keyUpdate )
return;
this._keyUpdate = e => this.updateShift(e.shiftKey);
window.addEventListener('keydown', this._keyUpdate);
window.addEventListener('keyup', this._keyUpdate);
}
removeModifiers() {
if ( ! this._keyUpdate )
return;
window.removeEventListener('keydown', this._keyUpdate);
window.removeEventListener('keyup', this._keyUpdate);
this._keyUpdate = null;
}
updateShift(state) {
if ( state === this.shift_state )
return;
this.shift_state = state;
if ( ! this._shift_af )
this._shift_af = requestAnimationFrame(() => {
this._shift_af = null;
for(const el of this.elements) {
const tip = el[this._accessor];
if ( tip && tip.outer ) {
tip.outer.dataset.shift = this.shift_state;
tip.update();
}
}
});
}
cleanup() {
if ( this.options.manual )
return;
@ -238,7 +285,8 @@ export class Tooltip {
inner = tip.element = createElement('div', opts.innerClass),
el = tip.outer = createElement('div', {
className: opts.tooltipClass
className: opts.tooltipClass,
'data-shift': this.shift_state
}, [inner, arrow]);
arrow.setAttribute('x-arrow', true);
@ -259,6 +307,7 @@ export class Tooltip {
if ( ! opts.manual || (hover_events && (opts.onHover || opts.onLeave || opts.onMove)) ) {
if ( hover_events && opts.onMove )
el.addEventListener('mousemove', el._ffz_move_handler = event => {
this.updateShift(event.shiftKey);
opts.onMove(target, tip, event);
});
@ -273,7 +322,7 @@ export class Tooltip {
/* no-op */
} else if ( maybe_call(opts.interactive, null, target, tip) )
this._enter(target);
else
else if ( target.dataset.forceOpen !== 'true' )
this._exit(target);
});
@ -281,7 +330,7 @@ export class Tooltip {
if ( hover_events && opts.onLeave )
opts.onLeave(target, tip, event);
if ( ! opts.manual )
if ( ! opts.manual && target.dataset.forceOpen !== 'true' )
this._exit(target);
});
}

View file

@ -50,8 +50,15 @@ export const DEFAULT_TYPES = {
},
number(val, node) {
if ( typeof val !== 'number' )
return val;
if ( typeof val !== 'number' ) {
let new_val = parseInt(val, 10);
if ( isNaN(new_val) || ! isFinite(new_val) )
new_val = parseFloat(val);
if ( isNaN(new_val) || ! isFinite(new_val) )
return val;
val = new_val;
}
return this.formatNumber(val, node.f);
},
@ -105,6 +112,14 @@ export const DEFAULT_FORMATS = {
year: '2-digit'
},
default: {},
medium: {
month: 'short',
day: 'numeric',
year: 'numeric'
},
long: {
month: 'long',
day: 'numeric',
@ -131,13 +146,6 @@ export const DEFAULT_FORMATS = {
second: 'numeric'
},
long: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short'
},
full: {
hour: 'numeric',
minute: 'numeric',
@ -155,14 +163,7 @@ export const DEFAULT_FORMATS = {
minute: 'numeric'
},
medium: {
month: 'numeric',
day: 'numeric',
year: '2-digit',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
},
medium: {},
long: {
month: 'long',
@ -201,6 +202,10 @@ export default class TranslationCore {
this.defaultLocale = options.defaultLocale || this._locale;
this.transformation = null;
this.defaultDateFormat = options.defaultDateFormat;
this.defaultTimeFormat = options.defaultTimeFormat;
this.defaultDateTimeFormat = options.defaultDateTimeFormat;
this.phrases = new Map;
this.cache = new Map;
@ -235,15 +240,14 @@ export default class TranslationCore {
return thing;
}
formatRelativeTime(value) { // eslint-disable-line class-methods-use-this
if ( !(value instanceof Date) )
value = new Date(Date.now() + value * 1000);
formatRelativeTime(value, f) { // eslint-disable-line class-methods-use-this
const d = dayjs(value),
without_suffix = f === 'plain';
const d = dayjs(value);
try {
return d.locale(this._locale).fromNow(true);
return d.locale(this._locale).fromNow(without_suffix);
} catch(err) {
return d.fromNow(true);
return d.fromNow(without_suffix);
}
}
@ -262,13 +266,15 @@ export default class TranslationCore {
}
formatDate(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
if ( ! format )
format = this.defaultDateFormat;
if ( format && ! this.formats.date[format] ) {
const d = dayjs(value);
try {
return d.locale(this._locale).format(f);
return d.locale(this._locale).format(format);
} catch(err) {
return d.format(f);
return d.format(format);
}
}
@ -279,13 +285,15 @@ export default class TranslationCore {
}
formatTime(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
if ( ! format )
format = this.defaultTimeFormat;
if ( format && ! this.formats.time[format] ) {
const d = dayjs(value);
try {
return d.locale(this._locale).format(f);
return d.locale(this._locale).format(format);
} catch(err) {
return d.format(f);
return d.format(format);
}
}
@ -296,13 +304,15 @@ export default class TranslationCore {
}
formatDateTime(value, format) {
if ( typeof format === 'string' && format.startsWith('::') ) {
const f = format.substr(2),
d = dayjs(value);
if ( ! format )
format = this.defaultDateTimeFormat;
if ( format && ! this.formats.datetime[format] ) {
const d = dayjs(value);
try {
return d.locale(this._locale).format(f);
return d.locale(this._locale).format(format);
} catch(err) {
return d.format(f);
return d.format(format);
}
}

View file

@ -194,6 +194,9 @@ export class Vue extends Module {
const router = t.resolve('site.router');
return router.getURL(route, data, opts, ...args);
},
getI18n() {
return t.i18n;
},
t(key, phrase, options) {
return this.$i18n.t_(key, phrase, options);
},

View file

@ -26,6 +26,260 @@
vertical-align: middle;
}
.ffz--line-clamp {
display: -webkit-box;
-webkit-line-clamp: var(--ffz-lines);
-webkit-box-orient: vertical;
overflow: hidden;
}
.ffz--rich-header {
line-height: 1.4;
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.ffz--overlay {
position: relative;
& > :not(.ffz--overlay__content) {
position: absolute;
z-index: 1;
background-color: var(--color-background-overlay);
color: var(--color-text-overlay);
padding: 0 0.5rem;
border-radius: 4px;
}
.ffz--overlay__content {
width: 100%;
height: 100%;
}
.ffz--overlay__bit {
max-width: calc(100% - 1rem);
&[data-side="top-left"],
&[data-side="top"],
&[data-side="top-right"] {
top: 0.5rem;
}
&[data-side="bottom-left"],
&[data-side="bottom"],
&[data-side="bottom-right"] {
bottom: 0.5rem;
}
&[data-side="top-left"],
&[data-side="left"],
&[data-side="bottom-left"] {
left: 0.5rem;
}
&[data-side="top-right"],
&[data-side="right"],
&[data-side="bottom-right"] {
right: 0.5rem;
}
&[data-side="left"], &[data-side="right"] {
top: 50%;
transform: translateY(-50%);
}
&[data-side="top"], &[data-side="bottom"] {
left: 50%;
transform: translateX(-50%);
}
&[data-side="center"] {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.ffz--rich-gallery & {
&[data-side="top-left"],
&[data-side="top"],
&[data-side="top-right"] {
top: 1rem;
}
&[data-side="bottom-left"],
&[data-side="bottom"],
&[data-side="bottom-right"] {
bottom: 1rem;
}
}
}
}
.ffz--header-image {
height: 4.8rem;
max-width: 25%;
img {
object-fit: contain;
height: 100%;
}
&.ffz--header-aspect img {
object-fit: cover;
width: 100%;
}
}
.ffz--compact-header .ffz--header-image {
height: 2.4rem;
}
.ffz--rich-gallery, .ffz--compact-header {
&:not(:first-child) {
margin-top: 0.5rem;
}
&:not(:last-child) {
margin-bottom: 0.5rem;
}
}
.ffz--corner-flag {
position: absolute;
top: 0;
right: 0;
border-color: transparent;
border-style: solid;
border-width: 0 3em 3em 0;
z-index: 100;
figure {
position: absolute;
top: 0;
right: -2.75em;
}
}
.ffz--corner-flag__warn {
border-right-color: #f33;
color: #fff;
.tw-root--theme-dark & {
border-right-color: #900;
}
}
.ffz--fields {
display: flex;
margin-top: -.5rem;
margin-left: -.5rem;
flex-flow: row wrap;
.ffz--field {
margin-top: 0.5rem;
margin-left: 0.5rem;
width: 100%;
}
.ffz--field-inline {
flex: 1;
width: unset;
min-width: 150px;
}
}
.ffz--twitter-badge {
height: 1.2rem;
width: 1.2rem;
background: url('//cdn.frankerfacez.com/static/twitter_sprites.png');
display: inline-block;
margin: 2px 0 -1px 0.5rem;
&.ffz--twitter-badge__verified {
background-position: 0 -15px;
}
&.ffz--twitter-badge__translator {
background-position: -12px -15px;
}
&.ffz--twitter-badge__protected {
background-position: -24px -15px;
width: 1.4rem;
}
}
.ffz--rich-gallery {
position: relative;
text-align: center;
max-height: 350px;
display: grid;
grid-column: 1/2;
grid-template-columns: 1fr 1fr;
column-gap: 4px;
border-radius: 4px;
overflow: hidden;
&[data-items="1"] {
display: flex;
justify-content: center;
.ffz--gallery-column {
width: 100%;
}
}
.ffz--gallery-column {
display: flex;
flex-direction: column;
overflow: hidden;
margin-top: -4px;
max-height: calc(100% + 4px);
&:only-child {
grid-column-start: 1;
grid-column-end: 3;
img, video {
object-fit:contain;
}
.tw-aspect { img, video { object-fit: cover; } }
}
> * {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
margin-top: 4px;
}
&[data-items="2"] > * {
min-height: calc(50% - 2px);
}
&[data-items="1"] > * {
min-height: 100%;
}
img, video {
height: 100%;
width: 100%;
object-fit: cover;
}
}
}
.ffz-badge {
display: inline-block;

View file

@ -65,6 +65,9 @@
.ffz-i-comp-off:before { content: '\e83f'; } /* '' */
.ffz-i-viewers:before { content: '\e840'; } /* '' */
.ffz-i-chat:before { content: '\e841'; } /* '' */
.ffz-i-location:before { content: '\e842'; } /* '' */
.ffz-i-link:before { content: '\e843'; } /* '' */
.ffz-i-volume-off:before { content: '\e845'; } /* '' */
.ffz-i-move:before { content: '\f047'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */

File diff suppressed because one or more lines are too long

View file

@ -65,6 +65,9 @@
.ffz-i-comp-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe83f;&nbsp;'); }
.ffz-i-viewers { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe840;&nbsp;'); }
.ffz-i-chat { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe841;&nbsp;'); }
.ffz-i-location { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe842;&nbsp;'); }
.ffz-i-link { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe843;&nbsp;'); }
.ffz-i-volume-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe845;&nbsp;'); }
.ffz-i-move { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf047;&nbsp;'); }
.ffz-i-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }
.ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf099;&nbsp;'); }

View file

@ -76,6 +76,9 @@
.ffz-i-comp-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe83f;&nbsp;'); }
.ffz-i-viewers { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe840;&nbsp;'); }
.ffz-i-chat { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe841;&nbsp;'); }
.ffz-i-location { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe842;&nbsp;'); }
.ffz-i-link { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe843;&nbsp;'); }
.ffz-i-volume-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xe845;&nbsp;'); }
.ffz-i-move { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf047;&nbsp;'); }
.ffz-i-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf08e;&nbsp;'); }
.ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf099;&nbsp;'); }

View file

@ -1,11 +1,11 @@
@font-face {
font-family: 'ffz-fontello';
src: url('../font/ffz-fontello.eot?13031397');
src: url('../font/ffz-fontello.eot?13031397#iefix') format('embedded-opentype'),
url('../font/ffz-fontello.woff2?13031397') format('woff2'),
url('../font/ffz-fontello.woff?13031397') format('woff'),
url('../font/ffz-fontello.ttf?13031397') format('truetype'),
url('../font/ffz-fontello.svg?13031397#ffz-fontello') format('svg');
src: url('../font/ffz-fontello.eot?81632949');
src: url('../font/ffz-fontello.eot?81632949#iefix') format('embedded-opentype'),
url('../font/ffz-fontello.woff2?81632949') format('woff2'),
url('../font/ffz-fontello.woff?81632949') format('woff'),
url('../font/ffz-fontello.ttf?81632949') format('truetype'),
url('../font/ffz-fontello.svg?81632949#ffz-fontello') format('svg');
font-weight: normal;
font-style: normal;
}
@ -15,7 +15,7 @@
@media screen and (-webkit-min-device-pixel-ratio:0) {
@font-face {
font-family: 'ffz-fontello';
src: url('../font/ffz-fontello.svg?13031397#ffz-fontello') format('svg');
src: url('../font/ffz-fontello.svg?81632949#ffz-fontello') format('svg');
}
}
*/
@ -121,6 +121,9 @@
.ffz-i-comp-off:before { content: '\e83f'; } /* '' */
.ffz-i-viewers:before { content: '\e840'; } /* '' */
.ffz-i-chat:before { content: '\e841'; } /* '' */
.ffz-i-location:before { content: '\e842'; } /* '' */
.ffz-i-link:before { content: '\e843'; } /* '' */
.ffz-i-volume-off:before { content: '\e845'; } /* '' */
.ffz-i-move:before { content: '\f047'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */

View file

@ -181,299 +181,24 @@ body {
text-align: left;
position: relative;
padding: 8px;
/*.stats:after, .heading:after {
content: '';
display: table;
clear: both;
}
.tweet-heading {
&:before {
content: '';
position: absolute;
top: 24px;
right: 24px;
width: 20px;
height: 16px;
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png") -38px -15px;
}
.avatar {
border-radius: 50%;
}
}
.heading {
.title {
font-weight: bold;
display: block;
text-align: justify;
}
}
.display-name {
padding-top: 3px;
font-size: 14px;
display: block;
&.big-name {
font-size: 20px;
padding-top: 18px;
}
}
.quoted {
.display-name {
padding: 0 5px 0 0;
display: inline;
font-size: 12px;
}
}
.avatar {
float: left;
margin-right: 8px;
max-height: 48px;
max-width: 100px;
}*/
line-height: 1.2em;
}
.ffz--chat-card,
.ffz-rich-tip {
.body { line-height: 1.5em }
.ffz__tooltip {
--color-border-base: var(--color-text-tooltip);
.tweet-heading:before {
content: '';
position: absolute;
top: 24px;
right: 24px;
width: 20px;
height: 16px;
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png") -38px -15px;
&:not([data-shift="true"]) .ffz--shift-show,
&[data-shift="true"] .ffz--shift-hide { display: none !important; }
.tw-c-text-base {
color: var(--color-text-tooltip) !important;
}
.stats:after, .heading:after {
content: '';
display: table;
clear: both;
.tw-c-text-alt {
color: var(--color-text-tooltip-alt) !important;
}
.avatar {
float: left;
margin-right: 8px;
max-height: 48px;
max-width: 100px;
}
.tweet-heading .avatar {
border-radius: 50%;
}
.heading .title {
font-weight: bold;
display: block;
text-align: justify;
}
.display-name {
padding-top: 3px;
font-size: 14px;
display: block;
&.big-name {
font-size: 20px;
padding-top: 18px;
}
}
.quoted .display-name {
padding: 0 5px 0 0;
display: inline;
font-size: 12px;
}
.twitch-heading {
.tip-badge.verified {
height: 12px;
width: 12px;
margin: 2px 0 -1px 5px;
background: url("https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/1");
background-size: contain;
display: inline-block;
}
.big-name .tip-badge.verified {
height: 18px;
width: 18px;
margin: 1px 0 0 10px;
}
}
.tweet-heading .tip-badge {
height: 12px;
width: 12px;
margin: 2px 0 -1px 5px;
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png");
display: inline-block;
}
.tip-badge {
&.verified {
background-position: 0 -15px;
}
&.translator {
background-position: -12px -15px;
}
&.protected {
background-position: -24px -15px;
width: 14px;
}
}
.emoji {
height: 1em;
vertical-align: middle;
}
.quoted > div:not(:first-child), > div:not(:first-child) {
margin-top: 8px;
}
.quote-heading + .body {
margin-top: 0 !important;
}
.replying {
+ .body {
margin-top: 0 !important;
}
opacity: 0.6;
font-size: 10px;
}
.subtitle, .quoted .body, .stats, .username {
opacity: 0.6;
}
.stats {
display: flex;
.wide-stat {
flex-grow: 1;
}
}
time {
flex-grow: 1;
}
.tweet-stats .stat:before {
.tw-root--theme-dark & {
filter: invert(100%);
}
content: '';
display: inline-block;
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png");
margin: 0 5px 0 10px;
}
.stat {
&.likes:before {
width: 17px;
height: 14px;
margin-bottom: -3px;
}
&.retweets:before {
width: 20px;
height: 12px;
background-position: -34px 0;
margin-bottom: -2px;
}
}
.media {
position: relative;
text-align: center;
&[data-count]:not([data-count="1"]) {
display: flex;
margin: 8px -5px -5px 0;
flex-flow: column wrap;
height: 329px;
}
.duration {
position: absolute;
bottom: 0;
right: 0;
margin: 4px;
background: #000;
color: #fff;
opacity: .8;
padding: 2px 4px;
border-radius: 2px;
z-index: 10;
font-weight: 500;
}
}
.sixteen-nine {
width: 100%;
overflow: hidden;
margin: 0;
padding-top: 56.25%;
position: relative;
img {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
transform: translate(-50%, -50%);
}
}
.media {
video {
width: 100%;
max-height: 324px;
}
&[data-count="4"] {
flex-flow: row wrap;
}
&[data-count="2"] {
height: 164.5px;
}
img {
max-height: 324px;
}
}
.quoted .media {
&[data-count]:not([data-count="1"]), &[data-count="2"] {
height: 150px;
}
video, img {
max-height: 150px;
}
}
.media {
span {
background: no-repeat center center;
background-size: cover;
}
&[data-count="2"] span, &[data-count="3"] span, &[data-count="4"] span {
width: calc(50% - 5px);
height: calc(50% - 5px);
margin: 0 5px 5px 0;
}
&[data-count="2"] span, &[data-count="3"] span:first-of-type {
height: 100%;
}
}
.profile-stats {
display: flex;
flex-flow: row;
flex-wrap: wrap;
div {
flex-grow: 1;
font-size: 16px;
margin-right: 10px;
}
span {
display: block;
font-size: 12px;
opacity: 0.7;
}
}
.quoted {
border: 1px solid #474747;
border-radius: 5px;
padding: 8px;
}
.media[data-type="video"]:after {
position: absolute;
content: '';
width: 64px;
height: 64px;
top: calc(50% - 32px);
left: calc(50% - 32px);
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='%23FFF' d='M15 10.001c0 .299-.305.514-.305.514l-8.561 5.303C5.51 16.227 5 15.924 5 15.149V4.852c0-.777.51-1.078 1.135-.67l8.561 5.305c-.001 0 .304.215.304.514z'/%3E%3C/svg%3E");
.tw-c-text-alt-2 {
color: var(--color-text-tooltip-alt-2) !important;
}
}