324 lines
No EOL
11 KiB
Nim
324 lines
No EOL
11 KiB
Nim
|
|
import options, json, times, httpcore, strformat, sugar, math, strutils
|
|
|
|
import threadlist, category, post, user
|
|
type
|
|
|
|
PostList* = ref object
|
|
thread*: Thread
|
|
history*: seq[Thread] ## If the thread was edited this will contain the
|
|
## older versions of the thread (title/category
|
|
## changes).
|
|
posts*: seq[Post]
|
|
|
|
when defined(js):
|
|
from dom import nil
|
|
|
|
include karax/prelude
|
|
import karax / [vstyles, kajax, kdom]
|
|
|
|
import karaxutils, error, replybox, editbox
|
|
|
|
type
|
|
State = ref object
|
|
list: Option[PostList]
|
|
loading: bool
|
|
status: HttpCode
|
|
replyingTo: Option[Post]
|
|
replyBox: ReplyBox
|
|
editing: Option[Post] ## If in edit mode, this contains the post.
|
|
editBox: EditBox
|
|
|
|
proc onReplyPosted(id: int)
|
|
proc onEditPosted(id: int, content: string, subject: Option[string])
|
|
proc onEditCancelled()
|
|
proc newState(): State =
|
|
State(
|
|
list: none[PostList](),
|
|
loading: false,
|
|
status: Http200,
|
|
replyingTo: none[Post](),
|
|
replyBox: newReplyBox(onReplyPosted),
|
|
editBox: newEditBox(onEditPosted, onEditCancelled)
|
|
)
|
|
|
|
var
|
|
state = newState()
|
|
|
|
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
|
|
state.loading = false
|
|
state.status = httpStatus.HttpCode
|
|
if state.status != Http200: return
|
|
|
|
let parsed = parseJson($response)
|
|
let list = to(parsed, PostList)
|
|
|
|
state.list = some(list)
|
|
|
|
# The anchor should be jumped to once all the posts have been loaded.
|
|
if postId.isSome():
|
|
discard setTimeout(
|
|
() => (
|
|
# Would have used scrollIntoView but then the `:target` selector
|
|
# isn't activated.
|
|
window.location.hash = "";
|
|
window.location.hash = "#" & $postId.get()
|
|
),
|
|
100
|
|
)
|
|
|
|
proc onMorePosts(httpStatus: int, response: kstring, start: int) =
|
|
state.loading = false
|
|
state.status = httpStatus.HttpCode
|
|
if state.status != Http200: return
|
|
|
|
let parsed = parseJson($response)
|
|
var list = to(parsed, seq[Post])
|
|
|
|
var idsLoaded: seq[int] = @[]
|
|
for i in 0..<list.len:
|
|
state.list.get().posts.insert(list[i], i+start)
|
|
idsLoaded.add(list[i].id)
|
|
|
|
# Save a list of the IDs which have not yet been loaded into the top-most
|
|
# post.
|
|
let postIndex = start+list.len
|
|
# The following check is necessary because we reuse this proc to load
|
|
# a newly created post.
|
|
if postIndex < state.list.get().posts.len:
|
|
let post = state.list.get().posts[postIndex]
|
|
var newPostIds: seq[int] = @[]
|
|
for id in post.moreBefore:
|
|
if id notin idsLoaded:
|
|
newPostIds.add(id)
|
|
post.moreBefore = newPostIds
|
|
|
|
proc loadMore(start: int, ids: seq[int]) =
|
|
if state.loading: return
|
|
|
|
state.loading = true
|
|
let uri = makeUri(
|
|
"specific_posts.json",
|
|
[("ids", $(%ids))]
|
|
)
|
|
ajaxGet(
|
|
uri,
|
|
@[],
|
|
(s: int, r: kstring) => onMorePosts(s, r, start)
|
|
)
|
|
|
|
proc onReplyPosted(id: int) =
|
|
## Executed when a reply has been successfully posted.
|
|
loadMore(state.list.get().posts.len, @[id])
|
|
|
|
proc onEditCancelled() = state.editing = none[Post]()
|
|
|
|
proc onEditPosted(id: int, content: string, subject: Option[string]) =
|
|
## Executed when an edit has been successfully posted.
|
|
state.editing = none[Post]()
|
|
let list = state.list.get()
|
|
for i in 0 ..< list.posts.len:
|
|
if list.posts[i].id == id:
|
|
list.posts[i].info.content = content
|
|
break
|
|
|
|
proc onReplyClick(e: Event, n: VNode, p: Option[Post]) =
|
|
state.replyingTo = p
|
|
state.replyBox.show()
|
|
|
|
proc onEditClick(e: Event, n: VNode, p: Post) =
|
|
state.editing = some(p)
|
|
|
|
# TODO: Ensure the edit box is as big as its content. Auto resize the
|
|
# text area.
|
|
|
|
proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) =
|
|
loadMore(start, post.moreBefore) # TODO: Don't load all!
|
|
|
|
proc genLoadMore(post: Post, start: int): VNode =
|
|
result = buildHtml():
|
|
tdiv(class="information load-more-posts",
|
|
onClick=(e: Event, n: VNode) => onLoadMore(e, n, start, post)):
|
|
tdiv(class="information-icon"):
|
|
italic(class="fas fa-comment-dots")
|
|
tdiv(class="information-main"):
|
|
if state.loading:
|
|
tdiv(class="loading loading-lg")
|
|
else:
|
|
tdiv(class="information-title"):
|
|
text "Load more posts "
|
|
span(class="more-post-count"):
|
|
text "(" & $post.moreBefore.len & ")"
|
|
|
|
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
|
|
let loggedIn = currentUser.isSome()
|
|
let authoredByUser =
|
|
loggedIn and currentUser.get().name == post.author.name
|
|
let currentAdmin =
|
|
currentUser.isSome() and currentUser.get().rank == Admin
|
|
|
|
# Don't show buttons if the post is being edited.
|
|
if state.editing.isSome() and state.editing.get() == post:
|
|
return buildHtml(tdiv())
|
|
|
|
result = buildHtml():
|
|
tdiv(class="post-buttons"):
|
|
if authoredByUser or currentAdmin:
|
|
tdiv(class="edit-button", onClick=(e: Event, n: VNode) =>
|
|
onEditClick(e, n, post)):
|
|
button(class="btn"):
|
|
italic(class="far fa-edit")
|
|
tdiv(class="delete-button"):
|
|
button(class="btn"):
|
|
italic(class="far fa-trash-alt")
|
|
|
|
if not authoredByUser:
|
|
tdiv(class="like-button"):
|
|
button(class="btn"):
|
|
span(class="like-count"):
|
|
if post.likes.len > 0:
|
|
text $post.likes.len
|
|
italic(class="far fa-heart")
|
|
|
|
if loggedIn:
|
|
tdiv(class="flag-button"):
|
|
button(class="btn"):
|
|
italic(class="far fa-flag")
|
|
|
|
if loggedIn:
|
|
tdiv(class="reply-button"):
|
|
button(class="btn", onClick=(e: Event, n: VNode) =>
|
|
onReplyClick(e, n, some(post))):
|
|
italic(class="fas fa-reply")
|
|
text " Reply"
|
|
|
|
proc genPost(post: Post, thread: Thread, currentUser: Option[User]): VNode =
|
|
let postCopy = post # TODO: Another workaround here, closure capture :(
|
|
|
|
result = buildHtml():
|
|
tdiv(class="post", id = $post.id):
|
|
tdiv(class="post-icon"):
|
|
render(post.author, "post-avatar")
|
|
tdiv(class="post-main"):
|
|
tdiv(class="post-title"):
|
|
tdiv(class="post-username"):
|
|
text post.author.name
|
|
if post.isModerated:
|
|
italic(class="fas fa-eye-slash",
|
|
title="User is moderated")
|
|
if post.author.rank == Moderator:
|
|
italic(class="fas fa-shield-alt",
|
|
title="User is a moderator")
|
|
if post.author.rank == Admin:
|
|
italic(class="fas fa-chess-knight",
|
|
title="User is an admin")
|
|
tdiv(class="post-time"):
|
|
let title = post.info.creation.fromUnix().local.
|
|
format("MMM d, yyyy HH:mm")
|
|
a(href=renderPostUrl(post, thread), title=title):
|
|
text renderActivity(post.info.creation)
|
|
tdiv(class="post-content"):
|
|
if state.editing.isSome() and state.editing.get() == post:
|
|
render(state.editBox, postCopy)
|
|
else:
|
|
verbatim(post.info.content)
|
|
|
|
genPostButtons(postCopy, currentUser)
|
|
|
|
proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode =
|
|
var latestTime =
|
|
if post.isSome: post.get().info.creation.fromUnix()
|
|
else: getTime()
|
|
|
|
# TODO: Use `between` once it's merged into stdlib.
|
|
let
|
|
tmpl =
|
|
if last: [
|
|
"A long time since last reply",
|
|
"$1 year since last reply",
|
|
"$1 years since last reply",
|
|
"$1 month since last reply",
|
|
"$1 months since last reply",
|
|
]
|
|
else: [
|
|
"Some time later",
|
|
"$1 year later", "$1 years later",
|
|
"$1 month later", "$1 months later"
|
|
]
|
|
var diffStr = tmpl[0]
|
|
let diff = latestTime - prevPost.info.creation.fromUnix()
|
|
if diff.weeks > 48:
|
|
let years = diff.weeks div 48
|
|
diffStr =
|
|
(if years == 1: tmpl[1] else: tmpl[2]) % $years
|
|
elif diff.weeks > 4:
|
|
let months = diff.weeks div 4
|
|
diffStr =
|
|
(if months == 1: tmpl[3] else: tmpl[4]) % $months
|
|
else:
|
|
return buildHtml(tdiv())
|
|
|
|
# PROTIP: Good thread ID to test this with is: 1267.
|
|
result = buildHtml():
|
|
tdiv(class="information time-passed"):
|
|
tdiv(class="information-icon"):
|
|
italic(class="fas fa-clock")
|
|
tdiv(class="information-main"):
|
|
tdiv(class="information-title"):
|
|
text diffStr
|
|
|
|
proc renderPostList*(threadId: int, postId: Option[int],
|
|
currentUser: Option[User]): VNode =
|
|
if state.status != Http200:
|
|
return renderError("Couldn't retrieve posts.")
|
|
|
|
if state.list.isNone or state.list.get().thread.id != threadId:
|
|
var params = @[("id", $threadId)]
|
|
if postId.isSome():
|
|
params.add(("anchor", $postId.get()))
|
|
let uri = makeUri("posts.json", params)
|
|
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
|
|
|
|
return buildHtml(tdiv(class="loading loading-lg"))
|
|
|
|
let list = state.list.get()
|
|
result = buildHtml():
|
|
section(class="container grid-xl"):
|
|
tdiv(class="title"):
|
|
p(): text list.thread.topic
|
|
if list.thread.isLocked:
|
|
italic(class="fas fa-lock fa-xs",
|
|
title="Thread cannot be replied to")
|
|
text "Locked"
|
|
if list.thread.isModerated:
|
|
italic(class="fas fa-eye-slash fa-xs",
|
|
title="Thread is moderated")
|
|
text "Moderated"
|
|
if list.thread.isSolved:
|
|
italic(class="fas fa-check-square fa-xs",
|
|
title="Thread has a solution")
|
|
text "Solved"
|
|
render(list.thread.category)
|
|
tdiv(class="posts"):
|
|
var prevPost: Option[Post] = none[Post]()
|
|
for i, post in list.posts:
|
|
if not post.visibleTo(currentUser): continue
|
|
|
|
if prevPost.isSome:
|
|
genTimePassed(prevPost.get(), some(post), false)
|
|
if post.moreBefore.len > 0:
|
|
genLoadMore(post, i)
|
|
genPost(post, list.thread, currentUser)
|
|
prevPost = some(post)
|
|
|
|
if prevPost.isSome:
|
|
genTimePassed(prevPost.get(), none[Post](), true)
|
|
|
|
tdiv(id="thread-buttons"):
|
|
button(class="btn btn-secondary",
|
|
onClick=(e: Event, n: VNode) =>
|
|
onReplyClick(e, n, none[Post]())):
|
|
italic(class="fas fa-reply")
|
|
text " Reply"
|
|
|
|
render(state.replyBox, list.thread, state.replyingTo, false) |