Merge pull request #1899 from Happy0/notif

[WIP] Lichess notification system
This commit is contained in:
Thibault Duplessis 2016-05-31 13:35:52 +02:00
commit ee709a0022
43 changed files with 953 additions and 49 deletions

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

@ -596,6 +596,9 @@ insight {
user_cache = insight_user_cache
}
}
notify {
collection.notify = notify
}
simulation {
enabled = false
players = 300

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
package lila
package object notify extends PackageObject with WithPlay

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View 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']);

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

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

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

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

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