1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00
* Added: Link Cards. As an option, you can open a preview card when clicking on links in chat. These preview cards function similarly to the existing tool-tip or rich embed options but can provide for enhanced interaction (e.g. an embedded video player), with potential for more in the future.
* Changed: When using a custom theme with a dark background, use lighter border colors.
* Changed: Draw the FFZ Control Center and other dialogs with rounded corners.
* Fixed: Issue when clicking certain global Twitch emotes preventing the emote card from appearing correctly.
* Fixed: Issue with URL/safety data not being loaded correctly from the link service when the overall result was an error.
* Fixed: Issue with link tool-tips still appearing, but with no content, when link tool-tips are disabled.
* Fixed: Issue where (re)subscription notices in chat for multiple-month-at-once subscriptions would not be displayed correctly.
* Fixed: Tool-tips not displaying correctly in chat pop-outs in some circumstances.
* Fixed: Incorrect border styles when the chat is in portrait mode.
* Experiment Added: Set up an MQTT-based PubSub system. Let's see how well this scales.
This commit is contained in:
SirStendec 2023-09-26 17:40:25 -04:00
parent d01f66c6f3
commit 98e5373e9a
36 changed files with 1554 additions and 92 deletions

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.54.0",
"version": "4.55.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",
@ -73,6 +73,7 @@
"sortablejs": "^1.14.0",
"sourcemapped-stacktrace": "^1.1.11",
"text-diff": "^1.0.1",
"u8-mqtt": "^0.5.3",
"vue": "^2.6.14",
"vue-clickaway": "^2.2.2",
"vue-color": "^2.8.1",

17
pnpm-lock.yaml generated
View file

@ -69,6 +69,9 @@ dependencies:
text-diff:
specifier: ^1.0.1
version: 1.0.1
u8-mqtt:
specifier: ^0.5.3
version: 0.5.3
vue:
specifier: ^2.6.14
version: 2.6.14
@ -2985,7 +2988,7 @@ packages:
dev: true
/fs.realpath@1.0.0:
resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
/fsevents@2.3.2:
@ -3359,7 +3362,7 @@ packages:
dev: true
/inflight@1.0.6:
resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=}
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
dependencies:
once: 1.4.0
wrappy: 1.0.2
@ -4180,7 +4183,7 @@ packages:
dev: true
/once@1.4.0:
resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
wrappy: 1.0.2
dev: true
@ -5320,6 +5323,10 @@ packages:
is-typed-array: 1.1.12
dev: true
/u8-mqtt@0.5.3:
resolution: {integrity: sha512-C9eaN2/kxtmMhLVrKT8Yk6a3pRj12K+nNpylDqUn/rKYwAaMEUnvXNWqd4QMd/EaKKcMxpeA9cyCU8DlUOvKsw==}
dev: false
/uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
dev: false
@ -5371,7 +5378,7 @@ packages:
dev: true
/util-deprecate@1.0.2:
resolution: {integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=}
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
/utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
@ -5841,7 +5848,7 @@ packages:
dev: true
/wrappy@1.0.2:
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
dev: true
/ws@8.13.0:

View file

@ -14,5 +14,13 @@
{"value": true, "weight": 30},
{"value": false, "weight": 70}
]
},
"pubsub": {
"name": "MQTT-Based PubSub",
"description": "An experimental new pubsub system that should be more reliable than the existing socket cluster.",
"groups": [
{"value": true, "weight": 50},
{"value": false, "weight": 50}
]
}
}

View file

