2018-04-28 17:56:03 -04:00
'use strict' ;
// ============================================================================
// Emoji Handling
// ============================================================================
import Module from 'utilities/module' ;
import { has , maybe _call , deep _copy } from 'utilities/object' ;
2019-04-29 18:14:04 -04:00
import { createElement , ClickOutside } from 'utilities/dom' ;
2018-04-28 17:56:03 -04:00
import Tooltip from 'utilities/tooltip' ;
import * as ACTIONS from './types' ;
import * as RENDERERS from './renderers' ;
2019-04-29 18:14:04 -04:00
import { transformPhrase } from 'src/i18n' ;
2018-04-28 17:56:03 -04:00
2019-04-29 18:14:04 -04:00
const VAR _REPLACE = /\{\{(.*?)(?:\|(.*?))?\}\}/g ;
2018-04-28 17:56:03 -04:00
export default class Actions extends Module {
constructor ( ... args ) {
super ( ... args ) ;
this . inject ( 'settings' ) ;
this . inject ( 'tooltips' ) ;
this . inject ( 'i18n' ) ;
this . actions = { } ;
this . renderers = { } ;
2019-04-29 18:14:04 -04:00
this . settings . add ( 'chat.actions.reasons' , {
default : [
{ v : { text : 'One-Man Spam' , i18n : 'chat.reasons.spam' } } ,
{ v : { text : 'Posting Bad Links' , i18n : 'chat.reasons.links' } } ,
{ v : { text : 'Ban Evasion' , i18n : 'chat.reasons.evasion' } } ,
{ v : { text : 'Threats / Personal Info' , i18n : 'chat.reasons.personal' } } ,
{ v : { text : 'Hate / Harassment' , i18n : 'chat.reasons.hate' } } ,
{ v : { text : 'Ignoring Broadcaster / Moderators' , i18n : 'chat.reason.ignore' } }
] ,
type : 'array_merge' ,
always _inherit : true ,
2019-05-03 22:36:26 -04:00
// Clean up after Vue being stupid.
process ( ctx , val ) {
if ( Array . isArray ( val ) )
for ( const entry of val )
if ( entry . i18n && typeof entry . i18n !== 'string' )
delete entry . i18n ;
return val ;
} ,
2019-04-29 18:14:04 -04:00
ui : {
2019-05-03 19:30:46 -04:00
path : 'Chat > Actions > Reasons >> Custom Reasons' ,
2019-04-29 18:14:04 -04:00
component : 'chat-reasons' ,
}
} ) ;
2018-04-28 17:56:03 -04:00
this . settings . add ( 'chat.actions.inline' , {
// Filter out actions
process : ( ctx , val ) =>
val . filter ( x => x . appearance &&
this . renderers [ x . appearance . type ] &&
( ! this . renderers [ x . appearance . type ] . load || this . renderers [ x . appearance . type ] . load ( x . appearance ) ) &&
( ! x . action || this . actions [ x . action ] )
) ,
default : [
2018-09-03 14:32:58 -04:00
{ v : { action : 'ban' , appearance : { type : 'icon' , icon : 'ffz-i-block' } , options : { } , display : { mod : true , mod _icons : true , deleted : false } } } ,
{ v : { action : 'unban' , appearance : { type : 'icon' , icon : 'ffz-i-ok' } , options : { } , display : { mod : true , mod _icons : true , deleted : true } } } ,
2018-04-28 17:56:03 -04:00
{ v : { action : 'timeout' , appearance : { type : 'icon' , icon : 'ffz-i-clock' } , display : { mod : true , mod _icons : true } } } ,
2018-12-05 14:51:03 -05:00
{ v : { action : 'msg_delete' , appearance : { type : 'icon' , icon : 'ffz-i-trash' } , options : { } , display : { mod : true , mod _icons : true } } }
2018-04-28 17:56:03 -04:00
] ,
type : 'array_merge' ,
ui : {
2019-04-29 18:14:04 -04:00
path : 'Chat > Actions > In-Line @{"description": "Here, you can define custom actions that will appear along messages in chat. If you aren\'t seeing an action you\'ve defined here in chat, please make sure that you have enabled Mod Icons in the chat settings menu."}' ,
2018-04-28 17:56:03 -04:00
component : 'chat-actions' ,
2018-10-16 02:13:14 -04:00
context : [ 'user' , 'room' , 'message' ] ,
2018-04-28 17:56:03 -04:00
inline : true ,
data : ( ) => {
const chat = this . resolve ( 'site.chat' ) ;
return {
color : val => chat && chat . colors ? chat . colors . process ( val ) : val ,
actions : deep _copy ( this . actions ) ,
renderers : deep _copy ( this . renderers )
}
}
}
} ) ;
2019-05-03 19:30:46 -04:00
this . settings . add ( 'chat.actions.rules-as-reasons' , {
default : true ,
ui : {
path : 'Chat > Actions > Reasons >> Rules' ,
component : 'setting-check-box' ,
title : "Include the current room's rules in the list of reasons."
}
} ) ;
2018-07-13 14:32:12 -04:00
this . settings . add ( 'chat.actions.viewer-card' , {
// Filter out actions
process : ( ctx , val ) =>
2018-07-19 22:03:01 -04:00
val . filter ( x => x . type || ( x . appearance &&
2018-07-13 14:32:12 -04:00
this . renderers [ x . appearance . type ] &&
( ! this . renderers [ x . appearance . type ] . load || this . renderers [ x . appearance . type ] . load ( x . appearance ) ) &&
( ! x . action || this . actions [ x . action ] )
2018-07-19 22:03:01 -04:00
) ) ,
2018-07-13 14:32:12 -04:00
default : [
{ v : { action : 'friend' } } ,
{ v : { action : 'whisper' , appearance : { type : 'text' , text : 'Whisper' , button : true } } } ,
{ v : { type : 'space' } } ,
{ v : { action : 'card_menu' } } ,
{ v : { type : 'new-line' } } ,
{ v : { action : 'ban' , appearance : { type : 'icon' , icon : 'ffz-i-block' } , display : { mod : true } } } ,
{ v : { action : 'timeout' , appearance : { type : 'icon' , icon : 'ffz-i-clock' } , display : { mod : true } } }
] ,
type : 'array_merge' ,
_ui : {
path : 'Chat > Viewer Cards >> tabs ~> Actions @{"description": "Here, you define what actions are available on viewer cards."}' ,
component : 'chat-actions' ,
2018-07-19 22:03:01 -04:00
context : [ 'user' , 'room' , 'product' ] ,
2018-07-13 14:32:12 -04:00
data : ( ) => {
const chat = this . resolve ( 'site.chat' ) ;
return {
color : val => chat && chat . colors ? chat . colors . process ( val ) : val ,
actions : deep _copy ( this . actions ) ,
renderers : deep _copy ( this . renderers )
}
}
}
} )
2018-04-28 17:56:03 -04:00
this . handleClick = this . handleClick . bind ( this ) ;
this . handleContext = this . handleContext . bind ( this ) ;
}
onEnable ( ) {
this . tooltips . types . action = ( target , tip ) => {
const data = this . getData ( target ) ;
if ( ! data )
return this . i18n . t ( 'chat.actions.unknown' , 'Unknown Action Type' ) ;
if ( ! data . definition . tooltip )
return ` Error: The " ${ data . action } " action provider does not have tooltip support. ` ;
if ( data . tip && data . tip . length )
return data . tip ;
return maybe _call ( data . definition . tooltip , this , data , target , tip ) ;
}
for ( const key in ACTIONS )
if ( has ( ACTIONS , key ) )
this . addAction ( key , ACTIONS [ key ] ) ;
for ( const key in RENDERERS )
if ( has ( RENDERERS , key ) )
this . addRenderer ( key , RENDERERS [ key ] ) ;
}
addAction ( key , data ) {
if ( has ( this . actions , key ) )
return this . log . warn ( ` Attempted to add action " ${ key } " which is already defined. ` ) ;
this . actions [ key ] = data ;
for ( const ctx of this . settings . _ _contexts )
ctx . update ( 'chat.actions.inline' ) ;
}
addRenderer ( key , data ) {
if ( has ( this . renderers , key ) )
return this . log . warn ( ` Attempted to add renderer " ${ key } " which is already defined. ` ) ;
this . renderers [ key ] = data ;
for ( const ctx of this . settings . _ _contexts )
ctx . update ( 'chat.actions.inline' ) ;
}
2019-04-29 18:14:04 -04:00
replaceVariables ( text , data ) {
return transformPhrase (
text ,
data ,
this . i18n . locale ,
VAR _REPLACE ,
{ }
) ;
}
renderInlineReasons ( data , t , tip ) {
const reasons = this . parent . context . get ( 'chat.actions.reasons' ) ,
reason _elements = [ ] ,
2019-05-03 19:30:46 -04:00
room = this . parent . context . get ( 'chat.actions.rules-as-reasons' ) && this . parent . getRoom ( data . room . id , data . room . login , true ) ,
2019-04-29 18:14:04 -04:00
rules = room && room . rules ;
if ( ! reasons && ! rules ) {
tip . hide ( ) ;
return null ;
}
const click _fn = reason => e => {
tip . hide ( ) ;
data . definition . click . call ( this , e , Object . assign ( { reason } , data ) ) ;
e . preventDefault ( ) ;
return false ;
} ;
2019-04-29 21:14:23 -04:00
if ( reasons && reasons . length ) {
for ( const reason of reasons ) {
2019-05-03 19:30:46 -04:00
const text = this . replaceVariables ( ( typeof reason . i18n === 'string' ) ? this . i18n . t ( reason . i18n , reason . text ) : reason . text , data ) ;
2019-04-29 21:14:23 -04:00
reason _elements . push ( < li class = "tw-full-width tw-relative" >
< a
href = "#"
onClick = { click _fn ( text ) }
class = "tw-block tw-full-width tw-interactable tw-interactable--inverted tw-interactive tw-pd-05"
>
{ text }
< / a >
< / li > )
}
2019-04-29 18:14:04 -04:00
}
2019-04-29 21:14:23 -04:00
if ( rules && rules . length ) {
if ( reasons && reasons . length )
reason _elements . push ( < div class = "tw-mg-y-05 tw-border-b" > < / div > ) ;
for ( const rule of rules ) {
reason _elements . push ( < li class = "tw-full-width tw-relative" >
< a
href = "#"
onClick = { click _fn ( rule ) }
class = "tw-block tw-full-width tw-interactable tw-interactable--inverted tw-interactive tw-pd-05"
>
{ rule }
< / a >
< / li > ) ;
}
2019-04-29 18:14:04 -04:00
}
let reason _text ;
if ( data . definition . reason _text )
reason _text = data . definition . reason _text . call ( this , data , t , tip ) ;
else
reason _text = this . i18n . t ( 'chat.actions.select-reason' , 'Please select a reason from the list below:' ) ;
return ( < div class = "ffz--inline-reasons" >
{ reason _text ? < div class = "tw-pd-05 tw-border-b" >
{ reason _text }
< / div > : null }
< ul > { reason _elements } < / ul >
< / div > ) ;
}
2018-04-28 17:56:03 -04:00
renderInlineContext ( target , data ) {
if ( target . _ffz _destroy )
return target . _ffz _destroy ( ) ;
const destroy = target . _ffz _destroy = ( ) => {
if ( target . _ffz _outside )
target . _ffz _outside . destroy ( ) ;
if ( target . _ffz _popup ) {
const fp = target . _ffz _popup ;
target . _ffz _popup = null ;
fp . destroy ( ) ;
}
target . _ffz _destroy = target . _ffz _outside = null ;
}
2019-04-29 18:14:04 -04:00
const definition = data . definition ;
let content ;
if ( definition . context )
content = ( t , tip ) => definition . context . call ( this , data , t , tip ) ;
else if ( definition . uses _reason ) {
content = ( t , tip ) => this . renderInlineReasons ( data , t , tip ) ;
} else
return ;
2019-04-12 17:34:01 -04:00
const parent = document . body . querySelector ( '#root>div' ) || document . body ,
2018-04-28 17:56:03 -04:00
tt = target . _ffz _popup = new Tooltip ( parent , target , {
logger : this . log ,
manual : true ,
2019-04-29 18:14:04 -04:00
live : false ,
2018-04-28 17:56:03 -04:00
html : true ,
2018-09-25 18:37:14 -04:00
tooltipClass : 'ffz-action-balloon tw-balloon tw-block tw-border tw-elevation-1 tw-border-radius-small tw-c-background-base' ,
2018-04-28 17:56:03 -04:00
arrowClass : 'tw-balloon__tail tw-overflow-hidden tw-absolute' ,
2018-09-25 18:37:14 -04:00
arrowInner : 'tw-balloon__tail-symbol tw-border-t tw-border-r tw-border-b tw-border-l tw-border-radius-small tw-c-background-base tw-absolute' ,
2019-04-29 18:14:04 -04:00
innerClass : '' ,
2018-04-28 17:56:03 -04:00
popper : {
placement : 'bottom' ,
modifiers : {
preventOverflow : {
boundariesElement : parent
} ,
flip : {
behavior : [ 'bottom' , 'top' , 'left' , 'right' ]
}
}
} ,
2019-04-29 18:14:04 -04:00
content ,
2018-04-28 17:56:03 -04:00
onShow : ( t , tip ) =>
setTimeout ( ( ) => {
target . _ffz _outside = new ClickOutside ( tip . outer , destroy )
} ) ,
onHide : destroy
} ) ;
tt . _enter ( target ) ;
}
2019-04-18 21:07:11 -04:00
getUserLevel ( room , user ) { // eslint-disable-line class-methods-use-this
if ( ! room || ! user )
return 0 ;
if ( room . id === user . id || room . login === user . login )
return 5 ;
else if ( user . moderator || user . type === 'mod' || ( user . badges && user . badges . moderator ) )
return 3 ;
return 0 ;
}
2018-04-28 17:56:03 -04:00
renderInline ( msg , mod _icons , current _user , current _room , createElement ) {
const actions = [ ] ;
2018-05-18 02:10:00 -04:00
if ( msg . user && current _user && current _user . login === msg . user . login )
2018-04-28 17:56:03 -04:00
return ;
2019-04-18 21:07:11 -04:00
const current _level = this . getUserLevel ( current _room , current _user ) ,
msg _level = this . getUserLevel ( current _room , msg . user ) ;
2018-04-28 17:56:03 -04:00
const chat = this . resolve ( 'site.chat' ) ;
for ( const data of this . parent . context . get ( 'chat.actions.inline' ) ) {
if ( ! data . action || ! data . appearance )
continue ;
const ap = data . appearance || { } ,
disp = data . display || { } ,
def = this . renderers [ ap . type ] ;
if ( ! def || disp . disabled ||
2018-05-10 19:56:39 -04:00
( disp . mod _icons != null && disp . mod _icons !== ! ! mod _icons ) ||
2019-04-18 21:07:11 -04:00
( disp . mod != null && disp . mod !== ( current _level > msg _level ) ) ||
2018-05-10 19:56:39 -04:00
( disp . staff != null && disp . staff !== ( current _user ? ! ! current _user . staff : false ) ) ||
( disp . deleted != null && disp . deleted !== ! ! msg . deleted ) )
2018-04-28 17:56:03 -04:00
continue ;
const has _color = def . colored && ap . color ,
color = has _color && ( chat && chat . colors ? chat . colors . process ( ap . color ) : ap . color ) ,
contents = def . render . call ( this , ap , createElement , color ) ;
actions . push ( < button
class = { ` ffz-tooltip ffz-mod-icon mod-icon tw-c-text-alt-2 ${ has _color ? ' colored' : '' } ` }
data - tooltip - type = "action"
data - action = { data . action }
data - options = { data . options ? JSON . stringify ( data . options ) : null }
data - tip = { ap . tooltip }
onClick = { this . handleClick }
onContextMenu = { this . handleContext }
>
{ contents }
< / button > ) ;
}
if ( ! actions . length )
return null ;
const room = current _room && JSON . stringify ( current _room ) ,
user = msg . user && JSON . stringify ( {
2018-05-18 02:10:00 -04:00
login : msg . user . login ,
displayName : msg . user . displayName ,
id : msg . user . id ,
type : msg . user . type
2018-04-28 17:56:03 -04:00
} ) ;
return ( < div
2018-07-16 13:57:56 -04:00
class = "ffz--inline-actions ffz-action-data tw-inline-block tw-mg-r-05"
2018-04-28 17:56:03 -04:00
data - msg - id = { msg . id }
data - user = { user }
data - room = { room }
>
{ actions }
< / div > ) ;
}
getData ( element ) {
const ds = element . dataset ,
2018-07-13 14:32:12 -04:00
parent = element . closest ( '.ffz-action-data' ) ,
2018-04-28 17:56:03 -04:00
pds = parent && parent . dataset ,
action = ds && ds . action ,
definition = this . actions [ action ] ;
if ( ! definition )
return null ;
const user = pds && pds . user ? JSON . parse ( pds . user ) : null ,
room = pds && pds . room ? JSON . parse ( pds . room ) : null ,
message _id = pds && pds . msgId ,
data = {
action ,
definition ,
tip : ds . tip ,
options : ds . options ? JSON . parse ( ds . options ) : null ,
user ,
room ,
message _id
} ;
if ( definition . defaults )
data . options = Object . assign ( { } , maybe _call ( definition . defaults , this , data , element ) , data . options ) ;
return data ;
}
handleClick ( event ) {
const target = event . target ,
data = this . getData ( target ) ;
if ( ! data )
return ;
if ( ! data . definition . click ) {
if ( data . definition . context )
return this . handleContext ( event ) ;
return this . log . warn ( ` No click handler for action provider " ${ data . action } " ` ) ;
}
if ( target . _ffz _tooltip$0 )
target . _ffz _tooltip$0 . hide ( ) ;
return data . definition . click . call ( this , event , data ) ;
}
handleContext ( event ) {
if ( event . shiftKey )
return ;
event . preventDefault ( ) ;
const target = event . target ,
data = this . getData ( event . target ) ;
if ( ! data )
return ;
if ( target . _ffz _tooltip$0 )
target . _ffz _tooltip$0 . hide ( ) ;
2019-04-29 18:14:04 -04:00
if ( ! data . definition . context && ! data . definition . uses _reason )
return ;
2018-04-28 17:56:03 -04:00
this . renderInlineContext ( event . target , data ) ;
}
sendMessage ( room , message ) {
return this . resolve ( 'site.chat' ) . sendMessage ( room , message ) ;
}
}