1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-29 07:45:33 +00:00

Add support for asynchronously loaded rich content blocks in chat.

This commit is contained in:
SirStendec 2018-04-03 19:28:06 -04:00
parent 9aafff3a14
commit d1acc9f7cc
9 changed files with 419 additions and 5 deletions

View file

@ -1,3 +1,8 @@
<div class="list-header">4.0.0-beta1.10<span>@77498dc31e57b48d0549</span> <time datetime="2018-04-03">(2018-04-03)</time></div>
<ul class="chat-menu-content menu-side-padding">
<li>Added: Rendering support for rich content blocks in chat.</li>
</ul>
<div class="list-header">4.0.0-beta1.9<span>@b27c86408c133765e687</span> <time datetime="2018-04-03">(2018-04-03)</time></div> <div class="list-header">4.0.0-beta1.9<span>@b27c86408c133765e687</span> <time datetime="2018-04-03">(2018-04-03)</time></div>
<ul class="chat-menu-content menu-side-padding"> <ul class="chat-menu-content menu-side-padding">
<li>Added: Option to stop the player from automatically playing the recommended video after a video finishes.</li> <li>Added: Option to stop the player from automatically playing the recommended video after a video finishes.</li>

View file

@ -95,7 +95,7 @@ class FrankerFaceZ extends Module {
FrankerFaceZ.Logger = Logger; FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = { const VER = FrankerFaceZ.version_info = {
major: 4, minor: 0, revision: 0, extra: '-beta1.9', major: 4, minor: 0, revision: 0, extra: '-beta1.10',
build: __webpack_hash__, build: __webpack_hash__,
toString: () => toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}` `${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}`

View file

@ -0,0 +1,20 @@
query FFZ_GetClipInfo($slug: ID!) {
clip(slug: $slug) {
id
curator {
id
displayName
}
broadcaster {
id
displayName
}
game {
id
displayName
}
title
thumbnailURL(width: 86, height: 45)
viewCount
}
}

View file

@ -14,6 +14,7 @@ import Emotes from './emotes';
import Room from './room'; import Room from './room';
import User from './user'; import User from './user';
import * as TOKENIZERS from './tokenizers'; import * as TOKENIZERS from './tokenizers';
import * as RICH_PROVIDERS from './rich_providers';
export default class Chat extends Module { export default class Chat extends Module {
@ -45,11 +46,33 @@ export default class Chat extends Module {
this.tokenizers = {}; this.tokenizers = {};
this.__tokenizers = []; this.__tokenizers = [];
this.rich_providers = {};
this.__rich_providers = [];
// ======================================================================== // ========================================================================
// Settings // Settings
// ======================================================================== // ========================================================================
this.settings.add('chat.rich.enabled', {
default: true,
ui: {
path: 'Chat > Appearance >> Rich Content',
title: 'Display rich content in chat.',
description: 'This displays rich content blocks for things like linked clips and videos.',
component: 'setting-check-box'
}
});
this.settings.add('chat.rich.hide-tokens', {
default: true,
ui: {
path: 'Chat > Appearance >> Rich Content',
title: 'Hide matching links for rich content.',
component: 'setting-check-box'
}
});
this.settings.add('chat.scrollback-length', { this.settings.add('chat.scrollback-length', {
default: 150, default: 150,
ui: { ui: {
@ -252,6 +275,10 @@ export default class Chat extends Module {
for(const key in TOKENIZERS) for(const key in TOKENIZERS)
if ( has(TOKENIZERS, key) ) if ( has(TOKENIZERS, key) )
this.addTokenizer(TOKENIZERS[key]); this.addTokenizer(TOKENIZERS[key]);
for(const key in RICH_PROVIDERS)
if ( has(RICH_PROVIDERS, key) )
this.addRichProvider(RICH_PROVIDERS[key]);
} }
@ -410,7 +437,22 @@ export default class Chat extends Module {
this.__tokenizers.sort((a, b) => { this.__tokenizers.sort((a, b) => {
if ( a.priority > b.priority ) return -1; if ( a.priority > b.priority ) return -1;
if ( a.priority < b.priority ) return 1; if ( a.priority < b.priority ) return 1;
return a.type < b.sort; return a.type < b.type;
});
}
addRichProvider(provider) {
const type = provider.type;
this.rich_providers[type] = provider;
if ( provider.priority == null )
provider.priority = 0;
this.__rich_providers.push(provider);
this.__rich_providers.sort((a,b) => {
if ( a.priority > b.priority ) return -1;
if ( a.priority < b.priority ) return 1;
return a.type < b.type;
}); });
} }
@ -425,6 +467,22 @@ export default class Chat extends Module {
} }
pluckRichContent(tokens) { // eslint-disable-line class-methods-use-this
if ( ! this.context.get('chat.rich.enabled') )
return;
const providers = this.__rich_providers;
for(const token of tokens) {
for(const provider of providers)
if ( provider.test.call(this, token) ) {
token.hidden = this.context.get('chat.rich.hide-tokens') && provider.hide_token;
return provider.process.call(this, token);
}
}
}
tokenizeMessage(msg, user) { tokenizeMessage(msg, user) {
if ( msg.content && ! msg.message ) if ( msg.content && ! msg.message )
msg.message = msg.content.text; msg.message = msg.content.text;

View file

@ -0,0 +1,138 @@
'use strict';
// ============================================================================
// Rich Content Providers
// ============================================================================
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
//const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
import GET_CLIP from './clip_info.gql';
//import GET_VIDEO from './video_info.gql';
// ============================================================================
// Clips
// ============================================================================
export const Clips = {
type: 'clip',
hide_token: true,
test(token) {
return token.type === 'link' && CLIP_URL.test(token.url)
},
process(token) {
const match = CLIP_URL.exec(token.url),
apollo = this.resolve('site.apollo');
if ( ! apollo || ! match )
return;
return {
getData: async () => {
const result = await apollo.client.query({
query: GET_CLIP,
variables: {
slug: match[1]
}
});
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;
if ( game_name === 'creative' )
desc_1 = this.i18n.t('clip.desc.1.creative', '%{user} being Creative', {
user
});
else if ( game )
desc_1 = this.i18n.t('clip.desc.1.playing', '%{user} playing %{game}', {
user,
game: game_display
});
else
desc_1 = this.i18n.t('clip.desc.1', 'Clip of %{user}', {user});
return {
url: token.url,
image: clip.thumbnailURL,
title: clip.title,
desc_1,
desc_2: this.i18n.t('clip.desc.2', 'Clipped by %{curator} — %{views|number} View%{views|en_plural}', {
curator: clip.curator.displayName,
views: clip.viewCount
})
}
}
}
}
}
/*export const Videos = {
type: 'video',
hide_token: true,
test(token) {
return token.type === 'link' && VIDEO_URL.test(token.url)
},
process(token) {
const match = VIDEO_URL.exec(token.url),
apollo = this.resolve('site.apollo');
if ( ! apollo || ! match )
return;
return {
getData: async () => {
const result = await apollo.client.query({
query: GET_VIDEO,
variables: {
id: match[1]
}
});
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;
if ( game_name === 'creative' )
desc_1 = this.i18n.t('clip.desc.1.creative', '%{user} being Creative', {
user
});
else if ( game )
desc_1 = this.i18n.t('clip.desc.1.playing', '%{user} playing %{game}', {
user,
game: game_display
});
else
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_2: this.i18n.t('video.desc.2', '%{length} %{views} Views - %{date}', {
length: video.lengthSeconds,
views: video.viewCount,
date: video.publishedAt
})/
}
}
}
}
}*/

View file

@ -0,0 +1,18 @@
query FFZ_GetVideoInfo($id: ID!) {
video(id: $id) {
id
title
previewThumbnailURL(width: 86, height: 45)
lengthSeconds
publishedAt
viewCount
game {
id
displayName
}
owner {
id
displayName
}
}
}

View file

@ -8,6 +8,8 @@ import Twilight from 'site';
import Module from 'utilities/module'; import Module from 'utilities/module';
//import {Color} from 'utilities/color'; //import {Color} from 'utilities/color';
import RichContent from './rich_content';
const SUB_TIERS = { const SUB_TIERS = {
1000: 1, 1000: 1,
2000: 2, 2000: 2,
@ -23,6 +25,7 @@ export default class ChatLine extends Module {
this.inject('chat'); this.inject('chat');
this.inject('site.fine'); this.inject('site.fine');
this.inject('site.web_munch'); this.inject('site.web_munch');
this.inject(RichContent);
this.ChatLine = this.fine.define( this.ChatLine = this.fine.define(
'chat-line', 'chat-line',
@ -43,13 +46,16 @@ export default class ChatLine extends Module {
this.chat.context.on('changed:chat.badges.hidden', this.updateLines, this); this.chat.context.on('changed:chat.badges.hidden', this.updateLines, this);
this.chat.context.on('changed:chat.badges.custom-mod', this.updateLines, this); this.chat.context.on('changed:chat.badges.custom-mod', this.updateLines, this);
this.chat.context.on('changed:chat.rituals.show', this.updateLines, this); this.chat.context.on('changed:chat.rituals.show', this.updateLines, this);
this.chat.context.on('changed:chat.rich.enabled', this.updateLines, this);
this.chat.context.on('changed:chat.rich.hide-tokens', this.updateLines, this);
const t = this, const t = this,
React = this.web_munch.getModule('react'); React = this.web_munch.getModule('react');
if ( ! React ) if ( ! React )
return; return;
const e = React.createElement; const e = React.createElement,
FFZRichContent = this.rich_content && this.rich_content.RichContent;
this.ChatLine.ready(cls => { this.ChatLine.ready(cls => {
cls.prototype.shouldComponentUpdate = function(props, state) { cls.prototype.shouldComponentUpdate = function(props, state) {
@ -101,7 +107,12 @@ export default class ChatLine extends Module {
if ( ! msg.message && msg.messageParts ) if ( ! msg.message && msg.messageParts )
detokenizeMessage(msg); detokenizeMessage(msg);
const tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, {login: this.props.currentUserLogin, display: this.props.currentUserDisplayName}); const u = {
login: this.props.currentUserLogin,
display: this.props.currentUserDisplayName
},
tokens = msg.ffz_tokens = msg.ffz_tokens || t.chat.tokenizeMessage(msg, u),
rich_content = FFZRichContent && t.chat.pluckRichContent(tokens, msg);
let cls = 'chat-line__message', let cls = 'chat-line__message',
out = (tokens.length || ! msg.ffz_type) ? [ out = (tokens.length || ! msg.ffz_type) ? [
@ -136,6 +147,8 @@ export default class ChatLine extends Module {
onClick: this.alwaysShowMessage onClick: this.alwaysShowMessage
}, `<message deleted>`)), }, `<message deleted>`)),
show && rich_content && e(FFZRichContent, rich_content),
/*this.state.renderDebug === 2 && e('div', { /*this.state.renderDebug === 2 && e('div', {
className: 'border mg-t-05' className: 'border mg-t-05'
}, old_render.call(this)), }, old_render.call(this)),

View file

@ -0,0 +1,155 @@
'use strict';
// ============================================================================
// RichContent Component
// ============================================================================
import Module from 'utilities/module';
import {timeout} from 'utilities/object';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
export default class RichContent extends Module {
constructor(...args) {
super(...args);
this.inject('i18n');
this.inject('site.web_munch');
this.RichContent = null;
}
onEnable() {
const t = this,
React = this.web_munch.getModule('react');
if ( ! React )
return;
const createElement = React.createElement;
this.RichContent = class RichContent extends React.Component {
constructor(props) {
super(props);
this.state = {
loaded: false,
error: false
}
}
async componentDidMount() {
try {
let data = this.props.getData();
if ( data instanceof Promise ) {
const to_wait = this.props.timeout || 1000;
if ( to_wait )
data = await timeout(data, to_wait);
else
data = await data;
}
this.setState(Object.assign({
loaded: true
}, data));
} catch(err) {
this.setState({
loaded: true,
error: true,
title: t.i18n.t('card.error', 'An error occured.'),
desc_1: String(err)
});
}
}
renderCardImage() {
return (<div class="chat-card__preview-img tw-c-background-alt-2 tw-align-items-center tw-flex tw-flex-shrink-0 tw-justify-content-center">
<div class="tw-card-img tw-flex-shrink-0 tw-flex tw-justify-content-center">
{this.state.error ?
(<img
class="chat-card__error-img"
data-test-selector="chat-card-error"
src={ERROR_IMAGE}
/>) :
(<figure class="tw-aspect tw-aspect--16x9 tw-aspect--align-top">
{this.state.loaded && this.state.image ?
(<img
class="tw-image"
src={this.state.image}
alt={this.state.title}
/>)
: null}
</figure>)}
</div>
</div>)
}
renderCardDescription() {
let title = this.state.title,
desc_1 = this.state.desc_1,
desc_2 = this.state.desc_2;
if ( ! this.state.loaded ) {
desc_1 = t.i18n.t('card.loading', 'Loading...');
desc_2 = '';
title = '';
}
return (<div class={`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-font-size-5"
data-test-selector="chat-card-title"
title={title}
>
{title}
</span>
</div>
<div class="tw-ellipsis">
<span
class="tw-c-text-alt-2 tw-font-size-6"
data-test-selector="chat-card-description"
title={desc_1}
>
{desc_1}
</span>
</div>
{desc_2 && (<div class="tw-ellipsis">
<span
class="tw-c-text-alt-2 tw-font-size-6"
data-test-selector="chat-card-description"
title={desc_2}
>
{desc_2}
</span>
</div>)}
</div>
</div>)
}
renderCard() {
return (<div class="ffz--chat-card tw-elevation-1 tw-mg-t">
<div class="tw-c-background tw-flex tw-flex-nowrap tw-pd-05">
{this.renderCardImage()}
{this.renderCardDescription()}
</div>
</div>)
}
render() {
if ( ! this.state.url )
return this.renderCard();
return (<a
class="chat-card__link"
target="_blank"
rel="noreferer noopener"
href={this.state.url}
>
{this.renderCard()}
</a>);
}
}
}
}

View file

@ -19,11 +19,18 @@
flex-shrink: 0; flex-shrink: 0;
} }
} }
span { span {
overflow-x: hidden; overflow-x: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
} }
}
.ffz--chat-card {
.ffz--two-line {
.tw-ellipsis { line-height: 1.4rem }
.chat-card__title { line-height: 1.5rem }
}
} }