feat: add "last reply by" to thread card

This commit is contained in:
2026-03-21 01:22:31 +02:00
parent 7f577c2abd
commit 7147d79881
14 changed files with 441 additions and 11 deletions

View File

@@ -401,6 +401,8 @@ class PostController extends OCSController {
$thread = $this->threadMapper->find($threadId);
$thread->setPostCount($thread->getPostCount() + 1);
$thread->setLastPostId($createdPost->getId());
$thread->setLastReplyAuthorId($createdPost->getAuthorId());
$thread->setLastReplyAt($createdPost->getCreatedAt());
$thread->setUpdatedAt(time());
$this->threadMapper->update($thread);
} catch (\Exception $e) {
@@ -561,9 +563,19 @@ class PostController extends OCSController {
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
if ($latestPost) {
$thread->setLastPostId($latestPost->getId());
if (!$latestPost->getIsFirstPost()) {
$thread->setLastReplyAuthorId($latestPost->getAuthorId());
$thread->setLastReplyAt($latestPost->getCreatedAt());
} else {
// Only the first post remains — no replies
$thread->setLastReplyAuthorId(null);
$thread->setLastReplyAt(null);
}
} else {
// No other posts in thread, set to null (or keep first post ID)
$thread->setLastPostId(null);
$thread->setLastReplyAuthorId(null);
$thread->setLastReplyAt(null);
}
}

View File

@@ -151,15 +151,26 @@ class ThreadController extends OCSController {
// Fetch threads for the current page
$threads = $this->threadMapper->findByCategoryId($categoryId, $perPage, $offset);
// Extract unique author IDs
$authorIds = array_unique(array_map(fn ($t) => $t->getAuthorId(), $threads));
// Extract unique author IDs (thread authors + last reply authors)
$authorIds = array_map(fn ($t) => $t->getAuthorId(), $threads);
$lastReplyAuthorIds = array_filter(array_map(fn ($t) => $t->getLastReplyAuthorId(), $threads));
$allAuthorIds = array_unique(array_merge($authorIds, $lastReplyAuthorIds));
// Batch fetch author data (includes roles)
$authors = $this->userService->enrichMultipleUsers($authorIds);
$authors = $this->userService->enrichMultipleUsers($allAuthorIds);
// Enrich threads with pre-fetched author data
// Enrich threads with pre-fetched author data and last reply info
$enrichedThreads = array_map(function ($t) use ($authors) {
return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null);
$lastReply = null;
$lastReplyAuthorId = $t->getLastReplyAuthorId();
if ($lastReplyAuthorId !== null) {
$lastReply = [
'postId' => $t->getLastPostId(),
'author' => $authors[$lastReplyAuthorId] ?? null,
'createdAt' => $t->getLastReplyAt(),
];
}
return $this->threadEnrichmentService->enrichThread($t, $authors[$t->getAuthorId()] ?? null, $lastReply);
}, $threads);
return new DataResponse([

View File

@@ -93,6 +93,25 @@ class PostMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Find posts by multiple IDs
*
* @param array<int> $ids
* @return array<Post>
*/
public function findByIds(array $ids): array {
if (empty($ids)) {
return [];
}
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)))
->andWhere($qb->expr()->isNull('deleted_at'));
return $this->findEntities($qb);
}
/**
* @return array<Post>
*/

View File

