local chat moderation

pull/3147/head
Thibault Duplessis 2017-06-10 19:48:04 +02:00
parent 55ae490078
commit 5781e5614b
17 changed files with 182 additions and 119 deletions

View File

@ -15,10 +15,11 @@ object ChatJsData {
timeout: Boolean,
public: Boolean, // game players chat is not public
withNote: Boolean = false,
writeable: Boolean = true
writeable: Boolean = true,
localMod: Boolean = false
)(implicit ctx: Context) =
json(
chat.chat, name = name, timeout = timeout, withNote = withNote, writeable = writeable, public = public, restricted = chat.restricted
chat.chat, name = name, timeout = timeout, withNote = withNote, writeable = writeable, public = public, restricted = chat.restricted, localMod = localMod
)
def json(
@ -28,7 +29,8 @@ object ChatJsData {
public: Boolean, // game players chat is not public
withNote: Boolean = false,
writeable: Boolean = true,
restricted: Boolean = false
restricted: Boolean = false,
localMod: Boolean = false
)(implicit ctx: Context) = Json.obj(
"data" -> Json.obj(
"id" -> chat.id,
@ -44,6 +46,7 @@ object ChatJsData {
"public" -> public,
"kobold" -> ctx.troll,
"permissions" -> Json.obj(
"local" -> localMod,
"timeout" -> isGranted(_.ChatTimeout).option(true),
"shadowban" -> isGranted(_.MarkTroll).option(true)
).noNull,

View File

@ -11,7 +11,13 @@ i18n: @board.userAnalysisI18n(),
tagTypes: '@lila.study.PgnTags.typesToString',
userId: @jsUserId,
chat: @jsOrNull(chatOption map { c =>
chat.ChatJsData.json(c.chat, name = trans.chatRoom.txt(), timeout = c.timeout, writeable = ctx.userId.??(s.canChat), public = false)
chat.ChatJsData.json(
c.chat,
name = trans.chatRoom.txt(),
timeout = c.timeout,
writeable = ctx.userId.??(s.canChat),
public = false,
localMod = ctx.userId.??(s.canContribute))
}),
explorer: {
endpoint: "@explorerEndpoint",

View File

@ -63,9 +63,9 @@ final class ChatApi(
lilaBus.publish(actorApi.ChatLine(chatId, line), channelOf(chatId)) inject line.some
}
def timeout(chatId: ChatId, modId: String, userId: String, reason: ChatTimeout.Reason): Funit =
def timeout(chatId: ChatId, modId: String, userId: String, reason: ChatTimeout.Reason, local: Boolean): Funit =
coll.byId[UserChat](chatId) zip UserRepo.byId(modId) zip UserRepo.byId(userId) flatMap {
case ((Some(chat), Some(mod)), Some(user)) if isMod(mod) => doTimeout(chat, mod, user, reason)
case Some(chat) ~ Some(mod) ~ Some(user) if isMod(mod) || local => doTimeout(chat, mod, user, reason)
case _ => fuccess(none)
}
@ -87,18 +87,18 @@ final class ChatApi(
chatTimeout.add(c, mod, user, reason) >>- {
lilaBus.publish(actorApi.OnTimeout(user.username), channelOf(chat.id))
lilaBus.publish(actorApi.ChatLine(chat.id, line), channelOf(chat.id))
modLog ! lila.hub.actorApi.mod.ChatTimeout(
if (isMod(mod)) modLog ! lila.hub.actorApi.mod.ChatTimeout(
mod = mod.id, user = user.id, reason = reason.key
)
}
}
private def isMod(user: User) = lila.security.Granter(_.ChatTimeout)(user)
def reinstate(list: List[ChatTimeout.Reinstate]) = list.foreach { r =>
lilaBus.publish(actorApi.OnReinstate(r.user), Symbol(s"chat-${r.chat}"))
}
private def isMod(user: User) = lila.security.Granter(_.ChatTimeout)(user)
private[ChatApi] def makeLine(chatId: String, userId: String, t1: String): Fu[Option[UserLine]] =
UserRepo.byId(userId) zip chatTimeout.isActive(chatId, userId) map {
case (Some(user), false) if !user.disabled => Writer cut t1 flatMap { t2 =>

View File

@ -15,7 +15,7 @@ private[chat] final class FrontActor(api: ChatApi) extends Actor {
case SystemTalk(chatId, text) => api.userChat.system(chatId, text)
case Timeout(chatId, modId, userId, reason) => api.userChat.timeout(chatId, modId, userId, reason)
case Timeout(chatId, modId, userId, reason, local) => api.userChat.timeout(chatId, modId, userId, reason, local)
case Remove(chatId) => api remove chatId
}

View File

@ -12,7 +12,8 @@ object Socket {
chatId: String,
member: SocketMember,
socket: ActorRef,
chat: ActorSelection
chat: ActorSelection,
canTimeout: Option[() => Fu[Boolean]] = None
): Handler.Controller = {
case ("talk", o) => for {
@ -25,7 +26,9 @@ object Socket {
modId <- member.userId
userId <- data.str("userId")
reason <- data.str("reason") flatMap ChatTimeout.Reason.apply
} chat ! actorApi.Timeout(chatId, modId, userId, reason)
} canTimeout.??(ct => ct()) foreach { localTimeout =>
chat ! actorApi.Timeout(chatId, modId, userId, reason, local = localTimeout)
}
}
type Send = (String, JsValue, Boolean) => Unit

View File

@ -5,7 +5,7 @@ case class UserTalk(chatId: String, userId: String, text: String, public: Boolea
case class PlayerTalk(chatId: String, white: Boolean, text: String)
case class SystemTalk(chatId: String, text: String)
case class ChatLine(chatId: String, line: Line)
case class Timeout(chatId: String, modId: String, userId: String, reason: ChatTimeout.Reason)
case class Timeout(chatId: String, mod: String, userId: String, reason: ChatTimeout.Reason, local: Boolean)
case class OnTimeout(username: String)
case class OnReinstate(userId: String)

View File

@ -225,7 +225,8 @@ private[study] final class SocketHandler(
chatId = studyId.value,
member = member,
socket = socket,
chat = chat
chat = chat,
canTimeout = Some(() => user.?? { u => api.isContributor(studyId, u.id) })
)
private def reading[A](o: JsValue)(f: A => Unit)(implicit reader: Reads[A]): Unit =

View File

@ -249,6 +249,8 @@ final class StudyApi(
} >>- onMembersChange(study)
}
def isContributor = studyRepo.isContributor _
private def onMembersChange(study: Study) = {
lightStudyCache.refresh(study.id)
sendTo(study, Socket.ReloadAll)

View File

@ -126,6 +126,9 @@ final class StudyRepo(private[study] val coll: Coll) {
.sort($sort desc "updatedAt")
.list[Study.IdName](nb, ReadPreference.secondaryPreferred)
def isContributor(studyId: Study.Id, userId: User.ID) =
coll.exists($id(studyId) ++ $doc(s"members.$userId.role" -> "w"))
def like(studyId: Study.Id, userId: User.ID, v: Boolean): Fu[Study.Likes] =
countLikes(studyId).flatMap {
case None => fuccess(Study.Likes(0))

View File

@ -91,6 +91,7 @@ module.exports = function(data, ctrl, tagTypes, practiceData) {
var configureAnalysis = function() {
if (ctrl.embed) return;
lichess.pubsub.emit('chat.writeable')(data.features.chat);
lichess.pubsub.emit('chat.permissions')({local: members.canContribute()});
var computer = data.chapter.features.computer || data.chapter.practice;
if (!computer) ctrl.getCeval().enabled(false);
ctrl.getCeval().allowed(computer);

View File

@ -1,4 +1,4 @@
import { Ctrl, ChatOpts, Line, Tab, ViewModel, Redraw } from './interfaces'
import { Ctrl, ChatOpts, Line, Tab, ViewModel, Redraw, Permissions, ModerationCtrl } from './interfaces'
import { presetCtrl } from './preset'
import { noteCtrl } from './note'
import { moderationCtrl } from './moderation'
@ -9,6 +9,8 @@ export default function(opts: ChatOpts, redraw: Redraw): Ctrl {
const pubsub = window.lichess.pubsub;
let moderation: ModerationCtrl | undefined;
const vm: ViewModel = {
tab: 'discussion',
enabled: !window.lichess.storage.get('nochat'),
@ -52,12 +54,20 @@ export default function(opts: ChatOpts, redraw: Redraw): Ctrl {
const trans = window.lichess.trans(opts.i18n);
const moderation = opts.permissions.timeout && opts.timeoutReasons ? moderationCtrl({
reasons: opts.timeoutReasons,
permissions: opts.permissions,
send: window.lichess.pubsub.emit('socket.send'),
redraw: redraw
}) : undefined;
function canMod() {
return opts.permissions.timeout || opts.permissions.local;
}
function instanciateModeration() {
moderation = canMod() ? moderationCtrl({
reasons: opts.timeoutReasons || ([{key: 'other', name: 'Inappropriate behavior'}]),
permissions: opts.permissions,
send: window.lichess.pubsub.emit('socket.send'),
redraw: redraw
}) : undefined;
if (canMod()) opts.loadCss('/assets/stylesheets/chat.mod.css');
}
instanciateModeration();
const note = data.userId && opts.noteId ? noteCtrl({
id: opts.noteId,
@ -78,6 +88,12 @@ export default function(opts: ChatOpts, redraw: Redraw): Ctrl {
vm.writeable = v;
redraw();
});
pubsub.on('chat.permissions', function(obj: Permissions) {
let p: keyof Permissions;
for (p in obj) opts.permissions[p] = obj[p];
instanciateModeration();
redraw();
});
const emitEnabled = () => pubsub.emit('chat.enabled')(vm.enabled);
emitEnabled();
@ -88,9 +104,9 @@ export default function(opts: ChatOpts, redraw: Redraw): Ctrl {
vm: vm,
setTab(t: Tab) {
vm.tab = t
redraw()
redraw()
},
moderation: moderation,
moderation: () => moderation,
note: note,
preset: preset,
post: post,

View File

@ -25,10 +25,15 @@ export default function(ctrl: Ctrl): Array<VNode | undefined> {
h('ol.messages.content.scroll-shadow-soft', {
hook: {
insert(vnode) {
$(vnode.elm as HTMLElement).on('click', 'a.jump', (e: Event) => {
window.lichess.pubsub.emit('jump')((e.target as HTMLElement).getAttribute('data-ply'));
});
scrollCb(vnode);
$(vnode.elm as HTMLElement)
.on('click', 'a.jump', (e: Event) => {
window.lichess.pubsub.emit('jump')((e.target as HTMLElement).getAttribute('data-ply'));
})
.on('click', '.mod', (e: Event) => {
const m = ctrl.moderation();
if (m) m.open(((e.target as HTMLElement).getAttribute('data-username') as string).split(' ')[0]);
});
scrollCb(vnode);
},
postpatch: (_, vnode) => scrollCb(vnode)
}
@ -124,15 +129,11 @@ function renderLine(ctrl: Ctrl, line: Line) {
h('span', '[' + line.c + ']'),
textNode
]);
var userNode = thunk('a', line.u, userLink, [line.u]);
const userNode = thunk('a', line.u, userLink, [line.u]);
return h('li', {
hook: ctrl.moderation ? bind('click', (e: Event) => {
const target = e.target as HTMLElement;
if (ctrl.moderation && target.classList.contains('mod'))
ctrl.moderation.open((target.getAttribute('data-username') as string).split(' ')[0]);
}) : {}
}, ctrl.moderation ? [
}, ctrl.moderation() ? [
line.u ? lineAction(line.u) : null,
userNode,
textNode

View File

@ -10,6 +10,7 @@ export interface ChatOpts {
i18n: Object
preset?: string
noteId?: string
loadCss: (url: string) => void
}
interface ChatData {
@ -29,7 +30,8 @@ export interface Line {
r?: boolean // troll
}
interface Permissions {
export interface Permissions {
local?: boolean
timeout?: boolean
shadowban?: boolean
}
@ -44,7 +46,7 @@ export interface Ctrl {
vm: ViewModel
preset: PresetCtrl
note?: NoteCtrl
moderation?: ModerationCtrl
moderation(): ModerationCtrl | undefined
post(text: string): boolean
trans: Trans
setTab(tab: Tab): void
@ -112,7 +114,7 @@ export interface ModerationCtrl {
loading(): boolean
data(): ModerationData | undefined
reasons: ModerationReason[]
permissions: Permissions
permissions(): Permissions
open(username: string): void
close(): void
timeout(reason: ModerationReason): void
@ -122,11 +124,11 @@ export interface ModerationCtrl {
export interface ModerationData {
id: string
username: string
games: number
troll: boolean
engine: boolean
booster: boolean
history: ModerationHistoryEntry[]
games?: number
troll?: boolean
engine?: boolean
booster?: boolean
history?: ModerationHistoryEntry[]
}
export interface ModerationReason {

View File

@ -14,6 +14,8 @@ const patch = init([klass, attributes]);
export default function LichessChat(element: Element, opts: ChatOpts) {
opts.loadCss('/assets/stylesheets/chat.css');
const container = element.parentNode as HTMLElement;
let vnode: VNode, ctrl: Ctrl

View File

@ -14,12 +14,19 @@ export function moderationCtrl(opts: ModerationOpts): ModerationCtrl {
let loading = false;
const open = (username: string) => {
loading = true;
userModInfo(username).then(d => {
data = d;
loading = false;
opts.redraw();
});
if (opts.permissions.timeout) {
loading = true;
userModInfo(username).then(d => {
data = d;
loading = false;
opts.redraw();
});
} else {
data = {
id: username,
username: username
};
}
opts.redraw();
};
@ -33,7 +40,7 @@ export function moderationCtrl(opts: ModerationOpts): ModerationCtrl {
loading: () => loading,
data: () => data,
reasons: opts.reasons,
permissions: opts.permissions,
permissions: () => opts.permissions,
open: open,
close: close,
timeout(reason: ModerationReason) {
@ -67,73 +74,85 @@ export function moderationView(ctrl?: ModerationCtrl): VNode[] | undefined {
if (ctrl.loading()) return [h('div.loading', spinner())];
const data = ctrl.data();
if (!data) return;
return [
h('div.top', {
key: 'mod-' + data.id,
}, [
h('span.text', {
attrs: {'data-icon': '' },
}, [userLink(data.username)]),
h('span.toggle_chat', {
attrs: {'data-icon': 'L'},
hook: bind('click', ctrl.close)
})
]),
h('div.content.moderation', [
h('div.infos.block', [
window.lichess.numberFormat(data.games) + ' games',
data.troll ? 'TROLL' : undefined,
data.engine ? 'ENGINE' : undefined,
data.booster ? 'BOOSTER' : undefined
].map(t => t && h('span', t)).concat([
h('a', {
attrs: {
href: '/@/' + data.username + '?mod'
}
}, 'profile')
]).concat(
ctrl.permissions.shadowban ? [
h('a', {
const perms = ctrl.permissions();
const infos = data.history ? h('div.infos.block', [
window.lichess.numberFormat(data.games || 0) + ' games',
data.troll ? 'TROLL' : undefined,
data.engine ? 'ENGINE' : undefined,
data.booster ? 'BOOSTER' : undefined
].map(t => t && h('span', t)).concat([
h('a', {
attrs: {
href: '/@/' + data.username + '?mod'
}
}, 'profile')
]).concat(
perms.shadowban ? [
h('a', {
attrs: {
href: '/mod/' + data.username + '/communication'
}
}, 'coms')
] : [])) : undefined;
const timeout = perms.timeout ? h('div.timeout.block', [
h('h2', 'Timeout 10 minutes for'),
...ctrl.reasons.map(r => {
return h('a.text', {
attrs: { 'data-icon': 'p' },
hook: bind('click', () => ctrl.timeout(r))
}, r.name);
}),
...(
(data.troll || !perms.shadowban) ? [] : [h('div.shadowban', [
'Or ',
h('button.button', {
hook: bind('click', ctrl.shadowban)
}, 'shadowban')
])])
]) : h('div.timeout.block', [
h('h2', 'Moderation'),
h('a.text', {
attrs: { 'data-icon': 'p' },
hook: bind('click', () => ctrl.timeout(ctrl.reasons[0]))
}, 'Timeout 10 minutes')
]);
const history = data.history ? h('div.history.block', [
h('h2', 'Timeout history'),
h('table', h('tbody.slist', {
hook: {
insert: () => window.lichess.pubsub.emit('content_loaded')()
}
}, data.history.map(function(e) {
return h('tr', [
h('td.reason', e.reason),
h('td.mod', e.mod),
h('td', h('time.moment', {
attrs: {
href: '/mod/' + data.username + '/communication'
'data-format': isToday(e.date) ? 'LT' : 'DD/MM/YY',
datetime: new Date(e.date).toISOString()
}
}, 'coms')
] : [])),
h('div.timeout.block', [
h('h2', 'Timeout 10 minutes for'),
...ctrl.reasons.map(r => {
return h('a.text', {
attrs: { 'data-icon': 'p' },
hook: bind('click', () => ctrl.timeout(r))
}, r.name);
}),
...(
(data.troll || !ctrl.permissions.shadowban) ? [] : [h('div.shadowban', [
'Or ',
h('button.button', {
hook: bind('click', ctrl.shadowban)
}, 'shadowban')
])])
}))
]);
})))
]) : undefined;
return [
h('div.top', { key: 'mod-' + data.id }, [
h('span.text', {
attrs: {'data-icon': '' },
}, [userLink(data.username)]),
h('span.toggle_chat', {
attrs: {'data-icon': 'L'},
hook: bind('click', ctrl.close)
})
]),
h('div.history.block', [
h('h2', 'Timeout history'),
h('table', h('tbody.slist', {
hook: {
insert: () => window.lichess.pubsub.emit('content_loaded')()
}
}, data.history.map(function(e) {
return h('tr', [
h('td.reason', e.reason),
h('td.mod', e.mod),
h('td', h('time.moment', {
attrs: {
'data-format': isToday(e.date) ? 'LT' : 'DD/MM/YY',
datetime: new Date(e.date).toISOString()
}
}))
]);
})))
h('div.content.moderation', [
infos,
timeout,
history
])
])
];
];
};

View File

@ -7,11 +7,13 @@ import { bind } from './util'
export default function(ctrl: Ctrl) {
const mod = ctrl.moderation();
return h('div#chat.side_box.mchat', {
class: {
mod: !!ctrl.opts.permissions.timeout
mod: !!mod
}
}, moderationView(ctrl.moderation) || normalView(ctrl))
}, moderationView(mod) || normalView(ctrl))
}
function normalView(ctrl: Ctrl) {

View File

@ -196,9 +196,12 @@ lichess.assetUrl = function(url, opts) {
var version = document.body.getAttribute('data-asset-version');
return baseUrl + url + (opts.noVersion ? '' : '?v=' + version);
};
lichess.loadedCss = {};
lichess.loadCss = function(url) {
if (lichess.loadedCss[url]) return;
lichess.loadedCss[url] = true;
$('head').append($('<link rel="stylesheet" type="text/css" />').attr('href', lichess.assetUrl(url)));
}
};
lichess.loadScript = function(url, opts) {
return $.ajax({
dataType: "script",
@ -226,8 +229,7 @@ lichess.shepherd = function(f) {
};
lichess.makeChat = function(id, data, callback) {
var isDev = document.body.getAttribute('data-dev');
lichess.loadCss('/assets/stylesheets/chat.css');
if (data.permissions.timeout) lichess.loadCss('/assets/stylesheets/chat.mod.css');
data.loadCss = lichess.loadCss;
lichess.loadScript("/assets/compiled/lichess.chat" + (isDev ? '' : '.min') + '.js').done(function() {
(callback || $.noop)(LichessChat.default(document.getElementById(id), data));
});