diff --git a/src/main.js b/src/main.js index 3023e6ab..e77ed2cf 100644 --- a/src/main.js +++ b/src/main.js @@ -149,7 +149,7 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}` FrankerFaceZ.Logger = Logger; const VER = FrankerFaceZ.version_info = { - major: 4, minor: 0, revision: 0, extra: '-rc13.23', + major: 4, minor: 0, revision: 0, extra: '-rc14', commit: __git_commit__, build: __webpack_hash__, toString: () => diff --git a/src/sites/twitch-twilight/modules/chat/index.js b/src/sites/twitch-twilight/modules/chat/index.js index e6c6e0ae..f07f994f 100644 --- a/src/sites/twitch-twilight/modules/chat/index.js +++ b/src/sites/twitch-twilight/modules/chat/index.js @@ -106,7 +106,8 @@ const CHAT_TYPES = make_enum( 'RewardGift', 'SubMysteryGift', 'AnonSubMysteryGift', - 'FirstCheerMessage' + 'FirstCheerMessage', + 'BitsBadgeTierMessage' ); @@ -119,11 +120,7 @@ const NULL_TYPES = [ const MISBEHAVING_EVENTS = [ - 'onBitsCharityEvent', - //'onRitualEvent', -- handled by conversion to Message event 'onBadgesUpdatedEvent', - 'onPurchaseEvent', - 'onCrateEvent' ]; @@ -234,6 +231,49 @@ export default class ChatHook extends Module { } }); + this.settings.add('chat.subs.show', { + default: 3, + ui: { + path: 'Chat > Appearance >> Subscriptions', + title: 'Display Subs in Chat', + component: 'setting-select-box', + description: '**Note**: Messages sent with re-subs will always be displayed. This only controls the special "X subscribed!" message.', + data: [ + {value: 0, title: 'Do Not Display'}, + {value: 1, title: 'Re-Subs with Messages Only'}, + {value: 2, title: 'Re-Subs Only'}, + {value: 3, title: 'Display All'} + ] + } + }); + + this.settings.add('chat.subs.compact', { + default: false, + ui: { + path: 'Chat > Appearance >> Subscriptions', + title: 'Display subscription notices in a more compact (classic style) form.', + component: 'setting-check-box' + } + }); + + this.settings.add('chat.subs.merge-gifts', { + default: 1000, + ui: { + path: 'Chat > Appearance >> Subscriptions', + title: 'Merge Mass Sub Gifts', + component: 'setting-select-box', + data: [ + {value: 1000, title: 'Disabled'}, + {value: 50, title: 'More than 50'}, + {value: 20, title: 'More than 20'}, + {value: 10, title: 'More than 10'}, + {value: 5, title: 'More than 5'}, + {value: 0, title: 'Always'} + ], + description: 'Merge mass gift subscriptions into a single message, depending on the quantity.\n**Note:** Only affects newly gifted subs.' + } + }); + this.settings.add('chat.lines.alternate', { default: false, ui: { @@ -882,9 +922,30 @@ export default class ChatHook extends Module { } + const old_sub = this.onSubscriptionEvent; + this.onSubscriptionEvent = function(e) { + try { + if ( t.chat.context.get('chat.subs.show') < 3 ) + return; + + e.body = ''; + const out = i.convertMessage({message: e}); + out.ffz_type = 'resub'; + out.sub_plan = e.methods; + return i.postMessageToCurrentChannel(e, out); + + } catch(err) { + t.log.capture(err, {extra: e}); + return old_sub.call(i, e); + } + } + const old_resub = this.onResubscriptionEvent; this.onResubscriptionEvent = function(e) { try { + if ( t.chat.context.get('chat.subs.show') < 2 && ! e.body ) + return; + const out = i.convertMessage({message: e}); out.ffz_type = 'resub'; out.sub_cumulative = e.cumulativeMonths || 0; @@ -903,6 +964,162 @@ export default class ChatHook extends Module { } } + const mysteries = this.ffz_mysteries = {}; + + const old_subgift = this.onSubscriptionGiftEvent; + this.onSubscriptionGiftEvent = function(e) { + try { + const key = `${e.channel}:${e.user.userID}`, + mystery = mysteries[key]; + + if ( mystery ) { + if ( mystery.expires < Date.now() ) { + mysteries[key] = null; + } else { + mystery.recipients.push({ + id: e.recipientID, + login: e.recipientLogin, + displayName: e.recipientName + }); + + if( mystery.recipients.length >= mystery.size ) + mysteries[key] = null; + + if ( mystery.line ) + mystery.line.forceUpdate(); + + return; + } + } + + e.body = ''; + const out = i.convertMessage({message: e}); + out.ffz_type = 'sub_gift'; + out.sub_recipient = { + id: e.recipientID, + login: e.recipientLogin, + displayName: e.recipientName + }; + out.sub_plan = e.methods; + out.sub_count = e.senderCount; + + //t.log.info('Sub Gift', e, out); + return i.postMessageToCurrentChannel(e, out); + + } catch(err) { + t.log.capture(err, {extra: e}); + return old_subgift.call(i, e); + } + } + + const old_anonsubgift = this.onAnonSubscriptionGiftEvent; + this.onAnonSubscriptionGiftEvent = function(e) { + try { + const key = `${e.channel}:ANON`, + mystery = mysteries[key]; + + if ( mystery ) { + if ( mystery.expires < Date.now() ) + mysteries[key] = null; + else { + mystery.recipients.push({ + id: e.recipientID, + login: e.recipientLogin, + displayName: e.recipientName + }); + + if( mystery.recipients.length >= mystery.size ) + mysteries[key] = null; + + if ( mystery.line ) + mystery.line.forceUpdate(); + + return; + } + } + + e.body = ''; + const out = i.convertMessage({message: e}); + out.ffz_type = 'sub_gift'; + out.sub_anon = true; + out.sub_recipient = { + id: e.recipientID, + login: e.recipientLogin, + displayName: e.recipientName + }; + out.sub_plan = e.methods; + out.sub_count = e.senderCount; + + //t.log.info('Anon Sub Gift', e, out); + return i.postMessageToCurrentChannel(e, out); + + } catch(err) { + t.log.capture(err, {extra: e}); + return old_anonsubgift.call(i, e); + } + } + + const old_submystery = this.onSubscriptionMysteryGiftEvent; + this.onSubscriptionMysteryGiftEvent = function(e) { + try { + let mystery = null; + if ( e.massGiftCount > t.chat.context.get('chat.subs.merge-gifts') ) { + const key = `${e.channel}:${e.user.userID}`; + mystery = mysteries[key] = { + recipients: [], + size: e.massGiftCount, + expires: Date.now() + 30000 + }; + } + + e.body = ''; + const out = i.convertMessage({message: e}); + out.ffz_type = 'sub_mystery'; + out.mystery = mystery; + out.sub_plan = e.plan; + out.sub_count = e.massGiftCount; + out.sub_total = e.senderCount; + + //t.log.info('Sub Mystery', e, out); + return i.postMessageToCurrentChannel(e, out); + + } catch(err) { + t.log.capture(err, {extra: e}); + return old_submystery.call(i, e); + } + } + + const old_anonsubmystery = this.onAnonSubscriptionMysteryGiftEvent; + this.onAnonSubscriptionMysteryGiftEvent = function(e) { + try { + let mystery = null; + if ( e.massGiftCount > t.chat.context.get('chat.subs.merge-gifts') ) { + const key = `${e.channel}:ANON`; + mystery = mysteries[key] = { + recipients: [], + size: e.massGiftCount, + expires: Date.now() + 30000 + }; + } + + e.body = ''; + const out = i.convertMessage({message: e}); + out.ffz_type = 'sub_mystery'; + out.sub_anon = true; + out.mystery = mystery; + out.sub_plan = e.plan; + out.sub_count = e.massGiftCount; + out.sub_total = e.senderCount; + + //t.log.info('Anon Sub Mystery', e, out); + return i.postMessageToCurrentChannel(e, out); + + } catch(err) { + t.log.capture(err, {extra: e}); + return old_anonsubmystery.call(i, e); + } + } + const old_ritual = this.onRitualEvent; this.onRitualEvent = function(e) { try { diff --git a/src/sites/twitch-twilight/modules/chat/line.js b/src/sites/twitch-twilight/modules/chat/line.js index 9fc49f24..e1911155 100644 --- a/src/sites/twitch-twilight/modules/chat/line.js +++ b/src/sites/twitch-twilight/modules/chat/line.js @@ -8,7 +8,8 @@ import Twilight from 'site'; import Module from 'utilities/module'; import RichContent from './rich_content'; -import { has } from 'src/utilities/object'; +import { has } from 'utilities/object'; +import { KEYS } from 'utilities/constants'; const SUB_TIERS = { 1000: 1, @@ -63,6 +64,8 @@ export default class ChatLine extends Module { 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.rituals.show', this.updateLines, this); + this.chat.context.on('changed:chat.subs.show', this.updateLines, this); + this.chat.context.on('changed:chat.subs.compact', 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); this.chat.context.on('changed:chat.rich.all-links', this.updateLines, this); @@ -251,6 +254,7 @@ export default class ChatLine extends Module { this._ffz_show = show; return show !== old_show || + state.ffz_expanded !== this.state.ffz_expanded || //state.renderDebug !== this.state.renderDebug || props.message !== this.props.message || props.isCurrentUserModerator !== this.props.isCurrentUserModerator || @@ -302,8 +306,30 @@ export default class ChatLine extends Module { rich_content = FFZRichContent && t.chat.pluckRichContent(tokens, msg), bg_css = msg.mentioned && msg.mention_color ? t.parent.inverse_colors.process(msg.mention_color) : null; - if ( ! this.ffz_user_click_handler ) - this.ffz_user_click_handler = this.openViewerCard || this.usernameClickHandler; //event => event.ctrlKey ? this.usernameClickHandler(event) : t.viewer_cards.openCard(r, user, event); + if ( ! this.ffz_user_click_handler ) { + if ( this.props.onUsernameClick ) + this.ffz_user_click_handler = event => { + if ( this.isKeyboardEvent(event) && event.keyCode !== KEYS.Space && event.keyCode !== KEYS.Enter ) + return; + + const target = event.currentTarget, + ds = target && target.dataset; + let target_user = user; + + if ( ds && ds.user ) { + try { + target_user = JSON.parse(ds.user); + } catch(err) { /* nothing~! */ } + } + + /*if ( event.ctrlKey ) + t.viewer_cards.openCard(r, target_user, event); + else*/ + this.props.onUsernameClick(target_user.login, 'chat_message', msg.id, target.getBoundingClientRect().bottom); + } + else + this.ffz_user_click_handler = this.openViewerCard || this.usernameClickHandler; //event => event.ctrlKey ? this.usernameClickHandler(event) : t.viewer_cards.openCard(r, user, event); + } let cls = `chat-line__message${show_class ? ' ffz--deleted-message' : ''}`, out = (tokens.length || ! msg.ffz_type) ? [ @@ -356,47 +382,83 @@ export default class ChatLine extends Module { }, JSON.stringify([tokens, msg.emotes], null, 2))*/ ] : null; - if ( msg.ffz_type === 'resub' ) { - const plan = msg.sub_plan || {}, - months = msg.sub_cumulative || msg.sub_months, - tier = SUB_TIERS[plan.plan] || 1; + if ( msg.ffz_type === 'sub_mystery' ) { + const mystery = msg.mystery; + if ( mystery ) + msg.mystery.line = this; - const sub_msg = t.i18n.tList('chat.sub.main', '%{user} subscribed %{plan}.', { - user: e('button', { - className: 'chatter-name', - onClick: this.ffz_user_click_handler - }, e('span', { - className: 'tw-c-text-base tw-strong' - }, user.userDisplayName)), - plan: plan.prime ? - t.i18n.t('chat.sub.twitch-prime', 'with Twitch Prime') : - t.i18n.t('chat.sub.plan', 'at Tier %{tier}', {tier}) + const sub_msg = t.i18n.tList('chat.sub.gift', "%{user} is gifting %{count} Tier %{tier} Sub%{count|en_plural} to %{channel}'s community! ", { + user: (msg.sub_anon || user.username === 'ananonymousgifter') ? + t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : + e('span', { + role: 'button', + className: 'chatter-name', + onClick: this.ffz_user_click_handler + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, user.userDisplayName)), + count: msg.sub_count, + tier: SUB_TIERS[msg.sub_plan] || 1, + channel: msg.roomLogin }); - if ( msg.sub_share_streak && msg.sub_streak ) { - sub_msg.push(t.i18n.t( - 'chat.sub.cumulative-months', - "They've subscribed for %{cumulative} months, currently on a %{streak} month streak!", - { - cumulative: msg.sub_cumulative, - streak: msg.sub_streak - } - )); + if ( msg.sub_total === 1 ) + sub_msg.push(t.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); + else if ( msg.sub_total > 1 ) + sub_msg.push(t.i18n.t('chat.sub.gift-total', "They've gifted %{count} Subs in the channel!", { + count: msg.sub_total + })); - } else if ( months ) { - sub_msg.push(t.i18n.t( - 'chat.sub.months', - "They've subscribed for %{count} months!", - { - count: months - } - )); + if ( ! this.ffz_click_expand ) + this.ffz_click_expand = () => { + this.setState({ + ffz_expanded: ! this.state.ffz_expanded + }); + } + + let sub_list = null; + if( this.state.ffz_expanded && mystery && mystery.recipients && mystery.recipients.length > 0 ) { + const the_list = []; + for(const x of mystery.recipients) { + if ( the_list.length ) + the_list.push(', '); + + the_list.push(e('span', { + role: 'button', + className: 'chatter-name', + onClick: this.ffz_user_click_handler, + 'data-user': JSON.stringify(x) + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, x.displayName))); + } + + sub_list = e('div', { + className: 'tw-mg-t-05 tw-border-t tw-pd-t-05 tw-c-text-alt-2' + }, the_list); } - - cls = 'user-notice-line tw-pd-y-05 tw-pd-r-2 ffz--subscribe-line'; + cls = 'user-notice-line tw-pd-y-05 ffz--subscribe-line'; out = [ - e('div', {className: 'tw-c-text-alt-2'}, sub_msg), + e('div', { + className: 'tw-flex tw-c-text-alt-2', + onClick: this.ffz_click_expand + }, [ + t.chat.context.get('chat.subs.compact') ? null : + e('figure', { + className: `ffz-i-star${msg.sub_anon ? '-empty' : ''} tw-mg-r-05` + }), + e('div', null, [ + (out || msg.sub_anon) ? null : t.actions.renderInline(msg, this.props.showModerationIcons, u, r, e), + sub_msg + ]), + mystery ? e('div', { + className: 'tw-mg-l-05 tw-border-l tw-pd-l-05 ffz--sub-expando' + }, e('figure', { + className: `ffz-i-${this.state.ffz_expanded ? 'down' : 'right'}-dir tw-pd-y-1` + })) : null + ]), + sub_list, out && e('div', { className: 'chat-line--inline chat-line__message', 'data-room-id': this.props.channelID, @@ -406,12 +468,130 @@ export default class ChatLine extends Module { }, out) ]; + } else if ( msg.ffz_type === 'sub_gift' ) { + const plan = msg.sub_plan || {}, + tier = SUB_TIERS[plan.plan] || 1; + + const sub_msg = t.i18n.tList('chat.sub.mystery', '%{user} gifted a %{plan} Sub to %{recipient}!', { + user: (msg.sub_anon || user.username === 'ananonymousgifter') ? + t.i18n.t('chat.sub.anonymous-gifter', 'An anonymous gifter') : + e('span', { + role: 'button', + className: 'chatter-name', + onClick: this.ffz_user_click_handler + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, user.userDisplayName)), + plan: plan.plan === 'custom' ? '' : + t.i18n.t('chat.sub.gift-plan', 'Tier %{tier}', {tier}), + recipient: e('span', { + role: 'button', + className: 'chatter-name', + onClick: this.ffz_user_click_handler, + 'data-user': JSON.stringify(msg.sub_recipient) + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, msg.sub_recipient.displayName)) + }); + + if ( msg.sub_total === 1 ) + sub_msg.push(t.i18n.t('chat.sub.gift-first', "It's their first time gifting a Sub in the channel!")); + else if ( msg.sub_total > 1 ) + sub_msg.push(t.i18n.t('chat.sub.gift-total', "They've gifted %{count} Subs in the channel!", { + count: msg.sub_total + })); + + cls = 'user-notice-line tw-pd-y-05 tw-pd-r-2 ffz--subscribe-line'; + out = [ + e('div', {className: 'tw-flex tw-c-text-alt-2'}, [ + t.chat.context.get('chat.subs.compact') ? null : + e('figure', { + className: 'ffz-i-star tw-mg-r-05' + }), + e('div', null, [ + (out || msg.sub_anon) ? null : t.actions.renderInline(msg, this.props.showModerationIcons, u, r, e), + sub_msg + ]) + ]), + out && e('div', { + className: 'chat-line--inline chat-line__message', + 'data-room-id': this.props.channelID, + 'data-room': room, + 'data-user-id': user.userID, + 'data-user': user.userLogin && user.userLogin.toLowerCase(), + }, out) + ]; + + } else if ( msg.ffz_type === 'resub' ) { + const months = msg.sub_cumulative || msg.sub_months, + setting = t.chat.context.get('chat.subs.show'); + + if ( setting === 3 || (setting === 1 && out && months > 1) || (setting === 2 && months > 1) ) { + const plan = msg.sub_plan || {}, + tier = SUB_TIERS[plan.plan] || 1; + + const sub_msg = t.i18n.tList('chat.sub.main', '%{user} subscribed %{plan}. ', { + user: e('span', { + role: 'button', + className: 'chatter-name', + onClick: this.ffz_user_click_handler + }, e('span', { + className: 'tw-c-text-base tw-strong' + }, user.userDisplayName)), + plan: plan.prime ? + t.i18n.t('chat.sub.twitch-prime', 'with Twitch Prime') : + t.i18n.t('chat.sub.plan', 'at Tier %{tier}', {tier}) + }); + + if ( msg.sub_share_streak && msg.sub_streak > 1 ) { + sub_msg.push(t.i18n.t( + 'chat.sub.cumulative-months', + "They've subscribed for %{cumulative} months, currently on a %{streak} month streak!", + { + cumulative: msg.sub_cumulative, + streak: msg.sub_streak + } + )); + + } else if ( months > 1 ) { + sub_msg.push(t.i18n.t( + 'chat.sub.months', + "They've subscribed for %{count} months!", + { + count: months + } + )); + } + + cls = 'user-notice-line tw-pd-y-05 tw-pd-r-2 ffz--subscribe-line'; + out = [ + e('div', {className: 'tw-flex tw-c-text-alt-2'}, [ + t.chat.context.get('chat.subs.compact') ? null : + e('figure', { + className: `ffz-i-${plan.prime ? 'crown' : 'star'} tw-mg-r-05` + }), + e('div', null, [ + out ? null : t.actions.renderInline(msg, this.props.showModerationIcons, u, r, e), + sub_msg + ]) + ]), + out && e('div', { + className: 'chat-line--inline chat-line__message', + 'data-room-id': this.props.channelID, + 'data-room': room, + 'data-user-id': user.userID, + 'data-user': user.userLogin && user.userLogin.toLowerCase(), + }, out) + ]; + } + } else if ( msg.ffz_type === 'ritual' && t.chat.context.get('chat.rituals.show') ) { let system_msg; if ( msg.ritual === 'new_chatter' ) system_msg = e('div', {className: 'tw-c-text-alt-2'}, [ t.i18n.tList('chat.ritual', '%{user} is new here. Say hello!', { - user: e('button', { + user: e('span', { + role: 'button', className: 'chatter-name', onClick: this.ffz_user_click_handler }, e('span', { diff --git a/src/sites/twitch-twilight/styles/chat.scss b/src/sites/twitch-twilight/styles/chat.scss index fcd51e81..dca3779d 100644 --- a/src/sites/twitch-twilight/styles/chat.scss +++ b/src/sites/twitch-twilight/styles/chat.scss @@ -12,6 +12,11 @@ pointer-events: none; } +.ffz--sub-expando { + font-size: 150%; + margin-right: -.5rem; +} + .chat-list__lines .simplebar-scrollbar { will-change: opacity; } diff --git a/src/utilities/constants.js b/src/utilities/constants.js index 15574bc0..0931cccf 100644 --- a/src/utilities/constants.js +++ b/src/utilities/constants.js @@ -14,6 +14,13 @@ export const LV_SERVER = 'https://cbenni.com/api'; export const LV_SOCKET_SERVER = 'wss://cbenni.com/socket.io/'; +export const KEYS = { + Space: 32, + Enter: 13, + Escape: 27, +}; + + export const TWITCH_EMOTE_BASE = '//static-cdn.jtvnw.net/emoticons/v1/'; export const KNOWN_CODES = {