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

1400 lines
43 KiB
Vue

<template>
<PageWrapper :full-width="true">
<template #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 (authenticated users only) -->
<NcCheckboxRadioSwitch
v-if="!loading && thread && userId !== null"
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>
<!-- Bookmark toggle button (authenticated users only) -->
<NcButton
v-if="!loading && thread && userId !== null"
@click="handleToggleBookmark"
:aria-label="thread.isBookmarked ? strings.removeBookmark : strings.addBookmark"
:title="thread.isBookmarked ? strings.removeBookmark : strings.addBookmark"
>
<template #icon>
<BookmarkIcon v-if="thread.isBookmarked" :size="20" />
<BookmarkOutlineIcon v-else :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>
<NcButton
@click="showMoveDialog = true"
:aria-label="strings.moveThread"
:title="strings.moveThread"
>
<template #icon>
<FolderMoveIcon :size="20" />
</template>
</NcButton>
</template>
<NcButton
v-if="canReply"
@click="replyToThread"
:disabled="loading || (thread?.isLocked && !canModerate)"
variant="primary"
>
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</AppToolbar>
</template>
<div class="thread-view">
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state: Thread not found -->
<ThreadNotFound v-else-if="error && (error.includes('not found') || error.includes('404'))" />
<!-- Error state: Other errors -->
<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">
<div class="thread-title-row">
<h2 v-if="!isEditingTitle" 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>
<NcTextField
v-else
v-model="editedTitle"
class="thread-title-input"
@keydown.enter="handleSaveTitle"
@keydown.esc="handleCancelEditTitle"
ref="titleInput"
:disabled="isSavingTitle"
/>
<NcButton
v-if="!isEditingTitle && canEditTitle"
@click="handleStartEditTitle"
variant="tertiary"
:aria-label="strings.editTitle"
:title="strings.editTitle"
class="edit-title-button"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="isEditingTitle"
@click="handleSaveTitle"
:disabled="isSavingTitle || !editedTitle.trim()"
variant="primary"
:aria-label="strings.saveTitle"
:title="strings.saveTitle"
class="save-title-button"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value" :class="{ 'deleted-user': thread.author?.isDeleted }">
{{ thread.author?.displayName || 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>
<!-- First post (always shown) -->
<section v-if="!loading && !error && firstPost" class="mt-16 first-post-section">
<PostCard
:ref="(el) => setPostCardRef(el, firstPost!.id)"
:post="firstPost"
:is-first-post="true"
:is-unread="isPostUnread(firstPost)"
:can-moderate-category="canModerate"
:can-reply="canReply"
:current-page="currentPage"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
@reassigned="handleReassigned"
/>
</section>
<!-- Replies section with pagination -->
<section
v-if="!loading && !error && (replies.length > 0 || totalPages > 1 || loadingReplies)"
class="mt-16 replies-section"
>
<!-- Pagination at top -->
<Pagination
v-if="totalPages > 1"
:current-page="currentPage"
:max-pages="totalPages"
class="pagination-top"
@update:current-page="handlePageChange"
/>
<!-- Loading state for replies -->
<div v-if="loadingReplies" class="replies-loading mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div v-else class="posts-list mt-16">
<PostCard
v-for="reply in replies"
:key="reply.id"
:ref="(el) => setPostCardRef(el, reply.id)"
:post="reply"
:is-first-post="false"
:is-unread="isPostUnread(reply)"
:can-moderate-category="canModerate"
:can-reply="canReply"
:current-page="currentPage"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
@reassigned="handleReassigned"
/>
</div>
<!-- Pagination at bottom -->
<Pagination
v-if="totalPages > 1"
:current-page="currentPage"
:max-pages="totalPages"
class="pagination-bottom mt-16"
@update:current-page="handlePageChange"
/>
</section>
<!-- Empty posts state (thread exists but no posts) -->
<NcEmptyContent
v-else-if="!loading && !error && thread && !firstPost"
:title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc"
class="mt-16"
>
<template v-if="canReply" #action>
<NcButton @click="replyToThread" variant="primary">
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Locked thread message (only shown to non-moderators) -->
<NcNoteCard
v-if="!loading && !error && thread && thread.isLocked && !canModerate && userId !== null"
type="warning"
class="mt-16"
>
<p>
<LockIcon :size="20" class="inline-icon" />
{{ strings.lockedMessage }}
</p>
</NcNoteCard>
<!-- No reply permission message (shown when user cannot reply, thread is not locked, and not already showing locked message) -->
<NcNoteCard
v-if="!loading && !error && thread && !canReply && !thread.isLocked && userId !== null"
type="info"
class="mt-16"
>
<p>{{ strings.noReplyPermission }}</p>
</NcNoteCard>
<!-- Guest user message (only when guest cannot reply) -->
<NcNoteCard
v-if="!loading && !error && thread && userId === null && !canReply"
type="info"
class="mt-16"
>
<p>{{ strings.guestMessage }}</p>
<template #action>
<NcButton @click="replyToThread" variant="primary">
{{ strings.signInToReply }}
</NcButton>
</template>
</NcNoteCard>
<!-- Reply form (authenticated users or guests with canReply permission, moderators can reply even when locked) -->
<PostReplyForm
v-if="
!loading &&
!error &&
thread &&
(userId !== null || isGuest) &&
canReply &&
(!thread.isLocked || canModerate)
"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
<!-- Move Category Dialog -->
<MoveCategoryDialog
v-if="thread"
ref="moveDialog"
:open="showMoveDialog"
:current-category-id="thread.categoryId"
@update:open="showMoveDialog = $event"
@move="handleMoveThread"
/>
</PageWrapper>
</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 NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import AppToolbar from '@/components/AppToolbar'
import PageWrapper from '@/components/PageWrapper'
import PostCard from '@/components/PostCard'
import PostReplyForm from '@/components/PostReplyForm'
import Pagination from '@/components/Pagination'
import ThreadNotFound from '@/views/ThreadNotFound.vue'
import MoveCategoryDialog from '@/components/MoveCategoryDialog'
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 BookmarkIcon from '@icons/Bookmark.vue'
import BookmarkOutlineIcon from '@icons/BookmarkOutline.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import ReplyIcon from '@icons/Reply.vue'
import PencilIcon from '@icons/Pencil.vue'
import CheckIcon from '@icons/Check.vue'
import FolderMoveIcon from '@icons/FolderMove.vue'
import type { Post } from '@/types'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { useCurrentThread } from '@/composables/useCurrentThread'
import { usePermissions } from '@/composables/usePermissions'
import { useCurrentUser } from '@/composables/useCurrentUser'
import { useGuestSession } from '@/composables/useGuestSession'
export default defineComponent({
name: 'ThreadView',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
NcNoteCard,
NcTextField,
AppToolbar,
PageWrapper,
PostCard,
PostReplyForm,
Pagination,
ThreadNotFound,
PinIcon,
PinOffIcon,
LockIcon,
LockOpenIcon,
EyeIcon,
BellIcon,
BookmarkIcon,
BookmarkOutlineIcon,
ArrowLeftIcon,
RefreshIcon,
ReplyIcon,
PencilIcon,
CheckIcon,
FolderMoveIcon,
MoveCategoryDialog,
},
setup() {
const { currentThread: thread, fetchThread } = useCurrentThread()
const { checkCategoryPermission } = usePermissions()
const { userId } = useCurrentUser()
const { isGuest, fetchGuestIdentity } = useGuestSession()
return {
thread,
fetchThread,
checkCategoryPermission,
userId,
isGuest,
fetchGuestIdentity,
}
},
data() {
return {
loading: false,
loadingReplies: false,
firstPost: null as Post | null,
replies: [] as Post[],
lastReadPostId: null as number | null,
error: null as string | null,
currentPage: 1,
totalPages: 1,
perPage: 20,
postCardRefs: new Map<number, any>(),
canModerate: false,
canReply: false,
isEditingTitle: false,
editedTitle: '',
isSavingTitle: false,
showMoveDialog: 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 replies yet'),
emptyPostsDesc: t('forum', 'Be the first to reply 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 add replies.'),
noReplyPermission: t('forum', 'You do not have permission to reply in this category.'),
guestMessage: t('forum', 'You must be signed in to reply to this thread.'),
signInToReply: t('forum', 'Sign in to reply'),
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'),
subscribed: t('forum', 'Subscribed'),
threadSubscribed: t('forum', 'Subscribed to thread'),
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
addBookmark: t('forum', 'Bookmark'),
removeBookmark: t('forum', 'Remove bookmark'),
threadBookmarked: t('forum', 'Thread bookmarked'),
threadUnbookmarked: t('forum', 'Bookmark removed'),
editTitle: t('forum', 'Edit title'),
saveTitle: t('forum', 'Save title'),
titleUpdated: t('forum', 'Thread title updated'),
moveThread: t('forum', 'Move thread'),
threadMoved: t('forum', 'Thread moved successfully'),
},
}
},
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
},
canEditTitle(): boolean {
// Allow if user is the author, or has moderation permissions
return this.thread?.authorId === this.userId || this.canModerate
},
// Whether ?page=last was requested
isLastPageRequested(): boolean {
return this.$route.query.page === 'last'
},
// Get page from query param
pageFromQuery(): number | null {
const page = this.$route.query.page
if (page) {
if (page === 'last') return null // handled separately
const parsed = parseInt(page as string)
return isNaN(parsed) ? null : parsed
}
return null
},
// Get post ID from query param
postFromQuery(): number | null {
const post = this.$route.query.post
if (post) {
const parsed = parseInt(post as string)
return isNaN(parsed) ? null : parsed
}
return null
},
},
watch: {
// Watch for query param changes to handle deep linking
'$route.query'(newQuery, oldQuery) {
const newPage = newQuery.page ? parseInt(newQuery.page as string) : null
const oldPage = oldQuery.page ? parseInt(oldQuery.page as string) : null
const newPost = newQuery.post ? parseInt(newQuery.post as string) : null
// If page changed, fetch that page
if (newPage && newPage !== oldPage && newPage !== this.currentPage) {
this.handlePageChange(newPage)
}
// If post param exists, scroll to it after posts are loaded
if (newPost) {
this.$nextTick(() => {
this.scrollToPostFromQuery()
})
}
},
},
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'))
}
// Check if thread was found
if (!threadData) {
throw new Error(t('forum', 'Thread not found'))
}
// Fetch guest identity if guest
if (this.isGuest) {
await this.fetchGuestIdentity()
}
// Fetch posts - use page from query param if present
// page=last → use a high number so backend clamps to last page
const initialPage = this.isLastPageRequested ? 999999 : this.pageFromQuery || 0
await this.fetchPosts(initialPage)
// Check permissions
await this.checkPermissions()
} 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 checkPermissions(): Promise<void> {
if (this.thread?.categoryId) {
const [canModerate, canReply] = await Promise.all([
this.checkCategoryPermission(this.thread.categoryId, 'canModerate'),
this.checkCategoryPermission(this.thread.categoryId, 'canReply'),
])
this.canModerate = canModerate
this.canReply = canReply
}
},
async fetchPosts(page: number = 0): Promise<void> {
try {
interface PaginatedResponse {
firstPost: Post | null
replies: Post[]
pagination: {
page: number
perPage: number
total: number
totalPages: number
startPage: number
lastReadPostId: number | null
}
}
const resp = await ocs.get<PaginatedResponse>(`/threads/${this.thread!.id}/posts`, {
params: {
page,
perPage: this.perPage,
},
})
const data = resp.data
if (data) {
this.firstPost = data.firstPost
this.replies = data.replies || []
this.currentPage = data.pagination.page
this.totalPages = data.pagination.totalPages
this.lastReadPostId = data.pagination.lastReadPostId
}
// Determine which post to scroll to on initial load (page=0 auto-navigation)
// Do this before markAsRead so lastReadPostId still reflects the old value
let scrollTargetPostId: number | null = null
if (page === 0 && !this.postFromQuery && this.lastReadPostId !== null) {
const firstUnreadReply = this.replies.find((r) => r.id > this.lastReadPostId!)
if (firstUnreadReply) {
// Scroll to first unread post
scrollTargetPostId = firstUnreadReply.id
} else if (this.replies.length > 0) {
// All posts read — scroll to last post
scrollTargetPostId = this.replies[this.replies.length - 1].id
}
}
// Mark thread as read up to the last post in the current view
const allPosts = this.getAllPosts()
if (allPosts.length > 0) {
await this.markAsRead()
}
// Scroll to post if post query param is present
await this.$nextTick()
if (this.postFromQuery) {
this.scrollToPostFromQuery()
} else if (scrollTargetPostId !== null) {
this.scrollToPost(scrollTargetPostId)
// Retry in case refs aren't ready yet
setTimeout(() => {
this.scrollToPost(scrollTargetPostId!)
}, 100)
}
} catch (e) {
console.error('Failed to fetch posts', e)
throw new Error(t('forum', 'Failed to load replies'))
}
},
async handlePageChange(newPage: number): Promise<void> {
if (newPage === this.currentPage) return
try {
this.loadingReplies = true
this.currentPage = newPage
// Update URL query param without triggering the watcher
const query = { ...this.$route.query, page: String(newPage) }
delete query.post
this.$router.replace({ query })
await this.fetchPosts(newPage)
// Scroll to the top of the replies section
await this.$nextTick()
const repliesSection = this.$el.querySelector('.replies-section')
if (repliesSection) {
repliesSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
} catch (e) {
console.error('Failed to load page', e)
} finally {
this.loadingReplies = false
}
},
getAllPosts(): Post[] {
const posts: Post[] = []
if (this.firstPost) {
posts.push(this.firstPost)
}
posts.push(...this.replies)
return posts
},
isPostUnread(post: Post): boolean {
// Guests see everything as read
if (this.userId === null) {
return false
}
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 {
// Guests can't mark as read
if (this.userId === null) {
return
}
// Get the last post ID from the current view
const allPosts = this.getAllPosts()
const lastPost = allPosts[allPosts.length - 1]
if (!lastPost || !this.thread) {
return
}
// Only update if the new post is newer than what we've already read
if (this.lastReadPostId !== null && lastPost.id <= this.lastReadPostId) {
return
}
// Send request to mark thread as read
await ocs.post('/read-markers', {
threadId: this.thread.id,
lastReadPostId: lastPost.id,
})
// Update local state so posts appear as read immediately
this.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)
const isFirstPost = this.firstPost && this.firstPost.id === 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 correct array (firstPost or replies)
if (isFirstPost) {
this.firstPost = { ...response.data, reactions: this.firstPost!.reactions || [] }
} else {
const index = this.replies.findIndex((p) => p.id === data.post.id)
if (index !== -1) {
// Preserve reactions when updating
this.replies[index] = {
...response.data,
reactions: this.replies[index]?.reactions || [],
}
}
}
// Exit edit mode
if (postCard && typeof postCard.finishEdit === 'function') {
postCard.finishEdit()
}
showSuccess(isFirstPost ? t('forum', 'Thread updated') : t('forum', 'Reply updated'))
}
} catch (e) {
console.error('Failed to update post', e)
showError(
isFirstPost
? t('forum', 'Failed to update thread')
: t('forum', 'Failed to update reply'),
)
// 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.firstPost && this.firstPost.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'))
// 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 replies array without refreshing
const index = this.replies.findIndex((p) => p.id === post.id)
if (index !== -1) {
this.replies.splice(index, 1)
}
showSuccess(t('forum', 'Reply deleted'))
}
} catch (e) {
console.error('Failed to delete post', e)
showError(t('forum', 'Failed to delete reply'))
}
},
async handleReassigned(data: {
guestAuthorId: string
targetUserId: string
targetDisplayName: string
}): Promise<void> {
try {
// Fetch the target user's roles from the forum user endpoint
let roles: any[] = []
try {
const response = await ocs.get(`/users/${data.targetUserId}`)
roles = response.data?.roles || []
} catch {
// User may not have a forum profile yet - that is fine
}
const newAuthor = {
userId: data.targetUserId,
displayName: data.targetDisplayName,
isDeleted: false,
isGuest: false,
roles,
signature: null,
signatureRaw: null,
}
// Update first post if it belonged to this guest
if (this.firstPost && this.firstPost.authorId === data.guestAuthorId) {
this.firstPost = { ...this.firstPost, authorId: data.targetUserId, author: newAuthor }
}
// Update all replies that belonged to this guest
this.replies = this.replies.map((reply) => {
if (reply.authorId === data.guestAuthorId) {
return { ...reply, authorId: data.targetUserId, author: newAuthor }
}
return reply
})
// Update thread header if the thread author was this guest
if (this.thread && this.thread.authorId === data.guestAuthorId) {
this.thread.authorId = data.targetUserId
this.thread.author = newAuthor
}
// Update lastReplyAuthorId if it was this guest
if (this.thread && this.thread.lastReplyAuthorId === data.guestAuthorId) {
this.thread.lastReplyAuthorId = data.targetUserId
}
} catch (e) {
console.error('Failed to update posts after reassignment', e)
// Posts were reassigned on the backend; a refresh will show the correct state
}
},
replyToThread(): void {
// Redirect guests to login (only if they cannot reply)
if (this.userId === null && !this.canReply) {
const returnUrl = generateUrl(`/apps/forum/t/${this.thread?.slug}`)
window.location.href = generateUrl(`/login?redirect_url=${encodeURIComponent(returnUrl)}`)
return
}
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,
})
// After submitting a reply, go to the last page and refresh
if (response.data) {
// Clear the form only on success
if (replyForm && typeof replyForm.clear === 'function') {
replyForm.clear()
}
// Reload the last page to show the new reply
// Set page to a high number so it gets clamped to the last page
await this.fetchPosts(999999)
}
} catch (e) {
console.error('Failed to submit reply', e)
// Reset submitting state on error
if (replyForm && typeof replyForm.setSubmitting === 'function') {
replyForm.setSubmitting(false)
}
showError(t('forum', 'Failed to submit reply'))
}
},
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
}
},
async handleToggleBookmark(): Promise<void> {
if (!this.thread) return
const currentState = this.thread.isBookmarked
try {
if (currentState) {
// Remove bookmark
await ocs.delete(`/threads/${this.thread.id}/bookmark`)
this.thread.isBookmarked = false
showSuccess(this.strings.threadUnbookmarked)
} else {
// Add bookmark
await ocs.post(`/threads/${this.thread.id}/bookmark`)
this.thread.isBookmarked = true
showSuccess(this.strings.threadBookmarked)
}
} catch (e) {
console.error('Failed to toggle thread bookmark', e)
showError(t('forum', 'Failed to update bookmark'))
// Revert the state on error
this.thread.isBookmarked = currentState
}
},
scrollToPostFromQuery(): void {
// Check if there's a post query param like ?post=123
const postId = this.postFromQuery
if (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')
}, 3000)
})
}
},
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('/')
}
},
handleStartEditTitle(): void {
if (!this.thread) return
this.editedTitle = this.thread.title
this.isEditingTitle = true
// Focus the input after it's rendered
this.$nextTick(() => {
const textFieldComponent = this.$refs.titleInput as any
if (textFieldComponent && textFieldComponent.$el) {
const input = textFieldComponent.$el.querySelector('input')
if (input) {
input.focus()
input.select()
}
}
})
},
handleCancelEditTitle(): void {
this.isEditingTitle = false
this.editedTitle = ''
},
async handleSaveTitle(): Promise<void> {
if (!this.thread || !this.editedTitle.trim() || this.isSavingTitle) return
// Don't save if title hasn't changed
if (this.editedTitle.trim() === this.thread.title) {
this.handleCancelEditTitle()
return
}
this.isSavingTitle = true
try {
const response = await ocs.put(`/threads/${this.thread.id}`, {
title: this.editedTitle.trim(),
})
if (response.data) {
// Update local thread state
this.thread.title = this.editedTitle.trim()
showSuccess(this.strings.titleUpdated)
this.isEditingTitle = false
this.editedTitle = ''
}
} catch (e) {
console.error('Failed to update thread title', e)
showError(t('forum', 'Failed to update thread title'))
} finally {
this.isSavingTitle = false
}
},
async handleMoveThread(categoryId: number): Promise<void> {
if (!this.thread) return
try {
const response = await ocs.put(`/threads/${this.thread.id}/move`, {
categoryId,
})
if (response.data) {
showSuccess(this.strings.threadMoved)
this.showMoveDialog = false
// Refresh the thread data to update category information and back link
await this.refresh()
}
} catch (e) {
console.error('Failed to move thread', e)
showError(t('forum', 'Failed to move thread'))
} finally {
// Always reset the move dialog state
const moveDialog = this.$refs.moveDialog as any
if (moveDialog && typeof moveDialog.reset === 'function') {
moveDialog.reset()
}
}
},
},
})
</script>
<style scoped lang="scss">
:deep(.icon-label) {
display: flex;
align-items: center;
gap: 4px;
}
.inline-icon {
vertical-align: middle;
margin-right: 4px;
}
.thread-view {
margin-bottom: 3rem;
.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-row {
display: flex;
align-items: center;
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;
}
.thread-title-input {
flex: 1;
:deep(input) {
font-size: 1.75rem;
font-weight: 600;
color: var(--color-main-text);
font-family: inherit;
}
}
.edit-title-button,
.save-title-button {
flex-shrink: 0;
}
.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;
}
.first-post-section {
// First post section styling
}
.replies-section {
// Replies section with pagination
}
.replies-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.pagination-top,
.pagination-bottom {
padding: 8px 0;
}
}
// Highlight animation for scrolled-to posts
:deep(.highlight-post) {
animation: highlightFade 3s ease-in-out;
}
@keyframes highlightFade {
0%,
66% {
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>