webapp/src/components/Article/RatingControl.tsx

258 lines
8.8 KiB
TypeScript
Raw Normal View History

2024-07-21 14:25:46 +00:00
import { useSearchParams } from '@solidjs/router'
import { clsx } from 'clsx'
2024-05-06 20:26:16 +00:00
import { Show, createEffect, createMemo, createSignal, on } from 'solid-js'
2024-07-21 14:25:46 +00:00
import { byCreated } from '~/lib/sort'
import { useLocalize } from '../../context/localize'
import { useReactions } from '../../context/reactions'
import { useSession } from '../../context/session'
2024-07-21 14:25:46 +00:00
import { useSnackbar } from '../../context/ui'
import { QueryLoad_Reactions_ByArgs, Reaction, ReactionKind, Shout } from '../../graphql/schema/core.gen'
import { Icon } from '../_shared/Icon'
2024-07-21 14:25:46 +00:00
import { InlineLoader } from '../_shared/InlineLoader'
import { LoadMoreItems, LoadMoreWrapper } from '../_shared/LoadMoreWrapper'
import { Popup } from '../_shared/Popup'
2023-03-09 23:56:19 +00:00
import { VotersList } from '../_shared/VotersList'
2024-02-15 12:51:04 +00:00
import stylesComment from './CommentRatingControl.module.scss'
import stylesShout from './ShoutRatingControl.module.scss'
interface RatingControlProps {
shout?: Shout
comment?: Reaction
class?: string
}
2024-02-15 12:51:04 +00:00
export const RatingControl = (props: RatingControlProps) => {
2024-02-16 11:29:27 +00:00
const { t, lang } = useLocalize()
2024-07-21 14:25:46 +00:00
const [_, changeSearchParams] = useSearchParams()
2024-05-06 20:26:16 +00:00
const snackbar = useSnackbar()
2024-07-21 14:25:46 +00:00
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)
2024-05-06 20:26:16 +00:00
2024-07-21 14:25:46 +00:00
// reaction kind
2024-05-06 20:26:16 +00:00
const checkReaction = (reactionKind: ReactionKind) =>
Object.values(reactionEntities).some(
(r) =>
r.kind === reactionKind &&
2024-07-21 14:25:46 +00:00
r.created_by.slug === session()?.user?.app_data?.profile?.slug &&
r.shout.id === props.comment?.shout.id &&
r.reply_to === props.comment?.id
2024-05-06 20:26:16 +00:00
)
const isUpvoted = createMemo(() => checkReaction(ReactionKind.Like))
const isDownvoted = createMemo(() => checkReaction(ReactionKind.Dislike))
createEffect(() => {
2024-07-21 14:25:46 +00:00
const shout = props.comment?.shout.id || props.shout?.id
2024-05-06 20:26:16 +00:00
if (shout && !ratingReactions()) {
let result = Object.values(reactionEntities).filter(
2024-07-21 14:25:46 +00:00
(r) => [ReactionKind.Like, ReactionKind.Dislike].includes(r.kind) && r.shout.id === shout
2024-05-06 20:26:16 +00:00
)
2024-07-21 14:25:46 +00:00
if (props.comment?.id) result = result.filter((r) => r.reply_to === props.comment?.id)
2024-05-06 20:26:16 +00:00
setRatingReactions(result)
}
})
const deleteRating = async (reactionKind: ReactionKind) => {
const reactionToDelete = Object.values(reactionEntities).find(
(r) =>
r.kind === reactionKind &&
2024-07-21 14:25:46 +00:00
r.created_by.slug === session()?.user?.nickname &&
r.shout.id === props.comment?.shout.id &&
r.reply_to === props.comment?.id
2024-05-06 20:26:16 +00:00
)
2024-07-21 14:25:46 +00:00
return reactionToDelete && deleteReaction(reactionToDelete.id)
2024-05-06 20:26:16 +00:00
}
2024-07-21 14:25:46 +00:00
// rating change
2024-05-06 20:26:16 +00:00
const handleRatingChange = async (isUpvote: boolean) => {
setIsLoading(true)
try {
if (isUpvoted()) {
await deleteRating(ReactionKind.Like)
} else if (isDownvoted()) {
await deleteRating(ReactionKind.Dislike)
} else {
2024-07-21 14:25:46 +00:00
props.comment?.shout.id &&
(await createReaction({
reaction: {
kind: isUpvote ? ReactionKind.Like : ReactionKind.Dislike,
shout: props.comment.shout.id,
reply_to: props.comment?.id
}
}))
2024-05-06 20:26:16 +00:00
}
} catch {
snackbar?.showSnackbar({ type: 'error', body: t('Error') })
}
2024-07-21 14:25:46 +00:00
if (props.comment?.shout.slug) {
const rrr = await loadReactionsBy({ by: { shout: props.comment.shout.slug } })
addReactions(rrr)
}
2024-05-06 20:26:16 +00:00
setIsLoading(false)
}
2024-02-15 13:41:14 +00:00
2024-07-21 14:25:46 +00:00
const total = createMemo<number>(() =>
props.comment?.stat?.rating ? props.comment.stat.rating : props.shout?.stat?.rating || 0
2024-02-16 08:01:40 +00:00
)
createEffect(
on(
2024-07-21 14:25:46 +00:00
[ratingReactions, () => session()?.user?.app_data?.profile],
2024-02-07 16:54:52 +00:00
([reactions, me]) => {
2024-03-03 17:06:58 +00:00
console.debug('[RatingControl] on reactions update')
const ratingVotes = Object.values(reactions).filter((r) => !r.reply_to)
2024-05-06 20:26:16 +00:00
setRatingReactions((_) => ratingVotes.sort(byCreated))
2024-03-03 17:06:58 +00:00
const myReaction = reactions.find((r) => r.created_by.id === me?.id)
setMyRate((_) => myReaction)
2024-02-07 16:54:52 +00:00
},
2024-07-21 14:25:46 +00:00
{ defer: true }
)
2024-03-03 17:06:58 +00:00
)
2024-01-23 14:41:49 +00:00
2024-02-16 08:01:40 +00:00
const getTrigger = createMemo(() => {
2024-05-06 20:26:16 +00:00
return (
2024-02-15 12:51:04 +00:00
<div
class={clsx(stylesComment.commentRatingValue, {
2024-05-06 20:26:16 +00:00
[stylesComment.commentRatingPositive]: total() > 0 && Boolean(props.comment?.id),
[stylesComment.commentRatingNegative]: total() < 0 && Boolean(props.comment?.id),
2024-07-21 14:25:46 +00:00
[stylesShout.ratingValue]: !props.comment?.id
2024-02-15 12:51:04 +00:00
})}
>
2024-05-06 20:26:16 +00:00
{total()}
2024-02-15 12:51:04 +00:00
</div>
)
2024-02-16 08:01:40 +00:00
})
2024-07-21 14:25:46 +00:00
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
}
2024-05-06 20:26:16 +00:00
return props.comment?.id ? (
<div class={stylesComment.commentRating}>
<button
role="button"
2024-07-21 14:25:46 +00:00
disabled={!session()?.user?.app_data?.profile}
2024-05-06 20:26:16 +00:00
onClick={() => handleRatingChange(true)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
2024-07-21 14:25:46 +00:00
[stylesComment.voted]: isUpvoted()
2024-05-06 20:26:16 +00:00
})}
/>
<Popup
trigger={
<div
class={clsx(stylesComment.commentRatingValue, {
2024-07-21 14:25:46 +00:00
[stylesComment.commentRatingPositive]: (props.comment?.stat?.rating || 0) > 0,
[stylesComment.commentRatingNegative]: (props.comment?.stat?.rating || 0) < 0
2024-05-06 20:26:16 +00:00
})}
>
2024-07-21 14:25:46 +00:00
{props.comment?.stat?.rating || 0}
2024-05-06 20:26:16 +00:00
</div>
}
variant="tiny"
>
2024-07-21 14:25:46 +00:00
<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>
2024-05-06 20:26:16 +00:00
</Popup>
<button
role="button"
2024-07-21 14:25:46 +00:00
disabled={!session()?.user?.app_data?.profile}
2024-05-06 20:26:16 +00:00
onClick={() => handleRatingChange(false)}
class={clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
2024-07-21 14:25:46 +00:00
[stylesComment.voted]: isDownvoted()
2024-05-06 20:26:16 +00:00
})}
/>
</div>
) : (
2024-02-15 12:51:04 +00:00
<div class={clsx(props.comment ? stylesComment.commentRating : stylesShout.rating, props.class)}>
<button
2024-05-06 20:26:16 +00:00
onClick={() => handleRatingChange(false)}
2024-02-15 12:51:04 +00:00
disabled={isLoading()}
class={
2024-02-16 08:02:00 +00:00
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlUp, {
2024-07-21 14:25:46 +00:00
[stylesComment.voted]: myRate()?.kind === 'LIKE'
2024-02-16 08:02:00 +00:00
})
: ''
2024-02-15 12:51:04 +00:00
}
>
<Show when={!props.comment}>
<Icon
2024-05-06 20:26:16 +00:00
name={isDownvoted() ? 'rating-control-checked' : 'rating-control-less'}
2024-02-15 12:51:04 +00:00
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
2024-02-15 12:51:04 +00:00
<Popup trigger={getTrigger()} variant="tiny">
2024-02-16 11:29:27 +00:00
<Show
2024-07-21 14:25:46 +00:00
when={!!session()?.user?.app_data?.profile}
2024-02-16 11:29:27 +00:00
fallback={
<>
2024-07-21 14:25:46 +00:00
<span class="link" onClick={() => changeSearchParams({ mode: 'login', m: 'auth' })}>
2024-02-16 11:29:27 +00:00
{t('Enter')}
</span>
{lang() === 'ru' ? ', ' : ' '}
{t('to see the voters')}
</>
}
>
2024-02-16 10:59:32 +00:00
<VotersList
2024-05-06 20:26:16 +00:00
reactions={ratingReactions()}
2024-02-16 10:59:32 +00:00
fallbackMessage={isLoading() ? t('Loading') : t('No one rated yet')}
/>
</Show>
</Popup>
2024-02-15 12:51:04 +00:00
<button
2024-05-06 20:26:16 +00:00
onClick={() => handleRatingChange(true)}
2024-02-15 12:51:04 +00:00
disabled={isLoading()}
class={
2024-02-16 08:02:00 +00:00
props.comment
? clsx(stylesComment.commentRatingControl, stylesComment.commentRatingControlDown, {
2024-07-21 14:25:46 +00:00
[stylesComment.voted]: myRate()?.kind === 'DISLIKE'
2024-02-16 08:02:00 +00:00
})
: ''
2024-02-15 12:51:04 +00:00
}
>
<Show when={!props.comment}>
<Icon
2024-05-06 20:26:16 +00:00
name={isUpvoted() ? 'rating-control-checked' : 'rating-control-more'}
2024-02-15 12:51:04 +00:00
class={isLoading() ? 'rotating' : ''}
/>
</Show>
</button>
</div>
)
}