more ublog tier/rank system

pull/9751/head
Thibault Duplessis 2021-09-07 14:16:07 +02:00
parent 2a8e423311
commit 1257ef10fc
17 changed files with 174 additions and 84 deletions

View File

@ -11,7 +11,8 @@ import lila.user.{ User => UserModel }
final class Ublog(env: Env) extends LilaController(env) {
import views.html.ublog.post.{ editUrlOf, urlOf }
import views.html.ublog.post.{ editUrlOfPost, urlOfPost }
import views.html.ublog.blog.{ urlOfBlog }
import lila.common.paginator.Paginator.zero
def index(username: String, page: Int) = Open { implicit ctx =>
@ -19,7 +20,7 @@ final class Ublog(env: Env) extends LilaController(env) {
OptionFuResult(env.user.repo named username) { user =>
env.ublog.api.getUserBlog(user) flatMap { blog =>
(canViewBlogOf(user, blog) ?? env.ublog.paginator.byUser(user, true, page)) map { posts =>
Ok(html.ublog.index(user, posts))
Ok(html.ublog.blog(user, blog, posts))
}
}
}
@ -42,7 +43,7 @@ final class Ublog(env: Env) extends LilaController(env) {
env.ublog.api.getUserBlog(user) flatMap { blog =>
env.ublog.api.findByIdAndBlog(UblogPost.Id(id), blog.id) flatMap {
_.filter(canViewPost(user, blog)) ?? { post =>
if (slug != post.slug) Redirect(urlOf(post)).fuccess
if (slug != post.slug) Redirect(urlOfPost(post)).fuccess
else {
env.ublog.api.otherPosts(UblogBlog.Id.User(user.id), post) zip
ctx.me.??(env.ublog.like.liked(post)) map { case (others, liked) =>
@ -94,7 +95,7 @@ final class Ublog(env: Env) extends LilaController(env) {
CreateLimitPerUser(me.id, cost = if (me.isVerified) 1 else 3) {
env.ublog.api.create(data, me) map { post =>
lila.mon.ublog.create(me.id).increment()
Redirect(editUrlOf(post)).flashSuccess
Redirect(editUrlOfPost(post)).flashSuccess
}
}(rateLimitedFu)
)
@ -120,7 +121,7 @@ final class Ublog(env: Env) extends LilaController(env) {
err => BadRequest(html.ublog.form.edit(me, prev, err)).fuccess,
data =>
env.ublog.api.update(data, prev, me) map { post =>
Redirect(urlOf(post)).flashSuccess
Redirect(urlOfPost(post)).flashSuccess
}
)
}
@ -144,6 +145,24 @@ final class Ublog(env: Env) extends LilaController(env) {
}
}
def setTier(blogId: String) = SecureBody(_.ModerateBlog) { implicit ctx => me =>
UblogBlog.Id(blogId).??(env.ublog.api.getBlog) flatMap {
_ ?? { blog =>
implicit val body = ctx.body
lila.ublog.UblogForm.tier
.bindFromRequest()
.fold(
err => Redirect(urlOfBlog(blog)).flashFailure.fuccess,
tier =>
env.ublog.api.setTier(blog.id, tier) >>
env.ublog.like.recomputeRankOfAllPosts(blog.id) >> env.mod.logApi
.blogTier(lila.report.Mod(me.user), blog.id.full, tier)
inject Redirect(urlOfBlog(blog)).flashSuccess
)
}
}
}
private val ImageRateLimitPerIp = lila.memo.RateLimit.composite[lila.common.IpAddress](
key = "ublog.image.ip"
)(

View File

@ -0,0 +1,56 @@
package views.html.ublog
import controllers.routes
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.common.paginator.Paginator
import lila.ublog.{ UblogBlog, UblogPost }
import lila.user.User
object blog {
import views.html.ublog.{ post => postView }
def apply(user: User, blog: UblogBlog, posts: Paginator[UblogPost.PreviewPost])(implicit ctx: Context) =
views.html.base.layout(
moreCss = cssTag("ublog"),
moreJs = frag(
posts.hasNextPage option infiniteScrollTag,
ctx.isAuth option jsModule("ublog")
),
title = trans.ublog.xBlog.txt(user.username)
) {
main(cls := "box box-pad page page-small ublog-index")(
div(cls := "box__top")(
h1(trans.ublog.xBlog(userLink(user))),
if (ctx is user)
div(cls := "box__top__actions")(
a(href := routes.Ublog.drafts(user.username))(trans.ublog.drafts()),
postView.newPostLink
)
else isGranted(_.ModerateBlog) option tierForm(blog)
),
standardFlash(),
if (posts.nbResults > 0)
div(cls := "ublog-index__posts ublog-post-cards infinite-scroll")(
posts.currentPageResults map { postView.card(_) },
pagerNext(posts, np => s"${routes.Ublog.index(user.username, np).url}")
)
else
div(cls := "ublog-index__posts--empty")(
trans.ublog.noPostsInThisBlogYet()
)
)
}
def urlOfBlog(blog: UblogBlog) = blog.id match {
case UblogBlog.Id.User(userId) => routes.Ublog.index(usernameOrId(userId))
}
private def tierForm(blog: UblogBlog) = postForm(action := routes.Ublog.setTier(blog.id.full)) {
val form = lila.ublog.UblogForm.tier.fill(blog.tier)
form3.select(form("tier"), lila.ublog.UblogBlog.Tier.options)
}
}

View File

@ -112,7 +112,9 @@ object form {
views.html.base.captcha(form, c)
},
form3.actions(
a(href := post.fold(routes.Ublog.index(user.username))(views.html.ublog.post.urlOf))(trans.cancel()),
a(href := post.fold(routes.Ublog.index(user.username))(views.html.ublog.post.urlOfPost))(
trans.cancel()
),
form3.submit(trans.apply())
)
)

View File

@ -13,40 +13,6 @@ object index {
import views.html.ublog.{ post => postView }
def apply(user: User, posts: Paginator[UblogPost.PreviewPost])(implicit ctx: Context) =
views.html.base.layout(
moreCss = cssTag("ublog"),
moreJs = frag(
posts.hasNextPage option infiniteScrollTag,
ctx.isAuth option jsModule("ublog")
),
title = trans.ublog.xBlog.txt(user.username)
) {
main(cls := "box box-pad page page-small ublog-index")(
div(cls := "box__top")(
h1(trans.ublog.xBlog(userLink(user))),
if (ctx is user)
div(cls := "box__top__actions")(
a(href := routes.Ublog.drafts(user.username))(trans.ublog.drafts()),
newPostLink
)
else if (isGranted(_.ModerateBlog) && user.marks.troll)
badTag("Not visible to the public")
else emptyFrag
),
standardFlash(),
if (posts.nbResults > 0)
div(cls := "ublog-index__posts ublog-post-cards infinite-scroll")(
posts.currentPageResults map { postView.card(_) },
pagerNext(posts, np => s"${routes.Ublog.index(user.username, np).url}")
)
else
div(cls := "ublog-index__posts--empty")(
trans.ublog.noPostsInThisBlogYet()
)
)
}
def drafts(user: User, posts: Paginator[UblogPost.PreviewPost])(implicit ctx: Context) =
views.html.base.layout(
moreCss = frag(cssTag("ublog")),
@ -58,12 +24,12 @@ object index {
h1(trans.ublog.drafts()),
div(cls := "box__top__actions")(
a(href := routes.Ublog.index(user.username))(trans.ublog.published()),
newPostLink
postView.newPostLink
)
),
if (posts.nbResults > 0)
div(cls := "ublog-index__posts ublog-index__posts--drafts ublog-post-cards infinite-scroll")(
posts.currentPageResults map { postView.card(_, postView.editUrlOf) },
posts.currentPageResults map { postView.card(_, postView.editUrlOfPost) },
pagerNext(posts, np => routes.Ublog.drafts(user.username, np).url)
)
else
@ -117,13 +83,4 @@ object index {
)
)
}
private def newPostLink(implicit ctx: Context) = ctx.me map { u =>
a(
href := routes.Ublog.form(u.username),
cls := "button button-green",
dataIcon := "",
title := trans.ublog.newPost.txt()
)
}
}

View File

@ -60,7 +60,7 @@ object post {
if (post.live) trans.ublog.thisPostIsPublished() else trans.ublog.thisIsADraft()
),
a(
href := editUrlOf(post),
href := editUrlOfPost(post),
cls := "button button-empty text",
dataIcon := ""
)(trans.edit())
@ -71,7 +71,7 @@ object post {
a(
titleOrText(trans.reportXToModerators.txt(user.username)),
cls := "button button-empty ublog-post__meta__report",
href := s"${routes.Report.form}?username=${user.username}&postUrl=${urlencode(s"${netBaseUrl}${urlOf(post).url}")}&reason=comm",
href := s"${routes.Report.form}?username=${user.username}&postUrl=${urlencode(s"${netBaseUrl}${urlOfPost(post).url}")}&reason=comm",
dataIcon := ""
)
),
@ -86,7 +86,7 @@ object post {
def card(
post: UblogPost.BasePost,
makeUrl: UblogPost.BasePost => Call = urlOf,
makeUrl: UblogPost.BasePost => Call = urlOfPost,
showAuthor: Boolean = false
)(implicit ctx: Context) =
a(cls := "ublog-post-card", href := makeUrl(post))(
@ -99,12 +99,21 @@ object post {
)
)
def urlOf(post: UblogPost.BasePost) = post.blog match {
def urlOfPost(post: UblogPost.BasePost) = post.blog match {
case UblogBlog.Id.User(userId) =>
routes.Ublog.post(usernameOrId(userId), post.slug, post.id.value)
}
def editUrlOf(post: UblogPost.BasePost) = routes.Ublog.edit(post.id.value)
def editUrlOfPost(post: UblogPost.BasePost) = routes.Ublog.edit(post.id.value)
private[ublog] def newPostLink(implicit ctx: Context) = ctx.me map { u =>
a(
href := routes.Ublog.form(u.username),
cls := "button button-green",
dataIcon := "",
title := trans.ublog.newPost.txt()
)
}
object thumbnail {
def apply(post: UblogPost.BasePost, size: UblogPost.thumbnail.SizeSelector) =

View File

@ -61,6 +61,8 @@ db.ublog_post.find({ blog: { $exists: false } }).forEach(p => {
at: p.liveAt,
}
: undefined,
likers: [],
likes: NumberInt(0),
},
$unset: {
user: 1,

View File

@ -67,6 +67,7 @@ GET /ublog/$id<\w{8}>/edit controllers.Ublog.edit(id: String)
POST /ublog/$id<\w{8}>/edit controllers.Ublog.update(id: String)
POST /ublog/$id<\w{8}>/del controllers.Ublog.delete(id: String)
POST /ublog/$id<\w{8}>/like controllers.Ublog.like(id: String, v: Boolean)
POST /ublog/:blogId/tier controllers.Ublog.setTier(blogId: String)
POST /upload/image/ublog/$id<\w{8}> controllers.Ublog.image(id: String)
# User

View File

@ -170,6 +170,7 @@ object ModActivity {
case object Appeal extends Action
case object SetEmail extends Action
case object Streamer extends Action
case object Blog extends Action
case object ForumAdmin extends Action
val dbMap = Map(
"modMessage" -> Message,
@ -191,6 +192,7 @@ object ModActivity {
"streamerDecline" -> Streamer,
"streamerunlist" -> Streamer,
"streamerTier" -> Streamer,
"blogTier" -> Blog,
"deletePost" -> ForumAdmin,
"closeTopic" -> ForumAdmin
)

View File

@ -68,6 +68,7 @@ case class Modlog(
case Modlog.streamerFeature => "feature streamer" // BC
case Modlog.streamerUnfeature => "unfeature streamer" // BC
case Modlog.streamerTier => "set streamer tier"
case Modlog.blogTier => "set blog tier"
case Modlog.teamKick => "kick from team"
case Modlog.teamEdit => "edited team"
case Modlog.appealPost => "posted in appeal"
@ -135,6 +136,7 @@ object Modlog {
val streamerFeature = "streamerFeature" // BC
val streamerUnfeature = "streamerUnfeature" // BC
val streamerTier = "streamerTier"
val blogTier = "blogTier"
val teamKick = "teamKick"
val teamEdit = "teamEdit"
val appealPost = "appealPost"

View File

@ -29,6 +29,10 @@ final class ModlogApi(repo: ModlogRepo, userRepo: UserRepo, ircApi: IrcApi)(impl
add {
Modlog(mod.user.id, streamerId.some, Modlog.streamerTier, v.toString.some)
}
def blogTier(mod: Mod, blogId: String, v: Int) =
add {
Modlog(mod.user.id, blogId.some, Modlog.blogTier, v.toString.some)
}
def practiceConfig(mod: User.ID) =
add {

View File

@ -3,7 +3,6 @@ package lila.round
import akka.actor._
import akka.stream.scaladsl._
import org.joda.time.DateTime
import scala.concurrent.duration._
import lila.common.LilaStream

View File

@ -34,7 +34,9 @@ final class Env(
val form = wire[UblogForm]
lila.common.Bus.subscribeFun("shadowban") { case lila.hub.actorApi.mod.Shadowban(userId, v) =>
api.setShadowban(userId, v).unit
api.setShadowban(userId, v) >>
like.recomputeRankOfAllPosts(UblogBlog.Id.User(userId))
()
}
}

View File

@ -107,7 +107,7 @@ final class UblogApi(
def setTier(blog: UblogBlog.Id, tier: Int): Funit =
colls.blog.update
.one($id(blog), $set("modTier" -> tier, "tier" -> tier))
.one($id(blog), $set("modTier" -> tier, "tier" -> tier), upsert = true)
.void
private[ublog] def setShadowban(userId: User.ID, v: Boolean) = {

View File

@ -6,8 +6,8 @@ case class UblogBlog(
_id: UblogBlog.Id,
title: Option[String],
intro: Option[String],
tier: Int, // actual tier, auto or set by a mod
modTier: Option[Int] // tier set by a mod
tier: UblogBlog.Tier, // actual tier, auto or set by a mod
modTier: Option[UblogBlog.Tier] // tier set by a mod
) {
def id = _id
def visible = tier >= UblogBlog.Tier.VISIBLE
@ -26,6 +26,7 @@ object UblogBlog {
}
}
type Tier = Int
object Tier {
val HIDDEN = 0 // not visible
val VISIBLE = 1 // not listed in community page
@ -38,6 +39,15 @@ object UblogBlog {
if (user.marks.troll) Tier.HIDDEN
else if (user.hasTitle || user.perfs.standard.glicko.establishedIntRating.exists(_ > 2200)) Tier.NORMAL
else Tier.LOW
val options = List(
HIDDEN -> "Hidden",
VISIBLE -> "Unlisted",
LOW -> "Low tier",
NORMAL -> "Normal tier",
HIGH -> "High tier",
BEST -> "Best tier"
)
}
def make(user: User) = UblogBlog(

View File

@ -71,7 +71,7 @@ object UblogForm {
created = UblogPost.Recorded(user.id, DateTime.now),
updated = none,
lived = none,
likes = UblogPost.Likes(0)
likes = UblogPost.Likes(1)
)
def update(user: User, prev: UblogPost) =
@ -85,4 +85,6 @@ object UblogForm {
lived = prev.lived orElse live.option(UblogPost.Recorded(user.id, DateTime.now))
)
}
val tier = Form(single("tier" -> number(min = UblogBlog.Tier.HIDDEN, max = UblogBlog.Tier.BEST)))
}

View File

@ -1,5 +1,6 @@
package lila.ublog
import cats.implicits._
import reactivemongo.api._
import scala.concurrent.ExecutionContext
import lila.db.dsl._
@ -16,30 +17,50 @@ final class UblogLike(colls: UblogColls)(implicit ec: ExecutionContext) {
colls.post.exists($id(post.id) ++ selectLiker(user.id))
def apply(postId: UblogPost.Id, user: User, v: Boolean): Fu[UblogPost.Likes] =
countLikes(postId).flatMap {
fetchRankData(postId).flatMap {
case None => fuccess(UblogPost.Likes(v ?? 1))
case Some((blog, prevLikes, liveAt)) =>
case Some((prevLikes, liveAt, tier)) =>
val likes = UblogPost.Likes(prevLikes.value + (if (v) 1 else -1))
colls.post.update.one(
$id(postId),
$set(
"likes" -> likes,
"rank" -> computeRank(blog, likes, liveAt)
"rank" -> computeRank(likes, liveAt, tier)
) ++ {
if (v) $addToSet("likers" -> user.id) else $pull("likers" -> user.id)
}
) inject likes
}
private def computeRank(blog: UblogBlog, likes: UblogPost.Likes, liveAt: DateTime) =
def recomputeRankOfAllPosts(blogId: UblogBlog.Id): Funit =
colls.blog.byId[UblogBlog](blogId.full) flatMap {
_ ?? { blog =>
colls.post
.find($doc("blog" -> blog.id), $doc("likes" -> true, "lived" -> true).some)
.cursor[Bdoc]()
.list() flatMap { docs =>
lila.common.Future.applySequentially(docs) { doc =>
(
doc.string("_id"),
doc.getAsOpt[UblogPost.Likes]("likes"),
doc.getAsOpt[UblogPost.Recorded]("lived")
).tupled ?? { case (id, likes, lived) =>
colls.post.updateField($id(id), "rank", computeRank(likes, lived.at, blog.tier)).void
}
}
}
}
}
private def computeRank(likes: UblogPost.Likes, liveAt: DateTime, tier: UblogBlog.Tier) =
UblogPost.Rank {
liveAt plusHours {
val baseHours = likesToHours(likes)
blog.tier match {
case UblogBlog.Tier.LOW => (baseHours * 0.2).toInt
tier match {
case UblogBlog.Tier.LOW => (baseHours * 0.3).toInt
case UblogBlog.Tier.NORMAL => baseHours
case UblogBlog.Tier.HIGH => baseHours * 5
case UblogBlog.Tier.BEST => baseHours * 12
case UblogBlog.Tier.HIGH => baseHours * 3
case UblogBlog.Tier.BEST => baseHours * 10
case _ => -99999
}
}
@ -47,34 +68,33 @@ final class UblogLike(colls: UblogColls)(implicit ec: ExecutionContext) {
private def likesToHours(likes: UblogPost.Likes): Int =
if (likes.value < 1) 0
else (5 * math.log(likes.value) + 1).toInt.min(likes.value) * 24
else (5 * math.log(likes.value) + 1).toInt.atMost(likes.value) * 12
private def countLikes(postId: UblogPost.Id): Fu[Option[(UblogBlog, UblogPost.Likes, DateTime)]] =
private def fetchRankData(postId: UblogPost.Id): Fu[Option[(UblogPost.Likes, DateTime, UblogBlog.Tier)]] =
colls.post
.aggregateWith[Bdoc]() { framework =>
.aggregateOne() { framework =>
import framework._
List(
Match($id(postId)),
Match($id(postId)) -> List(
PipelineOperator($lookup.simple(colls.blog, "blog", "blog", "_id")),
UnwindField("blog"),
Project(
$doc(
"_id" -> false,
"blog" -> true,
"likes" -> $doc("$size" -> "$likers"),
"lived.at" -> true
"_id" -> false,
"blog.tier" -> true,
"likes" -> $doc("$size" -> "$likers"),
"lived.at" -> true
)
)
)
}
.headOption
.map { docOption =>
for {
doc <- docOption
likes <- doc.getAsOpt[UblogPost.Likes]("likes")
lived <- doc.getAsOpt[Bdoc]("lived")
liveAt <- doc.getAsOpt[DateTime]("at")
blogs <- doc.getAsOpt[List[UblogBlog]]("blog")
blog <- blogs.headOption
} yield (blog, likes, liveAt)
liveAt <- lived.getAsOpt[DateTime]("at")
blog <- doc.getAsOpt[Bdoc]("blog")
tier <- blog int "tier"
} yield (likes, liveAt, tier)
}
}

View File

@ -16,4 +16,7 @@ lichess.load.then(() => {
.then(likes => button.text(likes).toggleClass(likeClass, liked));
})
);
$('#form3-tier').on('change', function (this: HTMLSelectElement) {
(this.parentNode as HTMLFormElement).submit();
});
});