1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-08-06 14:20:56 +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 and it serves up local development copies of files, falling back to the CDN
when a local copy of a file isn't present. 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 To make FrankerFaceZ load from your local development server, you must set
the local storage variable `ffzDebugMode` to true. Just run the following the local storage variable `ffzDebugMode` to true. Just run the following
in your console on Twitch: `localStorage.ffzDebugMode = true;` in your console on Twitch: `localStorage.ffzDebugMode = true;`

View file

@ -735,6 +735,24 @@
"css": "chat", "css": "chat",
"code": 59457, "code": 59457,
"src": "fontawesome" "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", "name": "frankerfacez",
"author": "Dan Salvato LLC", "author": "Dan Salvato LLC",
"version": "4.20.21", "version": "4.20.22",
"description": "FrankerFaceZ is a Twitch enhancement suite.", "description": "FrankerFaceZ is a Twitch enhancement suite.",
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "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="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="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" /> <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(); this._loader = this.loadAddonData();
} }
async onEnable() { onEnable() {
this.settings.addUI('add-ons', { 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}', 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', component: 'addon-list',

View file

@ -163,7 +163,7 @@ export class TranslationManager extends Module {
}, },
ui: { ui: {
path: 'Appearance > Localization >> General', path: 'Appearance > Localization >> General @{"sort":-100}',
title: 'Language', 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.`, 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 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) { getLocaleOptions(val) {
@ -244,6 +320,9 @@ export class TranslationManager extends Module {
this._ = new NewTransCore({ //TranslationCore({ this._ = new NewTransCore({ //TranslationCore({
warn: (...args) => this.log.warn(...args), 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 ) { if ( window.BroadcastChannel ) {

View file

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

View file

@ -1,27 +1,39 @@
<script> <script>
import {has, timeout} from 'utilities/object'; 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'; const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
let tokenizer;
export default { export default {
props: ['data', 'url', 'events'], props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia'],
data() { data() {
return { return {
has_tokenizer: false,
loaded: false, loaded: false,
error: null, error: null,
title: this.t('card.loading', 'Loading...'), accent: null,
title_tokens: null, short: null,
desc_1: null, full: null,
desc_1_tokens: null, unsafe: false,
desc_2: null, urls: null,
desc_2_tokens: null, allow_media: false,
image: null, allow_unsafe: false
image_title: null, }
image_square: false, },
accent: null
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() { data() {
this.reset(); this.reset();
this.load(); this.load();
},
events() {
this.listen();
} }
}, },
created() { created() {
if ( this.events ) { this.loadTokenizer();
this._events = this.events;
this._events.on('chat:update-link-resolver', this.checkRefresh, this);
}
this.listen();
this.load(); this.load();
}, },
beforeDestroy() { beforeDestroy() {
if ( this._events ) { this.unlisten();
this._events.off('chat:update-link-resolver', this.checkRefresh, this);
this._events = null;
}
}, },
methods: { 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) { checkRefresh(url) {
if ( ! url || (url && url === this.url) ) { if ( ! url || (url && url === this.url) ) {
this.reset(); this.reset();
@ -59,16 +95,13 @@ export default {
reset() { reset() {
this.loaded = false; this.loaded = false;
this.error = null; 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.accent = null;
this.short = null;
this.full = null;
this.unsafe = false;
this.urls = null;
this.allow_media = false;
this.allow_unsafe = false;
}, },
async load() { async load() {
@ -83,152 +116,130 @@ export default {
data = await data; 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) { } catch(err) {
data = { data = {
error: true, error: String(err)
title: this.t('card.error', 'An error occured.'), };
desc_1: 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.loaded = true;
this.error = data.error; 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.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) { renderCard(h) {
if ( this.data.renderBody ) if ( this.data.renderBody ) {
return [this.data.renderBody(h)]; const out = this.data.renderBody(h);
return Array.isArray(out) ? out : [out];
}
return [ return [
this.renderImage(h), this.renderUnsafe(h),
this.renderDescription(h) //this.forceFull ? null : this.renderImage(h),
]; this.renderBody(h)
]
}, },
renderTokens(tokens, h) { renderUnsafe(h) {
let out = []; if ( ! this.unsafe )
if ( ! Array.isArray(tokens) ) return null;
tokens = [tokens];
for(const token of tokens) { const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', ');
if ( Array.isArray(token) )
out = out.concat(this.renderTokens(token, h));
else if ( typeof token !== 'object' ) return h('div', {
out.push(token); 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()
}
)
}
}, [
h('figure', {
class: 'ffz-i-attention'
})
]);
},
else if ( token.type === 't') { renderBody(h) {
const content = {}; if ( this.has_tokenizer && this.loaded && (this.forceFull ? this.full : this.short) ) {
if ( token.content ) return h('div', {
for(const [key,val] of Object.entries(token.content)) class: 'ffz--card-rich tw-full-width tw-overflow-hidden tw-flex tw-flex-column'
content[key] = this.renderTokens(val, h); }, tokenizer.renderTokens(this.forceFull ? this.full : this.short, h, {
vue: true,
tList: (...args) => this.tList(...args),
i18n: this.getI18n(),
out = out.concat(this.tList(token.key, token.phrase, content)); allow_media: this.forceMedia ?? this.allow_media,
allow_unsafe: this.forceUnsafe ?? this.allow_unsafe
}));
} else
return this.renderBasic(h);
},
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 { } else {
const tag = token.tag || 'span'; description = this.t('card.loading', 'Loading...');
if ( ! ALLOWED_TAGS.includes(tag) ) {
console.log('Skipping disallowed tag', tag);
continue;
} }
const attrs = {}; if ( ! title && ! description )
if ( token.attrs ) { description = this.t('card.empty', 'No data was returned.');
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 = h(tag, { description = description ? description.split(/\n+/).slice(0,2).map(desc =>
class: token.class, h('div', {
attrs class: 'tw-c-text-alt-2 tw-ellipsis tw-mg-x-05',
}, this.renderTokens(token.content, h)); attrs:{title: desc}
}, [desc])
) : [];
out.push(el); 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'
return out; }, [
}, title ? h('div', {class: 'chat-card__title tw-ellipsis tw-mg-x-05'}, [
h('span', {class: 'tw-strong', attrs:{title}}, [title])
renderDescription(h) { ]) : null,
let title = this.title, ...description
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;
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)])
])]);
},
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
}
})])]);
}
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])
} }
}, },
@ -237,8 +248,9 @@ export default {
class: 'tw-flex tw-flex-nowrap tw-pd-05' class: 'tw-flex tw-flex-nowrap tw-pd-05'
}, this.renderCard(h)); }, this.renderCard(h));
if ( this.url ) { const tooltip = this.has_full && ! this.forceFull;
const tooltip = this.data.card_tooltip;
if ( this.url )
content = h('a', { content = h('a', {
class: [ class: [
tooltip && 'ffz-tooltip', tooltip && 'ffz-tooltip',
@ -255,10 +267,21 @@ export default {
href: this.url href: this.url
} }
}, [content]); }, [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', { 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: { style: {
'--ffz-color-accent': this.accent '--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' class: 'tw-border-radius-medium tw-c-background-base tw-flex tw-full-width'
}, [content])]); }, [content])]);
} }
} }
</script> </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]'; 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 = /[ .,!]/; const EMOTE_CHARS = /[ .,!]/;
export default class Chat extends Module { export default class Chat extends Module {
@ -1411,7 +1412,7 @@ export default class Chat extends Module {
const tt = tokenizer.tooltip; const tt = tokenizer.tooltip;
const tk = this.tooltips.types[type] = tt.bind(this); 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]; 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]]]; info = this._link_info[url] = [false, null, [[resolve, reject]]];
const handle = (success, data) => { const handle = (success, data) => {
data = this.fixLinkInfo(data);
const callbacks = ! info[0] && info[2]; const callbacks = ! info[0] && info[2];
info[0] = true; info[0] = true;
info[1] = Date.now() + 120000; 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_CLIP from './clip_info.gql';
import GET_VIDEO from './video_info.gql'; import GET_VIDEO from './video_info.gql';
import {truncate} from 'utilities/object';
// ============================================================================ // ============================================================================
// General Links // General Links
@ -47,31 +49,20 @@ export const Links = {
} catch(err) { } catch(err) {
return { return {
url: token.url, url: token.url,
title: this.i18n.t('card.error', 'An error occurred.'), error: String(err)
desc_1: String(err)
} }
} }
if ( ! data ) if ( ! data )
return { return {
url: token.url, url: token.url
title: this.i18n.t('card.error', 'An error occurred.'),
desc_1: this.i18n.t('card.empty', 'No data was returned.')
} }
return { return {
url: token.url, ...data,
accent: data.accent, allow_media: this.context.get('tooltip.link-images'),
image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null, allow_unsafe: this.context.get('tooltip.link-nsfw-images')
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
}
} }
} }
} }
@ -108,66 +99,82 @@ export const Users = {
if ( ! user || ! user.id ) if ( ! user || ! user.id )
return null; 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; let subtitle
if ( user.stream?.id && game ) { if ( stream_id && game )
desc_1_tokens = this.i18n.tList('cards.user.streaming', 'streaming {game}', { subtitle = {
game: {class: 'tw-semibold', content: [game]} type: 'i18n',
}); key: 'cards.user.streaming', phrase: 'streaming {game}', content: {
desc_1 = this.i18n.t('cards.user.streaming', 'streaming {game}', { game: {type: 'style', weight: 'semibold', content: game}
game
});
} }
};
const bits_tokens = this.i18n.tList('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', { const extra = truncate(user.description);
views: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.profileViewCount || 0)]}, const title = [user.displayName];
followers: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.followers?.totalCount || 0)]}
}), if ( user.displayName.trim().toLowerCase() !== user.login )
bits = this.i18n.t('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', { title.push({
views: user.profileViewCount || 0, type: 'style', color: 'alt-2',
followers: user.followers?.totalCount || 0 content: [' (', user.login, ')']
}); });
if ( desc_1 ) { if ( user.roles?.isPartner )
desc_2 = bits; title.push({
desc_2_tokens = bits_tokens; type: 'style', color: 'link',
} else { content: {type: 'icon', name: 'verified'}
desc_1 = bits; });
desc_1_tokens = bits_tokens;
}
const has_i18n = user.displayName.trim().toLowerCase() !== user.login; /*const full = [{
let title = user.displayName, title_tokens = null; type: 'header',
if ( has_i18n ) { image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
title = `${user.displayName} (${user.login})`; title,
title_tokens = [ subtitle,
user.displayName, extra: stream_id ? extra : null
{class: 'chat-author__intl-login', content: ` (${user.login})`} }];
];
}
if ( user.roles?.isPartner ) { if ( stream_id ) {
if ( ! title_tokens ) full.push({type: 'box', 'mg-y': 'small', lines: 1, content: user.broadcastSettings.title});
title_tokens = [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: [ full.push({
{tag: 'div', content: title_tokens}, type: 'fieldset',
{tag: 'figure', class: 'tw-mg-l-05 ffz-i-verified tw-c-text-link', content: []} 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 { return {
url: token.url, url: token.url,
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null, accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
image: user.profileImageURL, short: {
image_square: true, type: 'header',
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
title, title,
title_tokens, subtitle,
desc_1, extra
desc_1_tokens, }
desc_2,
desc_2_tokens
} }
} }
} }
@ -214,53 +221,51 @@ export const Clips = {
return null; return null;
const clip = result.data.clip, const clip = result.data.clip,
user = clip.broadcaster.displayName,
game = clip.game, game = clip.game,
game_name = game && game.name,
game_display = game && game.displayName; game_display = game && game.displayName;
let desc_1, desc_1_tokens; const user = {
if ( game_name === 'creative' ) { type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', { content: {
user: {class: 'tw-semibold', content: user} type: 'style', weight: 'semibold', color: 'alt-2',
}); content: clip.broadcaster.displayName
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
user
});
} 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}', {
user,
game: game_display
});
} 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 ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown'); const subtitle = game_display ? {
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
user,
game: {type: 'style', weight: 'semibold', content: game_display}
}
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
const curator = clip.curator ? {
type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`,
content: {
type: 'style', color: 'alt-2',
content: clip.curator.displayName
}
} : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'};
const extra = {
type: 'i18n', key: 'clip.desc.2',
phrase: 'Clipped by {curator} — {views,number} View{views,en_plural}',
content: {
curator,
views: clip.viewCount
}
};
return { return {
url: token.url, url: token.url,
image: clip.thumbnailURL,
short: {
type: 'header',
image: {type: 'image', url: clip.thumbnailURL, sfw: false, aspect: 16/9},
title: clip.title, title: clip.title,
desc_1, subtitle,
desc_1_tokens, extra
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)}
})
} }
} }
} }
@ -296,49 +301,43 @@ export const Videos = {
return null; return null;
const video = result.data.video, const video = result.data.video,
user = video.owner.displayName,
game = video.game, game = video.game,
game_name = game && game.name,
game_display = game && game.displayName; game_display = game && game.displayName;
let desc_1, desc_1_tokens; const user = {
if ( game_name === 'creative' ) { type: 'link', url: `https://www.twitch.tv/${video.owner.login}`,
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', { content: {
user: {class: 'tw-semibold', content: user} type: 'style', weight: 'semibold', color: 'alt-2',
}); content: video.owner.displayName
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
user
});
} 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}', {
user,
game: game_display
});
} 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 { const subtitle = game_display ? {
url: token.url, type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
image: video.previewThumbnailURL, user,
title: video.title, game: {type: 'style', weight: 'semibold', content: game_display}
desc_1, }
desc_1_tokens, } : {type: 'i18n', key: 'video.desc.1', phrase: 'Video of {user}', content: {user}};
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, length: video.lengthSeconds,
views: video.viewCount, views: video.viewCount,
date: video.publishedAt 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 // Links
// ============================================================================ // ============================================================================
const TOOLTIP_VERSION = 4; function datasetBool(value) {
return value == null ? null : value === 'true';
}
const TOOLTIP_VERSION = 5;
export const Links = { export const Links = {
type: 'link', type: 'link',
@ -47,60 +51,94 @@ export const Links = {
if ( target.dataset.isMail === 'true' ) if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})]; 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 ) if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
return ''; 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. let content;
if ( tip.element ) {
tip.element.classList.add('ffz-rich-tip');
tip.element.classList.add('tw-align-left');
}
if ( data.urls && data.urls.length > 1 ) if ( data.full ) {
content += (content.length ? '<hr>' : '') + content = rich_tokens.renderTokens(data.full, createElement, ctx);
sanitize(this.i18n.t(
'tooltip.link-destination',
'Destination: {url}',
{url: data.urls[data.urls.length-1][1]}
));
} else {
if ( data.short ) {
content = rich_tokens.renderTokens(data.short, createElement, ctx);
} else
content = this.i18n.t('card.empty', 'No data was returned.');
}
if ( ! data.urls )
return content;
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 ) { if ( data.unsafe ) {
const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', '); const reasons = Array.from(new Set(data.urls.map(url => url.flags).flat())).join(', ');
content = this.i18n.t( url_notice = (<div class="ffz-i-attention">
{this.i18n.tList(
'tooltip.link-unsafe', 'tooltip.link-unsafe',
"Caution: This URL is on Google's Safe Browsing List for: {reasons}", "Caution: This URL is on Google's Safe Browsing List for: {reasons}",
{reasons: sanitize(reasons.toLowerCase())} {reasons: reasons.toLowerCase()}
) + (content.length ? `<hr>${content}` : ''); )}
} </div>);
} else if ( data.urls.length > 1 )
const show_image = this.context.get('tooltip.link-images') && (data.image_safe || this.context.get('tooltip.link-nsfw-images')); url_notice = this.i18n.t('tooltip.link-destination', 'Destination: {url}', {
url: data.urls[data.urls.length-1].url
if ( show_image ) {
if ( data.image && ! data.image_iframe )
content = `<img class="preview-image" src="${sanitize(data.image)}">${content}`
setTimeout(() => {
if ( tip.element ) {
for(const el of tip.element.querySelectorAll('img'))
el.addEventListener('load', tip.update);
for(const el of tip.element.querySelectorAll('video'))
el.addEventListener('loadedmetadata', tip.update);
}
}); });
} else if ( content.length ) content = (<div>
content = content.replace(/<!--MS-->.*<!--ME-->/g, ''); <div class="ffz--shift-hide">
{content}
if ( data.tooltip_class ) {url_notice ? <div class="tw-mg-t-05 tw-border-t tw-pd-t-05 tw-align-center">
tip.element.classList.add(data.tooltip_class); {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; return content;
}).catch(error => }).catch(error => {
sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error})) console.error(error);
); return sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error}))
});
}, },
process(tokens) { process(tokens) {

View file

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

View file

@ -104,7 +104,8 @@
v-if="addon.website" v-if="addon.website"
:href="addon.website" :href="addon.website"
:title="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" target="_blank"
rel="noopener" rel="noopener"
> >

View file

@ -53,7 +53,8 @@
:href="commit.author.html_url" :href="commit.author.html_url"
target="_blank" target="_blank"
rel="noopener noreferrer" 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 <figure
v-if="commit.author.avatar_url" v-if="commit.author.avatar_url"
@ -76,7 +77,7 @@
v-if="commit.hash" v-if="commit.hash"
class="tw-font-size-8 tw-c-text-alt-2" 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> </div>
<time <time
v-if="commit.date" v-if="commit.date"

View file

@ -26,15 +26,51 @@
</select> </select>
<input <input
ref="text" ref="text"
v-model="raw_url"
:disabled="! isCustomURL" :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" 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>
</div> </div>
<div class="tw-flex tw-mg-b-1"> <div class="tw-flex tw-mg-b-1">
<div class="tw-flex-grow-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 <button
class="tw-mg-l-1 tw-button tw-button--text" class="tw-mg-l-1 tw-button tw-button--text"
@click="refresh" @click="refresh"
@ -44,6 +80,47 @@
</span> </span>
</button> </button>
</div> </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"> <div class="tw-flex tw-mg-b-1 tw-full-width">
<label> <label>
{{ t('debug.link-provider.embed', 'Rich Embed') }} {{ t('debug.link-provider.embed', 'Rich Embed') }}
@ -53,28 +130,26 @@
v-if="rich_data" v-if="rich_data"
:data="rich_data" :data="rich_data"
:url="url" :url="url"
:force-media="force_media"
:force-unsafe="force_unsafe"
:events="events" :events="events"
/> />
</div> </div>
</div> </div>
<div class="tw-flex tw-mg-b-1 tw-full-width"> <div class="tw-flex tw-mg-b-1 tw-full-width">
<label> <label>
{{ t('debug.link-provider.link', 'Chat Link') }} {{ t('debug.link-provider.full-embed', 'Full Embed') }}
</label> </label>
<div class="tw-full-width tw-overflow-hidden"> <div class="tw-full-width tw-overflow-hidden">
<a <chat-rich
v-if="url" v-if="rich_data"
:href="url" :data="rich_data"
:data-url="url" :url="url"
class="ffz-tooltip" :force-full="true"
data-tooltip-type="link" :force-media="force_media"
data-force-tooltip="true" :force-unsafe="force_unsafe"
data-is-mail="false" :events="events"
rel="noopener noreferrer" />
target="_blank"
>
{{ url }}
</a>
</div> </div>
</div> </div>
<div class="tw-flex tw-mg-b-1 tw-full-width"> <div class="tw-flex tw-mg-b-1 tw-full-width">
@ -99,12 +174,19 @@ import { debounce } from '../../../utilities/object';
const STOCK_URLS = [ const STOCK_URLS = [
'https://www.twitch.tv/sirstendec', '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://discord.gg/UrAkGhT',
'https://www.youtube.com/watch?v=CAL4WMpBNs0', 'https://www.youtube.com/watch?v=CAL4WMpBNs0',
'https://xkcd.com/221/', 'https://xkcd.com/221/',
'https://github.com/FrankerFaceZ/FrankerFaceZ', 'https://github.com/FrankerFaceZ/FrankerFaceZ',
'https://twitter.com/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 { export default {
@ -118,13 +200,26 @@ export default {
props: ['item', 'context'], props: ['item', 'context'],
data() { 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 { return {
stock_urls: deep_copy(STOCK_URLS), 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, rich_data: null,
isCustomURL: false,
raw_loading: false, raw_loading: false,
raw_data: null, 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: { events: {
on: (...args) => this.item.getChat().on(...args), on: (...args) => this.item.getChat().on(...args),
off: (...args) => this.item.getChat().off(...args) off: (...args) => this.item.getChat().off(...args)
@ -143,24 +238,75 @@ export default {
}, },
watch: { watch: {
raw_url() {
if ( ! this.isCustomURL )
this.$refs.text.value = this.raw_url;
},
url() { url() {
this.rebuildData(); 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() { rich_data() {
this.refreshRaw(); 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() { created() {
this.rebuildData = debounce(this.rebuildData, 250); this.rebuildData = debounce(this.rebuildData, 250);
this.refreshRaw = debounce(this.refreshRaw, 250); this.refreshRaw = debounce(this.refreshRaw, 250);
this.onTextChange = debounce(this.onTextChange, 500);
}, },
mounted() { mounted() {
this.chat = this.item.getChat(); this.chat = this.item.getChat();
this.chat.on('chat:update-link-resolver', this.checkRefreshRaw, this); this.chat.on('chat:update-link-resolver', this.checkRefreshRaw, this);
this.rebuildData(); 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() { beforeDestroy() {
@ -169,6 +315,21 @@ export default {
}, },
methods: { 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) { checkRefreshRaw(url) {
if ( ! url || (url && url === this.url) ) if ( ! url || (url && url === this.url) )
this.refreshRaw(); this.refreshRaw();
@ -220,8 +381,26 @@ export default {
this.isCustomURL = true; this.isCustomURL = true;
}, },
updateText() {
if ( this.isCustomURL )
this.raw_url = this.$refs.text.value;
},
onTextChange() { 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.markSeen(item);
this.currentItem = 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; let current = item;
while(current = current.parent) // eslint-disable-line no-cond-assign while(current = current.parent) // eslint-disable-line no-cond-assign
current.expanded = true; current.expanded = true;

View file

@ -36,6 +36,7 @@ export default class MainMenu extends Module {
//this.should_enable = true; //this.should_enable = true;
this.exclusive = false;
this.new_seen = false; this.new_seen = false;
this._settings_tree = null; 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); 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) { runFix(amount) {
this.settings.updateContext({ this.settings.updateContext({
force_chat_fix: (this.settings.get('context.force_chat_fix') || 0) + amount 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], const root = this._vue.$children[0],
item = root.currentItem, item = root.currentItem,
key = item && item.full_key, key = item && item.full_key,
wants_old = ! root.restoredItem,
state = window.history.state,
tree = this.getSettingsTree(); tree = this.getSettingsTree();
root.nav = tree; root.nav = tree;
root.nav_keys = tree.keys; root.nav_keys = tree.keys;
root.currentItem = tree.keys[key] || (this._wanted_page && tree.keys[this._wanted_page]) || (this.has_update ?
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.changelog'] :
tree.keys['home']); tree.keys['home'];
root.currentItem = current;
root.restoredItem = restored;
this._wanted_page = null; this._wanted_page = null;
} }
@ -813,7 +847,26 @@ export default class MainMenu extends Module {
getData() { getData() {
const settings = this.getSettingsTree(), const settings = this.getSettingsTree(),
context = this.getContext(), 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._wanted_page = null;
this.markSeen(current); this.markSeen(current);
@ -834,6 +887,7 @@ export default class MainMenu extends Module {
nav: settings, nav: settings,
currentItem: current, currentItem: current,
restoredItem: true, // restored, -- Look into making this smoother later.
nav_keys: settings.keys, nav_keys: settings.keys,
has_unseen, has_unseen,

View file

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

View file

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

View file

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

View file

@ -56,6 +56,7 @@ export default class ChatLine extends Module {
async onEnable() { async onEnable() {
this.on('chat.overrides:changed', id => this.updateLinesByUser(id), this); this.on('chat.overrides:changed', id => this.updateLinesByUser(id), this);
this.on('chat:update-lines', this.updateLines, 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.emoji.style', this.updateLines, this);
this.chat.context.on('changed:chat.bits.stack', 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 Module from 'utilities/module';
import {timeout, has} from 'utilities/object'; 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'; 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) { constructor(...args) {
super(...args); super(...args);
this.inject('chat');
this.inject('i18n'); this.inject('i18n');
this.inject('site.web_munch'); this.inject('site.web_munch');
this.RichContent = null; 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() { async onEnable() {
@ -34,8 +44,12 @@ export default class RichContent extends Module {
this.state = { this.state = {
loaded: false, 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() { async load() {
@ -49,16 +63,26 @@ export default class RichContent extends Module {
data = await data; data = await data;
} }
console.log('data', data);
if ( ! data ) if ( ! data )
data = { data = {
error: true, error: {type: 'i18n', key: 'card.empty', phrase: 'No data was returned.'}
title: t.i18n.t('card.error', 'An error occurred.'),
desc_1: t.i18n.t('card.empty', '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({ this.setState(Object.assign({
loaded: true, loaded: true,
url: this.props.url url: this.props.url,
}, data)); }, data));
} catch(err) { } catch(err) {
@ -66,11 +90,15 @@ export default class RichContent extends Module {
t.log.capture(err); t.log.capture(err);
this.setState({ this.setState({
has_tokenizer: t.has_tokenizer,
loaded: true, loaded: true,
error: true,
url: this.props.url, url: this.props.url,
title: t.i18n.t('card.error', 'An error occurred.'), short: {
desc_1: String(err) 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() { reload() {
this.setState({ this.setState({
loaded: false, loaded: false,
error: false error: false,
has_tokenizer: t.has_tokenizer
}, () => this.load()); }, () => this.load());
} }
@ -97,138 +126,83 @@ export default class RichContent extends Module {
t.off('chat:update-link-resolver', this.checkReload, this); 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() { renderCard() {
if ( this.props.renderBody ) if ( this.props.renderBody )
return this.props.renderBody(this.state, this, createElement); return this.props.renderBody(this.state, this, createElement);
if ( this.state.html ) return [
return <div dangerouslySetInnerHTML={{__html: this.state.html}} />; 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 [ return [
this.renderCardImage(), <div class="ffz--header-image" />,
this.renderCardDescription() (<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() { render() {
let content = <div class="tw-flex tw-flex-nowrap tw-pd-05">{this.renderCard()}</div>; 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 ) { if ( this.state.url ) {
const tooltip = this.props.card_tooltip;
content = (<a 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-tooltip-type="link"
data-url={this.state.url} data-url={this.state.url}
data-is-mail={false} data-is-mail={false}
@ -241,7 +215,7 @@ export default class RichContent extends Module {
} }
return (<div 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}} style={{'--ffz-color-accent': this.state.accent || null}}
> >
<div class="tw-border-radius-medium tw-c-background-base tw-flex tw-full-width"> <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; right: 0 !important;
} }
.channel-root__scroll-area--theatre-mode .channel-info-bar { .channel-root__scroll-area--theatre-mode {
.channel-info-content > div:first-child, .channel-info-bar {
left: calc(var(--ffz-chat-width) + 5rem) !important; left: calc(var(--ffz-chat-width) + 5rem) !important;
right: 25rem !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 ) if ( ! this.settings.get('player.theatre.auto-enter') || ! inst._ffz_mounted )
return; return;
if ( this.router.current_name === 'user-home' )
return;
if ( inst.props.channelHomeLive || inst.props.channelHomeCarousel || inst.props.theatreModeEnabled ) if ( inst.props.channelHomeLive || inst.props.channelHomeCarousel || inst.props.theatreModeEnabled )
return; return;

View file

@ -53,6 +53,28 @@ export default class ThemeEngine extends Module {
changed: () => this.updateCSS() 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', { this.settings.add('theme.dark', {
requires: ['theme.is-dark'], requires: ['theme.is-dark'],
default: false, 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()};`); 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 ) { if ( bits.length ) {
this.css_tweaks.set('colors', `body {${bits.join('\n')}}`); 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 { .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,
.ffz-stat.tw-button--text { .ffz-stat.tw-button--text {

View file

@ -66,14 +66,6 @@
border-right: .5rem solid var(--ffz-color-accent); 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 { .chat-card__title {
max-width: unset; max-width: unset;
} }

View file

@ -22,8 +22,10 @@ export default {
md.use(MILA, { md.use(MILA, {
attrs: { attrs: {
class: 'ffz-tooltip',
target: '_blank', 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 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 = { export const KEYS = {
Enter: 13, Enter: 13,
Shift: 16,
Control: 17,
Alt: 18,
Escape: 27, Escape: 27,
Space: 32, Space: 32,
PageUp: 33, PageUp: 33,
@ -38,7 +32,9 @@ export const KEYS = {
ArrowLeft: 37, ArrowLeft: 37,
ArrowUp: 38, ArrowUp: 38,
ArrowRight: 39, ArrowRight: 39,
ArrowDown: 40 ArrowDown: 40,
Meta: 91,
Context: 93
}; };

View file

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

View file

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

View file

@ -97,5 +97,8 @@ export default [
"viewers", "viewers",
"move", "move",
"chat-empty", "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 { export class SourcedSet {
constructor() { constructor() {
this._cache = []; 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.options = Object.assign({}, DefaultOptions, options);
this.live = this.options.live; this.live = this.options.live;
this.check_modifiers = this.options.check_modifiers;
this.parent = parent; this.parent = parent;
this.cls = cls; this.cls = cls;
if ( this.check_modifiers )
this.installModifiers();
if ( ! this.live ) { if ( ! this.live ) {
if ( typeof cls === 'string' ) if ( typeof cls === 'string' )
this.elements = parent.querySelectorAll(cls); this.elements = parent.querySelectorAll(cls);
@ -65,16 +69,18 @@ export class Tooltip {
this._accessor = `_ffz_tooltip$${last_id++}`; 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 ) { if ( this.options.manual ) {
// Do nothing~! // Do nothing~!
} else if ( this.live ) { } else if ( this.live ) {
this._onMouseOver = e => { this._onMouseOver = e => {
this.updateShift(e.shiftKey);
const target = e.target; 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); this._enter(target);
}
}; };
parent.addEventListener('mouseover', this._onMouseOver); parent.addEventListener('mouseover', this._onMouseOver);
@ -82,10 +88,12 @@ export class Tooltip {
} else { } else {
this._onMouseOver = e => { this._onMouseOver = e => {
this.updateShift(e.shiftKey);
const target = e.target; const target = e.target;
if ( this.elements.has(target) ) if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
this._enter(e.target); this._enter(e.target);
} }
}
if ( this.elements.size <= 5 ) if ( this.elements.size <= 5 )
for(const el of this.elements) { for(const el of this.elements) {
@ -102,6 +110,8 @@ export class Tooltip {
} }
destroy() { destroy() {
this.removeModifiers();
if ( this.options.manual ) { if ( this.options.manual ) {
// Do nothing~! // Do nothing~!
} else if ( this.live || this.elements.size > 5 ) { } 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() { cleanup() {
if ( this.options.manual ) if ( this.options.manual )
return; return;
@ -238,7 +285,8 @@ export class Tooltip {
inner = tip.element = createElement('div', opts.innerClass), inner = tip.element = createElement('div', opts.innerClass),
el = tip.outer = createElement('div', { el = tip.outer = createElement('div', {
className: opts.tooltipClass className: opts.tooltipClass,
'data-shift': this.shift_state
}, [inner, arrow]); }, [inner, arrow]);
arrow.setAttribute('x-arrow', true); arrow.setAttribute('x-arrow', true);
@ -259,6 +307,7 @@ export class Tooltip {
if ( ! opts.manual || (hover_events && (opts.onHover || opts.onLeave || opts.onMove)) ) { if ( ! opts.manual || (hover_events && (opts.onHover || opts.onLeave || opts.onMove)) ) {
if ( hover_events && opts.onMove ) if ( hover_events && opts.onMove )
el.addEventListener('mousemove', el._ffz_move_handler = event => { el.addEventListener('mousemove', el._ffz_move_handler = event => {
this.updateShift(event.shiftKey);
opts.onMove(target, tip, event); opts.onMove(target, tip, event);
}); });
@ -273,7 +322,7 @@ export class Tooltip {
/* no-op */ /* no-op */
} else if ( maybe_call(opts.interactive, null, target, tip) ) } else if ( maybe_call(opts.interactive, null, target, tip) )
this._enter(target); this._enter(target);
else else if ( target.dataset.forceOpen !== 'true' )
this._exit(target); this._exit(target);
}); });
@ -281,7 +330,7 @@ export class Tooltip {
if ( hover_events && opts.onLeave ) if ( hover_events && opts.onLeave )
opts.onLeave(target, tip, event); opts.onLeave(target, tip, event);
if ( ! opts.manual ) if ( ! opts.manual && target.dataset.forceOpen !== 'true' )
this._exit(target); this._exit(target);
}); });
} }

View file

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

View file

@ -26,6 +26,260 @@
vertical-align: middle; 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 { .ffz-badge {
display: inline-block; display: inline-block;

View file

@ -65,6 +65,9 @@
.ffz-i-comp-off:before { content: '\e83f'; } /* '' */ .ffz-i-comp-off:before { content: '\e83f'; } /* '' */
.ffz-i-viewers:before { content: '\e840'; } /* '' */ .ffz-i-viewers:before { content: '\e840'; } /* '' */
.ffz-i-chat:before { content: '\e841'; } /* '' */ .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-move:before { content: '\f047'; } /* '' */
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */ .ffz-i-link-ext:before { content: '\f08e'; } /* '' */
.ffz-i-twitter:before { content: '\f099'; } /* '' */ .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-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-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-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-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-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;'); } .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-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-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-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-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-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;'); } .ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = '&#xf099;&nbsp;'); }

View file

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

View file

@ -181,299 +181,24 @@ body {
text-align: left; text-align: left;
position: relative; position: relative;
padding: 8px; padding: 8px;
line-height: 1.2em;
/*.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;
}*/
} }
.ffz--chat-card, .ffz__tooltip {
.ffz-rich-tip { --color-border-base: var(--color-text-tooltip);
.body { line-height: 1.5em }
.tweet-heading:before { &:not([data-shift="true"]) .ffz--shift-show,
content: ''; &[data-shift="true"] .ffz--shift-hide { display: none !important; }
position: absolute;
top: 24px; .tw-c-text-base {
right: 24px; color: var(--color-text-tooltip) !important;
width: 20px;
height: 16px;
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png") -38px -15px;
} }
.stats:after, .heading:after { .tw-c-text-alt {
content: ''; color: var(--color-text-tooltip-alt) !important;
display: table;
clear: both;
} }
.avatar { .tw-c-text-alt-2 {
float: left; color: var(--color-text-tooltip-alt-2) !important;
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");
} }
} }