From 43fbd61029ff3960a21fbd213944bed66325696d Mon Sep 17 00:00:00 2001 From: Thibault Duplessis Date: Thu, 21 Jan 2021 16:04:41 +0100 Subject: [PATCH] decline challenges for a reason - closes #7487 also closes #7658 --- app/controllers/Challenge.scala | 16 ++++++++---- app/views/challenge/mine.scala | 6 ++++- modules/challenge/src/main/BSONHandlers.scala | 5 ++++ modules/challenge/src/main/Challenge.scala | 18 +++++++++++-- modules/challenge/src/main/ChallengeApi.scala | 8 +++--- .../challenge/src/main/ChallengeForm.scala | 18 +++++++++++++ .../challenge/src/main/ChallengeRepo.scala | 8 ++++-- modules/challenge/src/main/Env.scala | 2 ++ modules/challenge/src/main/JsonView.scala | 1 + modules/setup/src/main/Env.scala | 2 +- .../{FormFactory.scala => SetupForm.scala} | 2 +- ui/challenge/css/_page.scss | 25 ++++++++++++++----- ui/challenge/css/build/_challenge.page.scss | 1 + ui/common/css/component/_tagify.scss | 4 +-- 14 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 modules/challenge/src/main/ChallengeForm.scala rename modules/setup/src/main/{FormFactory.scala => SetupForm.scala} (99%) diff --git a/app/controllers/Challenge.scala b/app/controllers/Challenge.scala index de8f827869..f7951b8dc7 100644 --- a/app/controllers/Challenge.scala +++ b/app/controllers/Challenge.scala @@ -138,19 +138,25 @@ final class Challenge( def decline(id: String) = Auth { implicit ctx => _ => OptionFuResult(api byId id) { c => - if (isForMe(c)) api decline c - else notFound + isForMe(c) ?? api.decline(c, ChallengeModel.DeclineReason.default) } } def apiDecline(id: String) = - Scoped(_.Challenge.Write, _.Bot.Play, _.Board.Play) { _ => me => + ScopedBody(_.Challenge.Write, _.Bot.Play, _.Board.Play) { implicit req => me => + implicit val lang = reqLang api.activeByIdFor(id, me) flatMap { case None => env.bot.player.rematchDecline(id, me) flatMap { case true => jsonOkResult.fuccess case _ => notFoundJson() } - case Some(c) => api.decline(c) inject jsonOkResult + case Some(c) => + env.challenge.forms.decline + .bindFromRequest() + .fold( + newJsonFormError, + data => api.decline(c, data.realReason) inject jsonOkResult + ) } } @@ -168,7 +174,7 @@ final class Challenge( case Some(c) => api.cancel(c) inject jsonOkResult case None => api.activeByIdFor(id, me) flatMap { - case Some(c) => api.decline(c) inject jsonOkResult + case Some(c) => api.decline(c, ChallengeModel.DeclineReason.default) inject jsonOkResult case None => env.game.gameRepo game id dmap { _ flatMap { Pov.ofUserId(_, me.id) } diff --git a/app/views/challenge/mine.scala b/app/views/challenge/mine.scala index 24c9635e0c..0faf65d756 100644 --- a/app/views/challenge/mine.scala +++ b/app/views/challenge/mine.scala @@ -25,7 +25,7 @@ object mine { moreCss = cssTag("challenge.page") ) { val challengeLink = s"$netBaseUrl${routes.Round.watcher(c.id, "white")}" - main(cls := "page-small challenge-page box box-pad")( + main(cls := s"page-small challenge-page box box-pad challenge--${c.status.name}")( c.status match { case Status.Created | Status.Offline => div(id := "ping-challenge")( @@ -94,6 +94,10 @@ object mine { case Status.Declined => div(cls := "follow-up")( h1(trans.challenge.challengeDeclined()), + blockquote(cls := "challenge-reason pull-quote")( + p(c.anyDeclineReason.trans()), + footer(userIdLink(c.destUserId)) + ), bits.details(c), a(cls := "button button-fat", href := routes.Lobby.home())(trans.newOpponent()) ) diff --git a/modules/challenge/src/main/BSONHandlers.scala b/modules/challenge/src/main/BSONHandlers.scala index e7c55a785a..f5e586a87a 100644 --- a/modules/challenge/src/main/BSONHandlers.scala +++ b/modules/challenge/src/main/BSONHandlers.scala @@ -7,6 +7,7 @@ import chess.variant.Variant import lila.db.BSON import lila.db.BSON.{ Reader, Writer } import lila.db.dsl._ +import scala.util.Success private object BSONHandlers { @@ -71,6 +72,10 @@ private object BSONHandlers { "s" -> a.secret ) } + implicit val DeclineReasonBSONHandler = tryHandler[DeclineReason]( + { case BSONString(k) => Success(Challenge.DeclineReason(k)) }, + r => BSONString(r.key) + ) implicit val ChallengerBSONHandler = new BSON[Challenger] { def reads(r: Reader) = if (r contains "id") RegisteredBSONHandler reads r diff --git a/modules/challenge/src/main/Challenge.scala b/modules/challenge/src/main/Challenge.scala index a02308e42d..315029f477 100644 --- a/modules/challenge/src/main/Challenge.scala +++ b/modules/challenge/src/main/Challenge.scala @@ -25,7 +25,8 @@ case class Challenge( createdAt: DateTime, seenAt: Option[DateTime], // None for open challenges, so they don't sweep expiresAt: DateTime, - open: Option[Boolean] = None + open: Option[Boolean] = None, + declineReason: Option[Challenge.DeclineReason] = None ) { import Challenge._ @@ -94,6 +95,13 @@ case class Challenge( def isOpen = ~open lazy val perfType = perfTypeOf(variant, timeControl) + + def anyDeclineReason = declineReason | DeclineReason.default + + def declineWith(reason: DeclineReason) = copy( + status = Status.Declined, + declineReason = reason.some + ) } object Challenge { @@ -113,11 +121,17 @@ object Challenge { def apply(id: Int): Option[Status] = all.find(_.id == id) } - sealed abstract class DeclineReason(key: I18nKey) + sealed abstract class DeclineReason(val trans: I18nKey) { + val key = toString.toLowerCase + } object DeclineReason { case object Generic extends DeclineReason(I18nKeys.challenge.declineGeneric) case object Later extends DeclineReason(I18nKeys.challenge.declineLater) + + val default: DeclineReason = Generic + val all: List[DeclineReason] = List(Generic, Later) + def apply(key: String) = all.find(_.key == key) | Generic } case class Rating(int: Int, provisional: Boolean) { diff --git a/modules/challenge/src/main/ChallengeApi.scala b/modules/challenge/src/main/ChallengeApi.scala index dc1b9b87ef..66589cc873 100644 --- a/modules/challenge/src/main/ChallengeApi.scala +++ b/modules/challenge/src/main/ChallengeApi.scala @@ -73,10 +73,10 @@ final class ChallengeApi( case _ => fuccess(socketReload(id)) } - def decline(c: Challenge) = - repo.decline(c) >>- { + def decline(c: Challenge, reason: Challenge.DeclineReason) = + repo.decline(c, reason) >>- { uncacheAndNotify(c) - Bus.publish(Event.Decline(c), "challenge") + Bus.publish(Event.Decline(c declineWith reason), "challenge") } private val acceptQueue = new lila.hub.DuctSequencer(maxSize = 64, timeout = 5 seconds, "challengeAccept") @@ -149,7 +149,7 @@ final class ChallengeApi( } private def socketReload(id: Challenge.ID): Unit = - socket foreach (_ reload id) + socket.foreach(_ reload id) private def notify(userId: User.ID): Funit = for { diff --git a/modules/challenge/src/main/ChallengeForm.scala b/modules/challenge/src/main/ChallengeForm.scala new file mode 100644 index 0000000000..b0b7999fc4 --- /dev/null +++ b/modules/challenge/src/main/ChallengeForm.scala @@ -0,0 +1,18 @@ +package lila.challenge + +import play.api.data._ +import play.api.data.Forms._ + +final class ChallengeForm { + + val decline = Form( + mapping( + "reason" -> optional(nonEmptyText) + )(DeclineData.apply _)(DeclineData.unapply _) + ) + + case class DeclineData(reason: Option[String]) { + + def realReason = reason.fold(Challenge.DeclineReason.default)(Challenge.DeclineReason.apply) + } +} diff --git a/modules/challenge/src/main/ChallengeRepo.scala b/modules/challenge/src/main/ChallengeRepo.scala index 5a22642fd5..82a919e81e 100644 --- a/modules/challenge/src/main/ChallengeRepo.scala +++ b/modules/challenge/src/main/ChallengeRepo.scala @@ -114,8 +114,12 @@ final private class ChallengeRepo(coll: Coll, maxPerUser: Max)(implicit def offline(challenge: Challenge) = setStatus(challenge, Status.Offline, Some(_ plusHours 3)) def cancel(challenge: Challenge) = setStatus(challenge, Status.Canceled, Some(_ plusHours 3)) - def decline(challenge: Challenge) = setStatus(challenge, Status.Declined, Some(_ plusHours 3)) - def accept(challenge: Challenge) = setStatus(challenge, Status.Accepted, Some(_ plusHours 3)) + def decline(challenge: Challenge, reason: Challenge.DeclineReason) = + setStatus(challenge, Status.Declined, Some(_ plusHours 3)) >> { + (reason != Challenge.DeclineReason.default) ?? + coll.updateField($id(challenge.id), "declineReason", reason).void + } + def accept(challenge: Challenge) = setStatus(challenge, Status.Accepted, Some(_ plusHours 3)) def statusById(id: Challenge.ID) = coll.primitiveOne[Status]($id(id), "status") diff --git a/modules/challenge/src/main/Env.scala b/modules/challenge/src/main/Env.scala index da5c50fd82..ec9dfce044 100644 --- a/modules/challenge/src/main/Env.scala +++ b/modules/challenge/src/main/Env.scala @@ -50,6 +50,8 @@ final class Env( lazy val jsonView = wire[JsonView] + val forms = new ChallengeForm + system.scheduler.scheduleWithFixedDelay(10 seconds, 3 seconds) { () => api.sweep.unit } diff --git a/modules/challenge/src/main/JsonView.scala b/modules/challenge/src/main/JsonView.scala index f232fdfad4..f9cefde4b2 100644 --- a/modules/challenge/src/main/JsonView.scala +++ b/modules/challenge/src/main/JsonView.scala @@ -80,6 +80,7 @@ final class JsonView( ) .add("direction" -> direction.map(_.name)) .add("initialFen" -> c.initialFen) + .add("declineReason" -> c.declineReason.map(_.trans.txt())) private def iconChar(c: Challenge) = if (c.variant == chess.variant.FromPosition) '*' diff --git a/modules/setup/src/main/Env.scala b/modules/setup/src/main/Env.scala index 3cf31c297a..a96ba98986 100644 --- a/modules/setup/src/main/Env.scala +++ b/modules/setup/src/main/Env.scala @@ -16,7 +16,7 @@ final class Env( private lazy val maxPlaying = appConfig.get[Max]("setup.max_playing") - lazy val forms = wire[FormFactory] + lazy val forms = wire[SetupForm] lazy val processor = wire[Processor] } diff --git a/modules/setup/src/main/FormFactory.scala b/modules/setup/src/main/SetupForm.scala similarity index 99% rename from modules/setup/src/main/FormFactory.scala rename to modules/setup/src/main/SetupForm.scala index 03109f4699..538d312443 100644 --- a/modules/setup/src/main/FormFactory.scala +++ b/modules/setup/src/main/SetupForm.scala @@ -8,7 +8,7 @@ import play.api.data.Forms._ import lila.rating.RatingRange import lila.user.{ User, UserContext } -final class FormFactory { +final class SetupForm { import Mappings._ diff --git a/ui/challenge/css/_page.scss b/ui/challenge/css/_page.scss index 97bf371fdb..c6441e58c3 100644 --- a/ui/challenge/css/_page.scss +++ b/ui/challenge/css/_page.scss @@ -1,5 +1,3 @@ -$c-challenge: $c-secondary; - .challenge-page { .challenge-id-form { white-space: nowrap; @@ -41,18 +39,22 @@ $c-challenge: $c-secondary; .details { @extend %flex-between; + $c-bg: mix($c-good, $c-bg-box, 10%); + --c-font: #{$c-good}; + --c-bg: #{$c-bg}; + border-radius: 99px; - background: mix($c-challenge, $c-bg-box, 10%); - border: 1px solid $c-challenge; padding: .5em 1.1em; margin-bottom: 3rem; font-size: 2em; + background: var(--c-bg); + border: 1px solid var(--c-font); > div { @extend %flex-center, %roboto; &::before { - color: $c-challenge; + color: var(--c-font); font-size: 6rem; margin-right: .2em; } @@ -68,11 +70,22 @@ $c-challenge: $c-secondary; .mode { font-weight: bold; - color: $c-challenge; + color: var(--c-font); text-transform: uppercase; } } + &.challenge--declined .details { + $c-bg: mix($c-bad, $c-bg-box, 10%); + --c-font: #{$c-bad}; + --c-bg: #{$c-bg}; + } + + .challenge-reason { + margin: 2em auto 5em auto; + max-width: 70ch; + } + .follow-up .button { display: block; margin-top: 2em; diff --git a/ui/challenge/css/build/_challenge.page.scss b/ui/challenge/css/build/_challenge.page.scss index b278f742f0..23a66ab589 100644 --- a/ui/challenge/css/build/_challenge.page.scss +++ b/ui/challenge/css/build/_challenge.page.scss @@ -1,2 +1,3 @@ @import "../../../common/css/plugin"; +@import "../../../common/css/component/quote"; @import "../page"; diff --git a/ui/common/css/component/_tagify.scss b/ui/common/css/component/_tagify.scss index 6ed7758b4d..b321d62522 100644 --- a/ui/common/css/component/_tagify.scss +++ b/ui/common/css/component/_tagify.scss @@ -1,8 +1,8 @@ :root { - --tagify-dd-color-primary: $c-primary; + --tagify-dd-color-primary: #{$c-primary}; // should be same as "$tags-focus-border-color" - --tagify-dd-bg-color: $c-bg-box; + --tagify-dd-bg-color: #{$c-bg-box}; } .tagify {