Merge pull request #1899 from Happy0/notif
[WIP] Lichess notification system
This commit is contained in:
commit
ee709a0022
|
@ -127,6 +127,7 @@ object Env {
|
|||
def teamSearch = lila.teamSearch.Env.current
|
||||
def analyse = lila.analyse.Env.current
|
||||
def mod = lila.mod.Env.current
|
||||
def notif = lila.notify.Env.current
|
||||
def site = lila.site.Env.current
|
||||
def round = lila.round.Env.current
|
||||
def lobby = lila.lobby.Env.current
|
||||
|
|
|
@ -15,6 +15,7 @@ import lila.app._
|
|||
import lila.common.{ LilaCookie, HTTPRequest }
|
||||
import lila.security.{ Permission, Granter, FingerprintedUser }
|
||||
import lila.user.{ UserContext, User => UserModel }
|
||||
import lila.notify.Notification.Notifies
|
||||
|
||||
private[controllers] trait LilaController
|
||||
extends Controller
|
||||
|
@ -302,11 +303,12 @@ private[controllers] trait LilaController
|
|||
} recover { case _ => Nil }) zip
|
||||
Env.team.api.nbRequests(me.id) zip
|
||||
Env.message.api.unreadIds(me.id) zip
|
||||
Env.challenge.api.countInFor(me.id)
|
||||
Env.challenge.api.countInFor(me.id) zip
|
||||
Env.notif.notifyApi.getUnseenNotificationCount(Notifies(me.id))
|
||||
}
|
||||
} map {
|
||||
case (pref, (((friends, teamNbRequests), messageIds), nbChallenges)) =>
|
||||
PageData(friends, teamNbRequests, messageIds.size, nbChallenges, pref,
|
||||
case (pref, ((((friends, teamNbRequests), messageIds), nbChallenges), nbNotifications)) =>
|
||||
PageData(friends, teamNbRequests, messageIds.size, nbChallenges, nbNotifications, pref,
|
||||
blindMode = blindMode(ctx),
|
||||
hasFingerprint = hasFingerprint)
|
||||
}
|
||||
|
|
38
app/controllers/Notif.scala
Normal file
38
app/controllers/Notif.scala
Normal file
|
@ -0,0 +1,38 @@
|
|||
package controllers
|
||||
|
||||
import lila.app._
|
||||
import lila.notify.Notification.Notifies
|
||||
|
||||
import play.api.libs.json._
|
||||
import views.html
|
||||
|
||||
object Notif extends LilaController {
|
||||
|
||||
import lila.notify.JSONHandlers._
|
||||
|
||||
val env = Env.notif
|
||||
|
||||
def recent = Auth { implicit ctx =>
|
||||
me =>
|
||||
val notifies = Notifies(me.id)
|
||||
env.notifyApi.getNotifications(notifies, 1, 10) map {
|
||||
notifications => Ok(Json.toJson(notifications.currentPageResults)) as JSON
|
||||
}
|
||||
}
|
||||
|
||||
def markAllAsRead = Auth {
|
||||
implicit ctx =>
|
||||
me =>
|
||||
val userId = Notifies(me.id)
|
||||
env.notifyApi.markAllRead(userId)
|
||||
}
|
||||
|
||||
def notificationsPage = Auth { implicit ctx =>
|
||||
me =>
|
||||
val notifies = Notifies(me.id)
|
||||
env.notifyApi.getNotifications(notifies, 1, perPage = 100) map {
|
||||
notifications => Ok(html.notifications.view(notifications.currentPageResults.toList))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -6,11 +6,9 @@ import play.api.mvc._, Results._
|
|||
import lila.api.{ Context, BodyContext }
|
||||
import lila.app._
|
||||
import lila.app.mashup.GameFilterMenu
|
||||
import lila.common.LilaCookie
|
||||
import lila.evaluation.{ PlayerAggregateAssessment }
|
||||
import lila.game.{ GameRepo, Pov }
|
||||
import lila.rating.PerfType
|
||||
import lila.security.Permission
|
||||
import lila.user.{ User => UserModel, UserRepo }
|
||||
import views._
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ import play.api.mvc.Call
|
|||
|
||||
trait NotificationHelper {
|
||||
|
||||
def notifications(user: User): Html = {
|
||||
def renderNotifications(user: User): Html = {
|
||||
val notifs = notificationEnv.api get user.id take 2 map { notif =>
|
||||
views.html.notification.view(notif.id, notif.from)(Html(notif.html))
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ import java.text.SimpleDateFormat
|
|||
import java.util.Date
|
||||
import java.util.regex.Matcher.quoteReplacement
|
||||
|
||||
import lila.user.UserContext
|
||||
import lila.user.{User, UserContext}
|
||||
import org.apache.commons.lang3.StringEscapeUtils.escapeHtml4
|
||||
import play.twirl.api.Html
|
||||
|
||||
|
@ -47,13 +47,6 @@ trait StringHelper { self: NumberHelper =>
|
|||
}
|
||||
}
|
||||
|
||||
// Matches a lichess username with a '@' prefix only if the next char isn't a digit,
|
||||
// if it isn't after a word character (that'd be an email) and fits constraints in
|
||||
// https://github.com/ornicar/lila/blob/master/modules/security/src/main/DataForm.scala#L34-L44
|
||||
// Example: everyone says @ornicar is a pretty cool guy
|
||||
// False example: Write to lichess.contact@gmail.com, @1
|
||||
private val atUsernameRegex = """\B@(?>([a-zA-Z_-][\w-]{1,19}))(?U)(?![\w-])""".r
|
||||
|
||||
private val urlRegex = """(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s<>]+|\(([^\s<>]+|(\([^\s<>]+\)))*\))+(?:\(([^\s<>]+|(\([^\s<>]+\)))*\)|[^\s`!\[\]{};:'".,<>?«»“”‘’]))""".r
|
||||
|
||||
/**
|
||||
|
@ -61,7 +54,7 @@ trait StringHelper { self: NumberHelper =>
|
|||
* @param text The text to regex match
|
||||
* @return The text as a HTML hyperlink
|
||||
*/
|
||||
def addUserProfileLinks(text: String) = atUsernameRegex.replaceAllIn(text, m => {
|
||||
def addUserProfileLinks(text: String) = User.atUsernameRegex.replaceAllIn(text, m => {
|
||||
var user = m group 1
|
||||
var url = s"$netDomain/@/$user"
|
||||
|
||||
|
|
|
@ -111,6 +111,16 @@ withLangAnnotations: Boolean = true)(body: Html)(implicit ctx: Context)
|
|||
<div class="initiating">@base.spinner()</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="site_notifications fright">
|
||||
<a id="site_notifications_tag" class="toggle link data-count" data-count="@ctx.nbNotifications">
|
||||
<span class="hint--bottom-left" data-hint="Notifications">
|
||||
<span data-icon="u"></span>
|
||||
</span>
|
||||
</a>
|
||||
<div id="notifications_app" class="links dropdown">
|
||||
<div class="initiating">@base.spinner()</div>
|
||||
</div>
|
||||
</div>
|
||||
}.getOrElse {
|
||||
<a href="@routes.Auth.login" class="signin button fright text">@trans.signIn()</a>
|
||||
}
|
||||
|
@ -154,7 +164,7 @@ withLangAnnotations: Boolean = true)(body: Html)(implicit ctx: Context)
|
|||
<div class="content @ctx.is3d.fold("is3d", "is2d")">
|
||||
<div id="site_header">
|
||||
@ctx.me.map { me =>
|
||||
<div id="notifications">@notifications(me)</div>
|
||||
<div id="notifications">@renderNotifications(me)</div>
|
||||
}.getOrElse {
|
||||
<div id="notifications"></div>
|
||||
}
|
||||
|
|
12
app/views/notifications/layout.scala.html
Normal file
12
app/views/notifications/layout.scala.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
@(title: String, moreCss: Html = Html(""), moreJs: Html = Html(""))(body: Html)(implicit ctx: Context)
|
||||
|
||||
@evenMoreJs = {
|
||||
@moreJs
|
||||
}
|
||||
|
||||
@base.layout(
|
||||
title = title,
|
||||
moreJs = evenMoreJs,
|
||||
moreCss = moreCss) {
|
||||
@body
|
||||
}
|
31
app/views/notifications/view.scala.html
Normal file
31
app/views/notifications/view.scala.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
@(notifs: List[lila.notify.Notification])(implicit ctx: Context)
|
||||
|
||||
@title = @{"Notifications"}
|
||||
|
||||
@moreCss = {
|
||||
@cssTag("siteNotifications.css")
|
||||
}
|
||||
|
||||
@layout(title = title, moreCss = moreCss) {
|
||||
@notifs.map { notif =>
|
||||
<div class="site_notification">
|
||||
@notif.content match {
|
||||
case lila.notify.MentionedInThread(mentionedBy, category, topic, topicId, postId) => {
|
||||
<span>
|
||||
<a href="/@@/@mentionedBy"> @mentionedBy </a>
|
||||
<span>mentioned you in the</span>
|
||||
<a href="/forum/redirect/post/@postId">@topic</a>
|
||||
<span>forum thread</span>
|
||||
</span>
|
||||
}
|
||||
case lila.notify.InvitedToStudy(invitedBy, studyName, studyId) => {
|
||||
<a href="/@@/@invitedBy">@invitedBy </a>
|
||||
<span>invited you to their</span>
|
||||
<a href="/study/@studyId"> @studyName </a>
|
||||
<span>study</span>
|
||||
}
|
||||
}
|
||||
<div>@momentFromNow(notif.createdAt)</div>
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -596,6 +596,9 @@ insight {
|
|||
user_cache = insight_user_cache
|
||||
}
|
||||
}
|
||||
notify {
|
||||
collection.notify = notify
|
||||
}
|
||||
simulation {
|
||||
enabled = false
|
||||
players = 300
|
||||
|
|
|
@ -265,6 +265,11 @@ POST /challenge/$id<\w{8}>/cancel controllers.Challenge.cancel(id: String)
|
|||
GET /challenge/$id<\w{8}>/socket/v:apiVersion controllers.Challenge.websocket(id: String, apiVersion: Int)
|
||||
POST /challenge/rematch-of/$id<\w{8}> controllers.Challenge.rematchOf(id: String)
|
||||
|
||||
# Notification
|
||||
GET /notif controllers.Notif.recent
|
||||
POST /notif controllers.Notif.markAllAsRead
|
||||
GET /notifications controllers.Notif.notificationsPage
|
||||
|
||||
# Video
|
||||
GET /video controllers.Video.index
|
||||
GET /video/tags controllers.Video.tags
|
||||
|
|
|
@ -11,13 +11,14 @@ case class PageData(
|
|||
teamNbRequests: Int,
|
||||
nbMessages: Int,
|
||||
nbChallenges: Int,
|
||||
nbNotifications: Int,
|
||||
pref: Pref,
|
||||
blindMode: Boolean,
|
||||
hasFingerprint: Boolean)
|
||||
|
||||
object PageData {
|
||||
|
||||
val default = PageData(Nil, 0, 0, 0, Pref.default, false, false)
|
||||
val default = PageData(Nil, 0, 0, 0, 0, Pref.default, false, false)
|
||||
|
||||
def anon(blindMode: Boolean) = default.copy(blindMode = blindMode)
|
||||
}
|
||||
|
@ -31,6 +32,7 @@ sealed trait Context extends lila.user.UserContextWrapper {
|
|||
def teamNbRequests = pageData.teamNbRequests
|
||||
def nbMessages = pageData.nbMessages
|
||||
def nbChallenges = pageData.nbChallenges
|
||||
def nbNotifications = pageData.nbNotifications
|
||||
def pref = pageData.pref
|
||||
def blindMode = pageData.blindMode
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 14039ec16a3d5e0515e96f6a03786028276d53a3
|
||||
Subproject commit 2f202a1e1ea6eaad55efd81da77fd1a29f084736
|
|
@ -9,6 +9,18 @@ object Future {
|
|||
}
|
||||
}
|
||||
|
||||
def filter[A](list: List[A])(f: A => Fu[Boolean]): Fu[List[A]] = {
|
||||
list.map {
|
||||
element => f(element) map (_ option element)
|
||||
}.sequenceFu.map(_.flatten)
|
||||
}
|
||||
|
||||
def filterNot[A](list: List[A])(f: A => Fu[Boolean]): Fu[List[A]] = {
|
||||
list.map {
|
||||
element => !f(element) map (_ option element)
|
||||
}.sequenceFu.map(_.flatten)
|
||||
}
|
||||
|
||||
def traverseSequentially[A, B](list: List[A])(f: A => Fu[B]): Fu[List[B]] =
|
||||
list match {
|
||||
case h :: t => f(h).flatMap { r =>
|
||||
|
@ -20,6 +32,6 @@ object Future {
|
|||
def applySequentially[A](list: List[A])(f: A => Funit): Funit =
|
||||
list match {
|
||||
case h :: t => f(h) >> applySequentially(t)(f)
|
||||
case Nil => funit
|
||||
case Nil => funit
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
package lila.common
|
||||
|
||||
import play.api.libs.json.{Json, OWrites}
|
||||
import lila.common.PimpedJson._
|
||||
|
||||
case class LightUser(id: String, name: String, title: Option[String]) {
|
||||
|
||||
def titleName = title.fold(name)(_ + " " + name)
|
||||
|
@ -8,5 +11,9 @@ case class LightUser(id: String, name: String, title: Option[String]) {
|
|||
|
||||
object LightUser {
|
||||
|
||||
implicit val lightUserWrites = OWrites[LightUser] { u =>
|
||||
Json.obj("id" -> u.id, "name" -> u.name, "title" -> u.title).noNull
|
||||
}
|
||||
|
||||
type Getter = String => Option[LightUser]
|
||||
}
|
||||
|
|
|
@ -7,6 +7,9 @@ import lila.common.DetectLanguage
|
|||
import lila.common.PimpedConfig._
|
||||
import lila.hub.actorApi.forum._
|
||||
import lila.mod.ModlogApi
|
||||
import lila.notify.NotifyApi
|
||||
import lila.relation.RelationApi
|
||||
|
||||
|
||||
final class Env(
|
||||
config: Config,
|
||||
|
@ -15,6 +18,8 @@ final class Env(
|
|||
shutup: ActorSelection,
|
||||
hub: lila.hub.Env,
|
||||
detectLanguage: DetectLanguage,
|
||||
notifyApi : NotifyApi,
|
||||
relationApi: RelationApi,
|
||||
system: ActorSystem) {
|
||||
|
||||
private val settings = new {
|
||||
|
@ -33,6 +38,8 @@ final class Env(
|
|||
|
||||
lazy val categApi = new CategApi(env = this)
|
||||
|
||||
lazy val mentionNotifier = new MentionNotifier(notifyApi = notifyApi, relationApi = relationApi)
|
||||
|
||||
lazy val topicApi = new TopicApi(
|
||||
env = this,
|
||||
indexer = hub.actor.forumSearch,
|
||||
|
@ -40,7 +47,8 @@ final class Env(
|
|||
modLog = modLog,
|
||||
shutup = shutup,
|
||||
timeline = hub.actor.timeline,
|
||||
detectLanguage = detectLanguage)
|
||||
detectLanguage = detectLanguage,
|
||||
mentionNotifier = mentionNotifier)
|
||||
|
||||
lazy val postApi = new PostApi(
|
||||
env = this,
|
||||
|
@ -49,7 +57,8 @@ final class Env(
|
|||
modLog = modLog,
|
||||
shutup = shutup,
|
||||
timeline = hub.actor.timeline,
|
||||
detectLanguage = detectLanguage)
|
||||
detectLanguage = detectLanguage,
|
||||
mentionNotifier = mentionNotifier)
|
||||
|
||||
lazy val forms = new DataForm(hub.actor.captcher)
|
||||
lazy val recent = new Recent(postApi, RecentTtl, RecentNb, PublicCategIds)
|
||||
|
@ -76,5 +85,7 @@ object Env {
|
|||
shutup = lila.hub.Env.current.actor.shutup,
|
||||
hub = lila.hub.Env.current,
|
||||
detectLanguage = DetectLanguage(lila.common.PlayApp loadConfig "detectlanguage"),
|
||||
notifyApi = lila.notify.Env.current.notifyApi,
|
||||
relationApi = lila.relation.Env.current.api,
|
||||
system = lila.common.PlayApp.system)
|
||||
}
|
||||
|
|
59
modules/forum/src/main/MentionNotifier.scala
Normal file
59
modules/forum/src/main/MentionNotifier.scala
Normal file
|
@ -0,0 +1,59 @@
|
|||
package lila.forum
|
||||
|
||||
import lila.notify.{Notification, MentionedInThread}
|
||||
import lila.notify.NotifyApi
|
||||
import lila.relation.RelationApi
|
||||
import lila.user.{UserRepo, User}
|
||||
import org.joda.time.DateTime
|
||||
import lila.common.Future
|
||||
|
||||
/**
|
||||
* Notifier to inform users if they have been mentioned in a post
|
||||
*
|
||||
* @param notifyApi Api for sending inbox messages
|
||||
*/
|
||||
final class MentionNotifier(notifyApi: NotifyApi, relationApi: RelationApi) {
|
||||
|
||||
def notifyMentionedUsers(post: Post, topic: Topic): Unit = {
|
||||
post.userId foreach { author =>
|
||||
val mentionedUsers = extractMentionedUsers(post)
|
||||
val mentionedBy = MentionedInThread.MentionedBy(author)
|
||||
|
||||
for {
|
||||
validUsers <- filterValidUsers(mentionedUsers, author)
|
||||
notifications = validUsers.map(createMentionNotification(post, topic, _, mentionedBy))
|
||||
} yield notifyApi.addNotifications(notifications)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the database to make sure that the users mentioned exist, and removes any users that do not exist
|
||||
* or block the mentioner from the returned list.
|
||||
*/
|
||||
private def filterValidUsers(users: Set[String], mentionedBy: String) : Fu[List[Notification.Notifies]] = {
|
||||
for {
|
||||
validUsers <- UserRepo.existingUsernameIds(users take 20).map(_.take(5))
|
||||
validUnblockedUsers <- filterNotBlockedByUsers(validUsers, mentionedBy)
|
||||
validNotifies = validUnblockedUsers.map(Notification.Notifies.apply)
|
||||
} yield validNotifies
|
||||
}
|
||||
|
||||
private def filterNotBlockedByUsers(usersMentioned: List[String], mentionedBy: String) : Fu[List[String]]= {
|
||||
Future.filterNot(usersMentioned)(mentioned => relationApi.fetchBlocks(mentioned, mentionedBy))
|
||||
}
|
||||
|
||||
private def createMentionNotification(post: Post, topic: Topic, mentionedUser: Notification.Notifies, mentionedBy: MentionedInThread.MentionedBy): Notification = {
|
||||
val notificationContent = MentionedInThread(
|
||||
mentionedBy,
|
||||
MentionedInThread.Topic(topic.name),
|
||||
MentionedInThread.TopicId(topic.id),
|
||||
MentionedInThread.Category(post.categId),
|
||||
MentionedInThread.PostId(post.id))
|
||||
|
||||
Notification(mentionedUser, notificationContent, Notification.NotificationRead(false), DateTime.now)
|
||||
}
|
||||
|
||||
private def extractMentionedUsers(post: Post): Set[String] = {
|
||||
User.atUsernameRegex.findAllMatchIn(post.text).map(_.matched.tail).toSet
|
||||
}
|
||||
}
|
|
@ -19,7 +19,8 @@ final class PostApi(
|
|||
modLog: ModlogApi,
|
||||
shutup: ActorSelection,
|
||||
timeline: ActorSelection,
|
||||
detectLanguage: lila.common.DetectLanguage) {
|
||||
detectLanguage: lila.common.DetectLanguage,
|
||||
mentionNotifier: MentionNotifier) {
|
||||
|
||||
import BSONHandlers._
|
||||
|
||||
|
@ -64,7 +65,7 @@ final class PostApi(
|
|||
)
|
||||
}
|
||||
lila.mon.forum.post.create()
|
||||
} inject post
|
||||
} >>- mentionNotifier.notifyMentionedUsers(post, topic) inject post
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,8 @@ private[forum] final class TopicApi(
|
|||
modLog: lila.mod.ModlogApi,
|
||||
shutup: ActorSelection,
|
||||
timeline: ActorSelection,
|
||||
detectLanguage: lila.common.DetectLanguage) {
|
||||
detectLanguage: lila.common.DetectLanguage,
|
||||
mentionNotifier: MentionNotifier) {
|
||||
|
||||
import BSONHandlers._
|
||||
|
||||
|
@ -74,7 +75,7 @@ private[forum] final class TopicApi(
|
|||
)
|
||||
}
|
||||
lila.mon.forum.post.create()
|
||||
} inject topic
|
||||
} >>- mentionNotifier.notifyMentionedUsers(post, topic) inject topic
|
||||
}
|
||||
|
||||
def paginator(categ: Categ, page: Int, troll: Boolean): Fu[Paginator[TopicView]] = Paginator(
|
||||
|
|
98
modules/notify/src/main/BSONHandlers.scala
Normal file
98
modules/notify/src/main/BSONHandlers.scala
Normal file
|
@ -0,0 +1,98 @@
|
|||
package lila.notify
|
||||
|
||||
import lila.db.{dsl, BSON}
|
||||
import lila.db.BSON.{Reader, Writer}
|
||||
import lila.db.dsl._
|
||||
import lila.notify.InvitedToStudy.{StudyName, InvitedBy, StudyId}
|
||||
import lila.notify.MentionedInThread._
|
||||
import lila.notify.Notification._
|
||||
import reactivemongo.bson.{BSONString, BSONHandler, BSONDocument}
|
||||
|
||||
private object BSONHandlers {
|
||||
|
||||
implicit val NotifiesHandler = stringAnyValHandler[Notifies](_.value, Notifies.apply)
|
||||
|
||||
implicit val MentionByHandler = stringAnyValHandler[MentionedBy](_.value, MentionedBy.apply)
|
||||
implicit val TopicHandler = stringAnyValHandler[Topic](_.value, Topic.apply)
|
||||
implicit val TopicIdHandler = stringAnyValHandler[TopicId](_.value, TopicId.apply)
|
||||
implicit val CategoryHandler = stringAnyValHandler[Category](_.value, Category.apply)
|
||||
implicit val PostIdHandler = stringAnyValHandler[PostId](_.value, PostId.apply)
|
||||
|
||||
implicit val InvitedToStudyByHandler = stringAnyValHandler[InvitedBy](_.value, InvitedBy.apply)
|
||||
implicit val StudyNameHandler = stringAnyValHandler[StudyName](_.value, StudyName.apply)
|
||||
implicit val StudyIdHandler = stringAnyValHandler[StudyId](_.value, StudyId.apply)
|
||||
|
||||
implicit val NotificationContentHandler = new BSON[NotificationContent] {
|
||||
|
||||
private def writeNotificationType(notificationContent: NotificationContent) = {
|
||||
notificationContent match {
|
||||
case MentionedInThread(_, _, _, _, _) => "mention"
|
||||
case InvitedToStudy(_,_,_) => "invitedStudy"
|
||||
}
|
||||
}
|
||||
|
||||
private def writeNotificationContent(notificationContent: NotificationContent) = {
|
||||
notificationContent match {
|
||||
case MentionedInThread(mentionedBy, topic, topicId, category, postId) =>
|
||||
$doc("type" -> writeNotificationType(notificationContent), "mentionedBy" -> mentionedBy,
|
||||
"topic" -> topic, "topicId" -> topicId, "category" -> category, "postId" -> postId)
|
||||
case InvitedToStudy(invitedBy, studyName, studyId) =>
|
||||
$doc("type" -> writeNotificationType(notificationContent),
|
||||
"invitedBy" -> invitedBy,
|
||||
"studyName" -> studyName,
|
||||
"studyId" -> studyId)
|
||||
}
|
||||
}
|
||||
|
||||
private def readMentionedNotification(reader: Reader): MentionedInThread = {
|
||||
val mentionedBy = reader.get[MentionedBy]("mentionedBy")
|
||||
val topic = reader.get[Topic]("topic")
|
||||
val topicId = reader.get[TopicId]("topicId")
|
||||
val category = reader.get[Category]("category")
|
||||
val postNumber = reader.get[PostId]("postId")
|
||||
|
||||
MentionedInThread(mentionedBy, topic, topicId, category, postNumber)
|
||||
}
|
||||
|
||||
private def readInvitedStudyNotification(reader: Reader): NotificationContent = {
|
||||
val invitedBy = reader.get[InvitedBy]("invitedBy")
|
||||
val studyName = reader.get[StudyName]("studyName")
|
||||
val studyId = reader.get[StudyId]("studyId")
|
||||
|
||||
InvitedToStudy(invitedBy, studyName, studyId)
|
||||
}
|
||||
|
||||
override def reads(reader: Reader): NotificationContent = {
|
||||
val notificationType = reader.str("type")
|
||||
|
||||
notificationType match {
|
||||
case "mention" => readMentionedNotification(reader)
|
||||
case "invitedStudy" => readInvitedStudyNotification(reader)
|
||||
}
|
||||
}
|
||||
|
||||
override def writes(writer: Writer, n: NotificationContent): dsl.Bdoc = {
|
||||
writeNotificationContent(n)
|
||||
}
|
||||
}
|
||||
|
||||
implicit val NotificationBSONHandler = new BSON[Notification] {
|
||||
override def reads(reader: Reader): Notification = {
|
||||
val id = reader.str("_id")
|
||||
val created = reader.date("created")
|
||||
val hasRead = NotificationRead(reader.bool("read"))
|
||||
val notifies = Notifies(reader.str("notifies"))
|
||||
val content = reader.get[NotificationContent]("content")
|
||||
|
||||
Notification(id, notifies, content, hasRead, created)
|
||||
}
|
||||
|
||||
override def writes(writer: Writer, n: Notification): dsl.Bdoc = $doc(
|
||||
"_id" -> n.id,
|
||||
"created" -> n.createdAt,
|
||||
"read" -> n.read.value,
|
||||
"notifies" -> n.notifies,
|
||||
"content" -> n.content
|
||||
)
|
||||
}
|
||||
}
|
26
modules/notify/src/main/Env.scala
Normal file
26
modules/notify/src/main/Env.scala
Normal file
|
@ -0,0 +1,26 @@
|
|||
package lila.notify
|
||||
|
||||
import akka.actor.ActorSystem
|
||||
import com.typesafe.config.Config
|
||||
|
||||
final class Env(db: lila.db.Env, config: Config, system: ActorSystem) {
|
||||
|
||||
val settings = new {
|
||||
val collectionNotifications = config getString "collection.notify"
|
||||
|
||||
}
|
||||
|
||||
import settings._
|
||||
|
||||
private lazy val repo = new NotificationRepo(coll = db(collectionNotifications))
|
||||
|
||||
lazy val notifyApi = new NotifyApi(bus = system.lilaBus, repo = repo)
|
||||
}
|
||||
|
||||
object Env {
|
||||
|
||||
lazy val current = "notify" boot new Env(db = lila.db.Env.current,
|
||||
config = lila.common.PlayApp loadConfig "notify",
|
||||
system = lila.common.PlayApp.system)
|
||||
|
||||
}
|
47
modules/notify/src/main/JsonHandlers.scala
Normal file
47
modules/notify/src/main/JsonHandlers.scala
Normal file
|
@ -0,0 +1,47 @@
|
|||
package lila.notify
|
||||
|
||||
import play.api.libs.json.{JsValue, Json, Writes}
|
||||
import lila.common.LightUser
|
||||
import lila.user.User
|
||||
|
||||
|
||||
object JSONHandlers {
|
||||
|
||||
implicit val notificationWrites : Writes[Notification] = new Writes[Notification] {
|
||||
def writeBody(notificationContent: NotificationContent) = {
|
||||
notificationContent match {
|
||||
case MentionedInThread(mentionedBy, topic, _, category, postId) =>
|
||||
Json.obj("mentionedBy" -> lila.user.Env.current.lightUser(mentionedBy.value),
|
||||
"topic" -> topic.value, "category" -> category.value,
|
||||
"postId" -> postId.value)
|
||||
case InvitedToStudy(invitedBy, studyName, studyId) =>
|
||||
Json.obj("invitedBy" -> lila.user.Env.current.lightUser(invitedBy.value),
|
||||
"studyName" -> studyName.value,
|
||||
"studyId" -> studyId.value)
|
||||
}
|
||||
}
|
||||
|
||||
def writes(notification: Notification) = {
|
||||
val body = notification.content
|
||||
|
||||
val notificationType = body match {
|
||||
case MentionedInThread(_,_, _, _, _) => "mentioned"
|
||||
case InvitedToStudy(_,_,_) => "invitedStudy"
|
||||
}
|
||||
|
||||
Json.obj("content" -> writeBody(body),
|
||||
"type" -> notificationType,
|
||||
"read" -> notification.read.value,
|
||||
"date" -> notification.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
implicit val newNotificationWrites: Writes[NewNotification] = new Writes[NewNotification] {
|
||||
|
||||
def writes(newNotification: NewNotification) = {
|
||||
Json.obj("notification" -> newNotification.notification, "unread" -> newNotification.unreadNotifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
48
modules/notify/src/main/Notification.scala
Normal file
48
modules/notify/src/main/Notification.scala
Normal file
|
@ -0,0 +1,48 @@
|
|||
package lila.notify
|
||||
|
||||
import lila.notify.MentionedInThread.PostId
|
||||
import org.joda.time.DateTime
|
||||
import ornicar.scalalib.Random
|
||||
|
||||
case class NewNotification(notification: Notification, unreadNotifications: Int)
|
||||
|
||||
case class Notification(_id: String, notifies: Notification.Notifies, content: NotificationContent, read: Notification.NotificationRead, createdAt: DateTime) {
|
||||
def id = _id
|
||||
}
|
||||
|
||||
object Notification {
|
||||
|
||||
case class Notifies(value: String) extends AnyVal with StringValue
|
||||
case class NotificationRead(value: Boolean)
|
||||
|
||||
def apply(notifies: Notification.Notifies, content: NotificationContent, read: NotificationRead, createdAt: DateTime) : Notification = {
|
||||
val idSize = 8
|
||||
val id = Random nextStringUppercase idSize
|
||||
new Notification(id, notifies, content, read, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
sealed trait NotificationContent
|
||||
case class MentionedInThread(mentionedBy: MentionedInThread.MentionedBy,
|
||||
topic: MentionedInThread.Topic,
|
||||
topidId: MentionedInThread.TopicId,
|
||||
category: MentionedInThread.Category,
|
||||
postId: PostId) extends NotificationContent
|
||||
|
||||
object MentionedInThread {
|
||||
case class MentionedBy(value: String) extends AnyVal with StringValue
|
||||
case class Topic(value: String) extends AnyVal with StringValue
|
||||
case class TopicId(value: String) extends AnyVal with StringValue
|
||||
case class Category(value: String) extends AnyVal with StringValue
|
||||
case class PostId(value: String) extends AnyVal with StringValue
|
||||
}
|
||||
|
||||
case class InvitedToStudy(invitedBy: InvitedToStudy.InvitedBy,
|
||||
studyName: InvitedToStudy.StudyName,
|
||||
studyId: InvitedToStudy.StudyId) extends NotificationContent
|
||||
|
||||
object InvitedToStudy {
|
||||
case class InvitedBy(value: String) extends AnyVal with StringValue
|
||||
case class StudyName(value: String) extends AnyVal with StringValue
|
||||
case class StudyId(value: String) extends AnyVal with StringValue
|
||||
}
|
52
modules/notify/src/main/NotificationRepo.scala
Normal file
52
modules/notify/src/main/NotificationRepo.scala
Normal file
|
@ -0,0 +1,52 @@
|
|||
package lila.notify
|
||||
|
||||
import lila.db.dsl._
|
||||
import org.joda.time.DateTime
|
||||
|
||||
private final class NotificationRepo(val coll: Coll) {
|
||||
|
||||
import BSONHandlers._
|
||||
|
||||
def insert(notification: Notification) = {
|
||||
coll.insert(notification).void
|
||||
}
|
||||
|
||||
def markAllRead(notifies: Notification.Notifies) : Funit = {
|
||||
coll.update(unreadOnlyQuery(notifies), $set("read" -> true), multi=true).void
|
||||
}
|
||||
|
||||
def unreadNotificationsCount(userId: Notification.Notifies) : Fu[Int] = {
|
||||
coll.count(unreadOnlyQuery(userId).some)
|
||||
}
|
||||
|
||||
def hasRecentUnseenStudyInvitation(userId: Notification.Notifies, studyId: InvitedToStudy.StudyId) : Fu[Boolean] = {
|
||||
val query = $doc(
|
||||
"notifies" -> userId,
|
||||
"read" -> false,
|
||||
"content.type" -> "invitedStudy",
|
||||
"content.studyId" -> studyId,
|
||||
"created" -> $doc("$gt" ->DateTime.now.minusDays(7))
|
||||
)
|
||||
|
||||
coll.exists(query)
|
||||
}
|
||||
|
||||
def hasRecentUnseenNotifcationsInThread(userId: Notification.Notifies, topicId: MentionedInThread.TopicId) : Fu[Boolean] = {
|
||||
val query = $doc(
|
||||
"notifies" -> userId,
|
||||
"read" -> false,
|
||||
"content.type" -> "mention",
|
||||
"content.topicId" -> topicId,
|
||||
"created" -> $doc("$gt" ->DateTime.now.minusDays(7))
|
||||
)
|
||||
|
||||
coll.exists(query)
|
||||
}
|
||||
|
||||
val recentSort = $sort desc "created"
|
||||
|
||||
def userNotificationsQuery(userId: Notification.Notifies) = $doc("notifies" -> userId)
|
||||
|
||||
private def unreadOnlyQuery(userId:Notification.Notifies) = $doc("notifies" -> userId, "read" -> false)
|
||||
|
||||
}
|
73
modules/notify/src/main/NotifyApi.scala
Normal file
73
modules/notify/src/main/NotifyApi.scala
Normal file
|
@ -0,0 +1,73 @@
|
|||
package lila.notify
|
||||
|
||||
import scala.concurrent.Future
|
||||
import lila.common.paginator.Paginator
|
||||
import lila.db.dsl._
|
||||
import lila.db.paginator.Adapter
|
||||
import lila.hub.actorApi.SendTo
|
||||
import lila.memo.AsyncCache
|
||||
|
||||
final class NotifyApi(bus: lila.common.Bus, repo: NotificationRepo) {
|
||||
|
||||
import BSONHandlers.NotificationBSONHandler
|
||||
import JSONHandlers._
|
||||
|
||||
def getNotifications(userId: Notification.Notifies, page: Int, perPage: Int) : Fu[Paginator[Notification]] = Paginator(
|
||||
adapter = new Adapter(
|
||||
collection = repo.coll,
|
||||
selector = repo.userNotificationsQuery(userId),
|
||||
projection = $empty,
|
||||
sort = repo.recentSort),
|
||||
currentPage = page,
|
||||
maxPerPage = perPage
|
||||
)
|
||||
|
||||
def markAllRead(userId: Notification.Notifies) = repo.markAllRead(userId)
|
||||
|
||||
def getUnseenNotificationCount = AsyncCache(repo.unreadNotificationsCount, maxCapacity = 20000)
|
||||
|
||||
def addNotification(notification: Notification): Funit = {
|
||||
|
||||
// Add to database and then notify any connected clients of the new notification
|
||||
insertOrDiscardNotification(notification) map {
|
||||
_ ?? {
|
||||
notif =>
|
||||
getUnseenNotificationCount(notif.notifies).
|
||||
map(NewNotification(notif, _)).
|
||||
foreach(notifyConnectedClients)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def addNotifications(notifications: List[Notification]) : Funit = {
|
||||
notifications.map(addNotification).sequenceFu.void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts notification into the repository.
|
||||
*
|
||||
* If the user already has an unread notification on the topic, discard it.
|
||||
*
|
||||
* If the user does not already have an unread notification on the topic, returns it unmodified.
|
||||
*/
|
||||
private def insertOrDiscardNotification(notification: Notification): Fu[Option[Notification]] = {
|
||||
|
||||
notification.content match {
|
||||
case MentionedInThread(_, _, topicId, _, _) => {
|
||||
repo.hasRecentUnseenNotifcationsInThread(notification.notifies, topicId).flatMap(alreadyNotified =>
|
||||
if (alreadyNotified) fuccess(None) else repo.insert(notification).inject(notification.some)
|
||||
)
|
||||
}
|
||||
case InvitedToStudy(invitedBy, _, studyId) => {
|
||||
repo.hasRecentUnseenStudyInvitation(notification.notifies, studyId).flatMap(alreadyNotified =>
|
||||
if (alreadyNotified) fuccess(None) else repo.insert(notification).inject(notification.some))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def notifyConnectedClients(newNotification: NewNotification) : Unit = {
|
||||
val notificationsEventKey = "new_notification"
|
||||
val notificationEvent = SendTo(newNotification.notification.notifies.value, notificationsEventKey, newNotification)
|
||||
bus.publish(notificationEvent, 'users)
|
||||
}
|
||||
}
|
3
modules/notify/src/main/package.scala
Normal file
3
modules/notify/src/main/package.scala
Normal file
|
@ -0,0 +1,3 @@
|
|||
package lila
|
||||
|
||||
package object notify extends PackageObject with WithPlay
|
|
@ -77,8 +77,9 @@ final class Env(
|
|||
chapterMaker = chapterMaker,
|
||||
studyMaker = studyMaker,
|
||||
notifier = new StudyNotifier(
|
||||
messageActor = hub.actor.messenger,
|
||||
netBaseUrl = NetBaseUrl),
|
||||
notifyApi = lila.notify.Env.current.notifyApi,
|
||||
relationApi = lila.relation.Env.current.api
|
||||
),
|
||||
lightUser = getLightUser,
|
||||
chat = hub.actor.chat,
|
||||
timeline = hub.actor.timeline,
|
||||
|
|
|
@ -66,10 +66,6 @@ final class JsonView(
|
|||
)
|
||||
}
|
||||
|
||||
private implicit val lightUserWrites = OWrites[LightUser] { u =>
|
||||
Json.obj("id" -> u.id, "name" -> u.name, "title" -> u.title).noNull
|
||||
}
|
||||
|
||||
private[study] implicit val memberRoleWrites = Writes[StudyMember.Role] { r =>
|
||||
JsString(r.id)
|
||||
}
|
||||
|
|
|
@ -5,23 +5,27 @@ import akka.pattern.ask
|
|||
|
||||
import lila.hub.actorApi.HasUserId
|
||||
import lila.hub.actorApi.message.LichessThread
|
||||
import lila.notify.InvitedToStudy.InvitedBy
|
||||
import lila.notify.{InvitedToStudy, NotifyApi, Notification}
|
||||
import lila.relation.RelationApi
|
||||
import makeTimeout.short
|
||||
import org.joda.time.DateTime
|
||||
|
||||
private final class StudyNotifier(
|
||||
messageActor: ActorSelection,
|
||||
netBaseUrl: String) {
|
||||
notifyApi: NotifyApi,
|
||||
relationApi: RelationApi) {
|
||||
|
||||
def apply(study: Study, invited: lila.user.User, socket: ActorRef) =
|
||||
socket ? HasUserId(invited.id) mapTo manifest[Boolean] map { isPresent =>
|
||||
study.owner.ifFalse(isPresent) foreach { owner =>
|
||||
if (!isPresent) messageActor ! LichessThread(
|
||||
from = owner.id,
|
||||
to = invited.id,
|
||||
subject = s"Would you like to join my study?",
|
||||
message = s"I invited you to this study: ${studyUrl(study)}",
|
||||
notification = true)
|
||||
relationApi.fetchBlocks(invited.id, study.ownerId).flatMap {
|
||||
blocked =>
|
||||
socket ? HasUserId(invited.id) mapTo manifest[Boolean] map { isPresent =>
|
||||
study.owner.ifFalse(isPresent) foreach { owner =>
|
||||
if (!blocked && !isPresent) {
|
||||
val notificationContent = InvitedToStudy(InvitedToStudy.InvitedBy(owner.id), InvitedToStudy.StudyName(study.name), InvitedToStudy.StudyId(study.id))
|
||||
val notification = Notification(Notification.Notifies(invited.id), notificationContent, Notification.NotificationRead(false), DateTime.now())
|
||||
notifyApi.addNotification(notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def studyUrl(study: Study) = s"$netBaseUrl/study/${study.id}"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -107,6 +107,13 @@ object User {
|
|||
import lila.db.BSON.BSONJodaDateTimeHandler
|
||||
implicit def playTimeHandler = reactivemongo.bson.Macros.handler[PlayTime]
|
||||
|
||||
// Matches a lichess username with a '@' prefix only if the next char isn't a digit,
|
||||
// if it isn't after a word character (that'd be an email) and fits constraints in
|
||||
// https://github.com/ornicar/lila/blob/master/modules/security/src/main/DataForm.scala#L34-L44
|
||||
// Example: everyone says @ornicar is a pretty cool guy
|
||||
// False example: Write to lichess.contact@gmail.com, @1
|
||||
val atUsernameRegex = """\B@(?>([a-zA-Z_-][\w-]{1,19}))(?U)(?![\w-])""".r
|
||||
|
||||
def normalize(username: String) = username.toLowerCase
|
||||
|
||||
val titles = Seq(
|
||||
|
|
|
@ -230,6 +230,15 @@ object UserRepo {
|
|||
def nameExists(username: String): Fu[Boolean] = idExists(normalize(username))
|
||||
def idExists(id: String): Fu[Boolean] = coll exists $id(id)
|
||||
|
||||
/**
|
||||
* Filters out invalid usernames and returns the IDs for those usernames
|
||||
*
|
||||
* @param usernames Usernames to filter out the non-existent usernames from, and return the IDs for
|
||||
* @return A list of IDs for the usernames that were given that were valid
|
||||
*/
|
||||
def existingUsernameIds(usernames: Set[String]): Fu[List[String]] =
|
||||
coll.primitive[String]($inIds(usernames.map(normalize)), "_id")
|
||||
|
||||
def engineIds: Fu[Set[String]] =
|
||||
coll.distinct("_id", $doc("engine" -> true).some) map lila.db.BSON.asStringSet
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ object ApplicationBuild extends Build {
|
|||
|
||||
lazy val modules = Seq(
|
||||
chess, common, db, rating, user, security, wiki, hub, socket,
|
||||
message, notification, i18n, game, bookmark, search,
|
||||
message, challengeNotifications, notifications, i18n, game, bookmark, search,
|
||||
gameSearch, timeline, forum, forumSearch, team, teamSearch,
|
||||
analyse, mod, site, round, lobby, setup,
|
||||
importer, tournament, simul, relation, report, pref, // simulation,
|
||||
|
@ -238,7 +238,7 @@ object ApplicationBuild extends Build {
|
|||
libraryDependencies ++= provided(play.api, RM)
|
||||
)
|
||||
|
||||
lazy val study = project("study", Seq(common, db, hub, socket, game, round, importer)).settings(
|
||||
lazy val study = project("study", Seq(common, db, hub, socket, game, round, importer, notifications, relation)).settings(
|
||||
libraryDependencies ++= provided(play.api, RM)
|
||||
)
|
||||
|
||||
|
@ -267,7 +267,7 @@ object ApplicationBuild extends Build {
|
|||
play.api, RM, spray.caching)
|
||||
)
|
||||
|
||||
lazy val forum = project("forum", Seq(common, db, user, security, hub, mod)).settings(
|
||||
lazy val forum = project("forum", Seq(common, db, user, security, hub, mod, notifications)).settings(
|
||||
libraryDependencies ++= provided(
|
||||
play.api, RM, spray.caching)
|
||||
)
|
||||
|
@ -316,10 +316,14 @@ object ApplicationBuild extends Build {
|
|||
libraryDependencies ++= provided(play.api, RM)
|
||||
)
|
||||
|
||||
lazy val notification = project("notification", Seq(common, user, hub)).settings(
|
||||
lazy val challengeNotifications = project("notification", Seq(common, user, hub)).settings(
|
||||
libraryDependencies ++= provided(play.api)
|
||||
)
|
||||
|
||||
lazy val notifications = project("notify", Seq(common, db, user, hub, relation)).settings(
|
||||
libraryDependencies ++= provided(play.api, RM)
|
||||
)
|
||||
|
||||
lazy val site = project("site", Seq(common, socket)).settings(
|
||||
libraryDependencies ++= provided(play.api)
|
||||
)
|
||||
|
|
|
@ -35,6 +35,36 @@ lichess.challengeApp = (function() {
|
|||
};
|
||||
})();
|
||||
|
||||
lichess.siteNotifications = (function() {
|
||||
var instance;
|
||||
var $toggle = $('#site_notifications_tag');
|
||||
|
||||
var load = function() {
|
||||
var isDev = $('body').data('dev');
|
||||
|
||||
lichess.loadCss('/assets/stylesheets/siteNotifications.css');
|
||||
lichess.loadScript("/assets/compiled/lichess.notification" + (isDev ? '' : '.min') + '.js').done(function() {
|
||||
var element = document.getElementById('notifications_app');
|
||||
instance = LichessNotification(element, {
|
||||
maxNotifications: 10
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
preload: function() {
|
||||
if (!instance) {
|
||||
load();
|
||||
|
||||
$toggle.on('click', function() {
|
||||
instance.updateNotifications().then(instance.markAllReadServer);
|
||||
$toggle.attr('data-count', 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
(function() {
|
||||
|
||||
/////////////
|
||||
|
@ -152,6 +182,12 @@ lichess.challengeApp = (function() {
|
|||
}
|
||||
}
|
||||
},
|
||||
new_notification: function(e) {
|
||||
var notification = e.notification;
|
||||
|
||||
$('#site_notifications_tag').attr('data-count', e.unread || 0);
|
||||
$.sound.newPM();
|
||||
},
|
||||
mlat: function(e) {
|
||||
var $t = $('#top .server strong');
|
||||
if ($t.is(':visible')) {
|
||||
|
@ -763,6 +799,8 @@ lichess.challengeApp = (function() {
|
|||
$('#challenge_notifications_tag').one('mouseover click', lichess.challengeApp.preload);
|
||||
// $('#challenge_notifications_tag').trigger('click');
|
||||
|
||||
$('#site_notifications_tag').one('mouseover click', lichess.siteNotifications.preload);
|
||||
|
||||
$('#translation_call .close').click(function() {
|
||||
$.post($(this).data("href"));
|
||||
$(this).parent().fadeOut(500);
|
||||
|
|
|
@ -880,6 +880,11 @@ body.offline #reconnecting,
|
|||
#top .shown #challenge_notifications_tag.toggle {
|
||||
height: 29px;
|
||||
}
|
||||
|
||||
#top .shown #site_notifications_tag.toggle {
|
||||
height: 29px;
|
||||
}
|
||||
|
||||
#ham-plate {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -1160,6 +1165,7 @@ body.fpmenu #fpmenu {
|
|||
}
|
||||
#top div.message_notifications.shown .links,
|
||||
#top div.challenge_notifications.shown .links,
|
||||
#top div.site_notifications.shown .links,
|
||||
#top div.auth.shown .links {
|
||||
display: block;
|
||||
}
|
||||
|
@ -1287,6 +1293,17 @@ body.fpmenu #fpmenu {
|
|||
#message_notifications .actions a:hover {
|
||||
color: #d85000;
|
||||
}
|
||||
|
||||
#site_notifications_tag .content {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#site_notifications_tag span:before {
|
||||
font-size: 1.45em;
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
form.wide input[type="text"],
|
||||
form.wide textarea {
|
||||
padding: 0.5em;
|
||||
|
|
|
@ -295,6 +295,7 @@ body.dark .lichess_ground .replay move:not(.empty):hover,
|
|||
body.dark #challenge_app div.challenge,
|
||||
body.dark #challenge_app div.empty,
|
||||
body.dark #challenge_app div.initiating,
|
||||
body.dark #notifications_app .site_notification,
|
||||
body.dark div.lichess_overboard,
|
||||
body.dark div.analysis_menu > a,
|
||||
body.dark div.content_box,
|
||||
|
@ -340,6 +341,7 @@ body.dark #tournament_side .pairings tr:hover,
|
|||
body.dark #top .shown a.toggle,
|
||||
body.dark #top .dropdown,
|
||||
body.dark #challenge_app div.challenge,
|
||||
body.dark #notifications_app .site_notification,
|
||||
body.dark #friend_box,
|
||||
body.dark #powerTip,
|
||||
body.dark #miniGame {
|
||||
|
@ -410,6 +412,7 @@ body.dark .ui-widget-content,
|
|||
body.dark .lichess_ground .replay .moves {
|
||||
border-color: #323232;
|
||||
}
|
||||
body.dark #notifications_app .site_notification,
|
||||
body.dark .lichess_ground .negotiation a,
|
||||
body.dark group.radio label:hover,
|
||||
body.dark .button:hover,
|
||||
|
@ -482,6 +485,7 @@ body.dark div.user_show div.user-infos.scroll-shadow-hard {
|
|||
body.dark div.side_box div.game_infos .bookmark {
|
||||
background: radial-gradient(ellipse at center, rgba(38, 38, 38, 1), rgba(38, 38, 38, 1) 25%, rgba(38, 38, 38, 0) 100%);
|
||||
}
|
||||
body.dark #notifications_app .site_notification:hover,
|
||||
body.dark div.game_row:hover,
|
||||
body.dark .studies .study:hover {
|
||||
background: rgba(27, 51, 68, 0.5)!important;
|
||||
|
|
45
public/stylesheets/siteNotifications.css
Normal file
45
public/stylesheets/siteNotifications.css
Normal file
|
@ -0,0 +1,45 @@
|
|||
#notifications_app .site_notifications_box {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
#notifications_app .site_notification {
|
||||
display: flex;
|
||||
padding: 8px 12px;
|
||||
height: 36px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eee;
|
||||
position: relative;
|
||||
}
|
||||
#notifications_app .site_notification:hover {
|
||||
background: rgba(191, 231, 255, 0.2);
|
||||
}
|
||||
#notifications_app .site_notification i {
|
||||
font-size: 22px;
|
||||
opacity: 0.5;
|
||||
margin-right: 12px;
|
||||
}
|
||||
#notifications_app .site_notification:hover i {
|
||||
color: #3893E8!important;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.site_notification .content {
|
||||
flex: 0 1 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.site_notification .content span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.site_notification .content time {
|
||||
font-size: 0.9em;
|
||||
font-family: 'Roboto';
|
||||
font-weight: 300;
|
||||
}
|
||||
.site_notifications_box a.more {
|
||||
display: block;
|
||||
padding: 5px;
|
||||
color: #3893E8!important;
|
||||
text-align: center;
|
||||
}
|
2
ui/build
2
ui/build
|
@ -5,7 +5,7 @@ target=${1-dev}
|
|||
|
||||
mkdir -p public/compiled
|
||||
|
||||
for app in challenge insight editor puzzle round analyse lobby tournament tournamentSchedule opening simul perfStat; do
|
||||
for app in challenge insight editor puzzle round analyse lobby tournament tournamentSchedule opening simul perfStat notification; do
|
||||
cd ui/$app
|
||||
npm install --no-optional && gulp $target
|
||||
cd -
|
||||
|
|
54
ui/notification/gulpfile.js
Normal file
54
ui/notification/gulpfile.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
var source = require('vinyl-source-stream');
|
||||
var gulp = require('gulp');
|
||||
var gutil = require('gulp-util');
|
||||
var watchify = require('watchify');
|
||||
var browserify = require('browserify');
|
||||
var uglify = require('gulp-uglify');
|
||||
var streamify = require('gulp-streamify');
|
||||
|
||||
var sources = ['./src/main.js'];
|
||||
var destination = '../../public/compiled/';
|
||||
var onError = function(error) {
|
||||
gutil.log(gutil.colors.red(error.message));
|
||||
};
|
||||
var standalone = 'LichessNotification';
|
||||
|
||||
gulp.task('prod', function() {
|
||||
return browserify('./src/main.js', {
|
||||
standalone: standalone
|
||||
}).bundle()
|
||||
.on('error', onError)
|
||||
.pipe(source('lichess.notification.min.js'))
|
||||
.pipe(streamify(uglify()))
|
||||
.pipe(gulp.dest(destination));
|
||||
});
|
||||
|
||||
gulp.task('dev', function() {
|
||||
return browserify('./src/main.js', {
|
||||
standalone: standalone
|
||||
}).bundle()
|
||||
.on('error', onError)
|
||||
.pipe(source('lichess.notification.js'))
|
||||
.pipe(gulp.dest(destination));
|
||||
});
|
||||
|
||||
gulp.task('watch', function() {
|
||||
var opts = watchify.args;
|
||||
opts.debug = true;
|
||||
opts.standalone = standalone;
|
||||
|
||||
var bundleStream = watchify(browserify(sources, opts))
|
||||
.on('update', rebundle)
|
||||
.on('log', gutil.log);
|
||||
|
||||
function rebundle() {
|
||||
return bundleStream.bundle()
|
||||
.on('error', onError)
|
||||
.pipe(source('lichess.notification.js'))
|
||||
.pipe(gulp.dest(destination));
|
||||
}
|
||||
|
||||
return rebundle();
|
||||
});
|
||||
|
||||
gulp.task('default', ['watch']);
|
33
ui/notification/package.json
Normal file
33
ui/notification/package.json
Normal file
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "notification",
|
||||
"version": "1.0.0",
|
||||
"description": "lichess.org notifications",
|
||||
"main": "src/main.js",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ornicar/lila"
|
||||
},
|
||||
"keywords": [
|
||||
"chess",
|
||||
"lichess",
|
||||
"notification"
|
||||
],
|
||||
"author": "ornicar",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ornicar/lila/issues"
|
||||
},
|
||||
"homepage": "https://github.com/ornicar/lila",
|
||||
"devDependencies": {
|
||||
"browserify": "~13.0",
|
||||
"gulp": "~3.9",
|
||||
"gulp-streamify": "~1.0",
|
||||
"gulp-uglify": "~1.5",
|
||||
"gulp-util": "~3.0",
|
||||
"vinyl-source-stream": "~1.1",
|
||||
"watchify": "~3.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"mithril": "github:ornicar/mithril.js#v1.0.0"
|
||||
}
|
||||
}
|
33
ui/notification/src/ctrl.js
Normal file
33
ui/notification/src/ctrl.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
var xhr = require('./xhr');
|
||||
var m = require('mithril');
|
||||
|
||||
module.exports = function(env) {
|
||||
|
||||
this.data = [];
|
||||
|
||||
this.vm = {
|
||||
initiating: true,
|
||||
reloading: false
|
||||
};
|
||||
|
||||
this.setNotifications = function(data) {
|
||||
this.vm.initiating = false;
|
||||
this.vm.reloading = false;
|
||||
this.data = data;
|
||||
|
||||
console.dir(data);
|
||||
|
||||
m.redraw();
|
||||
}.bind(this);
|
||||
|
||||
this.updateNotifications = function() {
|
||||
this.vm.reloading = true;
|
||||
return xhr.load().then(this.setNotifications);
|
||||
}.bind(this);
|
||||
|
||||
this.markAllReadServer = function() {
|
||||
xhr.markAllRead();
|
||||
}.bind(this);
|
||||
|
||||
this.updateNotifications();
|
||||
};
|
20
ui/notification/src/main.js
Normal file
20
ui/notification/src/main.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
var m = require('mithril');
|
||||
var ctrl = require('./ctrl');
|
||||
|
||||
module.exports = function(element, opts) {
|
||||
|
||||
var controller = new ctrl(opts);
|
||||
|
||||
m.module(element, {
|
||||
controller: function() {
|
||||
return controller;
|
||||
},
|
||||
view: require('./view')
|
||||
});
|
||||
|
||||
return {
|
||||
setInitialNotifications: controller.setInitialNotifications,
|
||||
updateNotifications: controller.updateNotifications,
|
||||
markAllReadServer: controller.markAllReadServer
|
||||
}
|
||||
}
|
79
ui/notification/src/view.js
Normal file
79
ui/notification/src/view.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
var m = require('mithril');
|
||||
|
||||
function genericNotification(notification, url, icon, content) {
|
||||
return m('a.site_notification', {
|
||||
class: notification.type,
|
||||
href: url
|
||||
}, [
|
||||
m('i', {
|
||||
'data-icon': icon
|
||||
}),
|
||||
m('span.content', content)
|
||||
]);
|
||||
}
|
||||
|
||||
function drawTime(notification) {
|
||||
return m('time', {
|
||||
class: "moment-from-now",
|
||||
datetime: new Date(notification.date).toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
function drawMentionedNotification(notification) {
|
||||
var content = notification.content;
|
||||
var url = "/forum/redirect/post/" + content.postId
|
||||
|
||||
return genericNotification(notification, url, 'd', [
|
||||
m('span', [
|
||||
m('strong', content.mentionedBy.name),
|
||||
drawTime(notification)
|
||||
]),
|
||||
m('span', ' mentioned you in « ' + content.topic + ' ».')
|
||||
]);
|
||||
};
|
||||
|
||||
function drawStudyInviteNotification(notification) {
|
||||
var content = notification.content;
|
||||
var url = "/study/" + content.studyId;
|
||||
|
||||
return genericNotification(notification, url, '', [
|
||||
m('span', [
|
||||
m('strong', content.invitedBy.name),
|
||||
drawTime(notification)
|
||||
]),
|
||||
m('span', " invited you to « " + content.studyName + ' ».')
|
||||
]);
|
||||
};
|
||||
|
||||
function drawUnhandled(notification) {
|
||||
console.dir(notification);
|
||||
console.error(notification, "unhandled notification");
|
||||
};
|
||||
|
||||
var drawHandlers = {
|
||||
mentioned: drawMentionedNotification,
|
||||
invitedStudy: drawStudyInviteNotification
|
||||
};
|
||||
|
||||
function drawNotification(notification) {
|
||||
var handler = drawHandlers[notification.type] || drawUnhandled;
|
||||
return handler(notification);
|
||||
}
|
||||
|
||||
function recentNotifications(ctrl) {
|
||||
return ctrl.data.map(drawNotification);
|
||||
}
|
||||
|
||||
module.exports = function(ctrl) {
|
||||
|
||||
if (ctrl.vm.initiating) return m('div.initiating', m.trust(lichess.spinnerHtml));
|
||||
|
||||
return m('div', {
|
||||
class: "site_notifications_box"
|
||||
}, [
|
||||
recentNotifications(ctrl),
|
||||
m('a.more', {
|
||||
href: "/notifications"
|
||||
}, "See more")
|
||||
]);
|
||||
};
|
27
ui/notification/src/xhr.js
Normal file
27
ui/notification/src/xhr.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
var m = require('mithril');
|
||||
|
||||
var xhrConfig = function(xhr) {
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.setRequestHeader('Accept', 'application/vnd.lichess.v1+json');
|
||||
}
|
||||
|
||||
function uncache(url) {
|
||||
return url + '?_=' + new Date().getTime();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
load: function() {
|
||||
return m.request({
|
||||
method: 'GET',
|
||||
url: uncache('/notif'),
|
||||
config: xhrConfig
|
||||
});
|
||||
},
|
||||
markAllRead: function() {
|
||||
return m.request({
|
||||
method: 'POST',
|
||||
url: '/notif',
|
||||
config: xhrConfig
|
||||
});
|
||||
}
|
||||
};
|
Loading…
Reference in a new issue