Files
nextcloud-forum/src/views/ThreadView.vue

880 lines
25 KiB
Vue

<template>
<div class="thread-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
</NcButton>
</template>
<template #right>
<!-- Subscription toggle switch -->
<NcCheckboxRadioSwitch
v-if="!loading && thread"
v-model="thread.isSubscribed"
@update:model-value="handleToggleSubscription"
type="switch"
>
<span class="icon-label">
<BellIcon :size="20" />
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
</span>
</NcCheckboxRadioSwitch>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<!-- Moderation buttons (only visible to moderators) -->
<template v-if="canModerate && !loading">
<NcButton
@click="handleToggleLock"
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
>
<template #icon>
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
<LockIcon v-else :size="20" />
</template>
</NcButton>
<NcButton
@click="handleTogglePin"
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
>
<template #icon>
<PinOffIcon v-if="thread?.isPinned" :size="20" />
<PinIcon v-else :size="20" />
</template>
</NcButton>
</template>
<NcButton
@click="replyToThread"
:disabled="loading || (thread?.isLocked && !canModerate)"
variant="primary"
>
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Thread Header -->
<div v-else-if="thread" class="thread-header mt-16">
<div class="thread-title-section">
<h2 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
{{ thread.authorDisplayName || thread.authorId }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="stat-icon">
<EyeIcon :size="16" />
</span>
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
</span>
</div>
</div>
</div>
<!-- Posts list -->
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
<div class="posts-list">
<PostCard
v-for="(post, index) in posts"
:key="post.id"
:ref="(el) => setPostCardRef(el, post.id)"
:post="post"
:is-first-post="index === 0"
:is-unread="isPostUnread(post)"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
<!-- Pagination info -->
<div v-if="posts.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
</div>
</section>
<!-- Empty posts state (thread exists but no posts) -->
<NcEmptyContent
v-else-if="!loading && !error && thread && posts.length === 0"
:title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc"
class="mt-16"
>
<template #action>
<NcButton @click="replyToThread" variant="primary">
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Locked message (only shown to non-moderators) -->
<div
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
class="locked-message mt-16"
>
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
<template #icon>
<LockIcon :size="64" />
</template>
</NcEmptyContent>
</div>
<!-- Reply form (moderators can reply even when locked) -->
<PostReplyForm
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import PostCard from '@/components/PostCard.vue'
import PostReplyForm from '@/components/PostReplyForm.vue'
import PinIcon from '@icons/Pin.vue'
import PinOffIcon from '@icons/PinOff.vue'
import LockIcon from '@icons/Lock.vue'
import LockOpenIcon from '@icons/LockOpen.vue'
import EyeIcon from '@icons/Eye.vue'
import BellIcon from '@icons/Bell.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import ReplyIcon from '@icons/Reply.vue'
import type { Post } from '@/types'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { useCurrentThread } from '@/composables/useCurrentThread'
import { usePermissions } from '@/composables/usePermissions'
export default defineComponent({
name: 'ThreadView',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
AppToolbar,
PostCard,
PostReplyForm,
PinIcon,
PinOffIcon,
LockIcon,
LockOpenIcon,
EyeIcon,
BellIcon,
ArrowLeftIcon,
RefreshIcon,
ReplyIcon,
},
setup() {
const { currentThread: thread, fetchThread } = useCurrentThread()
const { checkCategoryPermission } = usePermissions()
return {
thread,
fetchThread,
checkCategoryPermission,
}
},
data() {
return {
loading: false,
posts: [] as Post[],
lastReadPostId: null as number | null,
error: null as string | null,
limit: 50,
offset: 0,
postCardRefs: new Map<number, any>(),
canModerate: false,
strings: {
back: t('forum', 'Back'),
backToCategory: (categoryName: string) =>
t('forum', 'Back to {category}', { category: categoryName }),
refresh: t('forum', 'Refresh'),
reply: t('forum', 'Reply'),
loading: t('forum', 'Loading…'),
errorTitle: t('forum', 'Error loading thread'),
emptyPostsTitle: t('forum', 'No posts yet'),
emptyPostsDesc: t('forum', 'Be the first to post in this thread.'),
retry: t('forum', 'Retry'),
by: t('forum', 'by'),
views: (count: number) => n('forum', '%n view', '%n views', count),
pinned: t('forum', 'Pinned thread'),
locked: t('forum', 'Locked thread'),
lockedMessage: t('forum', 'This thread is locked. Only moderators can post replies.'),
showingPosts: (count: number) => n('forum', 'Showing %n post', 'Showing %n posts', count),
lockThread: t('forum', 'Lock thread'),
unlockThread: t('forum', 'Unlock thread'),
pinThread: t('forum', 'Pin thread'),
unpinThread: t('forum', 'Unpin thread'),
threadLocked: t('forum', 'Thread locked'),
threadUnlocked: t('forum', 'Thread unlocked'),
threadPinned: t('forum', 'Thread pinned'),
threadUnpinned: t('forum', 'Thread unpinned'),
subscribe: t('forum', 'Subscribe to thread'),
subscribed: t('forum', 'Subscribed'),
threadSubscribed: t('forum', 'Subscribed to thread'),
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
},
}
},
computed: {
threadId(): number | null {
return this.$route.params.id ? parseInt(this.$route.params.id as string) : null
},
threadSlug(): string | null {
return (this.$route.params.slug as string) || null
},
},
watch: {
'$route.hash'(newHash: string) {
// When hash changes within the same thread, scroll to the post
if (newHash && newHash.startsWith('#post-') && this.posts.length > 0) {
this.$nextTick(() => {
this.scrollToPostFromHash()
})
}
},
},
created() {
this.refresh()
},
methods: {
async refresh(): Promise<void> {
try {
this.loading = true
this.error = null
// Fetch thread details using the composable
// Increment view count on initial load, but not on manual refresh
const incrementView = !this.thread
let threadData
if (this.threadSlug) {
threadData = await this.fetchThread(this.threadSlug, true, incrementView)
} else if (this.threadId) {
threadData = await this.fetchThread(this.threadId, false, incrementView)
} else {
throw new Error(t('forum', 'No thread ID or slug provided'))
}
// Fetch posts
if (threadData) {
await this.fetchPosts()
// Check moderation permission
await this.checkModerationPermission()
}
} catch (e) {
console.error('Failed to refresh', e)
this.error = (e as Error).message || t('forum', 'An unexpected error occurred')
} finally {
this.loading = false
}
},
async checkModerationPermission(): Promise<void> {
if (this.thread?.categoryId) {
this.canModerate = await this.checkCategoryPermission(this.thread.categoryId, 'canModerate')
}
},
async fetchPosts(): Promise<void> {
try {
// Fetch existing read marker before loading posts
await this.fetchReadMarker()
const resp = await ocs.get<Post[]>(`/threads/${this.thread!.id}/posts`, {
params: {
limit: this.limit,
offset: this.offset,
},
})
this.posts = resp.data || []
// Mark thread as read up to the last post in the current view
if (this.posts.length > 0) {
await this.markAsRead()
}
// Scroll to post if hash is present in URL
await this.$nextTick()
this.scrollToPostFromHash()
} catch (e) {
console.error('Failed to fetch posts', e)
throw new Error(t('forum', 'Failed to load posts'))
}
},
async fetchReadMarker(): Promise<void> {
try {
if (!this.thread) {
return
}
const resp = await ocs.get<{ threadId: number; lastReadPostId: number; readAt: number }>(
`/threads/${this.thread.id}/read-marker`,
)
this.lastReadPostId = resp.data?.lastReadPostId || null
} catch (e) {
// Not found or error - treat as no read marker
this.lastReadPostId = null
console.debug('No read marker found', e)
}
},
isPostUnread(post: Post): boolean {
if (this.lastReadPostId === null) {
// No read marker means all posts are unread
return true
}
// Post is unread if its ID is greater than last read post ID
return post.id > this.lastReadPostId
},
async markAsRead(): Promise<void> {
try {
// Get the last post ID from the current view
const lastPost = this.posts[this.posts.length - 1]
if (!lastPost || !this.thread) {
return
}
// Send request to mark thread as read
await ocs.post('/read-markers', {
threadId: this.thread.id,
lastReadPostId: lastPost.id,
})
} catch (e) {
// Silently fail - marking as read is not critical
console.debug('Failed to mark thread as read', e)
}
},
handleReply(post: Post): void {
const replyForm = this.$refs.replyForm as any
if (!replyForm) {
return
}
// Set the quoted content in the reply form
if (replyForm && typeof replyForm.setQuotedContent === 'function') {
replyForm.setQuotedContent(post.contentRaw)
}
// Scroll to the reply form with smooth behavior
const element = replyForm.$el as HTMLElement
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
})
}
// Wait for scroll animation to complete before focusing
setTimeout(() => {
if (replyForm && typeof replyForm.focus === 'function') {
replyForm.focus()
}
}, 500)
},
setPostCardRef(el: any, postId: number) {
if (el) {
this.postCardRefs.set(postId, el)
} else {
this.postCardRefs.delete(postId)
}
},
async handleUpdate(data: { post: Post; content: string }): Promise<void> {
const postCard = this.postCardRefs.get(data.post.id)
try {
const response = await ocs.put<Post>(`/posts/${data.post.id}`, {
content: data.content,
})
if (response.data) {
// Update the post in the local posts array
const index = this.posts.findIndex((p) => p.id === data.post.id)
if (index !== -1) {
// Preserve reactions when updating
this.posts[index] = { ...response.data, reactions: this.posts[index]?.reactions || [] }
}
// Exit edit mode
if (postCard && typeof postCard.finishEdit === 'function') {
postCard.finishEdit()
}
showSuccess(t('forum', 'Post updated successfully'))
}
} catch (e) {
console.error('Failed to update post', e)
showError(t('forum', 'Failed to update post'))
// Reset submitting state on error
if (postCard && typeof postCard.setEditSubmitting === 'function') {
postCard.setEditSubmitting(false)
}
}
},
async handleDelete(post: Post): Promise<void> {
try {
// If this is the first post, we're deleting the entire thread
const isFirstPost = this.posts.length > 0 && this.posts[0]?.id === post.id
if (isFirstPost) {
// Delete thread
if (!this.thread) {
return
}
const response = await ocs.delete<{ success: boolean; categorySlug: string }>(
`/threads/${this.thread.id}`,
)
if (response.data?.success && response.data.categorySlug) {
showSuccess(t('forum', 'Thread deleted successfully'))
// Navigate to the category
this.$router.push(`/c/${response.data.categorySlug}`)
}
} else {
// Delete post optimistically
await ocs.delete(`/posts/${post.id}`)
// Remove the post from the local array without refreshing
const index = this.posts.findIndex((p) => p.id === post.id)
if (index !== -1) {
this.posts.splice(index, 1)
}
showSuccess(t('forum', 'Post deleted successfully'))
}
} catch (e) {
console.error('Failed to delete post', e)
showError(t('forum', 'Failed to delete post'))
}
},
replyToThread(): void {
const replyForm = this.$refs.replyForm as any
if (!replyForm) {
return
}
// Scroll to the reply form with smooth behavior
const element = replyForm.$el as HTMLElement
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
})
}
// Wait for scroll animation to complete before focusing
setTimeout(() => {
if (replyForm && typeof replyForm.focus === 'function') {
replyForm.focus()
}
}, 500)
},
async handleSubmitReply(content: string): Promise<void> {
if (!this.thread) {
return
}
const replyForm = this.$refs.replyForm as any
try {
const response = await ocs.post<Post>('/posts', {
threadId: this.thread.id,
content,
})
// Append the new post to the existing posts array
if (response.data) {
// Add empty reactions array to the new post
const newPost = { ...response.data, reactions: [] }
this.posts.push(newPost)
// Clear the form only on success
if (replyForm && typeof replyForm.clear === 'function') {
replyForm.clear()
}
}
} catch (e) {
console.error('Failed to submit reply', e)
// Reset submitting state on error
if (replyForm && typeof replyForm.setSubmitting === 'function') {
replyForm.setSubmitting(false)
}
// TODO: Show error notification
}
},
handleCancelReply(): void {
// Optional: Could implement special behavior on cancel
console.log('Reply cancelled')
},
async handleToggleLock(): Promise<void> {
if (!this.thread) return
const newLockState = !this.thread.isLocked
try {
const response = await ocs.put(`/threads/${this.thread.id}/lock`, { locked: newLockState })
if (response.data) {
// Update local thread state
this.thread.isLocked = newLockState
showSuccess(newLockState ? this.strings.threadLocked : this.strings.threadUnlocked)
}
} catch (e) {
console.error('Failed to toggle thread lock', e)
showError(t('forum', 'Failed to update thread lock status'))
}
},
async handleTogglePin(): Promise<void> {
if (!this.thread) return
const newPinState = !this.thread.isPinned
try {
const response = await ocs.put(`/threads/${this.thread.id}/pin`, { pinned: newPinState })
if (response.data) {
// Update local thread state
this.thread.isPinned = newPinState
showSuccess(newPinState ? this.strings.threadPinned : this.strings.threadUnpinned)
}
} catch (e) {
console.error('Failed to toggle thread pin', e)
showError(t('forum', 'Failed to update thread pin status'))
}
},
async handleToggleSubscription(newValue: boolean): Promise<void> {
if (!this.thread) return
try {
if (newValue) {
// Subscribe to thread
await ocs.post(`/threads/${this.thread.id}/subscribe`)
this.thread.isSubscribed = true
showSuccess(this.strings.threadSubscribed)
} else {
// Unsubscribe from thread
await ocs.delete(`/threads/${this.thread.id}/subscribe`)
this.thread.isSubscribed = false
showSuccess(this.strings.threadUnsubscribed)
}
} catch (e) {
console.error('Failed to toggle thread subscription', e)
showError(t('forum', 'Failed to update subscription'))
// Revert the state on error
this.thread.isSubscribed = !newValue
}
},
scrollToPostFromHash(): void {
// Check if there's a hash in the URL like #post-123
const hash = window.location.hash || this.$route.hash
if (hash && hash.startsWith('#post-')) {
const postId = parseInt(hash.replace('#post-', ''))
if (!isNaN(postId)) {
// Try immediately first
this.scrollToPost(postId)
// If that didn't work (refs not ready), try again after a short delay
setTimeout(() => {
this.scrollToPost(postId)
}, 100)
// Final attempt after a longer delay
setTimeout(() => {
this.scrollToPost(postId)
}, 500)
}
}
},
scrollToPost(postId: number): void {
// Get the PostCard component reference
const postCardRef = this.postCardRefs.get(postId)
if (postCardRef && postCardRef.$el) {
const element = postCardRef.$el as HTMLElement
const offset = 80 // Offset for toolbar and some breathing room
// Use requestAnimationFrame to ensure scroll happens after any router scroll operations
requestAnimationFrame(() => {
// Find the scrolling container - Nextcloud uses #app-content or #forum-main
const scrollContainer =
document.querySelector('#app-content') ||
document.querySelector('#forum-main') ||
document.documentElement
const elementTop = element.getBoundingClientRect().top
const scrollTop = scrollContainer.scrollTop || 0
const containerTop = scrollContainer.getBoundingClientRect().top
const targetPosition = elementTop - containerTop + scrollTop - offset
scrollContainer.scrollTo({
top: targetPosition,
behavior: 'smooth',
})
// Add highlight effect
element.classList.add('highlight-post')
setTimeout(() => {
element.classList.remove('highlight-post')
}, 2000)
})
}
},
goBack(): void {
// Always navigate to the category, not browser history
if (this.thread?.categorySlug) {
this.$router.push(`/c/${this.thread.categorySlug}`)
} else {
// Fallback to home if no category info
this.$router.push('/')
}
},
},
})
</script>
<style scoped lang="scss">
.thread-view {
margin-bottom: 3rem;
.icon-label {
display: flex;
align-items: center;
gap: 4px;
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.ml-8 {
margin-left: 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.thread-header {
padding: 20px;
background: var(--color-background-hover);
border-radius: 8px;
border: 1px solid var(--color-border);
}
.thread-title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.thread-title {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.badge {
font-size: 1.2rem;
display: inline-flex;
align-items: center;
justify-content: center;
&.badge-pinned {
opacity: 0.9;
}
&.badge-locked {
opacity: 0.8;
}
}
.thread-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
color: var(--color-text-maxcontrast);
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-label {
font-style: italic;
}
.meta-value {
font-weight: 500;
color: var(--color-text-lighter);
&.deleted-user {
font-style: italic;
opacity: 0.7;
}
}
.meta-divider {
opacity: 0.5;
}
.stat-icon {
font-size: 1rem;
}
.stat-value {
font-weight: 600;
}
.stat-label {
font-size: 0.85rem;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.pagination-info {
text-align: center;
padding: 12px;
}
}
// Highlight animation for scrolled-to posts
:deep(.highlight-post) {
animation: highlightFade 2s ease-in-out;
}
@keyframes highlightFade {
0% {
background-color: var(--color-primary-element-light);
box-shadow: 0 0 0 4px var(--color-primary-element-light);
}
100% {
background-color: transparent;
box-shadow: none;
}
}
</style>