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:
Thibault Duplessis 2020-01-27 20:00:08 -06:00
commit c7c59f592c
110 changed files with 2534 additions and 1371 deletions

View file

@ -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]

View file

@ -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]

View file

@ -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))

View file

@ -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

View file

@ -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()

View file

@ -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))
)
}
}

View file

@ -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
View 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
)
}
}

View file

@ -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,

View file

@ -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 _ =>

View file

@ -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"
),

View file

@ -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} {...}
""")
)
)
}

View file

@ -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())
)
)
}
)
}
}

View file

@ -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)))
)
)
}
}

View file

@ -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())
)
)
}
}
)
)
}
}

View file

@ -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))
)
}
)
}
)
)
}
)

View file

@ -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
View 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
)
}

View file

@ -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"
)

View file

@ -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
View 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;
}

View file

@ -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
)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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
)
}
}

View file

@ -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) {

View file

@ -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
)

View file

@ -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

View file

@ -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
// }
}
}

View file

@ -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)

View file

@ -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")
}
}

View file

@ -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
}
}

View file

@ -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
)
}
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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]
}

View file

@ -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]
}

View file

@ -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"
}

View file

@ -1,5 +0,0 @@
package lila.message
object Event {
case class NewMessage(t: Thread, p: Post)
}

View file

@ -1,6 +0,0 @@
package lila
package object message extends PackageObject {
private[message] val logger = lila.log("message")
}

View 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))
}
}

View 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"))
}

View 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
)
}
}

View 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)
}
}

View 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
)
}

View 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))
}

View 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))
)
)
}
}
}
}

View file

@ -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)
}

View 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]
)
}

View 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
}

View 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)
}

View 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
)

View file

@ -0,0 +1,6 @@
package lila
package object msg extends PackageObject {
private[msg] val logger = lila.log("msg")
}

View file

@ -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]

View file

@ -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(

View file

@ -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(

View file

@ -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
)

View file

@ -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
}
}

View file

@ -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
)

View file

@ -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,

View file

@ -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
}

View file

@ -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) =

View file

@ -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
) {

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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
)
)
)
)
}
}
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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)

View file

@ -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) }

View file

@ -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)

View file

@ -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(

View file

@ -52,6 +52,7 @@
"ui/tournamentSchedule",
"ui/tournamentCalendar",
"ui/tree",
"ui/msg",
"ui/@build/cssProject",
"ui/@build/jsProject",
"ui/@build/tsProject",

View 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

View file

@ -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

View file

@ -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>";
}

View file

@ -39,7 +39,7 @@ body {
margin-top: $site-header-margin;
@media (hover: none) {
@media (hover: none) {
body.clinput & {
display: none;
}

View file

@ -20,7 +20,7 @@ export interface DasherData {
board: BoardData;
theme: ThemeData;
piece: PieceData;
kid: boolean;
inbox: boolean;
coach: boolean;
streamer: boolean;
i18n: any;

View file

@ -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',

View file

@ -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
View 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 }
}
}
}

View 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
View 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
View 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;
}
}
}
}

View file

@ -0,0 +1,4 @@
@import '../../../common/css/plugin';
@import '../../../common/css/base/scrollbar';
@import '../../../common/css/component/hover-text';
@import '../msg';

View file

@ -0,0 +1,2 @@
@import '../../../common/css/theme/dark';
@import 'msg';

View file

@ -0,0 +1,2 @@
@import '../../../common/css/theme/light';
@import 'msg';

View file

@ -0,0 +1,2 @@
@import '../../../common/css/theme/transp';
@import 'msg';

2
ui/msg/gulpfile.js Normal file
View file

@ -0,0 +1,2 @@
require('@build/tsProject')('LichessMsg', 'lichess.msg', __dirname);
require('@build/cssProject')(__dirname);

16
ui/msg/package.json Normal file
View 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
View 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
View 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
View 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
View 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)
};
}

View 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();
}

View 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