@@ -28,6 +28,10 @@ use OCP\AppFramework\Db\Entity;
* @method void setPostCount(int $value)
* @method int|null getLastPostId()
* @method void setLastPostId(?int $value)
* @method string|null getLastReplyAuthorId()
* @method void setLastReplyAuthorId(?string $value)
* @method int|null getLastReplyAt()
* @method void setLastReplyAt(?int $value)
* @method bool getIsLocked()
* @method void setIsLocked(bool $value)
* @method bool getIsPinned()
@@ -49,6 +53,8 @@ class Thread extends Entity implements JsonSerializable {
protected $viewCount;
protected $postCount;
protected $lastPostId;
protected $lastReplyAuthorId;
protected $lastReplyAt;
protected $isLocked;
protected $isPinned;
protected $isHidden;
@@ -65,6 +71,8 @@ class Thread extends Entity implements JsonSerializable {
$this->addType('viewCount', 'integer');
$this->addType('postCount', 'integer');
$this->addType('lastPostId', 'integer');
$this->addType('lastReplyAuthorId', 'string');
$this->addType('lastReplyAt', 'integer');
$this->addType('isLocked', 'boolean');
$this->addType('isPinned', 'boolean');
$this->addType('isHidden', 'boolean');
@@ -83,6 +91,8 @@ class Thread extends Entity implements JsonSerializable {
'viewCount' => $this->getViewCount(),
'postCount' => $this->getPostCount(),
'lastPostId' => $this->getLastPostId(),
'lastReplyAuthorId' => $this->getLastReplyAuthorId(),
'lastReplyAt' => $this->getLastReplyAt(),
'isLocked' => $this->getIsLocked(),
'isPinned' => $this->getIsPinned(),
'isHidden' => $this->getIsHidden(),

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Version 26 Migration:
* - Add last_reply_author_id and last_reply_at columns to forum_threads
* - Backfill from existing posts data
*/
class Version26Date20260321000000 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $db,
) {
}
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('forum_threads')) {
return null;
}
$table = $schema->getTable('forum_threads');
if (!$table->hasColumn('last_reply_author_id')) {
$table->addColumn('last_reply_author_id', Types::STRING, [
'notnull' => false,
'length' => 64,
]);
}
if (!$table->hasColumn('last_reply_at')) {
$table->addColumn('last_reply_at', Types::BIGINT, [
'notnull' => false,
'unsigned' => true,
]);
}
return $schema;
}
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$output->info('Forum: Backfilling last_reply_author_id and last_reply_at from posts …');
// Find threads that have a last_post_id pointing to a non-first-post
$qb = $this->db->getQueryBuilder();
$qb->select('t.id', 'p.author_id', 'p.created_at')
->from('forum_threads', 't')
->innerJoin('t', 'forum_posts', 'p', $qb->expr()->eq('t.last_post_id', 'p.id'))
->where($qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb->expr()->isNull('p.deleted_at'))
->andWhere($qb->expr()->isNull('t.deleted_at'));
$result = $qb->executeQuery();
$count = 0;
while ($row = $result->fetch()) {
$update = $this->db->getQueryBuilder();
$update->update('forum_threads')
->set('last_reply_author_id', $update->createNamedParameter($row['author_id']))
->set('last_reply_at', $update->createNamedParameter((int)$row['created_at'], IQueryBuilder::PARAM_INT))
->where($update->expr()->eq('id', $update->createNamedParameter((int)$row['id'], IQueryBuilder::PARAM_INT)));
$update->executeStatement();
$count++;
}
$result->closeCursor();
$output->info("Forum: Backfilled last reply info for $count threads.");
}
}

View File

