postmerge: loadmorewrappers

This commit is contained in:
Untone 2024-07-21 17:25:46 +03:00
parent 87d08dcb75
commit 0061b68257
8 changed files with 152 additions and 152 deletions

View File

@ -5,10 +5,18 @@ import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import { Author, Reaction, ReactionKind, ReactionSort } from '~/graphql/schema/core.gen'
import {
Author,
QueryLoad_Reactions_ByArgs,
Reaction,
ReactionKind,
ReactionSort
} from '~/graphql/schema/core.gen'
import { byCreated, byStat } from '~/lib/sort'
import { SortFunction } from '~/types/common'
import { Button } from '../_shared/Button'
import { InlineLoader } from '../_shared/InlineLoader'
import { LoadMoreItems, LoadMoreWrapper } from '../_shared/LoadMoreWrapper'
import { ShowIfAuthenticated } from '../_shared/ShowIfAuthenticated'
import styles from './Article.module.scss'
import { Comment } from './Comment'
@ -20,6 +28,7 @@ type Props = {
shoutSlug: string
shoutId: number
}
const COMMENTS_PER_PAGE = 50
export const CommentsTree = (props: Props) => {
const { session } = useSession()
@ -29,7 +38,7 @@ export const CommentsTree = (props: Props) => {
const [newReactions, setNewReactions] = createSignal<Reaction[]>([])
const [clearEditor, setClearEditor] = createSignal(false)
const [clickedReplyId, setClickedReplyId] = createSignal<number>()
const { reactionEntities, createReaction, loadReactionsBy } = useReactions()
const { reactionEntities, createReaction, loadReactionsBy, addReactions } = useReactions()
const comments = createMemo(() =>
Object.values(reactionEntities).filter((reaction) => reaction.kind === 'COMMENT')
@ -89,6 +98,23 @@ export const CommentsTree = (props: Props) => {
setClearEditor(false)
setPosting(false)
}
const [commentsLoading, setCommentsLoading] = createSignal(false)
const [pagination, setPagination] = createSignal(0)
const loadMoreComments = async () => {
setCommentsLoading(true)
const next = pagination() + 1
const offset = next * COMMENTS_PER_PAGE
const opts: QueryLoad_Reactions_ByArgs = {
by: { comment: true, shout: props.shoutSlug },
limit: COMMENTS_PER_PAGE,
offset
}
const rrr = await loadReactionsBy(opts)
rrr && addReactions(rrr)
rrr && setPagination(next)
setCommentsLoading(false)
return rrr as LoadMoreItems
}
return (
<>
@ -127,20 +153,31 @@ export const CommentsTree = (props: Props) => {
</ul>
</Show>
</div>
<ul class={styles.comments}>
<For each={sortedComments().filter((r) => !r.reply_to)}>
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(props.articleAuthors.some((a) => a?.id === reaction.created_by.id))}
comment={reaction}
clickedReply={(id) => setClickedReplyId(id)}
clickedReplyId={clickedReplyId()}
lastSeen={shoutLastSeen()}
/>
)}
</For>
</ul>
<Show when={commentsLoading()}>
<InlineLoader />
</Show>
<LoadMoreWrapper
loadFunction={loadMoreComments}
pageSize={COMMENTS_PER_PAGE}
hidden={commentsLoading()}
>
<ul class={styles.comments}>
<For each={sortedComments().filter((r) => !r.reply_to)}>
{(reaction) => (
<Comment
sortedComments={sortedComments()}
isArticleAuthor={Boolean(
props.articleAuthors.some((a) => a?.id === reaction.created_by.id)
)}
comment={reaction}
clickedReply={(id) => setClickedReplyId(id)}
clickedReplyId={clickedReplyId()}
lastSeen={shoutLastSeen()}
/>
)}
</For>
</ul>
</LoadMoreWrapper>
<ShowIfAuthenticated
fallback={
<div class={styles.signInMessage}>

View File

@ -6,10 +6,9 @@ import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, onMou
import { isServer } from 'solid-js/web'
import { useFeed } from '~/context/feed'
import { useLocalize } from '~/context/localize'
import { useReactions } from '~/context/reactions'
import { useSession } from '~/context/session'
import { DEFAULT_HEADER_OFFSET, useUI } from '~/context/ui'
import type { Author, Maybe, Shout, Topic } from '~/graphql/schema/core.gen'
import { type Author, type Maybe, type Shout, type Topic } from '~/graphql/schema/core.gen'
import { processPrepositions } from '~/intl/prepositions'
import { isCyrillic } from '~/intl/translate'
import { getImageUrl } from '~/lib/getThumbUrl'
@ -63,15 +62,11 @@ const scrollTo = (el: HTMLElement) => {
}
const imgSrcRegExp = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi
const COMMENTS_PER_PAGE = 30
const VOTES_PER_PAGE = 50
export const FullArticle = (props: Props) => {
const [searchParams, changeSearchParams] = useSearchParams<ArticlePageSearchParams>()
const { showModal } = useUI()
const { loadReactionsBy } = useReactions()
const [selectedImage, setSelectedImage] = createSignal('')
const [isReactionsLoaded, setIsReactionsLoaded] = createSignal(false)
const [isActionPopupActive, setIsActionPopupActive] = createSignal(false)
const { t, formatDate, lang } = useLocalize()
const { session, requireAuthentication } = useSession()
@ -79,27 +74,6 @@ export const FullArticle = (props: Props) => {
const { addSeen } = useFeed()
const formattedDate = createMemo(() => formatDate(new Date((props.article.published_at || 0) * 1000)))
const [pages, setPages] = createSignal<Record<string, number>>({})
createEffect(
on(
pages,
async (p: Record<string, number>) => {
await loadReactionsBy({
by: { shout: props.article.slug, comment: true },
limit: COMMENTS_PER_PAGE,
offset: COMMENTS_PER_PAGE * p.comments || 0
})
await loadReactionsBy({
by: { shout: props.article.slug, rating: true },
limit: VOTES_PER_PAGE,
offset: VOTES_PER_PAGE * p.rating || 0
})
setIsReactionsLoaded(true)
},
{ defer: true }
)
)
const canEdit = createMemo(
() =>
Boolean(author()?.id) &&
@ -167,7 +141,7 @@ export const FullArticle = (props: Props) => {
let commentsRef: HTMLDivElement | undefined
createEffect(() => {
if (searchParams?.commentId && isReactionsLoaded()) {
if (searchParams?.commentId) {
const commentElement = document.querySelector<HTMLElement>(
`[id='comment_${searchParams?.commentId}']`
)
@ -314,11 +288,8 @@ export const FullArticle = (props: Props) => {
}
)
)
const [ratings, setRatings] = createSignal<Reaction[]>([])
onMount(async () => {
// install('G-LQ4B87H8C2')
await loadReactionsBy({ by: { shout: props.article.slug } })
addSeen(props.article.slug)
document.title = props.article.title
updateIframeSizes()
@ -453,11 +424,7 @@ export const FullArticle = (props: Props) => {
<div class="col-md-16 offset-md-5">
<div class={styles.shoutStats}>
<div class={styles.shoutStatsItem}>
<ShoutRatingControl
shout={props.article}
class={styles.ratingControl}
ratings={ratings()}
/>
<ShoutRatingControl shout={props.article} class={styles.ratingControl} />
</div>
<Popover content={t('Comment')} disabled={isActionPopupActive()}>
@ -593,13 +560,11 @@ export const FullArticle = (props: Props) => {
</For>
</div>
<div id="comments" ref={(el) => (commentsRef = el)}>
<Show when={isReactionsLoaded()}>
<CommentsTree
shoutId={props.article.id}
shoutSlug={props.article.slug}
articleAuthors={props.article.authors as Author[]}
/>
</Show>
<CommentsTree
shoutId={props.article.id}
shoutSlug={props.article.slug}
articleAuthors={props.article.authors as Author[]}
/>
</div>
</div>
</div>

View File

@ -1,15 +1,15 @@
import { useSearchParams } from '@solidjs/router'
import { clsx } from 'clsx'
import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
import { byCreated } from '~/lib/sort'
import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { Reaction, ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { useRouter } from '../../stores/router'
import { loadShout } from '../../stores/zine/articles'
import { byCreated } from '../../utils/sortby'
import { useSnackbar } from '../../context/ui'
import { QueryLoad_Reactions_ByArgs, Reaction, ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { Icon } from '../_shared/Icon'
import { InlineLoader } from '../_shared/InlineLoader'
import { LoadMoreItems, LoadMoreWrapper } from '../_shared/LoadMoreWrapper'
import { Popup } from '../_shared/Popup'
import { VotersList } from '../_shared/VotersList'
import stylesComment from './CommentRatingControl.module.scss'
@ -18,38 +18,40 @@ import stylesShout from './ShoutRatingControl.module.scss'
interface RatingControlProps {
shout?: Shout
comment?: Reaction
ratings?: Reaction[]
class?: string
}
export const RatingControl = (props: RatingControlProps) => {
const { t, lang } = useLocalize()
const { changeSearchParams } = useRouter()
const [_, changeSearchParams] = useSearchParams()
const snackbar = useSnackbar()
const { author } = useSession()
const { reactionEntities, createReaction, deleteReaction, loadReactionsBy } = useReactions()
const { session } = useSession()
const { addReactions } = useReactions()
const { reactionEntities, reactionsByShout, createReaction, deleteReaction, loadReactionsBy } =
useReactions()
const [myRate, setMyRate] = createSignal<Reaction | undefined>()
const [ratingReactions, setRatingReactions] = createSignal<Reaction[]>([])
const [isLoading, setIsLoading] = createSignal(false)
// reaction kind
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
r.created_by.slug === session()?.user?.app_data?.profile?.slug &&
r.shout.id === props.comment?.shout.id &&
r.reply_to === props.comment?.id
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
const [myRate, setMyRate] = createSignal<Reaction | undefined>()
const [total, setTotal] = createSignal(props.comment?.stat?.rating || props.shout?.stat?.rating || 0)
const [ratingReactions, setRatingReactions] = createSignal<Reaction[]>([])
createEffect(() => {
const shout = props.comment.shout.id || props.shout.id
const shout = props.comment?.shout.id || props.shout?.id
if (shout && !ratingReactions()) {
let result = Object.values(reactionEntities).filter(
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === shout,
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === shout
)
if (props.comment?.id) result = result.filter((r) => r.reply_to === props.comment.id)
if (props.comment?.id) result = result.filter((r) => r.reply_to === props.comment?.id)
setRatingReactions(result)
}
})
@ -58,14 +60,14 @@ export const RatingControl = (props: RatingControlProps) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
r.created_by.slug === author()?.slug &&
r.shout.id === props.comment.shout.id &&
r.reply_to === props.comment.id,
r.created_by.slug === session()?.user?.nickname &&
r.shout.id === props.comment?.shout.id &&
r.reply_to === props.comment?.id
)
return deleteReaction(reactionToDelete.id)
return reactionToDelete && deleteReaction(reactionToDelete.id)
}
const [isLoading, setIsLoading] = createSignal(false)
// rating change
const handleRatingChange = async (isUpvote: boolean) => {
setIsLoading(true)
try {
@ -74,63 +76,33 @@ export const RatingControl = (props: RatingControlProps) => {
} else if (isDownvoted()) {
await deleteRating(ReactionKind.Dislike)
} else {
await createReaction({
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment.id,
})
props.comment?.shout.id &&
(await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment?.id
}
}))
}
} catch {
snackbar?.showSnackbar({ type: 'error', body: t('Error') })
}
await loadShout(props.comment.shout.slug)
await loadReactionsBy({
by: { shout: props.comment.shout.slug },
})
if (props.comment?.shout.slug) {
const rrr = await loadReactionsBy({ by: { shout: props.comment.shout.slug } })
addReactions(rrr)
}
setIsLoading(false)
}
createEffect(
on(
() => props.comment,
(comment) => {
if (comment) {
setTotal(comment?.stat?.rating)
}
},
{ defer: true },
),
const total = createMemo<number>(() =>
props.comment?.stat?.rating ? props.comment.stat.rating : props.shout?.stat?.rating || 0
)
createEffect(
on(
() => props.shout,
(shout) => {
if (shout) {
setTotal(shout.stat?.rating)
}
},
{ defer: true },
),
)
createEffect(
on(
() => reactionEntities,
(reactions) => {
const ratings = Object.values(reactions).filter((r) => !r?.reply_to)
const likes = ratings.filter((rating) => rating.kind === 'LIKE').length
const dislikes = ratings.filter((rating) => rating.kind === 'DISLIKE').length
const total = likes - dislikes
setTotal(total)
},
{ defer: true },
),
)
createEffect(
on(
[ratingReactions, author],
[ratingReactions, () => session()?.user?.app_data?.profile],
([reactions, me]) => {
console.debug('[RatingControl] on reactions update')
const ratingVotes = Object.values(reactions).filter((r) => !r.reply_to)
@ -138,8 +110,8 @@ export const RatingControl = (props: RatingControlProps) => {
const myReaction = reactions.find((r) => r.created_by.id === me?.id)
setMyRate((_) => myReaction)
},
{ defer: true },
),
{ defer: true }
)
)
const getTrigger = createMemo(() => {
@ -148,48 +120,78 @@ export const RatingControl = (props: RatingControlProps) => {
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: total() > 0 && Boolean(props.comment?.id),
[stylesComment.commentRatingNegative]: total() < 0 && Boolean(props.comment?.id),
[stylesShout.ratingValue]: !props.comment?.id,
[stylesShout.ratingValue]: !props.comment?.id
})}
>
{total()}
</div>
)
})
const VOTERS_PER_PAGE = 10
const [ratingPage, setRatingPage] = createSignal(0)
const [ratingLoading, setRatingLoading] = createSignal(false) // FIXME: use loading indication
const ratings = createMemo(() =>
props.shout
? reactionsByShout[props.shout?.slug]?.filter(
(r) => r.kind === ReactionKind.Like || r.kind === ReactionKind.Dislike
)
: []
)
const loadMoreReactions = async () => {
setRatingLoading(true)
const next = ratingPage() + 1
const offset = VOTERS_PER_PAGE * next
const opts: QueryLoad_Reactions_ByArgs = {
by: { rating: true, shout: props.shout?.slug },
limit: VOTERS_PER_PAGE,
offset
}
const rrr = await loadReactionsBy(opts)
rrr && addReactions(rrr)
rrr && setRatingPage(next)
setRatingLoading(false)
return rrr as LoadMoreItems
}
return props.comment?.id ? (
<div class={stylesComment.commentRating}>
<button
role="button"
disabled={!author()}
disabled={!session()?.user?.app_data?.profile}
onClick={() => handleRatingChange(true)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: isUpvoted(),
[stylesComment.voted]: isUpvoted()
})}
/>
<Popup
trigger={
<div
class={clsx(stylesComment.commentRatingValue, {
[stylesComment.commentRatingPositive]: props.comment.stat.rating > 0,
[stylesComment.commentRatingNegative]: props.comment.stat.rating < 0,
[stylesComment.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
[stylesComment.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0
})}
>
{props.comment.stat.rating || 0}
{props.comment?.stat?.rating || 0}
</div>
}
variant="tiny"
>
<VotersList
reactions={ratingReactions()}
fallbackMessage={t('This comment has not yet been rated')}
/>
<Show when={ratingLoading()}>
<InlineLoader />
</Show>
<LoadMoreWrapper
loadFunction={loadMoreReactions}
pageSize={VOTERS_PER_PAGE}
hidden={ratingLoading()}
>
<VotersList reactions={ratings()} fallbackMessage={t('This comment has not been rated yet')} />
</LoadMoreWrapper>
</Popup>
<button
role="button"
disabled={!author()}
disabled={!session()?.user?.app_data?.profile}
onClick={() => handleRatingChange(false)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: isDownvoted(),
[stylesComment.voted]: isDownvoted()
})}
/>
</div>
@ -201,7 +203,7 @@ export const RatingControl = (props: RatingControlProps) => {
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
[stylesComment.voted]: myRate()?.kind === 'LIKE',
[stylesComment.voted]: myRate()?.kind === 'LIKE'
})
: ''
}
@ -215,10 +217,10 @@ export const RatingControl = (props: RatingControlProps) => {
</button>
<Popup trigger={getTrigger()} variant="tiny">
<Show
when={author()}
when={!!session()?.user?.app_data?.profile}
fallback={
<>
<span class="link" onClick={() => changeSearchParams({ mode: 'login', modal: 'auth' })}>
<span class="link" onClick={() => changeSearchParams({ mode: 'login', m: 'auth' })}>
{t('Enter')}
</span>
{lang() === 'ru' ? ', ' : ' '}
@ -238,7 +240,7 @@ export const RatingControl = (props: RatingControlProps) => {
class={
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
[stylesComment.voted]: myRate()?.kind === 'DISLIKE',
[stylesComment.voted]: myRate()?.kind === 'DISLIKE'
})
: ''
}

View File

@ -1 +0,0 @@

View File

@ -456,7 +456,7 @@
"Theory": "Теории",
"There are unsaved changes in your profile settings. Are you sure you want to leave the page without saving?": "В настройках вашего профиля есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"There are unsaved changes in your publishing settings. Are you sure you want to leave the page without saving?": "В настройках публикации есть несохраненные изменения. Уверены, что хотите покинуть страницу без сохранения?",
"This comment has not yet been rated": "Этот комментарий еще пока никто не оценил",
"This comment has not been rated yet": "Этот комментарий еще пока никто не оценил",
"This content is not published yet": "Содержимое ещё не опубликовано",
"This email is": "Этот email",
"This email is not verified": "Этот email не подтвержден",

View File

@ -1 +0,0 @@