1 parent
cdd7147d8e
commit
b80e88b121
9 files changed
+660
-12
No files matched your search
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user