Introducing a new notifications system for showing notifications for things like study invitations and forum post mentions - and much more in the future.

pull/1899/head
Gordon Martin 2016-05-30 13:51:36 +01:00
parent 5dbd772b7b
commit 382c8f1812
39 changed files with 827 additions and 41 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

@ -1,5 +1,6 @@
package controllers
import lila.notify.Notification.Notifies
import ornicar.scalalib.Zero
import play.api.data.Form
import play.api.http._
@ -9,6 +10,7 @@ import play.api.mvc._, Results._
import play.api.mvc.WebSocket.FrameFormatter
import play.twirl.api.Html
import scalaz.Monoid
import lila.notify.{Notification}
import lila.api.{ PageData, Context, HeaderContext, BodyContext, TokenBucket }
import lila.app._
@ -302,11 +304,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

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

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

@ -264,6 +264,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

@ -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,8 @@ import lila.common.DetectLanguage
import lila.common.PimpedConfig._
import lila.hub.actorApi.forum._
import lila.mod.ModlogApi
import lila.notify.NotifyApi
final class Env(
config: Config,
@ -15,6 +17,7 @@ final class Env(
shutup: ActorSelection,
hub: lila.hub.Env,
detectLanguage: DetectLanguage,
notifyApi : NotifyApi,
system: ActorSystem) {
private val settings = new {
@ -33,6 +36,8 @@ final class Env(
lazy val categApi = new CategApi(env = this)
lazy val mentionNotifier = new MentionNotifier(notifyApi = notifyApi)
lazy val topicApi = new TopicApi(
env = this,
indexer = hub.actor.forumSearch,
@ -40,7 +45,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 +55,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 +83,6 @@ 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,
system = lila.common.PlayApp.system)
}

View File

@ -0,0 +1,51 @@
package lila.forum
import lila.notify.{Notification, MentionedInThread}
import lila.notify.NotifyApi
import lila.user.{UserRepo, User}
import org.joda.time.DateTime
/**
* Notifier to inform users if they have been mentioned in a post
*
* @param notifyApi Api for sending inbox messages
*/
final class MentionNotifier(notifyApi: NotifyApi) {
def notifyMentionedUsers(post: Post, topic: Topic): Unit = {
post.userId foreach { author =>
val mentionedUsers = extractMentionedUsers(post)
val mentionedBy = MentionedInThread.MentionedBy(author)
for {
validUsers <- filterValidUsers(mentionedUsers)
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
* from the returned list.
*/
private def filterValidUsers(users: Set[String]) : Fu[List[Notification.Notifies]] = {
for {
validUsers <- UserRepo.existingUsernameIds(users)
validNotifies = validUsers.map(Notification.Notifies.apply)
} yield validNotifies
}
private def createMentionNotification(post: Post, topic: Topic, mentionedUser: Notification.Notifies, mentionedBy: MentionedInThread.MentionedBy): Notification = {
val notificationContent = MentionedInThread(
mentionedBy,
MentionedInThread.Topic(topic.name),
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,94 @@
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 MentionByHandler = stringAnyValHandler[MentionedBy](_.value, MentionedBy.apply)
implicit val TopicHandler = stringAnyValHandler[Topic](_.value, Topic.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, category, postId) =>
$doc("type" -> writeNotificationType(notificationContent), "mentionedBy" -> mentionedBy,
"topic" -> topic, "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 category = reader.get[Category]("category")
val postNumber = reader.get[PostId]("postId")
MentionedInThread(mentionedBy, topic, 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.value,
"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,44 @@
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, "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,46 @@
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,
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 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,27 @@
package lila.notify
import lila.db.dsl._
private final class NotificationRepo(val coll: Coll) {
import BSONHandlers._
def insert(notification: Notification) : Funit = {
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)
}
val recentSort = $sort desc "created"
def userNotificationsQuery(userId: Notification.Notifies) = $doc("notifies" -> userId.value)
private def unreadOnlyQuery(userId:Notification.Notifies) = $doc("notifies" -> userId.value, "read" -> false)
}

View File

@ -0,0 +1,46 @@
package lila.notify
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
repo.insert(notification) >>-
getUnseenNotificationCount(notification.notifies).
map(NewNotification(notification, _)).
foreach(notifyConnectedClients)
}
def addNotifications(notifications: List[Notification]) : Funit = {
notifications.map(addNotification).sequenceFu.void
}
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,8 @@ final class Env(
chapterMaker = chapterMaker,
studyMaker = studyMaker,
notifier = new StudyNotifier(
messageActor = hub.actor.messenger,
netBaseUrl = NetBaseUrl),
notifyApi = lila.notify.Env.current.notifyApi
),
lightUser = getLightUser,
chat = hub.actor.chat,
timeline = hub.actor.timeline,

View File

@ -60,10 +60,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,22 @@ 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 makeTimeout.short
import org.joda.time.DateTime
private final class StudyNotifier(
messageActor: ActorSelection,
netBaseUrl: String) {
notifyApi: NotifyApi) {
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)
if (!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)).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)).settings(
libraryDependencies ++= provided(play.api, RM)
)
lazy val site = project("site", Seq(common, socket)).settings(
libraryDependencies ++= provided(play.api)
)

View File

@ -35,6 +35,42 @@ 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() {
if (parseInt($toggle.attr('data-count'))) {
instance.markAllReadServer();
$toggle.attr('data-count', 0);
}
});
}
},
addNewNotification: function(notification) {
if (instance)
instance.addNewNotification(notification);
}
}
})();
(function() {
/////////////
@ -152,6 +188,13 @@ lichess.challengeApp = (function() {
}
}
},
new_notification: function(e) {
var notification = e.notification;
lichess.siteNotifications.addNewNotification(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 +806,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

@ -0,0 +1,20 @@
.site_notifications_box {
width: 300px;
}
.site_notification {
padding-bottom: 8px;
border-bottom: 1px solid #e4e4e4;
margin-bottom: 8px;
font-family: 'Roboto';
font-weight: 300;
margin-left: 20px;
margin-right: 20px;
}
.site_notification a {
font-family: 'Noto Sans';
font-weight: bold;
color: #a0a0a0;
text-decoration: none;
}

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');
module.exports = function(env) {
this.data = [];
this.vm = {
initiating: true,
reloading: false
};
this.setInitialNotifications = function(data) {
this.vm.initiating = false;
this.vm.reloading = false;
this.data = data;
}.bind(this);
this.markAllReadServer = function () {
xhr.markAllRead();
}.bind(this);
this.addNewNotification = function(newNotification) {
this.data.unshift(newNotification);
// We only show the most recent notifications - the user should click 'see more' if they want
// to see older notifications
if (this.data.length > env.maxNotifications) this.data.pop();
m.redraw();
}.bind(this);
xhr.load().then(this.setInitialNotifications);
};

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,
addNewNotification: controller.addNewNotification,
markAllReadServer: controller.markAllReadServer
}
}

View File

@ -0,0 +1,68 @@
var m = require('mithril');
var drawMentionedNotification = function(notification) {
var content = notification.content;
var category = content.category;
var topic = content.topic;
var mentionedBy = content.mentionedBy.name;
var postId = content.postId;
var mentionedByProfile = "/@/" + mentionedBy;
var postUrl = "/forum/redirect/post/" + postId;
return m('div', [
m('a', {href: mentionedByProfile}, mentionedBy),
m('span', ' mentioned you in the '),
m('a', {href: postUrl, class: "forum_post_link"}, topic),
m('span', ' forum thread')
]
);
};
var drawStudyInviteNotification = function(notification) {
var content = notification.content;
var invitedBy = content.invitedBy.name;
var studyName = content.studyName;
var studyId = content.studyId;
var invitedByProfile = "/@/" + invitedBy;
var studyUrl = "/study/" + studyId;
return m('div', [
m('a', {href: invitedByProfile}, invitedBy),
m('span', " invited you to their "),
m('a', {href: studyUrl}, studyName),
m('span', " study")
]
);
}
var drawNotification = function (notification) {
var content = null;
switch (notification.type) {
case "mentioned" : content = drawMentionedNotification(notification); break;
case "invitedStudy": content = drawStudyInviteNotification(notification); break;
default: console.dir(notification); console.error("unhandled notification"); break;
}
var date = new Date(notification.date);
return m('div', {class: 'site_notification'}, [
content,
m('time', {class:"moment-from-now", datetime: date})
]);
};
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', {href:"/notifications"}, "See more")
]);
};

View File

@ -0,0 +1,29 @@
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
});
}
};