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
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = useEventSource<string[], IssueSseEvent>(
|
const { data, event } = useEventSource<string[], IssueSseEvent>(
|
||||||
`${window.location.origin}/api/issues/events`,
|
`${window.location.origin}/api/issues/events`,
|
||||||
['issue.created', 'issue.updated', 'issue.deleted'],
|
['issue.created', 'issue.updated', 'issue.deleted'],
|
||||||
{
|
{
|
||||||
@@ -33,12 +33,8 @@ const { data } = useEventSource<string[], IssueSseEvent>(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(data, () => {
|
watch(event, () => {
|
||||||
if (
|
if (event.value === 'issue.created' || event.value === 'issue.updated' || event.value === 'issue.deleted') {
|
||||||
data.value?.type === 'issue.created' ||
|
|
||||||
data.value?.type === 'issue.updated' ||
|
|
||||||
data.value?.type === 'issue.deleted'
|
|
||||||
) {
|
|
||||||
issueStore.getStat(currentProjectId.value)
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
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 icons = ['mdi-chevron-right', 'mdi-chevron-down']
|
||||||
const isOpen = ref(true)
|
const { title, storageKey } = defineProps<Props>()
|
||||||
const { title } = 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(() => {
|
const icon = computed(() => {
|
||||||
return isOpen.value ? icons[1] : icons[0]
|
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>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.content-sheet {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.content-sheet:hover {
|
.content-sheet:hover {
|
||||||
background-color: rgba(var(--v-theme-on-surface), 0.06);
|
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 ReporterInfo from '@/components/ReporterInfo.vue'
|
||||||
import IssueTitle from '@/components/IssueTitle.vue'
|
import IssueTitle from '@/components/IssueTitle.vue'
|
||||||
import IssueDate from '@/components/IssueDate.vue'
|
import IssueDate from '@/components/IssueDate.vue'
|
||||||
|
import CommentsList from '@/components/CommentsList.vue'
|
||||||
|
import HistoryList from '@/components/HistoryList.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ckey: string
|
ckey: string
|
||||||
@@ -23,6 +25,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
const issue = ref<Issue>()
|
const issue = ref<Issue>()
|
||||||
|
const activity = ref()
|
||||||
|
|
||||||
const { ckey } = defineProps<Props>()
|
const { ckey } = defineProps<Props>()
|
||||||
|
|
||||||
@@ -127,10 +130,23 @@ defineExpose({
|
|||||||
</expansion-panel>
|
</expansion-panel>
|
||||||
|
|
||||||
<expansion-panel title="Activity">
|
<expansion-panel title="Activity">
|
||||||
<v-btn-group divided border density="compact">
|
<v-sheet>
|
||||||
<v-btn>comments</v-btn>
|
<v-tabs v-model="activity" density="compact">
|
||||||
<v-btn>history</v-btn>
|
<v-tab prepend-icon="mdi-star" value="comment">Comments</v-tab>
|
||||||
</v-btn-group>
|
<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>
|
</expansion-panel>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 {
|
.u-height {
|
||||||
height: calc(100vh - 6rem);
|
height: calc(100vh - 10rem);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user