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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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