chat
Gitea Actions Demo / Deploy (push) Successful in 36s

This commit is contained in:
vadim committed 2026-10-02 21:43:19 +03:00
1 parent cdd7147d8e
commit b80e88b121
9 files changed
+660 -12

No files matched your search

+3 -7
View File
@@ -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)
}
})
+72
View File
@@ -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`
}
+299 -3
View File
@@ -1,5 +1,301 @@
<script setup lang="ts"></script>
<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'
<template><v-sheet class="px-4 py-2">a</v-sheet></template>
interface Props {
issueId: number
}
<style scoped lang="scss"></style>
/** 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>
+5
View File
@@ -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);
}
+1 -1
View File
@@ -140,7 +140,7 @@ defineExpose({
<v-tabs-window v-model="activity">
<v-tabs-window-item value="comment">
<comments-list />
<comments-list :issue-id="issue.id" />
</v-tabs-window-item>
<v-tabs-window-item value="history">
<history-list />
+61
View File
@@ -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 }
}
+65
View File
@@ -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 }
}
+153
View File
@@ -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,
}
})
+1 -1
View File
@@ -300,6 +300,6 @@ async function onDeleteBatch() {
}
.u-height {
height: calc(100vh - 6rem);
height: calc(100vh - 10rem);
}
</style>