chat moderation WIP

chat
Thibault Duplessis 2016-06-10 17:15:00 +02:00
parent ade9c162e5
commit bb9d15ba05
17 changed files with 266 additions and 43 deletions

View File

@ -21,7 +21,8 @@ lines: @Html(J.stringify(lila.chat.JsonView(c))),
i18n: @base.chatJsI18n(),
userId: @Html(ctx.userId.fold("null")(id => s""""$id"""")),
kobold: @ctx.troll,
mod: @isGranted(_.MarkTroll)
mod: @isGranted(_.MarkTroll),
timeoutReasons: @Html(J.stringify(lila.chat.JsonView.timeoutReasons))
});
}
}

View File

@ -107,7 +107,10 @@ donation {
other_donors = [ bitchess, st0rmy, arka50, feeglood, eugene_kurmanin, mastertan, lance5500, crosky, medvezhonok ]
}
chat {
collection.chat = chat
collection {
chat = chat
timeout = chat_timeout
}
max_lines = 50
net.domain = ${net.domain}
actor.name = chat

View File

@ -0,0 +1,78 @@
package lila.chat
import lila.db.dsl._
import lila.user.{ User, UserRepo }
import org.joda.time.DateTime
import reactivemongo.bson._
final class ChatTimeout(
chatColl: Coll,
timeoutColl: Coll) {
import ChatTimeout._
private val minutes = 10
def add(chatId: ChatId, modId: String, username: String, reason: Reason): Funit =
chatColl.byId[UserChat](chatId) zip UserRepo.named(modId) zip UserRepo.named(username) flatMap {
case ((Some(chat), Some(mod)), Some(user)) if isMod(mod) => add(chat, mod, user, reason.pp)
case _ => fuccess(none)
}
private def add(chat: UserChat, mod: User, user: User, reason: Reason): Funit =
isActive(chat, user).thenPp flatMap {
case true => funit
case false => timeoutColl.insert($doc(
"_id" -> makeId,
"chat" -> chat.id,
"mod" -> mod.id,
"user" -> user.id,
"reason" -> reason,
"createdAt" -> DateTime.now,
"expiresAt" -> DateTime.now.plusMinutes(minutes))).void
}
def isActive(chat: UserChat, user: User): Fu[Boolean] =
timeoutColl.exists($doc(
"chat" -> chat.id,
"user" -> user.id,
"expiresAt" $exists true))
def activeUserIds(chat: UserChat): Fu[List[String]] =
timeoutColl.primitive[String]($doc(
"chat" -> chat.id,
"expiresAt" $exists true
), "user")
def checkExpired: Funit = timeoutColl.primitive[String]($doc(
"expiresAt" $lt DateTime.now
), "_id") flatMap {
case Nil => funit
case ids => timeoutColl.unsetField($inIds(ids), "expiresAt", multi = true).void
}
private val idSize = 8
private def makeId = scala.util.Random.alphanumeric take idSize mkString
private def isMod(user: User) = lila.security.Granter(_.MarkTroll.pp)(user.pp).pp
}
object ChatTimeout {
sealed abstract class Reason(val key: String, val name: String)
object Reason {
case object PublicShaming extends Reason("shaming", "Public shaming; please use lichess.org/report")
case object Insult extends Reason("insult", "Disrespecting other players")
case object Spam extends Reason("spam", "Spamming the chat")
case object Other extends Reason("other", "Inappropriate behavior")
val all = List(PublicShaming, Insult, Spam, Other)
def apply(key: String) = all.find(_.key == key)
}
implicit val ReasonBSONHandler: BSONHandler[BSONString, Reason] = new BSONHandler[BSONString, Reason] {
def read(b: BSONString) = Reason(b.value) err s"Invalid reason ${b.value}"
def write(x: Reason) = BSONString(x.key)
}
}

View File

@ -14,6 +14,7 @@ final class Env(
private val settings = new {
val CollectionChat = config getString "collection.chat"
val CollectionTimeout = config getString "collection.timeout"
val MaxLinesPerChat = config getInt "max_lines"
val NetDomain = config getString "net.domain"
val ActorName = config getString "actor.name"
@ -27,12 +28,14 @@ final class Env(
maxLinesPerChat = MaxLinesPerChat,
netDomain = NetDomain)
private val tempBan = new TempBan(
coll = chatColl)
private val timeout = new ChatTimeout(
chatColl = chatColl,
timeoutColl = timeoutColl)
system.actorOf(Props(new FrontActor(api, tempBan)), name = ActorName)
system.actorOf(Props(new FrontActor(api, timeout)), name = ActorName)
private[chat] lazy val chatColl = db(CollectionChat)
private[chat] lazy val timeoutColl = db(CollectionTimeout)
}
object Env {

View File

@ -4,10 +4,11 @@ import akka.actor._
import chess.Color
import actorApi._
import lila.common.PimpedJson._
private[chat] final class FrontActor(
api: ChatApi,
tempBan: TempBan) extends Actor {
timeout: ChatTimeout) extends Actor {
def receive = {
@ -20,7 +21,12 @@ private[chat] final class FrontActor(
case SystemTalk(chatId, text, replyTo) =>
api.userChat.system(chatId, text) foreach publish(chatId, replyTo)
case TempBan(chatId, modId, userId) => tempBan.add(chatId, modId, userId)
case Timeout(chatId, member, o) => for {
data o obj "d"
modId <- member.userId
username <- data.str("username")
reason <- data.str("reason") flatMap ChatTimeout.Reason.apply
} timeout.add(chatId, modId, username, reason)
}
def publish(chatId: String, replyTo: ActorRef)(lineOption: Option[Line]) {

View File

@ -11,6 +11,12 @@ object JsonView {
def apply(line: Line): JsValue = lineWriter writes line
lazy val timeoutReasons = Json toJson ChatTimeout.Reason.all
implicit val timeoutReasonWriter: Writes[ChatTimeout.Reason] = OWrites[ChatTimeout.Reason] { r =>
Json.obj( "key" -> r.key, "name" -> r.name)
}
implicit val mixedChatWriter: Writes[MixedChat] = Writes[MixedChat] { c =>
JsArray(c.lines map lineWriter.writes)
}

View File

@ -1,17 +0,0 @@
package lila.chat
import lila.db.dsl._
import lila.user.{ User, UserRepo }
final class TempBan(coll: Coll) {
import Chat.userChatBSONHandler
def add(chatId: ChatId, modId: String, userId: String): Funit =
coll.byId[UserChat](chatId) zip UserRepo.named(modId) zip UserRepo.named(userId) flatMap {
case ((Some(chat), Some(mod)), Some(user)) => add(chat, mod, user)
case _ => fuccess(none)
}
def add(chat: UserChat, mod: User, user: User): Funit = ???
}

View File

@ -2,9 +2,10 @@ package lila.chat
package actorApi
import akka.actor.ActorRef
import play.api.libs.json.JsValue
case class UserTalk(chatId: String, userId: String, text: String, replyTo: ActorRef, public: Boolean = true)
case class PlayerTalk(chatId: String, white: Boolean, text: String, replyTo: ActorRef)
case class SystemTalk(chatId: String, text: String, replyTo: ActorRef)
case class ChatLine(chatId: String, line: Line)
case class TempBan(chatId: String, modId: String, userId: String)
case class Timeout(chatId: String, member: lila.socket.SocketMember, data: JsValue)

View File

@ -78,8 +78,8 @@ trait CollExt { self: dsl with QueryBuilderExt =>
def incFieldUnchecked(selector: BSONDocument, field: String, value: Int = 1) =
coll.uncheckedUpdate(selector, $inc(field -> value))
def unsetField(selector: BSONDocument, field: String) =
coll.update(selector, $unset(field))
def unsetField(selector: BSONDocument, field: String, multi: Boolean = false) =
coll.update(selector, $unset(field), multi = multi)
def fetchUpdate[D: BSONDocumentHandler](selector: BSONDocument)(update: D => BSONDocument): Funit =
uno[D](selector) flatMap {

View File

@ -6,9 +6,9 @@ import akka.actor._
import akka.pattern.ask
import actorApi._
import akka.actor.ActorSelection
import lila.common.PimpedJson._
import lila.hub.actorApi.map._
import akka.actor.ActorSelection
import lila.socket.actorApi.{ Connected => _, _ }
import lila.socket.Handler
import lila.user.User
@ -48,10 +48,7 @@ private[simul] final class SocketHandler(
chat ! lila.chat.actorApi.UserTalk(simId, userId, text, socket)
}
}
case ("temp-ban", o) => o str "d" foreach { userId =>
member.userId foreach { modId =>
chat ! lila.chat.actorApi.TempBan(simId, modId, userId)
}
}
case ("timeout", o) =>
chat ! lila.chat.actorApi.Timeout(simId, member, o)
}
}

View File

@ -148,7 +148,7 @@ object ApplicationBuild extends Build {
libraryDependencies ++= provided(play.api)
)
lazy val chat = project("chat", Seq(common, db, user, security, i18n)).settings(
lazy val chat = project("chat", Seq(common, db, user, security, i18n, socket)).settings(
libraryDependencies ++= provided(play.api, RM)
)

View File

@ -114,6 +114,7 @@ lichess.shepherd = function(f) {
});
};
lichess.makeChat = function(id, data) {
if (data.mod) lichess.loadCss('/assets/stylesheets/chat.mod.css');
lichess.loadScript('/assets/compiled/lichess.chat.js').then(function() {
lichess.chat = LichessChat(document.getElementById(id), data);
});

View File

@ -1016,7 +1016,7 @@ div.under_chat .watchers:hover {
#chat div.top {
position: relative;
}
#chat div.top input.toggle_chat {
#chat div.top .toggle_chat {
cursor: pointer;
position: absolute;
top: 4px;
@ -1058,9 +1058,9 @@ body.no-select #chat li {
opacity: 1;
}
#chat .messages li {
padding-left: 3px;
padding: 0.4em 0 0.4em 3px;
line-height: 14px;
margin: 0.8em 0 0.8em 3px;
margin: 0;
}
#chat .messages li.system {
padding: 2px 0;

View File

@ -0,0 +1,57 @@
div.mchat.mod li {
position: relative;
}
div.mchat.mod i.mod {
display: none;
position: absolute;
top: 0;
right: 0;
cursor: pointer;
margin-right: 3px;
padding: 4px 5px;
opacity: 0.4;
background: #fff;
color: #dc322f;
}
div.mchat.mod li:hover {
background: rgba(255,255,255,0.6);
}
body.dark div.mchat.mod li:hover {
background: rgba(255,255,255,0.05);
}
div.mchat.mod li:hover i.mod {
display: block;
}
div.mchat.mod i.mod:hover {
opacity: 1;
}
div.mchat.mod .timeout {
padding: 10px;
}
div.mchat.mod .timeout h2 {
text-transform: uppercase;
letter-spacing: 1px;
margin: 5px 0 10px 0;
}
div.mchat.mod .timeout a {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #3893E8;
padding: 2px 0;
transition: 0.3s;
margin-left: -10px;
}
div.mchat.mod .timeout a::before {
opacity: 0;
transition: 0.3s;
}
div.mchat.mod .timeout a:hover {
margin-left: 0;
}
div.mchat.mod .timeout a:hover::before {
opacity: 1;
margin-right: 2px;
}

View File

@ -1,5 +1,6 @@
var m = require('mithril');
var makeSocket = require('./socket');
var makeModeration = require('./moderation').ctrl;
module.exports = function(opts) {
@ -8,10 +9,16 @@ module.exports = function(opts) {
var vm = {
isTroll: opts.kobold,
isMod: opts.mod,
placeholderKey: 'talkInChat'
placeholderKey: 'talkInChat',
moderating: m.prop(null),
loading: m.prop(false)
};
var socket = makeSocket(opts.socketSend);
var socket = makeSocket(lichess.socket.send);
var moderation = vm.isMod ? makeModeration({
reasons: opts.timeoutReasons,
send: socket.send
}) : null;
return {
lines: lines,
@ -23,7 +30,7 @@ module.exports = function(opts) {
alert('Max length: 140 chars. ' + text.length + ' chars used.');
return false;
}
lichess.socket.send('talk', text);
socket.send('talk', text);
return false;
},
newLine: function(line) {
@ -31,6 +38,7 @@ module.exports = function(opts) {
lines.push(line);
m.redraw();
},
moderation: moderation,
trans: lichess.trans(opts.i18n),
};
};

View File

@ -0,0 +1,67 @@
var m = require('mithril');
module.exports = {
ctrl: function(opts) {
var vm = {
data: m.prop(null),
// data: m.prop({
// username: 'Foobidoo'
// }),
loading: m.prop(false)
};
var close = function() {
vm.data(null);
};
return {
vm: vm,
reasons: opts.reasons,
open: function(username) {
vm.data({
username: username
});
m.redraw();
},
close: close,
timeout: function(reason) {
opts.send('timeout', {
username: vm.data().username,
reason: reason
});
close();
}
}
},
view: {
lineAction: m('i', {
class: 'mod',
'data-icon': '',
title: 'Moderation'
}),
ui: function(ctrl) {
if (!ctrl) return;
var data = ctrl.vm.data();
if (!data) return;
return m('div.moderation', [
m('div.top', [
m('span.toggle_chat', {
'data-icon': 'L',
onclick: ctrl.close
}),
m('span.text', {
'data-icon': '',
}, data.username)
]),
m('div.timeout', [
m('h2', 'Timeout 10 minutes for:'),
ctrl.reasons.map(function(r) {
return m('a.text[data-icon=p]', {
onclick: function() {
ctrl.timeout(r.key)
}
}, r.name);
})
])
]);
}
}
};

View File

@ -1,16 +1,18 @@
var m = require('mithril');
var moderationView = require('./moderation').view;
function renderLine(ctrl) {
return function(line) {
return m('li', [
ctrl.vm.isMod ? moderationView.lineAction : null,
m.trust($.userLinkLimit(line.u, 14)),
line.t
]);
};
}
module.exports = function(ctrl) {
return m('div.mchat', [
function discussion(ctrl) {
return m('div.discussion', [
m('div.top', [
m('span', ctrl.trans('chatRoom')),
m('input', {
@ -21,6 +23,9 @@ module.exports = function(ctrl) {
]),
m('ol.messages.content.scroll-shadow-soft', {
config: function(el, isUpdate, ctx) {
if (!isUpdate && ctrl.moderation) $(el).on('click', 'i.mod', function(e) {
ctrl.moderation.open($(e.target).parent().find('.user_link').text());
});
var autoScroll = (el.scrollTop === 0 || (el.scrollTop > (el.scrollHeight - el.clientHeight - 150)));
el.scrollTop = 999999;
if (autoScroll) setTimeout(function() {
@ -44,5 +49,12 @@ module.exports = function(ctrl) {
});
}
})
]);
])
}
module.exports = function(ctrl) {
return m('div', {
class: 'mchat' + (ctrl.vm.isMod ? ' mod' : '')
},
moderationView.ui(ctrl.moderation) || discussion(ctrl));
};