diff --git a/app/UiEnv.scala b/app/UiEnv.scala
index 5f15379a93e52..8a50ce369c802 100644
--- a/app/UiEnv.scala
+++ b/app/UiEnv.scala
@@ -25,7 +25,6 @@ object UiEnv
def netConfig = env.net
def contactEmailInClear = env.net.email.value
def picfitUrl = env.memo.picfitUrl
- def socketTest = env.web.socketTest
given lila.core.config.NetDomain = env.net.domain
given (using ctx: PageContext): Option[Nonce] = ctx.nonce
diff --git a/app/controllers/Clas.scala b/app/controllers/Clas.scala
index b27410747d61d..ce44029ffc7e1 100644
--- a/app/controllers/Clas.scala
+++ b/app/controllers/Clas.scala
@@ -472,6 +472,24 @@ final class Clas(env: Env, authC: Auth) extends LilaController(env):
else redirectTo(clas)
}
+ def studentMove(id: ClasId, username: UserStr) = Secure(_.Teacher) { ctx ?=> me ?=>
+ WithClassAndStudents(id): (clas, students) =>
+ WithStudent(clas, username): s =>
+ for
+ classes <- env.clas.api.clas.of(me)
+ others = classes.filter(_.id != clas.id)
+ res <- Ok.page(views.clas.student.move(clas, students, s, others))
+ yield res
+ }
+
+ def studentMovePost(id: ClasId, username: UserStr, to: ClasId) = SecureBody(_.Teacher) { ctx ?=> me ?=>
+ WithClassAndStudents(id): (clas, students) =>
+ WithStudent(clas, username): s =>
+ WithClass(to): toClas =>
+ for _ <- env.clas.api.student.move(s, toClas)
+ yield Redirect(routes.Clas.show(clas.id)).flashSuccess
+ }
+
def becomeTeacher = AuthBody { ctx ?=> me ?=>
couldBeTeacher.elseNotFound:
val perm = lila.core.perm.Permission.Teacher.dbKey
diff --git a/app/controllers/Dev.scala b/app/controllers/Dev.scala
index 4950c91c8e4ca..adea16c0b1a4a 100644
--- a/app/controllers/Dev.scala
+++ b/app/controllers/Dev.scala
@@ -27,7 +27,6 @@ final class Dev(env: Env) extends LilaController(env):
env.web.settings.noDelaySecret,
env.web.settings.prizeTournamentMakers,
env.web.settings.sitewideCoepCredentiallessHeader,
- env.web.socketTest.distributionSetting,
env.tournament.reloadEndpointSetting,
env.tutor.nbAnalysisSetting,
env.tutor.parallelismSetting,
@@ -80,14 +79,4 @@ final class Dev(env: Env) extends LilaController(env):
env.mod.logApi.cli(command) >>
env.api.cli(command.split(" ").toList)
- def socketTestResult = AuthBody(parse.json) { ctx ?=> me ?=>
- ctx.body.body
- .validate[JsArray]
- .fold(
- err => BadRequest(Json.obj("error" -> err.toString)),
- results =>
- env.web.socketTest
- .put(Json.obj(me.userId.toString -> results))
- .inject(jsonOkResult)
- )
- }
+end Dev
diff --git a/app/controllers/Puzzle.scala b/app/controllers/Puzzle.scala
index 291ff737b3760..72b101ba998cd 100644
--- a/app/controllers/Puzzle.scala
+++ b/app/controllers/Puzzle.scala
@@ -535,12 +535,13 @@ final class Puzzle(env: Env, apiC: => Api) extends LilaController(env):
Auth { ctx ?=> me ?=>
meOrFetch(username)
.flatMapz: user =>
- (fuccess(isGranted(_.CheatHunter)) >>|
+ (fuccess(user.is(me) || isGranted(_.CheatHunter)) >>|
user.enabled.yes.so(env.clas.api.clas.isTeacherOf(me, user.id))).map {
_.option(user)
}
- .dmap(_ | me.value)
- .flatMap(f(_))
+ .flatMap:
+ case Some(user) => f(user)
+ case None => Redirect(routes.Puzzle.dashboard(Days(30), "home", none))
}
def WithPuzzlePerf[A](f: Perf ?=> Fu[A])(using Option[Me]): Fu[A] =
diff --git a/app/controllers/User.scala b/app/controllers/User.scala
index 67679ac691929..0b73d2fbd6440 100644
--- a/app/controllers/User.scala
+++ b/app/controllers/User.scala
@@ -44,10 +44,9 @@ final class User(
env.game.cached
.lastPlayedPlayingId(username.id)
.orElse(env.game.gameRepo.quickLastPlayedId(username.id))
- .flatMap {
+ .flatMap:
case None => NotFound("No ongoing game")
case Some(gameId) => gameC.exportGame(gameId)
- }
private def apiGames(u: UserModel, filter: String, page: Int)(using BodyContext[?]) =
userGames(u, filter, page).flatMap(env.game.userGameApi.jsPaginator).map { res =>
@@ -177,11 +176,12 @@ final class User(
ctx.userId.soFu(env.game.crosstableApi(user.id, _)),
ctx.isAuth.so(env.pref.api.followable(user.id))
).flatMapN: (blocked, crosstable, followable) =>
- val ping = env.socket.isOnline.exec(user.id).so(env.socket.getLagRating(user.id))
negotiate(
- html = (ctx.isnt(user)).so(currentlyPlaying(user.user)).flatMap { pov =>
- Ok.snip(views.user.mini(user, pov, blocked, followable, relation, ping, crosstable))
- .map(_.withHeaders(CACHE_CONTROL -> "max-age=5"))
+ html = ctx.isnt(user).so(currentlyPlaying(user.user)).flatMap { pov =>
+ val ping = env.socket.isOnline.exec(user.id).so(env.socket.getLagRating(user.id))
+ Ok.snip(
+ views.user.mini(user, pov, blocked, followable, relation, ping, crosstable)
+ ).map(_.withHeaders(CACHE_CONTROL -> "max-age=5"))
},
json =
import lila.game.JsonView.given
@@ -553,21 +553,24 @@ final class User(
}
def perfStat(username: UserStr, perfKey: PerfKey) = Open:
- Found(env.perfStat.api.data(username, perfKey)): data =>
- negotiate(
- Ok.async:
- env.history
- .ratingChartApi(data.user.user)
- .map:
- views.user.perfStatPage(data, _)
- ,
- JsonOk:
- getBool("graph")
- .soFu:
- env.history.ratingChartApi.singlePerf(data.user.user, data.stat.perfType.key)
- .map: graph =>
- env.perfStat.jsonView(data).add("graph", graph)
- )
+ PerfType
+ .isLeaderboardable(perfKey)
+ .so:
+ Found(env.perfStat.api.data(username, perfKey)): data =>
+ negotiate(
+ Ok.async:
+ env.history
+ .ratingChartApi(data.user.user)
+ .map:
+ views.user.perfStatPage(data, _)
+ ,
+ JsonOk:
+ getBool("graph")
+ .soFu:
+ env.history.ratingChartApi.singlePerf(data.user.user, data.stat.perfType.key)
+ .map: graph =>
+ env.perfStat.jsonView(data).add("graph", graph)
+ )
def autocomplete = OpenOrScoped(): ctx ?=>
NoTor:
diff --git a/app/views/base/page.scala b/app/views/base/page.scala
index ca9610d18bd5e..eedd9468c261c 100644
--- a/app/views/base/page.scala
+++ b/app/views/base/page.scala
@@ -101,7 +101,7 @@ object page:
"kid" -> ctx.kid.yes,
"mobile" -> lila.common.HTTPRequest.isMobileBrowser(ctx.req),
"playing fixed-scroll" -> p.playing,
- "no-rating" -> !pref.showRatings,
+ "no-rating" -> (!pref.showRatings || (p.playing && pref.hideRatingsInGame)),
"no-flair" -> !pref.flairs,
"zen" -> (pref.isZen || (p.playing && pref.isZenAuto)),
"zenable" -> p.zenable,
@@ -111,10 +111,10 @@ object page:
dataDev,
dataVapid := (ctx.isAuth && env.security.lilaCookie.isRememberMe(ctx.req))
.option(env.push.vapidPublicKey),
- dataUser := ctx.userId,
- dataSoundSet := pref.currentSoundSet.toString,
- attr("data-socket-domains") := socketTest.socketEndpoints(netConfig).mkString(","),
- attr("data-socket-test-running") := socketTest.isUserInTestBucket(),
+ dataUser := ctx.userId,
+ dataSoundSet := pref.currentSoundSet.toString,
+ attr("data-socket-domains") := (if ~pref.usingAltSocket then netConfig.socketAlts
+ else netConfig.socketDomains).mkString(","),
dataAssetUrl,
dataAssetVersion := assetVersion,
dataNonce := ctx.nonce.ifTrue(sameAssetDomain).map(_.value),
diff --git a/app/views/clas.scala b/app/views/clas.scala
index 8811ff105f2df..3a8518dc90c0f 100644
--- a/app/views/clas.scala
+++ b/app/views/clas.scala
@@ -18,7 +18,7 @@ object student:
lazy val formUi = lila.clas.ui.StudentFormUi(helpers, views.clas.ui, ui)
export ui.{ invite }
- export formUi.{ newStudent as form, many as manyForm, edit, release, close }
+ export formUi.{ newStudent as form, many as manyForm, edit, release, close, move }
def show(
clas: Clas,
diff --git a/app/views/game/side.scala b/app/views/game/side.scala
index 6927ebf3e28cf..d68b06d89fa31 100644
--- a/app/views/game/side.scala
+++ b/app/views/game/side.scala
@@ -78,7 +78,12 @@ object side:
game.players.mapList: p =>
frag(
div(cls := s"player color-icon is ${p.color.name} text")(
- playerLink(p, withOnline = false, withDiff = true, withBerserk = true)
+ playerLink(
+ p,
+ withOnline = false,
+ withDiff = true,
+ withBerserk = true
+ )
),
tour.flatMap(_.teamVs).map(_.teams(p.color)).map {
teamLink(_, withIcon = false)(cls := "team")
diff --git a/bin/mongodb/recap-notif.js b/bin/mongodb/recap-notif.js
index bed89d6138316..cf22182bc9c4c 100644
--- a/bin/mongodb/recap-notif.js
+++ b/bin/mongodb/recap-notif.js
@@ -1,28 +1,49 @@
const year = 2024;
const dry = false;
-let count = 0;
+let countAll = 0;
+let countSent = 0;
+let lastPrinted = 0;
-const hasPuzzles = userId => db.user_perf.count({ _id: userId, 'puzzle.nb': { $gt: 0 } });
+let hasRecap = new Set();
+function reloadHasRecap() {
+ print('Loading existing recaps...');
+ hasRecap = new Set(db.recap_report.distinct('_id'));
+ print('Loaded ' + hasRecap.size + ' recaps');
+}
+reloadHasRecap();
+setInterval(reloadHasRecap, 1000 * 60 * 10);
-function sendToUser(user) {
- if (!user.enabled) {
- print('------------- ' + user._id + ' is closed');
- return;
- }
- const exists = db.notify.countDocuments({ notifies: user._id, 'content.type': 'recap', }, { limit: 1 });
- if (exists) {
- print('------------- ' + user._id + ' already sent');
- return;
- }
- if (user.seenAt < new Date('2024-01-01')) {
- print('------------- ' + user._id + ' not seen in 2024');
- return;
- }
- if (!user.count?.game && !hasPuzzles(user._id)) {
- print('------------- ' + user._id + ' no games or puzzles');
- return;
+const hasPuzzles = userId => db.user_perf.countDocuments({ _id: userId, 'puzzle.nb': { $gt: 0 } }, { limit: 1 });
+
+// only keeps users that don't yet have a recap notification for the year
+// and don't have yet loaded their recap from another link
+const filterNewUsers = users => {
+ const noRecap = users.filter(u => !hasRecap.has(u._id));
+ const hasNotif = new Set(db.notify.distinct('notifies', {
+ notifies: { $in: noRecap.map(u => u._id) }, 'content.type': 'recap', 'content.year': year
+ }));
+ return noRecap.filter(u => !hasNotif.has(u._id));
+}
+
+function* group(size) {
+ let batch = [];
+ while (true) {
+ const element = yield;
+ if (!element) {
+ yield batch;
+ return;
+ }
+ batch.push(element);
+ if (batch.length >= size) {
+ let element = yield batch;
+ batch = [element];
+ }
}
+};
+
+function sendToUser(user) {
+ if (!user.count?.game && !hasPuzzles(user._id)) return;
if (!dry) db.notify.insertOne({
_id: Math.random().toString(36).substring(2, 10),
notifies: user._id,
@@ -33,45 +54,38 @@ function sendToUser(user) {
read: false,
createdAt: new Date(),
});
- count++;
- print(count + ' ' + user._id);
-}
-
-function sendToUserId(userId) {
- const user = db.user4.findOne({ _id: userId });
- if (!user) {
- print('------------- ' + userId + ' not found');
- return;
- }
- sendToUser(user);
-}
-
-function sendToRoleOwners() {
- db.user4.find({ enabled: true, roles: { $exists: 1, $ne: [] } }).forEach(user => {
- roles = user.roles.filter(r => r != 'ROLE_COACH' && r != 'ROLE_TEACHER' && r != 'ROLE_VERIFIED' && r != 'ROLE_BETA');
- if (roles.length) {
- sendTo(user);
- }
- });
-}
-
-function sendToTeamMembers(teamId) {
- db.team_member.find({ team: teamId }, { user: 1, _id: 0 }).forEach(member => {
- sendToUserId(member.user);
- });
-}
-
-function sendToRandomOnlinePlayers() {
- db.user4.find({ enabled: true, 'count.game': { $gt: 10 }, seenAt: { $gt: new Date(Date.now() - 1000 * 60 * 2) } }).sort({ seenAt: -1 }).limit(5_000).forEach(sendToUser);
+ countSent++;
}
function sendToRandomOfflinePlayers() {
- db.user4.find({
- enabled: true, 'count.game': { $gt: 10 }, seenAt: {
- $gt: new Date(Date.now() - 1000 * 60 * 60 * 24),
- $lt: new Date(Date.now() - 1000 * 60 * 60)
+ const grouper = group(100);
+ grouper.next();
+ const process = user => {
+ countAll++;
+ const batch = grouper.next(user).value;
+ if (batch) {
+ const newUsers = filterNewUsers(batch);
+ newUsers.forEach(sendToUser);
+ if (countAll % 1000 == 0) {
+ print(`+ ${countSent - lastPrinted} = ${countSent} / ${countAll} | ${user.createdAt.toLocaleDateString('fr')}`);
+ lastPrinted = countSent;
+ }
+ sleep(10 * newUsers.length);
}
- }).limit(25_000).forEach(sendToUser);
+ }
+ db.user4.find({
+ enabled: true,
+ createdAt: { $lt: new Date(year, 9, 1) },
+ seenAt: {
+ $gt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30 * 3),
+ // $lt: new Date(Date.now() - 1000 * 60 * 20) // avoid the lila notif cache!
+ },
+ marks: { $nin: ['boost', 'engine', 'troll'] }
+ }).forEach(process);
+ process(); // flush the generator
}
sendToRandomOfflinePlayers();
+
+print('Scan: ' + countAll);
+print('Sent: ' + countSent);
diff --git a/conf/clas.routes b/conf/clas.routes
index 7d119b15396be..893c5a1bc7c8b 100644
--- a/conf/clas.routes
+++ b/conf/clas.routes
@@ -32,3 +32,5 @@ POST /class/$id<\w{8}>/student/:username/release controllers.clas.Clas.studentR
GET /class/$id<\w{8}>/student/:username/close controllers.clas.Clas.studentClose(id: ClasId, username: UserStr)
POST /class/$id<\w{8}>/student/:username/close controllers.clas.Clas.studentClosePost(id: ClasId, username: UserStr)
POST /class/$id<\w{8}>/invitation/revoke controllers.clas.Clas.invitationRevoke(id: ClasInviteId)
+GET /class/$id<\w{8}>/student/:username/move controllers.clas.Clas.studentMove(id: ClasId, username: UserStr)
+POST /class/$id<\w{8}>/student/:username/move/$to<\w{8}> controllers.clas.Clas.studentMovePost(id: ClasId, username: UserStr, to: ClasId)
diff --git a/conf/routes b/conf/routes
index 454a56b30c2b7..0f9a6ffd0bff0 100644
--- a/conf/routes
+++ b/conf/routes
@@ -879,7 +879,6 @@ GET /dev/settings controllers.Dev.settings
POST /dev/settings/:id controllers.Dev.settingsPost(id)
GET /prometheus-metrics/:key controllers.Main.prometheusMetrics(key: String)
-POST /dev/socket-test controllers.Dev.socketTestResult
# Push
POST /mobile/register/:platform/:deviceId controllers.Push.mobileRegister(platform, deviceId)
diff --git a/modules/api/src/main/Context.scala b/modules/api/src/main/Context.scala
index e60568e3cbcb8..4088a6aa9b047 100644
--- a/modules/api/src/main/Context.scala
+++ b/modules/api/src/main/Context.scala
@@ -20,7 +20,6 @@ final class LoginContext(
val oauth: Option[TokenScopes]
):
export me.{ isDefined as isAuth, isEmpty as isAnon }
- def myId: Option[MyId] = me.map(_.myId)
def user: Option[User] = Me.raw(me)
def userId: Option[UserId] = user.map(_.id)
def username: Option[UserName] = user.map(_.username)
diff --git a/modules/clas/src/main/ClasApi.scala b/modules/clas/src/main/ClasApi.scala
index 94404a085ef2c..b37969700376e 100644
--- a/modules/clas/src/main/ClasApi.scala
+++ b/modules/clas/src/main/ClasApi.scala
@@ -258,6 +258,17 @@ final class ClasApi(
sendWelcomeMessage(teacher.id, user, clas)).inject(Student.WithPassword(student, password))
}
+ def move(s: Student.WithUser, toClas: Clas)(using teacher: Me): Fu[Option[Student]] = for
+ _ <- closeAccount(s)
+ stu = s.student.copy(id = Student.makeId(s.user.id, toClas.id), clasId = toClas.id)
+ moved <- colls.student.insert
+ .one(stu)
+ .inject(stu.some)
+ .recoverWith(lila.db.recoverDuplicateKey { _ =>
+ student.get(toClas, s.user.id)
+ })
+ yield moved
+
def manyCreate(
clas: Clas,
data: ClasForm.ManyNewStudent,
diff --git a/modules/clas/src/main/ClasMatesCache.scala b/modules/clas/src/main/ClasMatesCache.scala
index b0285aab0b17f..bc4d0fc45c77c 100644
--- a/modules/clas/src/main/ClasMatesCache.scala
+++ b/modules/clas/src/main/ClasMatesCache.scala
@@ -12,7 +12,7 @@ final class ClasMatesCache(colls: ClasColls, cacheApi: CacheApi, studentCache: C
def get(studentId: UserId): Fu[Set[UserId]] =
studentCache.isStudent(studentId).so(cache.get(studentId))
- private val cache = cacheApi[UserId, Set[UserId]](256, "clas.mates"):
+ private val cache = cacheApi[UserId, Set[UserId]](64, "clas.mates"):
_.expireAfterWrite(5 minutes)
.buildAsyncFuture(fetchMatesAndTeachers)
diff --git a/modules/clas/src/main/ui/DashboardUi.scala b/modules/clas/src/main/ui/DashboardUi.scala
index 7d36bbe73c43e..532c19eac7f56 100644
--- a/modules/clas/src/main/ui/DashboardUi.scala
+++ b/modules/clas/src/main/ui/DashboardUi.scala
@@ -139,11 +139,13 @@ final class DashboardUi(helpers: Helpers, ui: ClasUi)(using NetDomain):
tr(
td(userIdLink(i.userId.some)),
td(i.realName),
- td(if i.accepted.has(false) then "Declined" else "Pending"),
+ td(
+ if i.accepted.has(false) then trans.clas.declined.txt() else trans.clas.pending.txt()
+ ),
td(momentFromNow(i.created.at)),
td:
postForm(action := routes.Clas.invitationRevoke(i.id)):
- submitButton(cls := "button button-red button-empty")("Revoke")
+ submitButton(cls := "button button-red button-empty")(trans.site.delete())
)
)
val archivedBox =
diff --git a/modules/clas/src/main/ui/StudentFormUi.scala b/modules/clas/src/main/ui/StudentFormUi.scala
index 59803a9417101..38095a7e77b63 100644
--- a/modules/clas/src/main/ui/StudentFormUi.scala
+++ b/modules/clas/src/main/ui/StudentFormUi.scala
@@ -217,7 +217,11 @@ final class StudentFormUi(helpers: Helpers, clasUi: ClasUi, studentUi: StudentUi
cls := "button button-empty button-red",
title := trans.clas.closeDesc1.txt()
)(trans.clas.closeStudent())
- )
+ ),
+ a(
+ href := routes.Clas.studentMove(clas.id, s.user.username),
+ cls := "button button-empty"
+ )(trans.clas.moveToAnotherClass())
)
)
)
@@ -250,6 +254,32 @@ final class StudentFormUi(helpers: Helpers, clasUi: ClasUi, studentUi: StudentUi
)
)
+ def move(clas: Clas, students: List[Student], s: Student.WithUser, otherClasses: List[Clas])(using
+ Context
+ ) =
+ ClasPage(s.user.username.value, Left(clas.withStudents(students)), s.student.some)(
+ cls := "student-show student-edit"
+ ):
+
+ val classForms: Frag = otherClasses.map: toClass =>
+ postForm(action := routes.Clas.studentMovePost(clas.id, s.student.userId, toClass.id))(
+ form3.submit(toClass.name, icon = Icon.InternalArrow.some)(
+ cls := "yes-no-confirm button-blue button-empty",
+ title := trans.clas.moveToClass.txt(toClass.name)
+ )
+ )
+
+ frag(
+ studentUi.top(clas, s),
+ div(cls := "box__pad")(
+ h2(trans.clas.moveToAnotherClass()),
+ classForms,
+ form3.actions(
+ a(href := routes.Clas.studentShow(clas.id, s.user.username))(trans.site.cancel())
+ )
+ )
+ )
+
def close(clas: Clas, students: List[Student], s: Student.WithUser)(using Context) =
ClasPage(s.user.username.value, Left(clas.withStudents(students)), s.student.some)(
cls := "student-show student-edit"
diff --git a/modules/core/src/main/pref.scala b/modules/core/src/main/pref.scala
index 4d1ebe3fafb37..bc2a2a26c0cc4 100644
--- a/modules/core/src/main/pref.scala
+++ b/modules/core/src/main/pref.scala
@@ -2,7 +2,8 @@ package lila.core
package pref
import lila.core.user.User
-import lila.core.userId.UserId
+import lila.core.userId.{ MyId, UserId }
+import lila.core.game.Game
trait Pref:
val id: UserId
@@ -22,6 +23,7 @@ trait Pref:
def hasKeyboardMove: Boolean
def hasVoice: Boolean
+ def hideRatingsInGame: Boolean
def showRatings: Boolean
def animationMillis: Int
def animationMillisForSpeedPuzzles: Int
diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala
index e4179c5b93215..47a1a657abd23 100644
--- a/modules/coreI18n/src/main/key.scala
+++ b/modules/coreI18n/src/main/key.scala
@@ -282,6 +282,8 @@ object I18nKey:
val `welcomeToClass`: I18nKey = "class:welcomeToClass"
val `invitationToClass`: I18nKey = "class:invitationToClass"
val `clickToViewInvitation`: I18nKey = "class:clickToViewInvitation"
+ val `pending`: I18nKey = "class:pending"
+ val `declined`: I18nKey = "class:declined"
val `onlyVisibleToTeachers`: I18nKey = "class:onlyVisibleToTeachers"
val `lastActiveDate`: I18nKey = "class:lastActiveDate"
val `managed`: I18nKey = "class:managed"
@@ -330,6 +332,8 @@ object I18nKey:
val `anInvitationHasBeenSentToX`: I18nKey = "class:anInvitationHasBeenSentToX"
val `xAlreadyHasAPendingInvitation`: I18nKey = "class:xAlreadyHasAPendingInvitation"
val `xIsAKidAccountWarning`: I18nKey = "class:xIsAKidAccountWarning"
+ val `moveToClass`: I18nKey = "class:moveToClass"
+ val `moveToAnotherClass`: I18nKey = "class:moveToAnotherClass"
val `nbPendingInvitations`: I18nKey = "class:nbPendingInvitations"
val `nbTeachers`: I18nKey = "class:nbTeachers"
val `nbStudents`: I18nKey = "class:nbStudents"
@@ -659,8 +663,6 @@ object I18nKey:
val `ultraBulletBulletBlitzRapidClassicalAndCorrespondenceChess`: I18nKey = "features:ultraBulletBulletBlitzRapidClassicalAndCorrespondenceChess"
val `allFeaturesToCome`: I18nKey = "features:allFeaturesToCome"
val `landscapeSupportOnApp`: I18nKey = "features:landscapeSupportOnApp"
- val `supportLichess`: I18nKey = "features:supportLichess"
- val `contributeToLichessAndGetIcon`: I18nKey = "features:contributeToLichessAndGetIcon"
val `everybodyGetsAllFeaturesForFree`: I18nKey = "features:everybodyGetsAllFeaturesForFree"
val `weBelieveEveryChessPlayerDeservesTheBest`: I18nKey = "features:weBelieveEveryChessPlayerDeservesTheBest"
val `allFeaturesAreFreeForEverybody`: I18nKey = "features:allFeaturesAreFreeForEverybody"
@@ -1086,6 +1088,7 @@ object I18nKey:
val `displayBoardResizeHandle`: I18nKey = "preferences:displayBoardResizeHandle"
val `onlyOnInitialPosition`: I18nKey = "preferences:onlyOnInitialPosition"
val `inGameOnly`: I18nKey = "preferences:inGameOnly"
+ val `exceptInGame`: I18nKey = "preferences:exceptInGame"
val `chessClock`: I18nKey = "preferences:chessClock"
val `tenthsOfSeconds`: I18nKey = "preferences:tenthsOfSeconds"
val `whenTimeRemainingLessThanTenSeconds`: I18nKey = "preferences:whenTimeRemainingLessThanTenSeconds"
diff --git a/modules/game/src/main/ui/GameUi.scala b/modules/game/src/main/ui/GameUi.scala
index 3662cb28c117a..b523a95e87a74 100644
--- a/modules/game/src/main/ui/GameUi.scala
+++ b/modules/game/src/main/ui/GameUi.scala
@@ -20,7 +20,12 @@ final class GameUi(helpers: Helpers):
private val dataTimeControl = attr("data-tc")
val cgWrap = span(cls := "cg-wrap")(cgWrapContent)
- def apply(pov: Pov, ownerLink: Boolean = false, tv: Boolean = false, withLink: Boolean = true)(using
+ def apply(
+ pov: Pov,
+ ownerLink: Boolean = false,
+ tv: Boolean = false,
+ withLink: Boolean = true
+ )(using
ctx: Context
): Tag =
renderMini(
diff --git a/modules/memo/src/main/ParallelMongoQueue.scala b/modules/memo/src/main/ParallelMongoQueue.scala
index 3a928a8204262..1a061ad5581e0 100644
--- a/modules/memo/src/main/ParallelMongoQueue.scala
+++ b/modules/memo/src/main/ParallelMongoQueue.scala
@@ -50,7 +50,7 @@ final class ParallelMongoQueue[A: BSONHandler](
// just to prevent race conditions when enqueuing stuff
private val workQueue = scalalib.actor.AsyncActorSequencer(
- maxSize = Max(64),
+ maxSize = Max(256),
timeout = 5.seconds,
s"$name.workQueue",
lila.log.asyncActorMonitor.full
diff --git a/modules/perfStat/src/main/PerfStat.scala b/modules/perfStat/src/main/PerfStat.scala
index 6a1e171187b74..3c7ac6f84ffd5 100644
--- a/modules/perfStat/src/main/PerfStat.scala
+++ b/modules/perfStat/src/main/PerfStat.scala
@@ -6,6 +6,7 @@ import java.time.Duration
import scalalib.HeapSort
import lila.rating.PerfType
+import lila.rating.PerfType.GamePerf
extension (p: Pov) def loss = p.game.winner.map(_.color != p.color)
@@ -41,13 +42,13 @@ object PerfStat:
type Getter = (User, PerfType) => Fu[PerfStat]
- def makeId(userId: UserId, perfType: PerfType) = s"$userId/${perfType.id}"
+ def makeId(userId: UserId, perf: GamePerf) = s"$userId/${perf.id}"
- def init(userId: UserId, perfType: PerfType) =
+ def init(userId: UserId, perf: GamePerf) =
PerfStat(
- id = makeId(userId, perfType),
+ id = makeId(userId, perf),
userId = userId,
- perfType = perfType,
+ perfType = perf,
highest = none,
lowest = none,
bestWins = Results(Nil),
diff --git a/modules/perfStat/src/main/PerfStatApi.scala b/modules/perfStat/src/main/PerfStatApi.scala
index 03085be657d3a..84dd19376cf09 100644
--- a/modules/perfStat/src/main/PerfStatApi.scala
+++ b/modules/perfStat/src/main/PerfStatApi.scala
@@ -6,6 +6,7 @@ import lila.core.perm.Granter
import lila.rating.Glicko.minRating
import lila.rating.PerfExt.established
import lila.rating.{ PerfType, UserRankMap }
+import lila.rating.PerfType.GamePerf
case class PerfStatData(
user: UserWithPerfs,
@@ -30,37 +31,43 @@ final class PerfStatApi(
extends lila.core.perf.PerfStatApi:
def data(name: UserStr, perfKey: PerfKey)(using me: Option[Me]): Fu[Option[PerfStatData]] =
- userApi.withPerfs(name.id).flatMap {
- _.filter: u =>
- (u.enabled.yes && (!u.lame || me.exists(_.is(u.user)))) || me.soUse(Granter(_.UserModView))
- .filter: u =>
- !u.isBot || (perfKey != PerfKey.ultraBullet)
- .soFu: u =>
- for
- oldPerfStat <- get(u.user.id, perfKey)
- perfStat = oldPerfStat.copy(playStreak = oldPerfStat.playStreak.checkCurrent)
- distribution <- u
- .perfs(perfKey)
- .established
- .soFu(weeklyRatingDistribution(perfKey))
- percentile = calcPercentile(distribution, u.perfs(perfKey).intRating)
- percentileLow = perfStat.lowest.flatMap { r => calcPercentile(distribution, r.int) }
- percentileHigh = perfStat.highest.flatMap { r => calcPercentile(distribution, r.int) }
- _ = lightUserApi.preloadUser(u.user)
- _ <- lightUserApi.preloadMany(perfStat.userIds)
- yield PerfStatData(u, perfStat, rankingsOf(u.id), percentile, percentileLow, percentileHigh)
- }
+ PerfType(perfKey) match
+ case pk: GamePerf =>
+ userApi.withPerfs(name.id).flatMap {
+ _.filter: u =>
+ (u.enabled.yes && (!u.lame || me.exists(_.is(u.user)))) || me.soUse(Granter(_.UserModView))
+ .filter: u =>
+ !u.isBot || (perfKey != PerfKey.ultraBullet)
+ .soFu: u =>
+ for
+ oldPerfStat <- get(u.user.id, pk)
+ perfStat = oldPerfStat.copy(playStreak = oldPerfStat.playStreak.checkCurrent)
+ distribution <- u
+ .perfs(perfKey)
+ .established
+ .soFu(weeklyRatingDistribution(perfKey))
+ percentile = calcPercentile(distribution, u.perfs(perfKey).intRating)
+ percentileLow = perfStat.lowest.flatMap { r => calcPercentile(distribution, r.int) }
+ percentileHigh = perfStat.highest.flatMap { r => calcPercentile(distribution, r.int) }
+ _ = lightUserApi.preloadUser(u.user)
+ _ <- lightUserApi.preloadMany(perfStat.userIds)
+ yield PerfStatData(u, perfStat, rankingsOf(u.id), percentile, percentileLow, percentileHigh)
+ }
+ case pk => fuccess(none)
private def calcPercentile(wrd: Option[List[Int]], intRating: IntRating): Option[Double] =
wrd.map: distrib =>
val (under, sum) = percentileOf(distrib, intRating)
Math.round(under * 1000.0 / sum) / 10.0
- def get(user: UserId, perfType: PerfType): Fu[PerfStat] =
- storage.find(user, perfType).getOrElse(indexer.userPerf(user, perfType))
+ def get(user: UserId, perf: GamePerf): Fu[PerfStat] =
+ storage.find(user, perf).getOrElse(indexer.userPerf(user, perf))
def highestRating(user: UserId, perfKey: PerfKey): Fu[Option[IntRating]] =
- get(user, perfKey).map(_.highest.map(_.int))
+ PerfType
+ .gamePerf(perfKey)
+ .so: (gp: GamePerf) =>
+ get(user, gp).map(_.highest.map(_.int))
object weeklyRatingDistribution:
diff --git a/modules/perfStat/src/main/PerfStatIndexer.scala b/modules/perfStat/src/main/PerfStatIndexer.scala
index a53822b437a38..6941fba44deaa 100644
--- a/modules/perfStat/src/main/PerfStatIndexer.scala
+++ b/modules/perfStat/src/main/PerfStatIndexer.scala
@@ -1,12 +1,15 @@
package lila.perfStat
import lila.rating.PerfType
+import lila.rating.PerfType.GamePerf
final class PerfStatIndexer(
gameRepo: lila.core.game.GameRepo,
storage: PerfStatStorage
)(using Executor, Scheduler):
+ import PerfType.{ isLeaderboardable as isRelevant }
+
private val workQueue = scalalib.actor.AsyncActorSequencer(
maxSize = Max(64),
timeout = 10 seconds,
@@ -14,7 +17,7 @@ final class PerfStatIndexer(
lila.log.asyncActorMonitor.full
)
- private[perfStat] def userPerf(user: UserId, perfKey: PerfKey): Fu[PerfStat] =
+ private[perfStat] def userPerf(user: UserId, perfKey: GamePerf): Fu[PerfStat] =
workQueue:
storage
.find(user, perfKey)
@@ -36,7 +39,10 @@ final class PerfStatIndexer(
addPov(Pov(game, player), userId)
private def addPov(pov: Pov, userId: UserId): Funit =
- storage
- .find(userId, pov.game.perfKey)
- .flatMapz: perfStat =>
- storage.update(perfStat, perfStat.agg(pov))
+ PerfType
+ .gamePerf(pov.game.perfKey)
+ .so: (pk: GamePerf) =>
+ storage
+ .find(userId, pk)
+ .flatMapz: perfStat =>
+ storage.update(perfStat, perfStat.agg(pov))
diff --git a/modules/perfStat/src/main/PerfStatStorage.scala b/modules/perfStat/src/main/PerfStatStorage.scala
index 5b9e3885f4390..a2a4fb1767b43 100644
--- a/modules/perfStat/src/main/PerfStatStorage.scala
+++ b/modules/perfStat/src/main/PerfStatStorage.scala
@@ -6,6 +6,7 @@ import lila.db.AsyncCollFailingSilently
import lila.db.dsl.{ *, given }
import lila.rating.BSONHandlers.perfTypeIdHandler
import lila.rating.PerfType
+import lila.rating.PerfType.GamePerf
final class PerfStatStorage(coll: AsyncCollFailingSilently)(using Executor):
@@ -21,8 +22,8 @@ final class PerfStatStorage(coll: AsyncCollFailingSilently)(using Executor):
private given BSONDocumentHandler[Count] = Macros.handler
private given BSONDocumentHandler[PerfStat] = Macros.handler
- def find(userId: UserId, perfType: PerfType): Fu[Option[PerfStat]] =
- coll(_.byId[PerfStat](PerfStat.makeId(userId, perfType)))
+ def find(userId: UserId, perf: GamePerf): Fu[Option[PerfStat]] =
+ coll(_.byId[PerfStat](PerfStat.makeId(userId, perf)))
def insert(perfStat: PerfStat): Funit =
coll(_.insert.one(perfStat).void)
diff --git a/modules/plan/src/main/ui/PlanPages.scala b/modules/plan/src/main/ui/PlanPages.scala
index fd096c0417dc2..86631ee5882bf 100644
--- a/modules/plan/src/main/ui/PlanPages.scala
+++ b/modules/plan/src/main/ui/PlanPages.scala
@@ -153,19 +153,6 @@ final class PlanPages(helpers: Helpers)(fishnetPerDay: Int):
tr(check)(
strong(trans.features.allFeaturesToCome())
)
- ),
- header(h1(trans.features.supportLichess())),
- tbody(cls := "support")(
- st.tr(
- th(trans.features.contributeToLichessAndGetIcon()),
- td("-"),
- td(span(dataIcon := patronIconChar, cls := "is is-green text check")(trans.site.yes()))
- ),
- st.tr(cls := "price")(
- th,
- td(cls := "green")("$0"),
- td(a(href := routes.Plan.index(), cls := "green button")("$5/month"))
- )
)
),
p(cls := "explanation")(
diff --git a/modules/pool/src/main/GameStarter.scala b/modules/pool/src/main/GameStarter.scala
index 0dd4acd22a261..ccf7399b1bbda 100644
--- a/modules/pool/src/main/GameStarter.scala
+++ b/modules/pool/src/main/GameStarter.scala
@@ -14,7 +14,7 @@ final private class GameStarter(
)(using Executor, Scheduler):
private val workQueue = scalalib.actor.AsyncActorSequencer(
- maxSize = Max(32),
+ maxSize = Max(64),
timeout = 10 seconds,
name = "gameStarter",
lila.log.asyncActorMonitor.full
@@ -22,8 +22,8 @@ final private class GameStarter(
def apply(pool: PoolConfig, couples: Vector[MatchMaking.Couple]): Funit =
couples.nonEmpty.so:
+ val userIds = couples.flatMap(_.userIds)
workQueue:
- val userIds = couples.flatMap(_.userIds)
for
(perfs, ids) <- userApi.perfOf(userIds, pool.perfKey).zip(idGenerator.games(couples.size))
pairings <- couples.zip(ids).parallel(one(pool, perfs).tupled)
diff --git a/modules/pref/src/main/Pref.scala b/modules/pref/src/main/Pref.scala
index a087d92f0d729..fc9aebf0d083e 100644
--- a/modules/pref/src/main/Pref.scala
+++ b/modules/pref/src/main/Pref.scala
@@ -92,7 +92,8 @@ case class Pref(
def isZen = zen == Zen.YES
def isZenAuto = zen == Zen.GAME_AUTO
- val showRatings = ratings == Ratings.YES
+ def showRatings = ratings != Ratings.NO
+ def hideRatingsInGame = ratings == Ratings.EXCEPT_GAME
def is2d = !is3d
@@ -427,7 +428,16 @@ object Pref:
GAME_AUTO -> "In-game only"
)
- object Ratings extends BooleanPref
+ object Ratings:
+ val NO = 0
+ val YES = 1
+ val EXCEPT_GAME = 2
+
+ val choices = Seq(
+ NO -> "No",
+ YES -> "Yes",
+ EXCEPT_GAME -> "Except in-game"
+ )
val darkByDefaultSince = instantOf(2021, 11, 7, 8, 0)
val systemByDefaultSince = instantOf(2022, 12, 23, 8, 0)
diff --git a/modules/pref/src/main/PrefForm.scala b/modules/pref/src/main/PrefForm.scala
index 11f69266103dd..65b8e91d9d516 100644
--- a/modules/pref/src/main/PrefForm.scala
+++ b/modules/pref/src/main/PrefForm.scala
@@ -49,7 +49,7 @@ object PrefForm:
val moretime = "moretime" -> checkedNumber(Pref.Moretime.choices)
val clockSound = "clockSound" -> booleanNumber
val pieceNotation = "pieceNotation" -> booleanNumber
- val ratings = "ratings" -> booleanNumber
+ val ratings = "ratings" -> checkedNumber(Pref.Ratings.choices)
val flairs = "flairs" -> boolean
val follow = "follow" -> booleanNumber
val challenge = "challenge" -> checkedNumber(Pref.Challenge.choices)
diff --git a/modules/pref/src/main/ui/AccountPref.scala b/modules/pref/src/main/ui/AccountPref.scala
index f3519e4209df2..f731633254f9a 100644
--- a/modules/pref/src/main/ui/AccountPref.scala
+++ b/modules/pref/src/main/ui/AccountPref.scala
@@ -77,7 +77,7 @@ final class AccountPref(helpers: Helpers, helper: PrefHelper, bits: AccountUi):
setting(
trp.showPlayerRatings(),
frag(
- radios(form("ratings"), booleanChoices),
+ radios(form("ratings"), translatedRatingsChoices),
div(cls := "help text shy", dataIcon := Icon.InfoCircle)(trp.explainShowPlayerRatings())
),
"showRatings"
diff --git a/modules/pref/src/main/ui/PrefHelper.scala b/modules/pref/src/main/ui/PrefHelper.scala
index 062bdc168411a..b8d3c7b087b05 100644
--- a/modules/pref/src/main/ui/PrefHelper.scala
+++ b/modules/pref/src/main/ui/PrefHelper.scala
@@ -20,6 +20,13 @@ trait PrefHelper:
(Pref.Zen.GAME_AUTO, trans.preferences.inGameOnly.txt())
)
+ def translatedRatingsChoices(using Translate) =
+ List(
+ (Pref.Ratings.NO, trans.site.no.txt()),
+ (Pref.Ratings.YES, trans.site.yes.txt()),
+ (Pref.Ratings.EXCEPT_GAME, trans.preferences.exceptInGame.txt())
+ )
+
def translatedBoardCoordinateChoices(using Translate) =
List(
(Pref.Coords.NONE, trans.site.no.txt()),
diff --git a/modules/rating/src/main/PerfType.scala b/modules/rating/src/main/PerfType.scala
index 814931a5f7123..56279b9fc7f13 100644
--- a/modules/rating/src/main/PerfType.scala
+++ b/modules/rating/src/main/PerfType.scala
@@ -163,12 +163,23 @@ enum PerfType(
)
object PerfType:
+
+ // all but standard and puzzle
+ type GamePerf = Bullet.type | Blitz.type | Rapid.type | Classical.type | UltraBullet.type |
+ Correspondence.type | Crazyhouse.type | Chess960.type | KingOfTheHill.type | ThreeCheck.type |
+ Antichess.type | Atomic.type | Horde.type | RacingKings.type
+
+ def gamePerf(pt: PerfType): Option[GamePerf] = pt match
+ case gp: GamePerf => Some(gp)
+ case _ => None
+
given Conversion[PerfType, PerfKey] = _.key
given Conversion[PerfType, PerfId] = _.id
given Conversion[PerfKey, PerfType] = apply(_)
- val all: List[PerfType] = values.toList
- val byKey = all.mapBy(_.key)
- val byId = all.mapBy(_.id)
+
+ val all: List[PerfType] = values.toList
+ val byKey = all.mapBy(_.key)
+ val byId = all.mapBy(_.id)
def apply(key: PerfKey): PerfType =
byKey.getOrElse(key, sys.error(s"Impossible: $key couldn't have been instantiated"))
@@ -197,6 +208,7 @@ object PerfType:
PerfKey.racingKings
)
val isLeaderboardable: Set[PerfKey] = leaderboardable.toSet
+
val variants: List[PerfKey] =
List(
PerfKey.crazyhouse,
diff --git a/modules/recap/src/main/RecapBuilder.scala b/modules/recap/src/main/RecapBuilder.scala
index e8fff9ef67cd9..ff82f385e4c99 100644
--- a/modules/recap/src/main/RecapBuilder.scala
+++ b/modules/recap/src/main/RecapBuilder.scala
@@ -72,6 +72,12 @@ private final class RecapBuilder(
perfs = scan.perfs.toList.sortBy(-_._2).map(Recap.Perf.apply)
)
+ /* This might be made faster by:
+ * - fetching Bdoc instead of Game with a projection
+ * - uncompressing only the moves needed to compute the opening
+ * - using mutable state instead of runFold
+ * as the many little immutable objects hit the GC hard
+ */
private def runGameScan(userId: UserId): Fu[GameScan] =
val query =
Query.createdBetween(dateStart.some, dateEnd.some) ++
diff --git a/modules/ui/src/main/Context.scala b/modules/ui/src/main/Context.scala
index d26d1cb647530..b42d13faf2b3b 100644
--- a/modules/ui/src/main/Context.scala
+++ b/modules/ui/src/main/Context.scala
@@ -28,6 +28,7 @@ trait Context:
def is[U: UserIdOf](u: U): Boolean = me.exists(_.is(u))
def isnt[U: UserIdOf](u: U): Boolean = !is(u)
+ def myId: Option[MyId] = me.map(_.myId)
def noBlind = !blind
def flash(name: String): Option[String] = req.flash.get(name)
inline def noBot = !isBot
diff --git a/modules/ui/src/main/helper/Form3.scala b/modules/ui/src/main/helper/Form3.scala
index d7cfed2820c63..3ad27aa98bf3d 100644
--- a/modules/ui/src/main/helper/Form3.scala
+++ b/modules/ui/src/main/helper/Form3.scala
@@ -177,9 +177,9 @@ final class Form3(formHelper: FormHelper & I18nHelper & AssetHelper, flairApi: F
name := nameValue.map(_._1),
value := nameValue.map(_._2),
cls := List(
- "submit button" -> true,
- "text" -> icon.isDefined,
- "confirm" -> confirm.nonEmpty
+ "submit button" -> true,
+ "text" -> icon.isDefined,
+ "yes-no-confirm" -> confirm.nonEmpty
),
title := confirm
)(content)
diff --git a/modules/ui/src/main/helper/GameHelper.scala b/modules/ui/src/main/helper/GameHelper.scala
index 090515fabfb06..a44a64ba800ad 100644
--- a/modules/ui/src/main/helper/GameHelper.scala
+++ b/modules/ui/src/main/helper/GameHelper.scala
@@ -48,7 +48,7 @@ trait GameHelper:
user.name,
user.flair.map(userFlair),
withRating.option(
- frag(
+ span(cls := "rating")(
" (",
player.rating.fold(frag("?")): rating =>
if player.provisional.yes then
diff --git a/modules/user/src/main/ui/UserShowSide.scala b/modules/user/src/main/ui/UserShowSide.scala
index 0056217c986da..487ba7aeeeeb2 100644
--- a/modules/user/src/main/ui/UserShowSide.scala
+++ b/modules/user/src/main/ui/UserShowSide.scala
@@ -30,7 +30,10 @@ final class UserShowSide(helpers: Helpers):
"active" -> active.contains(pk)
),
href := ctx.pref.showRatings.so:
- if isPuzzle then routes.Puzzle.dashboard(Days(30), "home", u.username.some).url
+ if isPuzzle
+ then
+ val other = ctx.isnt(u).option(u.username)
+ routes.Puzzle.dashboard(Days(30), "home", other).url
else routes.User.perfStat(u.username, pk).url
,
span(
diff --git a/modules/web/src/main/Env.scala b/modules/web/src/main/Env.scala
index 2b48d4ab30608..71d5e2212f9d5 100644
--- a/modules/web/src/main/Env.scala
+++ b/modules/web/src/main/Env.scala
@@ -35,11 +35,6 @@ final class Env(
if mode.isProd then scheduler.scheduleOnce(5 seconds)(influxEvent.start())
private lazy val pagerDuty = wire[PagerDuty]
- val socketTest = SocketTest(
- yoloDb(lila.core.config.CollName("socket_test")).failingSilently(),
- settingStore
- )
-
lila.common.Bus.subscribeFun("announce"):
case lila.core.socket.Announce(msg, date, _) if msg.contains("will restart") =>
pagerDuty.lilaRestart(date)
diff --git a/modules/web/src/main/SocketTest.scala b/modules/web/src/main/SocketTest.scala
deleted file mode 100644
index a7c1c82faf1ca..0000000000000
--- a/modules/web/src/main/SocketTest.scala
+++ /dev/null
@@ -1,33 +0,0 @@
-package lila.web
-
-import play.api.libs.json.*
-
-import lila.db.JSON
-import lila.core.config.NetConfig
-import lila.ui.Context
-
-final class SocketTest(
- resultsDb: lila.db.AsyncCollFailingSilently,
- settingStore: lila.memo.SettingStore.Builder
-)(using Executor):
-
- val distributionSetting = settingStore[Int](
- "socketTestDistribution",
- default = 0,
- text = "Participates to socket test if userId.hashCode % distribution == 0".some
- )
-
- def put(results: JsObject) = resultsDb: coll =>
- coll.insert.one(JSON.bdoc(results)).void
-
- def isUserInTestBucket()(using ctx: Context) =
- distributionSetting.get() > 0 &&
- ctx.pref.usingAltSocket.isEmpty &&
- ctx.userId.exists(_.value.hashCode % distributionSetting.get() == 0)
-
- def socketEndpoints(net: NetConfig)(using ctx: Context): List[String] =
- ctx.pref.usingAltSocket.match
- case Some(true) => net.socketAlts
- case Some(false) => net.socketDomains
- case _ if isUserInTestBucket() => net.socketDomains.head :: net.socketAlts.headOption.toList
- case _ => net.socketDomains
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index e2c1be547ca12..90f25cef40626 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -25,7 +25,7 @@ object Dependencies {
val scalatags = "com.lihaoyi" %% "scalatags" % "0.13.1"
val lettuce = "io.lettuce" % "lettuce-core" % "6.5.1.RELEASE"
val nettyTransport =
- ("io.netty" % s"netty-transport-native-$notifier" % "4.1.115.Final").classifier(s"$os-$arch")
+ ("io.netty" % s"netty-transport-native-$notifier" % "4.1.116.Final").classifier(s"$os-$arch")
val lilaSearch = "org.lichess.search" %% "client" % "3.1.0"
val munit = "org.scalameta" %% "munit" % "1.0.3" % Test
val uaparser = "org.uaparser" %% "uap-scala" % "0.18.0"
diff --git a/translation/source/class.xml b/translation/source/class.xml
index c037cb3407514..5c2bccacb8046 100644
--- a/translation/source/class.xml
+++ b/translation/source/class.xml
@@ -57,6 +57,8 @@ Here is the link to access the class.
- One pending invitation
- %s pending invitations
+ Pending
+ Declined
Only visible to the class teachers
Active
Managed
@@ -114,4 +116,6 @@ It will display a horizontal line.
An invitation has been sent to %s
%s already has a pending invitation
%1$s is a kid account and can't receive your message. You must give them the invitation URL manually: %2$s
+ Move to %s
+ Move to another class
diff --git a/translation/source/features.xml b/translation/source/features.xml
index 335cb1336d2fe..0859204b4e081 100644
--- a/translation/source/features.xml
+++ b/translation/source/features.xml
@@ -24,8 +24,6 @@
UltraBullet, Bullet, Blitz, Rapid, Classical, Correspondence Chess
All features to come, forever!
iPhone & Android phones and tablets, landscape support
- Support Lichess
- Contribute to Lichess and get a cool looking Patron icon
Yes, both accounts have the same features!
We believe every chess player deserves the best, and so:
All features are free for everybody, forever!
diff --git a/translation/source/preferences.xml b/translation/source/preferences.xml
index 2a0040d62e1be..a89094b820e45 100644
--- a/translation/source/preferences.xml
+++ b/translation/source/preferences.xml
@@ -20,6 +20,7 @@
Show board resize handle
Only on initial position
In-game only
+ Except in-game
Chess clock
Tenths of seconds
When time remaining < 10 seconds
diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts
index 8df1de4f1f739..3cf9d394c987c 100644
--- a/ui/@types/lichess/i18n.d.ts
+++ b/ui/@types/lichess/i18n.d.ts
@@ -471,6 +471,8 @@ interface I18n {
createMultipleAccounts: string;
/** Only create accounts for real students. Do not use this to make multiple accounts for yourself. You would get banned. */
createStudentWarning: string;
+ /** Declined */
+ declined: string;
/** Edit news */
editNews: string;
/** Features */
@@ -515,6 +517,10 @@ interface I18n {
maxStudentsNote: I18nFormat;
/** Message all students about new class material */
messageAllStudents: string;
+ /** Move to another class */
+ moveToAnotherClass: string;
+ /** Move to %s */
+ moveToClass: I18nFormat;
/** You can also %s to create multiple Lichess accounts from a list of student names. */
multipleAccsFormDescription: I18nFormat;
/** N/A */
@@ -555,6 +561,8 @@ interface I18n {
overview: string;
/** Password: %s */
passwordX: I18nFormat;
+ /** Pending */
+ pending: string;
/** Private. Will never be shown outside the class. Helps you remember who the student is. */
privateWillNeverBeShown: string;
/** Progress */
@@ -1239,8 +1247,6 @@ interface I18n {
chessInsights: string;
/** Cloud engine analysis */
cloudEngineAnalysis: string;
- /** Contribute to Lichess and get a cool looking Patron icon */
- contributeToLichessAndGetIcon: string;
/** Correspondence chess with conditional premoves */
correspondenceWithConditionalPremoves: string;
/** Deep %s server analysis */
@@ -1269,8 +1275,6 @@ interface I18n {
standardChessAndX: I18nFormat;
/** Studies (shareable and persistent analysis) */
studies: string;
- /** Support Lichess */
- supportLichess: string;
/** Support us with a Patron account! */
supportUsWithAPatronAccount: string;
/** Tactical puzzles from user games */
@@ -2099,6 +2103,8 @@ interface I18n {
displayBoardResizeHandle: string;
/** Drag a piece */
dragPiece: string;
+ /** Except in-game */
+ exceptInGame: string;
/** Can be disabled during a game with the board menu */
explainCanThenBeTemporarilyDisabled: string;
/** Hold the key while promoting to temporarily disable auto-promotion */
diff --git a/ui/analyse/src/explorer/explorerConfig.ts b/ui/analyse/src/explorer/explorerConfig.ts
index 2c7c468bf0a1b..72935df2c22e3 100644
--- a/ui/analyse/src/explorer/explorerConfig.ts
+++ b/ui/analyse/src/explorer/explorerConfig.ts
@@ -83,7 +83,7 @@ export class ExplorerConfigCtrl {
value: storedStringProp('analyse.explorer.player.name', this.myName || ''),
previous: storedJsonProp('explorer.player.name.previous', () => []),
},
- color: prevData?.color || prop('white'),
+ color: prevData?.color || prop(root.bottomColor()),
byDb() {
return this.byDbData[this.db()] || this.byDbData.lichess;
},
diff --git a/ui/bits/css/_feature.scss b/ui/bits/css/_feature.scss
index 7b0fe2b5a13e4..c8d2f7178375a 100644
--- a/ui/bits/css/_feature.scss
+++ b/ui/bits/css/_feature.scss
@@ -42,15 +42,6 @@
font-size: 1.4em;
}
- .price {
- font-size: 1.4em;
- }
-
- .price > * {
- border: none;
- padding-top: 30px;
- }
-
.explanation {
@extend %box-neat;
@@ -101,10 +92,6 @@
font-weight: normal;
}
- .price {
- display: none;
- }
-
.explanation {
font-size: 1.1em;
margin: 3em 0 1em 0;
diff --git a/ui/common/css/component/_mini-game.scss b/ui/common/css/component/_mini-game.scss
index 5a17cd71cf689..5f419bf712ce6 100644
--- a/ui/common/css/component/_mini-game.scss
+++ b/ui/common/css/component/_mini-game.scss
@@ -44,6 +44,9 @@
margin-inline-start: 1ch;
font-size: 0.9em;
+ body.no-rating & {
+ display: none;
+ }
}
&__clock {
diff --git a/ui/common/css/component/_power-tip.scss b/ui/common/css/component/_power-tip.scss
index 29066ac33ddc1..b46e114d311b7 100644
--- a/ui/common/css/component/_power-tip.scss
+++ b/ui/common/css/component/_power-tip.scss
@@ -67,6 +67,9 @@
padding: 2px 3px;
text-align: left;
}
+ body.no-rating & {
+ display: none;
+ }
}
&__warning {
diff --git a/ui/common/css/component/_user-link.scss b/ui/common/css/component/_user-link.scss
index 0e46ff4907ae6..5b43bb0856970 100644
--- a/ui/common/css/component/_user-link.scss
+++ b/ui/common/css/component/_user-link.scss
@@ -47,6 +47,11 @@
content: $licon-Agent;
}
}
+ .rating {
+ body.no-rating & {
+ display: none;
+ }
+ }
}
a.user-link:hover {
diff --git a/ui/common/src/socket.ts b/ui/common/src/socket.ts
index a0f48eabd9730..06eb0334e25b2 100644
--- a/ui/common/src/socket.ts
+++ b/ui/common/src/socket.ts
@@ -1,7 +1,6 @@
import * as xhr from './xhr';
import { idleTimer, browserTaskQueueMonitor } from './timing';
import { storage, once, type LichessStorage } from './storage';
-import { objectStorage, nonEmptyStore, type ObjectStorage } from './objectStorage';
import { pubsub, type PubsubEvent } from './pubsub';
import { myUserId } from './common';
@@ -103,12 +102,6 @@ class WsSocket {
private lastUrl?: string;
private heartbeat = browserTaskQueueMonitor(1000);
- private isTestRunning = document.body.dataset.socketTestRunning === 'true';
- private stats: { store?: ObjectStorage; m2: number; n: number; mean: number } = {
- m2: 0,
- n: 0,
- mean: 0,
- };
constructor(
readonly url: string,
@@ -136,8 +129,6 @@ class WsSocket {
this.version = version;
pubsub.on('socket.send', this.send);
this.connect();
- this.flushStats();
- window.addEventListener('pagehide', () => this.storeStats({ event: 'pagehide' }));
}
sign = (s: string): void => {
@@ -226,7 +217,6 @@ class WsSocket {
private scheduleConnect = (delay: number = this.options.pongTimeout): void => {
if (this.options.idle) delay = 10 * 1000 + Math.random() * 10 * 1000;
- // debug('schedule connect ' + delay);
clearTimeout(this.pingSchedule);
clearTimeout(this.connectSchedule);
this.connectSchedule = setTimeout(() => {
@@ -275,7 +265,6 @@ class WsSocket {
this.averageLag += mix * (currentLag - this.averageLag);
pubsub.emit('socket.lag', this.averageLag);
- this.updateStats(currentLag);
};
private handle = (m: MsgIn): void => {
@@ -313,7 +302,6 @@ class WsSocket {
};
destroy = (): void => {
- this.storeStats();
clearTimeout(this.pingSchedule);
clearTimeout(this.connectSchedule);
this.disconnect();
@@ -339,7 +327,6 @@ class WsSocket {
pubsub.emit('socket.close');
if (this.heartbeat.wasSuspended) return this.onSuspended();
- this.storeStats({ event: 'close', code: e.code });
if (this.ws) {
this.debug('Will autoreconnect in ' + this.options.autoReconnectDelay);
@@ -374,7 +361,7 @@ class WsSocket {
this.heartbeat.reset(); // not a networking error, just get our connection back
clearTimeout(this.pingSchedule);
clearTimeout(this.connectSchedule);
- this.storeStats({ event: 'suspend' }).then(this.connect);
+ this.connect();
}
private nextBaseUrl = (): string => {
@@ -382,7 +369,7 @@ class WsSocket {
if (!url || !this.baseUrls.includes(url)) {
url = this.baseUrls[Math.floor(Math.random() * this.baseUrls.length)];
this.storage.set(url);
- } else if (this.isTestRunning || this.tryOtherUrl) {
+ } else if (this.tryOtherUrl) {
const i = this.baseUrls.findIndex(u => u === url);
url = this.baseUrls[(i + 1) % this.baseUrls.length];
this.storage.set(url);
@@ -393,56 +380,6 @@ class WsSocket {
pingInterval = (): number => this.computePingDelay() + this.averageLag;
getVersion = (): number | false => this.version;
-
- private async storeStats(event?: any) {
- if (!this.lastUrl || !this.isTestRunning) return;
- if (!event && this.stats.n < 2) return;
-
- const data = {
- dns: this.lastUrl.includes(`//${this.baseUrls[0]}`) ? 'ovh' : 'cf',
- n: this.stats.n,
- ...event,
- };
- if (this.stats.n > 0) data.mean = this.stats.mean;
- if (this.stats.n > 1) data.stdev = Math.sqrt(this.stats.m2 / (this.stats.n - 1));
- this.stats.m2 = this.stats.n = this.stats.mean = 0;
-
- localStorage.setItem(`socket.test.${myUserId()}`, JSON.stringify(data));
- return this.flushStats();
- }
-
- private async flushStats() {
- const dbInfo = { db: `socket.test.${myUserId()}--db`, store: `socket.test.${myUserId()}` };
- const last = localStorage.getItem(dbInfo.store);
-
- if (this.isTestRunning || last || (await nonEmptyStore(dbInfo))) {
- try {
- this.stats.store ??= await objectStorage(dbInfo);
- if (last) await this.stats.store.put(await this.stats.store.count(), JSON.parse(last));
-
- if (this.isTestRunning) return;
-
- const data = await this.stats.store.getMany();
- const rsp = await fetch('/dev/socket-test', {
- method: 'POST',
- body: JSON.stringify(data),
- headers: { 'Content-Type': 'application/json' },
- });
- if (rsp.ok) window.indexedDB.deleteDatabase(dbInfo.db);
- } finally {
- localStorage.removeItem(dbInfo.store);
- }
- }
- }
-
- private updateStats(lag: number) {
- if (!this.isTestRunning) return;
-
- this.stats.n++;
- const delta = lag - this.stats.mean;
- this.stats.mean += delta / this.stats.n;
- this.stats.m2 += delta * (lag - this.stats.mean);
- }
}
class Ackable {
diff --git a/ui/puzzle/src/report.ts b/ui/puzzle/src/report.ts
index ed0fff670bb81..354a12f507d2a 100644
--- a/ui/puzzle/src/report.ts
+++ b/ui/puzzle/src/report.ts
@@ -14,7 +14,7 @@ export default class Report {
tsHideReportDialog: StoredProp;
// bump when logic is changed, to distinguish cached clients from new ones
- private version = 5;
+ private version = 6;
constructor() {
this.tsHideReportDialog = storedIntProp('puzzle.report.hide.ts', 0);
@@ -47,7 +47,7 @@ export default class Report {
ctrl.mainline.some((n: Tree.Node) => n.id === node.id)
) {
const [bestEval, secondBestEval] = [ev.pvs[0], ev.pvs[1]];
- // stricly identical to lichess-puzzler v49 check
+ // stricter than lichess-puzzler v49 check in how it defines similar moves
if (
(ev.depth > 50 || ev.nodes > 25_000_000) &&
bestEval &&
@@ -56,7 +56,9 @@ export default class Report {
) {
// in all case, we do not want to show the dialog more than once
this.reported = true;
- const reason = `(v${this.version}) after move ${plyToTurn(node.ply)}. ${node.san}, at depth ${ev.depth}, multiple solutions, pvs ${ev.pvs.map(pv => `${pv.moves[0]}: ${showPv(pv)}`).join(', ')}`;
+ const engine = ctrl.ceval.engines.active;
+ const engineName = engine?.short || engine.name;
+ const reason = `(v${this.version}, ${engineName}) after move ${plyToTurn(node.ply)}. ${node.san}, at depth ${ev.depth}, multiple solutions, pvs ${ev.pvs.map(pv => `${pv.moves[0]}: ${showPv(pv)}`).join(', ')}`;
this.reportDialog(ctrl.data.puzzle.id, reason);
}
}
diff --git a/ui/recap/css/_recap.scss b/ui/recap/css/_recap.scss
index 44e3129a00949..3580815145c4b 100644
--- a/ui/recap/css/_recap.scss
+++ b/ui/recap/css/_recap.scss
@@ -7,6 +7,7 @@ body {
&,
.site-title,
.site-title span,
+ .site-title:hover span,
.site-buttons .link {
#user_tag::after,
&,
@@ -159,31 +160,16 @@ body {
}
.recap__shareable {
- .logo {
- width: 60%;
- max-width: 400px;
- }
+ .logo,
h2 {
- font-size: 1.5em;
- margin: 0.4em 0 1.5em;
- }
- .stat {
- font-size: 1.5em;
- @media (max-width: at-most($x-small)) {
- font-size: 1.3em;
- }
- @media (max-width: at-most($xx-small)) {
- font-size: 1.2em;
- }
+ display: none;
}
.grid {
display: flex;
flex-wrap: wrap;
- row-gap: 1.5em;
- padding: 0.5em;
+ row-gap: 0.5em;
.stat {
- flex: 50%;
a {
border: none;
@@ -195,32 +181,71 @@ body {
}
.openings {
- margin-top: 2em;
+ margin-top: 1em;
+ display: flex;
.stat {
+ flex: 50%;
+ font-size: 0.8em;
margin-top: 1em;
}
}
- @media (max-height: 650px) {
+}
+
+@media screen and (orientation: portrait) {
+ .recap__shareable .grid .stat {
+ flex: 50%;
+ }
+}
+@media screen and (orientation: landscape) {
+ .recap__shareable .grid .stat {
+ flex: 33%;
+ }
+}
+
+@media (min-height: at-least($short)) {
+ .recap__shareable {
.logo {
- height: 20px;
- width: auto;
+ display: inline;
+ height: 30px;
}
h2 {
- font-size: 1.2em;
- margin: 0.4em 0;
+ display: block;
+ margin: 0.5em 0;
+ }
+ .grid {
+ row-gap: 1em;
+ padding: 0.5em;
+ }
+ }
+}
+
+@media (min-width: at-least($x-small)) and (max-width: at-most($small)) and (min-height: at-least($tall)) and (max-width: at-most($x-tall)) {
+ .recap__shareable {
+ .logo {
+ height: 40px;
+ }
+ }
+}
+
+@media (min-width: at-least($small)) and (max-width: at-most($large)), (min-height: at-least($x-tall)) {
+ .recap__shareable {
+ .logo {
+ height: 40px;
}
.openings {
- margin-top: 1em;
+ margin-top: 2em;
.stat {
font-size: 1em;
- margin-top: 0.5em;
}
}
}
- @media (min-width: at-least($xx-small)) {
+}
+
+@media (min-width: at-least($large)) and (min-height: at-least($x-tall)) {
+ .recap__shareable {
.logo {
- display: none;
+ height: 80px;
}
}
}
diff --git a/ui/round/css/_user.scss b/ui/round/css/_user.scss
index 9f557e045f141..d0b3512c6113a 100644
--- a/ui/round/css/_user.scss
+++ b/ui/round/css/_user.scss
@@ -26,6 +26,9 @@
margin: 0 0.25em 0 0.3em;
color: $c-font-dim;
letter-spacing: -0.5px;
+ body.no-rating & {
+ display: none;
+ }
}
.line {
diff --git a/ui/round/src/view/replay.ts b/ui/round/src/view/replay.ts
index 4e05ed293ad31..957c3c5acfdca 100644
--- a/ui/round/src/view/replay.ts
+++ b/ui/round/src/view/replay.ts
@@ -146,28 +146,23 @@ const goThroughMoves = (ctrl: RoundController, e: Event) => {
function renderButtons(ctrl: RoundController) {
const firstPly = util.firstPly(ctrl.data),
lastPly = util.lastPly(ctrl.data);
- return h(
- 'div.buttons',
- {
- hook: onInsert(bindMobileMousedown(e => goThroughMoves(ctrl, e))),
- },
- [
- analysisButton(ctrl) || h('div.noop'),
- ...[
- [licon.JumpFirst, firstPly],
- [licon.JumpPrev, ctrl.ply - 1],
- [licon.JumpNext, ctrl.ply + 1],
- [licon.JumpLast, lastPly],
- ].map((b: [string, number], i) => {
- const enabled = ctrl.ply !== b[1] && b[1] >= firstPly && b[1] <= lastPly;
- return h('button.fbt', {
- class: { glowing: i === 3 && ctrl.isLate() },
- attrs: { disabled: !enabled, 'data-icon': b[0], 'data-ply': enabled ? b[1] : '-' },
- });
- }),
- boardMenuToggleButton(ctrl.menu, i18n.site.menu),
- ],
- );
+ return h('div.buttons', [
+ analysisButton(ctrl) || h('div.noop'),
+ ...[
+ [licon.JumpFirst, firstPly],
+ [licon.JumpPrev, ctrl.ply - 1],
+ [licon.JumpNext, ctrl.ply + 1],
+ [licon.JumpLast, lastPly],
+ ].map((b: [string, number], i) => {
+ const enabled = ctrl.ply !== b[1] && b[1] >= firstPly && b[1] <= lastPly;
+ return h('button.fbt.repeatable', {
+ class: { glowing: i === 3 && ctrl.isLate() },
+ attrs: { disabled: !enabled, 'data-icon': b[0], 'data-ply': enabled ? b[1] : '-' },
+ hook: onInsert(bindMobileMousedown(e => goThroughMoves(ctrl, e))),
+ });
+ }),
+ boardMenuToggleButton(ctrl.menu, i18n.site.menu),
+ ]);
}
function initMessage(ctrl: RoundController) {