mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
4.20.22
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:
parent
eec65551fb
commit
6310a2ed49
49 changed files with 2432 additions and 884 deletions
|
@ -24,6 +24,10 @@ FrankerFaceZ comes with a local development server that listens on port 8000
|
|||
and it serves up local development copies of files, falling back to the CDN
|
||||
when a local copy of a file isn't present.
|
||||
|
||||
> **Note:** The local development server uses `webpack-dev-server` internally,
|
||||
> which self-signs a certificate for hosting content via HTTPS. You will need
|
||||
> to ensure your browser accepts a self-signed certificate for localhost.
|
||||
|
||||
To make FrankerFaceZ load from your local development server, you must set
|
||||
the local storage variable `ffzDebugMode` to true. Just run the following
|
||||
in your console on Twitch: `localStorage.ffzDebugMode = true;`
|
||||
|
|
|
@ -735,6 +735,24 @@
|
|||
"css": "chat",
|
||||
"code": 59457,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "0d08dbb1dd648a43bdea81b7e6c9e036",
|
||||
"css": "location",
|
||||
"code": 59458,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "0ddd3e8201ccc7d41f7b7c9d27eca6c1",
|
||||
"css": "link",
|
||||
"code": 59459,
|
||||
"src": "fontawesome"
|
||||
},
|
||||
{
|
||||
"uid": "8489d61496923b1159b01d8a0a7b2df0",
|
||||
"css": "volume-off",
|
||||
"code": 59461,
|
||||
"src": "elusive"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "frankerfacez",
|
||||
"author": "Dan Salvato LLC",
|
||||
"version": "4.20.21",
|
||||
"version": "4.20.22",
|
||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
|
|
Binary file not shown.
|
@ -138,6 +138,12 @@
|
|||
|
||||
<glyph glyph-name="chat" unicode="" 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="" 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="" 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="" 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="" 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="" 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 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -37,7 +37,7 @@ export default class AddonManager extends Module {
|
|||
this._loader = this.loadAddonData();
|
||||
}
|
||||
|
||||
async onEnable() {
|
||||
onEnable() {
|
||||
this.settings.addUI('add-ons', {
|
||||
path: 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch.", "profile_warning": false}',
|
||||
component: 'addon-list',
|
||||
|
|
81
src/i18n.js
81
src/i18n.js
|
@ -163,7 +163,7 @@ export class TranslationManager extends Module {
|
|||
},
|
||||
|
||||
ui: {
|
||||
path: 'Appearance > Localization >> General',
|
||||
path: 'Appearance > Localization >> General @{"sort":-100}',
|
||||
title: 'Language',
|
||||
description: `FrankerFaceZ is lovingly translated by volunteers from our community. Thank you. If you're interested in helping to translate FrankerFaceZ, please [join our Discord](https://discord.gg/UrAkGhT) and ask about localization.`,
|
||||
|
||||
|
@ -173,6 +173,82 @@ export class TranslationManager extends Module {
|
|||
|
||||
changed: val => this.locale = val
|
||||
});
|
||||
|
||||
|
||||
this.settings.add('i18n.format.date', {
|
||||
default: 'default',
|
||||
ui: {
|
||||
path: 'Appearance > Localization >> Formatting',
|
||||
title: 'Date Format',
|
||||
description: 'The default date format. Custom date formats are formated using the [Day.js](https://github.com/iamkun/dayjs#readme) library.',
|
||||
component: 'setting-combo-box',
|
||||
data: () => {
|
||||
const out = [], now = new Date;
|
||||
for (const [key,fmt] of Object.entries(this._.formats.date)) {
|
||||
out.push({
|
||||
value: key, title: `${this.formatDate(now, key)} (${key})`
|
||||
})
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
},
|
||||
|
||||
changed: val => {
|
||||
this._.defaultDateFormat = val;
|
||||
this.emit(':update')
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('i18n.format.time', {
|
||||
default: 'short',
|
||||
ui: {
|
||||
path: 'Appearance > Localization >> Formatting',
|
||||
title: 'Time Format',
|
||||
description: 'The default time format. Custom time formats are formated using the [Day.js](https://github.com/iamkun/dayjs#readme) library.',
|
||||
component: 'setting-combo-box',
|
||||
data: () => {
|
||||
const out = [], now = new Date;
|
||||
for (const [key,fmt] of Object.entries(this._.formats.time)) {
|
||||
out.push({
|
||||
value: key, title: `${this.formatTime(now, key)} (${key})`
|
||||
})
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
},
|
||||
|
||||
changed: val => {
|
||||
this._.defaultTimeFormat = val;
|
||||
this.emit(':update')
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('i18n.format.datetime', {
|
||||
default: 'medium',
|
||||
ui: {
|
||||
path: 'Appearance > Localization >> Formatting',
|
||||
title: 'Date-Time Format',
|
||||
description: 'The default combined date-time format. Custom time formats are formated using the [Day.js](https://github.com/iamkun/dayjs#readme) library.',
|
||||
component: 'setting-combo-box',
|
||||
data: () => {
|
||||
const out = [], now = new Date;
|
||||
for (const [key,fmt] of Object.entries(this._.formats.datetime)) {
|
||||
out.push({
|
||||
value: key, title: `${this.formatDateTime(now, key)} (${key})`
|
||||
})
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
},
|
||||
|
||||
changed: val => {
|
||||
this._.defaultDateTimeFormat = val;
|
||||
this.emit(':update')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getLocaleOptions(val) {
|
||||
|
@ -244,6 +320,9 @@ export class TranslationManager extends Module {
|
|||
|
||||
this._ = new NewTransCore({ //TranslationCore({
|
||||
warn: (...args) => this.log.warn(...args),
|
||||
defaultDateFormat: this.settings.get('i18n.format.date'),
|
||||
defaultTimeFormat: this.settings.get('i18n.format.time'),
|
||||
defaultDateTimeFormat: this.settings.get('i18n.format.datetime')
|
||||
});
|
||||
|
||||
if ( window.BroadcastChannel ) {
|
||||
|
|
|
@ -3,10 +3,12 @@ query FFZ_GetClipInfo($slug: ID!) {
|
|||
id
|
||||
curator {
|
||||
id
|
||||
login
|
||||
displayName
|
||||
}
|
||||
broadcaster {
|
||||
id
|
||||
login
|
||||
displayName
|
||||
}
|
||||
game {
|
||||
|
|
|
@ -1,27 +1,39 @@
|
|||
<script>
|
||||
|
||||
import {has, timeout} from 'utilities/object';
|
||||
import {ALLOWED_ATTRIBUTES, ALLOWED_TAGS} from 'utilities/constants';
|
||||
|
||||
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
|
||||
|
||||
let tokenizer;
|
||||
|
||||
|
||||
export default {
|
||||
props: ['data', 'url', 'events'],
|
||||
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
has_tokenizer: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
title: this.t('card.loading', 'Loading...'),
|
||||
title_tokens: null,
|
||||
desc_1: null,
|
||||
desc_1_tokens: null,
|
||||
desc_2: null,
|
||||
desc_2_tokens: null,
|
||||
image: null,
|
||||
image_title: null,
|
||||
image_square: false,
|
||||
accent: null
|
||||
accent: null,
|
||||
short: null,
|
||||
full: null,
|
||||
unsafe: false,
|
||||
urls: null,
|
||||
allow_media: false,
|
||||
allow_unsafe: false
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
has_full() {
|
||||
if ( this.full == null )
|
||||
return false;
|
||||
|
||||
if ( this.full?.type === 'media' && ! this.allow_media )
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -29,26 +41,50 @@ export default {
|
|||
data() {
|
||||
this.reset();
|
||||
this.load();
|
||||
},
|
||||
|
||||
events() {
|
||||
this.listen();
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
if ( this.events ) {
|
||||
this._events = this.events;
|
||||
this._events.on('chat:update-link-resolver', this.checkRefresh, this);
|
||||
}
|
||||
this.loadTokenizer();
|
||||
|
||||
this.listen();
|
||||
this.load();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
if ( this._events ) {
|
||||
this._events.off('chat:update-link-resolver', this.checkRefresh, this);
|
||||
this._events = null;
|
||||
}
|
||||
this.unlisten();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadTokenizer() {
|
||||
if ( tokenizer )
|
||||
this.has_tokenizer = true;
|
||||
else {
|
||||
tokenizer = await import(/* webpack-chunk-name: 'rich_tokens' */ 'utilities/rich_tokens');
|
||||
this.has_tokenizer = true;
|
||||
}
|
||||
},
|
||||
|
||||
listen() {
|
||||
this.unlisten();
|
||||
|
||||
if ( this.events?.on ) {
|
||||
this._es = this.events;
|
||||
this._es.on('chat:update-link-resolver', this.checkRefresh, this);
|
||||
}
|
||||
},
|
||||
|
||||
unlisten() {
|
||||
if ( this._es?.off ) {
|
||||
this._es.off('chat:update-link-resolver', this.checkRefresh, this);
|
||||
this._es = null;
|
||||
}
|
||||
},
|
||||
|
||||
checkRefresh(url) {
|
||||
if ( ! url || (url && url === this.url) ) {
|
||||
this.reset();
|
||||
|
@ -59,16 +95,13 @@ export default {
|
|||
reset() {
|
||||
this.loaded = false;
|
||||
this.error = null;
|
||||
this.title = this.t('card.loading', 'Loading...');
|
||||
this.title_tokens = null;
|
||||
this.desc_1 = null;
|
||||
this.desc_1_tokens = null;
|
||||
this.desc_2 = null;
|
||||
this.desc_2_tokens = null;
|
||||
this.image = null;
|
||||
this.image_title = null;
|
||||
this.image_square = null;
|
||||
this.accent = null;
|
||||
this.short = null;
|
||||
this.full = null;
|
||||
this.unsafe = false;
|
||||
this.urls = null;
|
||||
this.allow_media = false;
|
||||
this.allow_unsafe = false;
|
||||
},
|
||||
|
||||
async load() {
|
||||
|
@ -83,152 +116,130 @@ export default {
|
|||
data = await data;
|
||||
}
|
||||
|
||||
if ( ! data )
|
||||
data = {
|
||||
error: true,
|
||||
title: this.t('card.error', 'An error occured.'),
|
||||
desc_1: this.t('card.empty', 'No data was returned.')
|
||||
}
|
||||
} catch(err) {
|
||||
data = {
|
||||
error: true,
|
||||
title: this.t('card.error', 'An error occured.'),
|
||||
desc_1: String(err)
|
||||
}
|
||||
error: String(err)
|
||||
};
|
||||
}
|
||||
|
||||
if ( ! data )
|
||||
data = {
|
||||
error: {type: 'i18n', key: 'card.empty', phrase: 'No data was returned.'}
|
||||
};
|
||||
|
||||
if ( data.error )
|
||||
data = {
|
||||
short: {
|
||||
type: 'header',
|
||||
logo: {type: 'image', url: ERROR_IMAGE},
|
||||
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
|
||||
subtitle: data.error
|
||||
}
|
||||
}
|
||||
|
||||
this.loaded = true;
|
||||
this.error = data.error;
|
||||
this.title = data.title;
|
||||
this.title_tokens = data.title_tokens;
|
||||
this.desc_1 = data.desc_1;
|
||||
this.desc_1_tokens = data.desc_1_tokens;
|
||||
this.desc_2 = data.desc_2;
|
||||
this.desc_2_tokens = data.desc_2_tokens;
|
||||
this.image = data.image;
|
||||
this.image_square = data.image_square;
|
||||
this.image_title = data.image_title;
|
||||
this.accent = data.accent;
|
||||
this.short = data.short;
|
||||
this.full = data.full;
|
||||
this.unsafe = data.unsafe;
|
||||
this.urls = data.urls;
|
||||
this.allow_media = data.allow_media;
|
||||
this.allow_unsafe = data.allow_unsafe;
|
||||
},
|
||||
|
||||
// Rendering
|
||||
|
||||
renderCard(h) {
|
||||
if ( this.data.renderBody )
|
||||
return [this.data.renderBody(h)];
|
||||
if ( this.data.renderBody ) {
|
||||
const out = this.data.renderBody(h);
|
||||
return Array.isArray(out) ? out : [out];
|
||||
}
|
||||
|
||||
return [
|
||||
this.renderImage(h),
|
||||
this.renderDescription(h)
|
||||
];
|
||||
this.renderUnsafe(h),
|
||||
//this.forceFull ? null : this.renderImage(h),
|
||||
this.renderBody(h)
|
||||
]
|
||||
},
|
||||
|
||||
renderTokens(tokens, h) {
|
||||
let out = [];
|
||||
if ( ! Array.isArray(tokens) )
|
||||
tokens = [tokens];
|
||||
renderUnsafe(h) {
|
||||
if ( ! this.unsafe )
|
||||
return null;
|
||||
|
||||
for(const token of tokens) {
|
||||
if ( Array.isArray(token) )
|
||||
out = out.concat(this.renderTokens(token, h));
|
||||
const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', ');
|
||||
|
||||
else if ( typeof token !== 'object' )
|
||||
out.push(token);
|
||||
|
||||
else if ( token.type === 't') {
|
||||
const content = {};
|
||||
if ( token.content )
|
||||
for(const [key,val] of Object.entries(token.content))
|
||||
content[key] = this.renderTokens(val, h);
|
||||
|
||||
out = out.concat(this.tList(token.key, token.phrase, content));
|
||||
|
||||
} else {
|
||||
const tag = token.tag || 'span';
|
||||
if ( ! ALLOWED_TAGS.includes(tag) ) {
|
||||
console.log('Skipping disallowed tag', tag);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrs = {};
|
||||
if ( token.attrs ) {
|
||||
for(const [key,val] of Object.entries(token.attrs)) {
|
||||
if ( ! ALLOWED_ATTRIBUTES.includes(key) && ! key.startsWith('data-') )
|
||||
console.log('Skipping disallowed attribute', key);
|
||||
else
|
||||
attrs[key] = val;
|
||||
return h('div', {
|
||||
class: 'ffz--corner-flag ffz--corner-flag__warn ffz-tooltip ffz-tooltip--no-mouse',
|
||||
attrs: {
|
||||
'data-title': this.t(
|
||||
'tooltip.link-unsafe',
|
||||
"Caution: This URL is on Google's Safe Browsing List for: {reasons}",
|
||||
{
|
||||
reasons: reasons.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const el = h(tag, {
|
||||
class: token.class,
|
||||
attrs
|
||||
}, this.renderTokens(token.content, h));
|
||||
|
||||
out.push(el);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [
|
||||
h('figure', {
|
||||
class: 'ffz-i-attention'
|
||||
})
|
||||
]);
|
||||
},
|
||||
|
||||
renderDescription(h) {
|
||||
let title = this.title,
|
||||
title_tokens = this.title_tokens,
|
||||
desc_1 = this.desc_1,
|
||||
desc_1_tokens = this.desc_1_tokens,
|
||||
desc_2 = this.desc_2,
|
||||
desc_2_tokens = this.desc_2_tokens;
|
||||
renderBody(h) {
|
||||
if ( this.has_tokenizer && this.loaded && (this.forceFull ? this.full : this.short) ) {
|
||||
return h('div', {
|
||||
class: 'ffz--card-rich tw-full-width tw-overflow-hidden tw-flex tw-flex-column'
|
||||
}, tokenizer.renderTokens(this.forceFull ? this.full : this.short, h, {
|
||||
vue: true,
|
||||
tList: (...args) => this.tList(...args),
|
||||
i18n: this.getI18n(),
|
||||
|
||||
if ( ! this.loaded ) {
|
||||
desc_1 = this.t('card.loading', 'Loading...');
|
||||
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: [
|
||||
'ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex',
|
||||
desc_2 && 'ffz--two-line'
|
||||
]
|
||||
}, [h('div', {class: 'tw-full-width tw-pd-l-1'}, [
|
||||
h('div', {class: 'chat-card__title tw-ellipsis'},
|
||||
[h('span', {class: 'tw-strong', attrs: {title}}, title_tokens ? this.renderTokens(title_tokens, h) : title)]),
|
||||
h('div', {class: 'tw-ellipsis'},
|
||||
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_1}}, desc_1_tokens ? this.renderTokens(desc_1_tokens, h) : desc_1)]),
|
||||
desc_2 && h('div', {class: 'tw-ellipsis'},
|
||||
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_2}}, desc_2_tokens ? this.renderTokens(desc_2_tokens, h) : desc_2)])
|
||||
])]);
|
||||
allow_media: this.forceMedia ?? this.allow_media,
|
||||
allow_unsafe: this.forceUnsafe ?? this.allow_unsafe
|
||||
}));
|
||||
} else
|
||||
return this.renderBasic(h);
|
||||
},
|
||||
|
||||
renderImage(h) {
|
||||
let content;
|
||||
if ( this.error )
|
||||
content = h('img', {
|
||||
class: 'chat-card__error-img',
|
||||
attrs: {
|
||||
src: ERROR_IMAGE
|
||||
}
|
||||
});
|
||||
else {
|
||||
content = h('div', {
|
||||
class: 'tw-card-img tw-flex-shrink-0 tw-overflow-hidden'
|
||||
}, [h('aspect', {
|
||||
props: {
|
||||
ratio: 16/9
|
||||
}
|
||||
}, [this.loaded && this.image && h('img', {
|
||||
class: 'tw-image',
|
||||
attrs: {
|
||||
src: this.image,
|
||||
alt: this.image_title ?? this.title
|
||||
}
|
||||
})])]);
|
||||
renderBasic(h) {
|
||||
let title, description;
|
||||
if ( this.loaded && this.forceFull && ! this.full ) {
|
||||
description = 'null';
|
||||
|
||||
} else if ( this.error ) {
|
||||
title = this.t('card.error', 'An error occurred.');
|
||||
description = this.error;
|
||||
|
||||
} else if ( this.loaded && this.has_tokenizer ) {
|
||||
title = this.title;
|
||||
description = this.description;
|
||||
} else {
|
||||
description = this.t('card.loading', 'Loading...');
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: [
|
||||
'chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center',
|
||||
this.image_square && 'square'
|
||||
]
|
||||
}, [content])
|
||||
if ( ! title && ! description )
|
||||
description = this.t('card.empty', 'No data was returned.');
|
||||
|
||||
description = description ? description.split(/\n+/).slice(0,2).map(desc =>
|
||||
h('div', {
|
||||
class: 'tw-c-text-alt-2 tw-ellipsis tw-mg-x-05',
|
||||
attrs:{title: desc}
|
||||
}, [desc])
|
||||
) : [];
|
||||
|
||||
return [
|
||||
h('div', {class: 'ffz--header-image'}),
|
||||
h('div', {
|
||||
class: 'ffz--card-text tw-full-width tw-overflow-hidden tw-flex tw-flex-column tw-justify-content-center'
|
||||
}, [
|
||||
title ? h('div', {class: 'chat-card__title tw-ellipsis tw-mg-x-05'}, [
|
||||
h('span', {class: 'tw-strong', attrs:{title}}, [title])
|
||||
]) : null,
|
||||
...description
|
||||
])
|
||||
];
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -237,8 +248,9 @@ export default {
|
|||
class: 'tw-flex tw-flex-nowrap tw-pd-05'
|
||||
}, this.renderCard(h));
|
||||
|
||||
if ( this.url ) {
|
||||
const tooltip = this.data.card_tooltip;
|
||||
const tooltip = this.has_full && ! this.forceFull;
|
||||
|
||||
if ( this.url )
|
||||
content = h('a', {
|
||||
class: [
|
||||
tooltip && 'ffz-tooltip',
|
||||
|
@ -255,10 +267,21 @@ export default {
|
|||
href: this.url
|
||||
}
|
||||
}, [content]);
|
||||
}
|
||||
else if ( tooltip )
|
||||
content = h('div', {
|
||||
class: 'ffz-tooltip tw-block tw-border-radius-medium tw-full-width',
|
||||
attrs: {
|
||||
'data-tooltip-type': 'link',
|
||||
'data-url': this.url,
|
||||
'data-is-mail': false,
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return h('div', {
|
||||
class: 'tw-border-radius-medium tw-elevation-1 ffz--chat-card',
|
||||
class: [
|
||||
'tw-border-radius-medium tw-elevation-1 ffz--chat-card tw-relative',
|
||||
this.unsafe ? 'ffz--unsafe' : ''
|
||||
],
|
||||
style: {
|
||||
'--ffz-color-accent': this.accent
|
||||
}
|
||||
|
@ -266,7 +289,6 @@ export default {
|
|||
class: 'tw-border-radius-medium tw-c-background-base tw-flex tw-full-width'
|
||||
}, [content])]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
|
@ -24,6 +24,7 @@ import Actions from './actions';
|
|||
|
||||
export const SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
|
||||
|
||||
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
|
||||
const EMOTE_CHARS = /[ .,!]/;
|
||||
|
||||
export default class Chat extends Module {
|
||||
|
@ -1411,7 +1412,7 @@ export default class Chat extends Module {
|
|||
const tt = tokenizer.tooltip;
|
||||
const tk = this.tooltips.types[type] = tt.bind(this);
|
||||
|
||||
for(const i of ['interactive', 'delayShow', 'delayHide'])
|
||||
for(const i of ['interactive', 'delayShow', 'delayHide', 'onShow', 'onHide'])
|
||||
tk[i] = typeof tt[i] === 'function' ? tt[i].bind(this) : tt[i];
|
||||
}
|
||||
|
||||
|
@ -1579,6 +1580,8 @@ export default class Chat extends Module {
|
|||
info = this._link_info[url] = [false, null, [[resolve, reject]]];
|
||||
|
||||
const handle = (success, data) => {
|
||||
data = this.fixLinkInfo(data);
|
||||
|
||||
const callbacks = ! info[0] && info[2];
|
||||
info[0] = true;
|
||||
info[1] = Date.now() + 120000;
|
||||
|
@ -1608,4 +1611,43 @@ export default class Chat extends Module {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
fixLinkInfo(data) {
|
||||
if ( data.error && data.message )
|
||||
data.error = data.message;
|
||||
|
||||
if ( data.error )
|
||||
data = {
|
||||
v: 5,
|
||||
title: this.i18n.t('card.error', 'An error occured.'),
|
||||
description: data.error,
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: ERROR_IMAGE},
|
||||
title: {type: 'i18n', key: 'card.error', phrase: 'An error occured.'},
|
||||
subtitle: data.error
|
||||
}
|
||||
}
|
||||
|
||||
if ( data.v < 5 && ! data.short && ! data.full && (data.title || data.desc_1 || data.desc_2) ) {
|
||||
const image = data.preview || data.image;
|
||||
|
||||
data = {
|
||||
v: 5,
|
||||
short: {
|
||||
type: 'header',
|
||||
image: image ? {
|
||||
type: 'image',
|
||||
url: image,
|
||||
sfw: data.image_safe ?? false,
|
||||
} : null,
|
||||
title: data.title,
|
||||
subtitle: data.desc_1,
|
||||
extra: data.desc_2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
|
@ -17,6 +17,8 @@ const BAD_USERS = [
|
|||
import GET_CLIP from './clip_info.gql';
|
||||
import GET_VIDEO from './video_info.gql';
|
||||
|
||||
import {truncate} from 'utilities/object';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// General Links
|
||||
|
@ -47,31 +49,20 @@ export const Links = {
|
|||
} catch(err) {
|
||||
return {
|
||||
url: token.url,
|
||||
title: this.i18n.t('card.error', 'An error occurred.'),
|
||||
desc_1: String(err)
|
||||
error: String(err)
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! data )
|
||||
return {
|
||||
url: token.url,
|
||||
title: this.i18n.t('card.error', 'An error occurred.'),
|
||||
desc_1: this.i18n.t('card.empty', 'No data was returned.')
|
||||
url: token.url
|
||||
}
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
accent: data.accent,
|
||||
image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null,
|
||||
image_title: data.image_title,
|
||||
image_square: data.image_square,
|
||||
title: data.title,
|
||||
title_tokens: data.title_tokens,
|
||||
desc_1: data.desc_1,
|
||||
desc_1_tokens: data.desc_1_tokens,
|
||||
desc_2: data.desc_2,
|
||||
desc_2_tokens: data.desc_2_tokens
|
||||
}
|
||||
...data,
|
||||
allow_media: this.context.get('tooltip.link-images'),
|
||||
allow_unsafe: this.context.get('tooltip.link-nsfw-images')
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -108,66 +99,82 @@ export const Users = {
|
|||
if ( ! user || ! user.id )
|
||||
return null;
|
||||
|
||||
const game = user.broadcastSettings?.game?.displayName;
|
||||
const game = user.broadcastSettings?.game?.displayName,
|
||||
stream_id = user.stream?.id;
|
||||
|
||||
let desc_1 = null, desc_2 = null, desc_1_tokens = null, desc_2_tokens = null;
|
||||
if ( user.stream?.id && game ) {
|
||||
desc_1_tokens = this.i18n.tList('cards.user.streaming', 'streaming {game}', {
|
||||
game: {class: 'tw-semibold', content: [game]}
|
||||
});
|
||||
desc_1 = this.i18n.t('cards.user.streaming', 'streaming {game}', {
|
||||
game
|
||||
});
|
||||
}
|
||||
let subtitle
|
||||
if ( stream_id && game )
|
||||
subtitle = {
|
||||
type: 'i18n',
|
||||
key: 'cards.user.streaming', phrase: 'streaming {game}', content: {
|
||||
game: {type: 'style', weight: 'semibold', content: game}
|
||||
}
|
||||
};
|
||||
|
||||
const bits_tokens = this.i18n.tList('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
|
||||
views: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.profileViewCount || 0)]},
|
||||
followers: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.followers?.totalCount || 0)]}
|
||||
}),
|
||||
bits = this.i18n.t('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
|
||||
views: user.profileViewCount || 0,
|
||||
followers: user.followers?.totalCount || 0
|
||||
const extra = truncate(user.description);
|
||||
const title = [user.displayName];
|
||||
|
||||
if ( user.displayName.trim().toLowerCase() !== user.login )
|
||||
title.push({
|
||||
type: 'style', color: 'alt-2',
|
||||
content: [' (', user.login, ')']
|
||||
});
|
||||
|
||||
if ( desc_1 ) {
|
||||
desc_2 = bits;
|
||||
desc_2_tokens = bits_tokens;
|
||||
} else {
|
||||
desc_1 = bits;
|
||||
desc_1_tokens = bits_tokens;
|
||||
}
|
||||
if ( user.roles?.isPartner )
|
||||
title.push({
|
||||
type: 'style', color: 'link',
|
||||
content: {type: 'icon', name: 'verified'}
|
||||
});
|
||||
|
||||
const has_i18n = user.displayName.trim().toLowerCase() !== user.login;
|
||||
let title = user.displayName, title_tokens = null;
|
||||
if ( has_i18n ) {
|
||||
title = `${user.displayName} (${user.login})`;
|
||||
title_tokens = [
|
||||
user.displayName,
|
||||
{class: 'chat-author__intl-login', content: ` (${user.login})`}
|
||||
];
|
||||
}
|
||||
/*const full = [{
|
||||
type: 'header',
|
||||
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
|
||||
title,
|
||||
subtitle,
|
||||
extra: stream_id ? extra : null
|
||||
}];
|
||||
|
||||
if ( user.roles?.isPartner ) {
|
||||
if ( ! title_tokens )
|
||||
title_tokens = [title];
|
||||
if ( stream_id ) {
|
||||
full.push({type: 'box', 'mg-y': 'small', lines: 1, content: user.broadcastSettings.title});
|
||||
full.push({type: 'conditional', content: {
|
||||
type: 'gallery', items: [{
|
||||
type: 'image', aspect: 16/9, sfw: false, url: user.stream.previewImageURL
|
||||
}]
|
||||
}});
|
||||
} else
|
||||
full.push({type: 'box', 'mg-y': 'small', wrap: 'pre-wrap', lines: 5, content: truncate(user.description, 1000, undefined, undefined, false)})
|
||||
|
||||
title_tokens = {tag: 'div', class: 'tw-flex tw-align-items-center', content: [
|
||||
{tag: 'div', content: title_tokens},
|
||||
{tag: 'figure', class: 'tw-mg-l-05 ffz-i-verified tw-c-text-link', content: []}
|
||||
]};
|
||||
}
|
||||
full.push({
|
||||
type: 'fieldset',
|
||||
fields: [
|
||||
{
|
||||
name: {type: 'i18n', key: 'embed.twitch.views', phrase: 'Views'},
|
||||
value: {type: 'format', format: 'number', value: user.profileViewCount},
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: {type: 'i18n', key: 'embed.twitch.followers', phrase: 'Followers'},
|
||||
value: {type: 'format', format: 'number', value: user.followers?.totalCount},
|
||||
inline: true
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
full.push({
|
||||
type: 'header',
|
||||
subtitle: [{type: 'icon', name: 'twitch'}, ' Twitch']
|
||||
});*/
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
|
||||
image: user.profileImageURL,
|
||||
image_square: true,
|
||||
title,
|
||||
title_tokens,
|
||||
desc_1,
|
||||
desc_1_tokens,
|
||||
desc_2,
|
||||
desc_2_tokens
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
|
||||
title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -214,53 +221,51 @@ export const Clips = {
|
|||
return null;
|
||||
|
||||
const clip = result.data.clip,
|
||||
user = clip.broadcaster.displayName,
|
||||
game = clip.game,
|
||||
game_name = game && game.name,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
let desc_1, desc_1_tokens;
|
||||
if ( game_name === 'creative' ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
|
||||
user
|
||||
});
|
||||
const user = {
|
||||
type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
|
||||
content: {
|
||||
type: 'style', weight: 'semibold', color: 'alt-2',
|
||||
content: clip.broadcaster.displayName
|
||||
}
|
||||
};
|
||||
|
||||
} else if ( game ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
|
||||
user: {class: 'tw-semibold', content: user},
|
||||
game: {class: 'tw-semibold', content: game_display}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
|
||||
const subtitle = game_display ? {
|
||||
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
|
||||
user,
|
||||
game: game_display
|
||||
});
|
||||
game: {type: 'style', weight: 'semibold', content: game_display}
|
||||
}
|
||||
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
|
||||
|
||||
} else {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1', 'Clip of {user}', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1', 'Clip of {user}', {user});
|
||||
}
|
||||
const curator = clip.curator ? {
|
||||
type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`,
|
||||
content: {
|
||||
type: 'style', color: 'alt-2',
|
||||
content: clip.curator.displayName
|
||||
}
|
||||
} : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'};
|
||||
|
||||
const curator = clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown');
|
||||
const extra = {
|
||||
type: 'i18n', key: 'clip.desc.2',
|
||||
phrase: 'Clipped by {curator} — {views,number} View{views,en_plural}',
|
||||
content: {
|
||||
curator,
|
||||
views: clip.viewCount
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
image: clip.thumbnailURL,
|
||||
title: clip.title,
|
||||
desc_1,
|
||||
desc_1_tokens,
|
||||
desc_2: this.i18n.t('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
|
||||
curator,
|
||||
views: clip.viewCount
|
||||
}),
|
||||
desc_2_tokens: this.i18n.tList('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
|
||||
curator: clip.curator ? {class: 'tw-semibold', content: curator} : curator,
|
||||
views: {class: 'tw-semibold', content: this.i18n.formatNumber(clip.viewCount)}
|
||||
})
|
||||
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: clip.thumbnailURL, sfw: false, aspect: 16/9},
|
||||
title: clip.title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -296,49 +301,43 @@ export const Videos = {
|
|||
return null;
|
||||
|
||||
const video = result.data.video,
|
||||
user = video.owner.displayName,
|
||||
game = video.game,
|
||||
game_name = game && game.name,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
let desc_1, desc_1_tokens;
|
||||
if ( game_name === 'creative' ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
|
||||
user
|
||||
});
|
||||
const user = {
|
||||
type: 'link', url: `https://www.twitch.tv/${video.owner.login}`,
|
||||
content: {
|
||||
type: 'style', weight: 'semibold', color: 'alt-2',
|
||||
content: video.owner.displayName
|
||||
}
|
||||
};
|
||||
|
||||
} else if ( game ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
|
||||
user: {class: 'tw-semibold', content: user},
|
||||
game: {class: 'tw-semibold', content: game_display}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
|
||||
const subtitle = game_display ? {
|
||||
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
|
||||
user,
|
||||
game: game_display
|
||||
});
|
||||
game: {type: 'style', weight: 'semibold', content: game_display}
|
||||
}
|
||||
} : {type: 'i18n', key: 'video.desc.1', phrase: 'Video of {user}', content: {user}};
|
||||
|
||||
} else {
|
||||
desc_1_tokens = this.i18n.tList('video.desc.1', 'Video of {user}', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('video.desc.1', 'Video of {user}', {user});
|
||||
}
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
image: video.previewThumbnailURL,
|
||||
title: video.title,
|
||||
desc_1,
|
||||
desc_1_tokens,
|
||||
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
|
||||
const extra = {
|
||||
type: 'i18n', key: 'video.desc.2',
|
||||
phrase: '{length,duration} — {views,number} Views — {date,datetime}', content: {
|
||||
length: video.lengthSeconds,
|
||||
views: video.viewCount,
|
||||
date: video.publishedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: video.previewThumbnailURL, sfw: false, aspect: 16/9},
|
||||
title: video.title,
|
||||
subtitle,
|
||||
extra
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,7 +20,11 @@ const EMOTE_CLASS = 'chat-image chat-line__message--emote',
|
|||
// Links
|
||||
// ============================================================================
|
||||
|
||||
const TOOLTIP_VERSION = 4;
|
||||
function datasetBool(value) {
|
||||
return value == null ? null : value === 'true';
|
||||
}
|
||||
|
||||
const TOOLTIP_VERSION = 5;
|
||||
|
||||
export const Links = {
|
||||
type: 'link',
|
||||
|
@ -47,60 +51,94 @@ export const Links = {
|
|||
if ( target.dataset.isMail === 'true' )
|
||||
return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})];
|
||||
|
||||
const url = target.dataset.url || target.href;
|
||||
const url = target.dataset.url || target.href,
|
||||
show_images = datasetBool(target.dataset.forceMedia) ?? this.context.get('tooltip.link-images'),
|
||||
show_unsafe = datasetBool(target.dataset.forceUnsafe) ?? this.context.get('tooltip.link-nsfw-images');
|
||||
|
||||
return this.get_link_info(url).then(data => {
|
||||
return Promise.all([
|
||||
import(/* webpack-chunk-name: 'rich_tokens' */ 'utilities/rich_tokens'),
|
||||
this.get_link_info(url)
|
||||
]).then(([rich_tokens, data]) => {
|
||||
if ( ! data || (data.v || 1) > TOOLTIP_VERSION )
|
||||
return '';
|
||||
|
||||
let content = data.content || data.html || '';
|
||||
const ctx = {
|
||||
tList: (...args) => this.i18n.tList(...args),
|
||||
i18n: this.i18n,
|
||||
allow_media: show_images,
|
||||
allow_unsafe: show_unsafe,
|
||||
onload: tip.update
|
||||
};
|
||||
|
||||
// TODO: Replace timestamps.
|
||||
|
||||
if ( data.urls && data.urls.length > 1 )
|
||||
content += (content.length ? '<hr>' : '') +
|
||||
sanitize(this.i18n.t(
|
||||
'tooltip.link-destination',
|
||||
'Destination: {url}',
|
||||
{url: data.urls[data.urls.length-1][1]}
|
||||
));
|
||||
|
||||
if ( data.unsafe ) {
|
||||
const reasons = Array.from(new Set(data.urls.map(x => x[2]).filter(x => x))).join(', ');
|
||||
content = this.i18n.t(
|
||||
'tooltip.link-unsafe',
|
||||
"Caution: This URL is on Google's Safe Browsing List for: {reasons}",
|
||||
{reasons: sanitize(reasons.toLowerCase())}
|
||||
) + (content.length ? `<hr>${content}` : '');
|
||||
let content;
|
||||
if ( tip.element ) {
|
||||
tip.element.classList.add('ffz-rich-tip');
|
||||
tip.element.classList.add('tw-align-left');
|
||||
}
|
||||
|
||||
const show_image = this.context.get('tooltip.link-images') && (data.image_safe || this.context.get('tooltip.link-nsfw-images'));
|
||||
if ( data.full ) {
|
||||
content = rich_tokens.renderTokens(data.full, createElement, ctx);
|
||||
|
||||
if ( show_image ) {
|
||||
if ( data.image && ! data.image_iframe )
|
||||
content = `<img class="preview-image" src="${sanitize(data.image)}">${content}`
|
||||
} else {
|
||||
if ( data.short ) {
|
||||
content = rich_tokens.renderTokens(data.short, createElement, ctx);
|
||||
} else
|
||||
content = this.i18n.t('card.empty', 'No data was returned.');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if ( tip.element ) {
|
||||
for(const el of tip.element.querySelectorAll('img'))
|
||||
el.addEventListener('load', tip.update);
|
||||
if ( ! data.urls )
|
||||
return content;
|
||||
|
||||
for(const el of tip.element.querySelectorAll('video'))
|
||||
el.addEventListener('loadedmetadata', tip.update);
|
||||
}
|
||||
const url_table = [];
|
||||
for(let i=0; i < data.urls.length; i++) {
|
||||
const url = data.urls[i];
|
||||
|
||||
url_table.push(<tr>
|
||||
<td>{this.i18n.formatNumber(i + 1)}.</td>
|
||||
<td class="tw-c-text-alt-2 tw-pd-x-05 tw-word-break-all">{url.url}</td>
|
||||
<td>{url.flags ? url.flags.map(flag => <span class="tw-pill">{flag.toLowerCase()}</span>) : null}</td>
|
||||
</tr>);
|
||||
}
|
||||
|
||||
let url_notice;
|
||||
if ( data.unsafe ) {
|
||||
const reasons = Array.from(new Set(data.urls.map(url => url.flags).flat())).join(', ');
|
||||
url_notice = (<div class="ffz-i-attention">
|
||||
{this.i18n.tList(
|
||||
'tooltip.link-unsafe',
|
||||
"Caution: This URL is on Google's Safe Browsing List for: {reasons}",
|
||||
{reasons: reasons.toLowerCase()}
|
||||
)}
|
||||
</div>);
|
||||
} else if ( data.urls.length > 1 )
|
||||
url_notice = this.i18n.t('tooltip.link-destination', 'Destination: {url}', {
|
||||
url: data.urls[data.urls.length-1].url
|
||||
});
|
||||
|
||||
} else if ( content.length )
|
||||
content = content.replace(/<!--MS-->.*<!--ME-->/g, '');
|
||||
|
||||
if ( data.tooltip_class )
|
||||
tip.element.classList.add(data.tooltip_class);
|
||||
content = (<div>
|
||||
<div class="ffz--shift-hide">
|
||||
{content}
|
||||
{url_notice ? <div class="tw-mg-t-05 tw-border-t tw-pd-t-05 tw-align-center">
|
||||
{url_notice}
|
||||
<div class=" tw-font-size-8">
|
||||
{this.i18n.t('tooltip.shift-detail', '(Shift for Details)')}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
<div class="ffz--shift-show tw-align-left">
|
||||
<div class="tw-semibold tw-mg-b-05 tw-align-center">
|
||||
{this.i18n.t('tooltip.link.urls', 'Visited URLs')}
|
||||
</div>
|
||||
<table>{url_table}</table>
|
||||
</div>
|
||||
</div>);
|
||||
|
||||
return content;
|
||||
|
||||
}).catch(error =>
|
||||
sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error}))
|
||||
);
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
return sanitize(this.i18n.t('tooltip.error', 'An error occurred. ({error})', {error}))
|
||||
});
|
||||
},
|
||||
|
||||
process(tokens) {
|
||||
|
|
|
@ -12,6 +12,7 @@ query FFZ_GetVideoInfo($id: ID!) {
|
|||
}
|
||||
owner {
|
||||
id
|
||||
login
|
||||
displayName
|
||||
}
|
||||
}
|
||||
|
|
|
@ -104,7 +104,8 @@
|
|||
v-if="addon.website"
|
||||
:href="addon.website"
|
||||
:title="addon.website"
|
||||
class="tw-button ffz-button--hollow tw-mg-r-1"
|
||||
class="tw-button ffz-button--hollow tw-mg-r-1 ffz-tooltip ffz-tooltip--no-mouse"
|
||||
data-tooltip-type="link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
|
|
|
@ -53,7 +53,8 @@
|
|||
:href="commit.author.html_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tw-inline-flex tw-align-items-center tw-link tw-link--inherit tw-mg-x-05"
|
||||
class="tw-inline-flex tw-align-items-center tw-link tw-link--inherit tw-mg-x-05 ffz-tooltip"
|
||||
data-tooltip-type="link"
|
||||
>
|
||||
<figure
|
||||
v-if="commit.author.avatar_url"
|
||||
|
@ -76,7 +77,7 @@
|
|||
v-if="commit.hash"
|
||||
class="tw-font-size-8 tw-c-text-alt-2"
|
||||
>
|
||||
@<a :href="commit.link" target="_blank" rel="noopener noreferrer" class="tw-link tw-link--inherit">{{ commit.hash }}</a>
|
||||
@<a :href="commit.link" target="_blank" rel="noopener noreferrer" class="tw-link tw-link--inherit ffz-tooltip" data-tooltip-type="link">{{ commit.hash }}</a>
|
||||
</div>
|
||||
<time
|
||||
v-if="commit.date"
|
||||
|
|
|
@ -26,15 +26,51 @@
|
|||
</select>
|
||||
<input
|
||||
ref="text"
|
||||
v-model="raw_url"
|
||||
:disabled="! isCustomURL"
|
||||
class="ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-input"
|
||||
@blur="updateText"
|
||||
@input="onTextChange"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-mg-b-1">
|
||||
<div class="tw-flex-grow-1" />
|
||||
|
||||
<div class="tw-pd-x-1 tw-checkbox">
|
||||
<input
|
||||
id="force_media"
|
||||
ref="force_media"
|
||||
:checked="force_media"
|
||||
type="checkbox"
|
||||
class="tw-checkbox__input"
|
||||
@change="onCheck"
|
||||
>
|
||||
|
||||
<label for="force_media" class="tw-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.link-provider.allow.media', 'Allow Media') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="tw-pd-x-1 tw-checkbox">
|
||||
<input
|
||||
id="force_unsafe"
|
||||
ref="force_unsafe"
|
||||
:checked="force_unsafe"
|
||||
type="checkbox"
|
||||
class="tw-checkbox__input"
|
||||
@change="onCheck"
|
||||
>
|
||||
|
||||
<label for="force_unsafe" class="tw-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.link-provider.allow.unsafe', 'Allow NSFW') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="tw-mg-l-1 tw-button tw-button--text"
|
||||
@click="refresh"
|
||||
|
@ -44,6 +80,47 @@
|
|||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-flex tw-mg-b-1 tw-full-width">
|
||||
<label>
|
||||
{{ t('debug.link-provider.link', 'Chat Link') }}
|
||||
</label>
|
||||
<div class="tw-full-width tw-overflow-hidden">
|
||||
<a
|
||||
v-if="url"
|
||||
ref="link"
|
||||
:href="url"
|
||||
:data-url="url"
|
||||
class="ffz-tooltip"
|
||||
data-tooltip-type="link"
|
||||
data-force-tooltip="true"
|
||||
:data-force-open="force_tooltip ? 'true' : 'false'"
|
||||
:data-force-media="force_media ? 'true' : 'false'"
|
||||
:data-force-unsafe="force_unsafe ? 'true' : 'false'"
|
||||
data-is-mail="false"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="tw-pd-x-1 tw-checkbox">
|
||||
<input
|
||||
id="force_tooltip"
|
||||
ref="force_tooltip"
|
||||
:checked="force_tooltip"
|
||||
type="checkbox"
|
||||
class="tw-checkbox__input"
|
||||
@change="onTooltip"
|
||||
>
|
||||
|
||||
<label for="force_tooltip" class="tw-checkbox__label">
|
||||
<span class="tw-mg-l-1">
|
||||
{{ t('debug.link-provider.force-tooltip', 'Force Tooltip') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-mg-b-1 tw-full-width">
|
||||
<label>
|
||||
{{ t('debug.link-provider.embed', 'Rich Embed') }}
|
||||
|
@ -53,28 +130,26 @@
|
|||
v-if="rich_data"
|
||||
:data="rich_data"
|
||||
:url="url"
|
||||
:force-media="force_media"
|
||||
:force-unsafe="force_unsafe"
|
||||
:events="events"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-mg-b-1 tw-full-width">
|
||||
<label>
|
||||
{{ t('debug.link-provider.link', 'Chat Link') }}
|
||||
{{ t('debug.link-provider.full-embed', 'Full Embed') }}
|
||||
</label>
|
||||
<div class="tw-full-width tw-overflow-hidden">
|
||||
<a
|
||||
v-if="url"
|
||||
:href="url"
|
||||
:data-url="url"
|
||||
class="ffz-tooltip"
|
||||
data-tooltip-type="link"
|
||||
data-force-tooltip="true"
|
||||
data-is-mail="false"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
<chat-rich
|
||||
v-if="rich_data"
|
||||
:data="rich_data"
|
||||
:url="url"
|
||||
:force-full="true"
|
||||
:force-media="force_media"
|
||||
:force-unsafe="force_unsafe"
|
||||
:events="events"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-flex tw-mg-b-1 tw-full-width">
|
||||
|
@ -99,12 +174,19 @@ import { debounce } from '../../../utilities/object';
|
|||
|
||||
const STOCK_URLS = [
|
||||
'https://www.twitch.tv/sirstendec',
|
||||
'https://www.twitch.tv/videos/42968068',
|
||||
'https://www.twitch.tv/sirstendec/clip/HedonisticMagnificentSoymilkChocolateRain',
|
||||
'https://clips.twitch.tv/HedonisticMagnificentSoymilkChocolateRain',
|
||||
'https://discord.gg/UrAkGhT',
|
||||
'https://www.youtube.com/watch?v=CAL4WMpBNs0',
|
||||
'https://xkcd.com/221/',
|
||||
'https://github.com/FrankerFaceZ/FrankerFaceZ',
|
||||
'https://twitter.com/frankerfacez',
|
||||
'https://twitter.com/FrankerFaceZ/status/1240717057630625792'
|
||||
'https://twitter.com/FrankerFaceZ/status/1240717057630625792',
|
||||
'http://testsafebrowsing.appspot.com/apiv4/ANY_PLATFORM/MALWARE/URL/',
|
||||
'https://en.wikipedia.org/wiki/Emoji',
|
||||
'https://en.wikipedia.org/wiki/Naginata',
|
||||
'https://www.smbc-comics.com/comic/punishment'
|
||||
]
|
||||
|
||||
export default {
|
||||
|
@ -118,13 +200,26 @@ export default {
|
|||
props: ['item', 'context'],
|
||||
|
||||
data() {
|
||||
const state = window.history.state;
|
||||
let url = state?.ffz_lt_url,
|
||||
is_custom = false;
|
||||
if ( url )
|
||||
is_custom = ! STOCK_URLS.includes(url);
|
||||
else
|
||||
url = STOCK_URLS[Math.floor(Math.random() * STOCK_URLS.length)];
|
||||
|
||||
return {
|
||||
stock_urls: deep_copy(STOCK_URLS),
|
||||
raw_url: STOCK_URLS[Math.floor(Math.random() * STOCK_URLS.length)],
|
||||
raw_url: url,
|
||||
isCustomURL: is_custom,
|
||||
rich_data: null,
|
||||
isCustomURL: false,
|
||||
raw_loading: false,
|
||||
raw_data: null,
|
||||
|
||||
force_media: state.ffz_lt_media ?? true,
|
||||
force_unsafe: state.ffz_lt_unsafe ?? false,
|
||||
force_tooltip: state.ffz_lt_tip ?? false,
|
||||
|
||||
events: {
|
||||
on: (...args) => this.item.getChat().on(...args),
|
||||
off: (...args) => this.item.getChat().off(...args)
|
||||
|
@ -143,24 +238,75 @@ export default {
|
|||
},
|
||||
|
||||
watch: {
|
||||
raw_url() {
|
||||
if ( ! this.isCustomURL )
|
||||
this.$refs.text.value = this.raw_url;
|
||||
},
|
||||
|
||||
url() {
|
||||
this.rebuildData();
|
||||
this.saveState();
|
||||
|
||||
if ( this.force_tooltip ) {
|
||||
const link = this.$refs.link;
|
||||
if ( ! link || ! this.chat )
|
||||
return;
|
||||
|
||||
const tips = this.chat.resolve('tooltips')?.tips;
|
||||
if ( ! tips )
|
||||
return;
|
||||
|
||||
tips._exit(link);
|
||||
setTimeout(() => tips._enter(link), 250);
|
||||
}
|
||||
},
|
||||
|
||||
rich_data() {
|
||||
this.refreshRaw();
|
||||
},
|
||||
|
||||
force_tooltip() {
|
||||
const link = this.$refs.link;
|
||||
if ( ! link || ! this.chat )
|
||||
return;
|
||||
|
||||
const tips = this.chat.resolve('tooltips')?.tips;
|
||||
if ( ! tips )
|
||||
return;
|
||||
|
||||
if ( this.force_tooltip )
|
||||
tips._enter(link);
|
||||
else
|
||||
tips._exit(link);
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.rebuildData = debounce(this.rebuildData, 250);
|
||||
this.refreshRaw = debounce(this.refreshRaw, 250);
|
||||
this.onTextChange = debounce(this.onTextChange, 500);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.chat = this.item.getChat();
|
||||
this.chat.on('chat:update-link-resolver', this.checkRefreshRaw, this);
|
||||
this.rebuildData();
|
||||
|
||||
this.$refs.text.value = this.raw_url;
|
||||
|
||||
|
||||
if ( this.force_tooltip ) {
|
||||
const link = this.$refs.link;
|
||||
if ( ! link || ! this.chat )
|
||||
return;
|
||||
|
||||
const tips = this.chat.resolve('tooltips')?.tips;
|
||||
if ( ! tips )
|
||||
return;
|
||||
|
||||
tips._enter(link);
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
|
@ -169,6 +315,21 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
saveState() {
|
||||
try {
|
||||
window.history.replaceState({
|
||||
...window.history.state,
|
||||
ffz_lt_url: this.raw_url,
|
||||
ffz_lt_media: this.force_media,
|
||||
ffz_lt_unsafe: this.force_unsafe,
|
||||
ffz_lt_tip: this.force_tooltip
|
||||
}, document.title);
|
||||
|
||||
} catch(err) {
|
||||
/* no-op */
|
||||
}
|
||||
},
|
||||
|
||||
checkRefreshRaw(url) {
|
||||
if ( ! url || (url && url === this.url) )
|
||||
this.refreshRaw();
|
||||
|
@ -220,8 +381,26 @@ export default {
|
|||
this.isCustomURL = true;
|
||||
},
|
||||
|
||||
updateText() {
|
||||
if ( this.isCustomURL )
|
||||
this.raw_url = this.$refs.text.value;
|
||||
},
|
||||
|
||||
onTextChange() {
|
||||
this.raw_url = this.$refs.text
|
||||
this.updateText();
|
||||
},
|
||||
|
||||
onCheck() {
|
||||
this.force_media = this.$refs.force_media.checked;
|
||||
this.force_unsafe = this.$refs.force_unsafe.checked;
|
||||
|
||||
this.saveState();
|
||||
},
|
||||
|
||||
onTooltip() {
|
||||
this.force_tooltip = this.$refs.force_tooltip.checked;
|
||||
|
||||
this.saveState();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -194,6 +194,17 @@ export default {
|
|||
this.markSeen(item);
|
||||
|
||||
this.currentItem = item;
|
||||
this.restoredItem = true;
|
||||
|
||||
try {
|
||||
window.history.replaceState({
|
||||
...window.history.state,
|
||||
ffzcc: item.full_key
|
||||
}, document.title)
|
||||
} catch(err) {
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
let current = item;
|
||||
while(current = current.parent) // eslint-disable-line no-cond-assign
|
||||
current.expanded = true;
|
||||
|
|
|
@ -36,6 +36,7 @@ export default class MainMenu extends Module {
|
|||
|
||||
//this.should_enable = true;
|
||||
|
||||
this.exclusive = false;
|
||||
this.new_seen = false;
|
||||
|
||||
this._settings_tree = null;
|
||||
|
@ -198,6 +199,12 @@ export default class MainMenu extends Module {
|
|||
this.off('site.menu_button:clicked', this.dialog.toggleVisible, this.dialog);
|
||||
}
|
||||
|
||||
openExclusive() {
|
||||
this.exclusive = true;
|
||||
this.dialog.exclusive = true;
|
||||
this.enable().then(() => this.dialog.show());
|
||||
}
|
||||
|
||||
runFix(amount) {
|
||||
this.settings.updateContext({
|
||||
force_chat_fix: (this.settings.get('context.force_chat_fix') || 0) + amount
|
||||
|
@ -278,14 +285,41 @@ export default class MainMenu extends Module {
|
|||
const root = this._vue.$children[0],
|
||||
item = root.currentItem,
|
||||
key = item && item.full_key,
|
||||
wants_old = ! root.restoredItem,
|
||||
state = window.history.state,
|
||||
|
||||
tree = this.getSettingsTree();
|
||||
|
||||
root.nav = tree;
|
||||
root.nav_keys = tree.keys;
|
||||
root.currentItem = tree.keys[key] || (this._wanted_page && tree.keys[this._wanted_page]) || (this.has_update ?
|
||||
tree.keys['home.changelog'] :
|
||||
tree.keys['home']);
|
||||
|
||||
let current, restored = true;
|
||||
|
||||
if ( this._wanted_page )
|
||||
current = tree.keys[this._wanted_page];
|
||||
|
||||
if ( ! current && wants_old ) {
|
||||
if ( state?.ffzcc )
|
||||
current = tree.keys[state.ffzcc];
|
||||
if ( ! current ) {
|
||||
const params = new URL(window.location).searchParams,
|
||||
key = params?.get?.('ffz-settings');
|
||||
current = key && tree.keys[key];
|
||||
}
|
||||
if ( ! current )
|
||||
restored = false;
|
||||
}
|
||||
|
||||
if ( ! current )
|
||||
current = tree.keys[key];
|
||||
|
||||
if ( ! current )
|
||||
current = this.has_update ?
|
||||
tree.keys['home.changelog'] :
|
||||
tree.keys['home'];
|
||||
|
||||
root.currentItem = current;
|
||||
root.restoredItem = restored;
|
||||
|
||||
this._wanted_page = null;
|
||||
}
|
||||
|
@ -813,7 +847,26 @@ export default class MainMenu extends Module {
|
|||
getData() {
|
||||
const settings = this.getSettingsTree(),
|
||||
context = this.getContext(),
|
||||
current = (this._wanted_page && settings.keys[this._wanted_page]) || (this.has_update ? settings.keys['home.changelog'] : settings.keys['home']);
|
||||
state = window.history.state;
|
||||
|
||||
let current, restored = true;
|
||||
if ( this._wanted_page )
|
||||
current = settings.keys[this._wanted_page];
|
||||
if ( ! current && state?.ffzcc ) {
|
||||
current = settings.keys[state.ffzcc];
|
||||
if ( ! current )
|
||||
restored = false;
|
||||
} if ( ! current ) {
|
||||
const params = new URL(window.location).searchParams,
|
||||
key = params?.get?.('ffz-settings');
|
||||
current = key && settings.keys[key];
|
||||
if ( ! current )
|
||||
restored = false;
|
||||
}
|
||||
if ( ! current )
|
||||
current = this.has_update ?
|
||||
settings.keys['home.changelog'] :
|
||||
settings.keys['home'];
|
||||
|
||||
this._wanted_page = null;
|
||||
this.markSeen(current);
|
||||
|
@ -834,6 +887,7 @@ export default class MainMenu extends Module {
|
|||
|
||||
nav: settings,
|
||||
currentItem: current,
|
||||
restoredItem: true, // restored, -- Look into making this smoother later.
|
||||
nav_keys: settings.keys,
|
||||
|
||||
has_unseen,
|
||||
|
|
|
@ -77,12 +77,12 @@ export default class TooltipProvider extends Module {
|
|||
this.on(':cleanup', this.cleanup);
|
||||
}
|
||||
|
||||
|
||||
_createInstance(container, klass = 'ffz-tooltip', default_type) {
|
||||
return new Tooltip(container, klass, {
|
||||
html: true,
|
||||
i18n: this.i18n,
|
||||
live: true,
|
||||
check_modifiers: true,
|
||||
|
||||
delayHide: this.checkDelayHide.bind(this, default_type),
|
||||
delayShow: this.checkDelayShow.bind(this, default_type),
|
||||
|
@ -117,7 +117,6 @@ export default class TooltipProvider extends Module {
|
|||
}
|
||||
|
||||
|
||||
|
||||
onFSChange() {
|
||||
const tip_element = document.fullscreenElement || this.container;
|
||||
if ( tip_element !== this.tip_element ) {
|
||||
|
@ -132,6 +131,7 @@ export default class TooltipProvider extends Module {
|
|||
this.tips.cleanup();
|
||||
}
|
||||
|
||||
|
||||
delegatePopperConfig(default_type, target, tip, pop_opts) {
|
||||
const type = target.dataset.tooltipType || default_type,
|
||||
handler = this.types[type];
|
||||
|
|
|
@ -126,11 +126,8 @@ export default class Twilight extends BaseSite {
|
|||
// settings window in exclusive mode.
|
||||
const params = new URL(window.location).searchParams;
|
||||
if ( params ) {
|
||||
if ( params.has('ffz-settings') ) {
|
||||
const main_menu = this.resolve('main_menu');
|
||||
main_menu.dialog.exclusive = true;
|
||||
main_menu.enable();
|
||||
}
|
||||
if ( params.has('ffz-settings') )
|
||||
this.resolve('main_menu').openExclusive();
|
||||
|
||||
if ( params.has('ffz-translate') ) {
|
||||
const translation = this.resolve('translation_ui');
|
||||
|
|
|
@ -31,6 +31,7 @@ export default class Channel extends Module {
|
|||
this.inject('metadata');
|
||||
this.inject('socket');
|
||||
|
||||
|
||||
this.settings.add('channel.panel-tips', {
|
||||
default: true,
|
||||
ui: {
|
||||
|
@ -39,7 +40,10 @@ export default class Channel extends Module {
|
|||
component: 'setting-check-box'
|
||||
},
|
||||
|
||||
changed: () => this.updatePanelTips()
|
||||
changed: val => {
|
||||
this.updatePanelTips();
|
||||
this.css_tweaks.toggle('panel-links', val);
|
||||
}
|
||||
});
|
||||
|
||||
this.settings.add('channel.auto-click-chat', {
|
||||
|
@ -107,6 +111,8 @@ export default class Channel extends Module {
|
|||
onEnable() {
|
||||
this.updateChannelColor();
|
||||
|
||||
this.css_tweaks.toggle('panel-links', this.settings.get('channel.panel-tips'));
|
||||
|
||||
this.on('i18n:update', this.updateLinks, this);
|
||||
|
||||
this.ChannelPanels.on('mount', this.updatePanelTips, this);
|
||||
|
|
|
@ -56,6 +56,7 @@ export default class ChatLine extends Module {
|
|||
async onEnable() {
|
||||
this.on('chat.overrides:changed', id => this.updateLinesByUser(id), this);
|
||||
this.on('chat:update-lines', this.updateLines, this);
|
||||
this.on('i18n:update', this.updateLines, this);
|
||||
|
||||
this.chat.context.on('changed:chat.emoji.style', this.updateLines, this);
|
||||
this.chat.context.on('changed:chat.bits.stack', this.updateLines, this);
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import Module from 'utilities/module';
|
||||
import {timeout, has} from 'utilities/object';
|
||||
import {ALLOWED_ATTRIBUTES, ALLOWED_TAGS} from 'utilities/constants';
|
||||
|
||||
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
|
||||
|
||||
|
@ -14,10 +13,21 @@ export default class RichContent extends Module {
|
|||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('chat');
|
||||
this.inject('i18n');
|
||||
this.inject('site.web_munch');
|
||||
|
||||
this.RichContent = null;
|
||||
this.has_tokenizer = false;
|
||||
}
|
||||
|
||||
async loadTokenizer() {
|
||||
if ( this.has_tokenizer )
|
||||
return;
|
||||
|
||||
this.tokenizer = await import(/* webpack-chunk-name: 'rich_tokens' */ 'utilities/rich_tokens');
|
||||
this.has_tokenizer = true;
|
||||
return this.tokenizer;
|
||||
}
|
||||
|
||||
async onEnable() {
|
||||
|
@ -34,8 +44,12 @@ export default class RichContent extends Module {
|
|||
|
||||
this.state = {
|
||||
loaded: false,
|
||||
error: false
|
||||
error: false,
|
||||
has_tokenizer: t.has_tokenizer
|
||||
}
|
||||
|
||||
if ( ! t.has_tokenizer )
|
||||
t.loadTokenizer().then(() => this.setState({...this.state, has_tokenizer: true}));
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
@ -49,16 +63,26 @@ export default class RichContent extends Module {
|
|||
data = await data;
|
||||
}
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
if ( ! data )
|
||||
data = {
|
||||
error: true,
|
||||
title: t.i18n.t('card.error', 'An error occurred.'),
|
||||
desc_1: t.i18n.t('card.empty', 'No data was returned.')
|
||||
error: {type: 'i18n', key: 'card.empty', phrase: 'No data was returned.'}
|
||||
}
|
||||
|
||||
if ( data.error )
|
||||
data = {
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: ERROR_IMAGE},
|
||||
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
|
||||
subtitle: data.error
|
||||
}
|
||||
};
|
||||
|
||||
this.setState(Object.assign({
|
||||
loaded: true,
|
||||
url: this.props.url
|
||||
url: this.props.url,
|
||||
}, data));
|
||||
|
||||
} catch(err) {
|
||||
|
@ -66,11 +90,15 @@ export default class RichContent extends Module {
|
|||
t.log.capture(err);
|
||||
|
||||
this.setState({
|
||||
has_tokenizer: t.has_tokenizer,
|
||||
loaded: true,
|
||||
error: true,
|
||||
url: this.props.url,
|
||||
title: t.i18n.t('card.error', 'An error occurred.'),
|
||||
desc_1: String(err)
|
||||
short: {
|
||||
type: 'header',
|
||||
image: {type: 'image', url: ERROR_IMAGE},
|
||||
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
|
||||
subtitle: String(err)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -83,7 +111,8 @@ export default class RichContent extends Module {
|
|||
reload() {
|
||||
this.setState({
|
||||
loaded: false,
|
||||
error: false
|
||||
error: false,
|
||||
has_tokenizer: t.has_tokenizer
|
||||
}, () => this.load());
|
||||
}
|
||||
|
||||
|
@ -97,138 +126,83 @@ export default class RichContent extends Module {
|
|||
t.off('chat:update-link-resolver', this.checkReload, this);
|
||||
}
|
||||
|
||||
renderCardImage() {
|
||||
return (<div class={`chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center${this.state.image_square ? ' square' : ''}`}>
|
||||
{this.state.error ?
|
||||
(<img
|
||||
class="chat-card__error-img"
|
||||
src={ERROR_IMAGE}
|
||||
/>) :
|
||||
(<div class="tw-card-img tw-flex-shrink-0 tw-overflow-hidden">
|
||||
<div class="tw-aspect tw-aspect--align-top">
|
||||
<div class="tw-aspect__spacer" style={{paddingTop: '56.25%'}} />
|
||||
{this.state.loaded && this.state.image ?
|
||||
(<img class="tw-image" src={this.state.image} alt={this.state.image_title ?? this.state.title} />)
|
||||
: null}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>)
|
||||
}
|
||||
|
||||
renderTokens(tokens) {
|
||||
let out = [];
|
||||
if ( ! Array.isArray(tokens) )
|
||||
tokens = [tokens];
|
||||
|
||||
for(const token of tokens) {
|
||||
if ( Array.isArray(token) )
|
||||
out = out.concat(this.renderTokens(token));
|
||||
|
||||
else if ( typeof token !== 'object' )
|
||||
out.push(token);
|
||||
|
||||
else if ( token.type === 't' ) {
|
||||
const content = {};
|
||||
if ( token.content )
|
||||
for(const [key,val] of Object.entries(token.content))
|
||||
content[key] = this.renderTokens(val);
|
||||
|
||||
out = out.concat(t.i18n.tList(token.key, token.phrase, content));
|
||||
|
||||
} else {
|
||||
const tag = token.tag || 'span';
|
||||
if ( ! ALLOWED_TAGS.includes(tag) ) {
|
||||
console.log('Skipping disallowed tag', tag);
|
||||
continue;
|
||||
}
|
||||
|
||||
const attrs = {};
|
||||
if ( token.attrs ) {
|
||||
for(const [key,val] of Object.entries(token.attrs)) {
|
||||
if ( ! ALLOWED_ATTRIBUTES.includes(key) && ! key.startsWith('data-') )
|
||||
console.log('Skipping disallowed attribute', key);
|
||||
else
|
||||
attrs[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const el = createElement(tag, {
|
||||
className: token.class,
|
||||
...attrs
|
||||
}, this.renderTokens(token.content));
|
||||
|
||||
out.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
renderCardDescription() {
|
||||
let title = this.state.title,
|
||||
title_tokens = this.state.title_tokens,
|
||||
desc_1 = this.state.desc_1,
|
||||
desc_1_tokens = this.state.desc_1_tokens,
|
||||
desc_2 = this.state.desc_2,
|
||||
desc_2_tokens = this.state.desc_2_tokens;
|
||||
|
||||
if ( ! this.state.loaded ) {
|
||||
desc_1 = t.i18n.t('card.loading', 'Loading...');
|
||||
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
|
||||
}
|
||||
|
||||
return (<div class={`ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex${desc_2 ? ' ffz--two-line' : ''}`}>
|
||||
<div class="tw-full-width tw-pd-l-1">
|
||||
<div class="chat-card__title tw-ellipsis">
|
||||
<span
|
||||
class="tw-strong"
|
||||
data-test-selector="chat-card-title"
|
||||
title={title}
|
||||
>
|
||||
{title_tokens ? this.renderTokens(title_tokens) : title}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw-ellipsis">
|
||||
<span
|
||||
class="tw-c-text-alt-2"
|
||||
data-test-selector="chat-card-description"
|
||||
title={desc_1}
|
||||
>
|
||||
{desc_1_tokens ? this.renderTokens(desc_1_tokens) : desc_1}
|
||||
</span>
|
||||
</div>
|
||||
{(desc_2_tokens || desc_2) && (<div class="tw-ellipsis">
|
||||
<span
|
||||
class="tw-c-text-alt-2"
|
||||
data-test-selector="chat-card-description"
|
||||
title={desc_2}
|
||||
>
|
||||
{desc_2_tokens ? this.renderTokens(desc_2_tokens) : desc_2}
|
||||
</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
|
||||
renderCard() {
|
||||
if ( this.props.renderBody )
|
||||
return this.props.renderBody(this.state, this, createElement);
|
||||
|
||||
if ( this.state.html )
|
||||
return <div dangerouslySetInnerHTML={{__html: this.state.html}} />;
|
||||
return [
|
||||
this.renderUnsafe(),
|
||||
this.renderBody()
|
||||
];
|
||||
}
|
||||
|
||||
renderUnsafe() {
|
||||
if ( ! this.state.unsafe )
|
||||
return null;
|
||||
|
||||
const reasons = Array.from(new Set(this.state.urls.map(url => url.flags).flat())).join(', ').toLowerCase();
|
||||
|
||||
return (<div
|
||||
class="ffz--corner-flag ffz--corner-flag__warn ffz-tooltip ffz-tooltip--no-mouse"
|
||||
data-title={t.i18n.t('tooltip.link-unsafe', "Caution: This URL is on Google's Safe Browsing List for: {reasons}", {reasons})}
|
||||
>
|
||||
<figure class="ffz-i-attention" />
|
||||
</div>);
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
const doc = this.props.force_full ? this.state.full : this.state.short;
|
||||
if ( t.has_tokenizer && this.state.loaded && doc ) {
|
||||
return (<div class="ffz-card-rich tw-full-width tw-overflow-hidden tw-flex tw-flex-column">
|
||||
{t.tokenizer.renderTokens(doc, createElement, {
|
||||
vue: false,
|
||||
tList: (...args) => t.i18n.tList(...args),
|
||||
i18n: t.i18n,
|
||||
|
||||
allow_media: t.chat.context.get('tooltip.link-images'),
|
||||
allow_unsafe: t.chat.context.get('tooltip.link-nsfw-images')
|
||||
})}
|
||||
</div>);
|
||||
|
||||
} else
|
||||
return this.renderBasic();
|
||||
}
|
||||
|
||||
renderBasic() {
|
||||
let title, description;
|
||||
if ( this.state.error ) {
|
||||
title = t.i18n.t('card.error', 'An error occured.');
|
||||
description = this.state.error;
|
||||
|
||||
} else if ( this.state.loaded && this.state.has_tokenizer ) {
|
||||
title = this.state.title;
|
||||
description = this.state.description;
|
||||
} else {
|
||||
description = t.i18n.t('card.loading', 'Loading...');
|
||||
}
|
||||
|
||||
if ( ! title && ! description )
|
||||
description = t.i18n.t('card.empty', 'No data was returned.');
|
||||
|
||||
description = description ? description.split(/\n+/).slice(0,2).map(desc =>
|
||||
<div class="tw-c-text-alt-2 tw-ellipsis tw-mg-x-05" title={desc}>{desc}</div>
|
||||
) : [];
|
||||
|
||||
return [
|
||||
this.renderCardImage(),
|
||||
this.renderCardDescription()
|
||||
<div class="ffz--header-image" />,
|
||||
(<div class="ffz--card-text tw-full-width tw-overflow-hidden tw-flex tw-flex-column tw-justify-content-center">
|
||||
{title && <div class="chat-card__title tw-ellipsis tw-mg-x-05"><span class="tw-strong" title={title}>{title}</span></div>}
|
||||
{description}
|
||||
</div>)
|
||||
];
|
||||
}
|
||||
|
||||
render() {
|
||||
let content = <div class="tw-flex tw-flex-nowrap tw-pd-05">{this.renderCard()}</div>;
|
||||
const tooltip = this.props.card_tooltip && this.state.full && ! this.props.force_full;
|
||||
if ( this.state.url ) {
|
||||
const tooltip = this.props.card_tooltip;
|
||||
content = (<a
|
||||
class={`${tooltip ? 'ffz-tooltip ' : ''}${this.state.accent ? 'ffz-accent-card ' : ''} tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactable--hover-enabled tw-interactive`}
|
||||
class={`${tooltip ? 'ffz-tooltip ' : ''}${this.state.accent ? 'ffz-accent-card ' : ''}${this.state.error ? 'tw-interactable--hover-enabled ': ''} tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactive`}
|
||||
data-tooltip-type="link"
|
||||
data-url={this.state.url}
|
||||
data-is-mail={false}
|
||||
|
@ -241,7 +215,7 @@ export default class RichContent extends Module {
|
|||
}
|
||||
|
||||
return (<div
|
||||
class="tw-border-radius-medium tw-elevation-1 ffz--chat-card"
|
||||
class={`tw-border-radius-medium tw-elevation-1 ffz--chat-card tw-relative${this.state.unsafe ? ' ffz--unsafe' : ''}`}
|
||||
style={{'--ffz-color-accent': this.state.accent || null}}
|
||||
>
|
||||
<div class="tw-border-radius-medium tw-c-background-base tw-flex tw-full-width">
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
.channel-panels {
|
||||
.default-panel {
|
||||
& > .tw-link {
|
||||
display: block;
|
||||
|
||||
& > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -72,7 +72,9 @@ body .whispers--theatre-mode.whispers--right-column-expanded-beside {
|
|||
right: 0 !important;
|
||||
}
|
||||
|
||||
.channel-root__scroll-area--theatre-mode .channel-info-bar {
|
||||
left: calc(var(--ffz-chat-width) + 5rem) !important;
|
||||
right: 25rem !important;
|
||||
.channel-root__scroll-area--theatre-mode {
|
||||
.channel-info-content > div:first-child, .channel-info-bar {
|
||||
left: calc(var(--ffz-chat-width) + 5rem) !important;
|
||||
right: 40rem !important;
|
||||
}
|
||||
}
|
|
@ -1517,6 +1517,9 @@ export default class Player extends Module {
|
|||
if ( ! this.settings.get('player.theatre.auto-enter') || ! inst._ffz_mounted )
|
||||
return;
|
||||
|
||||
if ( this.router.current_name === 'user-home' )
|
||||
return;
|
||||
|
||||
if ( inst.props.channelHomeLive || inst.props.channelHomeCarousel || inst.props.theatreModeEnabled )
|
||||
return;
|
||||
|
||||
|
|
|
@ -53,6 +53,28 @@ export default class ThemeEngine extends Module {
|
|||
changed: () => this.updateCSS()
|
||||
});
|
||||
|
||||
this.settings.add('theme.color.tooltip.background', {
|
||||
default: '',
|
||||
ui: {
|
||||
path: 'Appearance > Theme >> Colors',
|
||||
title: 'Tooltip Background',
|
||||
description: 'If not set, the tooltip settings will be automatically adjusted based on the brightness of the background.',
|
||||
component: 'setting-color-box',
|
||||
alpha: true
|
||||
},
|
||||
changed: () => this.updateCSS()
|
||||
});
|
||||
|
||||
this.settings.add('theme.color.tooltip.text', {
|
||||
default: '',
|
||||
ui: {
|
||||
path: 'Appearance > Theme >> Colors',
|
||||
title: 'Tooltip Text',
|
||||
component: 'setting-color-box'
|
||||
},
|
||||
changed: () => this.updateCSS()
|
||||
});
|
||||
|
||||
this.settings.add('theme.dark', {
|
||||
requires: ['theme.is-dark'],
|
||||
default: false,
|
||||
|
@ -176,6 +198,37 @@ The CSS loaded by this setting is far too heavy and can cause performance issues
|
|||
bits.push(`--color-text-alt-2: ${hsla._a(alpha - 0.4).toCSS()};`);
|
||||
}
|
||||
|
||||
// Tooltips
|
||||
let tooltip_bg = Color.RGBA.fromCSS(this.settings.get('theme.color.tooltip.background')),
|
||||
tooltip_dark;
|
||||
if ( ! tooltip_bg && background )
|
||||
tooltip_bg = Color.RGBA.fromCSS(dark ? '#FFF' : '#000');
|
||||
|
||||
if ( tooltip_bg ) {
|
||||
bits.push(`--color-background-tooltip: ${tooltip_bg.toCSS()};`);
|
||||
|
||||
const hsla = tooltip_bg.toHSLA(),
|
||||
luma = hsla.l;
|
||||
|
||||
tooltip_dark = luma < 0.5;
|
||||
} else
|
||||
tooltip_dark = ! dark;
|
||||
|
||||
let tooltip_text = Color.RGBA.fromCSS(this.settings.get('theme.color.tooltip.text'));
|
||||
const has_tt_text = tooltip_text || tooltip_bg;
|
||||
if ( ! tooltip_text )
|
||||
tooltip_text = Color.RGBA.fromCSS(tooltip_dark ? '#FFF' : '#000');
|
||||
|
||||
if ( tooltip_text ) {
|
||||
if ( has_tt_text )
|
||||
bits.push(`--color-text-tooltip: ${tooltip_text.toCSS()};`);
|
||||
|
||||
const hsla = tooltip_text.toHSLA(),
|
||||
alpha = hsla.a;
|
||||
|
||||
bits.push(`--color-text-tooltip-alt: ${hsla._a(alpha - 0.2).toCSS()};`);
|
||||
bits.push(`--color-text-tooltip-alt-2: ${hsla._a(alpha - 0.4).toCSS()};`);
|
||||
}
|
||||
|
||||
if ( bits.length ) {
|
||||
this.css_tweaks.set('colors', `body {${bits.join('\n')}}`);
|
||||
|
|
|
@ -34,18 +34,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.channel-panels {
|
||||
.default-panel {
|
||||
& > .tw-link {
|
||||
display: block;
|
||||
|
||||
& > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tw-root--theme-ffz, .tw-root--theme-ffz.tw-root--theme-dark, .tw-root--theme-dark, body {
|
||||
.ffz-stat > .tw-button--text,
|
||||
.ffz-stat.tw-button--text {
|
||||
|
|
|
@ -66,14 +66,6 @@
|
|||
border-right: .5rem solid var(--ffz-color-accent);
|
||||
}
|
||||
|
||||
.chat-card__preview-img.square {
|
||||
width: 4.5rem;
|
||||
|
||||
.tw-aspect__spacer {
|
||||
padding-top: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-card__title {
|
||||
max-width: unset;
|
||||
}
|
||||
|
|
|
@ -22,8 +22,10 @@ export default {
|
|||
|
||||
md.use(MILA, {
|
||||
attrs: {
|
||||
class: 'ffz-tooltip',
|
||||
target: '_blank',
|
||||
rel: 'noopener'
|
||||
rel: 'noopener',
|
||||
'data-tooltip-type': 'link'
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -18,17 +18,11 @@ export const LV_SERVER = 'https://cbenni.com/api';
|
|||
export const LV_SOCKET_SERVER = 'wss://cbenni.com/socket.io/';
|
||||
|
||||
|
||||
export const ALLOWED_TAGS = [
|
||||
'strong', 'em', 'i', 'b', 'time', 'br', 'hr', 'div', 'span', 'img', 'figure', 'p', 'a', 'video', 'audio', 'blockquote', 'heading', 'section', 'nav', 'footer', 'aside', 'article', 'source', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||
];
|
||||
|
||||
export const ALLOWED_ATTRIBUTES = [
|
||||
'datetime', 'src', 'href', 'style', 'alt', 'title', 'height', 'width', 'srcset', 'autoplay', 'volume', 'muted', 'loop', 'poster', 'type'
|
||||
];
|
||||
|
||||
|
||||
export const KEYS = {
|
||||
Enter: 13,
|
||||
Shift: 16,
|
||||
Control: 17,
|
||||
Alt: 18,
|
||||
Escape: 27,
|
||||
Space: 32,
|
||||
PageUp: 33,
|
||||
|
@ -38,7 +32,9 @@ export const KEYS = {
|
|||
ArrowLeft: 37,
|
||||
ArrowUp: 38,
|
||||
ArrowRight: 39,
|
||||
ArrowDown: 40
|
||||
ArrowDown: 40,
|
||||
Meta: 91,
|
||||
Context: 93
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -3,11 +3,13 @@ query FFZ_FetchUser($id: ID, $login: String) {
|
|||
id
|
||||
login
|
||||
displayName
|
||||
description
|
||||
profileImageURL(width: 50)
|
||||
profileViewCount
|
||||
primaryColorHex
|
||||
broadcastSettings {
|
||||
id
|
||||
title
|
||||
game {
|
||||
id
|
||||
displayName
|
||||
|
@ -15,6 +17,7 @@ query FFZ_FetchUser($id: ID, $login: String) {
|
|||
}
|
||||
stream {
|
||||
id
|
||||
previewImageURL
|
||||
}
|
||||
followers {
|
||||
totalCount
|
||||
|
|
|
@ -13,7 +13,7 @@ const ATTRS = [
|
|||
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
|
||||
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
|
||||
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
|
||||
'minlength', 'media', 'method', 'min', 'multiple', 'muted', 'name',
|
||||
'minlength', 'media', 'method', 'min', 'multiple', 'name',
|
||||
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
|
||||
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
|
||||
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
|
||||
|
@ -22,6 +22,10 @@ const ATTRS = [
|
|||
'title', 'type', 'usemap', 'value', 'width', 'wrap'
|
||||
];
|
||||
|
||||
const BOOLEAN_ATTRS = [
|
||||
'controls', 'autoplay', 'loop'
|
||||
];
|
||||
|
||||
|
||||
const range = document.createRange();
|
||||
|
||||
|
@ -95,8 +99,12 @@ export function createElement(tag, props, ...children) {
|
|||
el.style.cssText = prop;
|
||||
else
|
||||
for(const k in prop)
|
||||
if ( has(prop, k) )
|
||||
el.style[k] = prop[k];
|
||||
if ( has(prop, k) ) {
|
||||
if ( has(el.style, k) )
|
||||
el.style[k] = prop[k];
|
||||
else
|
||||
el.style.setProperty(k, prop[k]);
|
||||
}
|
||||
|
||||
} else if ( lk === 'dataset' ) {
|
||||
for(const k in prop)
|
||||
|
@ -114,11 +122,16 @@ export function createElement(tag, props, ...children) {
|
|||
else if ( lk.startsWith('data-') )
|
||||
el.dataset[camelCase(lk.slice(5))] = prop;
|
||||
|
||||
else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
|
||||
else if ( BOOLEAN_ATTRS.includes(lk) ) {
|
||||
if ( prop && prop !== 'false' )
|
||||
el.setAttribute(key, prop);
|
||||
console.log('bool-attr', key, prop);
|
||||
|
||||
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
|
||||
el.setAttribute(key, prop);
|
||||
|
||||
else
|
||||
el[key] = props[key];
|
||||
el[key] = prop;
|
||||
}
|
||||
|
||||
if ( children )
|
||||
|
|
|
@ -97,5 +97,8 @@ export default [
|
|||
"viewers",
|
||||
"move",
|
||||
"chat-empty",
|
||||
"chat"
|
||||
"chat",
|
||||
"location",
|
||||
"link",
|
||||
"volume-off"
|
||||
];
|
|
@ -540,6 +540,51 @@ export function glob_to_regex(input) {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Truncate a string. Tries to intelligently break the string in white-space
|
||||
* if possible, without back-tracking. The returned string can be up to
|
||||
* `ellipsis.length + target + overage` characters long.
|
||||
* @param {String} str The string to truncate.
|
||||
* @param {Number} target The target length for the result
|
||||
* @param {Number} overage Accept up to this many additional characters for a better result
|
||||
* @param {String} [ellipsis='…'] The string to append when truncating
|
||||
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
|
||||
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
|
||||
* @returns {String} The truncated string
|
||||
*/
|
||||
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
|
||||
if ( ! str || ! str.length )
|
||||
return str;
|
||||
|
||||
if ( trim )
|
||||
str = str.trim();
|
||||
|
||||
let idx = break_line ? str.indexOf('\n') : -1;
|
||||
if ( idx === -1 || idx > target )
|
||||
idx = target;
|
||||
|
||||
if ( str.length <= idx )
|
||||
return str;
|
||||
|
||||
let out = str.slice(0, idx).trimRight();
|
||||
if ( overage > 0 && out.length >= idx ) {
|
||||
let next_space = str.slice(idx).search(/\s+/);
|
||||
if ( next_space === -1 && overage + idx > str.length )
|
||||
next_space = str.length - idx;
|
||||
|
||||
if ( next_space !== -1 && next_space <= overage ) {
|
||||
if ( str.length <= (idx + next_space) )
|
||||
return str;
|
||||
|
||||
out = str.slice(0, idx + next_space);
|
||||
}
|
||||
}
|
||||
|
||||
return out + ellipsis;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class SourcedSet {
|
||||
constructor() {
|
||||
this._cache = [];
|
||||
|
|
946
src/utilities/rich_tokens.js
Normal file
946
src/utilities/rich_tokens.js
Normal 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);
|
||||
}
|
|
@ -42,10 +42,14 @@ export class Tooltip {
|
|||
|
||||
this.options = Object.assign({}, DefaultOptions, options);
|
||||
this.live = this.options.live;
|
||||
this.check_modifiers = this.options.check_modifiers;
|
||||
|
||||
this.parent = parent;
|
||||
this.cls = cls;
|
||||
|
||||
if ( this.check_modifiers )
|
||||
this.installModifiers();
|
||||
|
||||
if ( ! this.live ) {
|
||||
if ( typeof cls === 'string' )
|
||||
this.elements = parent.querySelectorAll(cls);
|
||||
|
@ -65,16 +69,18 @@ export class Tooltip {
|
|||
|
||||
this._accessor = `_ffz_tooltip$${last_id++}`;
|
||||
|
||||
this._onMouseOut = e => this._exit(e.target);
|
||||
this._onMouseOut = e => e.target && e.target.dataset.forceOpen !== 'true' && this._exit(e.target);
|
||||
|
||||
if ( this.options.manual ) {
|
||||
// Do nothing~!
|
||||
|
||||
} else if ( this.live ) {
|
||||
this._onMouseOver = e => {
|
||||
this.updateShift(e.shiftKey);
|
||||
const target = e.target;
|
||||
if ( target && target.classList && target.classList.contains(this.cls) )
|
||||
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) {
|
||||
this._enter(target);
|
||||
}
|
||||
};
|
||||
|
||||
parent.addEventListener('mouseover', this._onMouseOver);
|
||||
|
@ -82,9 +88,11 @@ export class Tooltip {
|
|||
|
||||
} else {
|
||||
this._onMouseOver = e => {
|
||||
this.updateShift(e.shiftKey);
|
||||
const target = e.target;
|
||||
if ( this.elements.has(target) )
|
||||
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
|
||||
this._enter(e.target);
|
||||
}
|
||||
}
|
||||
|
||||
if ( this.elements.size <= 5 )
|
||||
|
@ -102,6 +110,8 @@ export class Tooltip {
|
|||
}
|
||||
|
||||
destroy() {
|
||||
this.removeModifiers();
|
||||
|
||||
if ( this.options.manual ) {
|
||||
// Do nothing~!
|
||||
} else if ( this.live || this.elements.size > 5 ) {
|
||||
|
@ -128,6 +138,43 @@ export class Tooltip {
|
|||
}
|
||||
|
||||
|
||||
installModifiers() {
|
||||
if ( this._keyUpdate )
|
||||
return;
|
||||
|
||||
this._keyUpdate = e => this.updateShift(e.shiftKey);
|
||||
window.addEventListener('keydown', this._keyUpdate);
|
||||
window.addEventListener('keyup', this._keyUpdate);
|
||||
}
|
||||
|
||||
removeModifiers() {
|
||||
if ( ! this._keyUpdate )
|
||||
return;
|
||||
|
||||
window.removeEventListener('keydown', this._keyUpdate);
|
||||
window.removeEventListener('keyup', this._keyUpdate);
|
||||
this._keyUpdate = null;
|
||||
}
|
||||
|
||||
updateShift(state) {
|
||||
if ( state === this.shift_state )
|
||||
return;
|
||||
|
||||
this.shift_state = state;
|
||||
if ( ! this._shift_af )
|
||||
this._shift_af = requestAnimationFrame(() => {
|
||||
this._shift_af = null;
|
||||
for(const el of this.elements) {
|
||||
const tip = el[this._accessor];
|
||||
if ( tip && tip.outer ) {
|
||||
tip.outer.dataset.shift = this.shift_state;
|
||||
tip.update();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
cleanup() {
|
||||
if ( this.options.manual )
|
||||
return;
|
||||
|
@ -238,7 +285,8 @@ export class Tooltip {
|
|||
inner = tip.element = createElement('div', opts.innerClass),
|
||||
|
||||
el = tip.outer = createElement('div', {
|
||||
className: opts.tooltipClass
|
||||
className: opts.tooltipClass,
|
||||
'data-shift': this.shift_state
|
||||
}, [inner, arrow]);
|
||||
|
||||
arrow.setAttribute('x-arrow', true);
|
||||
|
@ -259,6 +307,7 @@ export class Tooltip {
|
|||
if ( ! opts.manual || (hover_events && (opts.onHover || opts.onLeave || opts.onMove)) ) {
|
||||
if ( hover_events && opts.onMove )
|
||||
el.addEventListener('mousemove', el._ffz_move_handler = event => {
|
||||
this.updateShift(event.shiftKey);
|
||||
opts.onMove(target, tip, event);
|
||||
});
|
||||
|
||||
|
@ -273,7 +322,7 @@ export class Tooltip {
|
|||
/* no-op */
|
||||
} else if ( maybe_call(opts.interactive, null, target, tip) )
|
||||
this._enter(target);
|
||||
else
|
||||
else if ( target.dataset.forceOpen !== 'true' )
|
||||
this._exit(target);
|
||||
});
|
||||
|
||||
|
@ -281,7 +330,7 @@ export class Tooltip {
|
|||
if ( hover_events && opts.onLeave )
|
||||
opts.onLeave(target, tip, event);
|
||||
|
||||
if ( ! opts.manual )
|
||||
if ( ! opts.manual && target.dataset.forceOpen !== 'true' )
|
||||
this._exit(target);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -50,8 +50,15 @@ export const DEFAULT_TYPES = {
|
|||
},
|
||||
|
||||
number(val, node) {
|
||||
if ( typeof val !== 'number' )
|
||||
return val;
|
||||
if ( typeof val !== 'number' ) {
|
||||
let new_val = parseInt(val, 10);
|
||||
if ( isNaN(new_val) || ! isFinite(new_val) )
|
||||
new_val = parseFloat(val);
|
||||
if ( isNaN(new_val) || ! isFinite(new_val) )
|
||||
return val;
|
||||
|
||||
val = new_val;
|
||||
}
|
||||
|
||||
return this.formatNumber(val, node.f);
|
||||
},
|
||||
|
@ -105,6 +112,14 @@ export const DEFAULT_FORMATS = {
|
|||
year: '2-digit'
|
||||
},
|
||||
|
||||
default: {},
|
||||
|
||||
medium: {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
},
|
||||
|
||||
long: {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
|
@ -131,13 +146,6 @@ export const DEFAULT_FORMATS = {
|
|||
second: 'numeric'
|
||||
},
|
||||
|
||||
long: {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
timeZoneName: 'short'
|
||||
},
|
||||
|
||||
full: {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
|
@ -155,14 +163,7 @@ export const DEFAULT_FORMATS = {
|
|||
minute: 'numeric'
|
||||
},
|
||||
|
||||
medium: {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
year: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
},
|
||||
medium: {},
|
||||
|
||||
long: {
|
||||
month: 'long',
|
||||
|
@ -201,6 +202,10 @@ export default class TranslationCore {
|
|||
this.defaultLocale = options.defaultLocale || this._locale;
|
||||
this.transformation = null;
|
||||
|
||||
this.defaultDateFormat = options.defaultDateFormat;
|
||||
this.defaultTimeFormat = options.defaultTimeFormat;
|
||||
this.defaultDateTimeFormat = options.defaultDateTimeFormat;
|
||||
|
||||
this.phrases = new Map;
|
||||
this.cache = new Map;
|
||||
|
||||
|
@ -235,15 +240,14 @@ export default class TranslationCore {
|
|||
return thing;
|
||||
}
|
||||
|
||||
formatRelativeTime(value) { // eslint-disable-line class-methods-use-this
|
||||
if ( !(value instanceof Date) )
|
||||
value = new Date(Date.now() + value * 1000);
|
||||
formatRelativeTime(value, f) { // eslint-disable-line class-methods-use-this
|
||||
const d = dayjs(value),
|
||||
without_suffix = f === 'plain';
|
||||
|
||||
const d = dayjs(value);
|
||||
try {
|
||||
return d.locale(this._locale).fromNow(true);
|
||||
return d.locale(this._locale).fromNow(without_suffix);
|
||||
} catch(err) {
|
||||
return d.fromNow(true);
|
||||
return d.fromNow(without_suffix);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -262,13 +266,15 @@ export default class TranslationCore {
|
|||
}
|
||||
|
||||
formatDate(value, format) {
|
||||
if ( typeof format === 'string' && format.startsWith('::') ) {
|
||||
const f = format.substr(2),
|
||||
d = dayjs(value);
|
||||
if ( ! format )
|
||||
format = this.defaultDateFormat;
|
||||
|
||||
if ( format && ! this.formats.date[format] ) {
|
||||
const d = dayjs(value);
|
||||
try {
|
||||
return d.locale(this._locale).format(f);
|
||||
return d.locale(this._locale).format(format);
|
||||
} catch(err) {
|
||||
return d.format(f);
|
||||
return d.format(format);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -279,13 +285,15 @@ export default class TranslationCore {
|
|||
}
|
||||
|
||||
formatTime(value, format) {
|
||||
if ( typeof format === 'string' && format.startsWith('::') ) {
|
||||
const f = format.substr(2),
|
||||
d = dayjs(value);
|
||||
if ( ! format )
|
||||
format = this.defaultTimeFormat;
|
||||
|
||||
if ( format && ! this.formats.time[format] ) {
|
||||
const d = dayjs(value);
|
||||
try {
|
||||
return d.locale(this._locale).format(f);
|
||||
return d.locale(this._locale).format(format);
|
||||
} catch(err) {
|
||||
return d.format(f);
|
||||
return d.format(format);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -296,13 +304,15 @@ export default class TranslationCore {
|
|||
}
|
||||
|
||||
formatDateTime(value, format) {
|
||||
if ( typeof format === 'string' && format.startsWith('::') ) {
|
||||
const f = format.substr(2),
|
||||
d = dayjs(value);
|
||||
if ( ! format )
|
||||
format = this.defaultDateTimeFormat;
|
||||
|
||||
if ( format && ! this.formats.datetime[format] ) {
|
||||
const d = dayjs(value);
|
||||
try {
|
||||
return d.locale(this._locale).format(f);
|
||||
return d.locale(this._locale).format(format);
|
||||
} catch(err) {
|
||||
return d.format(f);
|
||||
return d.format(format);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -194,6 +194,9 @@ export class Vue extends Module {
|
|||
const router = t.resolve('site.router');
|
||||
return router.getURL(route, data, opts, ...args);
|
||||
},
|
||||
getI18n() {
|
||||
return t.i18n;
|
||||
},
|
||||
t(key, phrase, options) {
|
||||
return this.$i18n.t_(key, phrase, options);
|
||||
},
|
||||
|
|
254
styles/chat.scss
254
styles/chat.scss
|
@ -26,6 +26,260 @@
|
|||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.ffz--line-clamp {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: var(--ffz-lines);
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ffz--rich-header {
|
||||
line-height: 1.4;
|
||||
|
||||
margin-left: -0.5rem;
|
||||
margin-right: -0.5rem;
|
||||
}
|
||||
|
||||
.ffz--overlay {
|
||||
position: relative;
|
||||
|
||||
& > :not(.ffz--overlay__content) {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
background-color: var(--color-background-overlay);
|
||||
color: var(--color-text-overlay);
|
||||
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ffz--overlay__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ffz--overlay__bit {
|
||||
max-width: calc(100% - 1rem);
|
||||
|
||||
&[data-side="top-left"],
|
||||
&[data-side="top"],
|
||||
&[data-side="top-right"] {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
&[data-side="bottom-left"],
|
||||
&[data-side="bottom"],
|
||||
&[data-side="bottom-right"] {
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&[data-side="top-left"],
|
||||
&[data-side="left"],
|
||||
&[data-side="bottom-left"] {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
&[data-side="top-right"],
|
||||
&[data-side="right"],
|
||||
&[data-side="bottom-right"] {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
&[data-side="left"], &[data-side="right"] {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
&[data-side="top"], &[data-side="bottom"] {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
&[data-side="center"] {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.ffz--rich-gallery & {
|
||||
&[data-side="top-left"],
|
||||
&[data-side="top"],
|
||||
&[data-side="top-right"] {
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
&[data-side="bottom-left"],
|
||||
&[data-side="bottom"],
|
||||
&[data-side="bottom-right"] {
|
||||
bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.ffz--header-image {
|
||||
height: 4.8rem;
|
||||
max-width: 25%;
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.ffz--header-aspect img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--compact-header .ffz--header-image {
|
||||
height: 2.4rem;
|
||||
}
|
||||
|
||||
.ffz--rich-gallery, .ffz--compact-header {
|
||||
&:not(:first-child) {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ffz--corner-flag {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-color: transparent;
|
||||
border-style: solid;
|
||||
border-width: 0 3em 3em 0;
|
||||
z-index: 100;
|
||||
|
||||
figure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -2.75em;
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--corner-flag__warn {
|
||||
border-right-color: #f33;
|
||||
color: #fff;
|
||||
|
||||
.tw-root--theme-dark & {
|
||||
border-right-color: #900;
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--fields {
|
||||
display: flex;
|
||||
margin-top: -.5rem;
|
||||
margin-left: -.5rem;
|
||||
flex-flow: row wrap;
|
||||
|
||||
.ffz--field {
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ffz--field-inline {
|
||||
flex: 1;
|
||||
width: unset;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--twitter-badge {
|
||||
height: 1.2rem;
|
||||
width: 1.2rem;
|
||||
background: url('//cdn.frankerfacez.com/static/twitter_sprites.png');
|
||||
display: inline-block;
|
||||
margin: 2px 0 -1px 0.5rem;
|
||||
|
||||
&.ffz--twitter-badge__verified {
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
|
||||
&.ffz--twitter-badge__translator {
|
||||
background-position: -12px -15px;
|
||||
}
|
||||
|
||||
&.ffz--twitter-badge__protected {
|
||||
background-position: -24px -15px;
|
||||
width: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--rich-gallery {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
max-height: 350px;
|
||||
|
||||
display: grid;
|
||||
grid-column: 1/2;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 4px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
&[data-items="1"] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.ffz--gallery-column {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ffz--gallery-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
margin-top: -4px;
|
||||
max-height: calc(100% + 4px);
|
||||
|
||||
&:only-child {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 3;
|
||||
|
||||
img, video {
|
||||
object-fit:contain;
|
||||
}
|
||||
|
||||
.tw-aspect { img, video { object-fit: cover; } }
|
||||
}
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&[data-items="2"] > * {
|
||||
min-height: calc(50% - 2px);
|
||||
}
|
||||
|
||||
&[data-items="1"] > * {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
img, video {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ffz-badge {
|
||||
display: inline-block;
|
||||
|
|
|
@ -65,6 +65,9 @@
|
|||
.ffz-i-comp-off:before { content: '\e83f'; } /* '' */
|
||||
.ffz-i-viewers:before { content: '\e840'; } /* '' */
|
||||
.ffz-i-chat:before { content: '\e841'; } /* '' */
|
||||
.ffz-i-location:before { content: '\e842'; } /* '' */
|
||||
.ffz-i-link:before { content: '\e843'; } /* '' */
|
||||
.ffz-i-volume-off:before { content: '\e845'; } /* '' */
|
||||
.ffz-i-move:before { content: '\f047'; } /* '' */
|
||||
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
|
||||
.ffz-i-twitter:before { content: '\f099'; } /* '' */
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -65,6 +65,9 @@
|
|||
.ffz-i-comp-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-viewers { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-chat { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-location { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-link { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-volume-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-move { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
|
|
|
@ -76,6 +76,9 @@
|
|||
.ffz-i-comp-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-viewers { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-chat { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-location { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-link { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-volume-off { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-move { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-link-ext { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
.ffz-i-twitter { *zoom: expression( this.runtimeStyle['zoom'] = '1', this.innerHTML = ' '); }
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
@font-face {
|
||||
font-family: 'ffz-fontello';
|
||||
src: url('../font/ffz-fontello.eot?13031397');
|
||||
src: url('../font/ffz-fontello.eot?13031397#iefix') format('embedded-opentype'),
|
||||
url('../font/ffz-fontello.woff2?13031397') format('woff2'),
|
||||
url('../font/ffz-fontello.woff?13031397') format('woff'),
|
||||
url('../font/ffz-fontello.ttf?13031397') format('truetype'),
|
||||
url('../font/ffz-fontello.svg?13031397#ffz-fontello') format('svg');
|
||||
src: url('../font/ffz-fontello.eot?81632949');
|
||||
src: url('../font/ffz-fontello.eot?81632949#iefix') format('embedded-opentype'),
|
||||
url('../font/ffz-fontello.woff2?81632949') format('woff2'),
|
||||
url('../font/ffz-fontello.woff?81632949') format('woff'),
|
||||
url('../font/ffz-fontello.ttf?81632949') format('truetype'),
|
||||
url('../font/ffz-fontello.svg?81632949#ffz-fontello') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
|||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
@font-face {
|
||||
font-family: 'ffz-fontello';
|
||||
src: url('../font/ffz-fontello.svg?13031397#ffz-fontello') format('svg');
|
||||
src: url('../font/ffz-fontello.svg?81632949#ffz-fontello') format('svg');
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
@ -121,6 +121,9 @@
|
|||
.ffz-i-comp-off:before { content: '\e83f'; } /* '' */
|
||||
.ffz-i-viewers:before { content: '\e840'; } /* '' */
|
||||
.ffz-i-chat:before { content: '\e841'; } /* '' */
|
||||
.ffz-i-location:before { content: '\e842'; } /* '' */
|
||||
.ffz-i-link:before { content: '\e843'; } /* '' */
|
||||
.ffz-i-volume-off:before { content: '\e845'; } /* '' */
|
||||
.ffz-i-move:before { content: '\f047'; } /* '' */
|
||||
.ffz-i-link-ext:before { content: '\f08e'; } /* '' */
|
||||
.ffz-i-twitter:before { content: '\f099'; } /* '' */
|
||||
|
|
|
@ -181,299 +181,24 @@ body {
|
|||
text-align: left;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
|
||||
/*.stats:after, .heading:after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.tweet-heading {
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png") -38px -15px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
.title {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: justify;
|
||||
}
|
||||
}
|
||||
|
||||
.display-name {
|
||||
padding-top: 3px;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
|
||||
&.big-name {
|
||||
font-size: 20px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.quoted {
|
||||
.display-name {
|
||||
padding: 0 5px 0 0;
|
||||
display: inline;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
float: left;
|
||||
margin-right: 8px;
|
||||
max-height: 48px;
|
||||
max-width: 100px;
|
||||
}*/
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
.ffz--chat-card,
|
||||
.ffz-rich-tip {
|
||||
.body { line-height: 1.5em }
|
||||
.ffz__tooltip {
|
||||
--color-border-base: var(--color-text-tooltip);
|
||||
|
||||
.tweet-heading:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
width: 20px;
|
||||
height: 16px;
|
||||
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png") -38px -15px;
|
||||
&:not([data-shift="true"]) .ffz--shift-show,
|
||||
&[data-shift="true"] .ffz--shift-hide { display: none !important; }
|
||||
|
||||
.tw-c-text-base {
|
||||
color: var(--color-text-tooltip) !important;
|
||||
}
|
||||
|
||||
.stats:after, .heading:after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
.tw-c-text-alt {
|
||||
color: var(--color-text-tooltip-alt) !important;
|
||||
}
|
||||
.avatar {
|
||||
float: left;
|
||||
margin-right: 8px;
|
||||
max-height: 48px;
|
||||
max-width: 100px;
|
||||
}
|
||||
.tweet-heading .avatar {
|
||||
border-radius: 50%;
|
||||
}
|
||||
.heading .title {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-align: justify;
|
||||
}
|
||||
.display-name {
|
||||
padding-top: 3px;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
&.big-name {
|
||||
font-size: 20px;
|
||||
padding-top: 18px;
|
||||
}
|
||||
}
|
||||
.quoted .display-name {
|
||||
padding: 0 5px 0 0;
|
||||
display: inline;
|
||||
font-size: 12px;
|
||||
}
|
||||
.twitch-heading {
|
||||
.tip-badge.verified {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
margin: 2px 0 -1px 5px;
|
||||
background: url("https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/1");
|
||||
background-size: contain;
|
||||
display: inline-block;
|
||||
}
|
||||
.big-name .tip-badge.verified {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
margin: 1px 0 0 10px;
|
||||
}
|
||||
}
|
||||
.tweet-heading .tip-badge {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
margin: 2px 0 -1px 5px;
|
||||
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png");
|
||||
display: inline-block;
|
||||
}
|
||||
.tip-badge {
|
||||
&.verified {
|
||||
background-position: 0 -15px;
|
||||
}
|
||||
&.translator {
|
||||
background-position: -12px -15px;
|
||||
}
|
||||
&.protected {
|
||||
background-position: -24px -15px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
.emoji {
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.quoted > div:not(:first-child), > div:not(:first-child) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.quote-heading + .body {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.replying {
|
||||
+ .body {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
}
|
||||
.subtitle, .quoted .body, .stats, .username {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
.wide-stat {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
time {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.tweet-stats .stat:before {
|
||||
.tw-root--theme-dark & {
|
||||
filter: invert(100%);
|
||||
}
|
||||
|
||||
content: '';
|
||||
display: inline-block;
|
||||
background: url("//cdn.frankerfacez.com/script/twitter_sprites.png");
|
||||
margin: 0 5px 0 10px;
|
||||
}
|
||||
.stat {
|
||||
&.likes:before {
|
||||
width: 17px;
|
||||
height: 14px;
|
||||
margin-bottom: -3px;
|
||||
}
|
||||
&.retweets:before {
|
||||
width: 20px;
|
||||
height: 12px;
|
||||
background-position: -34px 0;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
}
|
||||
.media {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
&[data-count]:not([data-count="1"]) {
|
||||
display: flex;
|
||||
margin: 8px -5px -5px 0;
|
||||
flex-flow: column wrap;
|
||||
height: 329px;
|
||||
}
|
||||
.duration {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 4px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
opacity: .8;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
.sixteen-nine {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding-top: 56.25%;
|
||||
position: relative;
|
||||
img {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
.media {
|
||||
video {
|
||||
width: 100%;
|
||||
max-height: 324px;
|
||||
}
|
||||
&[data-count="4"] {
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
&[data-count="2"] {
|
||||
height: 164.5px;
|
||||
}
|
||||
img {
|
||||
max-height: 324px;
|
||||
}
|
||||
}
|
||||
.quoted .media {
|
||||
&[data-count]:not([data-count="1"]), &[data-count="2"] {
|
||||
height: 150px;
|
||||
}
|
||||
video, img {
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
.media {
|
||||
span {
|
||||
background: no-repeat center center;
|
||||
background-size: cover;
|
||||
}
|
||||
&[data-count="2"] span, &[data-count="3"] span, &[data-count="4"] span {
|
||||
width: calc(50% - 5px);
|
||||
height: calc(50% - 5px);
|
||||
margin: 0 5px 5px 0;
|
||||
}
|
||||
&[data-count="2"] span, &[data-count="3"] span:first-of-type {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
div {
|
||||
flex-grow: 1;
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
.quoted {
|
||||
border: 1px solid #474747;
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
.media[data-type="video"]:after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
top: calc(50% - 32px);
|
||||
left: calc(50% - 32px);
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath fill='%23FFF' d='M15 10.001c0 .299-.305.514-.305.514l-8.561 5.303C5.51 16.227 5 15.924 5 15.149V4.852c0-.777.51-1.078 1.135-.67l8.561 5.305c-.001 0 .304.215.304.514z'/%3E%3C/svg%3E");
|
||||
.tw-c-text-alt-2 {
|
||||
color: var(--color-text-tooltip-alt-2) !important;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue