diff --git a/app/AppLoader.scala b/app/AppLoader.scala index e0535437b2..3ca49bc5af 100644 --- a/app/AppLoader.scala +++ b/app/AppLoader.scala @@ -93,6 +93,7 @@ final class LilaComponents(ctx: ApplicationLoader.Context) 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] diff --git a/app/Env.scala b/app/Env.scala index 4c8f07ae02..8b06a12c55 100644 --- a/app/Env.scala +++ b/app/Env.scala @@ -22,6 +22,7 @@ final class 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, @@ -179,6 +180,7 @@ final class EnvBoot( 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] diff --git a/app/controllers/Msg.scala b/app/controllers/Msg.scala new file mode 100644 index 0000000000..cfa8b9a883 --- /dev/null +++ b/app/controllers/Msg.scala @@ -0,0 +1,24 @@ +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 => + env.msg.api.threads(me) flatMap env.msg.json.threads(me) map { threads => + Ok( + views.html.msg.home( + Json.obj( + "me" -> me.light, + "threads" -> threads + ) + ) + ) + } + } +} diff --git a/app/controllers/Team.scala b/app/controllers/Team.scala index f009b5090f..672fb07f36 100644 --- a/app/controllers/Team.scala +++ b/app/controllers/Team.scala @@ -6,7 +6,6 @@ import play.api.mvc._ import lila.api.Context import lila.app._ import lila.common.config.MaxPerSecond -import lila.common.HTTPRequest import lila.hub.LightTeam import lila.security.Granter import lila.team.{ Joined, Motivate, Team => TeamModel } diff --git a/app/views/msg.scala b/app/views/msg.scala new file mode 100644 index 0000000000..3ca66d3cb2 --- /dev/null +++ b/app/views/msg.scala @@ -0,0 +1,38 @@ +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.app(document.querySelector('.msg-app'), ${safeJsonValue( + Json.obj( + "data" -> json, + "i18n" -> jsI18n + ) + )})""" + ) + ), + title = "Lichess Inbox" + ) { + main(cls := "page box msg-app")( + "loading" + ) + } + + def jsI18n(implicit ctx: Context) = i18nJsObject(translations) + + private val translations = List( + trans.inbox + ) +} diff --git a/bin/mongodb/msg_import.js b/bin/mongodb/msg_import.js index b6affd84d1..ec760a7600 100644 --- a/bin/mongodb/msg_import.js +++ b/bin/mongodb/msg_import.js @@ -1,8 +1,8 @@ db.msg_msg.remove({}); db.msg_thread.remove({}); -print("Create db.m_thread_sorted"); -if (true) { +if (false) { + print("Create db.m_thread_sorted"); db.m_thread_sorted.drop(); db.m_thread.find({visibleByUserIds:{$size:2}}).forEach(t => { t.visibleByUserIds.sort(); @@ -17,25 +17,35 @@ db.m_thread_sorted.aggregate([ let first = o.threads[0]; - let posts = []; + let msgs = []; - o.posts.forEach(ps => { - ps.forEach(p => posts.push); + o.threads.forEach(t => { + t.posts.forEach(p => { + msgs.push({ + _id: p.id, + thread: first._id, + text: p.text, + user: p.isByCreator ? t.creatorId : t.invitedId, + date: p.createdAt + }); + }); }); - posts.sort((a,b) => new Date(b.createdAt) - new Date(a.createdAt)); + msgs.sort((a,b) => new Date(b.date) - new Date(a.date)); - let msgs = posts.map(p => ({ - _id: p.id, - text: p.text, - date: p.createdAt, - read: p.isRead - })); + let last = msgs[msgs.length - 1]; let thread = { _id: first._id, users: o._id.sort(), - lastMsg: lastMsg + lastMsg: { + text: last.text.slice(0, 50), + user: last.user, + date: last.date, + read: !o.threads.find(t => t.posts.find(p => p.isRead)) + } } + db.msg_thread.insert(thread); + db.msg_msg.insertMany(msgs, {ordered: false}); }); diff --git a/conf/routes b/conf/routes index 5eb8bb0258..34ec42f446 100644 --- a/conf/routes +++ b/conf/routes @@ -415,14 +415,17 @@ 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) +GET /old/inbox controllers.Message.inbox(page: Int ?= 1) +GET /old/inbox/new controllers.Message.form +GET /old/inbox/unread-count controllers.Message.unreadCount +POST /old/inbox/new controllers.Message.create +POST /old/inbox/batch controllers.Message.batch +GET /old/inbox/$id<\w{8}> controllers.Message.thread(id: String) +POST /old/inbox/$id<\w{8}> controllers.Message.answer(id: String) +POST /old/inbox/$id<\w{8}>/delete controllers.Message.delete(id: String) + +# Message +GET /inbox controllers.Msg.home # Coach GET /coach controllers.Coach.allDefault(page: Int ?= 1) diff --git a/modules/msg/src/main/BsonHandlers.scala b/modules/msg/src/main/BsonHandlers.scala index 6e108e02d0..537c246a29 100644 --- a/modules/msg/src/main/BsonHandlers.scala +++ b/modules/msg/src/main/BsonHandlers.scala @@ -1,6 +1,7 @@ package lila.msg import lila.db.dsl._ +import lila.db.BSON import reactivemongo.api.bson._ private[msg] object BsonHandlers { @@ -9,7 +10,24 @@ private[msg] object BsonHandlers { implicit val msgContentBSONHandler = Macros.handler[Last] implicit val threadIdBSONHandler = stringAnyValHandler[MsgThread.Id](_.value, MsgThread.Id.apply) - implicit val threadBSONHandler = Macros.handler[MsgThread] + + implicit val threadBSONHandler = 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 msgIdBSONHandler = stringAnyValHandler[Msg.Id](_.value, Msg.Id.apply) implicit val msgBSONHandler = Macros.handler[Msg] diff --git a/modules/msg/src/main/Env.scala b/modules/msg/src/main/Env.scala index a007f4de8a..0299f6f7f8 100644 --- a/modules/msg/src/main/Env.scala +++ b/modules/msg/src/main/Env.scala @@ -7,12 +7,16 @@ import lila.common.config._ @Module final class Env( db: lila.db.Db, - userRepo: lila.user.UserRepo + userRepo: lila.user.UserRepo, + lightUserApi: lila.user.LightUserApi, + isOnline: lila.socket.IsOnline )(implicit ec: scala.concurrent.ExecutionContext) { private val colls = wire[MsgColls] lazy val api: MsgApi = wire[MsgApi] + + lazy val json = wire[MsgJson] } private class MsgColls(db: lila.db.Db) { diff --git a/modules/msg/src/main/MsgApi.scala b/modules/msg/src/main/MsgApi.scala index 9d8b079d25..b8f1587dfd 100644 --- a/modules/msg/src/main/MsgApi.scala +++ b/modules/msg/src/main/MsgApi.scala @@ -14,7 +14,7 @@ final class MsgApi( import BsonHandlers._ - def inbox(me: User): Fu[List[MsgThread]] = + def threads(me: User): Fu[List[MsgThread]] = colls.thread.ext .find( $doc( diff --git a/modules/msg/src/main/MsgJson.scala b/modules/msg/src/main/MsgJson.scala new file mode 100644 index 0000000000..98a20d4a06 --- /dev/null +++ b/modules/msg/src/main/MsgJson.scala @@ -0,0 +1,32 @@ +package lila.msg + +import play.api.libs.json._ + +import lila.user.User +import lila.common.Json._ +import lila.common.LightUser + +final class MsgJson( + lightUserApi: lila.user.LightUserApi, + isOnline: lila.socket.IsOnline +) { + + implicit val threadIdWrites: Writes[MsgThread.Id] = Writes.of[String].contramap[MsgThread.Id](_.value) + implicit val lastMsgWrites: OWrites[Msg.Last] = Json.writes[Msg.Last] + + def threads(me: User)(threads: List[MsgThread]): Fu[JsArray] = + lightUserApi.preloadMany(threads.map(_ other me)) inject Json.arr( + threads.map { t => + Json.obj( + "id" -> t.id, + "contact" -> contact(t other me), + "lastMsg" -> t.lastMsg + ) + } + ) + + private def contact(userId: User.ID): JsObject = + LightUser.lightUserWrites + .writes(lightUserApi.sync(userId) | LightUser.fallback(userId)) + .add("online" -> isOnline(userId)) +} diff --git a/modules/msg/src/main/MsgThread.scala b/modules/msg/src/main/MsgThread.scala index dce0ed276b..590d36ca40 100644 --- a/modules/msg/src/main/MsgThread.scala +++ b/modules/msg/src/main/MsgThread.scala @@ -5,10 +5,16 @@ import org.joda.time.DateTime import lila.user.User case class MsgThread( - _id: MsgThread.Id, // random - users: List[User.ID], // unique + id: MsgThread.Id, // random + user1: User.ID, + user2: User.ID, lastMsg: Msg.Last -) +) { + + def users = List(user1, user2) + + def other(user: User) = if (user1 == user.id) user2 else user1 +} object MsgThread { @@ -16,11 +22,12 @@ object MsgThread { def make( msg: Msg.Last, - orig: User.ID, - dest: User.ID + user1: User.ID, + user2: User.ID ): MsgThread = MsgThread( - _id = Id(ornicar.scalalib.Random nextString 8), - users = List(orig, dest).sorted, + id = Id(ornicar.scalalib.Random nextString 8), + user1 = user1, + user2 = user2, lastMsg = msg ) } diff --git a/package.json b/package.json index d718efe156..5466271f2f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "ui/tournamentSchedule", "ui/tournamentCalendar", "ui/tree", + "ui/msg", "ui/@build/cssProject", "ui/@build/jsProject", "ui/@build/tsProject", diff --git a/ui/build b/ui/build index 34df6ef716..53ce5989c2 100755 --- a/ui/build +++ b/ui/build @@ -15,7 +15,7 @@ mkdir -p public/compiled ts_apps1="common chess" ts_apps2="ceval game tree chat nvui" -apps="site chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker" +apps="site msg chat cli challenge notify learn insight editor puzzle round analyse lobby tournament tournamentSchedule tournamentCalendar simul dasher speech palantir serviceWorker" if [ $mode == "upgrade" ]; then yarn upgrade --non-interactive diff --git a/ui/msg/css/_msg.scss b/ui/msg/css/_msg.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/msg/css/build/_msg.scss b/ui/msg/css/build/_msg.scss new file mode 100644 index 0000000000..9d063961cd --- /dev/null +++ b/ui/msg/css/build/_msg.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/plugin'; +@import '../msg'; diff --git a/ui/msg/css/build/msg.dark.scss b/ui/msg/css/build/msg.dark.scss new file mode 100644 index 0000000000..8c1ae11e96 --- /dev/null +++ b/ui/msg/css/build/msg.dark.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/theme/dark'; +@import 'msg'; diff --git a/ui/msg/css/build/msg.light.scss b/ui/msg/css/build/msg.light.scss new file mode 100644 index 0000000000..6cf930b37f --- /dev/null +++ b/ui/msg/css/build/msg.light.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/theme/light'; +@import 'msg'; diff --git a/ui/msg/css/build/msg.transp.scss b/ui/msg/css/build/msg.transp.scss new file mode 100644 index 0000000000..255c25d0e7 --- /dev/null +++ b/ui/msg/css/build/msg.transp.scss @@ -0,0 +1,2 @@ +@import '../../../common/css/theme/transp'; +@import 'msg'; diff --git a/ui/msg/gulpfile.js b/ui/msg/gulpfile.js new file mode 100644 index 0000000000..1b2a0c73ff --- /dev/null +++ b/ui/msg/gulpfile.js @@ -0,0 +1,2 @@ +require('@build/tsProject')('LichessMsg', 'lichess.msg', __dirname); +require('@build/cssProject')(__dirname); diff --git a/ui/msg/package.json b/ui/msg/package.json new file mode 100644 index 0000000000..560ae34de8 --- /dev/null +++ b/ui/msg/package.json @@ -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" + } +} diff --git a/ui/msg/src/ctrl.ts b/ui/msg/src/ctrl.ts new file mode 100644 index 0000000000..e41326be58 --- /dev/null +++ b/ui/msg/src/ctrl.ts @@ -0,0 +1,12 @@ +import { MsgData, MsgOpts } from './interfaces'; + +export default class MsgCtrl { + + data: MsgData; + trans: Trans; + + constructor(opts: MsgOpts, readonly redraw: () => void) { + this.data = opts.data; + this.trans = window.lichess.trans(opts.i18n); + }; +} diff --git a/ui/msg/src/interfaces.ts b/ui/msg/src/interfaces.ts new file mode 100644 index 0000000000..bca22669b2 --- /dev/null +++ b/ui/msg/src/interfaces.ts @@ -0,0 +1,29 @@ +export interface MsgOpts { + data: MsgData; + i18n: any; +} +export interface MsgData { + me: User; + threads: Thread[]; + thread?: Thread; +} +export interface Thread { + id: string; + contact: User; + lastMsg: LastMsg; +} +export interface User { + id: string; + name: string; + title?: string; + patron: boolean; + online: boolean; +} +export interface LastMsg extends BaseMsg { + read: boolean; +} +export interface BaseMsg { + user: string; + text: string; + date: number; +} diff --git a/ui/msg/src/main.ts b/ui/msg/src/main.ts new file mode 100644 index 0000000000..b0e670f123 --- /dev/null +++ b/ui/msg/src/main.ts @@ -0,0 +1,28 @@ +import view from './view'; + +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 MsgCtrl from './ctrl'; + +const patch = init([klass, attributes]); + +export function app(element: HTMLElement, opts: MsgOpts) { + + let vnode: VNode, ctrl: MsgCtrl; + + function redraw() { + vnode = patch(vnode, view(ctrl)); + } + + ctrl = new MsgCtrl(opts, redraw); + + const blueprint = view(ctrl); + element.innerHTML = ''; + vnode = patch(element, blueprint); + + redraw(); +}; diff --git a/ui/msg/src/view.ts b/ui/msg/src/view.ts new file mode 100644 index 0000000000..6d8a56bdee --- /dev/null +++ b/ui/msg/src/view.ts @@ -0,0 +1,65 @@ +import { h } from 'snabbdom' +import { VNode } from 'snabbdom/vnode' +import { Thread, BaseMsg, User } from './interfaces' +import MsgCtrl from './ctrl'; + +function userIcon(user: User): VNode { + return h('i.line' + (user.patron ? '.patron' : '')); +} + +function userName(user: User): Array { + return user.title ? [ + h( + 'span.title', + user.title == 'BOT' ? { attrs: {'data-bot': true } } : {}, + user.title + ), ' ', user.name + ] : [user.name]; +} + +function renderDate(msg: BaseMsg) { + var date = new Date(msg.date); + return h('time.timeago', { + attrs: { + title: date.toLocaleString(), + datetime: msg.date + } + }, window.lichess.timeago.format(date)); +} + +function sideThread(thread: Thread) { + return h('div.msg-app__threads__thread', [ + h('a.msg-app__threads__thread__icon.user-link.ulpt', { + class: { + online: thread.contact.online, + offline: !thread.contact.online + }, + attrs: { + href: '/@/' + thread.contact.name + } + }, [userIcon(thread.contact)]), + h('div.msg-app__threads__thread__contact', [ + h('div.msg-app__threads__thread__head', [ + h('a.msg-app__threads__thread__name', userName(thread.contact)), + h('a.msg-app__threads__thread__date', renderDate(thread.lastMsg)) + ]), + h('a.msg-app__threads__thread__msg', thread.lastMsg.text) + ]) + ]); +} + +export default function(ctrl: MsgCtrl) { + return h('div.msg-app', [ + h('div.msg-app__side', [ + h('div.msg-app__side__search', [ + h('input') + ]), + h('div.msg-app__threads', [ + ctrl.data.threads.map(sideThread) + ]) + ]), + h('div.msg-app__main', [ + 'main' + ]) + ]); +} diff --git a/ui/msg/tsconfig.json b/ui/msg/tsconfig.json new file mode 100644 index 0000000000..631a9c851a --- /dev/null +++ b/ui/msg/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + } +}