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) {