Compare commits
3
Commits
eb5b3a095b
...
b80e88b121
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80e88b121 | ||
|
|
cdd7147d8e | ||
|
|
ab539bf84b |
No files matched your search
+3
-7
@@ -17,7 +17,7 @@ interface IssueSseEvent {
|
||||
type: string
|
||||
}
|
||||
|
||||
const { data } = useEventSource<string[], IssueSseEvent>(
|
||||
const { data, event } = useEventSource<string[], IssueSseEvent>(
|
||||
`${window.location.origin}/api/issues/events`,
|
||||
['issue.created', 'issue.updated', 'issue.deleted'],
|
||||
{
|
||||
@@ -33,12 +33,8 @@ const { data } = useEventSource<string[], IssueSseEvent>(
|
||||
},
|
||||
)
|
||||
|
||||
watch(data, () => {
|
||||
if (
|
||||
data.value?.type === 'issue.created' ||
|
||||
data.value?.type === 'issue.updated' ||
|
||||
data.value?.type === 'issue.deleted'
|
||||
) {
|
||||
watch(event, () => {
|
||||
if (event.value === 'issue.created' || event.value === 'issue.updated' || event.value === 'issue.deleted') {
|
||||
issueStore.getStat(currentProjectId.value)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiClient, toQuery } from '@/lib/api-client.ts'
|
||||
import type { User } from '@/api/user.api.ts'
|
||||
|
||||
export interface CommentFile {
|
||||
id: string
|
||||
fileName: string
|
||||
mimeType: string
|
||||
size: number
|
||||
uploaderId: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
issueId: number
|
||||
authorId: string | null
|
||||
/** null — the author deleted their account */
|
||||
author: User | null
|
||||
/** null for a deleted comment */
|
||||
content: string | null
|
||||
attachments: CommentFile[]
|
||||
editedAt: string | null
|
||||
deletedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CommentsPage {
|
||||
list: Comment[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
export interface CreateCommentInput {
|
||||
content: string
|
||||
fileIds?: string[]
|
||||
}
|
||||
|
||||
export type UpdateCommentInput = CreateCommentInput
|
||||
|
||||
/** forward — newer than the cursor, backward — older than the cursor (no cursor: the latest page) */
|
||||
export type CommentListDirection = 'forward' | 'backward'
|
||||
|
||||
export const COMMENT_EVENT_TYPES = ['comment.created', 'comment.updated', 'comment.deleted'] as const
|
||||
|
||||
export type CommentEventType = (typeof COMMENT_EVENT_TYPES)[number]
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function itemsList(
|
||||
issueId: number,
|
||||
params: { limit?: number; cursor?: string | null; direction?: CommentListDirection } = {},
|
||||
) {
|
||||
const query = toQuery({ limit: params.limit, cursor: params.cursor, direction: params.direction })
|
||||
return apiClient.get<CommentsPage>(`/api/issues/${issueId}/comments${query}`)
|
||||
}
|
||||
|
||||
export async function itemCreate(issueId: number, input: CreateCommentInput) {
|
||||
return apiClient.post<Comment>(`/api/issues/${issueId}/comments`, input)
|
||||
}
|
||||
|
||||
export async function itemUpdate(id: string, input: UpdateCommentInput) {
|
||||
return apiClient.patch<Comment>(`/api/comments/${id}`, input)
|
||||
}
|
||||
|
||||
export async function itemDelete(id: string) {
|
||||
await apiClient.delete(`/api/comments/${id}`)
|
||||
return true
|
||||
}
|
||||
|
||||
export function eventsUrl(issueId: number) {
|
||||
return `${window.location.origin}/api/issues/${issueId}/comments/events`
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCommentStore } from '@/stores/comment.ts'
|
||||
import { useCommentEvents } from '@/composables/useCommentEvents.ts'
|
||||
import { useChatScroll } from '@/composables/useChatScroll.ts'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import type { Comment } from '@/api/comment.api.ts'
|
||||
|
||||
interface Props {
|
||||
issueId: number
|
||||
}
|
||||
|
||||
/** Consecutive messages of one author closer than this are grouped under one header */
|
||||
const GROUP_GAP_MS = 5 * 60 * 1000
|
||||
|
||||
const { issueId } = defineProps<Props>()
|
||||
|
||||
const session = authClient.useSession()
|
||||
const myId = computed(() => session.value.data?.user.id ?? null)
|
||||
|
||||
const commentStore = useCommentStore()
|
||||
const { comments, olderCursor, loading, loadingOlder } = storeToRefs(commentStore)
|
||||
|
||||
const scroller = useTemplateRef<HTMLElement>('scroller')
|
||||
const { atBottom, scrollToBottom, keepPosition, isUnderfilled } = useChatScroll(scroller, { onReachTop: loadOlder })
|
||||
|
||||
const draft = ref('')
|
||||
const sending = ref(false)
|
||||
const unread = ref(0)
|
||||
|
||||
const canSend = computed(() => draft.value.trim().length > 0 && !sending.value)
|
||||
|
||||
async function loadOlder() {
|
||||
if (!olderCursor.value || loading.value || loadingOlder.value) return
|
||||
await keepPosition(() => commentStore.loadOlder())
|
||||
// Short pages may not fill the container, so the user could never scroll to the top
|
||||
if (isUnderfilled()) await loadOlder()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => issueId,
|
||||
async (id) => {
|
||||
unread.value = 0
|
||||
await commentStore.load(id)
|
||||
await scrollToBottom()
|
||||
if (isUnderfilled()) await loadOlder()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
useCommentEvents(() => issueId, {
|
||||
onEvent: commentStore.applyEvent,
|
||||
onReconnect: () => commentStore.loadNewer(),
|
||||
})
|
||||
|
||||
// A new message at the end: follow it if the user is at the bottom or wrote it, otherwise count it.
|
||||
// Runs before the DOM update, so `atBottom` still reflects the position before the message appeared.
|
||||
watch(
|
||||
() => comments.value.at(-1)?.id,
|
||||
(id, prevId) => {
|
||||
if (!id || !prevId || id === prevId || loading.value) return
|
||||
if (atBottom.value || comments.value.at(-1)!.authorId === myId.value) scrollToBottom('smooth')
|
||||
else unread.value++
|
||||
},
|
||||
)
|
||||
|
||||
watch(atBottom, (value) => {
|
||||
if (value) unread.value = 0
|
||||
})
|
||||
|
||||
const timeFormat = new Intl.DateTimeFormat('ru-RU', { timeStyle: 'short' })
|
||||
const dayFormat = new Intl.DateTimeFormat('ru-RU', { dateStyle: 'long' })
|
||||
|
||||
function dayKey(date: Date) {
|
||||
return date.toDateString()
|
||||
}
|
||||
|
||||
function dayLabel(date: Date) {
|
||||
const today = new Date()
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
if (dayKey(date) === dayKey(today)) return 'Today'
|
||||
if (dayKey(date) === dayKey(yesterday)) return 'Yesterday'
|
||||
return dayFormat.format(date)
|
||||
}
|
||||
|
||||
function authorName(comment: Comment) {
|
||||
return comment.author ? comment.author.name || comment.author.email : 'Deleted user'
|
||||
}
|
||||
|
||||
function initials(comment: Comment) {
|
||||
return authorName(comment)[0]!.toUpperCase()
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
comments.value.map((comment, i) => {
|
||||
const prev = comments.value[i - 1]
|
||||
const date = new Date(comment.createdAt)
|
||||
const prevDate = prev ? new Date(prev.createdAt) : null
|
||||
const newDay = !prevDate || dayKey(prevDate) !== dayKey(date)
|
||||
const grouped =
|
||||
!!prev && !newDay && prev.authorId === comment.authorId && date.getTime() - prevDate!.getTime() < GROUP_GAP_MS
|
||||
|
||||
return {
|
||||
comment,
|
||||
own: comment.authorId !== null && comment.authorId === myId.value,
|
||||
time: timeFormat.format(date),
|
||||
day: newDay ? dayLabel(date) : null,
|
||||
showHeader: !grouped,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
async function send() {
|
||||
if (!canSend.value) return
|
||||
sending.value = true
|
||||
const created = await commentStore.create({ content: draft.value.trim() })
|
||||
sending.value = false
|
||||
if (created) {
|
||||
draft.value = ''
|
||||
scrollToBottom('smooth')
|
||||
}
|
||||
}
|
||||
|
||||
function onEnter(e: KeyboardEvent) {
|
||||
// Shift+Enter — new line; Enter while composing (IME) — confirms the input, not a send
|
||||
if (e.shiftKey || e.isComposing) return
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat">
|
||||
<div class="chat__viewport">
|
||||
<div ref="scroller" class="chat__scroller px-2 py-3">
|
||||
<div v-if="loadingOlder" class="d-flex justify-center pb-2">
|
||||
<v-progress-circular indeterminate size="20" width="2" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading && comments.length === 0" class="chat__placeholder">
|
||||
<v-progress-circular indeterminate size="24" width="2" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="comments.length === 0" class="chat__placeholder text-medium-emphasis">No comments yet</div>
|
||||
|
||||
<template v-for="item in items" :key="item.comment.id">
|
||||
<div v-if="item.day" class="chat__day text-medium-emphasis text-body-small">{{ item.day }}</div>
|
||||
|
||||
<div class="chat__row" :class="{ 'chat__row--own': item.own, 'chat__row--grouped': !item.showHeader }">
|
||||
<div v-if="!item.own" class="chat__avatar">
|
||||
<v-avatar
|
||||
v-if="item.showHeader"
|
||||
:image="item.comment.author?.image ?? undefined"
|
||||
:text="initials(item.comment)"
|
||||
color="primary"
|
||||
size="32"
|
||||
border
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="chat__bubble">
|
||||
<div v-if="item.showHeader && !item.own" class="font-weight-medium text-body-small text-primary">
|
||||
{{ authorName(item.comment) }}
|
||||
</div>
|
||||
|
||||
<div v-if="item.comment.deletedAt" class="text-medium-emphasis font-italic">Comment deleted</div>
|
||||
<div v-else class="chat__content">{{ item.comment.content }}</div>
|
||||
|
||||
<div class="chat__meta text-medium-emphasis">
|
||||
<span v-if="item.comment.editedAt && !item.comment.deletedAt">edited · </span>{{ item.time }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<v-fade-transition>
|
||||
<v-btn v-if="!atBottom" class="chat__jump" icon size="small" elevation="2" @click="scrollToBottom('smooth')">
|
||||
<v-badge :model-value="unread > 0" :content="unread" color="primary" floating>
|
||||
<v-icon icon="mdi-chevron-down" />
|
||||
</v-badge>
|
||||
</v-btn>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="pa-2">
|
||||
<v-textarea
|
||||
v-model="draft"
|
||||
placeholder="Write a comment…"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
rows="1"
|
||||
max-rows="6"
|
||||
auto-grow
|
||||
hide-details
|
||||
@keydown.enter="onEnter"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-btn
|
||||
icon="mdi-send"
|
||||
color="primary"
|
||||
variant="text"
|
||||
density="compact"
|
||||
:disabled="!canSend"
|
||||
:loading="sending"
|
||||
@click="send"
|
||||
/>
|
||||
</template>
|
||||
</v-textarea>
|
||||
<div class="text-medium-emphasis text-body-small mt-1 px-1">Enter — send, Shift + Enter — new line</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: min(30vh, 560px);
|
||||
|
||||
&__viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__scroller {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
&__day {
|
||||
align-self: center;
|
||||
margin: 12px 0 4px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
|
||||
&--grouped {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&--own {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
max-width: min(75%, 640px);
|
||||
min-width: 0;
|
||||
padding: 6px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
|
||||
.chat__row--own & {
|
||||
background: rgba(var(--v-theme-primary), 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 0.7rem;
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__jump {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
/** localStorage key for the open/closed state; defaults to one derived from the title. */
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
const icons = ['mdi-chevron-right', 'mdi-chevron-down']
|
||||
const isOpen = ref(true)
|
||||
const { title } = defineProps<Props>()
|
||||
const { title, storageKey } = defineProps<Props>()
|
||||
|
||||
const key = computed(() => `expansion-panel:${storageKey ?? title}`)
|
||||
|
||||
function readStoredOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key.value) !== 'closed'
|
||||
} catch {
|
||||
// ignore storage errors (e.g. disabled in private mode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const isOpen = ref(readStoredOpen())
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
try {
|
||||
localStorage.setItem(key.value, value ? 'open' : 'closed')
|
||||
} catch {
|
||||
// ignore storage errors (e.g. disabled in private mode)
|
||||
}
|
||||
})
|
||||
|
||||
const icon = computed(() => {
|
||||
return isOpen.value ? icons[1] : icons[0]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="px-4 py-2"> History </v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -54,6 +54,11 @@ const cancel = () => {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content-sheet {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.content-sheet:hover {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import AssigneeMenu from '@/components/AssigneeMenu.vue'
|
||||
import ReporterInfo from '@/components/ReporterInfo.vue'
|
||||
import IssueTitle from '@/components/IssueTitle.vue'
|
||||
import IssueDate from '@/components/IssueDate.vue'
|
||||
import CommentsList from '@/components/CommentsList.vue'
|
||||
import HistoryList from '@/components/HistoryList.vue'
|
||||
|
||||
interface Props {
|
||||
ckey: string
|
||||
@@ -23,6 +25,7 @@ const emit = defineEmits<{
|
||||
|
||||
const issueStore = useIssueStore()
|
||||
const issue = ref<Issue>()
|
||||
const activity = ref()
|
||||
|
||||
const { ckey } = defineProps<Props>()
|
||||
|
||||
@@ -127,10 +130,23 @@ defineExpose({
|
||||
</expansion-panel>
|
||||
|
||||
<expansion-panel title="Activity">
|
||||
<v-btn-group divided border density="compact">
|
||||
<v-btn>comments</v-btn>
|
||||
<v-btn>history</v-btn>
|
||||
</v-btn-group>
|
||||
<v-sheet>
|
||||
<v-tabs v-model="activity" density="compact">
|
||||
<v-tab prepend-icon="mdi-star" value="comment">Comments</v-tab>
|
||||
<v-tab prepend-icon="mdi-star" value="history">History</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-tabs-window v-model="activity">
|
||||
<v-tabs-window-item value="comment">
|
||||
<comments-list :issue-id="issue.id" />
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="history">
|
||||
<history-list />
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-sheet>
|
||||
</expansion-panel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
interface Options {
|
||||
/** Distance in px from an edge that still counts as "at the edge" */
|
||||
threshold?: number
|
||||
/** Called when the user scrolls to the top — load older messages here */
|
||||
onReachTop?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll behaviour of a chat container: tracks whether the user is at the bottom,
|
||||
* scrolls to the bottom, and keeps the visible messages in place while older ones
|
||||
* are prepended above them.
|
||||
*/
|
||||
export function useChatScroll(container: Ref<HTMLElement | null | undefined>, options: Options = {}) {
|
||||
const threshold = options.threshold ?? 64
|
||||
const atBottom = ref(true)
|
||||
|
||||
function distanceToBottom(el: HTMLElement) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
container,
|
||||
'scroll',
|
||||
() => {
|
||||
const el = container.value
|
||||
if (!el) return
|
||||
atBottom.value = distanceToBottom(el) <= threshold
|
||||
if (el.scrollTop <= threshold) options.onReachTop?.()
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
|
||||
async function scrollToBottom(behavior: ScrollBehavior = 'auto') {
|
||||
await nextTick()
|
||||
const el = container.value
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior })
|
||||
atBottom.value = true
|
||||
}
|
||||
|
||||
/** Runs `prepend` (which adds content above) without shifting what the user sees. */
|
||||
async function keepPosition(prepend: () => Promise<unknown>) {
|
||||
const el = container.value
|
||||
const prevHeight = el?.scrollHeight ?? 0
|
||||
const prevTop = el?.scrollTop ?? 0
|
||||
await prepend()
|
||||
await nextTick()
|
||||
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight)
|
||||
}
|
||||
|
||||
/** Whether the content is too short to scroll — there is no way to "reach the top" then. */
|
||||
function isUnderfilled() {
|
||||
const el = container.value
|
||||
return !!el && el.scrollHeight <= el.clientHeight + threshold
|
||||
}
|
||||
|
||||
return { atBottom, scrollToBottom, keepPosition, isUnderfilled }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
||||
import { COMMENT_EVENT_TYPES, type Comment, type CommentEventType, eventsUrl } from '@/api/comment.api.ts'
|
||||
|
||||
interface Options {
|
||||
onEvent: (type: CommentEventType, comment: Comment) => void
|
||||
/** Called when the stream comes back after a drop — events from the gap are not replayed. */
|
||||
onReconnect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the SSE stream of an issue's comments while `issueId` is set.
|
||||
* Reopens the stream when the issue changes and closes it on scope dispose.
|
||||
*/
|
||||
export function useCommentEvents(issueId: MaybeRefOrGetter<number | null | undefined>, options: Options) {
|
||||
const connected = ref(false)
|
||||
let source: EventSource | null = null
|
||||
|
||||
function close() {
|
||||
source?.close()
|
||||
source = null
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function open(id: number) {
|
||||
let dropped = false
|
||||
const es = new EventSource(eventsUrl(id), { withCredentials: true })
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
connected.value = true
|
||||
if (dropped) options.onReconnect?.()
|
||||
dropped = false
|
||||
})
|
||||
|
||||
// EventSource reconnects by itself unless the server refused the stream (readyState CLOSED)
|
||||
es.addEventListener('error', () => {
|
||||
connected.value = false
|
||||
dropped = true
|
||||
})
|
||||
|
||||
for (const type of COMMENT_EVENT_TYPES) {
|
||||
es.addEventListener(type, (event) => {
|
||||
try {
|
||||
options.onEvent(type, JSON.parse((event as MessageEvent<string>).data) as Comment)
|
||||
} catch {
|
||||
// ignore malformed payloads
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
source = es
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(issueId),
|
||||
(id) => {
|
||||
close()
|
||||
if (id != null) open(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(close)
|
||||
|
||||
return { connected, close }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
type Comment,
|
||||
type CommentEventType,
|
||||
type CreateCommentInput,
|
||||
itemCreate,
|
||||
itemDelete,
|
||||
itemsList,
|
||||
itemUpdate,
|
||||
type UpdateCommentInput,
|
||||
} from '@/api/comment.api.ts'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* Comments of the currently opened issue, oldest first. Loaded chat-style:
|
||||
* the latest page first, older pages are prepended on demand.
|
||||
*/
|
||||
export const useCommentStore = defineStore('comment', () => {
|
||||
const issueId = ref<number | null>(null)
|
||||
const comments = ref<Comment[]>([])
|
||||
/** id of the oldest loaded comment while older ones exist on the server */
|
||||
const olderCursor = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadingOlder = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** Inserts or replaces a comment by id. New comments are always the newest — append. */
|
||||
function upsert(comment: Comment) {
|
||||
const index = comments.value.findIndex((c) => c.id === comment.id)
|
||||
if (index !== -1) comments.value[index] = comment
|
||||
else comments.value.push(comment)
|
||||
}
|
||||
|
||||
async function load(id: number) {
|
||||
issueId.value = id
|
||||
comments.value = []
|
||||
olderCursor.value = null
|
||||
loadingOlder.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await itemsList(id, { limit: PAGE_SIZE, direction: 'backward' })
|
||||
// The issue may have changed while the request was in flight
|
||||
if (issueId.value !== id) return
|
||||
comments.value = res.list
|
||||
olderCursor.value = res.nextCursor
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get list of comments'
|
||||
} finally {
|
||||
if (issueId.value === id) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
const id = issueId.value
|
||||
if (id === null || !olderCursor.value || loading.value || loadingOlder.value) return
|
||||
loadingOlder.value = true
|
||||
try {
|
||||
const res = await itemsList(id, { limit: PAGE_SIZE, cursor: olderCursor.value, direction: 'backward' })
|
||||
if (issueId.value !== id) return
|
||||
const known = new Set(comments.value.map((c) => c.id))
|
||||
comments.value.unshift(...res.list.filter((c) => !known.has(c.id)))
|
||||
olderCursor.value = res.nextCursor
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get list of comments'
|
||||
} finally {
|
||||
if (issueId.value === id) loadingOlder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches comments newer than the last loaded one — e.g. after an SSE reconnect. */
|
||||
async function loadNewer() {
|
||||
const id = issueId.value
|
||||
if (id === null || loading.value) return
|
||||
let cursor = comments.value.at(-1)?.id
|
||||
if (!cursor) return load(id)
|
||||
try {
|
||||
do {
|
||||
const res = await itemsList(id, { limit: PAGE_SIZE, cursor, direction: 'forward' })
|
||||
if (issueId.value !== id) return
|
||||
res.list.forEach(upsert)
|
||||
cursor = res.nextCursor ?? undefined
|
||||
} while (cursor)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error get list of comments'
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateCommentInput) {
|
||||
if (issueId.value === null) return
|
||||
try {
|
||||
const comment = await itemCreate(issueId.value, input)
|
||||
// The SSE event for the same comment may come before or after — upsert dedupes
|
||||
if (comment.issueId === issueId.value) upsert(comment)
|
||||
return comment
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error creating comment'
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id: string, input: UpdateCommentInput) {
|
||||
try {
|
||||
const comment = await itemUpdate(id, input)
|
||||
if (comment.issueId === issueId.value) upsert(comment)
|
||||
return comment
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error updating comment'
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
return await itemDelete(id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'error deleting comment'
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a server-sent event. Deleted comments stay in the list as placeholders. */
|
||||
function applyEvent(type: CommentEventType, comment: Comment) {
|
||||
if (comment.issueId !== issueId.value) return
|
||||
// Updates of comments older than the loaded window are irrelevant
|
||||
if (type !== 'comment.created' && !comments.value.some((c) => c.id === comment.id)) return
|
||||
upsert(comment)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
issueId.value = null
|
||||
comments.value = []
|
||||
olderCursor.value = null
|
||||
loading.value = false
|
||||
loadingOlder.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
issueId,
|
||||
comments,
|
||||
olderCursor,
|
||||
loading,
|
||||
loadingOlder,
|
||||
error,
|
||||
load,
|
||||
loadOlder,
|
||||
loadNewer,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
applyEvent,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
@@ -300,6 +300,6 @@ async function onDeleteBatch() {
|
||||
}
|
||||
|
||||
.u-height {
|
||||
height: calc(100vh - 6rem);
|
||||
height: calc(100vh - 10rem);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user