@@ -33,7 +33,7 @@ class ThreadEnrichmentService {
* @param array|null $author Optional pre-loaded author data
* @return array Enriched thread data
*/
public function enrichThread(mixed $thread, ?array $author = null): array {
public function enrichThread(mixed $thread, ?array $author = null, ?array $lastReply = null): array {
if (!is_array($thread)) {
$thread = $thread->jsonSerialize();
}
@@ -45,6 +45,9 @@ class ThreadEnrichmentService {
$thread['author'] = $author;
}
// Add last reply info (author + timestamp)
$thread['lastReply'] = $lastReply;
// Add category information (slug and name) for navigation
try {
$category = $this->categoryMapper->find($thread['categoryId']);

View File

@@ -133,4 +133,82 @@ describe('ThreadCard', () => {
expect(statValues[1]!.text()).toBe('0')
})
})
describe('last reply', () => {
it('should show last reply info when thread has a last reply', () => {
const thread = createMockThread({
lastPostId: 42,
lastReplyAuthorId: 'alice',
lastReplyAt: 1700000000,
lastReply: {
postId: 42,
author: {
userId: 'alice',
displayName: 'Alice',
isDeleted: false,
roles: [],
signature: null,
signatureRaw: null,
},
createdAt: 1700000000,
},
})
const wrapper = mount(ThreadCard, {
props: { thread },
})
const lastReply = wrapper.find('.last-reply')
expect(lastReply.exists()).toBe(true)
expect(lastReply.text()).toContain('Alice')
})
it('should not show last reply when thread has no last reply', () => {
const thread = createMockThread({ lastReply: undefined })
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.last-reply').exists()).toBe(false)
})
it('should emit navigate-last-reply when last reply link is clicked', async () => {
const thread = createMockThread({
lastPostId: 42,
lastReplyAuthorId: 'alice',
lastReplyAt: 1700000000,
lastReply: {
postId: 42,
author: {
userId: 'alice',
displayName: 'Alice',
isDeleted: false,
roles: [],
signature: null,
signatureRaw: null,
},
createdAt: 1700000000,
},
})
const wrapper = mount(ThreadCard, {
props: { thread },
})
await wrapper.find('.last-reply').trigger('click')
expect(wrapper.emitted('navigate-last-reply')).toBeTruthy()
})
it('should fall back to lastReplyAuthorId when author displayName is unavailable', () => {
const thread = createMockThread({
lastPostId: 42,
lastReplyAuthorId: 'bob',
lastReplyAt: 1700000000,
lastReply: {
postId: 42,
author: null,
createdAt: 1700000000,
},
})
const wrapper = mount(ThreadCard, {
props: { thread },
})
expect(wrapper.find('.last-reply').text()).toContain('bob')
})
})
})

View File

