From a2735be31467d9fcc62efc40781d67b7072ecda4 Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Fri, 10 Jun 2016 20:20:56 +0200 Subject: [PATCH] new chat WIP --- conf/base.conf | 4 +++ modules/chat/src/main/Chat.scala | 7 +++++ modules/chat/src/main/ChatApi.scala | 34 +++++++++++++++++++++---- modules/chat/src/main/ChatTimeout.scala | 34 ++++++++++--------------- modules/chat/src/main/Env.scala | 18 ++++++++++--- modules/chat/src/main/FrontActor.scala | 6 ++--- modules/chat/src/main/JsonView.scala | 10 +++++--- modules/chat/src/main/Line.scala | 14 +++++++++- modules/chat/src/main/actorApi.scala | 2 ++ modules/simul/src/main/Socket.scala | 16 ++++++++++-- public/stylesheets/common.css | 10 ++++++++ ui/chat/src/ctrl.js | 15 +++++++++-- ui/chat/src/socket.js | 10 +++++--- ui/chat/src/view.js | 20 +++++++++++++-- 14 files changed, 153 insertions(+), 47 deletions(-) diff --git a/conf/base.conf b/conf/base.conf index fade4152b5..004375bf1e 100644 --- a/conf/base.conf +++ b/conf/base.conf @@ -114,6 +114,10 @@ chat { max_lines = 50 net.domain = ${net.domain} actor.name = chat + timeout { + duration = 20 seconds + check_every = 5 seconds + } } puzzle { mongodb { diff --git a/modules/chat/src/main/Chat.scala b/modules/chat/src/main/Chat.scala index 01fb0efeac..28969aceae 100644 --- a/modules/chat/src/main/Chat.scala +++ b/modules/chat/src/main/Chat.scala @@ -21,6 +21,13 @@ case class UserChat( def forUser(u: Option[User]) = u.??(_.troll).fold(this, copy(lines = lines filterNot (_.troll))) + + def markDeleted(u: User) = copy( + lines = lines.map { l => + if (l.userId == u.id) l.delete else l + }) + + def add(line: UserLine) = copy(lines = lines :+ line) } case class MixedChat( diff --git a/modules/chat/src/main/ChatApi.scala b/modules/chat/src/main/ChatApi.scala index d941ecd4ce..c428d3c795 100644 --- a/modules/chat/src/main/ChatApi.scala +++ b/modules/chat/src/main/ChatApi.scala @@ -7,8 +7,10 @@ import lila.user.{ User, UserRepo } final class ChatApi( coll: Coll, + chatTimeout: ChatTimeout, flood: lila.security.Flood, shutup: akka.actor.ActorSelection, + lilaBus: lila.common.Bus, maxLinesPerChat: Int, netDomain: String) { @@ -23,7 +25,7 @@ final class ChatApi( findOption(chatId) map (_ | Chat.makeUser(chatId)) def write(chatId: ChatId, userId: String, text: String, public: Boolean): Fu[Option[UserLine]] = - makeLine(userId, text) flatMap { + makeLine(chatId, userId, text) flatMap { _ ?? { line => pushLine(chatId, line) >>- { shutup ! public.fold( @@ -38,14 +40,36 @@ final class ChatApi( pushLine(chatId, line) inject line.some } - private[ChatApi] def makeLine(userId: String, t1: String): Fu[Option[UserLine]] = UserRepo byId userId map { - _ flatMap { user => - Writer cut t1 ifFalse user.disabled flatMap { t2 => + def timeout(chatId: ChatId, modId: String, username: String, reason: ChatTimeout.Reason): Funit = + coll.byId[UserChat](chatId) zip UserRepo.named(modId) zip UserRepo.named(username) flatMap { + case ((Some(chat), Some(mod)), Some(user)) if isMod(mod) => doTimeout(chat, mod, user, reason) + case _ => fuccess(none) + } + + private def doTimeout(c: UserChat, mod: User, user: User, reason: ChatTimeout.Reason): Funit = { + val line = UserLine( + username = systemUserId, + text = s"${user.username} was timed out 10 minutes for ${reason.name}.", + troll = false, deleted = false) + val chat = c.markDeleted(user) add line + coll.update($id(chat.id), chat).void >> + chatTimeout.add(c, mod, user, reason) >>- { + val channel = Symbol(s"chat-${chat.id}") + lilaBus.publish(actorApi.MarkDeleted(user.username), channel) + lilaBus.publish(actorApi.ChatLine(chat.id, line), channel) + } + } + + private def isMod(user: User) = lila.security.Granter(_.MarkTroll)(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 => flood.allowMessage(user.id, t2) option UserLine(user.username, Writer preprocessUserInput t2, troll = user.troll, deleted = false) } + case _ => none } - } } object playerChat { diff --git a/modules/chat/src/main/ChatTimeout.scala b/modules/chat/src/main/ChatTimeout.scala index 5342fb9936..c7e54e19c9 100644 --- a/modules/chat/src/main/ChatTimeout.scala +++ b/modules/chat/src/main/ChatTimeout.scala @@ -5,23 +5,17 @@ import lila.user.{ User, UserRepo } import org.joda.time.DateTime import reactivemongo.bson._ +import scala.concurrent.duration._ final class ChatTimeout( chatColl: Coll, - timeoutColl: Coll) { + timeoutColl: Coll, + duration: FiniteDuration) { 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) - case _ => fuccess(none) - } - - private def add(chat: UserChat, mod: User, user: User, reason: Reason): Funit = - isActive(chat, user) flatMap { + def add(chat: UserChat, mod: User, user: User, reason: Reason): Funit = + isActive(chat.id, user.id) flatMap { case true => funit case false => timeoutColl.insert($doc( "_id" -> makeId, @@ -30,13 +24,13 @@ final class ChatTimeout( "user" -> user.id, "reason" -> reason, "createdAt" -> DateTime.now, - "expiresAt" -> DateTime.now.plusMinutes(minutes))).void + "expiresAt" -> DateTime.now.plusSeconds(duration.toSeconds.toInt))).void } - def isActive(chat: UserChat, user: User): Fu[Boolean] = + def isActive(chatId: String, userId: User.ID): Fu[Boolean] = timeoutColl.exists($doc( - "chat" -> chat.id, - "user" -> user.id, + "chat" -> chatId, + "user" -> userId, "expiresAt" $exists true)) def activeUserIds(chat: UserChat): Fu[List[String]] = @@ -55,8 +49,6 @@ final class ChatTimeout( private val idSize = 8 private def makeId = scala.util.Random.alphanumeric take idSize mkString - - private def isMod(user: User) = lila.security.Granter(_.MarkTroll)(user) } object ChatTimeout { @@ -64,10 +56,10 @@ 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") + 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) } diff --git a/modules/chat/src/main/Env.scala b/modules/chat/src/main/Env.scala index fdb8c9318d..7a41661204 100644 --- a/modules/chat/src/main/Env.scala +++ b/modules/chat/src/main/Env.scala @@ -2,6 +2,7 @@ package lila.chat import akka.actor.{ ActorSystem, Props, ActorSelection } import com.typesafe.config.Config +import scala.concurrent.duration._ import lila.common.PimpedConfig._ @@ -18,21 +19,30 @@ final class Env( val MaxLinesPerChat = config getInt "max_lines" val NetDomain = config getString "net.domain" val ActorName = config getString "actor.name" + val TimeoutDuration = config duration "timeout.duration" + val TimeoutCheckEvery = config duration "timeout.check_every" } import settings._ + private val chatTimeout = new ChatTimeout( + chatColl = chatColl, + timeoutColl = timeoutColl, + duration = TimeoutDuration) + val api = new ChatApi( coll = chatColl, + chatTimeout = chatTimeout, flood = flood, shutup = shutup, + lilaBus = system.lilaBus, maxLinesPerChat = MaxLinesPerChat, netDomain = NetDomain) - private val timeout = new ChatTimeout( - chatColl = chatColl, - timeoutColl = timeoutColl) + system.scheduler.schedule(TimeoutCheckEvery, TimeoutCheckEvery) { + chatTimeout.checkExpired + } - system.actorOf(Props(new FrontActor(api, timeout)), name = ActorName) + system.actorOf(Props(new FrontActor(api)), name = ActorName) private[chat] lazy val chatColl = db(CollectionChat) private[chat] lazy val timeoutColl = db(CollectionTimeout) diff --git a/modules/chat/src/main/FrontActor.scala b/modules/chat/src/main/FrontActor.scala index 11126f0a8a..a540beec42 100644 --- a/modules/chat/src/main/FrontActor.scala +++ b/modules/chat/src/main/FrontActor.scala @@ -6,9 +6,7 @@ import chess.Color import actorApi._ import lila.common.PimpedJson._ -private[chat] final class FrontActor( - api: ChatApi, - timeout: ChatTimeout) extends Actor { +private[chat] final class FrontActor(api: ChatApi) extends Actor { def receive = { @@ -26,7 +24,7 @@ private[chat] final class FrontActor( modId <- member.userId username <- data.str("username") reason <- data.str("reason") flatMap ChatTimeout.Reason.apply - } timeout.add(chatId, modId, username, reason) + } api.userChat.timeout(chatId, modId, username, reason) } def publish(chatId: String, replyTo: ActorRef)(lineOption: Option[Line]) { diff --git a/modules/chat/src/main/JsonView.scala b/modules/chat/src/main/JsonView.scala index 2b867b7478..69e679cac0 100644 --- a/modules/chat/src/main/JsonView.scala +++ b/modules/chat/src/main/JsonView.scala @@ -1,5 +1,6 @@ package lila.chat +import lila.common.PimpedJson._ import play.api.libs.json._ object JsonView { @@ -14,7 +15,7 @@ object JsonView { 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) + Json.obj("key" -> r.key, "name" -> r.name) } implicit val mixedChatWriter: Writes[MixedChat] = Writes[MixedChat] { c => @@ -31,8 +32,11 @@ object JsonView { } private implicit val userLineWriter = Writes[UserLine] { l => - val o = Json.obj("u" -> l.username, "t" -> l.text) - if (l.troll) o + ("r" -> JsBoolean(true)) else o + Json.obj( + "u" -> l.username, + "t" -> l.text, + "r" -> l.troll.option(true), + "d" -> l.deleted.option(true)).noNull } private implicit val playerLineWriter = Writes[PlayerLine] { l => diff --git a/modules/chat/src/main/Line.scala b/modules/chat/src/main/Line.scala index ce5c08950e..82131b766c 100644 --- a/modules/chat/src/main/Line.scala +++ b/modules/chat/src/main/Line.scala @@ -1,5 +1,7 @@ package lila.chat +import lila.user.User + import chess.Color sealed trait Line { @@ -16,7 +18,12 @@ case class UserLine( text: String, troll: Boolean, deleted: Boolean) extends Line { + def author = username + + def userId = User normalize username + + def delete = copy(deleted = true) } case class PlayerLine( color: Color, @@ -54,7 +61,12 @@ object Line { case UserLineRegex(username, "?", text) => UserLine(username, text, troll = false, deleted = true).some case _ => none } - def userLineToStr(x: UserLine) = s"${x.username}${if (x.troll) "!" else " "}${x.text}" + def userLineToStr(x: UserLine) = { + val sep = if (x.troll) "!" + else if (x.deleted) "?" + else " " + s"${x.username}$sep${x.text}" + } def strToLine(str: String): Option[Line] = strToUserLine(str) orElse { str.headOption flatMap Color.apply map { color => diff --git a/modules/chat/src/main/actorApi.scala b/modules/chat/src/main/actorApi.scala index 32fe893481..4671b480c9 100644 --- a/modules/chat/src/main/actorApi.scala +++ b/modules/chat/src/main/actorApi.scala @@ -9,3 +9,5 @@ case class PlayerTalk(chatId: String, white: Boolean, text: String, replyTo: Act case class SystemTalk(chatId: String, text: String, replyTo: ActorRef) case class ChatLine(chatId: String, line: Line) case class Timeout(chatId: String, member: lila.socket.SocketMember, data: JsValue) + +case class MarkDeleted(username: String) diff --git a/modules/simul/src/main/Socket.scala b/modules/simul/src/main/Socket.scala index d1a16f1056..b0d9e80ee7 100644 --- a/modules/simul/src/main/Socket.scala +++ b/modules/simul/src/main/Socket.scala @@ -21,6 +21,15 @@ private[simul] final class Socket( uidTimeout: Duration, socketTimeout: Duration) extends SocketActor[Member](uidTimeout) with Historical[Member, Messadata] { + override def preStart() { + lilaBus.subscribe(self, Symbol(s"chat-$simulId")) + } + + override def postStop() { + super.postStop() + lilaBus.unsubscribe(self) + } + private val timeBomb = new TimeBomb(socketTimeout) private var delayedCrowdNotification = false @@ -74,9 +83,12 @@ private[simul] final class Socket( case _ => } - case GetVersion => sender ! history.version + case lila.chat.actorApi.MarkDeleted(username) => + notifyVersion("chat_mark_deleted", username, Messadata()) - case Socket.GetUserIds => sender ! userIds + case GetVersion => sender ! history.version + + case Socket.GetUserIds => sender ! userIds case Join(uid, user) => val (enumerator, channel) = Concurrent.broadcast[JsValue] diff --git a/public/stylesheets/common.css b/public/stylesheets/common.css index 1ab04016fc..bd482456f2 100644 --- a/public/stylesheets/common.css +++ b/public/stylesheets/common.css @@ -745,6 +745,16 @@ div.mchat .moderation { div.mchat .lichess_say { border-width: 1px 0 0 0; } +div.mchat .deleted { + opacity: 0.5; +} +div.mchat .system { + display: block; + opacity: 0.8; + font-style: italic; + text-align: center; + font-size: 0.8em; +} .notification .message_infos, .notification .game_infos, div.side_box .game_infos { diff --git a/ui/chat/src/ctrl.js b/ui/chat/src/ctrl.js index 8c1a253a1d..ce0c35b526 100644 --- a/ui/chat/src/ctrl.js +++ b/ui/chat/src/ctrl.js @@ -11,10 +11,21 @@ module.exports = function(opts) { isMod: opts.mod, placeholderKey: 'talkInChat', moderating: m.prop(null), - loading: m.prop(false) + loading: m.prop(false), + timedOut: m.prop(false) }; - var socket = makeSocket(lichess.socket.send); + var timeout = function(username) { + lines.forEach(function(l) { + if (l.u === username) l.d = true; + }); + m.redraw(); + }; + + var socket = makeSocket({ + send: lichess.socket.send, + timeout: timeout + }); var moderation = vm.isMod ? makeModeration({ reasons: opts.timeoutReasons, send: socket.send diff --git a/ui/chat/src/socket.js b/ui/chat/src/socket.js index 3db410e83d..93e374fbcf 100644 --- a/ui/chat/src/socket.js +++ b/ui/chat/src/socket.js @@ -1,12 +1,16 @@ var m = require('mithril'); -module.exports = function(send) { +module.exports = function(opts) { - var handlers = {}; + var handlers = { + timeout: opts.timeout + }; return { - send: send, + send: opts.send, receive: function(type, data) { + console.log(type, data); + console.log(handlers[type]); if (handlers[type]) { handlers[type](data); return true; diff --git a/ui/chat/src/view.js b/ui/chat/src/view.js index 67b2372e05..9ea5668ab7 100644 --- a/ui/chat/src/view.js +++ b/ui/chat/src/view.js @@ -1,18 +1,34 @@ var m = require('mithril'); var moderationView = require('./moderation').view; +var deletedDom = m('em.deleted', ''); + function renderLine(ctrl) { return function(line) { + if (line.u === 'lichess') return m('li', m('em.system', line.t)); return m('li', { 'data-username': line.u }, [ ctrl.vm.isMod ? moderationView.lineAction : null, m.trust($.userLinkLimit(line.u, 14)), - line.t + line.d ? deletedDom : line.t ]); }; } +function sameLines(l1, l2) { + return l1.d && l2.d && l1.u === l2.u; +} + +function dedupLines(lines) { + var prev, ls = []; + lines.forEach(function(l) { + if (!prev || !sameLines(prev, l)) ls.push(l); + prev = l; + }); + return ls; +} + function discussion(ctrl) { return m('div.discussion', [ m('div.top', [ @@ -35,7 +51,7 @@ function discussion(ctrl) { }, 500); } }, - ctrl.lines.map(renderLine(ctrl)) + dedupLines(ctrl.lines).map(renderLine(ctrl)) ), m('input', { class: 'lichess_say',