diff --git a/app/controllers/Fide.scala b/app/controllers/Fide.scala index decb90ea1da86..e025b28afc0a7 100644 --- a/app/controllers/Fide.scala +++ b/app/controllers/Fide.scala @@ -25,11 +25,17 @@ final class Fide(env: Env) extends LilaController(env): if player.slug != slug then Redirect(routes.Fide.show(id, player.slug)) else for - user <- env.title.api.publicUserOf(player.id) - tours <- env.relay.playerTour.playerTours(player, page) - rendered <- renderPage(views.fide.player.show(player, user, tours)) + user <- env.title.api.publicUserOf(player.id) + tours <- env.relay.playerTour.playerTours(player, page) + isFollowing <- ctx.me.soFu(me => env.fide.repo.follower.isFollowing(me.userId, id)) + rendered <- renderPage(views.fide.player.show(player, user, tours, isFollowing)) yield Ok(rendered) + def follow(fideId: chess.FideId, follow: Boolean) = AuthBody: _ ?=> + me ?=> + val f = if follow then env.fide.repo.follower.follow else env.fide.repo.follower.unfollow + for _ <- f(me.userId, fideId) yield NoContent + def apiShow(id: chess.FideId) = Anon: Found(env.fide.repo.player.fetch(id))(JsonOk) diff --git a/app/views/fide.scala b/app/views/fide.scala index 6a8411805c353..a03f338dc0d25 100644 --- a/app/views/fide.scala +++ b/app/views/fide.scala @@ -12,11 +12,18 @@ export ui.federation object player: export ui.player.{ index, notFound } - def show(player: FidePlayer, user: Option[User], tours: Paginator[RelayTour.WithLastRound])(using Context) = + def show( + player: FidePlayer, + user: Option[User], + tours: Paginator[RelayTour.WithLastRound], + isFollowing: Option[Boolean] + )(using Context) = ui.player.show( player, user, (tours.nbResults > 0).option: views.relay.tour.renderPager(views.relay.tour.asRelayPager(tours)): routes.Fide.show(player.id, player.slug, _) + , + isFollowing ) diff --git a/conf/routes b/conf/routes index e4ce14593ec04..7335241c61d74 100644 --- a/conf/routes +++ b/conf/routes @@ -114,6 +114,7 @@ GET /fide controllers.Fide.index(page: Int ?= 1, q: GET /fide/federation controllers.Fide.federations(page: Int ?= 1) GET /fide/federation/:name controllers.Fide.federation(name, page: Int ?= 1) GET /fide/:fideId/:name controllers.Fide.show(fideId: chess.FideId, name, page: Int ?= 1) +POST /fide/:fideId/follow controllers.Fide.follow(fideId: chess.FideId, follow: Boolean) GET /api/fide/player/:id controllers.Fide.apiShow(id: chess.FideId) GET /api/fide/player controllers.Fide.apiSearch(q: String) diff --git a/modules/coach/src/main/ui/CoachUi.scala b/modules/coach/src/main/ui/CoachUi.scala index ba08f4813bfd0..33aaeacf1cfae 100644 --- a/modules/coach/src/main/ui/CoachUi.scala +++ b/modules/coach/src/main/ui/CoachUi.scala @@ -6,7 +6,7 @@ import scalalib.paginator.Paginator import lila.core.config.NetDomain import lila.core.data.RichText -import lila.core.user.{ Flag, FlagCode, Profile } +import lila.core.user.{ Flag, Profile } import lila.rating.UserPerfsExt.{ best6Perfs, hasEstablishedRating } import lila.ui.* diff --git a/modules/core/src/main/fide.scala b/modules/core/src/main/fide.scala index 5174a8c5a0f6b..8a8b6f0f36f4e 100644 --- a/modules/core/src/main/fide.scala +++ b/modules/core/src/main/fide.scala @@ -3,6 +3,7 @@ package fide import _root_.chess.{ FideId, PlayerName, PlayerTitle } import _root_.chess.rating.{ Elo, KFactor } +import lila.core.userId.UserId enum FideTC: case standard, rapid, blitz @@ -29,9 +30,10 @@ trait Player: def kFactorOf(tc: FideTC): KFactor def ratingsMap: Map[FideTC, Elo] -type PlayerToken = String -type GuessPlayer = (Option[FideId], Option[PlayerName], Option[PlayerTitle]) => Fu[Option[Player]] -type GetPlayer = FideId => Fu[Option[Player]] +type PlayerToken = String +type GuessPlayer = (Option[FideId], Option[PlayerName], Option[PlayerTitle]) => Fu[Option[Player]] +type GetPlayer = FideId => Fu[Option[Player]] +type GetPlayerFollowers = FideId => Fu[List[UserId]] type Tokenize = String => PlayerToken diff --git a/modules/fide/src/main/Env.scala b/modules/fide/src/main/Env.scala index d320dea4b566f..10c788b9e81c5 100644 --- a/modules/fide/src/main/Env.scala +++ b/modules/fide/src/main/Env.scala @@ -15,7 +15,11 @@ final class Env(db: lila.db.Db, cacheApi: CacheApi, ws: StandaloneWSClient)(usin )(using mode: play.api.Mode, scheduler: Scheduler): val repo = - FideRepo(playerColl = db(CollName("fide_player")), federationColl = db(CollName("fide_federation"))) + FideRepo( + playerColl = db(CollName("fide_player")), + federationColl = db(CollName("fide_federation")), + followerColl = db(CollName("fide_player_follower")) + ) lazy val playerApi = wire[FidePlayerApi] @@ -23,11 +27,12 @@ final class Env(db: lila.db.Db, cacheApi: CacheApi, ws: StandaloneWSClient)(usin lazy val paginator = wire[FidePaginator] - def federationsOf: hub.Federation.FedsOf = playerApi.federationsOf - def federationNamesOf: hub.Federation.NamesOf = playerApi.federationNamesOf - def tokenize: hub.Tokenize = FidePlayer.tokenize - def guessPlayer: hub.GuessPlayer = playerApi.guessPlayer.apply - def getPlayer: hub.GetPlayer = playerApi.get + def federationsOf: hub.Federation.FedsOf = playerApi.federationsOf + def federationNamesOf: hub.Federation.NamesOf = playerApi.federationNamesOf + def tokenize: hub.Tokenize = FidePlayer.tokenize + def guessPlayer: hub.GuessPlayer = playerApi.guessPlayer.apply + def getPlayer: hub.GetPlayer = playerApi.get + def getPlayerFollowers: hub.GetPlayerFollowers = repo.follower.followers def search(q: Option[String], page: Int = 1): Fu[Either[FidePlayer, Paginator[FidePlayer]]] = val query = q.so(_.trim) diff --git a/modules/fide/src/main/FideRepo.scala b/modules/fide/src/main/FideRepo.scala index 192b414acd776..9cbd6043b77aa 100644 --- a/modules/fide/src/main/FideRepo.scala +++ b/modules/fide/src/main/FideRepo.scala @@ -5,10 +5,12 @@ import reactivemongo.api.bson.* import lila.core.fide as hub import lila.db.dsl.{ *, given } +import scala.util.Success final private class FideRepo( private[fide] val playerColl: Coll, - private[fide] val federationColl: Coll + private[fide] val federationColl: Coll, + private val followerColl: Coll )(using Executor): object player: @@ -27,3 +29,15 @@ final private class FideRepo( def upsert(fed: Federation): Funit = federationColl.update.one($id(fed.id), fed, upsert = true).void def fetch(code: hub.Federation.Id): Fu[Option[Federation]] = federationColl.byId[Federation](code) + + object follower: + private def makeId(u: UserId, p: FideId) = s"$p/$u" + def followers(p: FideId): Fu[List[UserId]] = + for ids <- followerColl.distinctEasy[String, List]("_id", "_id".$startsWith(s"$p/")) + yield UserId.from(ids.map(id => id.drop(id.indexOf('/') + 1))) + def follow(u: UserId, p: FideId) = playerColl + .exists($id(p)) + .flatMapz: + followerColl.update.one($id(makeId(u, p)), $doc("u" -> u), upsert = true).void + def unfollow(u: UserId, p: FideId) = followerColl.delete.one($id(makeId(u, p))).void + def isFollowing(u: UserId, p: FideId) = followerColl.exists($id(makeId(u, p))) diff --git a/modules/fide/src/main/ui/FideUi.scala b/modules/fide/src/main/ui/FideUi.scala index 81618e86cdf8b..54d9759cef8e3 100644 --- a/modules/fide/src/main/ui/FideUi.scala +++ b/modules/fide/src/main/ui/FideUi.scala @@ -22,7 +22,7 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): private def page(title: String, active: String)(modifiers: Modifier*)(using Context): Page = Page(title) .css("bits.fide") - .js(infiniteScrollEsmInit): + .js(infiniteScrollEsmInit ++ esmInitBit("fidePlayerFollow")): main(cls := "page-menu")( menu(active), div(cls := "page-menu__content box")(modifiers) @@ -186,12 +186,29 @@ final class FideUi(helpers: Helpers)(menu: String => Context ?=> Frag): private def card(name: Frag, value: Frag) = div(cls := "fide-card fide-player__card")(em(name), strong(value)) - def show(player: FidePlayer, user: Option[User], tours: Option[Frag])(using Context) = + private def followButton(player: FidePlayer, isFollowing: Boolean)(using Context) = + val id = "fide-player-follow" + label(cls := "fide-player__follow")( + form3.cmnToggle( + fieldId = id, + fieldName = id, + checked = isFollowing, + action = Some(routes.Fide.follow(player.id, isFollowing).url) + ), + trans.site.follow() + ) + + def show(player: FidePlayer, user: Option[User], tours: Option[Frag], isFollowing: Option[Boolean])(using + Context + ) = page(s"${player.name} - FIDE player ${player.id}", "players")( cls := "box-pad fide-player", - h1( - span(titleTag(player.title), player.name), - user.map(userLink(_, withTitle = false)(cls := "fide-player__user")) + div(cls := "fide-player__header")( + h1( + span(titleTag(player.title), player.name), + user.map(userLink(_, withTitle = false)(cls := "fide-player__user")) + ), + isFollowing.map(followButton(player, _)) ), div(cls := "fide-cards fide-player__cards")( player.fed.map: fed => diff --git a/modules/relay/src/main/Env.scala b/modules/relay/src/main/Env.scala index 91ef55480c1a6..10af638f4de7a 100644 --- a/modules/relay/src/main/Env.scala +++ b/modules/relay/src/main/Env.scala @@ -31,6 +31,7 @@ final class Env( gameProxy: lila.core.game.GameProxy, guessPlayer: lila.core.fide.GuessPlayer, getPlayer: lila.core.fide.GetPlayer, + getPlayerFollowers: lila.core.fide.GetPlayerFollowers, cacheApi: lila.memo.CacheApi, settingStore: SettingStore.Builder, irc: lila.core.irc.IrcApi, diff --git a/modules/relay/src/main/RelayGame.scala b/modules/relay/src/main/RelayGame.scala index 78589424c5e58..cfa108e7698ef 100644 --- a/modules/relay/src/main/RelayGame.scala +++ b/modules/relay/src/main/RelayGame.scala @@ -30,8 +30,11 @@ case class RelayGame( points = None ) + def fideIds: chess.ByColor[Option[chess.FideId]] = + tags.fideIds.map(_.filter(_.value > 0)) + def fideIdsPair: Option[PairOf[Option[chess.FideId]]] = - tags.fideIds.some.filter(_.forall(_.exists(_.value > 0))).map(_.toPair) + fideIds.some.filter(_.forall(_.isDefined)).map(_.toPair) def hasUnknownPlayer: Boolean = List(RelayGame.whiteTags, RelayGame.blackTags).exists: diff --git a/modules/relay/src/main/RelayNotifier.scala b/modules/relay/src/main/RelayNotifier.scala index ebf9ad73d680f..38bfde0f95fe7 100644 --- a/modules/relay/src/main/RelayNotifier.scala +++ b/modules/relay/src/main/RelayNotifier.scala @@ -1,19 +1,52 @@ package lila.relay +import scalalib.cache.OnceEvery + import lila.core.notify.{ NotifyApi, NotificationContent } +import lila.study.Chapter + +final private class RelayNotifier( + notifyApi: NotifyApi, + tourRepo: RelayTourRepo, + getPlayerFollowers: lila.core.fide.GetPlayerFollowers +)(using Executor): + + private object notifyPlayerFollowers: + + private val dedupNotif = OnceEvery[StudyChapterId](1.day) + + def apply(rt: RelayRound.WithTour, chapter: Chapter, game: RelayGame): Funit = + dedupNotif(chapter.id).so: + val futureByColor = game.fideIds.mapWithColor: (color, fid) => + for + followers <- fid.so(getPlayerFollowers) + notify <- followers.nonEmpty.so: + chapter.tags.names.sequence.so: names => + notifyApi.notifyMany( + followers, + NotificationContent.BroadcastRound( + url = rt.path(chapter.id), + title = rt.tour.name.value, + text = s"${names(color)} is playing against ${names(!color)} in ${rt.round.name}" + ) + ) + yield notify + Future.sequence(futureByColor.all).void -final private class RelayNotifier(notifyApi: NotifyApi, tourRepo: RelayTourRepo)(using Executor): - - def roundBegin(rt: RelayRound.WithTour): Funit = - tourRepo - .hasNotified(rt) - .not - .flatMapz: - tourRepo.setNotified(rt) >> - tourRepo - .subscribers(rt.tour.id) - .flatMap: subscribers => - subscribers.nonEmpty.so: + private object notifyTournamentSubscribers: + + private val dedupDbReq = OnceEvery[RelayRoundId](5.minutes) + + def apply(rt: RelayRound.WithTour): Funit = + dedupDbReq(rt.round.id).so: + tourRepo + .hasNotified(rt) + .not + .flatMapz: + for + _ <- tourRepo.setNotified(rt) + subscribers <- tourRepo.subscribers(rt.tour.id) + _ <- subscribers.nonEmpty.so: notifyApi.notifyMany( subscribers, NotificationContent.BroadcastRound( @@ -22,3 +55,8 @@ final private class RelayNotifier(notifyApi: NotifyApi, tourRepo: RelayTourRepo) s"${rt.round.name} has begun" ) ) + yield () + + def chapterUpdated(rt: RelayRound.WithTour, chapter: Chapter, game: RelayGame): Unit = + notifyPlayerFollowers(rt, chapter, game) + notifyTournamentSubscribers(rt) diff --git a/modules/relay/src/main/RelaySync.scala b/modules/relay/src/main/RelaySync.scala index 2133c021bd98d..6b95bdc2c8506 100644 --- a/modules/relay/src/main/RelaySync.scala +++ b/modules/relay/src/main/RelaySync.scala @@ -51,7 +51,7 @@ final private class RelaySync( chapter <- updateInitialPosition(study.id, chapter, game) (tagUpdate, newEnd) <- updateChapterTags(rt.tour, study, chapter, game) nbMoves <- updateChapterTree(study, chapter, game)(using rt.tour) - _ <- (nbMoves > 0).so(notifier.roundBegin(rt)) + _ = if nbMoves > 0 then notifier.chapterUpdated(rt, chapter, game) yield SyncResult.ChapterResult(chapter.id, tagUpdate, nbMoves, newEnd) private def createChapter( diff --git a/modules/relay/src/main/RelayTourRepo.scala b/modules/relay/src/main/RelayTourRepo.scala index a8f585749c051..2684b897a55ae 100644 --- a/modules/relay/src/main/RelayTourRepo.scala +++ b/modules/relay/src/main/RelayTourRepo.scala @@ -42,7 +42,7 @@ final private class RelayTourRepo(val coll: Coll)(using Executor): def countBySubscriberId(uid: UserId): Fu[Int] = coll.countSel(selectors.subscriberId(uid)) - def hasNotified(rt: RelayRound.WithTour): Fu[Boolean] = + private[relay] def hasNotified(rt: RelayRound.WithTour): Fu[Boolean] = coll.exists($doc($id(rt.tour.id), "notified" -> rt.round.id)) def setNotified(rt: RelayRound.WithTour): Funit = diff --git a/modules/streamer/src/main/ui/StreamerBits.scala b/modules/streamer/src/main/ui/StreamerBits.scala index e8582b6a023b6..500ff45eb6ffa 100644 --- a/modules/streamer/src/main/ui/StreamerBits.scala +++ b/modules/streamer/src/main/ui/StreamerBits.scala @@ -191,7 +191,6 @@ final class StreamerBits(helpers: Helpers)(picfitUrl: lila.core.misc.PicfitUrl): (ctx.isAuth && ctx.isnt(s.user)).option: val id = s"streamer-subscribe-${s.streamer.userId}" label(cls := "streamer-subscribe")( - `for` := id, data("action") := s"${routes.Streamer.subscribe(s.streamer.userId, !s.subscribed)}" )( span( diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala index 66f25ae58f148..ad02d196194f7 100644 --- a/modules/ui/src/main/helper/Form3.scala +++ b/modules/ui/src/main/helper/Form3.scala @@ -94,7 +94,8 @@ final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: F checked: Boolean, disabled: Boolean = false, value: Value = "true", - title: Option[String] = None + title: Option[String] = None, + action: Option[String] = None ) = frag( (disabled && checked).option: // disabled checkboxes don't submit; need an extra hidden field @@ -107,7 +108,8 @@ final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: F tpe := "checkbox", cls := "form-control cmn-toggle", checked.option(st.checked), - disabled.option(st.disabled) + disabled.option(st.disabled), + action.map(st.data("action") := _) ), label( `for` := fieldId, diff --git a/ui/bits/css/_fide.scss b/ui/bits/css/_fide.scss index a01647fc78210..51943c3451981 100644 --- a/ui/bits/css/_fide.scss +++ b/ui/bits/css/_fide.scss @@ -106,6 +106,14 @@ align-items: center; gap: 1em; } + &__header { + @extend %flex-between-nowrap; + } + &__follow { + @extend %flex-center-nowrap; + gap: 1ch; + cursor: pointer; + } &__user { font-size: 0.7em; } diff --git a/ui/bits/css/build/bits.fide.scss b/ui/bits/css/build/bits.fide.scss index 3a1340703513c..900d79fe6a415 100644 --- a/ui/bits/css/build/bits.fide.scss +++ b/ui/bits/css/build/bits.fide.scss @@ -1,4 +1,5 @@ @import '../../../lib/css/plugin'; +@import '../../../lib/css/form/cmn-toggle'; @import '../../../lib/css/component/slist'; @import '../relay/relay'; diff --git a/ui/bits/css/streamer/_header.scss b/ui/bits/css/streamer/_header.scss index 087e134ee4aa9..a7490ca29dddc 100644 --- a/ui/bits/css/streamer/_header.scss +++ b/ui/bits/css/streamer/_header.scss @@ -134,6 +134,7 @@ align-self: flex-start; font-weight: bold; z-index: $z-link-overlay-2; + cursor: pointer; } .streamer-profile { diff --git a/ui/bits/src/bits.ts b/ui/bits/src/bits.ts index 7b92567d3d5cb..4a41bd385318f 100644 --- a/ui/bits/src/bits.ts +++ b/ui/bits/src/bits.ts @@ -2,6 +2,7 @@ import { text, formToXhr } from 'lib/xhr'; import flairPickerLoader from './flairPicker'; import { spinnerHtml } from 'lib/view/controls'; import { wireCropDialog } from './crop'; +import { debounce } from 'lib/async'; // avoid node_modules and pay attention to imports here. we don't want to force people // to download the entire toastui editor library just to do some light form processing. @@ -34,6 +35,8 @@ export function initModule(args: { fn: string } & any): void { return setAssetInfo(); case 'streamerSubscribe': return streamerSubscribe(); + case 'fidePlayerFollow': + return fidePlayerFollow(); case 'thanksReport': return thanksReport(); case 'titleRequest': @@ -233,6 +236,19 @@ function streamerSubscribe() { }); } +function fidePlayerFollow() { + const el = $('#fide-player-follow'); + el.on( + 'change', + debounce( + () => + text(el.data('action').replace(/follow=[^&]+/, `follow=${el.prop('checked')}`), { method: 'post' }), + 1000, + true, + ), + ); +} + function titleRequest() { $('.title-image-edit').each(function (this: HTMLElement) { wireCropDialog({