Merge branch 'inbox2'
* inbox2: (69 commits) more msg responsiveness restore msg search clear on blur msg report msg: avoid flashing the previous convo on mobile view msg submit button msg style tweak msg don't empty search on blur msg unread style limit inbox message length to 8k chars wait for conversation to be created before reloading it msg responsive side remove old inbox code more msg integration more msg integration /inbox for kids /inbox responsive msg oAuth API add API endpoint to post private messages full msg compat for mobile msg chat panic support ...
This commit is contained in:
commit
c7c59f592c
|
@ -92,7 +92,7 @@ final class LilaComponents(ctx: ApplicationLoader.Context)
|
|||
lazy val learn: Learn = wire[Learn]
|
||||
lazy val lobby: Lobby = wire[Lobby]
|
||||
lazy val main: Main = wire[Main]
|
||||
lazy val message: Message = wire[Message]
|
||||
lazy val msg: Msg = wire[Msg]
|
||||
lazy val mod: Mod = wire[Mod]
|
||||
lazy val notifyC: Notify = wire[Notify]
|
||||
lazy val oAuthApp: OAuthApp = wire[OAuthApp]
|
||||
|
|
|
@ -21,7 +21,7 @@ final class Env(
|
|||
val hub: lila.hub.Env,
|
||||
val socket: lila.socket.Env,
|
||||
val memo: lila.memo.Env,
|
||||
val message: lila.message.Env,
|
||||
val msg: lila.msg.Env,
|
||||
val i18n: lila.i18n.Env,
|
||||
val game: lila.game.Env,
|
||||
val bookmark: lila.bookmark.Env,
|
||||
|
@ -178,7 +178,7 @@ final class EnvBoot(
|
|||
lazy val security: lila.security.Env = wire[lila.security.Env]
|
||||
lazy val hub: lila.hub.Env = wire[lila.hub.Env]
|
||||
lazy val socket: lila.socket.Env = wire[lila.socket.Env]
|
||||
lazy val message: lila.message.Env = wire[lila.message.Env]
|
||||
lazy val msg: lila.msg.Env = wire[lila.msg.Env]
|
||||
lazy val i18n: lila.i18n.Env = wire[lila.i18n.Env]
|
||||
lazy val game: lila.game.Env = wire[lila.game.Env]
|
||||
lazy val bookmark: lila.bookmark.Env = wire[lila.bookmark.Env]
|
||||
|
|
|
@ -24,10 +24,10 @@ final class Clas(
|
|||
}
|
||||
}
|
||||
case Some(me) =>
|
||||
env.clas.api.student.isStudent(me) flatMap {
|
||||
env.clas.api.student.isStudent(me.id) flatMap {
|
||||
case false => renderHome
|
||||
case _ =>
|
||||
env.clas.api.student.clasIdsOfUser(me) flatMap
|
||||
env.clas.api.student.clasIdsOfUser(me.id) flatMap
|
||||
env.clas.api.clas.byIds map {
|
||||
case List(single) => Redirect(routes.Clas.show(single.id.value))
|
||||
case many => Ok(views.html.clas.clas.studentIndex(many))
|
||||
|
|
|
@ -94,7 +94,7 @@ final class Dasher(env: Env) extends LilaController(env) {
|
|||
"list" -> lila.pref.PieceSet3d.all.map(_.name)
|
||||
)
|
||||
),
|
||||
"kid" -> ctx.me ?? (_.kid),
|
||||
"inbox" -> ctx.hasInbox,
|
||||
"coach" -> isGranted(_.Coach),
|
||||
"streamer" -> isStreamer,
|
||||
"i18n" -> translations
|
||||
|
|
|
@ -482,7 +482,7 @@ abstract private[controllers] class LilaController(val env: Env)
|
|||
env.challenge.api.countInFor.get(me.id) zip
|
||||
env.notifyM.api.unreadCount(Notifies(me.id)).dmap(_.value) zip
|
||||
env.mod.inquiryApi.forMod(me) zip
|
||||
(if (isGranted(_.Teacher, me)) fuccess(true) else env.clas.api.student.isStudent(me))
|
||||
(if (isGranted(_.Teacher, me)) fuccess(true) else env.clas.api.student.isStudent(me.id))
|
||||
} else
|
||||
fuccess {
|
||||
(((((OnlineFriends.empty, 0), 0), 0), none), false)
|
||||
|
@ -608,6 +608,9 @@ abstract private[controllers] class LilaController(val env: Env)
|
|||
protected def jsonFormErrorDefaultLang(err: Form[_]) =
|
||||
jsonFormError(err)(lila.i18n.defaultLang)
|
||||
|
||||
protected def jsonFormErrorFor(err: Form[_], req: RequestHeader, user: Option[UserModel]) =
|
||||
jsonFormError(err)(lila.i18n.I18nLangPicker(req, user))
|
||||
|
||||
protected def pageHit(req: RequestHeader): Unit =
|
||||
if (HTTPRequest isHuman req) lila.mon.http.path(req.path).increment()
|
||||
|
||||
|
|
|
@ -1,177 +0,0 @@
|
|||
package controllers
|
||||
|
||||
import play.api.data.Form
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc.Result
|
||||
import scala.concurrent.duration._
|
||||
import scalatags.Text.Frag
|
||||
|
||||
import lila.api.Context
|
||||
import lila.app._
|
||||
import lila.common.{ HTTPRequest, IpAddress }
|
||||
import lila.security.Granter
|
||||
import lila.user.{ User => UserModel }
|
||||
import views._
|
||||
|
||||
final class Message(env: Env) extends LilaController(env) {
|
||||
|
||||
private def api = env.message.api
|
||||
private def security = env.message.security
|
||||
private def forms = env.message.forms
|
||||
private def relationApi = env.relation.api
|
||||
|
||||
def inbox(page: Int) = Auth { implicit ctx => me =>
|
||||
NotForKids {
|
||||
for {
|
||||
pag <- api.inbox(me, page)
|
||||
_ <- env.user.lightUserApi preloadMany pag.currentPageResults.flatMap(_.userIds)
|
||||
res <- negotiate(
|
||||
html = fuccess(html.message.inbox(me, pag)),
|
||||
api = _ => fuccess(env.message.jsonView.inbox(me, pag))
|
||||
)
|
||||
} yield res
|
||||
}
|
||||
}
|
||||
|
||||
def unreadCount = Auth { implicit ctx => me =>
|
||||
NotForKids {
|
||||
negotiate(
|
||||
html = notFound,
|
||||
api = _ => JsonOk(api unreadCount me)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def thread(id: String) = Auth { implicit ctx => implicit me =>
|
||||
NotForKids {
|
||||
negotiate(
|
||||
html = OptionFuOk(api.thread(id, me)) { thread =>
|
||||
relationApi.fetchBlocks(thread otherUserId me, me.id) map { blocked =>
|
||||
val form = thread.isReplyable option forms.post
|
||||
html.message.thread(thread, form, blocked)
|
||||
}
|
||||
} map NoCache,
|
||||
api = _ =>
|
||||
JsonOptionFuOk(api.thread(id, me)) { thread =>
|
||||
env.message.jsonView.thread(thread)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def answer(id: String) = AuthBody { implicit ctx => implicit me =>
|
||||
OptionFuResult(api.thread(id, me) map (_.filterNot(_.isTooBig))) { thread =>
|
||||
implicit val req = ctx.body
|
||||
negotiate(
|
||||
html = forms.post.bindFromRequest.fold(
|
||||
err =>
|
||||
relationApi.fetchBlocks(thread otherUserId me, me.id) map { blocked =>
|
||||
BadRequest(html.message.thread(thread, err.some, blocked))
|
||||
},
|
||||
text =>
|
||||
api.makePost(thread, text, me) inject Redirect {
|
||||
s"${routes.Message.thread(thread.id)}#bottom"
|
||||
}
|
||||
),
|
||||
api = _ =>
|
||||
forms.post.bindFromRequest.fold(
|
||||
_ => fuccess(BadRequest(Json.obj("err" -> "Malformed request"))),
|
||||
text => api.makePost(thread, text, me) inject Ok(Json.obj("ok" -> true, "id" -> thread.id))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def form = Auth { implicit ctx => implicit me =>
|
||||
NotForKids {
|
||||
renderForm(me, get("title"), identity) map { Ok(_) }
|
||||
}
|
||||
}
|
||||
|
||||
private val ThreadLimitPerUser = new lila.memo.RateLimit[lila.user.User.ID](
|
||||
credits = 20,
|
||||
duration = 24 hour,
|
||||
name = "PM thread per user",
|
||||
key = "pm_thread.user"
|
||||
)
|
||||
|
||||
private val ThreadLimitPerIP = new lila.memo.RateLimit[IpAddress](
|
||||
credits = 30,
|
||||
duration = 24 hour,
|
||||
name = "PM thread per IP",
|
||||
key = "pm_thread.ip"
|
||||
)
|
||||
|
||||
implicit private val rateLimited = ornicar.scalalib.Zero.instance[Fu[Result]] {
|
||||
fuccess(Redirect(routes.Message.inbox(1)))
|
||||
}
|
||||
|
||||
def create = AuthBody { implicit ctx => implicit me =>
|
||||
NotForKids {
|
||||
env.chat.panic.allowed(me) ?? {
|
||||
implicit val req = ctx.body
|
||||
negotiate(
|
||||
html = forms
|
||||
.thread(me)
|
||||
.bindFromRequest
|
||||
.fold(
|
||||
err => renderForm(me, none, _ => err) map { BadRequest(_) },
|
||||
data => {
|
||||
val cost =
|
||||
if (isGranted(_.ModMessage)) 0
|
||||
else if (!me.createdSinceDays(3)) 2
|
||||
else 1
|
||||
ThreadLimitPerUser(me.id, cost = cost) {
|
||||
ThreadLimitPerIP(HTTPRequest lastRemoteAddress ctx.req, cost = cost) {
|
||||
api.makeThread(data, me) map { thread =>
|
||||
if (thread.asMod)
|
||||
env.mod.logApi.modMessage(thread.creatorId, thread.invitedId, thread.name)
|
||||
Redirect(routes.Message.thread(thread.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
api = _ =>
|
||||
forms
|
||||
.thread(me)
|
||||
.bindFromRequest
|
||||
.fold(
|
||||
jsonFormError,
|
||||
data =>
|
||||
api.makeThread(data, me) map { thread =>
|
||||
Ok(Json.obj("ok" -> true, "id" -> thread.id))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def renderForm(me: UserModel, title: Option[String], f: Form[_] => Form[_])(
|
||||
implicit ctx: Context
|
||||
): Fu[Frag] =
|
||||
get("user") ?? env.user.repo.named flatMap { user =>
|
||||
user.fold(fuTrue)(u => security.canMessage(me.id, u.id)) map { canMessage =>
|
||||
html.message.form(
|
||||
f(forms thread me),
|
||||
reqUser = user,
|
||||
reqTitle = title,
|
||||
canMessage = canMessage || Granter(_.MessageAnyone)(me),
|
||||
oldEnough = env.chat.panic.allowed(me)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def batch = AuthBody { implicit ctx => implicit me =>
|
||||
val ids = get("ids").??(_.split(",").toList).distinct take 200
|
||||
env.message.batch(me, ~get("action"), ids) inject Redirect(routes.Message.inbox(1))
|
||||
}
|
||||
|
||||
def delete(id: String) = AuthBody { implicit ctx => implicit me =>
|
||||
negotiate(
|
||||
html = api.deleteThread(id, me) inject Redirect(routes.Message.inbox(1)),
|
||||
api = _ => api.deleteThread(id, me) inject Ok(Json.obj("ok" -> true))
|
||||
)
|
||||
}
|
||||
}
|
|
@ -93,13 +93,13 @@ final class Mod(
|
|||
|
||||
def warn(username: String, subject: String) =
|
||||
OAuthModBody(_.ModMessage) { me =>
|
||||
lila.message.ModPreset.bySubject(subject) ?? { preset =>
|
||||
lila.msg.MsgPreset.byName(subject) ?? { preset =>
|
||||
withSuspect(username) { prev =>
|
||||
for {
|
||||
inquiry <- env.report.api.inquiries ofModId me.id
|
||||
suspect <- modApi.setTroll(AsMod(me), prev, prev.user.marks.troll)
|
||||
thread <- env.message.api.sendPreset(me, suspect.user, preset)
|
||||
_ <- env.mod.logApi.modMessage(thread.creatorId, thread.invitedId, thread.name)
|
||||
_ <- env.msg.api.postPreset(suspect.user, preset)
|
||||
_ <- env.mod.logApi.modMessage(me.id, suspect.user.id, preset.name)
|
||||
} yield (inquiry, suspect).some
|
||||
}
|
||||
}
|
||||
|
@ -120,7 +120,7 @@ final class Mod(
|
|||
OAuthMod(_.Shadowban) { _ => _ =>
|
||||
withSuspect(username) { sus =>
|
||||
env.mod.publicChat.delete(sus) >>
|
||||
env.message.api.deleteThreadsBy(sus.user) map some
|
||||
env.msg.api.deleteAllBy(sus.user) map some
|
||||
}
|
||||
}(actionResult(username))
|
||||
|
||||
|
@ -222,11 +222,8 @@ final class Mod(
|
|||
.mon(_.mod.comm.segment("playerChats"))
|
||||
} zip
|
||||
priv.?? {
|
||||
env.message.repo
|
||||
.visibleOrDeletedByUser(user.id, 60)
|
||||
.map {
|
||||
_ filter (_ hasPostsWrittenBy user.id) take 30
|
||||
}
|
||||
env.msg.api
|
||||
.recentByForMod(user, 30)
|
||||
.mon(_.mod.comm.segment("pms"))
|
||||
} zip
|
||||
(env.shutup.api getPublicLines user.id)
|
||||
|
@ -240,7 +237,7 @@ final class Mod(
|
|||
env.report.api.inquiries
|
||||
.ofModId(me.id)
|
||||
.mon(_.mod.comm.segment("inquiries")) map {
|
||||
case chats ~ threads ~ publicLines ~ notes ~ history ~ inquiry =>
|
||||
case chats ~ convos ~ publicLines ~ notes ~ history ~ inquiry =>
|
||||
if (priv && !inquiry.??(_.isRecentCommOf(Suspect(user))))
|
||||
env.slack.api.commlog(mod = me, user = user, inquiry.map(_.oldestAtom.by.value))
|
||||
html.mod.communication(
|
||||
|
@ -248,7 +245,7 @@ final class Mod(
|
|||
(povs zip chats) collect {
|
||||
case (p, Some(c)) if c.nonEmpty => p -> c
|
||||
} take 15,
|
||||
threads,
|
||||
convos,
|
||||
publicLines,
|
||||
notes.filter(_.from != "irwin"),
|
||||
history,
|
||||
|
|
120
app/controllers/Msg.scala
Normal file
120
app/controllers/Msg.scala
Normal file
|
@ -0,0 +1,120 @@
|
|||
package controllers
|
||||
|
||||
import play.api.libs.json._
|
||||
|
||||
import lila.app._
|
||||
import lila.common.LightUser.lightUserWrites
|
||||
|
||||
final class Msg(
|
||||
env: Env
|
||||
) extends LilaController(env) {
|
||||
|
||||
def home = Auth { implicit ctx => me =>
|
||||
ctx.hasInbox ?? negotiate(
|
||||
html =
|
||||
inboxJson(me) map { json =>
|
||||
Ok(views.html.msg.home(json))
|
||||
},
|
||||
api = v => {
|
||||
if (v >= 5) inboxJson(me)
|
||||
else env.msg.compat.inbox(me, getInt("page"))
|
||||
} map { Ok(_) }
|
||||
)
|
||||
}
|
||||
|
||||
def convo(username: String) = Auth { implicit ctx => me =>
|
||||
if (username == "new") Redirect(get("user").fold(routes.Msg.home())(routes.Msg.convo)).fuccess
|
||||
else
|
||||
ctx.hasInbox ?? {
|
||||
env.msg.api.convoWith(me, username) flatMap { c =>
|
||||
def newJson = inboxJson(me).map { _ + ("convo" -> env.msg.json.convo(c)) }
|
||||
negotiate(
|
||||
html =
|
||||
if (c.contact.id == me.id) Redirect(routes.Msg.home).fuccess
|
||||
else
|
||||
newJson map { json =>
|
||||
Ok(views.html.msg.home(json))
|
||||
},
|
||||
api = v =>
|
||||
if (c.contact.id == me.id) notFoundJson()
|
||||
else {
|
||||
if (v >= 5) newJson
|
||||
else fuccess(env.msg.compat.thread(me, c))
|
||||
} map { Ok(_) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def search(q: String) = Auth { ctx => me =>
|
||||
ctx.hasInbox ?? {
|
||||
q.trim.some.filter(_.size > 1).filter(lila.user.User.couldBeUsername) match {
|
||||
case None => env.msg.json.searchResult(me)(env.msg.search.empty) map { Ok(_) }
|
||||
case Some(q) => env.msg.search(me, q) flatMap env.msg.json.searchResult(me) map { Ok(_) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def unreadCount = Auth { ctx => me =>
|
||||
JsonOk {
|
||||
ctx.hasInbox ?? {
|
||||
env.msg.api unreadCount me
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def convoDelete(username: String) = Auth { _ => me =>
|
||||
env.msg.api.delete(me, username) >>
|
||||
inboxJson(me) map { Ok(_) }
|
||||
}
|
||||
|
||||
def compatCreate = AuthBody { implicit ctx => me =>
|
||||
ctx.hasInbox ?? {
|
||||
env.msg.compat
|
||||
.create(me)(ctx.body)
|
||||
.fold(
|
||||
jsonFormError,
|
||||
_ map { id =>
|
||||
Ok(Json.obj("ok" -> true, "id" -> id))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
def apiPost(username: String) = {
|
||||
val userId = lila.user.User normalize username
|
||||
AuthOrScopedBody(_.Msg.Write)(
|
||||
// compat: reply
|
||||
auth = implicit ctx =>
|
||||
me =>
|
||||
ctx.hasInbox ?? {
|
||||
env.msg.compat
|
||||
.reply(me, userId)(ctx.body)
|
||||
.fold(
|
||||
jsonFormError,
|
||||
_ inject Ok(Json.obj("ok" -> true, "id" -> userId))
|
||||
)
|
||||
},
|
||||
// new API: create/reply
|
||||
scoped = implicit req =>
|
||||
me =>
|
||||
!me.kid ?? {
|
||||
import play.api.data._
|
||||
import play.api.data.Forms._
|
||||
Form(single("text" -> nonEmptyText)).bindFromRequest
|
||||
.fold(
|
||||
err => jsonFormErrorFor(err, req, me.some),
|
||||
text => env.msg.api.post(me.id, userId, text)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private def inboxJson(me: lila.user.User) =
|
||||
env.msg.api.threadsOf(me) flatMap env.msg.json.threads(me) map { threads =>
|
||||
Json.obj(
|
||||
"me" -> lightUserWrites.writes(me.light).add("kid" -> me.kid),
|
||||
"contacts" -> threads
|
||||
)
|
||||
}
|
||||
}
|
|
@ -103,7 +103,7 @@ final class Puzzle(
|
|||
ctx.me match {
|
||||
case Some(me) =>
|
||||
for {
|
||||
isStudent <- env.clas.api.student.isStudent(me)
|
||||
isStudent <- env.clas.api.student.isStudent(me.id)
|
||||
(round, mode) <- env.puzzle.finisher(puzzle, me, result, mobile = true, isStudent = isStudent)
|
||||
me2 <- if (mode.rated) env.user.repo byId me.id map (_ | me) else fuccess(me)
|
||||
infos <- env.puzzle userInfos me2
|
||||
|
@ -137,7 +137,7 @@ final class Puzzle(
|
|||
ctx.me match {
|
||||
case Some(me) =>
|
||||
for {
|
||||
isStudent <- env.clas.api.student.isStudent(me)
|
||||
isStudent <- env.clas.api.student.isStudent(me.id)
|
||||
(round, mode) <- env.puzzle.finisher(
|
||||
puzzle = puzzle,
|
||||
user = me,
|
||||
|
|
|
@ -46,10 +46,10 @@ final class Relation(
|
|||
def follow(userId: String) = Auth { implicit ctx => me =>
|
||||
api.reachedMaxFollowing(me.id) flatMap {
|
||||
case true =>
|
||||
env.message.api
|
||||
.sendPresetFromLichess(
|
||||
env.msg.api
|
||||
.postPreset(
|
||||
me,
|
||||
lila.message.ModPreset.maxFollow(me.username, env.relation.maxFollow.value)
|
||||
lila.msg.MsgPreset.maxFollow(me.username, env.relation.maxFollow.value)
|
||||
)
|
||||
.void
|
||||
case _ =>
|
||||
|
|
|
@ -66,7 +66,7 @@ $('.coach-review-form form').show();
|
|||
a(
|
||||
cls := "text button button-empty",
|
||||
dataIcon := "c",
|
||||
href := s"${routes.Message.form}?user=${c.user.username}"
|
||||
href := s"${routes.Msg.convo(c.user.username)}"
|
||||
)(
|
||||
"Send a private message"
|
||||
),
|
||||
|
|
|
@ -71,7 +71,9 @@ fishnet client create {username} analysis
|
|||
gdpr erase {username} forever
|
||||
patron lifetime {username}
|
||||
patron month {username}
|
||||
eval-cache drop 8/8/1k6/8/2K5/1P6/8/8 w - - 0 1""")
|
||||
eval-cache drop 8/8/1k6/8/2K5/1P6/8/8 w - - 0 1
|
||||
security grant {username} {PERM_ONE} {PERM_TWO} {...}
|
||||
""")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
package views.html.message
|
||||
|
||||
import play.api.data.Form
|
||||
|
||||
import lila.api.Context
|
||||
import lila.app.templating.Environment._
|
||||
import lila.app.ui.ScalatagsTemplate._
|
||||
import lila.common.String.html.safeJsonValue
|
||||
|
||||
import controllers.routes
|
||||
|
||||
object form {
|
||||
|
||||
def apply(
|
||||
form: Form[_],
|
||||
reqUser: Option[lila.user.User],
|
||||
reqTitle: Option[String],
|
||||
canMessage: Boolean,
|
||||
oldEnough: Boolean
|
||||
)(implicit ctx: Context) =
|
||||
views.html.base.layout(
|
||||
title = trans.composeMessage.txt(),
|
||||
moreCss = cssTag("message"),
|
||||
moreJs = jsTag("message.js")
|
||||
) {
|
||||
main(cls := "message-new box box-pad page-small")(
|
||||
h1(trans.composeMessage()),
|
||||
reqUser.ifFalse(canMessage).map { u =>
|
||||
frag(
|
||||
br,
|
||||
br,
|
||||
hr,
|
||||
br,
|
||||
p("Sorry, ", u.username, " doesn't accept new messages.")
|
||||
)
|
||||
} getOrElse {
|
||||
if (!oldEnough)
|
||||
frag(
|
||||
br,
|
||||
br,
|
||||
hr,
|
||||
br,
|
||||
p("Sorry, you cannot start conversations yet.")
|
||||
)
|
||||
else
|
||||
postForm(
|
||||
cls := "form3",
|
||||
action := routes.Message.create()
|
||||
)(
|
||||
form3.group(form("username"), trans.recipient()) { f =>
|
||||
reqUser map { user =>
|
||||
frag(
|
||||
userLink(user),
|
||||
form3.hidden(f.name, user.username)
|
||||
)
|
||||
} getOrElse input(
|
||||
cls := "form-control user-autocomplete",
|
||||
required,
|
||||
name := f.name,
|
||||
id := form3.id(f),
|
||||
value := f.value,
|
||||
autofocus,
|
||||
dataTag := "span"
|
||||
)
|
||||
},
|
||||
isGranted(_.ModMessage) option frag(
|
||||
form3.checkbox(form("mod"), frag("Send as mod")),
|
||||
form3.group(form("preset"), frag("Preset")) { form3.select(_, Nil) },
|
||||
embedJsUnsafe(s"""lichess_mod_presets=${safeJsonValue(lila.message.ModPreset.asJson)}""")
|
||||
),
|
||||
form3.group(form("subject"), trans.subject()) { f =>
|
||||
input(
|
||||
cls := "form-control",
|
||||
required,
|
||||
minlength := 3,
|
||||
maxlength := 100,
|
||||
name := f.name,
|
||||
id := form3.id(f),
|
||||
value := f.value.filter(_.nonEmpty).orElse(reqTitle),
|
||||
reqUser.isDefined option autofocus
|
||||
)
|
||||
},
|
||||
form3.group(form("text"), frag("Message"), klass = "message-text") { f =>
|
||||
form3.textarea(f)(required)
|
||||
},
|
||||
form3.actions(
|
||||
a(cls := "cancel", href := routes.Message.inbox())(trans.cancel()),
|
||||
submitButton(cls := "button text", dataIcon := "E")(trans.send())
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
package views.html.message
|
||||
|
||||
import lila.api.Context
|
||||
import lila.app.templating.Environment._
|
||||
import lila.app.ui.ScalatagsTemplate._
|
||||
import lila.common.paginator.Paginator
|
||||
|
||||
import controllers.routes
|
||||
|
||||
object inbox {
|
||||
|
||||
def apply(me: lila.user.User, threads: Paginator[lila.message.Thread])(implicit ctx: Context) =
|
||||
views.html.base.layout(
|
||||
title = trans.inbox.txt(),
|
||||
moreCss = cssTag("message"),
|
||||
moreJs = frag(infiniteScrollTag, jsTag("message.js"))
|
||||
) {
|
||||
main(cls := "message-list box")(
|
||||
div(cls := "box__top")(
|
||||
h1(trans.inbox()),
|
||||
div(cls := "box__top__actions")(
|
||||
threads.nbResults > 0 option frag(
|
||||
select(cls := "select")(
|
||||
option(value := "")("Select"),
|
||||
option(value := "all")("All"),
|
||||
option(value := "none")("None"),
|
||||
option(value := "unread")("Unread"),
|
||||
option(value := "read")("Read")
|
||||
),
|
||||
select(cls := "action")(
|
||||
option(value := "")("Do"),
|
||||
option(value := "unread")("Mark as unread"),
|
||||
option(value := "read")("Mark as read"),
|
||||
option(value := "delete")("Delete")
|
||||
)
|
||||
),
|
||||
a(href := routes.Message.form, cls := "button button-green text", dataIcon := "m")(
|
||||
trans.composeMessage()
|
||||
)
|
||||
)
|
||||
),
|
||||
table(cls := "slist slist-pad")(
|
||||
if (threads.nbResults > 0)
|
||||
tbody(cls := "infinitescroll")(
|
||||
pagerNextTable(threads, p => routes.Message.inbox(p).url),
|
||||
threads.currentPageResults.map { thread =>
|
||||
tr(
|
||||
cls := List(
|
||||
"paginated" -> true,
|
||||
"new" -> thread.isUnReadBy(me),
|
||||
"mod" -> thread.asMod
|
||||
)
|
||||
)(
|
||||
td(cls := "author")(userIdLink(thread.visibleOtherUserId(me), none)),
|
||||
td(cls := "subject")(a(href := s"${routes.Message.thread(thread.id)}#bottom")(thread.name)),
|
||||
td(cls := "date")(momentFromNow(thread.updatedAt)),
|
||||
td(cls := "check")(input(tpe := "checkbox", name := "threads", value := thread.id))
|
||||
)
|
||||
}
|
||||
)
|
||||
else tbody(tr(td(trans.noNewMessages(), br, br)))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
package views.html.message
|
||||
|
||||
import play.api.data.Form
|
||||
|
||||
import lila.api.Context
|
||||
import lila.app.templating.Environment._
|
||||
import lila.app.ui.ScalatagsTemplate._
|
||||
import lila.common.String.html.richText
|
||||
|
||||
import controllers.routes
|
||||
|
||||
object thread {
|
||||
|
||||
def apply(
|
||||
thread: lila.message.Thread,
|
||||
replyForm: Option[Form[_]],
|
||||
blocks: Boolean
|
||||
)(implicit ctx: Context, me: lila.user.User) =
|
||||
views.html.base.layout(
|
||||
title = thread.name,
|
||||
moreCss = cssTag("message"),
|
||||
moreJs = frag(
|
||||
jsTag("message.js"),
|
||||
jsAt("compiled/embed-analyse.js")
|
||||
)
|
||||
) {
|
||||
main(
|
||||
cls := List(
|
||||
"message-thread box box-pad page-small" -> true,
|
||||
"mod" -> thread.asMod
|
||||
)
|
||||
)(
|
||||
div(cls := "box__top")(
|
||||
h1(
|
||||
a(href := routes.Message.inbox(1), dataIcon := "I", cls := "text"),
|
||||
thread.nonEmptyName
|
||||
),
|
||||
postForm(action := routes.Message.delete(thread.id))(
|
||||
submitButton(cls := "button button-empty button-red confirm")("Delete")
|
||||
)
|
||||
),
|
||||
thread.posts.map { post =>
|
||||
st.article(cls := "message-thread__message embed_analyse", id := s"message_${post.id}")(
|
||||
div(cls := "infos")(
|
||||
div(
|
||||
userIdLink(thread.visibleSenderOf(post), none),
|
||||
iconTag("H")(cls := "to"),
|
||||
userIdLink(thread.visibleReceiverOf(post), "inline".some)
|
||||
),
|
||||
momentFromNow(post.createdAt),
|
||||
(!thread.isLichess && !thread.isWrittenBy(post, me)) option views.html.report.form.flag(
|
||||
username = thread otherUserId me,
|
||||
resource = s"message/${thread.id}",
|
||||
text = thread.flaggableText(post)
|
||||
)
|
||||
),
|
||||
div(cls := "message-thread__message__body")(richText(post.text))
|
||||
)
|
||||
},
|
||||
div(cls := "message-thread__answer", id := "bottom")(
|
||||
if (blocks)
|
||||
p(cls := "end")(
|
||||
userIdLink(thread.visibleOtherUserId(me).some),
|
||||
" blocks you. You cannot reply."
|
||||
)
|
||||
else {
|
||||
if (!thread.isVisibleByOther(me) && !me.marks.troll && !thread.isNeverRead)
|
||||
p(cls := "end")(
|
||||
userIdLink(thread.visibleOtherUserId(me).some),
|
||||
" has closed this thread. ",
|
||||
!thread.asMod option
|
||||
a(
|
||||
href := s"${routes.Message.form}?user=${thread.otherUserId(me)}",
|
||||
cls := "button"
|
||||
)("Create a new one")
|
||||
)
|
||||
else
|
||||
replyForm.map { form =>
|
||||
postForm(action := routes.Message.answer(thread.id))(
|
||||
div(cls := "field_body")(
|
||||
form3.textarea(form("text"))(required),
|
||||
errMsg(form("text"))
|
||||
),
|
||||
div(cls := "actions")(
|
||||
a(cls := "cancel", href := routes.Message.inbox(1))(trans.cancel()),
|
||||
submitButton(cls := "button text", dataIcon := "E")(trans.send())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ object communication {
|
|||
def apply(
|
||||
u: lila.user.User,
|
||||
players: List[(lila.game.Pov, lila.chat.MixedChat)],
|
||||
threads: List[lila.message.Thread],
|
||||
convos: List[lila.msg.MsgConvo],
|
||||
publicLines: List[lila.shutup.PublicLine],
|
||||
notes: List[lila.user.Note],
|
||||
history: List[lila.mod.Modlog],
|
||||
|
@ -121,22 +121,21 @@ object communication {
|
|||
),
|
||||
div(cls := "threads")(
|
||||
h2("Recent inbox messages"),
|
||||
threads.map { thread =>
|
||||
convos.map { convo =>
|
||||
div(cls := "thread")(
|
||||
p(cls := "title")(
|
||||
strong(thread.name),
|
||||
momentFromNowOnce(thread.createdAt),
|
||||
userIdLink(thread.creatorId.some),
|
||||
" -> ",
|
||||
userIdLink(thread.invitedId.some)
|
||||
),
|
||||
thread.posts.map { post =>
|
||||
div(cls := List("post" -> true, "author" -> thread.isWrittenBy(post, u)))(
|
||||
userIdLink(thread.senderOf(post).some),
|
||||
nbsp,
|
||||
richText(post.text)
|
||||
p(cls := "title")(strong(lightUserLink(convo.contact))),
|
||||
table(cls := "slist")(
|
||||
tbody(
|
||||
convo.msgs.reverse.map { msg =>
|
||||
val author = msg.user == u.id
|
||||
tr(cls := List("post" -> true, "author" -> author))(
|
||||
td(momentFromNowOnce(msg.date)),
|
||||
td(strong(if (author) u.username else convo.contact.name)),
|
||||
td(richText(msg.text))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -136,17 +136,12 @@ object inquiry {
|
|||
div(cls := "dropper warn buttons")(
|
||||
iconTag("e"),
|
||||
div(
|
||||
lila.message.ModPreset.all.map { preset =>
|
||||
postForm(action := routes.Mod.warn(in.user.username, preset.subject))(
|
||||
submitButton(cls := "fbt")(preset.subject),
|
||||
lila.msg.MsgPreset.all.map { preset =>
|
||||
postForm(action := routes.Mod.warn(in.user.username, preset.name))(
|
||||
submitButton(cls := "fbt")(preset.name),
|
||||
autoNextInput
|
||||
)
|
||||
},
|
||||
form(method := "get", action := routes.Message.form)(
|
||||
input(tpe := "hidden", name := "mod", value := "1"),
|
||||
input(tpe := "hidden", name := "user", value := "@in.user.id"),
|
||||
submitButton(cls := "fbt")("Custom message")
|
||||
)
|
||||
}
|
||||
)
|
||||
),
|
||||
isGranted(_.MarkEngine) option {
|
||||
|
|
42
app/views/msg.scala
Normal file
42
app/views/msg.scala
Normal file
|
@ -0,0 +1,42 @@
|
|||
package views.html
|
||||
|
||||
import play.api.libs.json._
|
||||
|
||||
import lila.api.Context
|
||||
import lila.app.templating.Environment._
|
||||
import lila.app.ui.ScalatagsTemplate._
|
||||
import lila.common.String.html.safeJsonValue
|
||||
|
||||
object msg {
|
||||
|
||||
def home(json: JsObject)(implicit ctx: Context) =
|
||||
views.html.base.layout(
|
||||
moreCss = frag(cssTag("msg")),
|
||||
moreJs = frag(
|
||||
jsAt(s"compiled/lichess.msg${isProd ?? (".min")}.js"),
|
||||
embedJsUnsafe(
|
||||
s"""$$(() =>LichessMsg.default(document.querySelector('.msg-app'), ${safeJsonValue(
|
||||
Json.obj(
|
||||
"data" -> json,
|
||||
"i18n" -> jsI18n
|
||||
)
|
||||
)}))"""
|
||||
)
|
||||
),
|
||||
title = "Lichess Inbox"
|
||||
) {
|
||||
main(cls := "box msg-app")
|
||||
}
|
||||
|
||||
def jsI18n(implicit ctx: Context) = i18nJsObject(translations)
|
||||
|
||||
private val translations = List(
|
||||
trans.inbox,
|
||||
trans.challengeToPlay,
|
||||
trans.block,
|
||||
trans.unblock,
|
||||
trans.blocked,
|
||||
trans.delete,
|
||||
trans.reportXToModerators
|
||||
)
|
||||
}
|
|
@ -29,7 +29,7 @@ object actions {
|
|||
),
|
||||
a(
|
||||
titleOrText(trans.composeMessage.txt()),
|
||||
href := s"${routes.Message.form()}?user=$userId",
|
||||
href := routes.Msg.convo(userId),
|
||||
cls := "btn-rack__btn",
|
||||
dataIcon := "c"
|
||||
)
|
||||
|
|
|
@ -54,7 +54,7 @@ object mini {
|
|||
dataIcon := "c",
|
||||
cls := "btn-rack__btn",
|
||||
title := trans.chat.txt(),
|
||||
href := s"${routes.Message.form()}?user=${u.username}"
|
||||
href := routes.Msg.convo(u.username)
|
||||
),
|
||||
a(
|
||||
dataIcon := "U",
|
||||
|
|
82
bin/mongodb/msg_import.js
Normal file
82
bin/mongodb/msg_import.js
Normal file
|
@ -0,0 +1,82 @@
|
|||
db.msg_msg.drop();
|
||||
db.msg_thread.drop();
|
||||
|
||||
function makeIndexes() {
|
||||
db.msg_thread.ensureIndex({users:1,'lastMsg.date':-1});
|
||||
db.msg_thread.ensureIndex({users:1},{partialFilterExpression:{'lastMsg.read':false}});
|
||||
db.msg_msg.ensureIndex({tid:1,date:-1})
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
print("Delete old notifications");
|
||||
db.notify.remove({'content.type':'privateMessage'});
|
||||
|
||||
if (true || !db.m_thread_sorted.count()) {
|
||||
print("Create db.m_thread_sorted");
|
||||
db.m_thread_sorted.drop();
|
||||
db.m_thread.find({
|
||||
mod:{$exists:false},
|
||||
visibleByUserIds:{$size:2},
|
||||
$or: [{
|
||||
creatorId: { $nin: ['lichess', 'lichess-qa', 'lichess-blog', 'lichess-team', 'mirlife', 'lichess4545', 'whatnext'] }
|
||||
}, {
|
||||
updatedAt: { $gt: new Date(Date.now() - 1000 * 3600 * 24 * 14) }
|
||||
}]
|
||||
}).forEach(t => {
|
||||
if (t.creatorId == t.invitedId) return;
|
||||
t.visibleByUserIds.sort();
|
||||
db.m_thread_sorted.insert(t);
|
||||
});
|
||||
}
|
||||
|
||||
print("Create db.msg_thread");
|
||||
db.m_thread_sorted.aggregate([
|
||||
{$group:{_id:'$visibleByUserIds',threads:{$push:'$$ROOT'}}}
|
||||
],{ allowDiskUse: true }).forEach(o => {
|
||||
|
||||
let userIds = o.threads[0].visibleByUserIds;
|
||||
userIds.sort();
|
||||
let threadId = userIds.join('/');
|
||||
|
||||
let msgs = [];
|
||||
|
||||
o.threads.forEach(t => {
|
||||
t.posts.forEach(p => {
|
||||
if (o.creatorId == 'lichess' && isOld(p.createdAt)) return;
|
||||
msgs.push({
|
||||
_id: p.id,
|
||||
tid: threadId,
|
||||
text: p.text,
|
||||
user: p.isByCreator ? t.creatorId : t.invitedId,
|
||||
date: p.createdAt
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!msgs.length) return;
|
||||
|
||||
msgs.sort((a,b) => new Date(a.date) - new Date(b.date));
|
||||
|
||||
let last = msgs[msgs.length - 1];
|
||||
|
||||
let thread = {
|
||||
_id: threadId,
|
||||
users: userIds,
|
||||
lastMsg: {
|
||||
text: last.text.slice(0, 60),
|
||||
user: last.user,
|
||||
date: last.date,
|
||||
read: !o.threads.find(t => t.posts.find(p => p.isRead)) || isOld(last.date)
|
||||
}
|
||||
}
|
||||
|
||||
db.msg_thread.insert(thread);
|
||||
db.msg_msg.insertMany(msgs, {ordered: false});
|
||||
});
|
||||
|
||||
makeIndexes();
|
||||
|
||||
function isOld(date) {
|
||||
return now - date > 1000 * 3600 * 24 * 7;
|
||||
}
|
12
build.sbt
12
build.sbt
|
@ -45,7 +45,7 @@ libraryDependencies ++= Seq(
|
|||
|
||||
lazy val modules = Seq(
|
||||
common, db, rating, user, security, hub, socket,
|
||||
message, notifyModule, i18n, game, bookmark, search,
|
||||
msg, notifyModule, i18n, game, bookmark, search,
|
||||
gameSearch, timeline, forum, forumSearch, team, teamSearch,
|
||||
analyse, mod, round, pool, lobby, setup,
|
||||
importer, tournament, simul, relation, report, pref,
|
||||
|
@ -319,12 +319,12 @@ lazy val practice = module("practice",
|
|||
)
|
||||
|
||||
lazy val playban = module("playban",
|
||||
Seq(common, db, game, message, chat),
|
||||
Seq(common, db, game, msg, chat),
|
||||
reactivemongo.bundle
|
||||
)
|
||||
|
||||
lazy val push = module("push",
|
||||
Seq(common, db, user, game, challenge, message),
|
||||
Seq(common, db, user, game, challenge, msg),
|
||||
Seq(googleOAuth) ++ reactivemongo.bundle
|
||||
)
|
||||
|
||||
|
@ -348,8 +348,8 @@ lazy val pref = module("pref",
|
|||
reactivemongo.bundle
|
||||
)
|
||||
|
||||
lazy val message = module("message",
|
||||
Seq(common, db, user, hub, relation, security, shutup, notifyModule),
|
||||
lazy val msg = module("msg",
|
||||
Seq(common, db, user, hub, relation, security, shutup, notifyModule, chat),
|
||||
reactivemongo.bundle
|
||||
)
|
||||
|
||||
|
@ -374,7 +374,7 @@ lazy val teamSearch = module("teamSearch",
|
|||
)
|
||||
|
||||
lazy val clas = module("clas",
|
||||
Seq(common, memo, db, user, security, message, history, puzzle),
|
||||
Seq(common, memo, db, user, security, msg, history, puzzle),
|
||||
reactivemongo.bundle
|
||||
)
|
||||
|
||||
|
|
19
conf/routes
19
conf/routes
|
@ -414,15 +414,16 @@ POST /forum/:categSlug/delete/:id controllers.ForumPost.delete(categSlug: S
|
|||
POST /forum/post/:id controllers.ForumPost.edit(id: String)
|
||||
GET /forum/redirect/post/:id controllers.ForumPost.redirect(id: String)
|
||||
|
||||
# Message
|
||||
GET /inbox controllers.Message.inbox(page: Int ?= 1)
|
||||
GET /inbox/new controllers.Message.form
|
||||
GET /inbox/unread-count controllers.Message.unreadCount
|
||||
POST /inbox/new controllers.Message.create
|
||||
POST /inbox/batch controllers.Message.batch
|
||||
GET /inbox/$id<\w{8}> controllers.Message.thread(id: String)
|
||||
POST /inbox/$id<\w{8}> controllers.Message.answer(id: String)
|
||||
POST /inbox/$id<\w{8}>/delete controllers.Message.delete(id: String)
|
||||
# Msg compat
|
||||
POST /inbox/new controllers.Msg.compatCreate
|
||||
# Msg
|
||||
GET /inbox controllers.Msg.home
|
||||
GET /inbox/search controllers.Msg.search(q: String)
|
||||
GET /inbox/unread-count controllers.Msg.unreadCount
|
||||
GET /inbox/:username controllers.Msg.convo(username: String)
|
||||
DELETE /inbox/:username controllers.Msg.convoDelete(username: String)
|
||||
# Msg API/compat
|
||||
POST /inbox/:username controllers.Msg.apiPost(username: String)
|
||||
|
||||
# Coach
|
||||
GET /coach controllers.Coach.allDefault(page: Int ?= 1)
|
||||
|
|
|
@ -57,6 +57,7 @@ sealed trait Context extends lila.user.UserContextWrapper {
|
|||
def noBlind = !blind
|
||||
def nonce = pageData.nonce
|
||||
def hasClas = pageData.hasClas
|
||||
def hasInbox = me.exists(u => !u.kid || hasClas)
|
||||
|
||||
def currentTheme = lila.pref.Theme(pref.theme)
|
||||
|
||||
|
|
|
@ -30,9 +30,9 @@ object Mobile {
|
|||
unsupportedAt: DateTime
|
||||
)
|
||||
|
||||
val currentVersion = ApiVersion(4)
|
||||
val currentVersion = ApiVersion(5)
|
||||
|
||||
val acceptedVersions: Set[ApiVersion] = Set(1, 2, 3, 4) map ApiVersion.apply
|
||||
val acceptedVersions: Set[ApiVersion] = Set(1, 2, 3, 4, 5) map ApiVersion.apply
|
||||
|
||||
val oldVersions: List[Old] = List(
|
||||
Old( // chat messages are html escaped
|
||||
|
|
|
@ -13,6 +13,9 @@ final class ChatPanic {
|
|||
}
|
||||
def allowed(u: User): Boolean = allowed(u, false)
|
||||
|
||||
def allowed(id: User.ID, fetch: User.ID => Fu[Option[User]]): Fu[Boolean] =
|
||||
if (enabled) fetch(id) dmap { _ ?? allowed } else fuTrue
|
||||
|
||||
def enabled = until exists { d =>
|
||||
(d isAfter DateTime.now) || {
|
||||
until = none
|
||||
|
|
|
@ -8,14 +8,14 @@ import lila.common.config.BaseUrl
|
|||
import lila.common.EmailAddress
|
||||
import lila.security.Permission
|
||||
import lila.db.dsl._
|
||||
import lila.message.MessageApi
|
||||
import lila.msg.MsgApi
|
||||
import lila.user.{ Authenticator, User, UserRepo }
|
||||
import lila.memo.CacheApi._
|
||||
|
||||
final class ClasApi(
|
||||
colls: ClasColls,
|
||||
userRepo: UserRepo,
|
||||
messageApi: MessageApi,
|
||||
msgApi: MsgApi,
|
||||
authenticator: Authenticator,
|
||||
cacheApi: lila.memo.CacheApi,
|
||||
baseUrl: BaseUrl
|
||||
|
@ -90,8 +90,14 @@ final class ClasApi(
|
|||
fetchNewObject = true
|
||||
)
|
||||
|
||||
def isTeacherOf(user: User, clasId: Clas.Id): Fu[Boolean] =
|
||||
coll.exists($id(clasId) ++ $doc("teachers" -> user.id))
|
||||
def isTeacherOf(teacher: User, clasId: Clas.Id): Fu[Boolean] =
|
||||
coll.exists($id(clasId) ++ $doc("teachers" -> teacher.id))
|
||||
|
||||
def isTeacherOfStudent(teacherId: User.ID, studentId: Student.Id): Fu[Boolean] =
|
||||
student.isStudent(studentId.value) >>&
|
||||
student.clasIdsOfUser(studentId.value).flatMap { clasIds =>
|
||||
coll.exists($inIds(clasIds) ++ $doc("teachers" -> teacherId))
|
||||
}
|
||||
|
||||
def archive(c: Clas, t: Teacher, v: Boolean): Funit =
|
||||
coll.update
|
||||
|
@ -123,8 +129,8 @@ final class ClasApi(
|
|||
.sort($sort asc "userId")
|
||||
.list[Student]()
|
||||
|
||||
def clasIdsOfUser(user: User): Fu[List[Clas.Id]] =
|
||||
coll.distinctEasy[Clas.Id, List]("clasId", $doc("userId" -> user.id))
|
||||
def clasIdsOfUser(userId: User.ID): Fu[List[Clas.Id]] =
|
||||
coll.distinctEasy[Clas.Id, List]("clasId", $doc("userId" -> userId))
|
||||
|
||||
def withUsers(students: List[Student]): Fu[List[Student.WithUser]] =
|
||||
userRepo.coll.idsMap[User, User.ID](
|
||||
|
@ -209,7 +215,7 @@ final class ClasApi(
|
|||
|
||||
def allIds = idsCache.getUnit
|
||||
|
||||
def isStudent(user: User) = idsCache.getUnit.dmap(_ contains user.id)
|
||||
def isStudent(userId: User.ID) = idsCache.getUnit.dmap(_ contains userId)
|
||||
|
||||
private val idsCache = cacheApi.unit[Set[User.ID]] {
|
||||
_.refreshAfterWrite(5 minutes)
|
||||
|
@ -219,17 +225,18 @@ final class ClasApi(
|
|||
}
|
||||
|
||||
private def sendWelcomeMessage(teacher: Teacher.WithUser, student: User, clas: Clas): Funit =
|
||||
messageApi
|
||||
.sendOnBehalf(
|
||||
sender = teacher.user,
|
||||
dest = student,
|
||||
subject = s"Invitation to ${clas.name}",
|
||||
msgApi
|
||||
.post(
|
||||
orig = teacher.user.id,
|
||||
dest = student.id,
|
||||
text = s"""
|
||||
Please click this link to access the class ${clas.name}:
|
||||
Welcome to your class: ${clas.name}.
|
||||
Here is the link to access the class.
|
||||
|
||||
$baseUrl/class/${clas.id}
|
||||
|
||||
${clas.desc}"""
|
||||
${clas.desc}""",
|
||||
unlimited = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ final class Env(
|
|||
gameRepo: lila.game.GameRepo,
|
||||
historyApi: lila.history.HistoryApi,
|
||||
puzzleRoundRepo: lila.puzzle.RoundRepo,
|
||||
messageApi: lila.message.MessageApi,
|
||||
msgApi: lila.msg.MsgApi,
|
||||
lightUserAsync: lila.common.LightUser.Getter,
|
||||
securityForms: lila.security.DataForm,
|
||||
authenticator: lila.user.Authenticator,
|
||||
|
@ -31,9 +31,15 @@ final class Env(
|
|||
|
||||
lazy val progressApi = wire[ClasProgressApi]
|
||||
|
||||
lila.common.Bus.subscribeFun("finishGame") {
|
||||
case lila.game.actorApi.FinishGame(game, _, _) => progressApi.onFinishGame(game)
|
||||
}
|
||||
lila.common.Bus.subscribeFuns(
|
||||
"finishGame" -> {
|
||||
case lila.game.actorApi.FinishGame(game, _, _) => progressApi.onFinishGame(game)
|
||||
},
|
||||
"clas" -> {
|
||||
case lila.hub.actorApi.clas.IsTeacherOf(teacher, student, promise) =>
|
||||
promise completeWith api.clas.isTeacherOfStudent(teacher, Student.Id(student))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private class ClasColls(db: lila.db.Db) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package lila.common
|
||||
|
||||
import play.api.libs.json.{ Json, OWrites }
|
||||
import play.api.libs.json._
|
||||
|
||||
case class LightUser(
|
||||
id: String,
|
||||
|
@ -19,18 +19,18 @@ object LightUser {
|
|||
private type UserID = String
|
||||
|
||||
implicit val lightUserWrites = OWrites[LightUser] { u =>
|
||||
Json
|
||||
.obj(
|
||||
"id" -> u.id,
|
||||
"name" -> u.name
|
||||
)
|
||||
.add("title" -> u.title)
|
||||
.add("patron" -> u.isPatron)
|
||||
writeNoId(u) + ("id" -> JsString(u.id))
|
||||
}
|
||||
|
||||
def fallback(userId: UserID) = LightUser(
|
||||
id = userId,
|
||||
name = userId,
|
||||
def writeNoId(u: LightUser): JsObject =
|
||||
Json
|
||||
.obj("name" -> u.name)
|
||||
.add("title" -> u.title)
|
||||
.add("patron" -> u.isPatron)
|
||||
|
||||
def fallback(name: String) = LightUser(
|
||||
id = name.toLowerCase,
|
||||
name = name,
|
||||
title = None,
|
||||
isPatron = false
|
||||
)
|
||||
|
|
|
@ -3,10 +3,6 @@ package lila.common
|
|||
import scala.concurrent.duration._
|
||||
|
||||
case class ApiVersion(value: Int) extends AnyVal with IntValue with Ordered[ApiVersion] {
|
||||
def v1 = value == 1
|
||||
def v2 = value == 2
|
||||
def v3 = value == 3
|
||||
def v4 = value == 4
|
||||
def compare(other: ApiVersion) = Integer.compare(value, other.value)
|
||||
def gt(other: Int) = value > other
|
||||
def gte(other: Int) = value >= other
|
||||
|
|
|
@ -241,5 +241,23 @@ trait CollExt { self: dsl with QueryBuilderExt =>
|
|||
) map {
|
||||
_.value flatMap implicitly[BSONDocumentReader[D]].readOpt
|
||||
}
|
||||
|
||||
// def findAndRemove[D: BSONDocumentReader](
|
||||
// selector: coll.pack.Document,
|
||||
// sort: Option[coll.pack.Document] = None,
|
||||
// fields: Option[coll.pack.Document] = None,
|
||||
// @silent writeConcern: CWC = CWC.Acknowledged
|
||||
// ): Fu[Option[D]] =
|
||||
// coll.findAndRemove(
|
||||
// selector = selector,
|
||||
// sort = sort,
|
||||
// fields = fields,
|
||||
// writeConcern = writeConcern,
|
||||
// maxTime = none,
|
||||
// collation = none,
|
||||
// arrayFilters = Seq.empty
|
||||
// ) map {
|
||||
// _.value flatMap implicitly[BSONDocumentReader[D]].readOpt
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,10 +34,15 @@ package socket {
|
|||
object remote {
|
||||
case class TellSriIn(sri: String, user: Option[String], msg: JsObject)
|
||||
case class TellSriOut(sri: String, payload: JsValue)
|
||||
case class TellUserIn(user: String, msg: JsObject)
|
||||
}
|
||||
case class BotIsOnline(userId: String, isOnline: Boolean)
|
||||
}
|
||||
|
||||
package clas {
|
||||
case class IsTeacherOf(teacherId: String, studentId: String, promise: Promise[Boolean])
|
||||
}
|
||||
|
||||
package report {
|
||||
case class Cheater(userId: String, text: String)
|
||||
case class Shutup(userId: String, text: String, major: Boolean)
|
||||
|
@ -54,7 +59,7 @@ package security {
|
|||
package shutup {
|
||||
case class RecordPublicForumMessage(userId: String, text: String)
|
||||
case class RecordTeamForumMessage(userId: String, text: String)
|
||||
case class RecordPrivateMessage(userId: String, toUserId: String, text: String, muted: Boolean)
|
||||
case class RecordPrivateMessage(userId: String, toUserId: String, text: String)
|
||||
case class RecordPrivateChat(chatId: String, userId: String, text: String)
|
||||
case class RecordPublicChat(userId: String, text: String, source: PublicSource)
|
||||
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import play.api.data._
|
||||
import play.api.data.Forms._
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.security.Granter
|
||||
import lila.user.User
|
||||
import lila.common.LightUser
|
||||
|
||||
final private[message] class DataForm(
|
||||
lightUserAsync: LightUser.Getter,
|
||||
security: MessageSecurity
|
||||
) {
|
||||
|
||||
import DataForm._
|
||||
|
||||
def thread(me: User) =
|
||||
Form(
|
||||
mapping(
|
||||
"username" -> lila.user.DataForm.historicalUsernameField
|
||||
.verifying("Unknown username", { blockingFetchUser(_).isDefined })
|
||||
.verifying(
|
||||
"Sorry, this player doesn't accept new messages", { name =>
|
||||
Granter(_.MessageAnyone)(me) || {
|
||||
security
|
||||
.canMessage(me.id, User normalize name)
|
||||
.await(2 seconds, "pmAccept") // damn you blocking API
|
||||
}
|
||||
}
|
||||
),
|
||||
"subject" -> text(minLength = 3, maxLength = 100),
|
||||
"text" -> text(minLength = 3, maxLength = 8000),
|
||||
"mod" -> optional(nonEmptyText)
|
||||
)({
|
||||
case (username, subject, text, mod) =>
|
||||
ThreadData(
|
||||
user = blockingFetchUser(username) err "Unknown username " + username,
|
||||
subject = subject,
|
||||
text = text,
|
||||
asMod = mod.isDefined
|
||||
)
|
||||
})(_.export.some)
|
||||
)
|
||||
|
||||
def post =
|
||||
Form(
|
||||
single(
|
||||
"text" -> text(minLength = 3)
|
||||
)
|
||||
)
|
||||
|
||||
private def blockingFetchUser(username: String) =
|
||||
lightUserAsync(User normalize username).await(1 second, "pmUser")
|
||||
}
|
||||
|
||||
object DataForm {
|
||||
|
||||
case class ThreadData(
|
||||
user: LightUser,
|
||||
subject: String,
|
||||
text: String,
|
||||
asMod: Boolean
|
||||
) {
|
||||
|
||||
def export = (user.name, subject, text, asMod option "1")
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import com.softwaremill.macwire._
|
||||
import io.methvin.play.autoconfig._
|
||||
import play.api.Configuration
|
||||
|
||||
import lila.common.config._
|
||||
import lila.user.UserRepo
|
||||
|
||||
@Module
|
||||
private class MessageConfig(
|
||||
@ConfigName("collection.thread") val threadColl: CollName,
|
||||
@ConfigName("thread.max_per_page") val threadMaxPerPage: MaxPerPage
|
||||
)
|
||||
|
||||
@Module
|
||||
final class Env(
|
||||
appConfig: Configuration,
|
||||
db: lila.db.Db,
|
||||
shutup: lila.hub.actors.Shutup,
|
||||
notifyApi: lila.notify.NotifyApi,
|
||||
relationApi: lila.relation.RelationApi,
|
||||
userRepo: UserRepo,
|
||||
prefApi: lila.pref.PrefApi,
|
||||
spam: lila.security.Spam,
|
||||
cacheApi: lila.memo.CacheApi,
|
||||
isOnline: lila.socket.IsOnline,
|
||||
lightUserSync: lila.common.LightUser.GetterSync,
|
||||
lightUserAsync: lila.common.LightUser.Getter
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
private val config = appConfig.get[MessageConfig]("message")(AutoConfig.loader)
|
||||
|
||||
private lazy val threadColl = db(config.threadColl)
|
||||
|
||||
lazy val repo = wire[ThreadRepo]
|
||||
|
||||
lazy val forms = wire[DataForm]
|
||||
|
||||
lazy val jsonView = wire[JsonView]
|
||||
|
||||
lazy val batch = wire[MessageBatch]
|
||||
|
||||
lazy val api = wire[MessageApi]
|
||||
|
||||
lazy val security = wire[MessageSecurity]
|
||||
|
||||
lila.common.Bus.subscribeFun("gdprErase") {
|
||||
case lila.user.User.GDPRErase(user) => api erase user
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc.Result
|
||||
import play.api.mvc.Results._
|
||||
|
||||
import lila.common.Json.jodaWrites
|
||||
import lila.common.LightUser
|
||||
import lila.common.paginator._
|
||||
import lila.user.User
|
||||
|
||||
final class JsonView(
|
||||
isOnline: lila.user.User.ID => Boolean,
|
||||
lightUser: LightUser.GetterSync
|
||||
) {
|
||||
|
||||
def inbox(me: User, threads: Paginator[Thread]): Result =
|
||||
Ok(PaginatorJson(threads.mapResults { t =>
|
||||
Json.obj(
|
||||
"id" -> t.id,
|
||||
"author" -> t.visibleOtherUserId(me),
|
||||
"name" -> t.name,
|
||||
"updatedAt" -> t.updatedAt,
|
||||
"isUnread" -> t.isUnReadBy(me)
|
||||
)
|
||||
}))
|
||||
|
||||
def thread(thread: Thread): Fu[JsValue] =
|
||||
fuccess(
|
||||
Json.obj(
|
||||
"id" -> thread.id,
|
||||
"name" -> thread.name,
|
||||
"posts" -> thread.posts.map { post =>
|
||||
threadPost(thread, post)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def threadPost(thread: Thread, post: Post): JsValue =
|
||||
Json.obj(
|
||||
"sender" -> user(thread.visibleSenderOf(post)),
|
||||
"receiver" -> user(thread.visibleReceiverOf(post)),
|
||||
"text" -> post.text,
|
||||
"createdAt" -> post.createdAt
|
||||
)
|
||||
|
||||
private def user(userId: String) =
|
||||
lightUser(userId).map { l =>
|
||||
LightUser.lightUserWrites.writes(l) ++ Json.obj(
|
||||
"online" -> isOnline(userId),
|
||||
"username" -> l.name // for mobile app BC
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,167 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.common.paginator._
|
||||
import lila.db.dsl._
|
||||
import lila.db.paginator._
|
||||
import lila.security.Granter
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
final class MessageApi(
|
||||
coll: Coll,
|
||||
userRepo: UserRepo,
|
||||
threadRepo: ThreadRepo,
|
||||
shutup: lila.hub.actors.Shutup,
|
||||
maxPerPage: lila.common.config.MaxPerPage,
|
||||
relationApi: lila.relation.RelationApi,
|
||||
notifyApi: lila.notify.NotifyApi,
|
||||
security: MessageSecurity,
|
||||
cacheApi: lila.memo.CacheApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import Thread.ThreadBSONHandler
|
||||
|
||||
def inbox(me: User, page: Int): Fu[Paginator[Thread]] = Paginator(
|
||||
adapter = new Adapter(
|
||||
collection = coll,
|
||||
selector = threadRepo visibleByUserQuery me.id,
|
||||
projection = none,
|
||||
sort = threadRepo.recentSort
|
||||
),
|
||||
currentPage = page,
|
||||
maxPerPage = maxPerPage
|
||||
)
|
||||
|
||||
private val unreadCountCache = cacheApi[User.ID, Int](256, "message.unreadCount") {
|
||||
_.expireAfterWrite(10 seconds)
|
||||
.buildAsyncFuture[User.ID, Int](threadRepo.unreadCount _)
|
||||
}
|
||||
|
||||
def unreadCount(me: User): Fu[Int] = unreadCountCache.get(me.id)
|
||||
|
||||
def thread(id: String, me: User): Fu[Option[Thread]] =
|
||||
for {
|
||||
threadOption <- coll.byId[Thread](id) map (_ filter (_ hasUser me))
|
||||
_ <- threadOption.filter(_ isUnReadBy me).??(threadRepo.setReadFor(me))
|
||||
} yield threadOption
|
||||
|
||||
def sendPreset(mod: User, user: User, preset: ModPreset): Fu[Thread] =
|
||||
makeThread(
|
||||
DataForm.ThreadData(
|
||||
user = user.light,
|
||||
subject = preset.subject,
|
||||
text = preset.text,
|
||||
asMod = true
|
||||
),
|
||||
mod
|
||||
)
|
||||
|
||||
def sendPresetFromLichess(user: User, preset: ModPreset) =
|
||||
userRepo.lichess orFail "Missing lichess user" flatMap { sendPreset(_, user, preset) }
|
||||
|
||||
def makeThread(data: DataForm.ThreadData, me: User): Fu[Thread] = {
|
||||
val fromMod = Granter(_.MessageAnyone)(me)
|
||||
userRepo named data.user.id flatMap {
|
||||
_.fold(fufail[Thread]("No such recipient")) { invited =>
|
||||
val t = Thread.make(
|
||||
name = data.subject,
|
||||
text = data.text,
|
||||
creatorId = me.id,
|
||||
invitedId = data.user.id,
|
||||
asMod = data.asMod
|
||||
)
|
||||
security.muteThreadIfNecessary(t, me, invited) flatMap { thread =>
|
||||
sendUnlessBlocked(thread, fromMod) flatMap {
|
||||
_ ?? {
|
||||
val text = s"${data.subject} ${data.text}"
|
||||
shutup ! lila.hub.actorApi.shutup
|
||||
.RecordPrivateMessage(me.id, invited.id, text, thread.looksMuted)
|
||||
notify(thread)
|
||||
}
|
||||
} inject thread
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def sendOnBehalf(
|
||||
sender: User,
|
||||
dest: User,
|
||||
subject: String,
|
||||
text: String
|
||||
) = makeThread(DataForm.ThreadData(dest.light, subject, text, false), sender).void
|
||||
|
||||
private def sendUnlessBlocked(thread: Thread, fromMod: Boolean): Fu[Boolean] =
|
||||
if (fromMod) coll.insert.one(thread) inject true
|
||||
else
|
||||
relationApi.fetchBlocks(thread.invitedId, thread.creatorId) flatMap { blocks =>
|
||||
((!blocks) ?? coll.insert.one(thread).void) inject !blocks
|
||||
}
|
||||
|
||||
def makePost(thread: Thread, text: String, me: User): Fu[Thread] = {
|
||||
val post = Post.make(
|
||||
text = text,
|
||||
isByCreator = thread isCreator me
|
||||
)
|
||||
if (thread endsWith post) fuccess(thread) // prevent duplicate post
|
||||
else
|
||||
relationApi.fetchBlocks(thread receiverOf post, me.id) flatMap {
|
||||
case true => fuccess(thread)
|
||||
case false =>
|
||||
val newThread = thread + post
|
||||
coll.update.one($id(newThread.id), newThread) >> {
|
||||
val toUserId = newThread otherUserId me
|
||||
shutup ! lila.hub.actorApi.shutup.RecordPrivateMessage(me.id, toUserId, text, muted = false)
|
||||
notify(thread, post)
|
||||
} inject newThread
|
||||
}
|
||||
}
|
||||
|
||||
def deleteThread(id: String, me: User): Funit =
|
||||
thread(id, me) flatMap {
|
||||
_ ?? { thread =>
|
||||
threadRepo.deleteFor(me.id)(thread.id) zip
|
||||
notifyApi.remove(
|
||||
lila.notify.Notification.Notifies(me.id),
|
||||
$doc("content.thread.id" -> thread.id)
|
||||
) void
|
||||
}
|
||||
}
|
||||
|
||||
def deleteThreadsBy(user: User): Funit =
|
||||
threadRepo.createdByUser(user.id) flatMap {
|
||||
_.map { thread =>
|
||||
val victimId = thread otherUserId user
|
||||
threadRepo.deleteFor(victimId)(thread.id) zip
|
||||
notifyApi.remove(
|
||||
lila.notify.Notification.Notifies(victimId),
|
||||
$doc("content.thread.id" -> thread.id)
|
||||
) void
|
||||
}.sequenceFu.void
|
||||
}
|
||||
|
||||
def notify(thread: Thread): Funit = thread.posts.headOption ?? { post =>
|
||||
notify(thread, post)
|
||||
}
|
||||
def notify(thread: Thread, post: Post): Funit =
|
||||
(thread isVisibleBy thread.receiverOf(post)) ?? {
|
||||
import lila.notify.{ Notification, PrivateMessage }
|
||||
import lila.common.String.shorten
|
||||
lila.common.Bus.publish(Event.NewMessage(thread, post), "newMessage")
|
||||
notifyApi addNotification Notification.make(
|
||||
Notification.Notifies(thread receiverOf post),
|
||||
PrivateMessage(
|
||||
PrivateMessage.SenderId(thread visibleSenderOf post),
|
||||
PrivateMessage.Thread(id = thread.id, name = shorten(thread.name, 80)),
|
||||
PrivateMessage.Text(shorten(post.text, 80))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def erase(user: User) = threadRepo.byAndForWithoutIndex(user) flatMap { threads =>
|
||||
lila.common.Future.applySequentially(threads) { thread =>
|
||||
coll.update.one($id(thread.id), thread erase user).void
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.user.User
|
||||
|
||||
final class MessageBatch(
|
||||
threadRepo: ThreadRepo,
|
||||
notifyApi: lila.notify.NotifyApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
def apply(me: User, action: String, ids: List[String]): Funit = ids.nonEmpty ?? {
|
||||
action match {
|
||||
case "read" => markRead(me, ids)
|
||||
case "unread" => markUnread(me, ids)
|
||||
case "delete" => delete(me, ids)
|
||||
case x => fufail(s"Invalid message batch action: $x")
|
||||
}
|
||||
}
|
||||
|
||||
def markRead(me: User, ids: List[String]): Funit =
|
||||
threadRepo
|
||||
.visibleByUserByIds(me, ids)
|
||||
.flatMap {
|
||||
_.map(threadRepo.setReadFor(me)).sequenceFu
|
||||
}
|
||||
.void
|
||||
|
||||
def markUnread(me: User, ids: List[String]): Funit =
|
||||
threadRepo
|
||||
.visibleByUserByIds(me, ids)
|
||||
.flatMap {
|
||||
_.map(threadRepo.setUnreadFor(me)).sequenceFu
|
||||
}
|
||||
.void
|
||||
|
||||
def delete(me: User, ids: List[String]): Funit =
|
||||
threadRepo.visibleByUserByIds(me, ids).flatMap {
|
||||
_.map { thread =>
|
||||
threadRepo.deleteFor(me.id)(thread.id) zip
|
||||
notifyApi.remove(
|
||||
lila.notify.Notification.Notifies(me.id),
|
||||
$doc("content.thread.id" -> thread.id)
|
||||
) void
|
||||
}.sequenceFu.void
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import org.joda.time.DateTime
|
||||
|
||||
import lila.shutup.Analyser
|
||||
import lila.user.User
|
||||
import lila.hub.actorApi.report.AutoFlag
|
||||
|
||||
final private[message] class MessageSecurity(
|
||||
relationApi: lila.relation.RelationApi,
|
||||
prefApi: lila.pref.PrefApi,
|
||||
spam: lila.security.Spam
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import lila.pref.Pref.Message._
|
||||
|
||||
def canMessage(from: User.ID, to: User.ID): Fu[Boolean] =
|
||||
relationApi.fetchBlocks(to, from) flatMap {
|
||||
case true => fuFalse
|
||||
case false =>
|
||||
prefApi.getPref(to).dmap(_.message) flatMap {
|
||||
case NEVER => fuFalse
|
||||
case FRIEND => relationApi.fetchFollows(to, from)
|
||||
case ALWAYS => fuTrue
|
||||
}
|
||||
}
|
||||
|
||||
def muteThreadIfNecessary(thread: Thread, creator: User, invited: User): Fu[Thread] = {
|
||||
val fullText = s"${thread.name} ${~thread.firstPost.map(_.text)}"
|
||||
if (spam.detect(fullText)) {
|
||||
logger.warn(s"PM spam from ${creator.username}: $fullText")
|
||||
fuTrue
|
||||
} else if (creator.marks.troll) !relationApi.fetchFollows(invited.id, creator.id)
|
||||
else if (Analyser(fullText).dirty && creator.createdAt.isAfter(DateTime.now.minusDays(30))) {
|
||||
relationApi.fetchFollows(invited.id, creator.id) map { f =>
|
||||
if (!f) thread.firstPost.foreach { post =>
|
||||
lila.common.Bus.publish(
|
||||
AutoFlag(creator.id, s"message/${thread.id}", thread flaggableText post),
|
||||
"autoFlag"
|
||||
)
|
||||
}
|
||||
!f
|
||||
}
|
||||
} else fuFalse
|
||||
} map { mute =>
|
||||
if (mute) thread deleteFor invited
|
||||
else thread
|
||||
}
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import org.joda.time.DateTime
|
||||
import ornicar.scalalib.Random
|
||||
|
||||
case class Post(
|
||||
id: String,
|
||||
text: String,
|
||||
isByCreator: Boolean,
|
||||
isRead: Boolean,
|
||||
createdAt: DateTime
|
||||
) {
|
||||
|
||||
def isByInvited = !isByCreator
|
||||
|
||||
def isUnRead = !isRead
|
||||
|
||||
def similar(other: Post) = text == other.text && isByCreator == other.isByCreator
|
||||
|
||||
def erase = copy(text = "<deleted>")
|
||||
}
|
||||
|
||||
object Post {
|
||||
|
||||
val idSize = 8
|
||||
|
||||
def make(
|
||||
text: String,
|
||||
isByCreator: Boolean
|
||||
): Post = Post(
|
||||
id = Random nextString idSize,
|
||||
text = text,
|
||||
isByCreator = isByCreator,
|
||||
isRead = false,
|
||||
createdAt = DateTime.now
|
||||
)
|
||||
|
||||
import lila.db.dsl.BSONJodaDateTimeHandler
|
||||
implicit private[message] val PostBSONHandler = reactivemongo.api.bson.Macros.handler[Post]
|
||||
}
|
|
@ -1,152 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import org.joda.time.DateTime
|
||||
import ornicar.scalalib.Random
|
||||
|
||||
import lila.user.User
|
||||
|
||||
case class Thread(
|
||||
_id: String,
|
||||
name: String,
|
||||
createdAt: DateTime,
|
||||
updatedAt: DateTime,
|
||||
posts: List[Post],
|
||||
creatorId: User.ID,
|
||||
invitedId: User.ID,
|
||||
visibleByUserIds: List[User.ID],
|
||||
deletedByUserIds: Option[List[User.ID]],
|
||||
mod: Option[Boolean]
|
||||
) {
|
||||
|
||||
def +(post: Post) = copy(
|
||||
posts = posts :+ post,
|
||||
updatedAt = post.createdAt
|
||||
)
|
||||
|
||||
def id = _id
|
||||
|
||||
def asMod = ~mod
|
||||
|
||||
def isCreator(user: User) = creatorId == user.id
|
||||
|
||||
def isReadBy(user: User) = nbUnreadBy(user) == 0
|
||||
|
||||
def isUnReadBy(user: User) = !isReadBy(user)
|
||||
|
||||
def isNeverRead = firstPost.fold(true)(_.isUnRead)
|
||||
|
||||
private def isPostUnreadBy(user: User)(post: Post) =
|
||||
post.isUnRead && post.isByCreator != isCreator(user)
|
||||
|
||||
def nbUnreadBy(user: User): Int = posts count isPostUnreadBy(user)
|
||||
|
||||
def nbPosts = posts.size
|
||||
|
||||
def isTooBig = nbPosts > 200
|
||||
|
||||
def isReplyable = !isTooBig && !isLichess
|
||||
|
||||
def isLichess = creatorId == User.lichessId
|
||||
|
||||
def firstPost: Option[Post] = posts.headOption
|
||||
|
||||
def isFirstPost(post: Post) = firstPost contains post
|
||||
|
||||
def firstPostUnreadBy(user: User): Option[Post] = posts find isPostUnreadBy(user)
|
||||
|
||||
def unreadIndexesBy(user: User): List[Int] = posts.zipWithIndex collect {
|
||||
case (post, index) if isPostUnreadBy(user)(post) => index
|
||||
}
|
||||
|
||||
def readIndexesBy(user: User): List[Int] = posts.zipWithIndex collect {
|
||||
case (post, index) if post.isRead && post.isByCreator != isCreator(user) => index
|
||||
}
|
||||
|
||||
def userIds = List(creatorId, invitedId)
|
||||
|
||||
def hasUser(user: User) = userIds contains user.id
|
||||
|
||||
def otherUserId(user: User) = if (isCreator(user)) invitedId else creatorId
|
||||
|
||||
def visibleOtherUserId(user: User) =
|
||||
if (isCreator(user)) invitedId
|
||||
else if (asMod) User.lichessId
|
||||
else creatorId
|
||||
|
||||
def senderOf(post: Post) = if (post.isByCreator) creatorId else invitedId
|
||||
|
||||
def visibleSenderOf(post: Post) =
|
||||
if (post.isByCreator && asMod) User.lichessId
|
||||
else senderOf(post)
|
||||
|
||||
def receiverOf(post: Post) = if (post.isByCreator) invitedId else creatorId
|
||||
|
||||
def visibleReceiverOf(post: Post) =
|
||||
if (!post.isByCreator && asMod) User.lichessId
|
||||
else receiverOf(post)
|
||||
|
||||
def isWrittenBy(post: Post, user: User) = post.isByCreator == isCreator(user)
|
||||
|
||||
def nonEmptyName = (name.trim.some filter (_.nonEmpty)) | "No subject"
|
||||
|
||||
def deleteFor(user: User) = copy(
|
||||
visibleByUserIds = visibleByUserIds filter (user.id !=),
|
||||
deletedByUserIds = Some(user.id :: ~deletedByUserIds)
|
||||
)
|
||||
|
||||
def isVisibleBy(userId: User.ID) = visibleByUserIds contains userId
|
||||
|
||||
def isVisibleByOther(user: User) = isVisibleBy(otherUserId(user))
|
||||
|
||||
def looksMuted = posts.length == 1 && (~deletedByUserIds).has(invitedId)
|
||||
|
||||
def hasPostsWrittenBy(userId: User.ID) = posts exists (_.isByCreator == (creatorId == userId))
|
||||
|
||||
def endsWith(post: Post) = posts.lastOption ?? post.similar
|
||||
|
||||
def erase(user: User) = copy(
|
||||
posts = posts.map {
|
||||
case p if p.isByCreator && user.id == creatorId => p.erase
|
||||
case p if !p.isByCreator && user.id == invitedId => p.erase
|
||||
case p => p
|
||||
}
|
||||
)
|
||||
|
||||
def flaggableText(post: Post) =
|
||||
if (isFirstPost(post)) s"${name} / ${post.text}"
|
||||
else post.text
|
||||
}
|
||||
|
||||
object Thread {
|
||||
|
||||
val idSize = 8
|
||||
|
||||
def make(
|
||||
name: String,
|
||||
text: String,
|
||||
creatorId: String,
|
||||
invitedId: String,
|
||||
asMod: Boolean
|
||||
): Thread = Thread(
|
||||
_id = Random nextString idSize,
|
||||
name = name,
|
||||
createdAt = DateTime.now,
|
||||
updatedAt = DateTime.now,
|
||||
posts = List(
|
||||
Post.make(
|
||||
text = text,
|
||||
isByCreator = true
|
||||
)
|
||||
),
|
||||
creatorId = creatorId,
|
||||
invitedId = invitedId,
|
||||
visibleByUserIds = List(creatorId, invitedId),
|
||||
deletedByUserIds = None,
|
||||
mod = asMod option true
|
||||
)
|
||||
|
||||
import lila.db.dsl.BSONJodaDateTimeHandler
|
||||
import Post.PostBSONHandler
|
||||
implicit private[message] val ThreadBSONHandler =
|
||||
reactivemongo.api.bson.Macros.handler[Thread]
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
import org.joda.time.DateTime
|
||||
import reactivemongo.api.ReadPreference
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.user.User
|
||||
|
||||
final class ThreadRepo(coll: Coll)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
type ID = String
|
||||
|
||||
def byUser(user: ID): Fu[List[Thread]] =
|
||||
coll.ext.find(userQuery(user)).sort(recentSort).cursor[Thread]().gather[List]()
|
||||
|
||||
def visibleOrDeletedByUser(user: ID, nb: Int): Fu[List[Thread]] =
|
||||
for {
|
||||
visible <- visibleByUser(user, nb)
|
||||
deleted <- coll.ext.find(deletedByUserQuery(user)).sort(recentSort).list[Thread](nb)
|
||||
} yield (visible ::: deleted).sortBy(_.updatedAt)(Ordering[DateTime].reverse).take(nb)
|
||||
|
||||
def visibleByUser(user: ID, nb: Int): Fu[List[Thread]] =
|
||||
coll.ext.find(visibleByUserQuery(user)).sort(recentSort).list[Thread](nb)
|
||||
|
||||
def visibleByUserByIds(user: User, ids: List[String]): Fu[List[Thread]] =
|
||||
coll.ext.find($inIds(ids) ++ visibleByUserQuery(user.id)).list[Thread]()
|
||||
|
||||
def createdByUser(user: ID): Fu[List[Thread]] =
|
||||
coll.ext.find(visibleByUserQuery(user) ++ $doc("creatorId" -> user)).list[Thread]()
|
||||
|
||||
// super heavy. For GDPR only.
|
||||
private[message] def byAndForWithoutIndex(user: User): Fu[List[Thread]] =
|
||||
coll.ext
|
||||
.find(
|
||||
$or(
|
||||
$doc("creatorId" -> user.id),
|
||||
$doc("invitedId" -> user.id)
|
||||
)
|
||||
)
|
||||
.list[Thread](999, readPreference = ReadPreference.secondaryPreferred)
|
||||
|
||||
def setReadFor(user: User)(thread: Thread): Funit = {
|
||||
val indexes = thread.unreadIndexesBy(user)
|
||||
indexes.nonEmpty ?? coll.update
|
||||
.one($id(thread.id), $doc("$set" -> indexes.foldLeft($empty) {
|
||||
case (s, index) => s ++ $doc(s"posts.$index.isRead" -> true)
|
||||
}))
|
||||
.void
|
||||
}
|
||||
|
||||
def setUnreadFor(user: User)(thread: Thread): Funit =
|
||||
thread.readIndexesBy(user).lastOption ?? { index =>
|
||||
coll.update.one($id(thread.id), $set(s"posts.$index.isRead" -> false)).void
|
||||
}
|
||||
|
||||
def unreadCount(userId: String): Fu[Int] = {
|
||||
import reactivemongo.api.bson.BSONNull
|
||||
coll
|
||||
.aggregateWith(
|
||||
readPreference = ReadPreference.secondaryPreferred
|
||||
) { framework =>
|
||||
import framework._
|
||||
Match(
|
||||
$doc(
|
||||
"visibleByUserIds" -> userId,
|
||||
"updatedAt" $gt DateTime.now.minusMonths(1),
|
||||
"posts.isRead" -> false
|
||||
)
|
||||
) -> List(
|
||||
Project(
|
||||
$doc(
|
||||
"m" -> $doc("$eq" -> $arr("$creatorId", userId)),
|
||||
"posts.isByCreator" -> true,
|
||||
"posts.isRead" -> true
|
||||
)
|
||||
),
|
||||
UnwindField("posts"),
|
||||
Match(
|
||||
$doc(
|
||||
"posts.isRead" -> false
|
||||
)
|
||||
),
|
||||
Project(
|
||||
$doc(
|
||||
"u" -> $doc("$ne" -> $arr("$posts.isByCreator", "$m"))
|
||||
)
|
||||
),
|
||||
Match(
|
||||
$doc(
|
||||
"u" -> true
|
||||
)
|
||||
),
|
||||
Group(BSONNull)("nb" -> SumAll)
|
||||
)
|
||||
}
|
||||
.headOption
|
||||
.map {
|
||||
~_.flatMap(_ int "nb")
|
||||
}
|
||||
}
|
||||
|
||||
def deleteFor(user: ID)(thread: ID) =
|
||||
coll.update
|
||||
.one($id(thread), $doc($pull("visibleByUserIds" -> user), $push("deletedByUserIds" -> user)))
|
||||
.void
|
||||
|
||||
def userQuery(user: String) = $doc("userIds" -> user)
|
||||
|
||||
def visibleByUserQuery(user: String) = $doc("visibleByUserIds" -> user)
|
||||
|
||||
def deletedByUserQuery(user: String) = $doc("deletedByUserIds" -> user)
|
||||
|
||||
val recentSort = $sort desc "updatedAt"
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package lila.message
|
||||
|
||||
object Event {
|
||||
case class NewMessage(t: Thread, p: Post)
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
package lila
|
||||
|
||||
package object message extends PackageObject {
|
||||
|
||||
private[message] val logger = lila.log("message")
|
||||
}
|
47
modules/msg/src/main/BsonHandlers.scala
Normal file
47
modules/msg/src/main/BsonHandlers.scala
Normal file
|
@ -0,0 +1,47 @@
|
|||
package lila.msg
|
||||
|
||||
import reactivemongo.api.bson._
|
||||
|
||||
import lila.user.User
|
||||
import lila.db.dsl._
|
||||
import lila.db.BSON
|
||||
|
||||
private object BsonHandlers {
|
||||
|
||||
import Msg.Last
|
||||
implicit val msgContentHandler = Macros.handler[Last]
|
||||
|
||||
implicit val threadIdHandler = stringAnyValHandler[MsgThread.Id](_.value, MsgThread.Id.apply)
|
||||
|
||||
implicit val threadHandler = new BSON[MsgThread] {
|
||||
def reads(r: BSON.Reader) = r.strsD("users") match {
|
||||
case List(u1, u2) =>
|
||||
MsgThread(
|
||||
id = r.get[MsgThread.Id]("_id"),
|
||||
user1 = u1,
|
||||
user2 = u2,
|
||||
lastMsg = r.get[Last]("lastMsg")
|
||||
)
|
||||
case x => sys error s"Invalid MsgThread users: $x"
|
||||
}
|
||||
def writes(w: BSON.Writer, t: MsgThread) = $doc(
|
||||
"_id" -> t.id,
|
||||
"users" -> t.users.sorted,
|
||||
"lastMsg" -> t.lastMsg
|
||||
)
|
||||
}
|
||||
|
||||
implicit val msgIdHandler = stringAnyValHandler[Msg.Id](_.value, Msg.Id.apply)
|
||||
implicit val msgHandler = Macros.handler[Msg]
|
||||
|
||||
def writeMsg(msg: Msg, threadId: MsgThread.Id): Bdoc =
|
||||
msgHandler.writeTry(msg).get ++ $doc(
|
||||
"_id" -> ornicar.scalalib.Random.nextString(10),
|
||||
"tid" -> threadId
|
||||
)
|
||||
|
||||
def writeThread(thread: MsgThread, delBy: Option[User.ID]): Bdoc =
|
||||
threadHandler.writeTry(thread).get ++ delBy.?? { by =>
|
||||
$doc("del" -> List(by))
|
||||
}
|
||||
}
|
62
modules/msg/src/main/Env.scala
Normal file
62
modules/msg/src/main/Env.scala
Normal file
|
@ -0,0 +1,62 @@
|
|||
package lila.msg
|
||||
|
||||
import com.softwaremill.macwire._
|
||||
|
||||
import lila.common.Bus
|
||||
import lila.common.config._
|
||||
import lila.user.User
|
||||
import lila.hub.actorApi.socket.remote.TellUserIn
|
||||
|
||||
@Module
|
||||
final class Env(
|
||||
db: lila.db.Db,
|
||||
lightUserApi: lila.user.LightUserApi,
|
||||
isOnline: lila.socket.IsOnline,
|
||||
userRepo: lila.user.UserRepo,
|
||||
relationApi: lila.relation.RelationApi,
|
||||
prefApi: lila.pref.PrefApi,
|
||||
notifyApi: lila.notify.NotifyApi,
|
||||
cacheApi: lila.memo.CacheApi,
|
||||
spam: lila.security.Spam,
|
||||
chatPanic: lila.chat.ChatPanic,
|
||||
shutup: lila.hub.actors.Shutup
|
||||
)(
|
||||
implicit ec: scala.concurrent.ExecutionContext,
|
||||
system: akka.actor.ActorSystem,
|
||||
scheduler: akka.actor.Scheduler
|
||||
) {
|
||||
|
||||
private val colls = wire[MsgColls]
|
||||
|
||||
lazy val json = wire[MsgJson]
|
||||
|
||||
private lazy val notifier = wire[MsgNotify]
|
||||
|
||||
private lazy val security = wire[MsgSecurity]
|
||||
|
||||
lazy val api: MsgApi = wire[MsgApi]
|
||||
|
||||
lazy val search = wire[MsgSearch]
|
||||
|
||||
lazy val compat = wire[MsgCompat]
|
||||
|
||||
Bus.subscribeFuns(
|
||||
"remoteSocketIn:msgRead" -> {
|
||||
case TellUserIn(userId, msg) =>
|
||||
msg str "d" map User.normalize foreach { api.setRead(userId, _) }
|
||||
},
|
||||
"remoteSocketIn:msgSend" -> {
|
||||
case TellUserIn(userId, msg) =>
|
||||
for {
|
||||
obj <- msg obj "d"
|
||||
dest <- obj str "dest" map User.normalize
|
||||
text <- obj str "text"
|
||||
} api.post(userId, dest, text)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private class MsgColls(db: lila.db.Db) {
|
||||
val thread = db(CollName("msg_thread"))
|
||||
val msg = db(CollName("msg_msg"))
|
||||
}
|
42
modules/msg/src/main/Msg.scala
Normal file
42
modules/msg/src/main/Msg.scala
Normal file
|
@ -0,0 +1,42 @@
|
|||
package lila.msg
|
||||
|
||||
import org.joda.time.DateTime
|
||||
|
||||
import lila.user.User
|
||||
|
||||
case class Msg(
|
||||
text: String,
|
||||
user: User.ID,
|
||||
date: DateTime
|
||||
) {
|
||||
|
||||
def asLast = Msg.Last(
|
||||
text = text take 60,
|
||||
user = user,
|
||||
date = date,
|
||||
read = false
|
||||
)
|
||||
}
|
||||
|
||||
object Msg {
|
||||
|
||||
case class Id(value: String) extends AnyVal
|
||||
|
||||
case class Last(
|
||||
text: String,
|
||||
user: User.ID,
|
||||
date: DateTime,
|
||||
read: Boolean
|
||||
) {
|
||||
def unreadBy(userId: User.ID) = !read && user != userId
|
||||
}
|
||||
|
||||
def make(text: String, user: User.ID): Option[Msg] = {
|
||||
val cleanText = text.trim
|
||||
cleanText.nonEmpty option Msg(
|
||||
text = cleanText take 10_000,
|
||||
user = user,
|
||||
date = DateTime.now
|
||||
)
|
||||
}
|
||||
}
|
175
modules/msg/src/main/MsgApi.scala
Normal file
175
modules/msg/src/main/MsgApi.scala
Normal file
|
@ -0,0 +1,175 @@
|
|||
package lila.msg
|
||||
|
||||
import reactivemongo.api._
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.common.{ Bus, LightUser }
|
||||
import lila.db.dsl._
|
||||
import lila.user.User
|
||||
|
||||
final class MsgApi(
|
||||
colls: MsgColls,
|
||||
userRepo: lila.user.UserRepo,
|
||||
cacheApi: lila.memo.CacheApi,
|
||||
lightUserApi: lila.user.LightUserApi,
|
||||
relationApi: lila.relation.RelationApi,
|
||||
json: MsgJson,
|
||||
notifier: MsgNotify,
|
||||
security: MsgSecurity,
|
||||
shutup: lila.hub.actors.Shutup
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import BsonHandlers._
|
||||
|
||||
def threadsOf(me: User): Fu[List[MsgThread]] =
|
||||
colls.thread.ext
|
||||
.find($doc("users" -> me.id, "del" $ne me.id))
|
||||
.sort($sort desc "lastMsg.date")
|
||||
.list[MsgThread](50)
|
||||
|
||||
def convoWith(me: User, username: String): Fu[MsgConvo] = {
|
||||
val userId = User.normalize(username)
|
||||
for {
|
||||
contact <- lightUserApi async userId dmap (_ | LightUser.fallback(username))
|
||||
threadId = MsgThread.id(me.id, userId)
|
||||
_ <- setReadBy(threadId, me)
|
||||
msgs <- threadMsgsFor(threadId, me)
|
||||
relations <- relationApi.fetchRelations(me.id, userId)
|
||||
postable <- security.may.post(me.id, userId, isNew = msgs.headOption.isEmpty)
|
||||
} yield MsgConvo(contact, msgs, relations, postable)
|
||||
}
|
||||
|
||||
def delete(me: User, username: String): Funit = {
|
||||
val threadId = MsgThread.id(me.id, User.normalize(username))
|
||||
colls.msg.update
|
||||
.one($doc("tid" -> threadId), $addToSet("del" -> me.id), multi = true) >>
|
||||
colls.thread.update
|
||||
.one($id(threadId), $addToSet("del" -> me.id))
|
||||
.void
|
||||
}
|
||||
|
||||
def post(
|
||||
orig: User.ID,
|
||||
dest: User.ID,
|
||||
text: String,
|
||||
unlimited: Boolean = false
|
||||
): Funit = Msg.make(text, orig) ?? { msg =>
|
||||
val threadId = MsgThread.id(orig, dest)
|
||||
for {
|
||||
contacts <- userRepo.contacts(orig, dest) orFail "Missing convo contact user"
|
||||
isNew <- !colls.thread.exists($id(threadId))
|
||||
verdict <- security.can.post(contacts, msg.text, isNew, unlimited)
|
||||
res <- verdict match {
|
||||
case _: MsgSecurity.Reject => funit
|
||||
case send: MsgSecurity.Send =>
|
||||
val msgWrite = colls.msg.insert.one(writeMsg(msg, threadId))
|
||||
val threadWrite =
|
||||
if (isNew)
|
||||
colls.thread.insert.one {
|
||||
writeThread(MsgThread.make(orig, dest, msg), delBy = send.mute option dest)
|
||||
}.void
|
||||
else
|
||||
colls.thread.update
|
||||
.one(
|
||||
$id(threadId),
|
||||
$set("lastMsg" -> msg.asLast) ++ $pull(
|
||||
// unset "deleted by receiver" unless the message is muted
|
||||
"del" $in (orig :: (!send.mute).option(dest).toList)
|
||||
)
|
||||
)
|
||||
.void
|
||||
(msgWrite zip threadWrite).void >>- {
|
||||
notifier.onPost(threadId)
|
||||
Bus.publish(
|
||||
lila.hub.actorApi.socket.SendTo(
|
||||
dest,
|
||||
lila.socket.Socket.makeMessage("msgNew", json.renderMsg(msg))
|
||||
),
|
||||
"socketUsers"
|
||||
)
|
||||
shutup ! lila.hub.actorApi.shutup.RecordPrivateMessage(orig, dest, text)
|
||||
}
|
||||
case _ => funit
|
||||
}
|
||||
} yield res
|
||||
}
|
||||
|
||||
def setRead(userId: User.ID, contactId: User.ID): Funit = {
|
||||
val threadId = MsgThread.id(userId, contactId)
|
||||
colls.thread
|
||||
.updateField(
|
||||
$id(threadId) ++ $doc("lastMsg.user" -> contactId),
|
||||
"lastMsg.read",
|
||||
true
|
||||
)
|
||||
.map { res =>
|
||||
if (res.nModified > 0) notifier.onRead(threadId)
|
||||
}
|
||||
}
|
||||
|
||||
def postPreset(dest: User, preset: MsgPreset): Funit =
|
||||
post(User.lichessId, dest.id, preset.text, unlimited = true)
|
||||
|
||||
def recentByForMod(user: User, nb: Int): Fu[List[MsgConvo]] =
|
||||
colls.thread.ext
|
||||
.find($doc("users" -> user.id))
|
||||
.sort($sort desc "lastMsg.date")
|
||||
.list[MsgThread](nb)
|
||||
.flatMap {
|
||||
_.map { thread =>
|
||||
colls.msg.ext
|
||||
.find($doc("tid" -> thread.id), msgProjection)
|
||||
.sort($sort desc "date")
|
||||
.list[Msg](10)
|
||||
.flatMap { msgs =>
|
||||
lightUserApi async thread.other(user) map { contact =>
|
||||
MsgConvo(
|
||||
contact | LightUser.fallback(thread other user),
|
||||
msgs,
|
||||
lila.relation.Relations(none, none),
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
}.sequenceFu
|
||||
}
|
||||
|
||||
def deleteAllBy(user: User): Funit =
|
||||
colls.thread.list[MsgThread]($doc("users" -> user.id)) flatMap { threads =>
|
||||
colls.thread.delete.one($doc("users" -> user.id)) >>
|
||||
colls.msg.delete.one($doc("tid" $in threads.map(_.id))) >>
|
||||
notifier.deleteAllBy(threads, user)
|
||||
}
|
||||
|
||||
def unreadCount(me: User): Fu[Int] = unreadCountCache.get(me.id)
|
||||
|
||||
private val unreadCountCache = cacheApi[User.ID, Int](256, "message.unreadCount") {
|
||||
_.expireAfterWrite(10 seconds)
|
||||
.buildAsyncFuture[User.ID, Int] { userId =>
|
||||
colls.thread.countSel($doc("users" -> userId, "lastMsg.read" -> false, "lastMsg.user" $ne userId))
|
||||
}
|
||||
}
|
||||
|
||||
private val msgProjection = $doc("_id" -> false, "tid" -> false)
|
||||
|
||||
private def threadMsgsFor(threadId: MsgThread.Id, me: User): Fu[List[Msg]] =
|
||||
colls.msg.ext
|
||||
.find(
|
||||
$doc("tid" -> threadId, "del" $ne me.id),
|
||||
msgProjection
|
||||
)
|
||||
.sort($sort desc "date")
|
||||
.list[Msg](100)
|
||||
|
||||
private def setReadBy(threadId: MsgThread.Id, me: User): Funit =
|
||||
colls.thread.updateField(
|
||||
$id(threadId) ++ $doc(
|
||||
"lastMsg.user" $ne me.id,
|
||||
"lastMsg.read" -> false
|
||||
),
|
||||
"lastMsg.read",
|
||||
true
|
||||
) map { res =>
|
||||
if (res.nModified > 0) notifier.onRead(threadId)
|
||||
}
|
||||
}
|
105
modules/msg/src/main/MsgCompat.scala
Normal file
105
modules/msg/src/main/MsgCompat.scala
Normal file
|
@ -0,0 +1,105 @@
|
|||
package lila.msg
|
||||
|
||||
import play.api.data._
|
||||
import play.api.data.Forms._
|
||||
import play.api.libs.json._
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.common.config._
|
||||
import lila.common.Json.jodaWrites
|
||||
import lila.common.LightUser
|
||||
import lila.common.paginator._
|
||||
import lila.user.{ LightUserApi, User }
|
||||
|
||||
final class MsgCompat(
|
||||
api: MsgApi,
|
||||
security: MsgSecurity,
|
||||
isOnline: lila.socket.IsOnline,
|
||||
lightUserApi: LightUserApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
private val maxPerPage = MaxPerPage(25)
|
||||
|
||||
def inbox(me: User, pageOpt: Option[Int]): Fu[JsObject] = {
|
||||
val page = pageOpt.fold(1)(_ atLeast 1 atMost 2)
|
||||
api.threadsOf(me) flatMap { allThreads =>
|
||||
val threads = allThreads.drop((page - 1) * maxPerPage.value).take(maxPerPage.value)
|
||||
lightUserApi.preloadMany(threads.map(_ other me)) inject
|
||||
PaginatorJson {
|
||||
Paginator
|
||||
.fromResults(
|
||||
currentPageResults = threads,
|
||||
nbResults = allThreads.size,
|
||||
currentPage = page,
|
||||
maxPerPage = maxPerPage
|
||||
)
|
||||
.mapResults { t =>
|
||||
val user = lightUserApi.sync(t other me) | LightUser.fallback(t other me)
|
||||
Json.obj(
|
||||
"id" -> user.id,
|
||||
"author" -> user.titleName,
|
||||
"name" -> t.lastMsg.text,
|
||||
"updatedAt" -> t.lastMsg.date,
|
||||
"isUnread" -> t.lastMsg.unreadBy(me.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def thread(me: User, c: MsgConvo): JsObject =
|
||||
Json.obj(
|
||||
"id" -> c.contact.id,
|
||||
"name" -> c.contact.name,
|
||||
"posts" -> c.msgs.reverse.map { msg =>
|
||||
Json.obj(
|
||||
"sender" -> renderUser(if (msg.user == c.contact.id) c.contact else me.light),
|
||||
"receiver" -> renderUser(if (msg.user != c.contact.id) c.contact else me.light),
|
||||
"text" -> msg.text,
|
||||
"createdAt" -> msg.date
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def create(me: User)(implicit req: play.api.mvc.Request[_]): Either[Form[_], Fu[User.ID]] =
|
||||
Form(
|
||||
mapping(
|
||||
"username" -> lila.user.DataForm.historicalUsernameField
|
||||
.verifying("Unknown username", { blockingFetchUser(_).isDefined })
|
||||
.verifying(
|
||||
"Sorry, this player doesn't accept new messages", { name =>
|
||||
security.may
|
||||
.post(me.id, User normalize name, isNew = true)
|
||||
.await(2 seconds, "pmAccept") // damn you blocking API
|
||||
}
|
||||
),
|
||||
"subject" -> text(minLength = 3, maxLength = 100),
|
||||
"text" -> text(minLength = 3, maxLength = 8000)
|
||||
)(ThreadData.apply)(ThreadData.unapply)
|
||||
).bindFromRequest
|
||||
.fold(
|
||||
err => Left(err),
|
||||
data => {
|
||||
val userId = User normalize data.user
|
||||
Right(api.post(me.id, userId, s"${data.subject}\n${data.text}") inject userId)
|
||||
}
|
||||
)
|
||||
|
||||
def reply(me: User, userId: User.ID)(implicit req: play.api.mvc.Request[_]): Either[Form[_], Funit] =
|
||||
Form(single("text" -> text(minLength = 3))).bindFromRequest
|
||||
.fold(
|
||||
err => Left(err),
|
||||
text => Right(api.post(me.id, userId, text))
|
||||
)
|
||||
|
||||
private def blockingFetchUser(username: String) =
|
||||
lightUserApi.async(User normalize username).await(1 second, "pmUser")
|
||||
|
||||
private case class ThreadData(user: String, subject: String, text: String)
|
||||
|
||||
private def renderUser(user: LightUser) =
|
||||
LightUser.lightUserWrites.writes(user) ++ Json.obj(
|
||||
"online" -> isOnline(user.id),
|
||||
"username" -> user.name // for mobile app BC
|
||||
)
|
||||
}
|
66
modules/msg/src/main/MsgJson.scala
Normal file
66
modules/msg/src/main/MsgJson.scala
Normal file
|
@ -0,0 +1,66 @@
|
|||
package lila.msg
|
||||
|
||||
import play.api.libs.json._
|
||||
|
||||
import lila.user.User
|
||||
import lila.common.Json._
|
||||
import lila.common.LightUser
|
||||
import lila.relation.Relations
|
||||
|
||||
final class MsgJson(
|
||||
lightUserApi: lila.user.LightUserApi,
|
||||
isOnline: lila.socket.IsOnline
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
implicit private val lastMsgWrites: OWrites[Msg.Last] = Json.writes[Msg.Last]
|
||||
implicit private val relationsWrites: OWrites[Relations] = Json.writes[Relations]
|
||||
|
||||
def threads(me: User)(threads: List[MsgThread]): Fu[JsArray] =
|
||||
withContacts(me, threads) map { threads =>
|
||||
JsArray(threads map renderThread)
|
||||
}
|
||||
|
||||
def convo(c: MsgConvo): JsObject = Json.obj(
|
||||
"user" -> renderContact(c.contact),
|
||||
"msgs" -> c.msgs.map(renderMsg),
|
||||
"relations" -> c.relations,
|
||||
"postable" -> c.postable
|
||||
)
|
||||
|
||||
def renderMsg(msg: Msg): JsObject =
|
||||
Json
|
||||
.obj(
|
||||
"text" -> msg.text,
|
||||
"user" -> msg.user,
|
||||
"date" -> msg.date
|
||||
)
|
||||
|
||||
def searchResult(me: User)(res: MsgSearch.Result): Fu[JsObject] =
|
||||
withContacts(me, res.threads) map { threads =>
|
||||
Json.obj(
|
||||
"contacts" -> threads.map(renderThread),
|
||||
"friends" -> res.friends,
|
||||
"users" -> res.users
|
||||
)
|
||||
}
|
||||
|
||||
private def withContacts(me: User, threads: List[MsgThread]): Fu[List[MsgThread.WithContact]] =
|
||||
lightUserApi.asyncMany(threads.map(_ other me)) map { users =>
|
||||
threads.zip(users).map {
|
||||
case (thread, userOption) =>
|
||||
MsgThread.WithContact(thread, userOption | LightUser.fallback(thread other me))
|
||||
}
|
||||
}
|
||||
|
||||
private def renderThread(t: MsgThread.WithContact) =
|
||||
Json
|
||||
.obj(
|
||||
"user" -> renderContact(t.contact),
|
||||
"lastMsg" -> t.thread.lastMsg
|
||||
)
|
||||
|
||||
private def renderContact(user: LightUser): JsObject =
|
||||
LightUser
|
||||
.writeNoId(user)
|
||||
.add("online" -> isOnline(user.id))
|
||||
}
|
72
modules/msg/src/main/MsgNotify.scala
Normal file
72
modules/msg/src/main/MsgNotify.scala
Normal file
|
@ -0,0 +1,72 @@
|
|||
package lila.msg
|
||||
|
||||
import akka.actor.Cancellable
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.db.dsl._
|
||||
import lila.notify.{ Notification, PrivateMessage }
|
||||
import lila.common.String.shorten
|
||||
import lila.user.User
|
||||
|
||||
final private class MsgNotify(
|
||||
colls: MsgColls,
|
||||
notifyApi: lila.notify.NotifyApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext, scheduler: akka.actor.Scheduler) {
|
||||
|
||||
import BsonHandlers._
|
||||
|
||||
private val delay = 5 seconds
|
||||
|
||||
private val delayed = new ConcurrentHashMap[MsgThread.Id, Cancellable](256)
|
||||
|
||||
def onPost(threadId: MsgThread.Id): Unit = schedule(threadId)
|
||||
|
||||
def onRead(threadId: MsgThread.Id): Unit = cancel(threadId)
|
||||
|
||||
def deleteAllBy(threads: List[MsgThread], user: User): Funit =
|
||||
threads
|
||||
.map { thread =>
|
||||
cancel(thread.id)
|
||||
notifyApi
|
||||
.remove(
|
||||
lila.notify.Notification.Notifies(thread other user),
|
||||
$doc("content.user" -> user.id)
|
||||
)
|
||||
.void
|
||||
}
|
||||
.sequenceFu
|
||||
.void
|
||||
|
||||
private def schedule(threadId: MsgThread.Id): Unit = delayed.compute(
|
||||
threadId,
|
||||
(id, canc) => {
|
||||
Option(canc).foreach(_.cancel)
|
||||
scheduler.scheduleOnce(delay) {
|
||||
delayed remove id
|
||||
doNotify(threadId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
private def cancel(threadId: MsgThread.Id): Unit =
|
||||
Option(delayed remove threadId).foreach(_.cancel)
|
||||
|
||||
private def doNotify(threadId: MsgThread.Id): Funit =
|
||||
colls.thread.byId[MsgThread](threadId.value) flatMap {
|
||||
_ ?? { thread =>
|
||||
val msg = thread.lastMsg
|
||||
val dest = thread other msg.user
|
||||
!thread.delBy(dest) ?? {
|
||||
lila.common.Bus.publish(MsgThread.Unread(thread), "msgUnread")
|
||||
notifyApi addNotification Notification.make(
|
||||
Notification.Notifies(dest),
|
||||
PrivateMessage(
|
||||
PrivateMessage.Sender(msg.user),
|
||||
PrivateMessage.Text(shorten(msg.text, 80))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,12 @@
|
|||
package lila.message
|
||||
package lila.msg
|
||||
|
||||
case class ModPreset(subject: String, text: String)
|
||||
case class MsgPreset(name: String, text: String)
|
||||
|
||||
/* From https://github.com/ornicar/lila/wiki/Canned-responses-for-moderators */
|
||||
object ModPreset {
|
||||
object MsgPreset {
|
||||
|
||||
/* First line is the message subject;
|
||||
* Other lines are the message body.
|
||||
* The message body can contain several lines.
|
||||
/* First line is the preset name;
|
||||
* Other lines are the message.
|
||||
* The message can contain several lines.
|
||||
*/
|
||||
// format: off
|
||||
val all = List("""
|
||||
|
@ -97,14 +96,14 @@ Unfortunately we had to reject your title verification. You are free to make ano
|
|||
|
||||
private def toPreset(txt: String) =
|
||||
txt.linesIterator.toList.map(_.trim).filter(_.nonEmpty) match {
|
||||
case subject :: body => ModPreset(subject, body mkString "\n").some
|
||||
case name :: body => MsgPreset(name, body mkString "\n").some
|
||||
case _ =>
|
||||
logger.warn(s"Invalid mod message preset $txt")
|
||||
logger.warn(s"Invalid message preset $txt")
|
||||
none
|
||||
}
|
||||
|
||||
lazy val sandbagAuto = ModPreset(
|
||||
subject = "Warning: possible sandbagging",
|
||||
lazy val sandbagAuto = MsgPreset(
|
||||
name = "Warning: possible sandbagging",
|
||||
text =
|
||||
"""You have lost a couple games after a few moves. Please note that you MUST try to win every rated game.
|
||||
Losing rated games on purpose is called "sandbagging", and is not allowed on Lichess.
|
||||
|
@ -112,15 +111,15 @@ Losing rated games on purpose is called "sandbagging", and is not allowed on Lic
|
|||
Thank you for your understanding."""
|
||||
)
|
||||
|
||||
lazy val sittingAuto = ModPreset(
|
||||
subject = "Warning: leaving games / stalling on time",
|
||||
lazy val sittingAuto = MsgPreset(
|
||||
name = "Warning: leaving games / stalling on time",
|
||||
text =
|
||||
"""In your game history, you have several games where you have left the game or just let the time run out instead of playing or resigning.
|
||||
This can be very annoying for your opponents. If this behavior continues to happen, we may be forced to terminate your account."""
|
||||
)
|
||||
|
||||
def maxFollow(username: String, max: Int) = ModPreset(
|
||||
subject = "Follow limit reached!",
|
||||
def maxFollow(username: String, max: Int) = MsgPreset(
|
||||
name = "Follow limit reached!",
|
||||
text = s"""Sorry, you can't follow more than $max players on Lichess.
|
||||
To follow new players, you must first unfollow some on https://lichess.org/@/$username/following.
|
||||
|
||||
|
@ -129,9 +128,9 @@ Thank you for your understanding."""
|
|||
|
||||
lazy val asJson = play.api.libs.json.Json.toJson {
|
||||
all.map { p =>
|
||||
List(p.subject, p.text)
|
||||
List(p.name, p.text)
|
||||
}
|
||||
}
|
||||
|
||||
def bySubject(s: String) = all.find(_.subject == s)
|
||||
def byName(s: String) = all.find(_.name == s)
|
||||
}
|
61
modules/msg/src/main/MsgSearch.scala
Normal file
61
modules/msg/src/main/MsgSearch.scala
Normal file
|
@ -0,0 +1,61 @@
|
|||
package lila.msg
|
||||
|
||||
import reactivemongo.api.bson._
|
||||
|
||||
import lila.common.LightUser
|
||||
import lila.db.dsl._
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
final class MsgSearch(
|
||||
colls: MsgColls,
|
||||
userRepo: UserRepo,
|
||||
lightUserApi: lila.user.LightUserApi,
|
||||
relationApi: lila.relation.RelationApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import BsonHandlers._
|
||||
|
||||
def apply(me: User, q: String): Fu[MsgSearch.Result] =
|
||||
searchThreads(me, q) zip searchFriends(me, q) zip searchUsers(me, q) map {
|
||||
case threads ~ friends ~ users =>
|
||||
MsgSearch
|
||||
.Result(
|
||||
threads,
|
||||
friends.filterNot(f => threads.exists(_.other(me) == f.id)) take 10,
|
||||
users.filterNot(u => u.id == me.id || friends.exists(_.id == u.id)) take 10
|
||||
)
|
||||
}
|
||||
|
||||
val empty = MsgSearch.Result(Nil, Nil, Nil)
|
||||
|
||||
private def searchThreads(me: User, q: String): Fu[List[MsgThread]] =
|
||||
colls.thread.ext
|
||||
.find(
|
||||
$doc(
|
||||
"users" -> $doc(
|
||||
$eq(me.id),
|
||||
"$regex" -> BSONRegex(s"^$q", "")
|
||||
),
|
||||
"del" $ne me.id
|
||||
)
|
||||
)
|
||||
.sort($sort desc "lastMsg.date")
|
||||
.list[MsgThread](5)
|
||||
|
||||
private def searchFriends(me: User, q: String): Fu[List[LightUser]] = !me.kid ?? {
|
||||
relationApi.searchFollowedBy(me, q, 15) flatMap lightUserApi.asyncMany dmap (_.flatten)
|
||||
}
|
||||
|
||||
private def searchUsers(me: User, q: String): Fu[List[LightUser]] = !me.kid ?? {
|
||||
userRepo.userIdsLike(q, 15) flatMap lightUserApi.asyncMany dmap (_.flatten)
|
||||
}
|
||||
}
|
||||
|
||||
object MsgSearch {
|
||||
|
||||
case class Result(
|
||||
threads: List[MsgThread],
|
||||
friends: List[LightUser],
|
||||
users: List[LightUser]
|
||||
)
|
||||
}
|
141
modules/msg/src/main/MsgSecurity.scala
Normal file
141
modules/msg/src/main/MsgSecurity.scala
Normal file
|
@ -0,0 +1,141 @@
|
|||
package lila.msg
|
||||
|
||||
import org.joda.time.DateTime
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.common.Bus
|
||||
import lila.db.dsl._
|
||||
import lila.hub.actorApi.report.AutoFlag
|
||||
import lila.hub.actorApi.clas.IsTeacherOf
|
||||
import lila.memo.RateLimit
|
||||
import lila.shutup.Analyser
|
||||
import lila.user.User
|
||||
|
||||
final private class MsgSecurity(
|
||||
colls: MsgColls,
|
||||
prefApi: lila.pref.PrefApi,
|
||||
userRepo: lila.user.UserRepo,
|
||||
relationApi: lila.relation.RelationApi,
|
||||
spam: lila.security.Spam,
|
||||
chatPanic: lila.chat.ChatPanic
|
||||
)(implicit ec: scala.concurrent.ExecutionContext, system: akka.actor.ActorSystem) {
|
||||
|
||||
import BsonHandlers._
|
||||
import MsgSecurity._
|
||||
|
||||
private val CreateLimitPerUser = new RateLimit[User.ID](
|
||||
credits = 20,
|
||||
duration = 24 hour,
|
||||
name = "PM creates per user",
|
||||
key = "msg_create.user"
|
||||
)
|
||||
|
||||
private val ReplyLimitPerUser = new RateLimit[User.ID](
|
||||
credits = 20,
|
||||
duration = 1 minute,
|
||||
name = "PM replies per user",
|
||||
key = "msg_reply.user"
|
||||
)
|
||||
|
||||
object can {
|
||||
|
||||
def post(contacts: User.Contacts, text: String, isNew: Boolean, unlimited: Boolean = false): Fu[Verdict] =
|
||||
may.post(contacts, isNew) flatMap {
|
||||
case false => fuccess(Block)
|
||||
case _ =>
|
||||
isLimited(contacts.orig, isNew, unlimited) orElse
|
||||
isSpam(text) orElse
|
||||
isTroll(contacts) orElse
|
||||
isDirt(contacts.orig, text, isNew) getOrElse
|
||||
fuccess(Ok)
|
||||
} flatMap {
|
||||
case mute: Mute =>
|
||||
relationApi.fetchFollows(contacts.dest.id, contacts.orig.id) dmap { isFriend =>
|
||||
if (isFriend) Ok else mute
|
||||
}
|
||||
case verdict => fuccess(verdict)
|
||||
} addEffect {
|
||||
case Dirt =>
|
||||
Bus.publish(
|
||||
AutoFlag(contacts.orig.id, s"msg/${contacts.orig.id}/${contacts.dest.id}", text),
|
||||
"autoFlag"
|
||||
)
|
||||
case Spam =>
|
||||
logger.warn(s"PM spam from ${contacts.orig.id}: ${text}")
|
||||
case _ =>
|
||||
}
|
||||
|
||||
private def isLimited(user: User.Contact, isNew: Boolean, unlimited: Boolean): Fu[Option[Verdict]] =
|
||||
if (unlimited) fuccess(none)
|
||||
else {
|
||||
val limiter = if (isNew) CreateLimitPerUser else ReplyLimitPerUser
|
||||
!limiter(user.id)(true) ?? fuccess(Limit.some)
|
||||
}
|
||||
|
||||
private def isSpam(text: String): Fu[Option[Verdict]] =
|
||||
spam.detect(text) ?? fuccess(Spam.some)
|
||||
|
||||
private def isTroll(contacts: User.Contacts): Fu[Option[Verdict]] =
|
||||
(contacts.orig.isTroll && !contacts.dest.isTroll) ?? fuccess(Troll.some)
|
||||
|
||||
private def isDirt(user: User.Contact, text: String, isNew: Boolean): Fu[Option[Verdict]] =
|
||||
(isNew && Analyser(text).dirty) ??
|
||||
!userRepo.isCreatedSince(user.id, DateTime.now.minusDays(30)) dmap { _ option Dirt }
|
||||
}
|
||||
|
||||
object may {
|
||||
|
||||
def post(orig: User.ID, dest: User.ID, isNew: Boolean): Fu[Boolean] =
|
||||
userRepo.contacts(orig, dest) orFail "Missing convo contact user" flatMap {
|
||||
post(_, isNew)
|
||||
}
|
||||
|
||||
def post(contacts: User.Contacts, isNew: Boolean): Fu[Boolean] =
|
||||
fuccess(contacts.dest.id != User.lichessId) >>&
|
||||
!relationApi.fetchBlocks(contacts.dest.id, contacts.orig.id) >>&
|
||||
(create(contacts) >>| reply(contacts)) >>&
|
||||
chatPanic.allowed(contacts.orig.id, userRepo.byId) >>&
|
||||
kidCheck(contacts, isNew)
|
||||
|
||||
private def create(contacts: User.Contacts): Fu[Boolean] =
|
||||
prefApi.getPref(contacts.dest.id, _.message) flatMap {
|
||||
case lila.pref.Pref.Message.NEVER => fuccess(false)
|
||||
case lila.pref.Pref.Message.FRIEND => relationApi.fetchFollows(contacts.dest.id, contacts.orig.id)
|
||||
case lila.pref.Pref.Message.ALWAYS => fuccess(true)
|
||||
}
|
||||
|
||||
// Even if the dest prefs disallow it,
|
||||
// you can still reply if they recently messaged you,
|
||||
// unless they deleted the thread.
|
||||
private def reply(contacts: User.Contacts): Fu[Boolean] =
|
||||
colls.thread.exists(
|
||||
$id(MsgThread.id(contacts.orig.id, contacts.dest.id)) ++ $or(
|
||||
"del" $ne contacts.dest.id,
|
||||
$doc(
|
||||
"lastMsg.user" -> contacts.dest.id,
|
||||
"lastMsg.date" $gt DateTime.now.minusDays(3)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
private def kidCheck(contacts: User.Contacts, isNew: Boolean): Fu[Boolean] =
|
||||
if (contacts.orig.isKid && isNew) fuFalse
|
||||
else if (!contacts.dest.isKid) fuTrue
|
||||
else Bus.ask[Boolean]("clas") { IsTeacherOf(contacts.orig.id, contacts.dest.id, _) }
|
||||
}
|
||||
}
|
||||
|
||||
private object MsgSecurity {
|
||||
|
||||
sealed trait Verdict
|
||||
sealed trait Reject extends Verdict
|
||||
sealed abstract class Send(val mute: Boolean) extends Verdict
|
||||
sealed abstract class Mute extends Send(true)
|
||||
|
||||
case object Ok extends Send(false)
|
||||
case object Troll extends Mute
|
||||
case object Spam extends Mute
|
||||
case object Dirt extends Mute
|
||||
case object Block extends Reject
|
||||
case object Limit extends Reject
|
||||
}
|
52
modules/msg/src/main/MsgThread.scala
Normal file
52
modules/msg/src/main/MsgThread.scala
Normal file
|
@ -0,0 +1,52 @@
|
|||
package lila.msg
|
||||
|
||||
import lila.user.User
|
||||
import lila.common.LightUser
|
||||
|
||||
case class MsgThread(
|
||||
id: MsgThread.Id,
|
||||
user1: User.ID,
|
||||
user2: User.ID,
|
||||
lastMsg: Msg.Last,
|
||||
del: Option[List[User.ID]] = None
|
||||
) {
|
||||
|
||||
def users = List(user1, user2)
|
||||
|
||||
def other(userId: User.ID): User.ID = if (user1 == userId) user2 else user1
|
||||
def other(user: User): User.ID = other(user.id)
|
||||
def other(user: LightUser): User.ID = other(user.id)
|
||||
|
||||
def delBy(userId: User.ID) = del.exists(_ contains userId)
|
||||
}
|
||||
|
||||
object MsgThread {
|
||||
|
||||
case class Id(value: String) extends AnyVal
|
||||
|
||||
case class WithMsgs(thread: MsgThread, msgs: List[Msg])
|
||||
|
||||
case class WithContact(thread: MsgThread, contact: LightUser)
|
||||
|
||||
case class Unread(thread: MsgThread)
|
||||
|
||||
def id(u1: User.ID, u2: User.ID): Id = Id {
|
||||
sortUsers(u1, u2) match {
|
||||
case (user1, user2) => s"$user1/$user2"
|
||||
}
|
||||
}
|
||||
|
||||
def make(u1: User.ID, u2: User.ID, msg: Msg): MsgThread = sortUsers(u1, u2) match {
|
||||
case (user1, user2) =>
|
||||
s"$user1/$user2"
|
||||
MsgThread(
|
||||
id = id(user1, user2),
|
||||
user1 = user1,
|
||||
user2 = user2,
|
||||
lastMsg = msg.asLast
|
||||
)
|
||||
}
|
||||
|
||||
private def sortUsers(u1: User.ID, u2: User.ID): (User.ID, User.ID) =
|
||||
if (u1 < u2) (u1, u2) else (u2, u1)
|
||||
}
|
11
modules/msg/src/main/model.scala
Normal file
11
modules/msg/src/main/model.scala
Normal file
|
@ -0,0 +1,11 @@
|
|||
package lila.msg
|
||||
|
||||
import lila.common.LightUser
|
||||
import lila.relation.Relations
|
||||
|
||||
case class MsgConvo(
|
||||
contact: LightUser,
|
||||
msgs: List[Msg],
|
||||
relations: Relations,
|
||||
postable: Boolean
|
||||
)
|
6
modules/msg/src/main/package.scala
Normal file
6
modules/msg/src/main/package.scala
Normal file
|
@ -0,0 +1,6 @@
|
|||
package lila
|
||||
|
||||
package object msg extends PackageObject {
|
||||
|
||||
private[msg] val logger = lila.log("msg")
|
||||
}
|
|
@ -25,8 +25,7 @@ private object BSONHandlers {
|
|||
implicit val ReadHandler = booleanAnyValHandler[NotificationRead](_.value, NotificationRead.apply)
|
||||
|
||||
import PrivateMessage._
|
||||
implicit val PMThreadHandler = Macros.handler[Thread]
|
||||
implicit val PMSenderIdHandler = stringAnyValHandler[SenderId](_.value, SenderId.apply)
|
||||
implicit val PMSenderIdHandler = stringAnyValHandler[Sender](_.value, Sender.apply)
|
||||
implicit val PMTextHandler = stringAnyValHandler[Text](_.value, Text.apply)
|
||||
implicit val PrivateMessageHandler = Macros.handler[PrivateMessage]
|
||||
|
||||
|
|
|
@ -7,8 +7,6 @@ import lila.common.Json.jodaWrites
|
|||
|
||||
final class JSONHandlers(getLightUser: LightUser.GetterSync) {
|
||||
|
||||
implicit val privateMessageThreadWrites = Json.writes[PrivateMessage.Thread]
|
||||
|
||||
implicit val notificationWrites: Writes[Notification] = new Writes[Notification] {
|
||||
|
||||
private def writeBody(notificationContent: NotificationContent) = {
|
||||
|
@ -26,11 +24,10 @@ final class JSONHandlers(getLightUser: LightUser.GetterSync) {
|
|||
"studyName" -> studyName.value,
|
||||
"studyId" -> studyId.value
|
||||
)
|
||||
case PrivateMessage(senderId, thread, text) =>
|
||||
case PrivateMessage(senderId, text) =>
|
||||
Json.obj(
|
||||
"sender" -> getLightUser(senderId.value),
|
||||
"thread" -> privateMessageThreadWrites.writes(thread),
|
||||
"text" -> text.value
|
||||
"user" -> getLightUser(senderId.value),
|
||||
"text" -> text.value
|
||||
)
|
||||
case TeamJoined(id, name) =>
|
||||
Json.obj(
|
||||
|
|
|
@ -64,15 +64,13 @@ object InvitedToStudy {
|
|||
}
|
||||
|
||||
case class PrivateMessage(
|
||||
senderId: PrivateMessage.SenderId,
|
||||
thread: PrivateMessage.Thread,
|
||||
user: PrivateMessage.Sender,
|
||||
text: PrivateMessage.Text
|
||||
) extends NotificationContent("privateMessage")
|
||||
|
||||
object PrivateMessage {
|
||||
case class SenderId(value: String) extends AnyVal with StringValue
|
||||
case class Thread(id: String, name: String)
|
||||
case class Text(value: String) extends AnyVal with StringValue
|
||||
case class Sender(value: String) extends AnyVal with StringValue
|
||||
case class Text(value: String) extends AnyVal with StringValue
|
||||
}
|
||||
|
||||
case class TeamJoined(
|
||||
|
|
|
@ -54,12 +54,15 @@ final private class NotificationRepo(val coll: Coll)(implicit ec: scala.concurre
|
|||
) ++ hasOldOrUnread
|
||||
)
|
||||
|
||||
def hasRecentPrivateMessageFrom(userId: Notification.Notifies, thread: PrivateMessage.Thread): Fu[Boolean] =
|
||||
def hasRecentPrivateMessageFrom(
|
||||
userId: Notification.Notifies,
|
||||
sender: PrivateMessage.Sender
|
||||
): Fu[Boolean] =
|
||||
coll.exists(
|
||||
$doc(
|
||||
"notifies" -> userId,
|
||||
"content.type" -> "privateMessage",
|
||||
"content.thread.id" -> thread.id
|
||||
"notifies" -> userId,
|
||||
"content.type" -> "privateMessage",
|
||||
"content.user" -> sender
|
||||
) ++ hasOld
|
||||
)
|
||||
|
||||
|
|
|
@ -77,7 +77,7 @@ final class NotifyApi(
|
|||
case MentionedInThread(_, _, topicId, _, _) =>
|
||||
repo.hasRecentNotificationsInThread(notification.notifies, topicId)
|
||||
case InvitedToStudy(_, _, studyId) => repo.hasRecentStudyInvitation(notification.notifies, studyId)
|
||||
case PrivateMessage(_, thread, _) => repo.hasRecentPrivateMessageFrom(notification.notifies, thread)
|
||||
case PrivateMessage(sender, _) => repo.hasRecentPrivateMessageFrom(notification.notifies, sender)
|
||||
case _ => fuFalse
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,10 @@ object OAuthScope {
|
|||
case object Write extends OAuthScope("team:write", "Join, leave, and manage teams")
|
||||
}
|
||||
|
||||
object Msg {
|
||||
case object Write extends OAuthScope("msg:write", "Send private messages to other players")
|
||||
}
|
||||
|
||||
object Bot {
|
||||
case object Play extends OAuthScope("bot:play", "Play as a bot")
|
||||
}
|
||||
|
@ -56,6 +60,7 @@ object OAuthScope {
|
|||
Tournament.Write,
|
||||
Puzzle.Read,
|
||||
Team.Write,
|
||||
Msg.Write,
|
||||
Bot.Play
|
||||
)
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import lila.common.config.CollName
|
|||
@Module
|
||||
final class Env(
|
||||
appConfig: Configuration,
|
||||
messenger: lila.message.MessageApi,
|
||||
messenger: lila.msg.MsgApi,
|
||||
chatApi: lila.chat.ChatApi,
|
||||
userRepo: lila.user.UserRepo,
|
||||
lightUser: lila.common.LightUser.Getter,
|
||||
|
|
|
@ -7,7 +7,7 @@ import chess.{ Centis, Color, Status }
|
|||
import lila.common.{ Bus, Iso, Uptime }
|
||||
import lila.db.dsl._
|
||||
import lila.game.{ Game, Player, Pov, Source }
|
||||
import lila.message.{ MessageApi, ModPreset }
|
||||
import lila.msg.{ MsgApi, MsgPreset }
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
import org.joda.time.DateTime
|
||||
|
@ -18,7 +18,7 @@ final class PlaybanApi(
|
|||
feedback: PlaybanFeedback,
|
||||
userRepo: UserRepo,
|
||||
cacheApi: lila.memo.CacheApi,
|
||||
messenger: MessageApi
|
||||
messenger: MsgApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import lila.db.BSON.BSONJodaDateTimeHandler
|
||||
|
@ -239,16 +239,14 @@ final class PlaybanApi(
|
|||
rageSitCache.put(record.userId, fuccess(record.rageSit))
|
||||
(delta < 0) ?? {
|
||||
if (record.rageSit.isTerrible) funit
|
||||
else if (record.rageSit.isVeryBad) for {
|
||||
mod <- userRepo.lichess
|
||||
user <- userRepo byId record.userId
|
||||
} yield (mod zip user).headOption foreach {
|
||||
case (m, u) =>
|
||||
lila.log("ragesit").info(s"https://lichess.org/@/${u.username} ${record.rageSit.counterView}")
|
||||
Bus.publish(lila.hub.actorApi.mod.AutoWarning(u.id, ModPreset.sittingAuto.subject), "autoWarning")
|
||||
messenger.sendPreset(m, u, ModPreset.sittingAuto).void
|
||||
}
|
||||
else funit
|
||||
else if (record.rageSit.isVeryBad)
|
||||
userRepo byId record.userId map {
|
||||
_ ?? { u =>
|
||||
lila.log("ragesit").info(s"https://lichess.org/@/${u.username} ${record.rageSit.counterView}")
|
||||
Bus.publish(lila.hub.actorApi.mod.AutoWarning(u.id, MsgPreset.sittingAuto.name), "autoWarning")
|
||||
messenger.postPreset(u, MsgPreset.sittingAuto).void
|
||||
}
|
||||
} else funit
|
||||
}
|
||||
case _ => funit
|
||||
}
|
||||
|
|
|
@ -5,12 +5,12 @@ import scala.concurrent.duration._
|
|||
|
||||
import chess.Color
|
||||
import lila.game.Game
|
||||
import lila.message.{ MessageApi, ModPreset }
|
||||
import lila.msg.{ MsgApi, MsgPreset }
|
||||
import lila.user.{ User, UserRepo }
|
||||
|
||||
final private class SandbagWatch(
|
||||
userRepo: UserRepo,
|
||||
messenger: MessageApi
|
||||
messenger: MsgApi
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import SandbagWatch._
|
||||
|
@ -29,15 +29,13 @@ final private class SandbagWatch(
|
|||
}
|
||||
|
||||
private def sendMessage(userId: User.ID): Funit =
|
||||
for {
|
||||
mod <- userRepo.lichess
|
||||
user <- userRepo byId userId
|
||||
} yield (mod zip user).headOption.?? {
|
||||
case (m, u) =>
|
||||
userRepo byId userId map {
|
||||
_ ?? { u =>
|
||||
lila.log("sandbag").info(s"https://lichess.org/@/${u.username}")
|
||||
lila.common.Bus
|
||||
.publish(lila.hub.actorApi.mod.AutoWarning(u.id, ModPreset.sandbagAuto.subject), "autoWarning")
|
||||
messenger.sendPreset(m, u, ModPreset.sandbagAuto).void
|
||||
.publish(lila.hub.actorApi.mod.AutoWarning(u.id, MsgPreset.sandbagAuto.name), "autoWarning")
|
||||
messenger.postPreset(u, MsgPreset.sandbagAuto).void
|
||||
}
|
||||
}
|
||||
|
||||
private def updateRecord(userId: User.ID, record: Record) =
|
||||
|
|
|
@ -4,7 +4,7 @@ import org.joda.time.DateTime
|
|||
|
||||
final private case class Device(
|
||||
_id: String, // Firebase token or OneSignal playerId
|
||||
platform: String, // cordova platform (android, ios)
|
||||
platform: String, // cordova platform (android, ios, firebase)
|
||||
userId: String,
|
||||
seenAt: DateTime
|
||||
) {
|
||||
|
|
|
@ -47,4 +47,7 @@ final private class DeviceApi(coll: Coll)(implicit ec: scala.concurrent.Executio
|
|||
lila.mon.push.register.out.increment()
|
||||
coll.delete.one($doc("userId" -> user.id)).void
|
||||
}
|
||||
|
||||
def delete(device: Device) =
|
||||
coll.delete.one($id(device._id)).void
|
||||
}
|
||||
|
|
|
@ -66,6 +66,7 @@ final class Env(
|
|||
"finishGame",
|
||||
"moveEventCorres",
|
||||
"newMessage",
|
||||
"msgUnread",
|
||||
"challenge",
|
||||
"corresAlarm",
|
||||
"offerEventCorres"
|
||||
|
@ -76,7 +77,7 @@ final class Env(
|
|||
case lila.hub.actorApi.round.CorresTakebackOfferEvent(gameId) =>
|
||||
pushApi takebackOffer gameId logFailure logger
|
||||
case lila.hub.actorApi.round.CorresDrawOfferEvent(gameId) => pushApi drawOffer gameId logFailure logger
|
||||
case lila.message.Event.NewMessage(t, p) => pushApi newMessage (t, p) logFailure logger
|
||||
case lila.msg.MsgThread.Unread(t) => pushApi newMsg t logFailure logger
|
||||
case lila.challenge.Event.Create(c) => pushApi challengeCreate c logFailure logger
|
||||
case lila.challenge.Event.Accept(c, joinerId) => pushApi.challengeAccept(c, joinerId) logFailure logger
|
||||
case lila.game.actorApi.CorresAlarmEvent(pov) => pushApi corresAlarm pov logFailure logger
|
||||
|
|
|
@ -64,7 +64,10 @@ final private class FirebasePush(
|
|||
)
|
||||
) flatMap {
|
||||
case res if res.status == 200 => funit
|
||||
case res => fufail(s"[push] firebase: ${res.status} ${res.body}")
|
||||
case res if res.status == 404 =>
|
||||
logger.info(s"Delete missing firebase device ${device}")
|
||||
deviceApi delete device
|
||||
case res => fufail(s"[push] firebase: ${res.status} ${res.body}")
|
||||
}
|
||||
|
||||
// filter out any non string value, otherwise Firebase API silently rejects
|
||||
|
|
|
@ -9,7 +9,6 @@ import lila.common.{ Future, LightUser }
|
|||
import lila.game.{ Game, Namer, Pov }
|
||||
import lila.hub.actorApi.map.Tell
|
||||
import lila.hub.actorApi.round.{ IsOnGame, MoveEvent }
|
||||
import lila.message.{ Post, Thread }
|
||||
import lila.user.User
|
||||
|
||||
final private class PushApi(
|
||||
|
@ -171,28 +170,28 @@ final private class PushApi(
|
|||
"fullId" -> pov.fullId
|
||||
)
|
||||
|
||||
def newMessage(t: Thread, p: Post): Funit =
|
||||
lightUser(t.visibleSenderOf(p)) flatMap {
|
||||
def newMsg(t: lila.msg.MsgThread): Funit =
|
||||
lightUser(t.lastMsg.user) flatMap {
|
||||
_ ?? { sender =>
|
||||
userRepo.isKid(t receiverOf p) flatMap {
|
||||
case true => funit
|
||||
case _ =>
|
||||
userRepo.isKid(t other sender) flatMap {
|
||||
!_ ?? {
|
||||
pushToAll(
|
||||
t receiverOf p,
|
||||
t other sender,
|
||||
_.message,
|
||||
PushApi.Data(
|
||||
title = s"${sender.titleName}: ${t.name}",
|
||||
body = p.text take 140,
|
||||
title = sender.titleName,
|
||||
body = t.lastMsg.text take 140,
|
||||
stacking = Stacking.NewMessage,
|
||||
payload = Json.obj(
|
||||
"userId" -> t.receiverOf(p),
|
||||
"userId" -> t.other(sender),
|
||||
"userData" -> Json.obj(
|
||||
"type" -> "newMessage",
|
||||
"threadId" -> t.id
|
||||
"threadId" -> sender.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,9 @@ final class RelationApi(
|
|||
}
|
||||
def fetchRelation(u1: User, u2: User): Fu[Option[Relation]] = fetchRelation(u1.id, u2.id)
|
||||
|
||||
def fetchRelations(u1: User.ID, u2: User.ID): Fu[Relations] =
|
||||
fetchRelation(u2, u1) zip fetchRelation(u1, u2) dmap Relations.tupled
|
||||
|
||||
def fetchFollowing = repo following _
|
||||
|
||||
def fetchFollowersFromSecondary = repo.followersFromSecondary _
|
||||
|
@ -173,13 +176,17 @@ final class RelationApi(
|
|||
(config.maxBlock < nb) ?? repo.drop(u, false, nb - config.maxBlock.value)
|
||||
}
|
||||
|
||||
def block(u1: ID, u2: ID): Funit = (u1 != u2) ?? {
|
||||
def block(u1: ID, u2: ID): Funit = (u1 != u2 && u2 != User.lichessId) ?? {
|
||||
fetchBlocks(u1, u2) flatMap {
|
||||
case true => funit
|
||||
case _ =>
|
||||
repo.block(u1, u2) >> limitBlock(u1) >> unfollow(u2, u1) >>- {
|
||||
reloadOnlineFriends(u1, u2)
|
||||
Bus.publish(lila.hub.actorApi.relation.Block(u1, u2), "relation")
|
||||
Bus.publish(
|
||||
lila.hub.actorApi.socket.SendTo(u2, lila.socket.Socket.makeMessage("blockedBy", u1)),
|
||||
"socketUsers"
|
||||
)
|
||||
lila.mon.relation.block.increment()
|
||||
}
|
||||
}
|
||||
|
@ -206,6 +213,10 @@ final class RelationApi(
|
|||
repo.unblock(u1, u2) >>- {
|
||||
reloadOnlineFriends(u1, u2)
|
||||
Bus.publish(lila.hub.actorApi.relation.UnBlock(u1, u2), "relation")
|
||||
Bus.publish(
|
||||
lila.hub.actorApi.socket.SendTo(u2, lila.socket.Socket.makeMessage("unblockedBy", u1)),
|
||||
"socketUsers"
|
||||
)
|
||||
lila.mon.relation.unblock.increment()
|
||||
}
|
||||
case _ => funit
|
||||
|
|
|
@ -22,6 +22,11 @@ case class Related(
|
|||
relation: Option[Relation]
|
||||
)
|
||||
|
||||
case class Relations(
|
||||
in: Option[Relation],
|
||||
out: Option[Relation]
|
||||
)
|
||||
|
||||
private[relation] case class FriendEntering(user: LightUser, isPlaying: Boolean, isStudying: Boolean)
|
||||
|
||||
object BSONHandlers {
|
||||
|
|
|
@ -38,7 +38,7 @@ final class Env(
|
|||
api.publicForumMessage(userId, text)
|
||||
case RecordTeamForumMessage(userId, text) =>
|
||||
api.teamForumMessage(userId, text)
|
||||
case RecordPrivateMessage(userId, toUserId, text, _) =>
|
||||
case RecordPrivateMessage(userId, toUserId, text) =>
|
||||
api.privateMessage(userId, toUserId, text)
|
||||
case RecordPrivateChat(chatId, userId, text) =>
|
||||
api.privateChat(chatId, userId, text)
|
||||
|
|
|
@ -15,7 +15,7 @@ import lila.hub.actorApi.Announce
|
|||
import lila.hub.actorApi.relation.ReloadOnlineFriends
|
||||
import lila.hub.actorApi.round.Mlat
|
||||
import lila.hub.actorApi.security.CloseAccount
|
||||
import lila.hub.actorApi.socket.remote.{ TellSriIn, TellSriOut }
|
||||
import lila.hub.actorApi.socket.remote.{ TellSriIn, TellSriOut, TellUserIn }
|
||||
import lila.hub.actorApi.socket.{ BotIsOnline, SendTo, SendTos }
|
||||
import Socket.Sri
|
||||
|
||||
|
@ -59,6 +59,8 @@ final class RemoteSocket(
|
|||
onlineUserIds.getAndUpdate((x: UserIds) => x ++ lags.keys)
|
||||
case In.TellSri(sri, userId, typ, msg) =>
|
||||
Bus.publish(TellSriIn(sri.value, userId, msg), s"remoteSocketIn:$typ")
|
||||
case In.TellUser(userId, typ, msg) =>
|
||||
Bus.publish(TellUserIn(userId, msg), s"remoteSocketIn:$typ")
|
||||
case In.WsBoot =>
|
||||
logger.warn("Remote socket boot")
|
||||
onlineUserIds set Set("lichess")
|
||||
|
@ -182,6 +184,7 @@ object RemoteSocket {
|
|||
case class Lags(lags: Map[String, Centis]) extends In
|
||||
case class FriendsBatch(userIds: Iterable[String]) extends In
|
||||
case class TellSri(sri: Sri, userId: Option[String], typ: String, msg: JsObject) extends In
|
||||
case class TellUser(userId: String, typ: String, msg: JsObject) extends In
|
||||
case class ReqResponse(reqId: Int, response: String) extends In
|
||||
|
||||
val baseReader: Reader = raw =>
|
||||
|
@ -212,6 +215,14 @@ object RemoteSocket {
|
|||
}.toMap).some
|
||||
case "friends/batch" => FriendsBatch(commas(raw.args)).some
|
||||
case "tell/sri" => raw.get(3)(tellSriMapper)
|
||||
case "tell/user" =>
|
||||
raw.get(2) {
|
||||
case Array(user, payload) =>
|
||||
for {
|
||||
obj <- Json.parse(payload).asOpt[JsObject]
|
||||
typ <- obj str "t"
|
||||
} yield TellUser(user, typ, obj)
|
||||
}
|
||||
case "req/response" =>
|
||||
raw.get(2) {
|
||||
case Array(reqId, response) => reqId.toIntOption map { ReqResponse(_, response) }
|
||||
|
|
|
@ -176,6 +176,13 @@ object User {
|
|||
def isTroll = marks.exists(_.troll)
|
||||
}
|
||||
|
||||
case class Contact(_id: ID, kid: Option[Boolean], marks: Option[UserMarks]) {
|
||||
def id = _id
|
||||
def isKid = ~kid
|
||||
def isTroll = marks.exists(_.troll)
|
||||
}
|
||||
case class Contacts(orig: Contact, dest: Contact)
|
||||
|
||||
case class PlayTime(total: Int, tv: Int) {
|
||||
import org.joda.time.Period
|
||||
def totalPeriod = new Period(total * 1000L)
|
||||
|
@ -284,6 +291,7 @@ object User {
|
|||
}
|
||||
|
||||
implicit val speakerHandler = reactivemongo.api.bson.Macros.handler[Speaker]
|
||||
implicit val contactHandler = reactivemongo.api.bson.Macros.handler[Contact]
|
||||
|
||||
private val firstRow: List[PerfType] =
|
||||
List(PerfType.Bullet, PerfType.Blitz, PerfType.Rapid, PerfType.Classical, PerfType.Correspondence)
|
||||
|
|
|
@ -341,6 +341,9 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont
|
|||
|
||||
def isTroll(id: ID): Fu[Boolean] = coll.exists($id(id) ++ trollSelect(true))
|
||||
|
||||
def isCreatedSince(id: ID, since: DateTime): Fu[Boolean] =
|
||||
coll.exists($id(id) ++ $doc(F.createdAt $lt since))
|
||||
|
||||
def setRoles(id: ID, roles: List[String]) = coll.updateField($id(id), F.roles, roles)
|
||||
|
||||
def disableTwoFactor(id: ID) = coll.update.one($id(id), $unset(F.totpSecret))
|
||||
|
@ -570,6 +573,18 @@ final class UserRepo(val coll: Coll)(implicit ec: scala.concurrent.ExecutionCont
|
|||
coll.one[User.Speaker]($id(id))
|
||||
}
|
||||
|
||||
def contacts(orig: User.ID, dest: User.ID): Fu[Option[User.Contacts]] = {
|
||||
import User.contactHandler
|
||||
coll.byOrderedIds[User.Contact, User.ID](
|
||||
List(orig, dest),
|
||||
$doc(F.kid -> true, F.marks -> true).some,
|
||||
ReadPreference.secondaryPreferred
|
||||
)(_._id) map {
|
||||
case List(o, d) => User.Contacts(o, d).some
|
||||
case _ => none
|
||||
}
|
||||
}
|
||||
|
||||
def erase(user: User): Funit =
|
||||
coll.update
|
||||
.one(
|
||||
|
|
|
@ -52,6 +52,7 @@
|
|||
"ui/tournamentSchedule",
|
||||
"ui/tournamentCalendar",
|
||||
"ui/tree",
|
||||
"ui/msg",
|
||||
"ui/@build/cssProject",
|
||||
"ui/@build/jsProject",
|
||||
"ui/@build/tsProject",
|
||||
|
|
4
public/logo/lichess-thick3.svg
Normal file
4
public/logo/lichess-thick3.svg
Normal file
|
@ -0,0 +1,4 @@
|
|||
<svg viewBox="-1 -1 52 52" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#000000" stroke="#000000" stroke-linejoin="round" style="stroke-width:3"
|
||||
d="M38.956.5c-3.53.418-6.452.902-9.286 2.984C5.534 1.786-.692 18.533.68 29.364 3.493 50.214 31.918 55.785 41.329 41.7c-7.444 7.696-19.276 8.752-28.323 3.084C3.959 39.116-.506 27.392 4.683 17.567 9.873 7.742 18.996 4.535 29.03 6.405c2.43-1.418 5.225-3.22 7.655-3.187l-1.694 4.86 12.752 21.37c-.439 5.654-5.459 6.112-5.459 6.112-.574-1.47-1.634-2.942-4.842-6.036-3.207-3.094-17.465-10.177-15.788-16.207-2.001 6.967 10.311 14.152 14.04 17.663 3.73 3.51 5.426 6.04 5.795 6.756 0 0 9.392-2.504 7.838-8.927L37.4 7.171z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 671 B |
2
ui/build
2
ui/build
|
@ -15,7 +15,7 @@ mkdir -p public/compiled
|
|||
|
||||
ts_apps1="common chess"
|
||||
ts_apps2="ceval game tree chat nvui"
|
||||
apps="site chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker"
|
||||
apps="site msg chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker"
|
||||
|
||||
if [ $mode == "upgrade" ]; then
|
||||
yarn upgrade --non-interactive
|
||||
|
|
|
@ -21,11 +21,11 @@ function linkReplace(url: string, scheme: string) {
|
|||
return '<a target="_blank" rel="nofollow" href="' + fullUrl + '">' + minUrl + '</a>';
|
||||
}
|
||||
|
||||
const userPattern = /(^|[^\w@#/])(@|(?:https:\/\/)?lichess\.org\/@\/)([\w-]{2,})/g;
|
||||
const userPattern = /(^|[^\w@#/])@([\w-]{2,})/g;
|
||||
const pawnDropPattern = /^[a-h][2-7]$/;
|
||||
|
||||
function userLinkReplace(orig: string, prefix: String, scheme: String, user: string) {
|
||||
if (user.length > 20 || (scheme === '@' && user.match(pawnDropPattern))) return orig;
|
||||
function userLinkReplace(orig: string, prefix: String, user: string) {
|
||||
if (user.length > 20 || user.match(pawnDropPattern)) return orig;
|
||||
return prefix + '<a href="/@/' + user + '">@' + user + "</a>";
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ body {
|
|||
|
||||
margin-top: $site-header-margin;
|
||||
|
||||
@media (hover: none) {
|
||||
@media (hover: none) {
|
||||
body.clinput & {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ export interface DasherData {
|
|||
board: BoardData;
|
||||
theme: ThemeData;
|
||||
piece: PieceData;
|
||||
kid: boolean;
|
||||
inbox: boolean;
|
||||
coach: boolean;
|
||||
streamer: boolean;
|
||||
i18n: any;
|
||||
|
|
|
@ -16,10 +16,10 @@ export default function(ctrl: DasherCtrl): VNode {
|
|||
linkCfg(`/@/${d.user.name}`, d.user.patron ? '' : ''),
|
||||
noarg('profile')),
|
||||
|
||||
d.kid ? null : h(
|
||||
d.inbox ? h(
|
||||
'a.text',
|
||||
linkCfg('/inbox', 'e'),
|
||||
noarg('inbox')),
|
||||
noarg('inbox')) : null,
|
||||
|
||||
h(
|
||||
'a.text',
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const headers = {
|
||||
'Accept': 'application/vnd.lichess.v2+json'
|
||||
'Accept': 'application/vnd.lichess.v4+json'
|
||||
};
|
||||
|
||||
export function get(url: string, cache: boolean = false) {
|
||||
|
|
100
ui/msg/css/_convo.scss
Normal file
100
ui/msg/css/_convo.scss
Normal file
|
@ -0,0 +1,100 @@
|
|||
@import 'convoMsgs';
|
||||
|
||||
.msg-app {
|
||||
&__convo {
|
||||
@extend %flex-column;
|
||||
flex: 1 1 100%;
|
||||
@include breakpoint($mq-not-small) {
|
||||
flex: 1 0 100%;
|
||||
display: none;
|
||||
.pane-convo & { display: flex; }
|
||||
}
|
||||
&__head {
|
||||
@extend %flex-between-nowrap;
|
||||
flex: 0 0 $msg-top-height;
|
||||
background: $c-bg-zebra2;
|
||||
border-bottom: $border;
|
||||
&__left {
|
||||
@extend %flex-center-nowrap;
|
||||
height: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
&__back {
|
||||
@extend %flex-center;
|
||||
color: $c-font-dim;
|
||||
padding: 0 1em;
|
||||
font-size: 1.5em;
|
||||
@include breakpoint($mq-small) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.user-link {
|
||||
@extend %flex-center-nowrap;
|
||||
flex: 0 0 auto;
|
||||
color: $c-font-clear;
|
||||
font-size: 1.4em;
|
||||
.title {
|
||||
margin-right: .7ch;
|
||||
}
|
||||
.line {
|
||||
flex: 0 0 auto;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
&__actions {
|
||||
@extend %flex-center-nowrap;
|
||||
margin-right: 1.2em;
|
||||
}
|
||||
}
|
||||
&__action {
|
||||
&.button {
|
||||
color: $c-font;
|
||||
&.bad:hover {
|
||||
color: $c-bad;
|
||||
}
|
||||
}
|
||||
&__sep {
|
||||
color: $c-font-dimmer;
|
||||
margin: 0 .5em;
|
||||
}
|
||||
}
|
||||
&__reply {
|
||||
@extend %flex-center-nowrap;
|
||||
flex: 0 0 auto;
|
||||
background: $c-bg-zebra2;
|
||||
border-top: $border;
|
||||
padding: 1em 2em;
|
||||
&__block {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
margin: .6em;
|
||||
}
|
||||
}
|
||||
&__post {
|
||||
@extend %flex-center-nowrap;
|
||||
flex: 1 1 100%;
|
||||
&__text {
|
||||
@extend %msg-input-focus;
|
||||
flex: 1 1 100%;
|
||||
border-radius: 1.5em;
|
||||
background: $c-bg-box;
|
||||
resize: none;
|
||||
}
|
||||
&__submit {
|
||||
outline: 0;
|
||||
margin-left: 1em;
|
||||
border-radius: 99px;
|
||||
}
|
||||
}
|
||||
@include breakpoint($mq-not-xx-small) {
|
||||
&__head__back {
|
||||
padding: 0 .5em;
|
||||
}
|
||||
.user-link {
|
||||
font-size: 1.2em;
|
||||
.line, .title { display: none }
|
||||
}
|
||||
&__action__sep, .play { display: none }
|
||||
}
|
||||
}
|
||||
}
|
57
ui/msg/css/_convoMsgs.scss
Normal file
57
ui/msg/css/_convoMsgs.scss
Normal file
|
@ -0,0 +1,57 @@
|
|||
.msg-app__convo__msgs {
|
||||
@extend %flex-column;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
&__init {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
&__content {
|
||||
@extend %flex-column, %break-word;
|
||||
flex: 0 0 auto;
|
||||
padding: 3em 3vw 1em 3vw;
|
||||
}
|
||||
group {
|
||||
@extend %flex-column;
|
||||
margin: .5em 0;
|
||||
}
|
||||
mine, their, day {
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px .5px rgba(0,0,0,.13);
|
||||
@if $theme-dark {
|
||||
box-shadow: 0 1px .5px rgb(0,0,0);
|
||||
}
|
||||
margin: .2em 0;
|
||||
padding: .5em 1em;
|
||||
color: $c-font-clear;
|
||||
max-width: 70%;
|
||||
}
|
||||
day {
|
||||
@extend %roboto;
|
||||
background: mix($c-primary, $c-bg-box, 15%);
|
||||
align-self: center;
|
||||
font-size: .9em;
|
||||
color: $c-font;
|
||||
margin: .5em 0;
|
||||
}
|
||||
mine {
|
||||
align-self: flex-end;
|
||||
background: mix($c-msg, $c-bg-box, 15%);
|
||||
&:first-child {
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
their {
|
||||
align-self: flex-start;
|
||||
background: mix($c-brag, $c-bg-box, 15%);
|
||||
&:first-child {
|
||||
border-top-left-radius: 0;
|
||||
}
|
||||
}
|
||||
em {
|
||||
@extend %roboto;
|
||||
color: $c-font-dim;
|
||||
font-size: .8em;
|
||||
float: right;
|
||||
margin: .4em 0 0 2em;
|
||||
}
|
||||
}
|
37
ui/msg/css/_msg.scss
Normal file
37
ui/msg/css/_msg.scss
Normal file
|
@ -0,0 +1,37 @@
|
|||
$c-msg: $c-secondary;
|
||||
$msg-top-height: 4em;
|
||||
|
||||
@import 'side';
|
||||
@import 'convo';
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
%msg-input-focus {
|
||||
outline: 0;
|
||||
border-color: $c-bg-zebra2;
|
||||
&:focus {
|
||||
border-color: mix($c-msg, $c-bg-box, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.msg-app {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
height: calc(100vh - var(--site-header-height));
|
||||
@include breakpoint($mq-main-margin) {
|
||||
height: calc(98.5vh - var(--site-header-height));
|
||||
}
|
||||
overflow: hidden;
|
||||
|
||||
&__search {
|
||||
h2 {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
margin: 1.2em 0 .8em 2em;
|
||||
text-transform: uppercase;
|
||||
color: $c-brag;
|
||||
}
|
||||
}
|
||||
}
|
92
ui/msg/css/_side.scss
Normal file
92
ui/msg/css/_side.scss
Normal file
|
@ -0,0 +1,92 @@
|
|||
.msg-app {
|
||||
&__side {
|
||||
@extend %flex-column;
|
||||
flex: 0 0 50ch;
|
||||
@include breakpoint($mq-not-medium) {
|
||||
flex: 0 0 40ch;
|
||||
}
|
||||
@include breakpoint($mq-not-small) {
|
||||
flex: 1 0 100%;
|
||||
display: none;
|
||||
.pane-side & { display: flex; }
|
||||
}
|
||||
background: $c-bg-zebra;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
border-right: $border;
|
||||
::-webkit-scrollbar, ::-webkit-scrollbar-corner {
|
||||
background: $c-bg-zebra;
|
||||
}
|
||||
&__search {
|
||||
@extend %flex-center;
|
||||
flex: 0 0 $msg-top-height;
|
||||
background: $c-bg-zebra2;
|
||||
border-bottom: $border;
|
||||
overflow-y: auto;
|
||||
input {
|
||||
@extend %msg-input-focus;
|
||||
width: 100%;
|
||||
margin: auto 2em;
|
||||
border-radius: 99px;
|
||||
background: $c-bg-box;
|
||||
padding: .6em 1.2em;
|
||||
}
|
||||
}
|
||||
&__content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
&__contact {
|
||||
@extend %flex-center-nowrap;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
background: mix($c-msg, $c-bg-box, 15%);
|
||||
}
|
||||
&:active,
|
||||
&.active {
|
||||
background: mix($c-msg, $c-bg-box, 30%);
|
||||
}
|
||||
&__icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 2.3em;
|
||||
}
|
||||
&__user {
|
||||
@extend %nowrap-ellipsis;
|
||||
flex: 1 1 auto;
|
||||
padding: .8em 1.5em .8em 0;
|
||||
}
|
||||
&__head {
|
||||
@extend %flex-between-nowrap;
|
||||
}
|
||||
&__date {
|
||||
flex: 0 0 auto;
|
||||
time {
|
||||
opacity: 1;
|
||||
color: $c-font-dim;
|
||||
letter-spacing: -.5px;
|
||||
}
|
||||
}
|
||||
&__name {
|
||||
@extend %nowrap-ellipsis;
|
||||
flex: 1 1 auto;
|
||||
color: $c-font-clear;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
&__body {
|
||||
@extend %flex-between-nowrap;
|
||||
height: 1.4em;
|
||||
}
|
||||
&__msg {
|
||||
@extend %roboto, %nowrap-ellipsis;
|
||||
color: $c-font-dim;
|
||||
&--new {
|
||||
font-weight: bold;
|
||||
color: $c-primary;
|
||||
}
|
||||
}
|
||||
&__new {
|
||||
color: $c-primary;
|
||||
margin-left: .3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
4
ui/msg/css/build/_msg.scss
Normal file
4
ui/msg/css/build/_msg.scss
Normal file
|
@ -0,0 +1,4 @@
|
|||
@import '../../../common/css/plugin';
|
||||
@import '../../../common/css/base/scrollbar';
|
||||
@import '../../../common/css/component/hover-text';
|
||||
@import '../msg';
|
2
ui/msg/css/build/msg.dark.scss
Normal file
2
ui/msg/css/build/msg.dark.scss
Normal file
|
@ -0,0 +1,2 @@
|
|||
@import '../../../common/css/theme/dark';
|
||||
@import 'msg';
|
2
ui/msg/css/build/msg.light.scss
Normal file
2
ui/msg/css/build/msg.light.scss
Normal file
|
@ -0,0 +1,2 @@
|
|||
@import '../../../common/css/theme/light';
|
||||
@import 'msg';
|
2
ui/msg/css/build/msg.transp.scss
Normal file
2
ui/msg/css/build/msg.transp.scss
Normal file
|
@ -0,0 +1,2 @@
|
|||
@import '../../../common/css/theme/transp';
|
||||
@import 'msg';
|
2
ui/msg/gulpfile.js
Normal file
2
ui/msg/gulpfile.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
require('@build/tsProject')('LichessMsg', 'lichess.msg', __dirname);
|
||||
require('@build/cssProject')(__dirname);
|
16
ui/msg/package.json
Normal file
16
ui/msg/package.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "msg",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "lichess.org msg",
|
||||
"author": "Thibault Duplessis",
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@build/tsProject": "2.0.0",
|
||||
"@types/jquery": "^2.0",
|
||||
"@types/lichess": "2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"snabbdom": "ornicar/snabbdom#0.7.1-lichess"
|
||||
}
|
||||
}
|
146
ui/msg/src/ctrl.ts
Normal file
146
ui/msg/src/ctrl.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { MsgData, Contact, Msg, LastMsg, SearchRes, Pane, Redraw } from './interfaces';
|
||||
import notify from 'common/notification';
|
||||
import * as network from './network';
|
||||
|
||||
export default class MsgCtrl {
|
||||
|
||||
data: MsgData;
|
||||
searchRes?: SearchRes;
|
||||
pane: Pane;
|
||||
|
||||
constructor(data: MsgData, readonly trans: Trans, readonly redraw: Redraw) {
|
||||
this.data = data;
|
||||
this.pane = data.convo ? 'convo' : 'side';
|
||||
network.websocketHandler(this);
|
||||
window.addEventListener('focus', this.setRead);
|
||||
};
|
||||
|
||||
openConvo = (userId: string) => {
|
||||
// this to avoid flashing the previous convo on mobile view
|
||||
if (this.pane == 'side' && this.data.convo?.user.id != userId) this.data.convo = undefined;
|
||||
network.loadConvo(userId).then(data => {
|
||||
this.data = data;
|
||||
this.searchRes = undefined;
|
||||
if (data.convo) {
|
||||
history.replaceState({contact: userId}, '', `/inbox/${data.convo.user.name}`);
|
||||
this.redraw();
|
||||
}
|
||||
else this.showSide();
|
||||
});
|
||||
this.pane = 'convo';
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
showSide = () => {
|
||||
this.pane = 'side';
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
post = (text: string) => {
|
||||
if (this.data.convo) {
|
||||
network.post(this.data.convo.user.id, text);
|
||||
const msg: LastMsg = {
|
||||
text,
|
||||
user: this.data.me.id,
|
||||
date: new Date(),
|
||||
read: true
|
||||
};
|
||||
this.data.convo.msgs.unshift(msg);
|
||||
const contact = this.currentContact();
|
||||
if (contact) this.addMsg(msg, contact);
|
||||
else setTimeout(() => network.loadContacts().then(data => {
|
||||
this.data.contacts = data.contacts;
|
||||
this.redraw();
|
||||
}), 1000);
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
receive = (msg: LastMsg) => {
|
||||
const contact = this.findContact(msg.user);
|
||||
this.addMsg(msg, contact);
|
||||
if (contact) {
|
||||
let redrawn = false;
|
||||
if (msg.user == this.data.convo?.user.id) {
|
||||
this.data.convo.msgs.unshift(msg);
|
||||
if (document.hasFocus()) redrawn = this.setRead();
|
||||
else this.notify(contact, msg);
|
||||
}
|
||||
if (!redrawn) this.redraw();
|
||||
} else network.loadContacts().then(data => {
|
||||
this.data.contacts = data.contacts;
|
||||
this.notify(this.findContact(msg.user)!, msg);
|
||||
this.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
private addMsg = (msg: LastMsg, contact?: Contact) => {
|
||||
if (contact) {
|
||||
contact.lastMsg = msg;
|
||||
this.data.contacts = [contact].concat(this.data.contacts.filter(c => c.user.id != contact.user.id));
|
||||
}
|
||||
}
|
||||
|
||||
private findContact = (userId: string): Contact | undefined =>
|
||||
this.data.contacts.find(c => c.user.id == userId);
|
||||
|
||||
private currentContact = (): Contact | undefined =>
|
||||
this.data.convo && this.findContact(this.data.convo.user.id);
|
||||
|
||||
private notify = (contact: Contact, msg: Msg) => {
|
||||
notify(() => `${contact.user.name}: ${msg.text}`);
|
||||
}
|
||||
|
||||
search = (q: string) => {
|
||||
if (q.length > 1) network.search(q).then((res: SearchRes) => {
|
||||
this.searchRes = res;
|
||||
this.redraw();
|
||||
});
|
||||
else {
|
||||
this.searchRes = undefined;
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
setRead = () => {
|
||||
const msg = this.currentContact()?.lastMsg;
|
||||
if (msg && msg.user != this.data.me.id && !msg.read) {
|
||||
msg.read = true;
|
||||
network.setRead(msg.user);
|
||||
this.redraw();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
delete = () => {
|
||||
const userId = this.data.convo?.user.id;
|
||||
if (userId) network.del(userId).then(data => {
|
||||
this.data = data;
|
||||
this.redraw();
|
||||
history.replaceState({}, '', '/inbox');
|
||||
});
|
||||
}
|
||||
|
||||
report = () => {
|
||||
const user = this.data.convo?.user;
|
||||
if (user) {
|
||||
const text = this.data.convo?.msgs.find(m => m.user != this.data.me.id)?.text.slice(0, 140);
|
||||
if (text) network.report(user.name, text).then(_ => alert('Your report has been sent.'));
|
||||
}
|
||||
}
|
||||
|
||||
block = () => {
|
||||
const userId = this.data.convo?.user.id;
|
||||
if (userId) network.block(userId).then(() => this.openConvo(userId));
|
||||
}
|
||||
|
||||
unblock = () => {
|
||||
const userId = this.data.convo?.user.id;
|
||||
if (userId) network.unblock(userId).then(() => this.openConvo(userId));
|
||||
}
|
||||
|
||||
changeBlockBy = (userId: string) => {
|
||||
if (userId == this.data.convo?.user.id) this.openConvo(userId);
|
||||
}
|
||||
}
|
57
ui/msg/src/interfaces.ts
Normal file
57
ui/msg/src/interfaces.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
export interface MsgOpts {
|
||||
data: MsgData;
|
||||
i18n: any;
|
||||
}
|
||||
export interface MsgData {
|
||||
me: Me;
|
||||
contacts: Contact[];
|
||||
convo?: Convo;
|
||||
}
|
||||
export interface Contact {
|
||||
user: User;
|
||||
lastMsg: LastMsg;
|
||||
}
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
title?: string;
|
||||
patron: boolean;
|
||||
online: boolean;
|
||||
}
|
||||
export interface Me extends User {
|
||||
kid: boolean;
|
||||
}
|
||||
export interface Msg {
|
||||
user: string;
|
||||
text: string;
|
||||
date: Date;
|
||||
}
|
||||
export interface LastMsg extends Msg {
|
||||
read: boolean;
|
||||
}
|
||||
export interface Convo {
|
||||
user: User;
|
||||
msgs: Msg[];
|
||||
relations: Relations;
|
||||
postable: boolean;
|
||||
}
|
||||
|
||||
export interface Relations {
|
||||
in?: boolean;
|
||||
out?: boolean;
|
||||
}
|
||||
|
||||
export interface Daily {
|
||||
date: Date;
|
||||
msgs: Msg[][];
|
||||
}
|
||||
|
||||
export interface SearchRes {
|
||||
contacts: Contact[];
|
||||
friends: User[];
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export type Pane = 'side' | 'convo';
|
||||
|
||||
export type Redraw = () => void;
|
33
ui/msg/src/main.ts
Normal file
33
ui/msg/src/main.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import view from './view/main';
|
||||
|
||||
import { init } from 'snabbdom';
|
||||
import { VNode } from 'snabbdom/vnode'
|
||||
import klass from 'snabbdom/modules/class';
|
||||
import attributes from 'snabbdom/modules/attributes';
|
||||
|
||||
import { MsgOpts } from './interfaces'
|
||||
import { upgradeData } from './network'
|
||||
import MsgCtrl from './ctrl';
|
||||
|
||||
const patch = init([klass, attributes]);
|
||||
|
||||
export default function LichessMsg(element: HTMLElement, opts: MsgOpts) {
|
||||
|
||||
let vnode: VNode, ctrl: MsgCtrl;
|
||||
|
||||
function redraw() {
|
||||
vnode = patch(vnode, view(ctrl));
|
||||
}
|
||||
|
||||
ctrl = new MsgCtrl(
|
||||
upgradeData(opts.data),
|
||||
window.lichess.trans(opts.i18n),
|
||||
redraw
|
||||
);
|
||||
|
||||
const blueprint = view(ctrl);
|
||||
element.innerHTML = '';
|
||||
vnode = patch(element, blueprint);
|
||||
|
||||
redraw();
|
||||
};
|
118
ui/msg/src/network.ts
Normal file
118
ui/msg/src/network.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import MsgCtrl from './ctrl';
|
||||
import { MsgData, Contact, User, Msg, Convo, SearchRes } from './interfaces';
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/vnd.lichess.v5+json'
|
||||
};
|
||||
const cache: RequestCache = 'no-cache';
|
||||
|
||||
export function loadConvo(userId: string): Promise<MsgData> {
|
||||
return fetch(`/inbox/${userId}`, { headers, cache })
|
||||
.then(r => r.json())
|
||||
.then(upgradeData);
|
||||
}
|
||||
|
||||
export function loadContacts(): Promise<MsgData> {
|
||||
return fetch(`/inbox`, { headers, cache })
|
||||
.then(r => r.json())
|
||||
.then(upgradeData);
|
||||
}
|
||||
|
||||
export function search(q: string): Promise<SearchRes> {
|
||||
return fetch(`/inbox/search?q=${q}`)
|
||||
.then(r => r.json())
|
||||
.then(res => ({
|
||||
...res,
|
||||
contacts: res.contacts.map(upgradeContact)
|
||||
} as SearchRes));
|
||||
}
|
||||
|
||||
export function block(u: string) {
|
||||
return fetch(`/rel/block/${u}`, {
|
||||
method: 'post',
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
export function unblock(u: string) {
|
||||
return fetch(`/rel/unblock/${u}`, {
|
||||
method: 'post',
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
export function del(u: string): Promise<MsgData> {
|
||||
return fetch(`/inbox/${u}`, {
|
||||
method: 'delete',
|
||||
headers
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(upgradeData);
|
||||
}
|
||||
|
||||
export function report(name: string, text: string): Promise<any> {
|
||||
const formData = new FormData()
|
||||
formData.append('username', name);
|
||||
formData.append('text', text);
|
||||
formData.append('resource', 'msg');
|
||||
return fetch('/report/flag', {
|
||||
method: 'post',
|
||||
headers,
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
export function post(dest: string, text: string) {
|
||||
window.lichess.pubsub.emit('socket.send', 'msgSend', { dest, text });
|
||||
}
|
||||
|
||||
export function setRead(dest: string) {
|
||||
window.lichess.pubsub.emit('socket.send', 'msgRead', dest);
|
||||
}
|
||||
|
||||
export function websocketHandler(ctrl: MsgCtrl) {
|
||||
const listen = window.lichess.pubsub.on;
|
||||
listen('socket.in.msgNew', msg => {
|
||||
ctrl.receive({
|
||||
...upgradeMsg(msg),
|
||||
read: false
|
||||
});
|
||||
});
|
||||
listen('socket.in.blockedBy', ctrl.changeBlockBy);
|
||||
listen('socket.in.unblockedBy', ctrl.changeBlockBy);
|
||||
}
|
||||
|
||||
// the upgrade functions convert incoming timestamps into JS dates
|
||||
export function upgradeData(d: any): MsgData {
|
||||
return {
|
||||
...d,
|
||||
convo: d.convo && upgradeConvo(d.convo),
|
||||
contacts: d.contacts.map(upgradeContact)
|
||||
};
|
||||
}
|
||||
function upgradeMsg(m: any): Msg {
|
||||
return {
|
||||
...m,
|
||||
date: new Date(m.date)
|
||||
};
|
||||
}
|
||||
function upgradeUser(u: any): User {
|
||||
return {
|
||||
...u,
|
||||
id: u.name.toLowerCase()
|
||||
};
|
||||
}
|
||||
function upgradeContact(c: any): Contact {
|
||||
return {
|
||||
...c,
|
||||
user: upgradeUser(c.user),
|
||||
lastMsg: upgradeMsg(c.lastMsg)
|
||||
};
|
||||
}
|
||||
function upgradeConvo(c: any): Convo {
|
||||
return {
|
||||
...c,
|
||||
user: upgradeUser(c.user),
|
||||
msgs: c.msgs.map(upgradeMsg)
|
||||
};
|
||||
}
|
68
ui/msg/src/view/actions.ts
Normal file
68
ui/msg/src/view/actions.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { h } from 'snabbdom'
|
||||
import { VNode } from 'snabbdom/vnode'
|
||||
import { Convo } from '../interfaces'
|
||||
import { bind } from './util';
|
||||
import MsgCtrl from '../ctrl';
|
||||
|
||||
export default function renderActions(ctrl: MsgCtrl, convo: Convo): VNode[] {
|
||||
if (convo.user.id == 'lichess') return [];
|
||||
const nodes = [];
|
||||
const cls = 'msg-app__convo__action.button.button-empty';
|
||||
nodes.push(
|
||||
h(`a.${cls}.play`, {
|
||||
key: 'play',
|
||||
attrs: {
|
||||
'data-icon': 'U',
|
||||
href: `/?user=${convo.user.name}#friend`,
|
||||
title: ctrl.trans.noarg('challengeToPlay')
|
||||
}
|
||||
})
|
||||
);
|
||||
nodes.push(h('div.msg-app__convo__action__sep', '|'));
|
||||
if (convo.relations.out === false) nodes.push(
|
||||
h(`button.${cls}.text.hover-text`, {
|
||||
key: 'unblock',
|
||||
attrs: {
|
||||
'data-icon': 'k',
|
||||
title: ctrl.trans.noarg('blocked'),
|
||||
'data-hover-text': ctrl.trans.noarg('unblock')
|
||||
},
|
||||
hook: bind('click', ctrl.unblock)
|
||||
})
|
||||
);
|
||||
else nodes.push(
|
||||
h(`button.${cls}.bad`, {
|
||||
key: 'block',
|
||||
attrs: {
|
||||
'data-icon': 'k',
|
||||
title: ctrl.trans.noarg('block')
|
||||
},
|
||||
hook: bind('click', withConfirm(ctrl.block))
|
||||
})
|
||||
);
|
||||
nodes.push(
|
||||
h(`button.${cls}.bad`, {
|
||||
key: 'delete',
|
||||
attrs: {
|
||||
'data-icon': 'q',
|
||||
title: ctrl.trans.noarg('delete')
|
||||
},
|
||||
hook: bind('click', withConfirm(ctrl.delete))
|
||||
})
|
||||
);
|
||||
if (!!convo.msgs[0]) nodes.push(
|
||||
h(`button.${cls}.bad`, {
|
||||
key: 'report',
|
||||
attrs: {
|
||||
'data-icon': '!',
|
||||
title: ctrl.trans('reportXToModerators', convo.user.name)
|
||||
},
|
||||
hook: bind('click', withConfirm(ctrl.report))
|
||||
})
|
||||
);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const withConfirm = (f: () => void) => (e: MouseEvent) => {
|
||||
if (confirm(`${(e.target as HTMLElement).getAttribute('title') || 'Confirm'}?`)) f();
|
||||
}
|
41
ui/msg/src/view/contact.ts
Normal file
41
ui/msg/src/view/contact.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { h } from 'snabbdom'
|
||||
import { VNode } from 'snabbdom/vnode'
|
||||
import { Contact, LastMsg } from '../interfaces'
|
||||
import MsgCtrl from '../ctrl';
|
||||
import { userName, userIcon, bindMobileMousedown } from './util';
|
||||
|
||||
export default function renderContact(ctrl: MsgCtrl, contact: Contact, active?: string): VNode {
|
||||
const user = contact.user, msg = contact.lastMsg,
|
||||
isNew = !msg.read && msg.user != ctrl.data.me.id;
|
||||
return h('div.msg-app__side__contact', {
|
||||
key: user.id,
|
||||
class: { active: active == user.id, },
|
||||
hook: bindMobileMousedown(_ => ctrl.openConvo(user.id)),
|
||||
}, [
|
||||
userIcon(user, 'msg-app__side__contact__icon'),
|
||||
h('div.msg-app__side__contact__user', [
|
||||
h('div.msg-app__side__contact__head', [
|
||||
h('div.msg-app__side__contact__name', userName(user)),
|
||||
h('div.msg-app__side__contact__date', renderDate(msg))
|
||||
]),
|
||||
h('div.msg-app__side__contact__body', [
|
||||
h('div.msg-app__side__contact__msg', {
|
||||
class: { 'msg-app__side__contact__msg--new': isNew }
|
||||
}, msg.text),
|
||||
isNew ? h('i.msg-app__side__contact__new', {
|
||||
attrs: { 'data-icon': '' }
|
||||
}) : null
|
||||
])
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
function renderDate(msg: LastMsg): VNode {
|
||||
return h('time.timeago', {
|
||||
key: msg.date.getTime(),
|
||||
attrs: {
|
||||
title: msg.date.toLocaleString(),
|
||||
datetime: msg.date.getTime()
|
||||
}
|
||||
}, window.lichess.timeago.format(msg.date));
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue