diff --git a/app/controllers/Study.scala b/app/controllers/Study.scala index 6a9e37603f762..e3d7e00c1aacd 100644 --- a/app/controllers/Study.scala +++ b/app/controllers/Study.scala @@ -16,7 +16,7 @@ import lila.core.study.Order import lila.study.JsonView.JsData import lila.study.PgnDump.WithFlags import lila.study.Study.WithChapter -import lila.study.actorApi.{ BecomeStudyAdmin, Who } +import lila.study.{ BecomeStudyAdmin, Who } import lila.study.{ Chapter, Orders, Settings, Study as StudyModel, StudyForm } import lila.tree.Node.partitionTreeJsonWriter import com.fasterxml.jackson.core.JsonParseException diff --git a/modules/common/src/main/mon.scala b/modules/common/src/main/mon.scala index 7d4ed6f6b172a..855dd94f1ca85 100644 --- a/modules/common/src/main/mon.scala +++ b/modules/common/src/main/mon.scala @@ -289,7 +289,7 @@ object mon: timer("relay.sync.time").withTags(relay(official, id, slug)) def httpGet(code: Int, host: String, etag: String, proxy: Option[String]) = timer("relay.http.get").withTags: - tags("code" -> code, "host" -> host, "etag" -> etag, "proxy" -> proxy.getOrElse("none")) + tags("code" -> code.toLong, "host" -> host, "etag" -> etag, "proxy" -> proxy.getOrElse("none")) val dedup = counter("relay.fetch.dedup").withoutTags() object bot: diff --git a/modules/practice/src/main/Env.scala b/modules/practice/src/main/Env.scala index 894ac94cd8dfa..7feda25a58c4e 100644 --- a/modules/practice/src/main/Env.scala +++ b/modules/practice/src/main/Env.scala @@ -21,6 +21,6 @@ final class Env( def getStudies: lila.core.practice.GetStudies = api.structure.getStudies - lila.common.Bus.subscribeFun("study") { case lila.study.actorApi.SaveStudy(study) => + lila.common.Bus.subscribeFun("study") { case lila.study.SaveStudy(study) => api.structure.onSave(study) } diff --git a/modules/relay/src/main/Env.scala b/modules/relay/src/main/Env.scala index 84efd8e52a0f0..4cc805748fd10 100644 --- a/modules/relay/src/main/Env.scala +++ b/modules/relay/src/main/Env.scala @@ -144,19 +144,19 @@ final class Env( "study" -> { case lila.core.study.RemoveStudy(studyId) => api.onStudyRemove(studyId) }, - "relayToggle" -> { case lila.study.actorApi.RelayToggle(id, v, who) => + "relayToggle" -> { case lila.study.RelayToggle(id, v, who) => studyApi .isContributor(id, who.u) .foreach: _.so(api.requestPlay(id.into(RelayRoundId), v, "manual toggle")) }, - "kickStudy" -> { case lila.study.actorApi.Kick(studyId, userId, who) => + "kickStudy" -> { case lila.study.Kick(studyId, userId, who) => roundRepo.tourIdByStudyId(studyId).flatMapz(api.kickBroadcast(userId, _, who)) }, - "adminStudy" -> { case lila.study.actorApi.BecomeStudyAdmin(studyId, me) => + "adminStudy" -> { case lila.study.BecomeStudyAdmin(studyId, me) => api.becomeStudyAdmin(studyId, me) }, - "isOfficialRelay" -> { case lila.study.actorApi.IsOfficialRelay(studyId, promise) => + "isOfficialRelay" -> { case lila.study.IsOfficialRelay(studyId, promise) => promise.completeWith(api.isOfficial(studyId.into(RelayRoundId))) } ) diff --git a/modules/relay/src/main/RelayDelay.scala b/modules/relay/src/main/RelayDelay.scala index 9c380bd031260..ae43d28b12e7b 100644 --- a/modules/relay/src/main/RelayDelay.scala +++ b/modules/relay/src/main/RelayDelay.scala @@ -19,7 +19,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): ): Fu[RelayGames] = dedupCache(url, round, () => doFetchUrl(url)) .flatMap: latest => - round.sync.delay match + round.sync.delayMinusLag match case Some(delay) if delay > 0 => store.get(url, delay).map(_ | latest.map(_.resetToSetup)) case _ => fuccess(latest) @@ -48,7 +48,7 @@ final private class RelayDelay(colls: RelayColls)(using Executor): ) .games .addEffect: games => - if round.sync.hasDelay then store.putIfNew(url, games) + if round.sync.delayMinusLag.isDefined then store.putIfNew(url, games) private object store: diff --git a/modules/relay/src/main/RelayFetch.scala b/modules/relay/src/main/RelayFetch.scala index 208c381c72ea8..43fa4b3454e5c 100644 --- a/modules/relay/src/main/RelayFetch.scala +++ b/modules/relay/src/main/RelayFetch.scala @@ -147,16 +147,9 @@ final private class RelayFetch( private def continueRelay(tour: RelayTour, updating: Updating[RelayRound]): Updating[RelayRound] = val round = updating.current round.sync.upstream.fold(updating): upstream => + reportBroadcastFailure(round.withTour(tour)) val seconds: Seconds = if round.sync.log.alwaysFails then - round.sync.log.events.lastOption - .filterNot(_.isTimeout) - .flatMap(_.error) - .ifTrue(tour.official && round.shouldHaveStarted) - .filterNot(_.contains("Cannot parse move")) - .filterNot(_.contains("Cannot parse pgn")) - .filterNot(_.contains("Found an empty PGN")) - .foreach { irc.broadcastError(round.id, round.withTour(tour).fullName, _) } Seconds(tour.tier.fold(60): case RelayTour.Tier.best => 10 case RelayTour.Tier.high => 20 @@ -173,6 +166,17 @@ final private class RelayFetch( }.some ) + private def reportBroadcastFailure(r: RelayRound.WithTour): Unit = + if r.round.sync.log.alwaysFails then + r.round.sync.log.events.lastOption + .filterNot(_.isTimeout) + .flatMap(_.error) + .ifTrue(r.tour.official && r.round.shouldHaveStarted) + .filterNot(_.contains("Cannot parse move")) + .filterNot(_.contains("Cannot parse pgn")) + .filterNot(_.contains("Found an empty PGN")) + .foreach { irc.broadcastError(r.round.id, r.fullName, _) } + private def dynamicPeriod(tour: RelayTour, round: RelayRound, upstream: Sync.Upstream) = Seconds: val base = if upstream.hasLcc then 5 diff --git a/modules/relay/src/main/RelayPlayerEnrich.scala b/modules/relay/src/main/RelayPlayerEnrich.scala index 9baefdfa247c4..f79fc3d72ae10 100644 --- a/modules/relay/src/main/RelayPlayerEnrich.scala +++ b/modules/relay/src/main/RelayPlayerEnrich.scala @@ -204,6 +204,6 @@ private final class RelayPlayerEnrich( chapterId = chapter.id, tags = enriched, newName = newName.filter(_ != chapter.name) - )(lila.study.actorApi.Who(chapter.ownerId, Sri(""))) + )(lila.study.Who(chapter.ownerId, Sri(""))) .runWith(Sink.ignore) yield () diff --git a/modules/relay/src/main/RelayPush.scala b/modules/relay/src/main/RelayPush.scala index 70d63663e567e..248f883800954 100644 --- a/modules/relay/src/main/RelayPush.scala +++ b/modules/relay/src/main/RelayPush.scala @@ -40,7 +40,7 @@ final class RelayPush( parsed.map(_.map(g => Success(g.tags, g.root.mainline.size))) val andSyncTargets = response.exists(_.isRight) - rt.round.sync.nonEmptyDelay + rt.round.sync.delayMinusLag .ifTrue(games.exists(_.root.children.nonEmpty)) .match case None => push(rt, games, andSyncTargets).inject(response) diff --git a/modules/relay/src/main/RelayRound.scala b/modules/relay/src/main/RelayRound.scala index 6d6b7d62a4029..8b1a575559d4d 100644 --- a/modules/relay/src/main/RelayRound.scala +++ b/modules/relay/src/main/RelayRound.scala @@ -126,8 +126,9 @@ object RelayRound: def addLog(event: SyncLog.Event) = copy(log = log.add(event)) def clearLog = copy(log = SyncLog.empty) - def nonEmptyDelay = delay.filter(_.value > 0) - def hasDelay = nonEmptyDelay.isDefined + // subtract estimated source polling lag from transmission delay + private def pollingLag = Seconds(if isPush then 1 else 6) + def delayMinusLag = delay.map(_ - pollingLag).filter(_.value > 0) override def toString = upstream.toString diff --git a/modules/relay/src/main/RelaySync.scala b/modules/relay/src/main/RelaySync.scala index ad8f0a5643824..8b56af0aa98c8 100644 --- a/modules/relay/src/main/RelaySync.scala +++ b/modules/relay/src/main/RelaySync.scala @@ -6,6 +6,7 @@ import chess.format.pgn.{ Tag, Tags } import lila.core.socket.Sri import lila.study.* import lila.tree.Branch +import lila.study.AddNode final private class RelaySync( studyApi: StudyApi, @@ -91,20 +92,19 @@ final private class RelaySync( studyId = study.id, position = Position(chapter, path).ref, toMainline = true - )(by) >> chapterRepo.setRelayPath(chapter.id, path) + )(using by) >> chapterRepo.setRelayPath(chapter.id, path) _ <- newNode match case Some(newNode) => newNode.mainline .foldM(Position(chapter, path).ref): (position, n) => - studyApi - .addNode( - studyId = study.id, - position = position, - node = n, - opts = moveOpts, - relay = makeRelayFor(game, position.path + n.id).some - )(by) - .inject(position + n) + val node = AddNode( + studyId = study.id, + positionRef = position, + node = n, + opts = moveOpts, + relay = makeRelayFor(game, position.path + n.id).some + )(using by) + studyApi.addNode(node).inject(position + n) case None => // the chapter already has all the game moves, // but its relayPath might be out of sync. This can happen if the broadcast @@ -121,13 +121,14 @@ final private class RelaySync( game.root.children .nodeAt(gameMainlinePath) .so: lastMainlineNode => - studyApi.addNode( - studyId = study.id, - position = Position(chapter, gameMainlinePath.parent).ref, - node = lastMainlineNode, - opts = moveOpts, - relay = makeRelayFor(game, gameMainlinePath).some - )(by) + studyApi.addNode: + AddNode( + studyId = study.id, + positionRef = Position(chapter, gameMainlinePath.parent).ref, + node = lastMainlineNode, + opts = moveOpts, + relay = makeRelayFor(game, gameMainlinePath).some + )(using by) yield newNode.so(_.mainline.size) private def updateChapterTags( @@ -212,7 +213,7 @@ final private class RelaySync( ) private val sri = Sri("") - private def who(userId: UserId) = actorApi.Who(userId, sri) + private def who(userId: UserId) = Who(userId, sri) private def vs(tags: Tags) = s"${tags(_.White) | "?"} - ${tags(_.Black) | "?"}" diff --git a/modules/relay/src/main/ui/RelayTourUi.scala b/modules/relay/src/main/ui/RelayTourUi.scala index b0a03e27dffd9..52c9dba177298 100644 --- a/modules/relay/src/main/ui/RelayTourUi.scala +++ b/modules/relay/src/main/ui/RelayTourUi.scala @@ -38,7 +38,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): nonEmptyTier(_.high), nonEmptyTier(_.normal), h2(cls := "relay-index__section")(trc.pastBroadcasts()), - div(cls := "relay-cards relay-cards--past"): + div(cls := "relay-cards"): past.map: t => card.render(t, live = _ => false) , @@ -150,7 +150,7 @@ final class RelayTourUi(helpers: Helpers, ui: RelayUi): div(cls := "page-menu__content box box-pad")( boxTop(h1(dataIcon := Icon.RadioTower, cls := "text")(trc.broadcastCalendar())), dateForm("top"), - div(cls := "relay-cards relay-cards--past"): + div(cls := "relay-cards"): tours.map(card.renderCalendar) , (tours.sizeIs > 8).option(dateForm("bottom")) diff --git a/modules/security/src/main/EmailAddressValidator.scala b/modules/security/src/main/EmailAddressValidator.scala index e1ceb1efc5504..b772adf791978 100644 --- a/modules/security/src/main/EmailAddressValidator.scala +++ b/modules/security/src/main/EmailAddressValidator.scala @@ -43,7 +43,8 @@ final class EmailAddressValidator( // only compute valid and non-whitelisted email domains private[security] def apply(e: EmailAddress): Fu[Result] = - e.domain.map(_.lower).fold(fuccess(Result.DomainMissing))(validateDomain) + if isInfiniteAlias(e) then fuccess(Result.Alias) + else e.domain.map(_.lower).fold(fuccess(Result.DomainMissing))(validateDomain) private[security] def validateDomain(domain: Domain.Lower): Fu[Result] = if DisposableEmailDomain.whitelisted(domain.into(Domain)) then fuccess(Result.Passlist) @@ -95,6 +96,14 @@ final class EmailAddressValidator( case (acc, _) => acc if variations.isEmpty then List(email) else variations + private def isInfiniteAlias(e: EmailAddress) = + duckAliases.is(e) + + private object duckAliases: + private val domain = Domain.Lower.from("duck.com") + private val regex = """^\w{3,}-\w{3,}-\w{3,}$""".r + def is(e: EmailAddress) = e.nameAndDomain.exists((n, d) => d.lower == domain && regex.matches(n)) + private def wasUsedTwiceRecently(email: EmailAddress): Fu[Boolean] = userRepo.countRecentByPrevEmail(email.normalize, nowInstant.minusWeeks(1)).dmap(_ >= 2) >>| userRepo.countRecentByPrevEmail(email.normalize, nowInstant.minusMonths(1)).dmap(_ >= 4) @@ -106,10 +115,8 @@ object EmailAddressValidator: case Alright extends Result(none) case DomainMissing extends Result("The email address domain is missing.".some) // no translation needed case Blocklist extends Result("Cannot use disposable email addresses (Blocklist).".some) + case Alias extends Result("Cannot use email address aliases.".some) case DnsMissing extends Result("This email domain doesn't seem to work (missing MX DNS)".some) case DnsTimeout extends Result("This email domain doesn't seem to work (timeout MX DNS)".some) - case DnsBlocklist - extends Result( - "Cannot use disposable email addresses (DNS blocklist).".some - ) - case Reputation extends Result("This email domain has a poor reputation and cannot be used.".some) + case DnsBlocklist extends Result("Cannot use disposable email addresses (DNS blocklist).".some) + case Reputation extends Result("This email domain has a poor reputation and cannot be used.".some) diff --git a/modules/security/src/main/VerifyMail.scala b/modules/security/src/main/VerifyMail.scala index 23edd96d7ab40..9dc04fc0954e4 100644 --- a/modules/security/src/main/VerifyMail.scala +++ b/modules/security/src/main/VerifyMail.scala @@ -71,7 +71,7 @@ final private class VerifyMail( if res.status == 429 then logger.info(s"Mailcheck rate limited $url") - rateLimitedUntil = nowInstant.plusMinutes(2) + rateLimitedUntil = nowInstant.plusMinutes(5) true else (for diff --git a/modules/study/src/main/Env.scala b/modules/study/src/main/Env.scala index c12c7b968433f..b0a9be849aac4 100644 --- a/modules/study/src/main/Env.scala +++ b/modules/study/src/main/Env.scala @@ -65,7 +65,7 @@ final class Env( private lazy val chapterMaker = wire[ChapterMaker] - private lazy val explorerGame = wire[ExplorerGame] + private lazy val explorerGame = wire[ExplorerGameApi] private lazy val studyMaker = wire[StudyMaker] diff --git a/modules/study/src/main/ExplorerGame.scala b/modules/study/src/main/ExplorerGame.scala index 8666062d10c6d..f4c069093deab 100644 --- a/modules/study/src/main/ExplorerGame.scala +++ b/modules/study/src/main/ExplorerGame.scala @@ -6,7 +6,7 @@ import chess.format.{ Fen, UciPath } import lila.tree.Node.Comment import lila.tree.{ Branch, Node, Root } -final private class ExplorerGame( +final private class ExplorerGameApi( explorer: lila.core.game.Explorer, namer: lila.core.game.Namer, lightUserApi: lila.core.user.LightUserApi, diff --git a/modules/study/src/main/JsonView.scala b/modules/study/src/main/JsonView.scala index 2faec52644254..73e31f3b88a97 100644 --- a/modules/study/src/main/JsonView.scala +++ b/modules/study/src/main/JsonView.scala @@ -218,5 +218,5 @@ object JsonView: private[study] given Writes[Chapter.ServerEval] = Json.writes - private[study] given OWrites[actorApi.Who] = OWrites: w => + private[study] given OWrites[Who] = OWrites: w => Json.obj("u" -> w.u, "s" -> w.sri) diff --git a/modules/study/src/main/StudyApi.scala b/modules/study/src/main/StudyApi.scala index 21197f0e144f5..b37182b242d59 100644 --- a/modules/study/src/main/StudyApi.scala +++ b/modules/study/src/main/StudyApi.scala @@ -13,8 +13,6 @@ import lila.core.timeline.{ Propagate, StudyLike } import lila.tree.Branch import lila.tree.Node.{ Comment, Gamebook, Shapes } -import actorApi.Who - final class StudyApi( studyRepo: StudyRepo, chapterRepo: ChapterRepo, @@ -22,7 +20,7 @@ final class StudyApi( studyMaker: StudyMaker, chapterMaker: ChapterMaker, inviter: StudyInvite, - explorerGameHandler: ExplorerGame, + explorerGameHandler: ExplorerGameApi, topicApi: StudyTopicApi, lightUserApi: lila.core.user.LightUserApi, chatApi: lila.core.chat.ChatApi, @@ -225,27 +223,21 @@ final class StudyApi( yield sendTo(study.id)(_.setPath(position, who)) case _ => funit - def addNode( - studyId: StudyId, - position: Position.Ref, - node: Branch, - opts: MoveOpts, - relay: Option[Chapter.Relay] = None - )(who: Who): Funit = - sequenceStudyWithChapter(studyId, position.chapterId): + def addNode(args: AddNode): Funit = + import args.{ *, given } + sequenceStudyWithChapter(studyId, positionRef.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): - doAddNode(study, Position(chapter, position.path), node, opts, relay)(who) + doAddNode(args, study, Position(chapter, positionRef.path)) .flatMapz { _() } private def doAddNode( + args: AddNode, study: Study, - position: Position, - rawNode: Branch, - opts: MoveOpts, - relay: Option[Chapter.Relay] - )(who: Who): Fu[Option[() => Funit]] = - val singleNode = rawNode.withoutChildren + position: Position + ): Fu[Option[() => Funit]] = + import args.{ *, given } + val singleNode = args.node.withoutChildren def failReload() = reloadSriBecauseOf(study, who.sri, position.chapter.id) if position.chapter.isOverweight then logger.info(s"Overweight chapter ${study.id}/${position.chapter.id}") @@ -272,7 +264,7 @@ final class StudyApi( isMainline = newPosition.path.isMainline(chapter.root) promoteToMainline = opts.promoteToMainline && !isMainline yield promoteToMainline.option: () => - promote(study.id, position.ref + node, toMainline = true)(who) + promote(study.id, position.ref + node, toMainline = true) } } @@ -326,7 +318,7 @@ final class StudyApi( yield onChapterChange(study.id, chapter.id, who) // rewrites the whole chapter because of `forceVariation`. Very inefficient. - def promote(studyId: StudyId, position: Position.Ref, toMainline: Boolean)(who: Who): Funit = + def promote(studyId: StudyId, position: Position.Ref, toMainline: Boolean)(using who: Who): Funit = sequenceStudyWithChapter(studyId, position.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): @@ -441,7 +433,7 @@ final class StudyApi( reloadSriBecauseOf(sc.study, who.sri, position.chapterId) fufail(s"Invalid setClock $position $clock") - def setTag(studyId: StudyId, setTag: actorApi.SetTag)(who: Who) = + def setTag(studyId: StudyId, setTag: SetTag)(who: Who) = sequenceStudyWithChapter(studyId, setTag.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): @@ -545,7 +537,7 @@ final class StudyApi( reloadSriBecauseOf(study, who.sri, chapter.id) fufail(s"Invalid setGamebook $studyId $position") - def explorerGame(studyId: StudyId, data: actorApi.ExplorerGame)(who: Who) = + def explorerGame(studyId: StudyId, data: ExplorerGame)(who: Who) = sequenceStudyWithChapter(studyId, data.position.chapterId): case Study.WithChapter(study, chapter) => Contribute(who.u, study): diff --git a/modules/study/src/main/StudySocket.scala b/modules/study/src/main/StudySocket.scala index a53099cee2154..00aeae7fb29e6 100644 --- a/modules/study/src/main/StudySocket.scala +++ b/modules/study/src/main/StudySocket.scala @@ -13,8 +13,6 @@ import lila.tree.Branch import lila.tree.Node.{ Comment, Gamebook, Shape, Shapes } import lila.tree.Node.minimalNodeJsonWriter -import actorApi.Who - final private class StudySocket( api: StudyApi, jsonView: JsonView, @@ -75,13 +73,13 @@ final private class StudySocket( AnaMove .parse(o) .foreach: move => - applyWho(moveOrDrop(studyId, move, MoveOpts.parse(o))) + applyWho(moveOrDrop(studyId, move, MoveOpts.parse(o))(using _)) case "anaDrop" => AnaDrop .parse(o) .foreach: drop => - applyWho(moveOrDrop(studyId, drop, MoveOpts.parse(o))) + applyWho(moveOrDrop(studyId, drop, MoveOpts.parse(o))(using _)) case "deleteNode" => reading[AtPosition](o): position => @@ -96,7 +94,7 @@ final private class StudySocket( (o \ "d" \ "toMainline") .asOpt[Boolean] .foreach: toMainline => - applyWho(api.promote(studyId, position.ref, toMainline)) + applyWho(api.promote(studyId, position.ref, toMainline)(using _)) case "forceVariation" => reading[AtPosition](o): position => @@ -114,7 +112,7 @@ final private class StudySocket( .foreach: username => applyWho: w => api.kick(studyId, username.id, w.myId) - Bus.publish(actorApi.Kick(studyId, username.id, w.myId), "kickStudy") + Bus.publish(Kick(studyId, username.id, w.myId), "kickStudy") case "leave" => who.foreach: w => @@ -177,7 +175,7 @@ final private class StudySocket( applyWho(api.editStudy(studyId, data)) case "setTag" => - reading[actorApi.SetTag](o): setTag => + reading[SetTag](o): setTag => applyWho(api.setTag(studyId, setTag)) case "setComment" => @@ -216,7 +214,7 @@ final private class StudySocket( applyWho(api.setTopics(studyId, topics)) case "explorerGame" => - reading[actorApi.ExplorerGame](o): data => + reading[ExplorerGame](o): data => applyWho(api.explorerGame(studyId, data)) case "requestAnalysis" => @@ -235,7 +233,7 @@ final private class StudySocket( case "relaySync" => applyWho: w => - Bus.publish(actorApi.RelayToggle(studyId, ~(o \ "d").asOpt[Boolean], w), "relayToggle") + Bus.publish(RelayToggle(studyId, ~(o \ "d").asOpt[Boolean], w), "relayToggle") case t => logger.warn(s"Unhandled study socket message: $t") @@ -246,18 +244,18 @@ final private class StudySocket( _ => _ => none, // the "talk" event is handled by the study API localTimeout = Some { (roomId, modId, suspectId) => api.isContributor(roomId, modId) >>& api.isMember(roomId, suspectId).not >>& - Bus.ask("isOfficialRelay") { actorApi.IsOfficialRelay(roomId, _) }.not + Bus.ask("isOfficialRelay") { IsOfficialRelay(roomId, _) }.not }, chatBusChan = _.study ) - private def moveOrDrop(studyId: StudyId, m: AnaAny, opts: MoveOpts)(who: Who) = + private def moveOrDrop(studyId: StudyId, m: AnaAny, opts: MoveOpts)(using Who) = m.branch.foreach: branch => if branch.ply < Node.MAX_PLIES then m.chapterId .ifTrue(opts.write) .foreach: chapterId => - api.addNode(studyId, Position.Ref(chapterId, m.path), branch, opts)(who) + api.addNode(AddNode(studyId, Position.Ref(chapterId, m.path), branch, opts)) private lazy val send = socketKit.send("study-out") @@ -297,7 +295,6 @@ final private class StudySocket( .obj( "n" -> minimalNodeJsonWriter.writes(node), "p" -> pos, - "d" -> dests.dests, "s" -> sticky ) .add("w", Option.when(relay.isEmpty)(who)) @@ -458,9 +455,9 @@ object StudySocket: given Reads[ChapterMaker.EditData] = Json.reads given Reads[ChapterMaker.DescData] = Json.reads given studyDataReads: Reads[Study.Data] = Json.reads - given Reads[actorApi.SetTag] = Json.reads + given Reads[SetTag] = Json.reads given Reads[Gamebook] = Json.reads - given Reads[actorApi.ExplorerGame] = Json.reads + given Reads[ExplorerGame] = Json.reads object Out: def getIsPresent(reqId: Int, studyId: StudyId, userId: UserId) = diff --git a/modules/study/src/main/actorApi.scala b/modules/study/src/main/model.scala similarity index 78% rename from modules/study/src/main/actorApi.scala rename to modules/study/src/main/model.scala index 1378b69ac1131..abba07c496f34 100644 --- a/modules/study/src/main/actorApi.scala +++ b/modules/study/src/main/model.scala @@ -1,7 +1,7 @@ package lila.study -package actorApi import chess.format.UciPath +import lila.tree.Branch case class SaveStudy(study: Study) case class SetTag(chapterId: StudyChapterId, name: String, value: String): @@ -16,3 +16,11 @@ case class RelayToggle(studyId: StudyId, v: Boolean, who: Who) case class Kick(studyId: StudyId, userId: UserId, who: MyId) case class BecomeStudyAdmin(studyId: StudyId, me: Me) case class IsOfficialRelay(studyId: StudyId, promise: Promise[Boolean]) + +case class AddNode( + studyId: StudyId, + positionRef: Position.Ref, + node: Branch, + opts: MoveOpts, + relay: Option[Chapter.Relay] = None +)(using val who: Who) diff --git a/translation/dest/appeal/hr-HR.xml b/translation/dest/appeal/hr-HR.xml index 5aa5c5d675845..9709dd245426e 100644 --- a/translation/dest/appeal/hr-HR.xml +++ b/translation/dest/appeal/hr-HR.xml @@ -1,4 +1,20 @@ - Tvoj profil su zatvorili moderatori. + Vaš korisnički račun je označen zbog vanjske pomoći u partijama. + Definiramo ovo kao korištenje bilo koje vanjske pomoći u znanju i/ili računanju u svrhu postignuća nepoštene prednosti nad Vašim protivnikom. Pogledajte %s stranicu za više detalja. + Vašem korisničkom računu je zabranjen pristup arenama. + Vašem korisničkom računu je zabranjen pristup turnirima sa stvarnim nagradama. + Vaš korisnički račun je označen zbog manipuliranja rejtinga. + Namjernu manipulaciju rejtinga definiramo kao namjerno gubljenje partija ili igranje s protivnikom koji namjerno gubi partije. + Vaš korisnički račun je ušutkan. + Pročitajte naš %s. Kršeći komunikacijske smjernice može rezultirati ušutkavanjem Vašeg korisničkog računa. + Vaš korisnički račun je izuzet s ljestvice. + Definiramo ovo kao bilo koji nepošten način za doći na ljestvicu. + Vaš korisnički račun je zatvoren od strane moderatora. + Vaši blogovi su sakriveni od strane moderatora. + Pročitajte ponovno naše %s. + Imate vremensko ograničenje igranja. + komunikacijske smjernice + pravila bloga + Fair Play diff --git a/translation/dest/arena/bn-BD.xml b/translation/dest/arena/bn-BD.xml index 93771bdca113b..fa5338268eceb 100644 --- a/translation/dest/arena/bn-BD.xml +++ b/translation/dest/arena/bn-BD.xml @@ -54,6 +54,8 @@ এরিনায় ধারাবাহিক জয় ২টি জয়ের পর প্রতি ধারাবাহিক জয়ের জন্য ২ পয়েন্টের বদলে ৪ পয়েন্ট দেয়া হবে। আপনার দল বাছাই করুন + আপনাকে একটি দলে যোগ দিতে হবে! + তৈরী হয়েছে মোট শুধুমাত্র খেতাবধারী দাবাড়ুগণ diff --git a/translation/dest/broadcast/fa-IR.xml b/translation/dest/broadcast/fa-IR.xml index debe3ec660d86..efca650098498 100644 --- a/translation/dest/broadcast/fa-IR.xml +++ b/translation/dest/broadcast/fa-IR.xml @@ -38,7 +38,7 @@ ویرایش مطالعه دور حذف این مسابقات کل مسابقات، شامل همه دورها و بازی‌هایش را به طور کامل حذف کن. - نمایش امتیاز بازیکنان بر پایه نتیجه بازی‌ها + نمایش امتیاز بازیکنان بر پایه نتیجه بازی‌ها اختیاری: عوض کردن نام، درجه‌بندی و عنوان بازیکنان کشورگان‌های فیده ده درجه‌بندی برتر @@ -57,7 +57,7 @@ بارگذاری تصویر مسابقات تاکنون هیچی. وقتی بازی‌ها بارگذاری شدند، میزها پدیدار خواهند شد. میزها را می‌توان از یک منبع یا از راه %s بارگذاری کرد - پخش زنده به زودی آغاز خواهد شد. + پخش زنده به زودی خواهد آغازید. پخش زنده هنوز نیاغازیده است. وبگاه رسمی رده‌بندی diff --git a/translation/dest/broadcast/hr-HR.xml b/translation/dest/broadcast/hr-HR.xml index 9765ee852e055..8b188c079fe0a 100644 --- a/translation/dest/broadcast/hr-HR.xml +++ b/translation/dest/broadcast/hr-HR.xml @@ -49,4 +49,35 @@ Federacija Starost ove godine Nerangiran + Nedavni turniri + Ekipe + Ploče + Pregled + Pretplatite se kako bi bili obaviješteni o početku runde. Možete uključiti obavijesti za prijenose u postavkama korisničkog računa. + Učitajte sliku turnira + Još nema ploča. Pojavit će se kad se partije učitaju. + Ploče mogu biti učitane iz izvora ili preko %s + Počinje nakon %s + Prijenos počinje uskoro. + Prijenos još nije počeo. + Službena stranica + Tablica + Službena tablica + Više mogučnosti na %s + portal za vlasnike web stranica + Javan izvorni PGN za ovu rundu u stvarnom vremenu. Također nudimo %s za bržu i efikasniju sinkronizaciju. + Ugradite ovaj prijenos na svoju web stranicu + Ugradi %s u svoju web stranicu + Razlika rejtinga + Partije u ovom turniru + Rezultat + Sve ekipe + Format turnira + Mjesto turnira + Najbolji igrači + Vremenska zona + FIDE rejting kategorija + Neobavezni detalji + Prijašnji prijenosi + Pogledaj sve prijenose prema mjesecu diff --git a/translation/dest/oauthScope/hr-HR.xml b/translation/dest/oauthScope/hr-HR.xml index 5aeef17794ac2..5d75b2e8b6685 100644 --- a/translation/dest/oauthScope/hr-HR.xml +++ b/translation/dest/oauthScope/hr-HR.xml @@ -33,7 +33,7 @@ Koristite moderatorske alate (unutar granica vašeg dopuštenja) Osobni API pristupni bonovi Možeš zatražiti OAuth zahtijev bez da prolaziš kroz %s. - proces autorizacije koda + proces autorizacije koda Umjesto toga, %s kojeg možeš izravno koristiti u API zahtijevima. stvori osobni pristupni bon Pažljivo čuvaj bonove. Oni su poput lozinke. Prednost korištenja bonova nasuprot postavljanja lozinke u skriptu je ta što se bonovi mogu opozvati te ih možeš generirati gomilu. @@ -48,6 +48,7 @@ Napomena za razvojne programere: Moguće je unaprijed ispuniti ovaj obrazac podešavanjem parametara upita URL-a. Na primjer: %s + označi %1$s i %2$s kateogrije i postavi opis bon. Kodovi opsega mogu se pronaći u HTML kodu obrasca. Davanje ovih unaprijed ispunjenih URL-ova vašim korisnicima pomoći će im da dobiju prave opsege bona. diff --git a/translation/dest/onboarding/hr-HR.xml b/translation/dest/onboarding/hr-HR.xml index 3ea04e700dfa8..e12c3ff166c43 100644 --- a/translation/dest/onboarding/hr-HR.xml +++ b/translation/dest/onboarding/hr-HR.xml @@ -1,2 +1,17 @@ - + + Dobrodošli! + Dobrodošli na Lichess.org! + Ovo je Vaša profilna stranica. + Hoće li dijete koristiti ovaj korisnički račun? Možda bi htjeli uključiti %s. + Što sad? Evo par prijedloga: + Naučite pravila šaha + Poboljšajte se rješavajući zadatke sa šahovskim taktikama. + Igrajte s Umjetnom Inteligencijom. + Igrajte protiv ljudi diljem svijeta. + Pratite svoje prijatelje na Lichessu. + Igrajte u turnirima. + Učite iz %1$s i %2$s. + Podesite Lichess prema svojim željama. + Istražite stranicu i zabavite se :) + diff --git a/translation/dest/site/fa-IR.xml b/translation/dest/site/fa-IR.xml index 86f61bfdfba2b..f7786ae4754ae 100644 --- a/translation/dest/site/fa-IR.xml +++ b/translation/dest/site/fa-IR.xml @@ -936,7 +936,7 @@ مدیریت جریان‌سازی لغو مسابقه توضیحات مسابقه - نکته خاصی را می‌خواهید به شرکت‌کنندگان گویید؟ بکوشید کوتاه باشد. پیوندهای فرونشان موجودند: + نکته خاصی را می‌خواهید به شرکت‌کنندگان گویید؟ بکوشید کوتاه باشد. پیوندهای فرونشان موجودند: [name](https://url) بازی‌ها رسمی هستند و روی درجه‌بندی بازیکنان تاثیر می‌گذارند @@ -982,7 +982,7 @@ شما یک بازی در حال انجام با %s دارید. انصراف از بازی تسلیم - شما نمی توانید تا زمانی که این بازی تمام نشده بازی جدیدی آغاز کنید. + تا وقتی که این بازی تمام نشده، نمی‌توانید بازی جدیدی را بیاغازید. از وقتی که تا وقتی که بازی‌های رسمی برگزاریده در Lichess diff --git a/translation/dest/site/ha-NG.xml b/translation/dest/site/ha-NG.xml new file mode 100644 index 0000000000000..3ea04e700dfa8 --- /dev/null +++ b/translation/dest/site/ha-NG.xml @@ -0,0 +1,2 @@ + + diff --git a/translation/dest/site/hr-HR.xml b/translation/dest/site/hr-HR.xml index 70f85b093576b..3c9c78103c3fb 100644 --- a/translation/dest/site/hr-HR.xml +++ b/translation/dest/site/hr-HR.xml @@ -375,6 +375,11 @@ %s studije %s studija + + %s simultanka + %s simultanke + %s simultanki + Pogledaj turnir Povratak na turnir U švicarskom turniru neriješen rezultat nije moguć prije 30. poteza. diff --git a/translation/dest/site/kmr-TR.xml b/translation/dest/site/kmr-TR.xml index 94ab7ab265e46..29fb35a7046fd 100644 --- a/translation/dest/site/kmr-TR.xml +++ b/translation/dest/site/kmr-TR.xml @@ -70,6 +70,8 @@ Varyasyonê bipêşîne Bike rêza esasî Ji vir pê de jê bibe + Varyasyonan teng bike + Varyasyonan fireh bike Zorê bide bo vê varyasyonê PGNa varyasyonê kopî bike Hemle @@ -101,8 +103,12 @@ Kaşifê destpêkê Geroka despêk/dawîyê %s kaşifê destpêkê + Hemleya pêşî ya kaşîfê vekirin/dawîya lîstikê bilîze Ji ber qaîdeya 50 hemleyê rê li ber serkeftinê hate girtin Rê li ber mexlubiyetê hate girtin ji ber qaîdeya 50 hemleyê + Qezenckirin an 50 hemleyên ji ber xetayên berê + Qeybkirin an 50 hemleyên ji ber xetayên berê + Qezenckirin/qeybkirin tenê gava ku xetên di binkeya tabloyê de pêşnîyarkirî piştî zeftkirin an hemleya pîyonê yê dawî û pê ve were şopandin tê garantîkirin, ji ber giloverkirina nirxên DTZyê yên di binkeyên tabloyê ya Syzygy de ya muhtemel. Temam e! PGNyê bîne navê Jê bibe @@ -112,6 +118,7 @@ Bi CPL Biçalakîne Nîşandana hemleya çêtirîn + Tîrikên varyasyonê nîşan bide Nîşandera nirxandinê Varyantên pirhejmar CPU @@ -143,11 +150,17 @@ %s lîzer %s lîzer + Beranberîya bi qebûla herdu alîyan + Pêncî hemleyên bêyî pêşketinek Lîstikên vê gavê %s lîstik %s lîstik + + %1$s puanên ji ser %2$s lîstikan + %1$s puanên ji ser %2$s lîstikan + %s cihnîşan %s cihnîşan @@ -165,6 +178,7 @@ Şandiyên forumê yên dawîn Lîzer Hevalan + lîstikvanên din Nîqaşan Îro Doh @@ -184,6 +198,10 @@ %s demjimêr %s demjimêr + + %s deqe + %s deqe + Dem Pile Statîstîkên pileyan @@ -364,6 +382,7 @@ Ya reş bi hemleyekê şahmat dike Cardin biceribîne Cardin tê girêdan + Offlayn %s heval serhêl e %s heval serhêl in @@ -428,6 +447,7 @@ Hemleyên lîstî Serkeftinên spî Serkeftinên reş + Rêjeya wekhevmayînê Wekhevî Pêşbirka li dû %s: Dijbera averaj diff --git a/translation/dest/site/ml-IN.xml b/translation/dest/site/ml-IN.xml index 037312962970e..17187d41f2b34 100644 --- a/translation/dest/site/ml-IN.xml +++ b/translation/dest/site/ml-IN.xml @@ -103,6 +103,7 @@ എക്സ്പ്ലോററും ടേബിൾ ബേസും തുറക്കാം ഓപ്പണിങ്ങ്/എൻഡ്ഗെയിം എക്‌സ്‌പ്ലോറർ %s ഓപ്പണിങ് എക്സ്പ്ലോറർ + ആദ്യഘട്ടം/അവസാനഘട്ടം-ആരായകൻ നീക്കം കളിക്കുക 50 നീക്കങ്ങളുടെ നിയമം പ്രകാരം ജയം തടയപ്പെട്ടു 50 നീക്കങ്ങളുടെ നിയമം പ്രകാരം തോൽവി തടയപ്പെട്ടു വിജയം അല്ലെങ്കിൽ മുൻപറ്റിയ പിഴകൊണ്ടു് 50 നീക്കങ്ങളിൽ സമനില @@ -167,9 +168,9 @@ രജിസ്റ്റര്‍ കമ്പ്യൂട്ടറുകള്‍ക്കും കമ്പ്യൂട്ടര്‍ സഹായമുള്ള കളിക്കാര്‍ക്കും കളിക്കാന്‍ അനുവാദമില്ല. ദയവായി കളിക്കിടെ ചെസ്സ്‌ എഞ്ചിനുകളുടെയോ ഡാറ്റാബേസുകളുടെയോ മറ്റു കളിക്കാരുടെയോ സഹായം തേടരുത്. നിരവധി അക്കൗണ്ടുകളുണ്ടാക്കുന്നത് ശക്തമായി നിരുത്സാഹപ്പെടുത്തുകയും അങ്ങനെ ചെയ്താല്‍ വിലക്കേര്‍പ്പെടുത്തും എന്നും പ്രത്യേകം ശ്രദ്ധിക്കുക. കളികള്‍ - ഫോറം + ചർച്ചാവേദി %1$s കുറിച്ചു %2$s എന്ന വിഷയത്തെ പറ്റി - ഏറ്റവും പുതിയ ഫോറം പോസ്റ്റുകള്‍ + ഏറ്റവും പുതിയ കൂട്ടായ്മ പോസ്റ്റുകള്‍ കളിക്കാര് കൂട്ടുകാർ മറ്റേ കളിക്കാർ @@ -214,6 +215,7 @@ പാസ്‌വേഡ് മറന്നുപോയോ? ഈ രഹസ്യവാക്ക് ഊഹിക്കുകാൻ വളരെ എളുപ്പമാണ്. ദയവായി താങ്ങളുടെ ഉപയോക്തൃനാമം രഹസ്യവാക്കയായി ഉപയോഗിക്കരുത്. + താങ്ങൾ ഇതേ രഹസ്യവാക്കു് മറ്റൊരു വെബ്സ്ഥാനത്തിൽ ഉപയോഗിച്ചിട്ടുണ്ടു് പക്ഷേ ആ വെബ്സ്ഥാനത്തിന്റെ ഒത്തുതീർപ്പു് നടന്നിരിക്കുന്നു. താങ്ങളുടെ Lichess അക്കൗണ്ടിന്റെ സുരക്ഷക്കു വേണ്ടി താങ്ങൾക്കു് ഒരു പുതിയ രഹസ്യവാക്കു് ഇടണ്ടി വരും. സഹകരിക്കുന്നതിനു് നന്ദി. താങ്ങൾ Lichess വിടുകയാണ് ഒരിക്കലും താങ്ങളുടെ Lichess രഹസ്യവാക്കു് മറ്റെ വെബ്സ്ഥാനത്തിൽ എഴുതരുതു്! %s വരെ ചെല്ലുക @@ -380,6 +382,7 @@ സൗജന്യ തൽസമയം ചെസ്സ്‌ സെര്‍വര്‍. ഇപ്പോള്‍ മികച്ച രീതിയില്‍ ചെസ്സ്‌ കളിക്കാം. രജിസ്റ്റര്‍ ചെയ്യണ്ട, പരസ്യങ്ങള്‍ ഇല്ല, പ്ലഗിനുകള്‍ വേണ്ട. കമ്പ്യൂട്ടറുമായോ സുഹൃത്തുക്കളുമായോ പുതിയ എതിരാളികളുമായോ ചെസ്സ്‌ കളിക്കാം. %1$s സംഘത്തിൽ %2$s അംഗമായി %1$s നിർമിച്ച %2$s സംഘം + സംപ്രേക്ഷണം ആരംഭിച്ചിരിക്കുന്നു %s സംപ്രേക്ഷണം ആരംഭിച്ചിരിക്കുന്നു ശരാശരി നിലവാരം സ്ഥലം @@ -398,6 +401,8 @@ പഠനം മത്സരം അപ്പ്‌ലോഡ് ചെയ്യുക ഒരു ഗെയിം PGN പേസ്റ്റ് ചെയ്യുമ്പോൾ ബ്രൗസ്‌ ചെയ്യാൻ പറ്റുന്ന റീപ്ലേ, കമ്പ്യൂട്ടർ വിശകലനം, ഗെയിം ചാറ്റ്, ഷെയർ ചെയ്യാൻ പറ്റുന്ന ഒരു URL എന്നിവ ലഭിക്കുന്നു. + വ്യതിയാനങ്ങൾ മായ്ക്കപ്പെടും. അവയെ വയ്ക്കാനായി PGN ഒരു പഠനം വഴി ഇറക്കുമതിക്കുക. + ഈ PGN എല്ലാവൎക്കും കാണാം. ഈ കളി സ്വകാര്യമായി ഇറക്കുമതിക്കാൻ ഒരു പഠനം ഉപയോഗിക്കുക. ഇറക്കിയ കളികൾ %s ഇറക്കിയ കളികൾ %s @@ -504,6 +509,7 @@ സമൂഹമാധ്യമ ലിങ്കുകൾ ഒരു വരിയിൽ ഒരു വിലാസം. ഇൻഫയൽ നോട്ടെഷൻ + കരുതിവയ്ക്കാനും പങ്കിടാനും ഒരു പഠനം ഉണ്ടാക്കാൻ പരിഗണയിൽ എടുക്കുക. നീക്കങ്ങൾ കളയുക Lichess TV യിൽ നേരെത്തെയുള്ളത് തൽസമയ കളിക്കാർ @@ -875,6 +881,8 @@ ഞാൻ Lichess പോളിസികൾ അനുകൂലിക്കുന്നതായി സമ്മതിക്കുന്നു. കണ്ടുപിടിക്കുക അല്ലെങ്കിൽ പുതിയൊരു സംഭാഷണം ആരംഭിക്കുക മാറ്റുക + വെടിയുണ്ടകളി + മിന്നൽകളി റാപിഡ് ക്ലാസിക്കല്‍ തീവ്രവേഗത്തിലുള്ള കളികള്‍: 30 സെക്കന്‍റില്‍ താഴെ @@ -897,8 +905,8 @@ %1$s ഇൽ ചേരുക, ഫോറത്തിൽ പോസ്റ്റ്‌ ചെയുന്നതിനു %1$s ടീം ഇപ്പോൾ ഈ ഫോറത്തിൽ നിങ്ങൾക്കു പോസ്റ്റ്‌ ചെയ്യാൻ കഴിയുകയില്ല കുറച്ചു ഗ്യാമുകൾക്കു ശേഷം ശ്രമിക്കുക! - സബ്സ്ക്രൈബ് - അൺസബ്സ്ക്രൈബ് + പേരെഴുതുക + പേർമാറ്റുക %1$s ഇൽ നിങ്ങളെ പരാമർശിച്ചു. %2$s നിങ്ങളെ %1$s പരാമർശിച്ചു. %1$s ഇലേക്കു നിങ്ങളെ ക്ഷണിക്കുന്നു. diff --git a/translation/dest/site/nso-ZA.xml b/translation/dest/site/nso-ZA.xml new file mode 100644 index 0000000000000..3ea04e700dfa8 --- /dev/null +++ b/translation/dest/site/nso-ZA.xml @@ -0,0 +1,2 @@ + + diff --git a/translation/dest/streamer/bg-BG.xml b/translation/dest/streamer/bg-BG.xml index 4dfdc51015ba2..2a5aeb1872852 100644 --- a/translation/dest/streamer/bg-BG.xml +++ b/translation/dest/streamer/bg-BG.xml @@ -28,8 +28,10 @@ Вашият стрийм е одобрен. Вашият стрийм се преглежда от модератори. Моля, попълнете информацията за своя стрийм и качете снимка. + Изпратете за преглед Страницата за стрийминг на Lichess е насочена към вашата аудитория с езика, предоставен от вашата платформа за стрийминг. Задайте правилния език по подразбиране за вашите шахматни стриймове в приложението или услугата, която използвате за излъчване. Вашето потребителско име в Twitch или URL адрес + Изисква се Twitch или YouTube ID на канала Ви в YouTube Име на вашата стрийм страница в Lichess Видимо на публичната страницата със стриймъри diff --git a/translation/dest/study/bg-BG.xml b/translation/dest/study/bg-BG.xml index fa134eb0d6572..e82197c89deae 100644 --- a/translation/dest/study/bg-BG.xml +++ b/translation/dest/study/bg-BG.xml @@ -166,4 +166,5 @@ Играйте отново Какво бихте играли в тази позиция? Поздравления! Вие завършихте този урок. + %s на страница diff --git a/translation/dest/timeago/hr-HR.xml b/translation/dest/timeago/hr-HR.xml index 18de0577bdb74..534880692699d 100644 --- a/translation/dest/timeago/hr-HR.xml +++ b/translation/dest/timeago/hr-HR.xml @@ -67,5 +67,15 @@ prije %s godine prije %s godina + + Preostala %s minuta + Preostale %s minute + Preostalo %s minuta + + + Preostao %s sat + Preostala %s sata + Preostalo %s sati + završeno diff --git a/ui/analyse/css/_tools.scss b/ui/analyse/css/_tools.scss index 93ff2ee2ee296..add14c15923c1 100644 --- a/ui/analyse/css/_tools.scss +++ b/ui/analyse/css/_tools.scss @@ -50,6 +50,8 @@ flex-direction: column; justify-content: space-between; + @include prevent-select; + // 0 size forces vertical scrollbar overflow-y: auto; overflow-x: hidden; diff --git a/ui/analyse/css/study/relay/_back-to-live.scss b/ui/analyse/css/study/relay/_back-to-live.scss new file mode 100644 index 0000000000000..7e3ff9c9b09a7 --- /dev/null +++ b/ui/analyse/css/study/relay/_back-to-live.scss @@ -0,0 +1,14 @@ +.relay-back-to-live { + @extend %flex-center; + justify-content: center; + padding: 0.5em 0; + background: $c-accent; + color: $c-over; + cursor: pointer; + + flex: 0 0 auto; + + @include mq-is-col1 { + display: none; + } +} diff --git a/ui/analyse/css/study/relay/_layout.scss b/ui/analyse/css/study/relay/_layout.scss index f6c9fdc79f63b..a06bcfdbd70bd 100644 --- a/ui/analyse/css/study/relay/_layout.scss +++ b/ui/analyse/css/study/relay/_layout.scss @@ -129,4 +129,9 @@ main.analyse.is-relay:not(.has-relay-tour) { flex: 1 0 200px; } } + &.relay-in-variation { + .main-board cg-board { + filter: saturate(0.65) brightness(0.9); + } + } } diff --git a/ui/analyse/css/study/relay/_show.scss b/ui/analyse/css/study/relay/_show.scss index 340a8892c4aa0..f47865813f126 100644 --- a/ui/analyse/css/study/relay/_show.scss +++ b/ui/analyse/css/study/relay/_show.scss @@ -3,3 +3,4 @@ @import 'rounds'; @import 'teams'; @import 'video-player'; +@import 'back-to-live'; diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts index 7c93e687c2a0c..bd791711b24b5 100644 --- a/ui/analyse/src/ctrl.ts +++ b/ui/analyse/src/ctrl.ts @@ -99,7 +99,7 @@ export default class AnalyseCtrl { keyboardHelp: boolean = location.hash === '#keyboard'; threatMode: Prop = prop(false); treeView: TreeView; - treeVersion = 1; // increment to recreate tree + treeVersion = 1; // increment to recreate vnode tree cgVersion = { js: 1, // increment to recreate chessground dom: 1, diff --git a/ui/analyse/src/plugins/analyse.study.tour.ts b/ui/analyse/src/plugins/analyse.study.tour.ts index ef56ab292b673..ba5f39a07e90d 100644 --- a/ui/analyse/src/plugins/analyse.study.tour.ts +++ b/ui/analyse/src/plugins/analyse.study.tour.ts @@ -2,6 +2,7 @@ import type AnalyseCtrl from '../ctrl'; import Shepherd from 'shepherd.js'; import type { ChapterTab, StudyTour, Tab } from '../study/interfaces'; import { pubsub } from 'common/pubsub'; +import * as licon from 'common/licon'; export function initModule(): StudyTour { return { @@ -48,8 +49,8 @@ export function initModule(): StudyTour { { title: 'Study members', text: - " Spectators can view the study and talk in the chat.
" + - "
Contributors can make moves and update the study.", + ` Spectators can view the study and talk in the chat.
` + + `
Contributors can make moves and update the study.`, attachTo: { element: '.study__members', on: 'right' }, when: onTab('members'), }, @@ -58,7 +59,9 @@ export function initModule(): StudyTour { if (ctrl.study?.members.isOwner()) { steps.push({ title: 'Invite members', - text: "By clicking the button.
" + 'Then decide who can contribute or not.', + text: + `By clicking the button.
` + + 'Then decide who can contribute or not.', attachTo: { element: '.study__members .add', on: 'right' }, when: onTab('members'), }); @@ -76,7 +79,7 @@ export function initModule(): StudyTour { if (ctrl.study?.members.canContribute()) { steps.push({ title: 'Create new chapters', - text: "By clicking the button.", + text: `By clicking the button.`, attachTo: { element: '.study__chapters .add', on: 'right' }, when: onTab('chapters'), scrollTo: true, @@ -84,8 +87,8 @@ export function initModule(): StudyTour { steps.push({ title: 'Comment on a position', text: - "With the button, or a right click on the move list on the right.
" + - 'Comments are shared and persisted.', + `With the button, or a right click on the move ` + + 'list on the right.
Comments are shared and persisted.', attachTo: { element: '.study__buttons .left-buttons .comments', on: 'top' }, }); steps.push({ diff --git a/ui/analyse/src/study/interfaces.ts b/ui/analyse/src/study/interfaces.ts index 5d15a72dc4996..5f8f90b706d3b 100644 --- a/ui/analyse/src/study/interfaces.ts +++ b/ui/analyse/src/study/interfaces.ts @@ -276,7 +276,6 @@ export interface AnaDrop { ch?: string; } export interface ServerNodeMsg extends WithWhoAndPos { - d: string; n: Tree.NodeFromServer; o: Opening; s: boolean; diff --git a/ui/analyse/src/study/multiBoard.ts b/ui/analyse/src/study/multiBoard.ts index b388e9108658b..85ce3b0d0e038 100644 --- a/ui/analyse/src/study/multiBoard.ts +++ b/ui/analyse/src/study/multiBoard.ts @@ -291,7 +291,7 @@ export const renderClock = (chapter: ChapterPreview, color: Color) => { const computeTimeLeft = (preview: ChapterPreview, color: Color): number | undefined => { const clock = preview.players?.[color]?.clock; if (notNull(clock)) { - if (defined(preview.lastMoveAt) && fenColor(preview.fen) === color) { + if (defined(preview.lastMoveAt) && defined(preview.lastMove) && fenColor(preview.fen) === color) { const spent = (Date.now() - preview.lastMoveAt) / 1000; return Math.max(0, clock / 100 - spent); } else { diff --git a/ui/analyse/src/study/playerBars.ts b/ui/analyse/src/study/playerBars.ts index 5c6ac428dc4e9..51b9d8823b2b4 100644 --- a/ui/analyse/src/study/playerBars.ts +++ b/ui/analyse/src/study/playerBars.ts @@ -7,6 +7,8 @@ import type { StudyPlayers, Federation, TagArray } from './interfaces'; import { findTag, isFinished, looksLikeLichessGame, resultOf } from './studyChapters'; import { userTitle } from 'common/userLink'; import RelayPlayers, { fidePageLinkAttrs } from './relay/relayPlayers'; +import { StudyCtrl } from './studyDeps'; +import { intersection } from 'tree/path'; export default function (ctrl: AnalyseCtrl): VNode[] | undefined { const study = ctrl.study; @@ -15,8 +17,8 @@ export default function (ctrl: AnalyseCtrl): VNode[] | undefined { const players = study.currentChapter().players, tags = study.data.chapter.tags, - clocks = renderClocks(ctrl), - ticking = !isFinished(study.data.chapter) && ctrl.turnColor(), + clocks = renderClocks(ctrl, selectClockPath(ctrl, study)), + tickingColor = study.isClockTicking(ctrl.path) && ctrl.turnColor(), materialDiffs = renderMaterialDiffs(ctrl); return (['white', 'black'] as Color[]).map(color => @@ -27,13 +29,27 @@ export default function (ctrl: AnalyseCtrl): VNode[] | undefined { materialDiffs, players, color, - ticking === color, + tickingColor === color, study.data.showRatings || !looksLikeLichessGame(tags), relayPlayers, ), ); } +// The tree node whose clocks are displayed. +// Finished game: last mainline node of the current variation. +// Ongoing game: the last mainline node, no matter what +function selectClockPath(ctrl: AnalyseCtrl, study: StudyCtrl): Tree.Path { + const gamePath = ctrl.gamePath || study.data.chapter.relayPath; + return isFinished(study.data.chapter) + ? ctrl.node.clock + ? ctrl.path + : gamePath + ? intersection(ctrl.path, gamePath) + : ctrl.path + : gamePath || ctrl.path; +} + function renderPlayer( ctrl: AnalyseCtrl, tags: TagArray[], diff --git a/ui/analyse/src/study/relay/relayView.ts b/ui/analyse/src/study/relay/relayView.ts index 546968987e505..716d620fe34c6 100644 --- a/ui/analyse/src/study/relay/relayView.ts +++ b/ui/analyse/src/study/relay/relayView.ts @@ -1,6 +1,6 @@ import { view as cevalView } from 'ceval'; import { onClickAway } from 'common'; -import { looseH as h, onInsert, type VNode } from 'common/snabbdom'; +import { bind, dataIcon, looseH as h, onInsert, type VNode } from 'common/snabbdom'; import * as licon from 'common/licon'; import type AnalyseCtrl from '../../ctrl'; import { view as keyboardView } from '../../keyboard'; @@ -34,6 +34,25 @@ export function relayView( ]); } +export const backToLiveView = (ctrl: AnalyseCtrl) => + ctrl.study?.isRelayAwayFromLive() + ? h( + 'button.fbt.relay-back-to-live.text', + { + attrs: dataIcon(licon.RadioTower), + hook: bind( + 'click', + () => { + const p = ctrl.study?.data.chapter.relayPath; + if (p) ctrl.userJump(p); + }, + ctrl.redraw, + ), + }, + 'Back to live move', + ) + : undefined; + export function renderStreamerMenu(relay: RelayCtrl): VNode { const makeUrl = (id: string) => { const url = new URL(location.href); diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index dd815038cd49d..7a728e42d766f 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -44,7 +44,7 @@ import { MultiBoardCtrl } from './multiBoard'; import type { StudySocketSendParams } from '../socket'; import { storedMap } from 'common/storage'; import { opposite } from 'chessops/util'; -import StudyChaptersCtrl from './studyChapters'; +import StudyChaptersCtrl, { isFinished } from './studyChapters'; import { SearchCtrl } from './studySearch'; import type { GamebookOverride } from './gamebook/interfaces'; import type { EvalHitMulti, EvalHitMultiArray } from '../interfaces'; @@ -526,6 +526,18 @@ export default class StudyCtrl { }; onFlip = () => this.chapterFlipMapProp(this.data.chapter.id, this.ctrl.flipped); + isClockTicking = (path: Tree.Path) => + path !== '' && this.data.chapter.relayPath === path && !isFinished(this.data.chapter); + + isRelayAwayFromLive = (): boolean => + !!this.relay && + !isFinished(this.data.chapter) && + defined(this.data.chapter.relayPath) && + this.ctrl.path !== this.data.chapter.relayPath; + + isRelayAndInVariation = (): boolean => + this.isRelayAwayFromLive() && !treePath.contains(this.data.chapter.relayPath!, this.ctrl.path); + setPath = (path: Tree.Path, node: Tree.Node) => { this.onSetPath(path); this.commentForm.onSetPath(this.vm.chapterId, path, node); @@ -656,7 +668,7 @@ export default class StudyCtrl { this.data.chapter.relayPath = d.relayPath; const newPath = this.ctrl.tree.addNode(node, position.path); if (!newPath) return this.xhrReload(); - this.ctrl.tree.addDests(d.d, newPath); + if (d.n.dests) this.ctrl.tree.addDests(d.n.dests, newPath); if (d.relayPath && !this.ctrl.tree.pathIsMainline(d.relayPath)) this.ctrl.tree.promoteAt(d.relayPath, true); if (sticky) this.data.position.path = newPath; diff --git a/ui/analyse/src/view/clocks.ts b/ui/analyse/src/view/clocks.ts index bd0cf6581cb6c..8bee45fb5dada 100644 --- a/ui/analyse/src/view/clocks.ts +++ b/ui/analyse/src/view/clocks.ts @@ -1,25 +1,22 @@ import { h, type VNode } from 'snabbdom'; import type AnalyseCtrl from '../ctrl'; -import { isFinished } from '../study/studyChapters'; import { notNull } from 'common'; -export default function renderClocks(ctrl: AnalyseCtrl): [VNode, VNode] | undefined { - const node = ctrl.node, - clock = node.clock; - - const whitePov = ctrl.bottomIsWhite(), - parentClock = ctrl.tree.getParentClock(node, ctrl.path), +export default function renderClocks(ctrl: AnalyseCtrl, path: Tree.Path): [VNode, VNode] | undefined { + const node = ctrl.tree.nodeAtPath(path), + whitePov = ctrl.bottomIsWhite(), + parentClock = ctrl.tree.getParentClock(node, path), isWhiteTurn = node.ply % 2 === 0, - centis: Array = isWhiteTurn ? [parentClock, clock] : [clock, parentClock]; + centis: Array = isWhiteTurn ? [parentClock, node.clock] : [node.clock, parentClock]; if (!centis.some(notNull)) return; const study = ctrl.study; const lastMoveAt = study - ? study.data.chapter.relayPath !== ctrl.path || ctrl.path === '' || isFinished(study.data.chapter) - ? undefined - : study.relay?.lastMoveAt(study.vm.chapterId) + ? study.isClockTicking(path) + ? study.relay?.lastMoveAt(study.vm.chapterId) + : undefined : ctrl.autoplay.lastMoveAt; if (lastMoveAt) { diff --git a/ui/analyse/src/view/components.ts b/ui/analyse/src/view/components.ts index 625ec4bff1f2b..88b7f03fe34dc 100644 --- a/ui/analyse/src/view/components.ts +++ b/ui/analyse/src/view/components.ts @@ -42,6 +42,7 @@ import type * as studyDeps from '../study/studyDeps'; import { renderPgnError } from '../pgnImport'; import { storage } from 'common/storage'; import { makeChat } from 'chat'; +import { backToLiveView } from '../study/relay/relayView'; export interface ViewContext { ctrl: AnalyseCtrl; @@ -91,6 +92,7 @@ export function renderMain( { ctrl, playerBars, gaugeOn, gamebookPlayView, needsInnerCoords, hasRelayTour }: ViewContext, kids: VNodeKids, ): VNode { + const isRelay = defined(ctrl.study?.relay); return h( 'main.analyse.variant-' + ctrl.data.game.variant.key, { @@ -118,9 +120,10 @@ export function renderMain( 'has-players': !!playerBars, 'gamebook-play': !!gamebookPlayView, 'has-relay-tour': hasRelayTour, - 'is-relay': ctrl.study?.relay !== undefined, + 'is-relay': isRelay, 'analyse-hunter': ctrl.opts.hunter, 'analyse--wiki': !!ctrl.wiki && !ctrl.study, + 'relay-in-variation': !!ctrl.study?.isRelayAndInVariation(), }, }, kids, @@ -137,6 +140,7 @@ export function renderTools({ ctrl, deps, concealOf, allowVideo }: ViewContext, !ctrl.retro?.isSolving() && !ctrl.practice && cevalView.renderPvs(ctrl), renderMoveList(ctrl, deps, concealOf), deps?.gbEdit.running(ctrl) ? deps?.gbEdit.render(ctrl) : undefined, + backToLiveView(ctrl), forkView(ctrl, concealOf), retroView(ctrl) || practiceView(ctrl) || explorerView(ctrl), ]), @@ -440,7 +444,7 @@ function renderPlayerStrips(ctrl: AnalyseCtrl): [VNode, VNode] | undefined { const renderPlayerStrip = (cls: string, materialDiff: VNode, clock?: VNode): VNode => h('div.analyse__player_strip.' + cls, [materialDiff, clock]); - const clocks = renderClocks(ctrl), + const clocks = renderClocks(ctrl, ctrl.path), whitePov = ctrl.bottomIsWhite(), materialDiffs = renderMaterialDiffs(ctrl); diff --git a/ui/chess/src/glyphs.ts b/ui/chess/src/glyphs.ts index ea2dc03da40da..f17cd17020571 100644 --- a/ui/chess/src/glyphs.ts +++ b/ui/chess/src/glyphs.ts @@ -91,7 +91,7 @@ const glyphToSvg: Dictionary = { // Equal position '=': composeGlyph( - '#f5918f', + '#82c2ef', '', ), diff --git a/ui/common/src/socket.ts b/ui/common/src/socket.ts index 06eb0334e25b2..4de4a3add83fe 100644 --- a/ui/common/src/socket.ts +++ b/ui/common/src/socket.ts @@ -286,6 +286,8 @@ class WsSocket { case 'ack': this.ackable.onServerAck(m.d); break; + case 'batch': + m.d.forEach(this.handle); default: // return true in a receive handler to prevent pubsub and events if (!(this.settings.receive && this.settings.receive(m.t, m.d))) { diff --git a/ui/lobby/src/setupCtrl.ts b/ui/lobby/src/setupCtrl.ts index dfaa671f2075d..5557d4792820a 100644 --- a/ui/lobby/src/setupCtrl.ts +++ b/ui/lobby/src/setupCtrl.ts @@ -197,10 +197,7 @@ export default class SetupController { this.loadPropsFromStore(forceOptions); }; - closeModal = () => { - this.gameType = null; - this.root.redraw(); - }; + closeModal?: () => void; // managed by view/setup/modal.ts validateFen = debounce(() => { const fen = this.fen(); @@ -292,7 +289,7 @@ export default class SetupController { const poolMember = this.hookToPoolMember(color); if (poolMember) { this.root.enterPool(poolMember); - this.closeModal(); + this.closeModal?.(); return; } @@ -330,13 +327,13 @@ export default class SetupController { if (response.status === 403) { // 403 FORBIDDEN closes this modal because challenges to the recipient // will not be accepted. see friend() in controllers/Setup.scala - this.closeModal(); + this.closeModal?.(); } } else if (redirected) { location.href = url; } else { this.loading = false; - this.closeModal(); + this.closeModal?.(); } }; } diff --git a/ui/lobby/src/view/setup/modal.ts b/ui/lobby/src/view/setup/modal.ts index b4344a0d7c184..d86ae5ff76661 100644 --- a/ui/lobby/src/view/setup/modal.ts +++ b/ui/lobby/src/view/setup/modal.ts @@ -18,9 +18,17 @@ export default function setupModal(ctrl: LobbyController): MaybeVNode { return snabDialog({ class: 'game-setup', css: [{ hashed: 'lobby.setup' }], - onClose: setupCtrl.closeModal, + onClose: () => { + setupCtrl.closeModal = undefined; + setupCtrl.gameType = null; + setupCtrl.root.redraw(); + }, modal: true, vnodes: [...views[setupCtrl.gameType](ctrl), ratingView(ctrl)], + onInsert: dlg => { + setupCtrl.closeModal = dlg.close; + dlg.show(); + }, }); } diff --git a/ui/round/src/ctrl.ts b/ui/round/src/ctrl.ts index da498a0f2fe92..9e52423fa6152 100644 --- a/ui/round/src/ctrl.ts +++ b/ui/round/src/ctrl.ts @@ -584,6 +584,13 @@ export default class RoundController implements MoveRootCtrl { wakeLock.release(); if (this.data.game.status.name === 'started') site.sound.saySan(this.stepAt(this.ply).san, false); else site.sound.say(viewStatus(this), false, false, true); + if ( + !d.player.spectator && + o.status.name === 'outoftime' && + this.chessground.state.turnColor === d.opponent.color + ) { + notify(viewStatus(this)); + } }; challengeRematch = async (): Promise => { diff --git a/ui/site/src/serviceWorker.ts b/ui/site/src/serviceWorker.ts index 872a816c28044..e1e2c08635f97 100644 --- a/ui/site/src/serviceWorker.ts +++ b/ui/site/src/serviceWorker.ts @@ -2,10 +2,6 @@ import { url as assetUrl, jsModule } from './asset'; import { log } from 'common/permalog'; import { storage } from 'common/storage'; -function makeUrlSafe(base64: string): string { - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - export default async function () { if (!('serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window)) return; const workerUrl = new URL( @@ -25,10 +21,7 @@ export default async function () { if (!vapid || Notification.permission !== 'granted') return store.remove(); else if (sub && !resub) return; - newSub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: makeUrlSafe(vapid), - }); + newSub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapid }); if (!newSub) throw new Error(JSON.stringify(await reg.pushManager.permissionState())); diff --git a/ui/tree/src/path.ts b/ui/tree/src/path.ts index 8ada21b70119e..2b26407c9a945 100644 --- a/ui/tree/src/path.ts +++ b/ui/tree/src/path.ts @@ -20,3 +20,9 @@ export function fromNodeList(nodes: Tree.Node[]): Tree.Path { export const isChildOf = (child: Tree.Path, parent: Tree.Path): boolean => !!child && child.slice(0, -2) === parent; + +export const intersection = (p1: Tree.Path, p2: Tree.Path): Tree.Path => { + const head1 = head(p1), + head2 = head(p2); + return head1 !== '' && head1 === head2 ? head1 + intersection(tail(p1), tail(p2)) : ''; +}; diff --git a/ui/tree/src/tree.ts b/ui/tree/src/tree.ts index e29acbbc3b7c7..7ae0023307012 100644 --- a/ui/tree/src/tree.ts +++ b/ui/tree/src/tree.ts @@ -74,8 +74,6 @@ export function build(root: Tree.Node): TreeWrapper { const pathIsMainline = (path: Tree.Path): boolean => pathIsMainlineFrom(root, path); - const pathExists = (path: Tree.Path): boolean => !!nodeAtPathOrNull(path); - function pathIsMainlineFrom(node: Tree.Node, path: Tree.Path): boolean { if (path === '') return true; const pathId = treePath.head(path), @@ -84,6 +82,8 @@ export function build(root: Tree.Node): TreeWrapper { return pathIsMainlineFrom(child, treePath.tail(path)); } + const pathExists = (path: Tree.Path): boolean => !!nodeAtPathOrNull(path); + const pathIsForcedVariation = (path: Tree.Path): boolean => !!getNodeList(path).find(n => n.forceVariation); function lastMainlineNodeFrom(node: Tree.Node, path: Tree.Path): Tree.Node { @@ -233,9 +233,7 @@ export function build(root: Tree.Node): TreeWrapper { }, pathIsMainline, pathIsForcedVariation, - lastMainlineNode(path: Tree.Path): Tree.Node { - return lastMainlineNodeFrom(root, path); - }, + lastMainlineNode: (path: Tree.Path): Tree.Node => lastMainlineNodeFrom(root, path), pathExists, deleteNodeAt, promoteAt,