feat: category read markers

This commit is contained in:
2026-02-15 10:02:32 +02:00
parent f10d0ff9a9
commit da0c77114a
21 changed files with 490 additions and 69 deletions

View File

@@ -15,9 +15,16 @@ concurrency:
cancel-in-progress: true
jobs:
phpunit-incremental:
phpunit-incremental-v0-14-0:
uses: chenasraf/workflows/.github/workflows/nextcloud-phpunit-incremental.yml@nextcloud-latest
secrets: inherit
with:
baseline-version: 'v0.14.0'
validation-query: 'SELECT COUNT(*) FROM oc_forum_users'
phpunit-incremental-v0-22-8:
uses: chenasraf/workflows/.github/workflows/nextcloud-phpunit-incremental.yml@nextcloud-latest
secrets: inherit
with:
baseline-version: 'v0.22.8'
validation-query: 'SELECT COUNT(*) FROM oc_forum_users'

View File

@@ -121,7 +121,7 @@ class BookmarkController extends OCSController {
*
* @param int $page Page number (1-indexed)
* @param int $perPage Number of threads per page
* @return DataResponse<Http::STATUS_OK, array{threads: list<array<string, mixed>>, pagination: array{page: int, perPage: int, total: int, totalPages: int}, readMarkers: array<string, array{threadId: int, lastReadPostId: int, readAt: int}>}, array{}>
* @return DataResponse<Http::STATUS_OK, array{threads: list<array<string, mixed>>, pagination: array{page: int, perPage: int, total: int, totalPages: int}, readMarkers: array<string, array{entityId: int, lastReadPostId: int, readAt: int}>}, array{}>
*
* 200: Bookmarked threads returned with pagination and read markers
*/
@@ -197,8 +197,8 @@ class BookmarkController extends OCSController {
$readMarkers = [];
$markers = $this->readMarkerMapper->findByUserAndThreads($userId, $threadIds);
foreach ($markers as $marker) {
$readMarkers[$marker->getThreadId()] = [
'threadId' => $marker->getThreadId(),
$readMarkers[$marker->getEntityId()] = [
'entityId' => $marker->getEntityId(),
'lastReadPostId' => $marker->getLastReadPostId(),
'readAt' => $marker->getReadAt(),
];

View File

@@ -12,6 +12,7 @@ use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\CategoryPerm;
use OCA\Forum\Db\CategoryPermMapper;
use OCA\Forum\Db\CatHeaderMapper;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\Role;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
@@ -35,6 +36,7 @@ class CategoryController extends OCSController {
private CategoryMapper $categoryMapper,
private CategoryPermMapper $categoryPermMapper,
private ThreadMapper $threadMapper,
private ReadMarkerMapper $readMarkerMapper,
private RoleMapper $roleMapper,
private IUserSession $userSession,
private IGroupManager $groupManager,
@@ -55,9 +57,20 @@ class CategoryController extends OCSController {
#[ApiRoute(verb: 'GET', url: '/api/categories')]
public function index(): DataResponse {
try {
// Fetch all headers and categories in just 2 queries
// Fetch all headers, categories, and last activity timestamps
$headers = $this->catHeaderMapper->findAll();
$allCategories = $this->categoryMapper->findAll();
$lastActivityMap = $this->threadMapper->getLastActivityByCategories();
// Fetch category read markers for authenticated users
$readMarkerMap = [];
$user = $this->userSession->getUser();
if ($user) {
$markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID());
foreach ($markers as $marker) {
$readMarkerMap[$marker->getEntityId()] = $marker->getReadAt();
}
}
// Group categories by header_id
$categoriesByHeader = [];
@@ -66,7 +79,10 @@ class CategoryController extends OCSController {
if (!isset($categoriesByHeader[$headerId])) {
$categoriesByHeader[$headerId] = [];
}
$categoriesByHeader[$headerId][] = $category->jsonSerialize();
$categoryData = $category->jsonSerialize();
$categoryData['lastActivityAt'] = $lastActivityMap[$category->getId()] ?? null;
$categoryData['readAt'] = $readMarkerMap[$category->getId()] ?? null;
$categoriesByHeader[$headerId][] = $categoryData;
}
// Build result with nested categories

View File

@@ -35,19 +35,34 @@ class ReadMarkerController extends OCSController {
* Get read markers for multiple threads
*
* @param string $threadIds Array of thread IDs (comma-separated in query string)
* @return DataResponse<Http::STATUS_OK, array<string, array{threadId: int, lastReadPostId: int, readAt: int}>, array{}>
* @param string $markerType Marker type ('thread' or 'category')
* @return DataResponse<Http::STATUS_OK, array<string, array{entityId: int, lastReadPostId: int|null, readAt: int}>, array{}>
*
* 200: Read markers returned (keyed by thread ID)
* 200: Read markers returned (keyed by entity ID)
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/read-markers')]
public function index(string $threadIds = ''): DataResponse {
public function index(string $threadIds = '', string $markerType = 'thread'): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
// Category markers
if ($markerType === 'category') {
$markers = $this->readMarkerMapper->findCategoryMarkersByUserId($user->getUID());
$result = [];
foreach ($markers as $marker) {
$result[$marker->getEntityId()] = [
'entityId' => $marker->getEntityId(),
'readAt' => $marker->getReadAt(),
];
}
return new DataResponse($result);
}
// Thread markers (default)
// Parse thread IDs from query parameter
$threadIdArray = [];
if (!empty($threadIds)) {
@@ -59,8 +74,8 @@ class ReadMarkerController extends OCSController {
$markers = $this->readMarkerMapper->findByUserId($user->getUID());
$result = [];
foreach ($markers as $marker) {
$result[$marker->getThreadId()] = [
'threadId' => $marker->getThreadId(),
$result[$marker->getEntityId()] = [
'entityId' => $marker->getEntityId(),
'lastReadPostId' => $marker->getLastReadPostId(),
'readAt' => $marker->getReadAt(),
];
@@ -73,8 +88,8 @@ class ReadMarkerController extends OCSController {
// Convert to associative array keyed by thread ID for easier frontend lookup
$result = [];
foreach ($markers as $marker) {
$result[$marker->getThreadId()] = [
'threadId' => $marker->getThreadId(),
$result[$marker->getEntityId()] = [
'entityId' => $marker->getEntityId(),
'lastReadPostId' => $marker->getLastReadPostId(),
'readAt' => $marker->getReadAt(),
];
@@ -115,23 +130,38 @@ class ReadMarkerController extends OCSController {
}
/**
* Mark a thread as read
* Mark a thread or category as read
*
* @param int $threadId Thread ID
* @param int $lastReadPostId Last read post ID
* @param int|null $categoryId Category ID (if provided, creates a category marker instead)
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Thread marked as read
* 200: Marked as read
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/read-markers')]
public function create(int $threadId, int $lastReadPostId): DataResponse {
public function create(int $threadId = 0, int $lastReadPostId = 0, ?int $categoryId = null): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
// Category marker
if ($categoryId !== null) {
$marker = $this->readMarkerMapper->createOrUpdateCategoryMarker(
$user->getUID(),
$categoryId
);
return new DataResponse($marker->jsonSerialize());
}
// Thread marker (default)
if ($threadId === 0 || $lastReadPostId === 0) {
return new DataResponse(['error' => 'threadId and lastReadPostId are required'], Http::STATUS_BAD_REQUEST);
}
$marker = $this->readMarkerMapper->createOrUpdate(
$user->getUID(),
$threadId,
@@ -152,8 +182,8 @@ class ReadMarkerController extends OCSController {
return new DataResponse($marker->jsonSerialize());
} catch (\Exception $e) {
$this->logger->error('Error marking thread as read: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to mark thread as read'], Http::STATUS_INTERNAL_SERVER_ERROR);
$this->logger->error('Error marking as read: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to mark as read'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}

View File

@@ -16,23 +16,30 @@ use OCP\AppFramework\Db\Entity;
* @method void setId(int $value)
* @method string getUserId()
* @method void setUserId(string $value)
* @method int getThreadId()
* @method void setThreadId(int $value)
* @method int getLastReadPostId()
* @method void setLastReadPostId(int $value)
* @method int getEntityId()
* @method void setEntityId(int $value)
* @method string getMarkerType()
* @method void setMarkerType(string $value)
* @method int|null getLastReadPostId()
* @method void setLastReadPostId(?int $value)
* @method int getReadAt()
* @method void setReadAt(int $value)
*/
class ReadMarker extends Entity implements JsonSerializable {
public const TYPE_THREAD = 'thread';
public const TYPE_CATEGORY = 'category';
protected $userId;
protected $threadId;
protected $entityId;
protected $markerType;
protected $lastReadPostId;
protected $readAt;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('threadId', 'integer');
$this->addType('entityId', 'integer');
$this->addType('markerType', 'string');
$this->addType('lastReadPostId', 'integer');
$this->addType('readAt', 'integer');
}
@@ -41,7 +48,8 @@ class ReadMarker extends Entity implements JsonSerializable {
return [
'id' => $this->getId(),
'userId' => $this->getUserId(),
'threadId' => $this->getThreadId(),
'entityId' => $this->getEntityId(),
'markerType' => $this->getMarkerType(),
'lastReadPostId' => $this->getLastReadPostId(),
'readAt' => $this->getReadAt(),
];

View File

@@ -52,7 +52,10 @@ class ReadMarkerMapper extends QBMapper {
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('entity_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
@@ -67,6 +70,9 @@ class ReadMarkerMapper extends QBMapper {
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR))
);
return $this->findEntities($qb);
}
@@ -91,7 +97,10 @@ class ReadMarkerMapper extends QBMapper {
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->in('thread_id', $qb->createNamedParameter($threadIds, IQueryBuilder::PARAM_INT_ARRAY))
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_THREAD, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->in('entity_id', $qb->createNamedParameter($threadIds, IQueryBuilder::PARAM_INT_ARRAY))
);
return $this->findEntities($qb);
}
@@ -119,13 +128,67 @@ class ReadMarkerMapper extends QBMapper {
// Create new marker
$marker = new ReadMarker();
$marker->setUserId($userId);
$marker->setThreadId($threadId);
$marker->setEntityId($threadId);
$marker->setMarkerType(ReadMarker::TYPE_THREAD);
$marker->setLastReadPostId($lastReadPostId);
$marker->setReadAt(time());
return $this->insert($marker);
}
}
/**
* Find all category read markers for a user
*
* @return array<ReadMarker>
*/
public function findCategoryMarkersByUserId(string $userId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR))
);
return $this->findEntities($qb);
}
/**
* Create or update a category read marker
*/
public function createOrUpdateCategoryMarker(string $userId, int $categoryId): ReadMarker {
try {
// Try to find existing marker
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('marker_type', $qb->createNamedParameter(ReadMarker::TYPE_CATEGORY, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->eq('entity_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))
);
$marker = $this->findEntity($qb);
// Always update the timestamp
$marker->setReadAt(time());
return $this->update($marker);
} catch (DoesNotExistException $e) {
// Create new marker
$marker = new ReadMarker();
$marker->setUserId($userId);
$marker->setEntityId($categoryId);
$marker->setMarkerType(ReadMarker::TYPE_CATEGORY);
$marker->setLastReadPostId(null);
$marker->setReadAt(time());
return $this->insert($marker);
}
}
/**
* @return array<ReadMarker>
*/

View File

@@ -220,6 +220,31 @@ class ThreadMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Get last activity timestamp (max updated_at) per category
*
* @return array<int, int> categoryId => lastActivityTimestamp
*/
public function getLastActivityByCategories(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('category_id')
->selectAlias($qb->func()->max('updated_at'), 'last_activity')
->from($this->getTableName())
->where($qb->expr()->isNull('deleted_at'))
->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->groupBy('category_id');
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
$map = [];
foreach ($rows as $row) {
$map[(int)$row['category_id']] = (int)$row['last_activity'];
}
return $map;
}
/**
* Find recent threads in specified categories
*

View File

@@ -0,0 +1,96 @@
<?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\IDBConnection;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Version 18 Migration:
* - Make forum_read_markers polymorphic by adding entity_id and marker_type columns
* - Make last_read_post_id nullable (category markers don't need it)
* - Copy thread_id values to entity_id
* - Drop old indexes
*/
class Version18Date20260214000000 extends SimpleMigrationStep {
public function __construct(
private IDBConnection $db,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return ISchemaWrapper|null
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('forum_read_markers')) {
return null;
}
$table = $schema->getTable('forum_read_markers');
// Add entity_id column (will be populated from thread_id in postSchemaChange)
if (!$table->hasColumn('entity_id')) {
$output->info('Forum: Adding entity_id column to forum_read_markers...');
$table->addColumn('entity_id', 'bigint', [
'notnull' => true,
'unsigned' => true,
'default' => 0,
]);
}
// Add marker_type column
if (!$table->hasColumn('marker_type')) {
$output->info('Forum: Adding marker_type column to forum_read_markers...');
$table->addColumn('marker_type', 'string', [
'notnull' => true,
'length' => 16,
'default' => 'thread',
]);
}
// Make last_read_post_id nullable (category markers don't use it)
$lastReadPostIdCol = $table->getColumn('last_read_post_id');
$lastReadPostIdCol->setNotnull(false);
$lastReadPostIdCol->setDefault(null);
// Drop old indexes
if ($table->hasIndex('forum_read_mark_uniq_idx')) {
$output->info('Forum: Dropping old unique index forum_read_mark_uniq_idx...');
$table->dropIndex('forum_read_mark_uniq_idx');
}
if ($table->hasIndex('forum_read_mark_tid_idx')) {
$output->info('Forum: Dropping old index forum_read_mark_tid_idx...');
$table->dropIndex('forum_read_mark_tid_idx');
}
return $schema;
}
/**
* Copy thread_id values to entity_id
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$output->info('Forum: Copying thread_id values to entity_id...');
$qb = $this->db->getQueryBuilder();
$qb->update('forum_read_markers')
->set('entity_id', 'thread_id');
$qb->executeStatement();
$output->info('Forum: thread_id values copied to entity_id successfully.');
}
}

View File

@@ -0,0 +1,57 @@
<?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\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
/**
* Version 19 Migration:
* - Drop thread_id column (data already copied to entity_id in Version18)
* - Add new indexes for polymorphic read markers
*/
class Version19Date20260214000001 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return ISchemaWrapper|null
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('forum_read_markers')) {
return null;
}
$table = $schema->getTable('forum_read_markers');
// Drop thread_id column
if ($table->hasColumn('thread_id')) {
$output->info('Forum: Dropping thread_id column from forum_read_markers...');
$table->dropColumn('thread_id');
}
// Add unique index on (user_id, marker_type, entity_id)
if (!$table->hasIndex('forum_read_mark_uniq_idx')) {
$output->info('Forum: Adding unique index forum_read_mark_uniq_idx...');
$table->addUniqueIndex(['user_id', 'marker_type', 'entity_id'], 'forum_read_mark_uniq_idx');
}
// Add index on entity_id
if (!$table->hasIndex('forum_read_mark_eid_idx')) {
$output->info('Forum: Adding index forum_read_mark_eid_idx...');
$table->addIndex(['entity_id'], 'forum_read_mark_eid_idx');
}
return $schema;
}
}

View File

@@ -1768,12 +1768,12 @@
"additionalProperties": {
"type": "object",
"required": [
"threadId",
"entityId",
"lastReadPostId",
"readAt"
],
"properties": {
"threadId": {
"entityId": {
"type": "integer",
"format": "int64"
},
@@ -6237,6 +6237,15 @@
"default": ""
}
},
{
"name": "markerType",
"in": "query",
"description": "Marker type ('thread' or 'category')",
"schema": {
"type": "string",
"default": "thread"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
@@ -6250,7 +6259,7 @@
],
"responses": {
"200": {
"description": "Read markers returned (keyed by thread ID)",
"description": "Read markers returned (keyed by entity ID)",
"content": {
"application/json": {
"schema": {
@@ -6274,18 +6283,19 @@
"additionalProperties": {
"type": "object",
"required": [
"threadId",
"entityId",
"lastReadPostId",
"readAt"
],
"properties": {
"threadId": {
"entityId": {
"type": "integer",
"format": "int64"
},
"lastReadPostId": {
"type": "integer",
"format": "int64"
"format": "int64",
"nullable": true
},
"readAt": {
"type": "integer",
@@ -6333,7 +6343,7 @@
},
"post": {
"operationId": "read_marker-create",
"summary": "Mark a thread as read",
"summary": "Mark a thread or category as read",
"tags": [
"read_marker"
],
@@ -6346,25 +6356,30 @@
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"threadId",
"lastReadPostId"
],
"properties": {
"threadId": {
"type": "integer",
"format": "int64",
"default": 0,
"description": "Thread ID"
},
"lastReadPostId": {
"type": "integer",
"format": "int64",
"default": 0,
"description": "Last read post ID"
},
"categoryId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Category ID (if provided, creates a category marker instead)"
}
}
}
@@ -6385,7 +6400,7 @@
],
"responses": {
"200": {
"description": "Thread marked as read",
"description": "Marked as read",
"content": {
"application/json": {
"schema": {

View File

@@ -1768,12 +1768,12 @@
"additionalProperties": {
"type": "object",
"required": [
"threadId",
"entityId",
"lastReadPostId",
"readAt"
],
"properties": {
"threadId": {
"entityId": {
"type": "integer",
"format": "int64"
},
@@ -6237,6 +6237,15 @@
"default": ""
}
},
{
"name": "markerType",
"in": "query",
"description": "Marker type ('thread' or 'category')",
"schema": {
"type": "string",
"default": "thread"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
@@ -6250,7 +6259,7 @@
],
"responses": {
"200": {
"description": "Read markers returned (keyed by thread ID)",
"description": "Read markers returned (keyed by entity ID)",
"content": {
"application/json": {
"schema": {
@@ -6274,18 +6283,19 @@
"additionalProperties": {
"type": "object",
"required": [
"threadId",
"entityId",
"lastReadPostId",
"readAt"
],
"properties": {
"threadId": {
"entityId": {
"type": "integer",
"format": "int64"
},
"lastReadPostId": {
"type": "integer",
"format": "int64"
"format": "int64",
"nullable": true
},
"readAt": {
"type": "integer",
@@ -6333,7 +6343,7 @@
},
"post": {
"operationId": "read_marker-create",
"summary": "Mark a thread as read",
"summary": "Mark a thread or category as read",
"tags": [
"read_marker"
],
@@ -6346,25 +6356,30 @@
}
],
"requestBody": {
"required": true,
"required": false,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"threadId",
"lastReadPostId"
],
"properties": {
"threadId": {
"type": "integer",
"format": "int64",
"default": 0,
"description": "Thread ID"
},
"lastReadPostId": {
"type": "integer",
"format": "int64",
"default": 0,
"description": "Last read post ID"
},
"categoryId": {
"type": "integer",
"format": "int64",
"nullable": true,
"default": null,
"description": "Category ID (if provided, creates a category marker instead)"
}
}
}
@@ -6385,7 +6400,7 @@
],
"responses": {
"200": {
"description": "Thread marked as read",
"description": "Marked as read",
"content": {
"application/json": {
"schema": {

View File

@@ -1,6 +1,7 @@
<template>
<div class="category-card">
<div class="category-card" :class="{ unread: isUnread }">
<div class="category-header">
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<h4 class="category-name">{{ category.name }}</h4>
<div class="category-stats">
<span class="stat">
@@ -31,6 +32,10 @@ export default defineComponent({
type: Object as PropType<Category>,
required: true,
},
isUnread: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -38,6 +43,7 @@ export default defineComponent({
threads: (count: number) => t('forum', 'Threads'),
replies: (count: number) => t('forum', 'Replies'),
noDescription: t('forum', 'No description available'),
unread: t('forum', 'New activity'),
},
}
},
@@ -57,11 +63,25 @@ export default defineComponent({
cursor: inherit;
}
&.unread {
border-left: 4px solid var(--color-primary-element);
background: var(--color-primary-element-light-hover);
}
&:hover {
border-color: var(--color-primary-element);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.unread-indicator {
display: inline-block;
width: 8px;
height: 8px;
background: var(--color-primary-element);
border-radius: 50%;
flex-shrink: 0;
}
.category-header {
display: flex;
justify-content: space-between;

View File

@@ -56,6 +56,22 @@ export function useCategories() {
return fetchCategories(true, silent)
}
/**
* Mark a category as read in the local state
* Updates the readAt timestamp so the category appears read without refetching
*/
const markCategoryAsRead = (categoryId: number): void => {
for (const header of categoryHeaders.value) {
if (!header.categories) continue
for (const category of header.categories) {
if (category.id === categoryId) {
category.readAt = Math.floor(Date.now() / 1000)
return
}
}
}
}
/**
* Clear cached categories
*/
@@ -76,5 +92,6 @@ export function useCategories() {
fetchCategories,
refresh,
clear,
markCategoryAsRead,
}
}

View File

@@ -14,6 +14,8 @@ export interface Category {
postCount: number
createdAt: number
updatedAt: number
lastActivityAt?: number | null
readAt?: number | null
}
export interface CategoryHeader {
@@ -106,8 +108,9 @@ export interface BBCode {
export interface ReadMarker {
id: number
userId: string
threadId: number
lastReadPostId: number
entityId: number
markerType: string
lastReadPostId: number | null
readAt: number
}

View File

@@ -198,7 +198,7 @@ export default defineComponent({
total: number
totalPages: number
}
readMarkers: Record<number, { threadId: number; lastReadPostId: number; readAt: number }>
readMarkers: Record<number, { entityId: number; lastReadPostId: number; readAt: number }>
}
const resp = await ocs.get<BookmarksResponse>('/bookmarks', {

View File

@@ -45,6 +45,7 @@
v-for="category in header.categories"
:key="category.id"
:category="category"
:is-unread="isCategoryUnread(category)"
@click="navigateToCategory(category)"
/>
</div>
@@ -69,6 +70,7 @@ import CategoryCard from '@/components/CategoryCard'
import RefreshIcon from '@icons/Refresh.vue'
import { useCategories } from '@/composables/useCategories'
import { usePublicSettings } from '@/composables/usePublicSettings'
import { useCurrentUser } from '@/composables/useCurrentUser'
import type { Category } from '@/types'
import { t } from '@nextcloud/l10n'
@@ -85,17 +87,21 @@ export default defineComponent({
RefreshIcon,
},
setup() {
const { categoryHeaders, loading, fetchCategories, refresh } = useCategories()
const { categoryHeaders, loading, fetchCategories, refresh, markCategoryAsRead } =
useCategories()
const { settings, loading: settingsLoading, fetchPublicSettings } = usePublicSettings()
const { userId } = useCurrentUser()
return {
categoryHeaders,
loading,
fetchCategories,
refreshCategories: refresh,
markCategoryAsRead,
publicSettings: settings,
settingsLoading,
fetchPublicSettings,
userId,
}
},
data() {
@@ -134,7 +140,24 @@ export default defineComponent({
}
},
isCategoryUnread(category: Category): boolean {
if (this.userId === null) {
return false
}
const lastActivity = category.lastActivityAt
if (!lastActivity) {
return false
}
if (category.readAt == null) {
return true
}
return lastActivity > category.readAt
},
navigateToCategory(category: Category) {
if (this.userId !== null) {
this.markCategoryAsRead(category.id)
}
this.$router.push(`/c/${category.slug}`)
},
},

View File

@@ -143,12 +143,14 @@ import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { useCurrentUser } from '@/composables/useCurrentUser'
import { useCategories } from '@/composables/useCategories'
export default defineComponent({
name: 'CategoryView',
setup() {
const { userId } = useCurrentUser()
return { userId }
const { markCategoryAsRead } = useCategories()
return { userId, markCategoryAsReadLocal: markCategoryAsRead }
},
components: {
NcButton,
@@ -222,6 +224,8 @@ export default defineComponent({
await this.fetchThreads()
// Fetch read markers after threads are loaded
await this.fetchReadMarkers()
// Mark category as read for authenticated users
this.markCategoryAsRead()
}
} catch (e) {
console.error('Failed to refresh', e)
@@ -304,6 +308,19 @@ export default defineComponent({
}
},
async markCategoryAsRead() {
if (this.userId === null || !this.category) {
return
}
// Update shared state immediately so back navigation shows as read
this.markCategoryAsReadLocal(this.category.id)
try {
await ocs.post('/read-markers', { categoryId: this.category.id })
} catch (e) {
console.debug('Failed to mark category as read', e)
}
},
async fetchReadMarkers() {
try {
// Guests don't have read markers
@@ -317,7 +334,7 @@ export default defineComponent({
const threadIds = this.threads.map((t) => t.id).join(',')
const resp = await ocs.get<
Record<number, { threadId: number; lastReadPostId: number; readAt: number }>
Record<number, { entityId: number; lastReadPostId: number; readAt: number }>
>('/read-markers', {
params: { threadIds },
})

View File

@@ -391,7 +391,7 @@ class BookmarkControllerTest extends TestCase {
$data = $response->getData();
$this->assertArrayHasKey('readMarkers', $data);
$this->assertArrayHasKey(1, $data['readMarkers']);
$this->assertEquals(1, $data['readMarkers'][1]['threadId']);
$this->assertEquals(1, $data['readMarkers'][1]['entityId']);
$this->assertEquals(5, $data['readMarkers'][1]['lastReadPostId']);
}
@@ -426,7 +426,8 @@ class BookmarkControllerTest extends TestCase {
$marker = new ReadMarker();
$marker->setId($id);
$marker->setUserId($userId);
$marker->setThreadId($threadId);
$marker->setEntityId($threadId);
$marker->setMarkerType(ReadMarker::TYPE_THREAD);
$marker->setLastReadPostId($lastReadPostId);
$marker->setReadAt(time());
return $marker;

View File

@@ -12,6 +12,7 @@ use OCA\Forum\Db\CategoryPerm;
use OCA\Forum\Db\CategoryPermMapper;
use OCA\Forum\Db\CatHeader;
use OCA\Forum\Db\CatHeaderMapper;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\Role;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
@@ -36,6 +37,8 @@ class CategoryControllerTest extends TestCase {
private CategoryPermMapper $categoryPermMapper;
/** @var ThreadMapper&MockObject */
private ThreadMapper $threadMapper;
/** @var ReadMarkerMapper&MockObject */
private ReadMarkerMapper $readMarkerMapper;
/** @var RoleMapper&MockObject */
private RoleMapper $roleMapper;
/** @var IUserSession&MockObject */
@@ -53,6 +56,7 @@ class CategoryControllerTest extends TestCase {
$this->categoryMapper = $this->createMock(CategoryMapper::class);
$this->categoryPermMapper = $this->createMock(CategoryPermMapper::class);
$this->threadMapper = $this->createMock(ThreadMapper::class);
$this->readMarkerMapper = $this->createMock(ReadMarkerMapper::class);
$this->roleMapper = $this->createMock(RoleMapper::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->groupManager = $this->createMock(IGroupManager::class);
@@ -65,6 +69,7 @@ class CategoryControllerTest extends TestCase {
$this->categoryMapper,
$this->categoryPermMapper,
$this->threadMapper,
$this->readMarkerMapper,
$this->roleMapper,
$this->userSession,
$this->groupManager,

View File

@@ -945,7 +945,8 @@ class PostControllerTest extends TestCase {
// Mock read marker - last read post ID is 31
$readMarker = new ReadMarker();
$readMarker->setUserId('user1');
$readMarker->setThreadId($threadId);
$readMarker->setEntityId($threadId);
$readMarker->setMarkerType(ReadMarker::TYPE_THREAD);
$readMarker->setLastReadPostId(31);
$readMarker->setReadAt(time());
@@ -1017,7 +1018,8 @@ class PostControllerTest extends TestCase {
// Mock read marker - all posts read (last read = 50)
$readMarker = new ReadMarker();
$readMarker->setUserId('user1');
$readMarker->setThreadId($threadId);
$readMarker->setEntityId($threadId);
$readMarker->setMarkerType(ReadMarker::TYPE_THREAD);
$readMarker->setLastReadPostId(50);
$readMarker->setReadAt(time());

View File

@@ -97,7 +97,7 @@ class ReadMarkerControllerTest extends TestCase {
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($threadId, $data['threadId']);
$this->assertEquals($threadId, $data['entityId']);
$this->assertEquals(10, $data['lastReadPostId']);
}
@@ -151,7 +151,7 @@ class ReadMarkerControllerTest extends TestCase {
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($threadId, $data['threadId']);
$this->assertEquals($threadId, $data['entityId']);
$this->assertEquals($lastReadPostId, $data['lastReadPostId']);
}
@@ -180,7 +180,7 @@ class ReadMarkerControllerTest extends TestCase {
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertEquals($threadId, $data['threadId']);
$this->assertEquals($threadId, $data['entityId']);
}
public function testCreateReturnsUnauthorizedWhenUserNotAuthenticated(): void {
@@ -232,7 +232,8 @@ class ReadMarkerControllerTest extends TestCase {
$marker = new ReadMarker();
$marker->setId($id);
$marker->setUserId($userId);
$marker->setThreadId($threadId);
$marker->setEntityId($threadId);
$marker->setMarkerType(ReadMarker::TYPE_THREAD);
$marker->setLastReadPostId($lastReadPostId);
$marker->setReadAt(time());
return $marker;