Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e27b09c
Implement the ability to follow FIDE players, that is users are notified
julien4215 Jun 24, 2025
0ac0ea8
Don't overwrite previous subscribers of a FIDE player when the FIDE
julien4215 Jun 25, 2025
54cd1de
Store relation between a user and a fide player in a new collection.
julien4215 Jun 26, 2025
4d0b313
Tweak follow fide player code and apply some lilaism.
julien4215 Jun 26, 2025
1759151
Merge branch 'master' into fide-player-subscribe
ornicar Jun 27, 2025
96667d0
remove unnecessary proxy functions
ornicar Jun 27, 2025
7298889
optimize fide_player_follower
ornicar Jun 27, 2025
3fe08b4
boolean.match == if
ornicar Jun 27, 2025
9f052a6
fide follow code golf
ornicar Jun 27, 2025
7d5d6e8
only check if the relay tour has notified once every 15 minutes, save…
ornicar Jun 27, 2025
c674f08
make it clear that the Future is discarded
ornicar Jun 27, 2025
968f971
fix tour subscriber dedup, should be on round not tour
ornicar Jun 27, 2025
0dbde88
use an in-heap cache to remember which relay chapter has notified pla…
ornicar Jun 27, 2025
fa6a6c3
code golf with `.so`
ornicar Jun 27, 2025
027d3b9
code golf with .traverse
ornicar Jun 27, 2025
7d2b620
scala tweaks
ornicar Jun 27, 2025
4ae21af
we don't need a chapter preview player to send notifications, only a …
ornicar Jun 27, 2025
f558385
skip player notification if a player name is missing
ornicar Jun 27, 2025
36224e0
scala tweaks
ornicar Jun 27, 2025
cf74244
fix fide follow checkbox label didn't trigger the checkbox
ornicar Jun 27, 2025
aee7507
save click addicts from rate limits
ornicar Jun 27, 2025
31e9f32
Update modules/relay/src/main/RelayNotifier.scala
ornicar Jun 27, 2025
fb55109
Merge branch 'master' into fide-player-subscribe
ornicar Jun 27, 2025
a3d68f0
scalafmtAll
ornicar Jun 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions app/controllers/Fide.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 8 additions & 1 deletion app/views/fide.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
1 change: 1 addition & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion modules/coach/src/main/ui/CoachUi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down
8 changes: 5 additions & 3 deletions modules/core/src/main/fide.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
17 changes: 11 additions & 6 deletions modules/fide/src/main/Env.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,24 @@ 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]

lazy val federationApi = wire[FederationApi]

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)
Expand Down
16 changes: 15 additions & 1 deletion modules/fide/src/main/FideRepo.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)))
27 changes: 22 additions & 5 deletions modules/fide/src/main/ui/FideUi.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 =>
Expand Down
1 change: 1 addition & 0 deletions modules/relay/src/main/Env.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion modules/relay/src/main/RelayGame.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 50 additions & 12 deletions modules/relay/src/main/RelayNotifier.scala
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
2 changes: 1 addition & 1 deletion modules/relay/src/main/RelaySync.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion modules/relay/src/main/RelayTourRepo.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
1 change: 0 additions & 1 deletion modules/streamer/src/main/ui/StreamerBits.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions modules/ui/src/main/helper/Form3.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions ui/bits/css/_fide.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions ui/bits/css/build/bits.fide.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import '../../../lib/css/plugin';
@import '../../../lib/css/form/cmn-toggle';
@import '../../../lib/css/component/slist';
@import '../relay/relay';

Expand Down
1 change: 1 addition & 0 deletions ui/bits/css/streamer/_header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
align-self: flex-start;
font-weight: bold;
z-index: $z-link-overlay-2;
cursor: pointer;
}

.streamer-profile {
Expand Down
16 changes: 16 additions & 0 deletions ui/bits/src/bits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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({
Expand Down
Loading