@@ -33,6 +33,21 @@
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</template>
</UserInfo>
<template v-if="thread.lastReply">
<span class="meta-divider">·</span>
<a
class="last-reply"
:href="lastReplyUrl"
@click.prevent.stop="$emit('navigate-last-reply', thread)"
>
{{
strings.lastReplyBy(
thread.lastReply.author?.displayName || thread.lastReplyAuthorId || '',
)
}}
<NcDateTime :timestamp="thread.lastReply.createdAt * 1000" />
</a>
</template>
</div>
</div>
@@ -87,6 +102,12 @@ export default defineComponent({
default: false,
},
},
emits: ['navigate-last-reply'],
computed: {
lastReplyUrl(): string {
return `/apps/forum/t/${this.thread.slug}?page=last&post=${this.thread.lastPostId}`
},
},
data() {
return {
strings: {
@@ -95,6 +116,7 @@ export default defineComponent({
pinned: t('forum', 'Pinned thread'),
locked: t('forum', 'Locked thread'),
unread: t('forum', 'Unread'),
lastReplyBy: (name: string) => t('forum', 'Last reply by {name}', { name }),
},
}
},
@@ -265,6 +287,22 @@ export default defineComponent({
font-size: 0.65rem;
}
}
.meta-divider {
color: var(--color-text-maxcontrast);
}
.last-reply {
font-size: 0.8rem;
color: var(--color-text-maxcontrast);
text-decoration: none;
white-space: nowrap;
&:hover {
color: var(--color-primary-element);
text-decoration: underline;
}
}
}
@media (max-width: 768px) {

View File

@@ -67,6 +67,8 @@ export function createMockThread(overrides: Partial<Thread> = {}): Thread {
viewCount: 100,
postCount: 10,
lastPostId: null,
lastReplyAuthorId: null,
lastReplyAt: null,
isLocked: false,
isPinned: false,
isHidden: false,

View File

@@ -54,6 +54,8 @@ export interface Thread {
viewCount: number
postCount: number
lastPostId: number | null
lastReplyAuthorId: string | null
lastReplyAt: number | null
isLocked: boolean
isPinned: boolean
isHidden: boolean
@@ -65,6 +67,11 @@ export interface Thread {
categoryName?: string | null
isSubscribed?: boolean
isBookmarked?: boolean
lastReply?: {
postId: number
author: User | null
createdAt: number
} | null
}
export interface Post {

View File

@@ -110,6 +110,7 @@
:thread="thread"
:is-unread="isThreadUnread(thread)"
@click="navigateToThread(thread)"
@navigate-last-reply="navigateToLastReply(thread)"
/>
</div>
@@ -371,6 +372,14 @@ export default defineComponent({
this.$router.push(`/t/${thread.slug}`)
},
navigateToLastReply(thread: Thread) {
const query: Record<string, string> = { page: 'last' }
if (thread.lastPostId) {
query.post = String(thread.lastPostId)
}
this.$router.push({ path: `/t/${thread.slug}`, query })
},
createThread() {
// Redirect guests to login only if they cannot post
if (this.userId === null && !this.canPost) {

View File

@@ -497,10 +497,15 @@ export default defineComponent({
// 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
}
@@ -568,7 +573,8 @@ export default defineComponent({
}
// Fetch posts - use page from query param if present
const initialPage = this.pageFromQuery || 0
// 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()

View File

@@ -614,9 +614,12 @@ class PostControllerTest extends TestCase {
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
->willReturnCallback(function ($updatedThread) use ($userId, $createdPost) {
// Thread post count should be incremented from 5 to 6
$this->assertEquals(6, $updatedThread->getPostCount());
// Last reply info should be set
$this->assertEquals($userId, $updatedThread->getLastReplyAuthorId());
$this->assertEquals($createdPost->getCreatedAt(), $updatedThread->getLastReplyAt());
return $updatedThread;
});
@@ -683,9 +686,12 @@ class PostControllerTest extends TestCase {
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
->willReturnCallback(function ($updatedThread) use ($userId) {
// Thread post count should be decremented from 5 to 4
$this->assertEquals(4, $updatedThread->getPostCount());
// Last reply info should be updated to the new last post
$this->assertEquals($userId, $updatedThread->getLastReplyAuthorId());
$this->assertNotNull($updatedThread->getLastReplyAt());
return $updatedThread;
});
@@ -789,6 +795,78 @@ class PostControllerTest extends TestCase {
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testDestroyLastReplyClearsLastReplyFieldsWhenOnlyFirstPostRemains(): void {
$postId = 5;
$userId = 'user1';
$post = $this->createMockPost($postId, 1, $userId, 'The only reply');
$post->setIsFirstPost(false);
$thread = new Thread();
$thread->setId(1);
$thread->setCategoryId(1);
$thread->setPostCount(1);
$thread->setLastPostId($postId);
$thread->setLastReplyAuthorId($userId);
$thread->setLastReplyAt(1700000000);
$category = new Category();
$category->setId(1);
$category->setPostCount(10);
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn($userId);
$this->userSession->method('getUser')->willReturn($user);
$this->permissionService->method('getCategoryIdFromPost')->willReturn(1);
$this->permissionService->method('hasCategoryPermission')->willReturn(false);
$this->postMapper->expects($this->once())
->method('find')
->willReturn($post);
$this->postMapper->expects($this->once())
->method('update')
->willReturn($post);
$this->threadMapper->expects($this->once())
->method('find')
->willReturn($thread);
// Only the first post remains after deletion
$firstPost = $this->createMockPost(1, 1, $userId, 'First post');
$firstPost->setIsFirstPost(true);
$this->postMapper->expects($this->once())
->method('findLatestByThreadId')
->with(1, $postId)
->willReturn($firstPost);
$this->threadMapper->expects($this->once())
->method('update')
->willReturnCallback(function ($updatedThread) {
// Last reply fields should be cleared
$this->assertNull($updatedThread->getLastReplyAuthorId());
$this->assertNull($updatedThread->getLastReplyAt());
$this->assertEquals(1, $updatedThread->getLastPostId()); // Set to first post
return $updatedThread;
});
$this->categoryMapper->expects($this->once())
->method('find')
->willReturn($category);
$this->categoryMapper->expects($this->once())
->method('update')
->willReturn($category);
$this->forumUserMapper->expects($this->once())
->method('decrementPostCount')
->with($userId);
$response = $this->controller->destroy($postId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testByThreadPaginatedReturnsPostsSuccessfully(): void {
$threadId = 1;
$page = 1;

View File

@@ -104,12 +104,13 @@ class ThreadControllerTest extends TestCase {
// Mock thread enrichment service to return serialized thread with mock data
$this->threadEnrichmentService->method('enrichThread')
->willReturnCallback(function ($thread) {
->willReturnCallback(function ($thread, $author = null, $lastReply = null) {
$data = is_array($thread) ? $thread : $thread->jsonSerialize();
$data['author'] = ['userId' => $data['authorId'], 'displayName' => 'Test User'];
$data['author'] = $author ?? ['userId' => $data['authorId'], 'displayName' => 'Test User'];
$data['categorySlug'] = 'test-category';
$data['categoryName'] = 'Test Category';
$data['isSubscribed'] = false;
$data['lastReply'] = $lastReply;
return $data;
});
@@ -1256,6 +1257,79 @@ class ThreadControllerTest extends TestCase {
$this->assertEquals(0, $data['pagination']['total']);
}
public function testByCategoryPaginatedEnrichesLastReplyData(): void {
$categoryId = 1;
$page = 1;
$perPage = 20;
$thread = $this->createMockThread(1, $categoryId, 'user1', 'Thread With Reply');
$thread->setLastPostId(10);
$thread->setLastReplyAuthorId('user2');
$thread->setLastReplyAt(1700000000);
$this->threadMapper->expects($this->once())
->method('countByCategoryId')
->with($categoryId)
->willReturn(1);
$this->threadMapper->expects($this->once())
->method('findByCategoryId')
->with($categoryId, $perPage, 0)
->willReturn([$thread]);
$this->userService->expects($this->once())
->method('enrichMultipleUsers')
->willReturn([
'user1' => ['userId' => 'user1', 'displayName' => 'User 1'],
'user2' => ['userId' => 'user2', 'displayName' => 'User 2'],
]);
$response = $this->controller->byCategoryPaginated($categoryId, $page, $perPage);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertCount(1, $data['threads']);
$enrichedThread = $data['threads'][0];
$this->assertNotNull($enrichedThread['lastReply']);
$this->assertEquals(10, $enrichedThread['lastReply']['postId']);
$this->assertEquals('user2', $enrichedThread['lastReply']['author']['userId']);
$this->assertEquals(1700000000, $enrichedThread['lastReply']['createdAt']);
}
public function testByCategoryPaginatedOmitsLastReplyWhenNoReplies(): void {
$categoryId = 1;
$page = 1;
$perPage = 20;
// Thread with no replies (lastReplyAuthorId is null)
$thread = $this->createMockThread(1, $categoryId, 'user1', 'Thread Without Reply');
$thread->setLastPostId(1);
$this->threadMapper->expects($this->once())
->method('countByCategoryId')
->with($categoryId)
->willReturn(1);
$this->threadMapper->expects($this->once())
->method('findByCategoryId')
->with($categoryId, $perPage, 0)
->willReturn([$thread]);
$this->userService->expects($this->once())
->method('enrichMultipleUsers')
->willReturn([
'user1' => ['userId' => 'user1', 'displayName' => 'User 1'],
]);
$response = $this->controller->byCategoryPaginated($categoryId, $page, $perPage);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$enrichedThread = $data['threads'][0];
$this->assertNull($enrichedThread['lastReply']);
}
public function testCreateThreadUpdatesCategoryReadMarkerWhenCategoryWasRead(): void {
$categoryId = 1;
$title = 'New Thread';