diff --git a/app/controllers/Streamer.scala b/app/controllers/Streamer.scala index d0f10e00827a..20658461cc54 100644 --- a/app/controllers/Streamer.scala +++ b/app/controllers/Streamer.scala @@ -102,29 +102,28 @@ final class Streamer(env: Env, apiC: => Api) extends LilaController(env): modData(s.streamer).flatMap: forMod => BadRequest.page(views.streamer.edit(sws, error, forMod)), data => - api.update(sws.streamer, data, isGranted(_.Streamers)).flatMap { change => - if change.decline then logApi.streamerDecline(s.user.id) - change.list.foreach { logApi.streamerList(s.user.id, _) } - change.tier.foreach { logApi.streamerTier(s.user.id, _) } - if data.approval.flatMap(_.quick).isDefined - then - env.streamer.pager.nextRequestId.map: nextId => - Redirect: - nextId.fold(s"${routes.Streamer.index()}?requests=1"): id => - s"${routes.Streamer.edit.url}?u=$id" - else - val next = if sws.streamer.is(me) then "" else s"?u=${sws.user.id}" - Redirect(s"${routes.Streamer.edit.url}$next") - } + api + .update(sws.streamer, data, isGranted(_.Streamers)) + .flatMap: + case Some(change) => + if change.decline then logApi.streamerDecline(s.user.id) + change.list.foreach { logApi.streamerList(s.user.id, _) } + change.tier.foreach { logApi.streamerTier(s.user.id, _) } + if data.approval.flatMap(_.quick).isDefined + then + env.streamer.pager.nextRequestId.map: nextId => + Redirect: + nextId.fold(s"${routes.Streamer.index()}?requests=1"): id => + s"${routes.Streamer.edit.url}?u=$id" + else + val next = if sws.streamer.is(me) then "" else s"?u=${sws.user.id}" + Redirect(s"${routes.Streamer.edit.url}$next") + case _ => + Redirect(routes.Streamer.edit) ) } } - def approvalRequest = AuthBody { _ ?=> me ?=> - NoBot: - api.approval.request(me).inject(Redirect(routes.Streamer.edit)) - } - def pictureApply = AuthBody(parse.multipartFormData) { ctx ?=> me ?=> AsStreamer: s => ctx.body.body.file("picture") match @@ -135,7 +134,7 @@ final class Streamer(env: Env, apiC: => Api) extends LilaController(env): .recoverWith { case e: Exception => Redirect(routes.Streamer.edit).flashFailure } - .inject(Redirect(routes.Streamer.edit)) + .inject(Ok) case None => Redirect(routes.Streamer.edit).flashFailure } diff --git a/conf/routes b/conf/routes index 0f9a6ffd0bff..bdd157fd5e94 100644 --- a/conf/routes +++ b/conf/routes @@ -340,7 +340,6 @@ GET /streamer/live controllers.Main.movedPermanently(to = "/ GET /streamer/edit controllers.Streamer.edit POST /streamer/new controllers.Streamer.create POST /streamer/edit controllers.Streamer.editApply -POST /streamer/approval/request controllers.Streamer.approvalRequest POST /streamer/subscribe/:streamer controllers.Streamer.subscribe(streamer: UserStr, set: Boolean ?= true) POST /upload/image/streamer controllers.Streamer.pictureApply GET /streamer/:username controllers.Streamer.show(username: UserStr) diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala index 47a1a657abd2..a2fff7e4c57d 100644 --- a/modules/coreI18n/src/main/key.scala +++ b/modules/coreI18n/src/main/key.scala @@ -2331,10 +2331,11 @@ object I18nKey: val `pendingReview`: I18nKey = "streamer:pendingReview" val `pleaseFillIn`: I18nKey = "streamer:pleaseFillIn" val `whenReady`: I18nKey = "streamer:whenReady" - val `requestReview`: I18nKey = "streamer:requestReview" + val `submitForReview`: I18nKey = "streamer:submitForReview" val `streamerLanguageSettings`: I18nKey = "streamer:streamerLanguageSettings" val `twitchUsername`: I18nKey = "streamer:twitchUsername" - val `optionalOrEmpty`: I18nKey = "streamer:optionalOrEmpty" + val `twitchOrYouTubeRequired`: I18nKey = "streamer:twitchOrYouTubeRequired" + val `twitchOrYouTubeMustBeVerified`: I18nKey = "streamer:twitchOrYouTubeMustBeVerified" val `youTubeChannelId`: I18nKey = "streamer:youTubeChannelId" val `streamerName`: I18nKey = "streamer:streamerName" val `visibility`: I18nKey = "streamer:visibility" diff --git a/modules/streamer/src/main/Streamer.scala b/modules/streamer/src/main/Streamer.scala index 4847cbd749b1..a5dd1685fc67 100644 --- a/modules/streamer/src/main/Streamer.scala +++ b/modules/streamer/src/main/Streamer.scala @@ -30,7 +30,7 @@ case class Streamer( def completeEnough = { twitch.isDefined || youTube.isDefined - } && headline.isDefined && hasPicture + } && name.value.length > 2 && hasPicture object Streamer: diff --git a/modules/streamer/src/main/StreamerApi.scala b/modules/streamer/src/main/StreamerApi.scala index 46b2de1ddd5f..0950cde7acaf 100644 --- a/modules/streamer/src/main/StreamerApi.scala +++ b/modules/streamer/src/main/StreamerApi.scala @@ -73,13 +73,16 @@ final class StreamerApi( candidateIds <- cache.candidateIds.getUnit yield if streams.map(_.streamer.id).exists(candidateIds.contains) then cache.candidateIds.invalidateUnit() - def update(prev: Streamer, data: StreamerForm.UserData, asMod: Boolean): Fu[Streamer.ModChange] = + def update(prev: Streamer, data: StreamerForm.UserData, asMod: Boolean): Fu[Option[Streamer.ModChange]] = val streamer = data(prev, asMod) - for - _ <- coll.update.one($id(streamer.id), streamer) - _ = cache.listedIds.invalidateUnit() - _ = streamer.youTube.foreach(tuber => ytApi.channelSubscribe(tuber.channelId, true)) - yield modChange(prev, streamer) + coll.update + .one($id(streamer.id), streamer) + .map: _ => + asMod.option: + cache.listedIds.invalidateUnit() + streamer.youTube + .foreach(tuber => ytApi.channelSubscribe(tuber.channelId, true)) + modChange(prev, streamer) def forceCheck(uid: UserId): Funit = byId(uid.into(Streamer.Id)).map: @@ -87,20 +90,29 @@ final class StreamerApi( s.youTube.foreach(ytApi.forceCheckWithHtmlScraping) private def modChange(prev: Streamer, current: Streamer): Streamer.ModChange = - val list = (prev.approval.granted != current.approval.granted).option(current.approval.granted) - (~list).so( + if !current.approval.granted then notifyApi.notifyOne( current, lila.core.notify.NotificationContent.GenericLink( - url = "/streamer/edit", + url = streamerPageActivationRoute.url, + title = "Streamer application denied".some, + text = + "Your streamer application was denied. Click here to read instructions before resubmitting.".some, + icon = lila.ui.Icon.Mic.value + ) + ) + else if !prev.approval.granted && current.approval.granted then + notifyApi.notifyOne( + current, + lila.core.notify.NotificationContent.GenericLink( + url = routes.Streamer.edit.url, title = "Listed on /streamer".some, text = "Your streamer page is public".some, icon = lila.ui.Icon.Mic.value ) ) - ) Streamer.ModChange( - list = list, + list = (prev.approval.granted != current.approval.granted).option(current.approval.granted), tier = (prev.approval.tier != current.approval.tier).option(current.approval.tier), decline = !current.approval.granted && !current.approval.requested && prev.approval.requested ) @@ -146,7 +158,7 @@ final class StreamerApi( picfitApi .uploadFile(s"streamer:${s.id}", picture, userId = by.id) .flatMap { pic => - coll.update.one($id(s.id), $set("picture" -> pic.id)).void + coll.update.one($id(s.id), $set("picture" -> pic.id, "approval.requested" -> true)).void } // unapprove after 6 weeks if you never streamed (was originally 1 week) diff --git a/modules/streamer/src/main/StreamerForm.scala b/modules/streamer/src/main/StreamerForm.scala index d94795fa45bf..ff7451bd03c5 100644 --- a/modules/streamer/src/main/StreamerForm.scala +++ b/modules/streamer/src/main/StreamerForm.scala @@ -76,34 +76,33 @@ object StreamerForm: def apply(streamer: Streamer, asMod: Boolean) = val liveVideoId = streamer.youTube.flatMap(_.liveVideoId) val pubsubVideoId = streamer.youTube.flatMap(_.pubsubVideoId) - val newStreamer = streamer.copy( + val newTwitch = twitch.flatMap(Twitch.parseUserId).map(Twitch.apply) + val newYouTube = + youTube.flatMap(YouTube.parseChannelId).map(YouTube.apply(_, liveVideoId, pubsubVideoId)) + val urlChanges = newTwitch != streamer.twitch || newYouTube != streamer.youTube + streamer.copy( name = name, headline = headline, description = description, - twitch = twitch.flatMap(Twitch.parseUserId).map(Twitch.apply), - youTube = youTube.flatMap(YouTube.parseChannelId).map(YouTube.apply(_, liveVideoId, pubsubVideoId)), + twitch = newTwitch, + youTube = newYouTube, listed = listed, - updatedAt = nowInstant - ) - newStreamer.copy( + updatedAt = nowInstant, approval = approval.map(_.resolve) match case Some(m) if asMod => streamer.approval.copy( granted = m.granted, tier = m.tier | streamer.approval.tier, - requested = !m.granted && { - if streamer.approval.requested != m.requested then m.requested - else streamer.approval.requested || m.requested - }, + requested = !m.granted && m.requested, ignored = m.ignored && !m.granted, chatEnabled = m.chat, lastGrantedAt = m.granted.option(nowInstant).orElse(streamer.approval.lastGrantedAt) ) case _ => streamer.approval.copy( - granted = streamer.approval.granted && - newStreamer.twitch.forall(streamer.twitch.has) && - newStreamer.youTube.forall(streamer.youTube.has) + requested = streamer.approval.requested || urlChanges || name != streamer.name + || headline != streamer.headline || description != streamer.description, + granted = streamer.approval.granted && !urlChanges ) ) diff --git a/modules/streamer/src/main/package.scala b/modules/streamer/src/main/package.scala index 5bf862622bdd..3c3675d67dbd 100644 --- a/modules/streamer/src/main/package.scala +++ b/modules/streamer/src/main/package.scala @@ -4,3 +4,6 @@ export lila.core.lilaism.Lilaism.{ *, given } export lila.common.extensions.* private val logger = lila.log("streamer") + +private val streamerPageActivationRoute = + routes.Cms.lonePage(lila.core.id.CmsPageKey("streamer-page-activation")) diff --git a/modules/streamer/src/main/ui/StreamerBits.scala b/modules/streamer/src/main/ui/StreamerBits.scala index e4d35bd58aca..377c3458ba41 100644 --- a/modules/streamer/src/main/ui/StreamerBits.scala +++ b/modules/streamer/src/main/ui/StreamerBits.scala @@ -169,7 +169,7 @@ final class StreamerBits(helpers: Helpers)(picfitUrl: lila.core.misc.PicfitUrl): a(href := routes.Cms.lonePage(CmsPageKey("streaming-fairplay-faq")))(trs.streamingFairplayFAQ()) ) ), - li(a(href := routes.Cms.lonePage(CmsPageKey("streamer-page-activation")))(trs.rule3())) + li(a(href := streamerPageActivationRoute.url)(trs.rule3())) ), h2(trs.perks()), ul( diff --git a/modules/streamer/src/main/ui/StreamerEdit.scala b/modules/streamer/src/main/ui/StreamerEdit.scala index 9b06af24daa1..10775d4d29bb 100644 --- a/modules/streamer/src/main/ui/StreamerEdit.scala +++ b/modules/streamer/src/main/ui/StreamerEdit.scala @@ -14,9 +14,17 @@ final class StreamerEdit(helpers: Helpers, bits: StreamerBits): def apply(s: Streamer.WithUserAndStream, form: Form[?], modZone: Option[(Frag, List[Streamer])])(using ctx: Context ) = + val wasListed = s.streamer.approval.lastGrantedAt.isDefined Page(s"${s.user.titleUsername} ${trs.lichessStreamer.txt()}") .css("bits.streamer.form") - .js(esmInitBit("streamer")): + .i18n(_.streamer) + .js( + esmInitObj( + "bits.streamer", + "youtube" -> wasListed.so(s.streamer.youTube).so[String](_.channelId), + "twitch" -> wasListed.so(s.streamer.twitch).so[String](_.userId) + ) + ): main(cls := "page-menu")( bits.menu("edit", s.some), div(cls := "page-menu__content box streamer-edit")( @@ -36,44 +44,42 @@ final class StreamerEdit(helpers: Helpers, bits: StreamerBits): ) else bits.header(s, modZone.isDefined), div(cls := "box-pad") { - val granted = s.streamer.approval.granted + val granted = s.streamer.approval.granted + val requested = s.streamer.approval.requested + val (clas, icon) = (granted, requested, wasListed) match + case (true, true, _) => ("status is-green", Icon.Search) + case (true, false, _) => ("status is-green", Icon.Checkmark) + case (false, true, _) => ("status is-gold", Icon.CautionTriangle) + case (false, false, true) => ("status is-red", Icon.X) + case (false, false, false) => ("status is", Icon.InfoCircle) frag( - (ctx.is(s.user) && s.streamer.listed.value).option( - div( - cls := s"status is${granted.so("-green")}", - dataIcon := (if granted then Icon.Checkmark else Icon.InfoCircle) - )( - if granted then - frag( - trs.approved(), - (s.streamer.approval.tier > 0).option: - frag( - br, - strong("You have been selected for frontpage featuring!"), - p( - "Note that we can only show a limited number of streams on the homepage, ", - "so yours may not always appear." - ) - ) - ) - else - frag( - if s.streamer.approval.requested then trs.pendingReview() - else - frag( - if s.streamer.completeEnough then - trs.whenReady( - postForm(action := routes.Streamer.approvalRequest)( - button(tpe := "submit", cls := "button", ctx.isnt(s.user).option(disabled))( - trs.requestReview() - ) - ) + (ctx.is(s.user) && s.streamer.listed.value) + .option( + div(cls := clas, dataIcon := icon)( + if granted then + frag( + if requested then "Changes are under review." else trs.approved(), + (s.streamer.approval.tier > 0).option: + frag( + br, + strong("You have been selected for frontpage featuring!"), + p( + "Note that we can only show a limited number of streams on the homepage, ", + "so yours may not always appear." ) - else trs.pleaseFillIn() + ) + ) + else if requested then trs.pendingReview() + else if wasListed then + frag( + "Your previous application was declined. ", + a(href := streamerPageActivationRoute)( + "See instructions before submitting again" ) - ) - ) - ), + ) + else trs.pleaseFillIn() + ) + ), ctx.is(s.user).option(div(cls := "status")(trs.streamerLanguageSettings())), modZone.map: (modFrag, same) => frag( @@ -163,13 +169,13 @@ final class StreamerEdit(helpers: Helpers, bits: StreamerBits): form3.group( form("twitch"), trs.twitchUsername(), - help = trs.optionalOrEmpty().some, + help = trs.twitchOrYouTubeRequired().some, half = true )(form3.input(_)), form3.group( form("youTube"), trs.youTubeChannelId(), - help = trs.optionalOrEmpty().some, + help = trs.twitchOrYouTubeRequired().some, half = true )(form3.input(_)) ), @@ -195,7 +201,11 @@ final class StreamerEdit(helpers: Helpers, bits: StreamerBits): form3.group(form("description"), trs.longDescription())(form3.textarea(_)(rows := 10)), form3.actions( a(href := routes.Streamer.show(s.user.username))(trans.site.cancel()), - form3.submit(trans.site.apply()) + button( + tpe := "submit", + cls := "submit button text approval-request-submit", + title := "You must provide an image, a streamer name, and a Twitch or YouTube channel." + )(trs.submitForReview()) ) ) ) diff --git a/modules/streamer/src/main/ui/StreamerUi.scala b/modules/streamer/src/main/ui/StreamerUi.scala index 810d714f5b88..80cdb12d4f0a 100644 --- a/modules/streamer/src/main/ui/StreamerUi.scala +++ b/modules/streamer/src/main/ui/StreamerUi.scala @@ -72,7 +72,7 @@ final class StreamerUi(helpers: Helpers, bits: StreamerBits)(using netDomain: Ne Page(title) .css("bits.streamer.list") .js(infiniteScrollEsmInit) - .js(esmInitBit("streamer")): + .js(esmInitBit("streamerSubscribe")): main(cls := "page-menu")( bits.menu(if requests then "requests" else "index", none)(cls := " page-menu__menu"), div(cls := "page-menu__content box streamer-list")( @@ -106,7 +106,7 @@ final class StreamerUi(helpers: Helpers, bits: StreamerBits)(using netDomain: Ne Page(s"${s.titleName} streams chess") .csp(csp) .css("bits.streamer.show") - .js(esmInitBit("streamer")) + .js(esmInitBit("streamerSubscribe")) .graph( OpenGraph( title = s"${s.titleName} streams chess", diff --git a/translation/source/streamer.xml b/translation/source/streamer.xml index a2c3c1e3c0a0..730501a2c45a 100644 --- a/translation/source/streamer.xml +++ b/translation/source/streamer.xml @@ -29,10 +29,13 @@ Your stream is being reviewed by moderators. Please fill in your streamer information, and upload a picture. When you are ready to be listed as a Lichess streamer, %s - request a moderator review + Submit for review The Lichess streamer page targets your audience with the language provided by your streaming platform. Set the correct default language for your chess streams in the app or service you use to broadcast. Your Twitch username or URL - Optional. Leave empty if none + Either Twitch or YouTube is required + Twitch and YouTube changes must be verified. + + Your streamer badge and listing will be suspended while the review is in process. This can take up to 72 hours. Your YouTube channel ID Your streamer name on Lichess diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts index 3cf9d394c987..fbb8092411ca 100644 --- a/ui/@types/lichess/i18n.d.ts +++ b/ui/@types/lichess/i18n.d.ts @@ -4557,8 +4557,6 @@ interface I18n { maxSize: I18nFormat; /** OFFLINE */ offline: string; - /** Optional. Leave empty if none */ - optionalOrEmpty: string; /** Your stream is being reviewed by moderators. */ pendingReview: string; /** Get a flaming streamer icon on your Lichess profile. */ @@ -4573,8 +4571,6 @@ interface I18n { perks: string; /** Please fill in your streamer information, and upload a picture. */ pleaseFillIn: string; - /** request a moderator review */ - requestReview: string; /** Include the keyword "lichess.org" in your stream title and use the category "Chess" when you stream on Lichess. */ rule1: string; /** Remove the keyword when you stream non-Lichess stuff. */ @@ -4591,8 +4587,14 @@ interface I18n { streamerName: string; /** streaming Fairplay FAQ */ streamingFairplayFAQ: string; + /** Submit for review */ + submitForReview: string; /** Tell us about your stream in one sentence */ tellUsAboutTheStream: string; + /** Twitch and YouTube changes must be verified. */ + twitchOrYouTubeMustBeVerified: string; + /** Either Twitch or YouTube is required */ + twitchOrYouTubeRequired: string; /** Your Twitch username or URL */ twitchUsername: string; /** Upload a picture */ diff --git a/ui/bits/src/bits.cropDialog.ts b/ui/bits/src/bits.cropDialog.ts index 7768fe4977c5..043cf73f2fbd 100644 --- a/ui/bits/src/bits.cropDialog.ts +++ b/ui/bits/src/bits.cropDialog.ts @@ -8,7 +8,7 @@ export interface CropOpts { source?: Blob | string; // image or url max?: { megabytes?: number; pixels?: number }; // constrain size post?: { url: string; field?: string }; // multipart post form url and field name - onCropped?: (result: Blob | boolean, error?: string) => void; // result callback + onCropped?: (result: Blob | false, error?: string) => void; // result callback } export async function initModule(o?: CropOpts): Promise { diff --git a/ui/bits/src/bits.streamer.ts b/ui/bits/src/bits.streamer.ts new file mode 100644 index 000000000000..b3cac6fcdbd7 --- /dev/null +++ b/ui/bits/src/bits.streamer.ts @@ -0,0 +1,56 @@ +import { wireCropDialog } from './crop'; +import { confirm } from 'common/dialog'; + +export function initModule(old: { youtube?: string; twitch?: string }): any { + const submit = document.querySelector('.approval-request-submit')!; + const streamerEdit = document.querySelector('.streamer-edit')!; + const youTube = streamerEdit.querySelector('#form3-youTube')!; + const twitch = streamerEdit.querySelector('#form3-twitch')!; + const name = streamerEdit.querySelector('#form3-name')!; + const setSubmitEnabled = (): boolean => { + const enabled = + (youTube.value || twitch.value) && + name.value && + name.value.length >= 3 && + !streamerEdit.querySelector('img[src$="images/placeholder.png"]'); + submit.disabled = !enabled; + submit.classList.toggle('disabled', !enabled); + if (enabled) submit.title = ''; // remove "You need an image, streamer name, ..." tooltip + return Boolean(enabled); + }; + + const wasEnabled = setSubmitEnabled(); + + document + .querySelectorAll('.streamer-edit input') + .forEach(i => i.addEventListener('input', setSubmitEnabled)); + + submit.addEventListener('click', async e => { + if (!e.isTrusted) return; + e.preventDefault(); + + if (!old.youtube && !old.twitch) return submit.click(); + if (youTube.value === (old.youtube || '') && twitch.value === (old?.twitch || '')) return submit.click(); + + if (await confirm(i18n.streamer.twitchOrYouTubeMustBeVerified, i18n.site.ok, i18n.site.cancel)) + return submit.click(); + }); + + wireCropDialog({ + aspectRatio: 1, + post: { url: '/upload/image/streamer', field: 'picture' }, + max: { pixels: 1000 }, + selectClicks: $('.select-image, .drop-target'), + selectDrags: $('.drop-target'), + onCropped: blob => { + if (!blob) return; + const img = streamerEdit.querySelector('img.picture')!; + img.src = URL.createObjectURL(blob); + img.onload = () => { + if (wasEnabled) return submit.click(); + URL.revokeObjectURL(img.src); + setSubmitEnabled(); + }; + }, + }); +} diff --git a/ui/bits/src/bits.ts b/ui/bits/src/bits.ts index 6dbf7b4aa58b..48d730631152 100644 --- a/ui/bits/src/bits.ts +++ b/ui/bits/src/bits.ts @@ -34,8 +34,8 @@ export function initModule(args: { fn: string } & any): void { return relayForm(); case 'setAssetInfo': return setAssetInfo(); - case 'streamer': - return streamer(); + case 'streamerSubscribe': + return streamerSubscribe(); case 'thanksReport': return thanksReport(); case 'titleRequest': @@ -233,7 +233,7 @@ function setAssetInfo() { $('#asset-version-message').text(site.info.message); } -function streamer() { +function streamerSubscribe() { $('.streamer-show, .streamer-list').on('change', '.streamer-subscribe input', (e: Event) => { const target = e.target as HTMLInputElement; $(target) @@ -247,13 +247,6 @@ function streamer() { ); }); }); - wireCropDialog({ - aspectRatio: 1, - post: { url: '/upload/image/streamer', field: 'picture' }, - max: { pixels: 1000 }, - selectClicks: $('.select-image, .drop-target'), - selectDrags: $('.drop-target'), - }); } function titleRequest() { diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts index 0889e41b77d4..c82e6dede364 100644 --- a/ui/common/src/dialog.ts +++ b/ui/common/src/dialog.ts @@ -89,7 +89,7 @@ export async function confirm( ( await domDialog({ htmlText: - `
${escapeHtml(msg)}
` + + `
${escapeHtmlAddBreaks(msg)}
` + `` + ``, class: 'alert', @@ -111,7 +111,7 @@ export async function confirm( export async function prompt(msg: string, def: string = ''): Promise { const res = await domDialog({ htmlText: - `
${escapeHtml(msg)}
` + + `
${escapeHtmlAddBreaks(msg)}
` + `` + `` + ``, @@ -361,6 +361,10 @@ function loadAssets(o: DialogOpts) { ]); } +function escapeHtmlAddBreaks(s: string) { + return escapeHtml(s).replace(/\n/g, '
'); +} + function onKeydown(e: KeyboardEvent) { if (e.key === 'Tab') { const $focii = $(focusQuery, e.currentTarget as Element),