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

+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 />