mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: category read markers
This commit is contained in:
9
.github/workflows/phpunit-incremental.yml
vendored
9
.github/workflows/phpunit-incremental.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
96
lib/Migration/Version18Date20260214000000.php
Normal file
96
lib/Migration/Version18Date20260214000000.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
57
lib/Migration/Version19Date20260214000001.php
Normal file
57
lib/Migration/Version19Date20260214000001.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
41
openapi.json
41
openapi.json
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user