@ -14,7 +14,7 @@ import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import SocketClient from './socket';
//import PubSubClient from './pubsub';
import PubSubClient from './pubsub';
import Site from 'site';
import Vue from 'utilities/vue';
import StagingSelector from './staging';
@ -61,7 +61,7 @@ class FrankerFaceZ extends Module {
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('socket', SocketClient);
//this.inject('pubsub', PubSubClient);
this.inject('pubsub', PubSubClient);
this.inject('site', Site);
this.inject('addons', AddonManager);

View file

@ -1,5 +1,6 @@
<script>
import {FFZEvent} from 'utilities/events';
import {has, timeout} from 'utilities/object';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
@ -8,13 +9,14 @@ let tokenizer;
export default {
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia', 'forceMid'],
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia', 'forceMid', 'noLink', 'noTooltip', 'noElevation', 'noUnsafe'],
data() {
return {
has_tokenizer: false,
loaded: false,
version: null,
player_state: {},
fragments: {},
error: null,
accent: null,
@ -56,14 +58,40 @@ export default {
this.listen();
this.load();
this.handle_click = this.handleClick.bind(this);
},
beforeDestroy() {
this.unlisten();
this.clearRefresh();
this.handle_click = null;
},
methods: {
handleClick(event) {
if ( ! this.events.emit || event.ctrlKey || event.shiftKey )
return;
const target = event.currentTarget,
ds = target?.dataset;
if ( ! ds )
return;
const evt = new FFZEvent({
url: ds.url ?? target.href,
source: event
});
this.events.emit('chat:click-link', evt);
if ( evt.defaultPrevented ) {
event.preventDefault();
return true;
}
},
async loadTokenizer() {
if ( tokenizer )
this.has_tokenizer = true;
@ -105,6 +133,7 @@ export default {
reset(refresh = false) {
this.clearRefresh();
this.player_state = {};
this.loaded = false;
this.error = null;
this.version = null;
@ -202,7 +231,7 @@ export default {
},
renderUnsafe(h) {
if ( ! this.unsafe )
if ( ! this.unsafe || this.noUnsafe )
return null;
const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', ');
@ -226,8 +255,13 @@ export default {
},
renderBody(h) {
let body = this.forceFull ? this.full :
this.forceMid ? this.mid : this.short;
let body;
if ( this.forceFull === true || (this.forceFull !== false && this.full) )
body = this.full;
else if ( this.forceMid === true || (this.forceMid !== false && this.mid) )
body = this.mid;
else
body = this.short;
if ( this.has_tokenizer && this.version && this.version > tokenizer.VERSION )
body = null;
@ -240,6 +274,15 @@ export default {
tList: (...args) => this.tList(...args),
i18n: this.getI18n(),
last_player: 0,
player_state: this.player_state,
togglePlayer: id => {
this.$set(this.player_state, id, ! this.player_state[id]);
},
link_click_handler: this.handle_click,
fragments: this.fragments,
i18n_prefix: this.i18n_prefix,
@ -295,12 +338,12 @@ export default {
render(h) {
let content = h('div', {
class: 'tw-flex tw-flex-nowrap tw-pd-05'
class: 'tw-flex tw-flex-nowrap tw-full-width tw-pd-05'
}, this.renderCard(h));
const tooltip = this.has_full && ! this.forceFull;
const tooltip = ! this.noTooltip && this.has_full && ! this.forceFull;
if ( this.url )
if ( this.url && ! this.noLink )
content = h('a', {
class: [
tooltip && 'ffz-tooltip',
@ -308,6 +351,9 @@ export default {
!this.error && 'ffz-interactable--hover-enabled',
'tw-block tw-border-radius-medium tw-full-width ffz-interactable ffz-interactable--default tw-interactive'
],
on: {
click: this.handleClick
},
attrs: {
'data-tooltip-type': 'link',
'data-url': this.url,
@ -329,7 +375,8 @@ export default {
return h('div', {
class: [
'tw-border-radius-medium tw-elevation-1 ffz--chat-card tw-relative',
'tw-border-radius-medium ffz--chat-card tw-relative',
this.noElevation ? '' : 'tw-elevation-1',
this.unsafe ? 'ffz--unsafe' : ''
],
style: {

View file

@ -7,9 +7,11 @@
import dayjs from 'dayjs';
import Module from 'utilities/module';
import {createElement, ManagedStyle} from 'utilities/dom';
import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars} from 'utilities/object';
import {Color} from 'utilities/color';
import {createElement, ManagedStyle} from 'utilities/dom';
import {FFZEvent} from 'utilities/events';
import {getFontsList} from 'utilities/fonts';
import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars} from 'utilities/object';
import Badges from './badges';
import Emotes from './emotes';
@ -23,7 +25,7 @@ import * as RICH_PROVIDERS from './rich_providers';
import * as LINK_PROVIDERS from './link_providers';
import Actions from './actions/actions';
import { getFontsList } from 'src/utilities/fonts';
function sortPriorityColorTerms(list) {
list.sort((a,b) => {
@ -83,6 +85,7 @@ export default class Chat extends Module {
// Bind for JSX stuff
this.clickToReveal = this.clickToReveal.bind(this);
this.handleLinkClick = this.handleLinkClick.bind(this);
this.handleMentionClick = this.handleMentionClick.bind(this);
this.handleReplyClick = this.handleReplyClick.bind(this);
@ -1266,6 +1269,7 @@ export default class Chat extends Module {
onEnable() {
this.socket = this.resolve('socket');
this.pubsub = this.resolve('pubsub');
this.on('site.subpump:pubsub-message', this.onPubSub, this);
@ -1503,6 +1507,31 @@ export default class Chat extends Module {
}
handleLinkClick(event) {
if ( event.ctrlKey || event.shiftKey )
return;
const target = event.currentTarget,
ds = target?.dataset;
if ( ! ds )
return;
const evt = new FFZEvent({
url: ds.url ?? target.href,
source: event
});
this.emit('chat:click-link', evt);
if ( evt.defaultPrevented ) {
event.preventDefault();
event.stopPropagation();
return true;
}
}
handleReplyClick(event) {
const target = event.target,
fine = this.resolve('site.fine');
@ -2318,7 +2347,9 @@ export default class Chat extends Module {
image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
subtitle: data.error
}
},
unsafe: data.unsafe,
urls: data.urls
}
if ( data.v < 5 && ! data.short && ! data.full && (data.title || data.desc_1 || data.desc_2) ) {

View file

@ -32,6 +32,9 @@ export default class Room {
if ( id )
this.manager.room_ids[id] = this;
if ( id && this.manager.pubsub )
this.manager.pubsub.subscribe(this, `twitch/${id}/chat`);
this.manager.emit(':room-add', this);
this.load_data();
}
@ -81,6 +84,9 @@ export default class Room {
this.manager.socket.unsubscribe(this, `room.${this.login}`);
}
if ( this._id && this.manager.pubsub )
this.manager.pubsub.unsubscribe(this, `twitch/${this._id}/chat`);
if ( this.manager.room_ids[this._id] === this )
this.manager.room_ids[this._id] = null;
}

View file

@ -6,6 +6,7 @@
import {sanitize, createElement} from 'utilities/dom';
import {has, getTwitchEmoteURL, split_chars, getTwitchEmoteSrcSet} from 'utilities/object';
import { NoContent } from 'utilities/tooltip';
import {EmoteTypes, REPLACEMENT_BASE, REPLACEMENTS, WEIRD_EMOTE_SIZES} from 'utilities/constants';
import {CATEGORIES, JOINER_REPLACEMENT} from './emoji';
@ -80,12 +81,13 @@ export const Links = {
rel="noopener noreferrer"
target="_blank"
href={token.url}
onClick={this.handleLinkClick}
>{token.text}</a>);
},
tooltip(target, tip) {
if ( ! this.context.get('tooltip.rich-links') && ! target.dataset.forceTooltip )
return '';
return NoContent;
if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})];

View file

@ -4,6 +4,7 @@
:class="{'tw-pd-b-05': expanded}"
>
<div
v-if="! noHeader"
class="tw-flex tw-align-items-center tw-c-background-alt-2 tw-pd-y-05 tw-pd-x-1 ffz--cursor"
@click="toggle"
>
@ -59,12 +60,13 @@ export default {
props: [
'emote',
'getFFZ'
'getFFZ',
'noHeader'
],
data() {
return {
expanded: false,
expanded: this.noHeader ? true : false,
loading: false,
error: false,
presence: null,
@ -72,6 +74,11 @@ export default {
}
},
created() {
if ( this.expanded && ! this.collections )
this.loadCollections();
},
methods: {
toggle() {
this.expanded = ! this.expanded;

View file

@ -6,7 +6,7 @@
import {createElement} from 'utilities/dom';
import {deep_copy, getTwitchEmoteURL} from 'utilities/object';
import { EmoteTypes } from 'utilities/constants';
import { EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS } from 'utilities/constants';
import GET_EMOTE from './twitch_data.gql';
@ -181,7 +181,21 @@ export default class EmoteCard extends Module {
//console.log("loaded data", data);
const type = getEmoteTypeFromTwitchType(data.type);
let type = getEmoteTypeFromTwitchType(data.type);
let set;
try {
set = parseInt(data.setID, 10);
} catch(err) { /* no-op */ }
if ( TWITCH_GLOBAL_SETS.includes(set) )
type = EmoteTypes.Global;
else if ( TWITCH_POINTS_SETS.includes(set) )
type = EmoteTypes.ChannelPoints;
else if ( TWITCH_PRIME_SETS.includes(set) )
type = EmoteTypes.Prime;
//console.log('loaded data', data, type);
if ( type === EmoteTypes.Subscription ) {
const products = data.owner?.subscriptionProducts;
@ -197,7 +211,7 @@ export default class EmoteCard extends Module {
source = this.i18n.t('emote-card.sub', 'Tier {tier} Sub Emote ({source})', {
tier: tier,
source: data.owner.displayName || data.owner.login
source: data.owner?.displayName || data.owner?.login
});
body = 'twitch';

View file

@ -0,0 +1,525 @@
<template>
<div
:style="{zIndex: z, '--ffz-color-accent': accent}"
class="ffz-viewer-card tw-border tw-border-radius-medium tw-c-background-base tw-c-text-base tw-elevation-2 tw-flex tw-flex-column viewer-card ffz-accent-card"
tabindex="0"
@focusin="onFocus"
@keyup.esc="close"
>
<div
class="ffz-viewer-card__header tw-border-radius-medium tw-c-background-accent-alt tw-flex-grow-0 tw-flex-shrink-0 viewer-card__background tw-relative"
>
<div
v-if="isUnsafe"
class="ffz--corner-flag ffz--corner-flag--left ffz--corner-flag__warn ffz-tooltip ffz-tooltip--no-mouse tw-border-top-left-radius-medium"
:data-title="unsafeTip"
>
<figure class="ffz-i-attention" />
</div>
<div class="tw-flex tw-flex-column tw-full-height tw-full-width viewer-card__overlay">
<div
class="tw-align-center tw-border-radius-medium tw-align-items-center tw-c-background-alt tw-c-text-base tw-flex tw-flex-grow-1 tw-flex-row tw-full-width tw-justify-content-start tw-pd-05 tw-relative viewer-card__banner"
:class="{'tw-pd-l-3': isUnsafe}"
>
<div class="tw-align-left tw-flex-grow-1 tw-ellipsis tw-mg-l-05 tw-mg-y-05 viewer-card__display-name">
<p class="tw-font-size-6 tw-ellipsis" :title="url">
<span class="tw-c-text-alt-2">{{ urlPrefix }}</span><span>{{ urlDomain }}</span><span class="tw-c-text-alt-2">{{ urlPath }}</span>
</p>
</div>
<div class="tw-flex tw-align-self-start">
<a
:data-title="t('link-card.open-ext', 'Open Link')"
:data-url="targetUrl"
:href="targetUrl"
class="ffz--cursor viewer-card-drag-cancel tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
rel="noreferrer noopener"
target="_blank"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-link-ext" />
</span>
</a>
<div
v-if="hasMoreActions"
v-on-clickaway="closeMore"
class="tw-relative viewer-card-drag-cancel"
>
<button
:data-title="t('emote-card.more', 'More')"
:aria-label="t('emote-card.more', 'More')"
class="tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="toggleMore"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-ellipsis-vert" />
</span>
</button>
<balloon
v-if="moreOpen"
color="background-alt-2"
dir="down-right"
size="sm"
class="tw-border-radius-medium"
>
<simplebar classes="ffz-mh-30">
<div class="tw-pd-y-05">
<template v-for="(entry, idx) in moreActions">
<div
v-if="entry.divider"
:key="idx"
class="tw-mg-1 tw-border-b"
/>
<a
:key="idx"
:disabled="entry.disabled"
:href="entry.href"
rel="noopener noreferrer"
target="_blank"
class="tw-block ffz-interactable ffz-interactable--hover-enabled ffz-interactable--default tw-interactive tw-full-width ffz--cursor"
@click="clickMore(entry, $event)"
>
<div class="tw-flex tw-align-items-center tw-pd-y-05 tw-pd-x-1">
<div
class="tw-flex-grow-1"
:class="{'tw-mg-r-1' : !! entry.icon}"
>
{{ entry.title_i18n ? t(entry.title_i18n, entry.title, entry) : entry.title }}
</div>
<figure
v-if="entry.icon || entry.type === 'link'"
:class="entry.icon || 'ffz-i-link-ext'"
/>
</div>
</a>
</template>
</div>
</simplebar>
</balloon>
</div>
<button
:data-title="t('emote-card.close', 'Close')"
:aria-label="t('emote-card.close', 'Close')"
class="viewer-card-drag-cancel tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="close"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
</div>
</div>
</div>
</div>
<section class="tw-c-background-body">
<div class="viewer-card__tabs-container tw-border-t">
<div
v-for="(d, key) in tabs"
:id="`link-card__${key}`"
:key="key"
:class="{
active: active_tab === key,
'tw-inline-flex': !! d.pill,
'tw-align-items-center': !! d.pill
}"
class="viewer-card__tab tw-pd-x-1"
@click="active_tab = key"
>
<span>{{ d.label_i18n ? t(d.label_i18n, d.label, d) : d.label }}</span>
<span v-if="d.pill" class="tw-mg-l-05 ffz-pill" :class="d.pill_classes || ''">{{ d.pill_i18n ? t(d.pill_i18n, d.pill, d) : d.pill }}</span>
</div>
</div>
</section>
<keep-alive>
<chat-rich
v-if="rich_data && active_tab === 'preview'"
:data="rich_data"
:url="url"
:events="events"
:no-unsafe="true"
:no-elevation="true"
:no-tooltip="true"
:no-link="true"
/>
</keep-alive>
<keep-alive>
<ManageFFZ
v-if="active_tab === 'manage' && ffzEmote"
:emote="ffzEmote"
:getFFZ="getFFZ"
:no-header="true"
/>
</keep-alive>
<div
class="tw-c-background-base tw-pd-05"
v-if="active_tab === 'urls'"
>
<table v-if="embed && embed.urls && embed.urls.length">
<tbody
v-for="(url, idx) in embed.urls"
:key="idx"
>
<tr>
<td class="tw-c-text-alt-2">{{ tNumber(idx + 1) }}.</td>
<td class="tw-pd-x-05 tw-word-break-all">
<a
:data-url="url.url"
:href="url.url"
rel="noreferrer noopener"
target="_blank"
class="ffz-link--inherit"
>
<lc-url :url="url.url" :show-protocol="true" />
</a>
</td>
</tr>
<tr v-if="url.shortened || (url.flags && url.flags.length)">
<td>&nbsp;</td>
<td class="tw-pd-x-05">
<span
v-if="url.shortened"
class="ffz-pill"
>{{ t('link-card.shortened', 'shortened') }}</span>
<span
v-if="url.flags"
v-for="flag in url.flags"
class="ffz-pill ffz-pill--live"
>{{ flag }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import {deep_copy, sha256} from 'utilities/object';
import displace from 'displacejs';
import ManageFFZ from '../../emote_card/components/manage-ffz.vue';
export default {
components: {
ManageFFZ,
'chat-rich': async () => {
const stuff = await import(/* webpackChunkName: "chat" */ 'src/modules/chat/components');
return stuff.default('./chat-rich.vue').default;
}
},
props: [
'url', 'data',
'pos_x', 'pos_y',
'getZ', 'getFFZ',
'use_dest'
],
data() {
const token = {
type: 'link',
force_rich: true,
is_mail: false,
url: this.url,
text: this.url
};
const chat = this.getFFZ().resolve('chat');
return {
z: this.getZ(),
active_tab: 'preview',
moreOpen: false,
rich_data: chat.rich_providers.link.process.call(chat, token),
url_hash: null,
loaded: false,
errored: false,
pinned: false,
embed: null,
events: {
on: (...args) => this.getFFZ().on(...args),
off: (...args) => this.getFFZ().off(...args),
emit: (...args) => this.getFFZ().emit(...args)
}
}
},
computed: {
isUnsafe() {
return this.embed?.unsafe;
},
ffzEmote() {
if ( this.embed?.special?.type !== 'ffz-emote' )
return null;
return {
id: this.embed.special.id
}
},
unsafeTip() {
if ( ! Array.isArray(this.embed?.urls) )
return null;
const reasons = Array.from(new Set(this.embed.urls.map(url => url.flags).flat())).join(', ');
return this.t(
'tooltip.link-unsafe',
'Caution: This URL is has been flagged as potentially harmful by: {reasons}',
{
reasons
}
)
},
tabs() {
const tabs = {
preview: {
label: 'Preview',
label_i18n: 'link-card.preview'
}
};
if ( this.ffzEmote?.id )
tabs.manage = {
label: 'Manage Emote',
label_i18n: 'link-card.manage-emote'
};
if ( Array.isArray(this.embed?.urls) ) {
tabs.urls = {
label: 'Visited URLs',
label_i18n: 'tooltip.link.urls'
};
if ( this.embed.urls.length > 1 ) {
tabs.urls.pill = this.tNumber(this.embed.urls.length);
if ( this.embed.unsafe )
tabs.urls.pill_classes = ['ffz-pill--live'];
}
}
return tabs;
},
accent() {
return this.embed?.accent
},
_url() {
if ( this.url instanceof URL )
return this.url;
return new URL(this.url);
},
targetUrl() {
const urls = this.use_dest ? this.embed?.urls : null;
if ( Array.isArray(urls) )
for(const url of urls) {
if ( ! url.shortened )
return url.url;
}
return this.url;
},
urlPrefix() {
return null;
//return this._url.protocol;
},
urlDomain() {
return this._url.host;
},
urlPath() {
return this._url.toString().slice(this._url.origin.length);
},
moreActions() {
const actions = [];
/*if ( this.url_hash && this.vt_key )
actions.push({
type: 'virus-total',
title_i18n: 'link-card.virus-check',
title: 'Check URL on VirusTotal',
icon: 'ffz-i-flag'
});*/
if ( Array.isArray(this.embed?.actions) )
for(const act of this.embed.actions)
actions.push(act);
return actions;
},
hasMoreActions() {
return (this.moreActions?.length ?? 0) > 0;
},
},
beforeMount() {
this.ffzEmit(':open', this);
sha256(this.url).then(hash => {
this.url_hash = hash;
});
this.data.then(data => {
this.loaded = true;
this.ffzEmit(':load', this);
this.embed = deep_copy(data);
this.$nextTick(() => this.handleResize());
}).catch(err => {
console.error('Error loading link card data', err);
this.errored = true;
});
},
mounted() {
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
this.createDrag();
},
beforeDestroy() {
this.ffzEmit(':close', this);
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
},
methods: {
toggleMore() {
this.moreOpen = ! this.moreOpen;
},
closeMore() {
this.moreOpen = false;
},
clickMore(entry, evt) {
this.moreOpen = false;
if ( entry.type === 'link' )
return;
evt.preventDefault();
//if ( entry.type === 'virus-total' )
// this.openVirusTotal();
},
/*async openVirusTotal() {
if ( ! this.url_hash || ! this.vt_key )
return;
const resp = await fetch(`https://www.virustotal.com/api/v3/urls`, {
method: 'POST',
headers: {
'x-apikey': this.vt_key
},
body: new URLSearchParams({
url: this.url
})
}).then(resp => resp.ok ? resp.json() : null);
console.log('response', resp);
},*/
constrain() {
const el = this.$el;
let parent = el.parentElement,
moved = false;
if ( ! parent )
parent = document.body;
const box = el.getBoundingClientRect(),
pbox = parent.getBoundingClientRect();
if ( box.top < pbox.top ) {
el.style.top = `${el.offsetTop + (pbox.top - box.top)}px`;
moved = true;
} else if ( box.bottom > pbox.bottom ) {
el.style.top = `${el.offsetTop - (box.bottom - pbox.bottom)}px`;
moved = true;
}
if ( box.left < pbox.left ) {
el.style.left = `${el.offsetLeft + (pbox.left - box.left)}px`;
moved = true;
} else if ( box.right > pbox.right ) {
el.style.left = `${el.offsetLeft - (box.right - pbox.right)}px`;
moved = true;
}
if ( moved && this.displace )
this.displace.reinit();
},
pin() {
this.pinned = true;
this.$emit('pin');
this.ffzEmit(':pin', this);
},
cleanTips() {
this.$nextTick(() => this.ffzEmit('tooltips:cleanup'))
},
close() {
this.$emit('close');
},
createDrag() {
this.$nextTick(() => {
this.displace = displace(this.$el, {
handle: this.$el.querySelector('.ffz-viewer-card__header'),
highlightInputs: true,
constrain: true,
ignoreFn: e => e.target.closest('.viewer-card-drag-cancel') != null
});
})
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
handleResize() {
if ( this.displace )
this.displace.reinit();
},
onFocus() {
this.z = this.getZ();
},
focus() {
this.$el.focus();
},
ffzEmit(event, ...args) {
this.$emit('emit', event, ...args);
}
}
}
</script>

View file

@ -0,0 +1,197 @@
'use strict';
// ============================================================================
// Link Cards
// ============================================================================
import { createElement } from 'utilities/dom';
import { deep_copy } from 'utilities/object';
import Module from 'utilities/module';
export default class LinkCard extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('i18n');
this.inject('chat');
this.inject('site');
this.inject('settings');
this.vue = this.resolve('vue');
this.settings.add('link-cards.enable', {
default: false,
ui: {
path: 'Chat > Link Cards >> General',
title: 'Enable Link Cards.',
description: 'When this is enabled and you click a link in chat or whispers, a popup will open with information about the link. This provides the same data as rich link tooltips, but in a form that allows more interaction.',
component: 'setting-check-box'
}
});
this.settings.add('link-cards.use-destination', {
default: false,
ui: {
path: 'Chat > Link Cards >> General',
title: 'Bypass Known Shorteners',
description: 'When clicking "Open Link" from a Link Card with this enabled, you will bypass known shorteners and tracking services and go directly to the destination URL.',
component: 'setting-check-box'
}
});
this.last_z = 9000;
this.open_cards = {};
this.last_card = null;
}
onEnable() {
this.on('chat:click-link', this.handleClick, this);
}
handleClick(evt) {
evt.preventDefault();
this.openCard(evt.url, evt.source);
}
async loadVue() {
if ( this._vue_loaded )
return;
await this.vue.enable();
const card_component = await import(/* webpackChunkName: 'emote-cards' */ './components/card.vue');
this.vue.component('link-card', card_component.default);
this.vue.component('lc-url', {
functional: true,
props: ['url', 'show-protocol'],
render(createElement, context) {
let url = context.props.url;
if ( !(url instanceof URL) )
url = new URL(url);
const out = [];
if ( context.props.showProtocol )
out.push(createElement('span', {
class: 'tw-c-text-alt-2'
}, `${url.protocol}//`));
out.push(createElement('span', url.host));
let suffix = url.toString().slice(url.origin.length);
if ( suffix.length && suffix !== '/' )
out.push(createElement('span', {
class: 'tw-c-text-alt-2'
}, suffix));
return createElement('span', out);
}
});
this._vue_loaded = true;
}
async openCard(link, event) {
const card_key = `${link}`,
old_card = this.open_cards[card_key];
if ( old_card ) {
old_card.$el.style.zIndex = ++this.last_z;
old_card.focus();
return;
}
let pos_x = event ? event.clientX : window.innerWidth / 2,
pos_y = event ? event.clientY + 15 : window.innerHeight / 2;
/*if ( this.last_card ) {
const card = this.last_card;
if ( ! event ) {
pos_x = card.$el.offsetLeft;
pos_y = card.$el.offsetTop;
}
card.close();
}*/
// Start loading data. Don't await it yet, so we can
// wait for Vue at the same time.
const data = this.chat.get_link_info(link);
// Now load vue.
await this.loadVue();
// Display the card.
this.last_card = this.open_cards[card_key] = this.buildCard(
pos_x,
pos_y,
link,
data
);
}
buildCard(pos_x, pos_y, link, data) {
let child;
const component = new this.vue.Vue({
el: createElement('div'),
render: h => h('link-card', {
props: {
url: link,
data: data,
use_dest: this.settings.get('link-cards.use-destination'),
getFFZ: () => this,
getZ: () => ++this.last_z
},
on: {
emit: (event, ...data) => this.emit(event, ...data),
close: () => {
const el = component.$el;
el.remove();
component.$destroy();
if ( this.last_card === child )
this.last_card = null;
const card_key = link;
if ( this.open_cards[card_key] === child )
this.open_cards[card_key] = null;
this.emit('tooltips:cleanup');
},
pin: () => {
if ( this.last_card === child )
this.last_card = null;
}
}
})
});
child = component.$children[0];
const el = component.$el;
el.style.left = `${pos_x}px`;
el.style.top = `${pos_y}px`;
const container = document.querySelector(this.site.constructor.DIALOG_SELECTOR ?? '#root>div>.tw-full-height,.twilight-minimal-root>.tw-full-height');
container.appendChild(el);
requestAnimationFrame(() => child.constrain());
return child;
}
}

View file

@ -152,6 +152,8 @@
v-if="rich_data"
:data="rich_data"
:url="url"
:force-mid="false"
:force-full="false"
:force-media="force_media"
:force-unsafe="force_unsafe"
:events="events"
@ -168,6 +170,7 @@
:data="rich_data"
:url="url"
:force-mid="true"
:force-full="false"
:force-media="force_media"
:force-unsafe="force_unsafe"
:events="events"
@ -207,7 +210,8 @@
<div v-if="raw_loading" class="tw-align-center">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div>
<code v-else>{{ raw_data }}</code>
<code v-else-if="typeof raw_data === 'string'">{{ raw_data }}</code>
<code v-else v-html="highlightJson(raw_data, true)"></code>
</div>
</div>
</div>
@ -215,7 +219,9 @@
</template>
<script>
import { debounce, timeout, pick_random } from 'utilities/object'
import { highlightJson } from 'utilities/dom';
const STOCK_URLS = [
'https://www.twitch.tv/sirstendec',
@ -503,7 +509,7 @@ export default {
},
async refreshRaw() {
this.raw_data = null;
this.raw_data = undefined;
this.length = 0;
if ( ! this.rich_data ) {
this.raw_loading = false;
@ -513,7 +519,7 @@ export default {
this.raw_loading = true;
try {
const data = await this.chat.get_link_info(this.url);
this.raw_data = JSON.stringify(data, null, '\t');
this.raw_data = data; //JSON.stringify(data, null, '\t');
this.length = JSON.stringify(data).length;
} catch(err) {
this.raw_data = `Error\n\n${err.toString()}`;
@ -585,7 +591,11 @@ export default {
this.force_tooltip = this.$refs.force_tooltip.checked;
this.saveState();
}
},
highlightJson(object, pretty) {
return highlightJson(object, pretty);
},
}
}

View file

@ -15,6 +15,10 @@
"name": "Resubscribe (Tier 1, 17 Months, Message)",
"data": "@badge-info=subscriber/17;badges=subscriber/12,moments/1;color=#2E8B57;display-name=FaustDaimos;emotes=;flags=;id=cdb378a9-4e56-4933-a78b-f4bcf2a3961a;login=faustdaimos;mod=0;msg-id=resub;msg-param-cumulative-months=17;msg-param-months=0;msg-param-multimonth-duration=0;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Baka\\sBrigade!;msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=24761645;subscriber=1;system-msg=FaustDaimos\\ssubscribed\\sat\\sTier\\s1.\\sThey've\\ssubscribed\\sfor\\s17\\smonths!;tmi-sent-ts=1671231277242;user-id=37613709;user-type= :tmi.twitch.tv USERNOTICE #cirno_tv :when do we try it out"
},
{
"name": "Subscribe (Tier 1, 6 Months, No Message)",
"data": "@badge-info=subscriber/1;badges=subscriber/0;color=#191970;display-name=0x800CCC0F;emotes=;flags=;id=929243d4-3a4c-4cd6-ad78-31faa187d4f5;login=0x800ccc0f;mod=0;msg-id=sub;msg-param-cumulative-months=1;msg-param-months=0;msg-param-multimonth-duration=6;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=The\\sGeologists;msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=49399878;subscriber=1;system-msg=0x800CCC0F\\ssubscribed\\sat\\sTier\\s1.;tmi-sent-ts=1695428795186;user-id=128451573;user-type=;vip=0 :tmi.twitch.tv USERNOTICE #sirstendec"
},
{
"name": "Mass Gift Sub (Tier 1, 5 Total)",
"data": [

View file

@ -89,7 +89,11 @@ export default class TooltipProvider extends Module {
}
getRoot() { // eslint-disable-line class-methods-use-this
return document.querySelector('.sunlight-root') || document.querySelector('#root>div') || document.querySelector('#root') || document.querySelector('.clips-root') || document.body;
return document.querySelector('.sunlight-root') ||
//document.querySelector('#root>div') ||
document.querySelector('#root') ||
document.querySelector('.clips-root') ||
document.body;
}
_createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) {

289
src/pubsub/index.js Normal file
View file

@ -0,0 +1,289 @@
'use strict';
// ============================================================================
// PubSub Client
// ============================================================================
import Module from 'utilities/module';
import {DEBUG, PUBSUB_CLUSTERS} from 'utilities/constants';
export const State = {
DISCONNECTED: 0,
CONNECTING: 1,
CONNECTED: 2
}
const decoder = new TextDecoder();
export default class PubSubClient extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('experiments');
this.settings.add('pubsub.use-cluster', {
default: 'Staging',
ui: {
path: 'Debugging @{"expanded": false, "sort": 9999} > PubSub >> General',
title: 'Server Cluster',
description: 'Which server cluster to connect to. You can use this setting to disable PubSub if you want, but should otherwise leave this on the default value unless you know what you\'re doing.',
force_seen: true,
component: 'setting-select-box',
data: [{
value: null,
title: 'Disabled'
}].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
value: x,
title: x
})))
},
changed: () => {
if ( this.experiments.getAssignment('pubsub') )
this.reconnect();
}
});
this._topics = new Map;
this._client = null;
this._state = 0;
}
loadMQTT() {
if ( this._mqtt )
return Promise.resolve(this._mqtt);
if ( this._mqtt_loader )
return new Promise((s,f) => this._mqtt_loader.push([s,f]));
return new Promise((s,f) => {
const loaders = this._mqtt_loader = [[s,f]];
import('u8-mqtt')
.then(thing => {
this._mqtt = thing;
this._mqtt_loader = null;
for(const pair of loaders)
pair[0](thing);
})
.catch(err => {
this._mqtt_loader = null;
for(const pair of loaders)
pair[1](err);
});
});
}
onEnable() {
// Check to see if we should be using PubSub.
if ( ! this.experiments.getAssignment('pubsub') )
return;
this.connect();
}
onDisable() {
this.disconnect();
}
// ========================================================================
// Properties
// ========================================================================
get connected() {
return this._state === State.CONNECTED;
}
get connecting() {
return this._state === State.CONNECTING;
}
get disconnected() {
return this._state === State.DISCONNECTED;
}
// ========================================================================
// Connection Logic
// ========================================================================
reconnect() {
this.disconnect();
this.connect();
}
async connect() {
if ( this._client )
return;
let cluster_id = this.settings.get('pubsub.use-cluster');
if ( cluster_id === null )
return;
let cluster = PUBSUB_CLUSTERS[cluster_id];
// If we didn't get a valid cluster, use production.
if ( ! cluster?.length ) {
cluster_id = 'Production';
cluster = PUBSUB_CLUSTERS.Production;
}
this.log.info(`Using Cluster: ${cluster_id}`);
this._state = State.CONNECTING;
let client;
try {
const mqtt = await this.loadMQTT();
client = this._client = mqtt.mqtt_v5({
})
.with_websock(cluster)
.with_autoreconnect();
await client.connect({
client_id: [`ffz_${FrankerFaceZ.version_info}--`, '']
});
this._state = State.CONNECTED;
} catch(err) {
this._state = State.DISCONNECTED;
if ( this._client )
try {
this._client.end(true);
} catch(err) { /* no-op */ }
this._client = null;
throw err;
}
client.on_topic('*', pkt => {
const topic = pkt.topic;
let data;
try {
data = pkt.json();
} catch(err) {
this.log.warn(`Error decoding PubSub message on topic "${topic}":`, err);
return;
}
if ( ! data?.cmd ) {
this.log.warn(`Received invalid PubSub message on topic "${topic}":`, data);
return;
}
data.topic = topic;
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
this.emit(`socket:command:${data.cmd}`, data.data, data);
});
/*client.on('connect', () => {
this._state = State.CONNECTED;
});
client.on('message', (topic, message, packet) => {
let data;
try {
message = decoder.decode(message);
data = JSON.parse(message);
} catch(err) {
this.log.warn(`Error decoding PubSub message on topic "${topic}":`, err);
return;
}
if ( ! data.cmd ) {
this.log.warn(`Received invalid PubSub message on topic "${topic}":`, data);
return;
}
data.topic = topic;
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
this.emit(`socket:command:${data.cmd}`, data.data, data);
});
client.on('close', () => {
this._state = State.CONNECTING;
});*/
// Subscribe to topics.
const topics = [...this._topics.keys()];
client.subscribe(topics);
}
disconnect() {
if ( ! this._client )
return;
this._client.disconnect();
this._client = null;
this._state = State.DISCONNECTED;
}
// ========================================================================
// Topics
// ========================================================================
subscribe(referrer, ...topics) {
const t = this._topics;
let changed = false;
for(const topic of topics) {
if ( ! t.has(topic) ) {
if ( this._client )
this._client.subscribe(topic);
t.set(topic, new Set);
changed = true;
}
const tp = t.get(topic);
tp.add(referrer);
}
if ( changed )
this.emit(':sub-change');
}
unsubscribe(referrer, ...topics) {
const t = this._topics;
let changed = false;
for(const topic of topics) {
if ( ! t.has(topic) )
continue;
const tp = t.get(topic);
tp.delete(referrer);
if ( ! tp.size ) {
changed = true;
t.delete(topic);
if ( this._client )
this._client.unsubscribe(topic);
}
}
if ( changed )
this.emit(':sub-change');
}
get topics() {
return Array.from(this._topics.keys());
}
}
PubSubClient.State = State;

View file

@ -437,6 +437,6 @@ Twilight.ROUTES = {
};
Twilight.DIALOG_EXCLUSIVE = '.moderation-root,.sunlight-root,.twilight-main,.twilight-minimal-root>div,#root>div>.tw-full-height,.clips-root,#root>div>div';
Twilight.DIALOG_EXCLUSIVE = '.moderation-root,.sunlight-root,.twilight-main,.twilight-minimal-root>div,#root>div>.tw-full-height,.clips-root,#root';
Twilight.DIALOG_MAXIMIZED = '.moderation-view-page > div[data-highlight-selector="main-grid"],.sunlight-page,.twilight-main,.twilight-minimal-root,#root .dashboard-side-nav+.tw-full-height,.clips-root>.tw-full-height .scrollable-area,.teams-page-body__outer-container .scrollable-area';
Twilight.DIALOG_SELECTOR = '.moderation-root,.sunlight-root,#root>div,.twilight-minimal-root>.tw-full-height,.clips-root>.tw-full-height .scrollable-area';

View file

@ -30,6 +30,7 @@ export default class Channel extends Module {
this.inject('site.twitch_data');
this.inject('metadata');
this.inject('socket');
this.inject('pubsub');
this.settings.add('channel.auto-click-off-featured', {
default: false,
@ -301,15 +302,25 @@ export default class Channel extends Module {
}*/
updateSubscription(login) {
if ( this._subbed_login === login )
updateSubscription(id, login) {
if ( this._subbed_login === login && this._subbed_id === id )
return;
if ( this._subbed_id ) {
this.pubsub.unsubscribe(this, `twitch/${this._subbed_id}/channel/#`);
this._subbed_id = null;
}
if ( this._subbed_login ) {
this.socket.unsubscribe(this, `channel.${this._subbed_login}`);
this._subbed_login = null;
}
if ( id ) {
this.pubsub.subscribe(this, `twitch/${id}/channel`);
this._subbed_id = id;
}
if ( login ) {
this.socket.subscribe(this, `channel.${login}`);
this._subbed_login = login;
@ -379,7 +390,7 @@ export default class Channel extends Module {
});
if ( ! el._ffz_cont || ! props?.channelID ) {
this.updateSubscription(null);
this.updateSubscription(null, null);
return;
}
@ -470,12 +481,12 @@ export default class Channel extends Module {
//if ( ! this.settings.get('channel.hosting.enable') && props.hostLogin )
// this.setHost(props.channelID, props.channelLogin, null, null);
this.updateSubscription(props.channelLogin);
this.updateSubscription(props.channelID, props.channelLogin);
this.updateMetadata(el);
}
removeBar(el) {
this.updateSubscription(null);
this.updateSubscription(null, null);
if ( el._ffz_cont )
el._ffz_cont.classList.remove('ffz--meta-tray');

View file

@ -2506,6 +2506,11 @@ export default class ChatHook extends Module {
out.gift_theme = e.giftTheme;
out.sub_goal = i.getGoalData ? i.getGoalData(e.goalData) : null;
out.sub_plan = e.methods;
out.sub_multi = e.multiMonthData?.multiMonthDuration ? {
count: e.multiMonthData.multiMonthDuration,
tenure: e.multiMonthData.multiMonthTenure
} : null;
return i.postMessageToCurrentChannel(e, out);
} catch(err) {
@ -2588,6 +2593,10 @@ export default class ChatHook extends Module {
out.sub_share_streak = e.shouldShareStreakTenure;
out.sub_months = e.months;
out.sub_plan = e.methods;
out.sub_multi = e.multiMonthData?.multiMonthDuration ? {
count: e.multiMonthData.multiMonthDuration,
tenure: e.multiMonthData.multiMonthTenure
} : null;
//t.log.info('Resub Event', e, out);

View file

@ -165,21 +165,31 @@ export default class ChatLine extends Module {
const user = msg.user,
plan = msg.sub_plan || {},
tier = SUB_TIERS[plan.plan] || 1;
tier = SUB_TIERS[plan.plan] || 1,
multi = msg.sub_multi,
const sub_msg = this.i18n.tList('chat.sub.main', '{user} subscribed {plan}. ', {
user: e('span', {
role: 'button',
className: 'chatter-name',
onClick: inst.ffz_user_click_handler,
onContextMenu: this.actions.handleUserContext
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.displayName)),
plan: plan.prime ?
this.i18n.t('chat.sub.twitch-prime', 'with Prime Gaming') :
this.i18n.t('chat.sub.plan', 'at Tier {tier}', {tier})
});
has_multi = (multi?.count ?? 0) > 1 && multi.tenure === 0;
const sub_msg = this.i18n.tList(
`chat.sub.main${has_multi ? '-multi' : ''}`,
`{user} subscribed {plan}${has_multi ? ' for {multi, plural, one {# month} other {# months}} in advance' : ''}. `,
{
user: e('span', {
role: 'button',
className: 'chatter-name',
onClick: inst.ffz_user_click_handler,
onContextMenu: this.actions.handleUserContext
}, e('span', {
className: 'tw-c-text-base tw-strong'
}, user.displayName)),
plan: plan.prime ?
this.i18n.t('chat.sub.twitch-prime', 'with Prime Gaming') :
this.i18n.t('chat.sub.plan', 'at Tier {tier}', {tier}),
multi: has_multi
? multi.count
: 1
}
);
if ( msg.sub_share_streak && msg.sub_streak > 1 ) {
sub_msg.push(this.i18n.t(

View file

@ -176,9 +176,13 @@ export default class RichContent extends Module {
}
renderBody() {
let doc = this.props.force_full ? this.state.full :
this.props.force_mid ? this.state.mid :
((this.props.want_mid ? this.state.mid : null) ?? this.state.short);
let doc;
if ( this.props.force_full === true || (this.props.force_full !== false && this.props.want_full && this.state.full) )
doc = this.state.full;
if ( this.props.force_mid === true || (this.props.force_mid !== false && this.props.want_mid && this.state.mid) )
doc = this.state.mid;
else
doc = this.state.short;
if ( t.has_tokenizer && this.state.v && this.state.v > t.tokenizer.VERSION)
doc = null;
@ -191,6 +195,20 @@ export default class RichContent extends Module {
tList: (...args) => t.i18n.tList(...args),
i18n: t.i18n,
last_player: 0,
player_state: this.state.player_state,
togglePlayer: id => {
const player_state = this.state.player_state ?? {};
player_state[id] = ! player_state[id];
this.setState({
player_state
});
},
link_click_handler: t.chat.handleLinkClick,
fragments: this.state.fragments,
i18n_prefix: this.state.i18n_prefix,
@ -244,6 +262,7 @@ export default class RichContent extends Module {
target="_blank"
rel="noreferrer noopener"
href={this.state.url}
onClick={t.chat.handleLinkClick}
>
{content}
</a>);

View file

@ -86,21 +86,13 @@
body:not(.ffz--portrait-invert) & {
top: unset !important;
bottom: 0 !important;
border-top: 1px solid #dad8de;
.tw-root--theme-dark & {
border-top-color: #2a2a2a;
}
border-top: 1px solid var(--color-border-base);
}
.ffz--portrait-invert & {
top: 0 !important;
bottom: unset !important;
border-bottom: 1px solid #dad8de;
.tw-root--theme-dark & {
border-bottom-color: #2a2a2a;
}
border-bottom: 1px solid var(--color-border-base);
}
& > .tw-full-height {
@ -163,6 +155,10 @@
.channel-root__right-column,
.channel-page__right-column {
width: 100% !important;
& > div {
border-left: none !important;
}
}
}
}

View file

@ -22,6 +22,7 @@ export default class ModView extends Module {
this.inject('site.twitch_data');
this.inject('metadata');
this.inject('socket');
this.inject('pubsub');
this.should_enable = true;
@ -60,15 +61,25 @@ export default class ModView extends Module {
this.checkNavigation();
}
updateSubscription(login) {
if ( this._subbed_login === login )
updateSubscription(id, login) {
if ( this._subbed_login === login && this._subbed_id === id )
return;
if ( this._subbed_id ) {
this.pubsub.unsubscribe(this, `twitch/${this._subbed_id}/channel/#`);
this._subbed_id = null;
}
if ( this._subbed_login ) {
this.socket.unsubscribe(this, `channel.${this._subbed_login}`);
this._subbed_login = null;
}
if ( id ) {
this.pubsub.subscribe(this, `twitch/${id}/channel`);
this._subbed_id = id;
}
if ( login ) {
this.socket.subscribe(this, `channel.${login}`);
this._subbed_login = login;
@ -114,7 +125,7 @@ export default class ModView extends Module {
this._cached_id = channel.id;
this._cached_channel = channel;
this._cached_color = null;
this.updateSubscription(channel.login);
this.updateSubscription(channel.id, channel.login);
this.getChannelColor(el, channel.id).then(color => {
if ( this._cached_id != channel.id )

View file

@ -390,6 +390,15 @@ export default class ThemeEngine extends Module {
}
bits.push(this.generateBackgroundBlob(hsla));
// TODO: Actually calculate these
if ( dark ) {
bits.push(`--color-border-base: var(--color-opac-gl-2);`);
bits.push(`--color-background-interactable-hover: var(--color-opac-gl-1);`);
bits.push(`--color-background-interactable-active: var(--color-opac-gl-2);`);
bits.push(`--color-background-button-text-hover: var(--color-opac-gl-1);`);
bits.push(`--color-background-button-text-active: var(--color-opac-gl-2);`);
}
}
let text = Color.RGBA.fromCSS(this.settings.get('theme.color.text'));

View file

@ -102,6 +102,15 @@
.creator-chat-stats-carousel__left-arrow {
background: linear-gradient(90deg, var(--color-background-body) 60%, transparent) !important;
}
.emote-picker__emote-link {
&:hover {
background-color: var(--color-background-button-text-hover) !important;
}
&:active,&:focus {
background-color: var(--color-background-button-text-active) !important;
}
}
}
html {

View file

@ -9,4 +9,8 @@
.twilight-main > .tw-relative.tw-z-above.tw-bottom-0 {
position: absolute !important;
}
#root.ffz-has-dialog {
min-height: 100%;
}

View file

@ -11,6 +11,11 @@
overflow: unset !important;
}
&.ffz-accent-card {
width: 40rem;
border-right: 0.5rem solid var(--ffz-color-accent) !important;
}
> div:first-child {
cursor: move;
}
@ -37,7 +42,7 @@
margin-right: .5rem;
&:hover, &.active {
border-top: 1px solid #6441a4;
border-top: 3px solid var(--color-text-link);
}
}
}

View file

@ -239,6 +239,14 @@ export const WS_CLUSTERS = {
]
}
export const PUBSUB_CLUSTERS = {
Production: 'wss://pubsub.frankerfacez.com/mqtt',
Staging: 'wss://pubsub-staging.frankerfacez.com/mqtt',
Development: 'wss://127.0.0.1:8084/mqtt'
}
export const IS_OSX = navigator.platform ? navigator.platform.indexOf('Mac') !== -1 : /OS X/.test(navigator.userAgent);
export const IS_WIN = navigator.platform ? navigator.platform.indexOf('Win') !== -1 : /Windows/.test(navigator.userAgent);
export const IS_WEBKIT = navigator.userAgent.indexOf('AppleWebKit/') !== -1 && navigator.userAgent.indexOf('Edge/') === -1;

View file

@ -0,0 +1,9 @@
query FFZ_SearchTags($query: String!, $first: Int) {
searchFreeformTags(userQuery: $query, first: $first) {
edges {
node {
tagName
}
}
}
}

View file

@ -334,14 +334,14 @@ export class ClickOutside {
// TODO: Rewrite this method to not use raw HTML.
export function highlightJson(object, pretty = false, depth = 1) {
export function highlightJson(object, pretty = false, depth = 1, max_depth = 30) {
let indent = '', indent_inner = '';
if ( pretty ) {
indent = ' '.repeat(depth - 1);
indent_inner = ' '.repeat(depth);
}
if ( depth > 10 )
if ( depth > max_depth )
return `<span class="ffz-ct--obj-literal">&lt;nested&gt;</span>`;
if (object == null)
@ -355,8 +355,10 @@ export function highlightJson(object, pretty = false, depth = 1) {
if ( Array.isArray(object) )
return `<span class="ffz-ct--obj-open" depth="${depth}">[</span>`
+ object.map(x => (pretty ? `\n${indent_inner}` : '') + highlightJson(x, pretty, depth + 1)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
+ (pretty ? `\n${indent}` : '')
+ (object.length > 0 ? (
object.map(x => (pretty ? `\n${indent_inner}` : '') + highlightJson(x, pretty, depth + 1, max_depth)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
+ (pretty ? `\n${indent}` : '')
) : '')
+ `<span class="ffz-ct--obj-close" depth="${depth}">]</span>`;
const out = [];
@ -368,8 +370,8 @@ export function highlightJson(object, pretty = false, depth = 1) {
if ( pretty )
out.push(`\n${indent_inner}`);
out.push(`<span class="ffz-ct--obj-key" depth="${depth}">"${sanitize(key)}"</span><span class="ffz-ct--obj-key-sep" depth="${depth}">: </span>`);
out.push(highlightJson(val, pretty, depth + 1));
out.push(highlightJson(val, pretty, depth + 1, max_depth));
}
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}${pretty ? `\n${indent}` : ''}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}${out.length && pretty ? `\n${indent}` : ''}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
}

View file

@ -45,6 +45,22 @@ export function generateUUID(input) {
}
export async function sha256(message) {
// encode as UTF-8
const msgBuffer = new TextEncoder().encode(message);
// hash the message
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
// convert ArrayBuffer to Array
const hashArray = Array.from(new Uint8Array(hashBuffer));
// convert bytes to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
/*export function sortScreens(screens) {
screens.sort((a,b) => {
if ( a.left < b.left ) return -1;

View file

@ -8,7 +8,7 @@ import {has} from 'utilities/object';
import Markdown from 'markdown-it';
import MILA from 'markdown-it-link-attributes';
export const VERSION = 7;
export const VERSION = 8;
export const TOKEN_TYPES = {};
@ -989,7 +989,11 @@ TOKEN_TYPES.link = function(token, createElement, ctx) {
if ( token.no_color )
klass.push(`ffz-link--inherit`);
if ( ctx.vue )
if ( ctx.vue ) {
let on = {};
if ( ctx.link_click_handler )
on.click = ctx.link_click_handler;
return createElement('a', {
class: klass,
attrs: {
@ -997,15 +1001,18 @@ TOKEN_TYPES.link = function(token, createElement, ctx) {
target: '_blank',
'data-tooltip-type': 'link',
href: token.url
}
},
on
}, content);
}
return createElement('a', {
className: klass.join(' '),
rel: 'noopener noreferrer',
target: '_blank',
'data-tooltip-type': 'link',
href: token.url
href: token.url,
onClick: ctx.link_click_handler
}, content);
}
@ -1065,6 +1072,140 @@ TOKEN_TYPES.overlay = function(token, createElement, ctx) {
}
// ============================================================================
// Token Type: Player
// ============================================================================
function handlePlayerClick(token, id, ctx, event) {
//console.log('clicked player', token, id, ctx, event);
if ( ctx.togglePlayer ) {
ctx.togglePlayer(id);
event.preventDefault();
event.stopPropagation();
}
const target = event.currentTarget;
if ( target instanceof HTMLVideoElement ) {
if ( target.paused )
target.play();
else
target.pause();
}
}
TOKEN_TYPES.player = function(token, createElement, ctx) {
// Make a unique ID for this player, within the context.
const id = ctx.last_player = (ctx.last_player || 0) + 1,
active = ctx.player_state?.[id];
const handler = handlePlayerClick.bind(this, token, id, ctx);
if ( token.iframe )
return render_player_iframe(id, active ?? false, handler, token, createElement, ctx);
if ( ! token.sources )
return null;
const autoplay = token.autoplay ?? false,
loop = token.loop ?? false,
playing = active ?? autoplay,
controls = ! token.silent || ! autoplay;
const muted = token.silent ? true : (active == null && autoplay);
const style = {};
const aspect = token.active_aspect ?? token.aspect;
if ( aspect )
style.aspectRatio = aspect;
if ( ctx.vue )
return createElement('video', {
style,
attrs: {
autoplay: playing,
loop,
controls,
poster: token.poster
},
domProps: {
muted
},
on: {
click: handler
}
}, token.sources.map(source => createElement('source', {
attrs: {
type: source.type,
src: source.src
}
})));
return createElement('video', {
style,
muted,
autoplay: playing,
loop,
poster: token.poster,
controls,
onClick: handler
}, token.sources.map(source => createElement('source', {
type: source.type,
src: source.src
})));
}
function render_player_iframe(id, active, handler, token, createElement, ctx) {
if ( active ) {
const style = {},
attrs = {
src: token.iframe,
frameborder: 0,
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
allowfullscreen: true
};
const aspect = token.active_aspect ?? token.aspect;
if ( aspect )
style.aspectRatio = aspect;
if ( ctx.vue )
return createElement('iframe', {
style,
attrs
});
return createElement('iframe', {
style,
...attrs
});
}
const content = renderTokens(token.content, createElement, ctx, token.markdown),
classes = ['ffz--rich-player'],
style = {};
if ( token.aspect )
style.aspectRatio = token.aspect;
if ( ctx.vue )
return createElement('div', {
class: classes,
style,
on: {
click: handler
}
}, content);
return createElement('div', {
className: classes.join(' '),
onClick: handler,
style
}, content);
}
// ============================================================================
// Token Type: Style
// ============================================================================

View file

@ -15,6 +15,8 @@ import {createPopper} from '@popperjs/core';
let last_id = 0;
export const NoContent = Symbol('NoContent');
export const DefaultOptions = {
html: false,
delayShow: 0,
@ -77,7 +79,7 @@ export class Tooltip {
} else if ( this.live ) {
this._onMouseOver = e => {
this.updateShift(e.shiftKey);
this.updateShift(e.shiftKey, e.ctrlKey);
const target = e.target;
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) {
this._enter(target);
@ -89,7 +91,7 @@ export class Tooltip {
} else {
this._onMouseOver = e => {
this.updateShift(e.shiftKey);
this.updateShift(e.shiftKey, e.ctrlKey);
const target = e.target;
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
this._enter(e.target);
@ -144,7 +146,7 @@ export class Tooltip {
if ( this._keyUpdate )
return;
this._keyUpdate = e => this.updateShift(e.shiftKey);
this._keyUpdate = e => this.updateShift(e.shiftKey, e.ctrlKey);
window.addEventListener('keydown', this._keyUpdate);
window.addEventListener('keyup', this._keyUpdate);
}
@ -158,11 +160,13 @@ export class Tooltip {
this._keyUpdate = null;
}
updateShift(state) {
if ( state === this.shift_state )
updateShift(state, ctrl_state) {
if ( state === this.shift_state && ctrl_state === this.ctrl_state )
return;
this.shift_state = state;
this.ctrl_state = ctrl_state;
if ( ! this._shift_af )
this._shift_af = requestAnimationFrame(() => {
this._shift_af = null;
@ -171,7 +175,9 @@ export class Tooltip {
const tip = el[this._accessor];
if ( tip && tip.outer ) {
tip.outer.dataset.shift = this.shift_state;
tip.outer.dataset.ctrl = this.ctrl_state;
tip.update();
//tip.updateVideo();
}
}
});
@ -268,19 +274,30 @@ export class Tooltip {
tip = target[this._accessor] = {target};
this.show(tip);
};
tip.updateVideo = () => {
if ( ! tip.element )
return;
const videos = tip.element.querySelectorAll('video');
for(const video of videos) {
if ( this.ctrl_state )
video.play();
else
video.pause();
}
};
tip.hide = () => this.hide(tip);
tip.rerender = () => {
if ( tip.visible ) {
tip.hide();
tip.show();
}
}
};
let content = maybe_call(opts.content, null, target, tip);
if ( content === undefined )
content = tip.target.title;
if ( tip.visible || (! content && ! opts.onShow) )
if ( tip.visible || content === NoContent || (! content && ! opts.onShow) )
return;
// Build the DOM.
@ -289,7 +306,8 @@ export class Tooltip {
el = tip.outer = createElement('div', {
className: opts.tooltipClass,
'data-shift': this.shift_state
'data-shift': this.shift_state,
'data-ctrl': this.ctrl_state
}, [inner, arrow]);
arrow.setAttribute('x-arrow', true);
@ -314,7 +332,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);
this.updateShift(event.shiftKey, event.ctrlKey);
opts.onMove(target, tip, event);
});
@ -388,7 +406,7 @@ export class Tooltip {
tip._promises = null;
if ( content instanceof Promise || (content.then && content.toString() === '[object Promise]') ) {
if ( content instanceof Promise || (content?.then && content.toString() === '[object Promise]') ) {
inner.innerHTML = '<div class="ffz-i-zreknarf loader"></div>';
content.then(content => {
if ( ! content )

View file

@ -112,6 +112,14 @@
}
}
.ffz--rich-player {
cursor: pointer;
> * {
width: 100%;
}
}
.ffz--overlay {
position: relative;
@ -242,6 +250,22 @@
border-width: 0 3em 3em 0;
z-index: 100;
border-right-color: var(--ffz-flag-color);
&.ffz--corner-flag--left {
right: unset;
left: 0;
border-width: 0 0 3em 3em;
border-right-color: transparent;
border-left-color: var(--ffz-flag-color);
figure {
right: unset;
left: -2.75em;
}
}
figure {
position: absolute;
top: 0;
@ -250,11 +274,12 @@
}
.ffz--corner-flag__warn {
border-right-color: #f33;
--ffz-flag-color: #f33;
color: #fff;
.tw-root--theme-dark & {
border-right-color: #900;
--ffz-flag-color: #900;
//border-right-color: #900;
}
}
@ -336,7 +361,7 @@
flex-direction: column;
overflow: hidden;
margin-top: -4px;
max-height: calc(100% + 4px);
max-height: calc(350px + 4px);
&:only-child {
grid-column-start: 1;

View file

@ -24,8 +24,12 @@
height: 50vh;
height: var(--height);
border-radius: var(--border-radius-extra-large);
> header {
cursor: move;
border-top-left-radius: var(--border-radius-extra-large);
border-top-right-radius: var(--border-radius-extra-large);
}
&.faded {
@ -76,7 +80,7 @@
.ffz-has-dialog {
position: relative;
& > :not(.ffz-dialog) {
& > :not(.ffz-dialog):not(.ffz__tooltip) {
visibility: hidden;
}
}

View file

@ -14,4 +14,8 @@
pointer-events: all;
}
}
}
.ffz-dialog:not(.maximized) .ffz-vertical-nav {
border-bottom-left-radius: var(--border-radius-extra-large);
}