Study icon for friends in a study

The study icon will appear when:

 * A friend, who is a contributor, joins a study, or makes a move in a study if he does not have the icon yet (the latter happens if you have two studies open and close one).
 * A friend is in a study and gets added as contributor.
 * A friend is a contributor in a private study and the study becomes public.

The study icon will disappear when:

 * A friend, who is a contributor, leaves a study.
 * A friend, who is a contributor, gets his status revoked, or kicked.
 * A friend, who is a contributor, is in a public study that becomes private.
pull/2659/head
ProgramFOX 2017-02-12 22:09:23 +01:00
parent dfd070b62f
commit 8913cd81b3
15 changed files with 259 additions and 30 deletions

View File

@ -31,6 +31,15 @@ object User extends LilaController {
}
}
def studyTv(username: String) = Open { implicit ctx =>
OptionFuResult (UserRepo named username) { user =>
lila.relation.Env.current.onlineStudying.get(user.id) match {
case None => notFound
case Some(studyId) => fuccess(Redirect(routes.Study.show(studyId)))
}
}
}
def show(username: String) = OpenBody { implicit ctx =>
filter(username, none, 1)
}

View File

@ -179,7 +179,8 @@ withGtm: Boolean = false)(body: Html)(implicit ctx: Context)
@ctx.me.map { me =>
<div id="friend_box" data-preload="@ctx.onlineFriends.users.map(_.titleName).mkString(",")"
data-playing="@ctx.onlineFriends.playing.mkString(",")"
data-patrons="@ctx.onlineFriends.patrons.mkString(",")">
data-patrons="@ctx.onlineFriends.patrons.mkString(",")"
data-studying="@ctx.onlineFriends.studying.mkString(",")">
<div class="title"><strong class="online"> </strong> @trans.onlineFriends()</div>
<div class="content_wrap">
<div class="content list"></div>

View File

@ -55,6 +55,7 @@ GET /@/:username/mod controllers.User.mod(username: String)
POST /@/:username/note controllers.User.writeNote(username: String)
GET /@/:username/mini controllers.User.showMini(username: String)
GET /@/:username/tv controllers.User.tv(username: String)
GET /@/:username/studyTv controllers.User.studyTv(username: String)
GET /@/:username/perf/:perfKey controllers.User.perfStat(username: String, perfKey: String)
GET /@/:username/:filterName controllers.User.showFilter(username: String, filterName: String, page: Int ?= 1)
GET /@/:username controllers.User.show(username: String)

View File

@ -12,6 +12,7 @@ final class Env(
hub: lila.hub.Env,
getOnlineUserIds: () => Set[String],
lightUser: lila.common.LightUser.Getter,
lightUserSync: lila.common.LightUser.GetterSync,
followable: String => Fu[Boolean],
system: ActorSystem,
asyncCache: lila.memo.AsyncCache.Builder,
@ -40,12 +41,17 @@ final class Env(
maxBlock = MaxBlock)
val onlinePlayings = new lila.memo.ExpireSetMemo(4 hour)
val onlineStudying = new OnlineStudyingMemo(4 hour) // people with write access in public studies
val onlineStudyingAll = new OnlineStudyingMemo(4 hour) // people with write or read access in public and private studies
private[relation] val actor = system.actorOf(Props(new RelationActor(
getOnlineUserIds = getOnlineUserIds,
lightUser = lightUser,
lightUserSync = lightUserSync,
api = api,
onlinePlayings
onlinePlayings,
onlineStudying,
onlineStudyingAll
)), name = ActorName)
scheduler.once(15 seconds) {
@ -63,6 +69,7 @@ object Env {
hub = lila.hub.Env.current,
getOnlineUserIds = () => lila.user.Env.current.onlineUserIdMemo.keySet,
lightUser = lila.user.Env.current.lightUser,
lightUserSync = lila.user.Env.current.lightUserSync,
followable = lila.pref.Env.current.api.followable _,
system = lila.common.PlayApp.system,
asyncCache = lila.memo.Env.current.asyncCache,

View File

@ -1,6 +1,7 @@
package lila.relation
import lila.hub.actorApi.relation.OnlineFriends
import lila.common.LightUser
import play.api.libs.json._
object JsonView {
@ -22,7 +23,9 @@ object JsonView {
"t" -> "following_onlines",
"d" -> onlineFriends.users.map(_.titleName),
"playing" -> onlineFriends.playing,
"patrons" -> onlineFriends.patrons)
"studying" -> onlineFriends.studying,
"patrons" -> onlineFriends.patrons
)
}
def writeFriendEntering(friendEntering: FriendEntering) = {
@ -31,7 +34,16 @@ object JsonView {
"t" -> "following_enters",
"d" -> friendEntering.user.titleName,
"playing" -> friendEntering.isPlaying,
"studyId" -> friendEntering.studyId,
"patron" -> friendEntering.user.isPatron
)
}
def writeFriendJoinedOrQuitStudy(user: LightUser, studyId: String, message: String) = {
Json.obj(
"t" -> message,
"user" -> user.titleName,
"studyId" -> studyId
)
}
}

View File

@ -0,0 +1,23 @@
package lila.relation
import com.github.blemale.scaffeine.{ Cache, Scaffeine }
import scala.concurrent.duration._
final class OnlineStudyingMemo(ttl: Duration) {
private val cache: Cache[String, String] /* userId, studyId */ = Scaffeine()
.expireAfterAccess(ttl)
.build[String, String]
def put(userId: String, studyId: String): Unit =
cache.put(userId, studyId)
def get(userId: String): Option[String] =
cache getIfPresent userId
def remove(userId: String): Unit =
cache invalidate userId
def getMap(): Map[String, String] =
scala.collection.immutable.Map() ++ (cache asMap)
}

View File

@ -10,11 +10,16 @@ import lila.common.LightUser
import lila.hub.actorApi.relation._
import lila.hub.actorApi.{ SendTo, SendTos }
import play.api.libs.json.Json
private[relation] final class RelationActor(
getOnlineUserIds: () => Set[String],
lightUser: LightUser.Getter,
lightUserSync: LightUser.GetterSync,
api: RelationApi,
onlinePlayings: ExpireSetMemo) extends Actor {
onlinePlayings: ExpireSetMemo,
onlineStudying: OnlineStudyingMemo,
onlineStudyingAll: OnlineStudyingMemo) extends Actor {
private val bus = context.system.lilaBus
@ -23,6 +28,7 @@ private[relation] final class RelationActor(
override def preStart(): Unit = {
context.system.lilaBus.subscribe(self, 'startGame)
context.system.lilaBus.subscribe(self, 'finishGame)
context.system.lilaBus.subscribe(self, 'study)
}
override def postStop(): Unit = {
@ -64,10 +70,52 @@ private[relation] final class RelationActor(
val usersPlaying = game.userIds
onlinePlayings putAll usersPlaying
notifyFollowersGameStateChanged(usersPlaying, "following_playing")
case lila.hub.actorApi.study.StudyJoin(userId, studyId, contributor, public) =>
onlineStudyingAll.put(userId, studyId)
if (contributor && public) {
val wasAlreadyInStudy = onlineStudying.get(userId) != None
onlineStudying.put(userId, studyId)
if (!wasAlreadyInStudy) notifyFollowersFriendInStudyStateChanged(userId, studyId, "following_joined_study")
}
case lila.hub.actorApi.study.StudyQuit(userId, studyId, contributor, public) =>
onlineStudyingAll remove userId
if (contributor && public) {
onlineStudying remove userId
notifyFollowersFriendInStudyStateChanged(userId, studyId, "following_left_study")
}
case lila.hub.actorApi.study.StudyBecamePrivate(studyId, contributors) =>
val contributorsInStudy = contributors.filter(onlineStudying.get(_) != None).toSet
for (c <- contributorsInStudy) {
onlineStudying remove c
notifyFollowersFriendInStudyStateChanged(c, studyId, "following_left_study")
}
case lila.hub.actorApi.study.StudyBecamePublic(studyId, contributors) =>
val contributorsInStudy = contributors.filter(onlineStudyingAll.get(_) != None).toSet
for (c <- contributorsInStudy) {
onlineStudying.put(c, studyId)
notifyFollowersFriendInStudyStateChanged(c, studyId, "following_joined_study")
}
case lila.hub.actorApi.study.StudyMemberGotWriteAccess(userId, studyId, public) if public =>
if (onlineStudyingAll.get(userId) != None) {
onlineStudying.put(userId, studyId)
notifyFollowersFriendInStudyStateChanged(userId, studyId, "following_joined_study")
}
case lila.hub.actorApi.study.StudyMemberLostWriteAccess(userId, studyId, public) if public =>
if (onlineStudying.get(userId) != None) {
onlineStudying remove userId
notifyFollowersFriendInStudyStateChanged(userId, studyId, "following_left_study")
}
}
private def makeFriendEntering(enters: LightUser) = {
FriendEntering(enters, onlinePlayings.get(enters.id))
val studyId = onlineStudying.get(enters.id)
FriendEntering(enters, onlinePlayings.get(enters.id), studyId)
}
private def onlineIds: Set[ID] = onlines.keySet
@ -76,13 +124,18 @@ private[relation] final class RelationActor(
api fetchFollowing userId map { ids =>
val friends = ids.flatMap(onlines.get).toList
val friendsPlaying = filterFriendsPlaying(friends)
OnlineFriends(friends, friendsPlaying)
val friendsStudying = filterFriendsStudying(friends)
OnlineFriends(friends, friendsPlaying, friendsStudying)
}
private def filterFriendsPlaying(friends: List[LightUser]): Set[String] = {
friends.filter(p => onlinePlayings.get(p.id)).map(_.id).toSet
}
private def filterFriendsStudying(friends: List[LightUser]): Set[String] = {
friends.filter(p => onlineStudying.get(p.id) != None).map(_.id).toSet
}
private def notifyFollowersFriendEnters(friendsEntering: List[FriendEntering]) =
friendsEntering foreach { entering =>
api fetchFollowers entering.user.id map (_ filter onlines.contains) foreach { ids =>
@ -103,4 +156,9 @@ private[relation] final class RelationActor(
if (ids.nonEmpty) bus.publish(SendTos(ids.toSet, message, userId), 'users)
}
}
private def notifyFollowersFriendInStudyStateChanged(userId: String, studyId: String, message: String) =
api fetchFollowers userId map (_ filter onlines.contains) foreach { ids =>
if (ids.nonEmpty) bus.publish(SendTos(ids.toSet, message, userId), 'users)
}
}

View File

@ -15,7 +15,7 @@ case class Blocked(u2: String) {
def userId = u2
}
private[relation] case class FriendEntering(user: LightUser, isPlaying: Boolean)
private[relation] case class FriendEntering(user: LightUser, isPlaying: Boolean, studyId: Option[String])
object BSONHandlers {

View File

@ -21,7 +21,8 @@ final class Env(
evalCacheHandler: lila.evalCache.EvalCacheSocketHandler,
system: ActorSystem,
hub: lila.hub.Env,
db: lila.db.Env) {
db: lila.db.Env,
asyncCache: lila.memo.AsyncCache.Builder) {
private val settings = new {
val CollectionStudy = config getString "collection.study"
@ -46,7 +47,8 @@ final class Env(
lightUser = lightUserApi.async,
history = new lila.socket.History(ttl = HistoryMessageTtl),
uidTimeout = UidTimeout,
socketTimeout = SocketTimeout)
socketTimeout = SocketTimeout,
lightStudyCache = lightStudyCache)
}), name = SocketName)
def version(studyId: Study.Id): Fu[Int] =
@ -93,7 +95,8 @@ final class Env(
chat = hub.actor.chat,
bus = system.lilaBus,
timeline = hub.actor.timeline,
socketHub = socketHub)
socketHub = socketHub,
lightStudyCache = lightStudyCache)
lazy val pager = new StudyPager(
studyRepo = studyRepo,
@ -113,6 +116,8 @@ final class Env(
logger = logger)
}))
lazy val lightStudyCache = new lila.study.LightStudyCache(4 hour, studyRepo, asyncCache)
def cli = new lila.common.Cli {
def process = {
case "study" :: "rank" :: "reset" :: Nil => api.resetAllRanks.map { count => s"$count done" }
@ -130,5 +135,6 @@ object Env {
evalCacheHandler = lila.evalCache.Env.current.socketHandler,
system = lila.common.PlayApp.system,
hub = lila.hub.Env.current,
db = lila.db.Env.current)
db = lila.db.Env.current,
asyncCache = lila.memo.Env.current.asyncCache)
}

View File

@ -0,0 +1,34 @@
package lila.study
import com.github.blemale.scaffeine.{ AsyncLoadingCache, Scaffeine }
import scala.concurrent.duration._
import lila.study._
import lila.user.User
final class LightStudyCache(ttl: Duration,
studyRepo: StudyRepo,
asyncCache: lila.memo.AsyncCache.Builder) {
private val cache = asyncCache.clearable(
name = "study.lightStudyCache",
f = fetch,
expireAfter = _.ExpireAfterWrite(4 hours)
)
def remove(studyId: String): Unit =
cache invalidate studyId
def get(studyId: String): Fu[Option[LightStudy]] =
cache get studyId
private def fetch(studyId: String): Fu[Option[LightStudy]] =
studyRepo byId new Study.Id(studyId) flatMap { studyOption =>
studyOption match {
case Some(study) => fuccess(Some(new LightStudy(study.isPublic, study.members.members.filter(_._2.canContribute).map(_._1).toSet)))
case None => fuccess(None)
}
}
}
final class LightStudy(val isPublic: Boolean, val contributors: Set[User.ID])

View File

@ -20,7 +20,8 @@ private final class Socket(
lightUser: lila.common.LightUser.Getter,
val history: History[Socket.Messadata],
uidTimeout: Duration,
socketTimeout: Duration) extends SocketActor[Socket.Member](uidTimeout) with Historical[Socket.Member, Socket.Messadata] {
socketTimeout: Duration,
lightStudyCache: LightStudyCache) extends SocketActor[Socket.Member](uidTimeout) with Historical[Socket.Member, Socket.Messadata] {
import Socket._
import JsonView._
@ -41,12 +42,35 @@ private final class Socket(
lilaBus.unsubscribe(self)
}
def sendStudyJoin(userId: User.ID) {
lightStudyCache.get(studyId.value) foreach { studyOption =>
studyOption foreach { study =>
val contributor = study.contributors.contains(userId)
context.system.lilaBus.publish(lila.hub.actorApi.study.StudyJoin(userId, studyId.value, contributor, study.isPublic), 'study)
}
}
}
def sendStudyQuit(userId: User.ID) {
lightStudyCache.get(studyId.value) foreach { studyOption =>
studyOption foreach { study =>
val contributor = study.contributors.contains(userId)
context.system.lilaBus.publish(lila.hub.actorApi.study.StudyQuit(userId, studyId.value, contributor, study.isPublic), 'study)
}
}
}
def receiveSpecific = ({
case SetPath(pos, uid) => notifyVersion("path", Json.obj(
"p" -> pos,
"w" -> who(uid).map(whoWriter.writes)
), noMessadata)
case SetPath(pos, uid) =>
uidToUserId(uid) match {
case Some(userId) => sendStudyJoin(userId)
case None => {}
}
notifyVersion("path", Json.obj(
"p" -> pos,
"w" -> who(uid).map(whoWriter.writes)
), noMessadata)
case AddNode(pos, node, uid) =>
val dests = AnaDests.Ref(chess.variant.Standard, node.fen.value, pos.path.toString).compute
@ -146,10 +170,16 @@ private final class Socket(
addMember(uid.value, member)
notifyCrowd
sender ! Socket.Connected(enumerator, member)
userId match {
case Some(u) => sendStudyJoin(u)
case None => {}
}
case Quit(uid) =>
members get uid foreach { member =>
quit(uid)
val userId = member.userId.get
sendStudyQuit(userId)
notifyCrowd
}

View File

@ -24,7 +24,8 @@ final class StudyApi(
chat: ActorSelection,
bus: lila.common.Bus,
timeline: ActorSelection,
socketHub: ActorRef) {
socketHub: ActorRef,
lightStudyCache: LightStudyCache) {
def byId = studyRepo byId _
@ -189,7 +190,14 @@ final class StudyApi(
def setRole(byUserId: User.ID, studyId: Study.Id, userId: User.ID, roleStr: String) = sequenceStudy(studyId) { study =>
(study isOwner byUserId) ?? {
val previousRole = study.members.get(userId).get.role
val role = StudyMember.Role.byId.getOrElse(roleStr, StudyMember.Role.Read)
if (previousRole == StudyMember.Role.Read && role == StudyMember.Role.Write) {
bus.publish(lila.hub.actorApi.study.StudyMemberGotWriteAccess(userId, studyId.value, study.isPublic), 'study)
} else if (previousRole == StudyMember.Role.Write && role == StudyMember.Role.Read) {
bus.publish(lila.hub.actorApi.study.StudyMemberLostWriteAccess(userId, studyId.value, study.isPublic), 'study)
}
lightStudyCache.remove(studyId.value)
studyRepo.setRole(study, userId, role) >>- reloadMembers(study)
}
}
@ -207,6 +215,10 @@ final class StudyApi(
def kick(studyId: Study.Id, userId: User.ID) = sequenceStudy(studyId) { study =>
study.isMember(userId) ?? {
if (study canContribute userId) {
bus.publish(lila.hub.actorApi.study.StudyMemberLostWriteAccess(userId, studyId.value, study.isPublic), 'study)
}
lightStudyCache.remove(studyId.value)
studyRepo.removeMember(study, userId)
} >>- reloadMembers(study) >>- indexStudy(study)
}
@ -392,10 +404,16 @@ final class StudyApi(
name = Study toName data.name,
settings = settings,
visibility = data.vis)
if (study.visibility == Study.Visibility.Private && newStudy.visibility == Study.Visibility.Public) {
bus.publish(lila.hub.actorApi.study.StudyBecamePublic(studyId.value, study.members.ids.filter(study.canContribute _).toSet), 'study)
} else if (study.visibility == Study.Visibility.Public && newStudy.visibility == Study.Visibility.Private) {
bus.publish(lila.hub.actorApi.study.StudyBecamePrivate(studyId.value, study.members.ids.filter(study.canContribute _).toSet), 'study)
}
(newStudy != study) ?? {
studyRepo.updateSomeFields(newStudy) >>-
sendTo(study, Socket.ReloadAll) >>-
indexStudy(study)
indexStudy(study) >>-
lightStudyCache.remove(studyId.value)
}
}
}
@ -403,7 +421,8 @@ final class StudyApi(
def delete(study: Study) = sequenceStudy(study.id) { study =>
studyRepo.delete(study) >>
chapterRepo.deleteByStudy(study) >>-
bus.publish(actorApi.RemoveStudy(study.id), 'study)
bus.publish(actorApi.RemoveStudy(study.id), 'study) >>-
lightStudyCache.remove(study.id.value)
}
def like(studyId: Study.Id, userId: User.ID, v: Boolean, socket: ActorRef, uid: Uid): Funit =

View File

@ -6,3 +6,5 @@ case class RemoveStudy(id: Study.Id)
case class SetTag(chapterId: Chapter.Id, name: String, value: String) {
def tag = chess.format.pgn.Tag(name, value take 140)
}
case class UserJoined(userId: String, studyId: String)
case class UserLeft(userId: String, studyId: String)

View File

@ -154,10 +154,10 @@ lichess.notifyApp = (function() {
$.extend(true, lichess.StrongSocket.defaults, {
events: {
following_onlines: function(d, all) {
$('#friend_box').friends("set", all.d, all.playing, all.patrons);
$('#friend_box').friends("set", all.d, all.playing, all.studying, all.patrons);
},
following_enters: function(d, all) {
$('#friend_box').friends('enters', all.d, all.playing, all.patron);
$('#friend_box').friends('enters', all.d, all.playing, all.studying, all.patron);
},
following_leaves: function(name) {
$('#friend_box').friends('leaves', name);
@ -168,6 +168,12 @@ lichess.notifyApp = (function() {
following_stopped_playing: function(name) {
$('#friend_box').friends('stopped_playing', name);
},
following_joined_study: function(name) {
$('#friend_box').friends('study_join', name);
},
following_left_study: function(name) {
$('#friend_box').friends('study_leave', name);
},
new_notification: function(e) {
$('#site_notifications_tag').attr('data-count', e.unread || 0);
$.sound.newPM();
@ -1075,18 +1081,20 @@ lichess.notifyApp = (function() {
var users = self.element.data('preload').split(',');
var playings = self.element.data('playing').split(',');
var studyings = self.element.data('studying').split(',');
var patrons = self.element.data('patrons').split(',');
self.set(users, playings, patrons);
self.set(users, playings, studyings, patrons);
},
_findByUsername: function(n) {
return this.users.filter(function(u) {
return isSameUser(n.toLowerCase(), u);
})[0];
},
_makeUser: function(name, playing, patron) {
_makeUser: function(name, playing, studyId, patron) {
return {
'name': name,
'playing': !!playing,
'studyId': studyId,
'patron': !!patron
}
},
@ -1113,16 +1121,17 @@ lichess.notifyApp = (function() {
}).map(this._renderUser).join(""));
}.bind(this));
},
set: function(us, playings, patrons) {
set: function(us, playings, studyings, patrons) {
this.users = us.map(function(user) {
return this._makeUser(user, false, false);
return this._makeUser(user, false, null, false);
}.bind(this));
for (user in playings) this._setPlaying(playings[user], true);
for (user in patrons) this._setPatron(patrons[user], true);
for (i in playings) this._setPlaying(playings[i], true);
for (i in studyings) this._setStudying(studyings[i], true);
for (i in patrons) this._setPatron(patrons[i], true);
this.repaint();
},
enters: function(userName, playing, patron) {
var user = this._makeUser(userName, playing, patron);
enters: function(userName, playing, studying, patron) {
var user = this._makeUser(userName, playing, studying, patron);
this.users.push(user);
this.repaint();
},
@ -1140,6 +1149,10 @@ lichess.notifyApp = (function() {
var user = this._findByUsername(userName);
if (user) user.patron = patron;
},
_setStudying: function(userName, studying) {
var user = this._findByUsername(userName);
if (user) user.studying = studying;
},
playing: function(userName) {
this._setPlaying(userName, true);
this.repaint();
@ -1148,13 +1161,23 @@ lichess.notifyApp = (function() {
this._setPlaying(userName, false);
this.repaint();
},
study_join: function(userName) {
this._setStudying(userName, true);
this.repaint();
},
study_leave: function(userName) {
this._setStudying(userName, false);
this.repaint();
},
_renderUser: function(user) {
var icon = '<i class="is-green line' + (user.patron ? ' patron' : '') + '"></i>';
var name = $.fp.contains(user.name, ' ') ? user.name.split(' ')[1] : user.name;
var url = '/@/' + name;
var tvButton = user.playing ? '<a data-icon="1" class="tv is-green ulpt" data-pt-pos="nw" href="' + url + '/tv" data-href="' + url + '"></a>' : '';
var studyButton = user.studying ? '<a data-icon="&#xe00e;" class="is-green friend-study" data-pt-pos="nw" href="' + url + '/studyTv" data-href="' + '' + '"></a>' : '';
var rightButton = tvButton || studyButton;
return '<div><a class="user_link ulpt" data-pt-pos="nw" href="' + url + '">' + icon + user.name + '</a>' + tvButton + '</div>';
return '<div><a class="user_link ulpt" data-pt-pos="nw" href="' + url + '">' + icon + user.name + '</a>' + rightButton + '</div>';
}
};
})());

View File

@ -1489,6 +1489,10 @@ body.fpmenu #friend_box {
flex: 0 0 auto;
padding: 0 5px;
}
#friend_box .content a.friend-study {
flex: 0 0 auto;
padding: 2px 5px 0 5px;
}
#friend_box .content a:hover {
background: #F0F0F0;
}