Compare commits

...

22 Commits

Author SHA1 Message Date
a1671baf2d chore(master): release 0.2.1 2025-11-17 18:15:40 +02:00
71ee133ac6 fix: unread counts for deleted posts 2025-11-17 18:13:19 +02:00
1add8db287 fix: thread card hover styles 2025-11-17 17:52:34 +02:00
e1e3ede1d8 chore(master): release 0.2.0 2025-11-17 10:09:24 +02:00
9833e51997 fix: admin/mod post permissions 2025-11-17 10:01:42 +02:00
664ee53670 fix: user avatar container size 2025-11-17 09:45:40 +02:00
7a80c19613 chore: fix ts errors 2025-11-17 03:18:49 +02:00
8cc34d9d7a feat: update thread card user info display 2025-11-17 03:16:01 +02:00
364226fdc8 fix: create user stats for existing users 2025-11-17 03:10:09 +02:00
11aa3af887 feat: unify user info component 2025-11-17 03:05:55 +02:00
0de120f2bf feat: rebuild user stats task & command 2025-11-17 02:42:06 +02:00
e590f73fc0 fix: user stats table 2025-11-17 02:41:54 +02:00
4ca6388923 feat: add emoji picker close icon 2025-11-17 01:48:25 +02:00
cdecdce9d1 fix: emoji picker position 2025-11-17 01:44:53 +02:00
bf59b47b2a build: exclude openapi from precommit formatting 2025-11-17 01:32:49 +02:00
2fbe180d5e feat: thread subscriptions & notifications 2025-11-17 01:27:17 +02:00
d16288f237 fix: default support category sort order 2025-11-16 23:15:24 +02:00
6ba8034b75 fix: autoload 2025-11-16 16:35:45 +02:00
860092d6a9 chore(master): release 0.1.7 2025-11-16 16:27:45 +02:00
29311708a5 fix: autoload 2025-11-16 16:18:55 +02:00
51bcf64213 fix: update tar build tar 2025-11-16 14:53:56 +02:00
4e6ba7cb28 docs: add disclaimer 2025-11-16 14:28:01 +02:00
44 changed files with 2309 additions and 371 deletions

View File

@@ -1,6 +1,10 @@
module.exports = {
'*.{ts,vue}': ['eslint --fix'],
'*.{scss,vue,ts,md,json}': ['prettier --write'],
'*.{scss,vue,ts,md}': ['prettier --write'],
'*.json': (files) => {
const filtered = files.filter(file => !file.includes('openapi.json'));
return filtered.length > 0 ? `prettier --write ${filtered.join(' ')}` : [];
},
'*.php': [() => 'make php-cs-fixer'],
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json'],
}

View File

@@ -1 +1 @@
{".":"0.1.6"}
{".":"0.2.1"}

View File

@@ -1,5 +1,43 @@
# Changelog
## [0.2.1](https://github.com/chenasraf/nextcloud-forum/compare/v0.2.0...v0.2.1) (2025-11-17)
### Bug Fixes
* thread card hover styles ([1add8db](https://github.com/chenasraf/nextcloud-forum/commit/1add8db28775d2d13d8b2eb9428a90eb99b32ae8))
* unread counts for deleted posts ([71ee133](https://github.com/chenasraf/nextcloud-forum/commit/71ee133ac6b59f9005918594f7e668031b8224fa))
## [0.2.0](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.7...v0.2.0) (2025-11-17)
### Features
* add emoji picker close icon ([4ca6388](https://github.com/chenasraf/nextcloud-forum/commit/4ca6388923299751a251f56785c2b29dc2dd75dd))
* rebuild user stats task & command ([0de120f](https://github.com/chenasraf/nextcloud-forum/commit/0de120f2bf88bd377aa13a760f29f1b46ece98e9))
* thread subscriptions & notifications ([2fbe180](https://github.com/chenasraf/nextcloud-forum/commit/2fbe180d5e8a6e6fdd02b3506896f9355d6bef22))
* unify user info component ([11aa3af](https://github.com/chenasraf/nextcloud-forum/commit/11aa3af887f17c3236ff8abcc8ef1d3b15ee03c2))
* update thread card user info display ([8cc34d9](https://github.com/chenasraf/nextcloud-forum/commit/8cc34d9d7a0711d43b938b9fd686a8ea682160cf))
### Bug Fixes
* admin/mod post permissions ([9833e51](https://github.com/chenasraf/nextcloud-forum/commit/9833e519973da5ff059ef0346333bdc96d73c072))
* autoload ([6ba8034](https://github.com/chenasraf/nextcloud-forum/commit/6ba8034b7535d1c449e8b75d5645398f948b7941))
* create user stats for existing users ([364226f](https://github.com/chenasraf/nextcloud-forum/commit/364226fdc84713162b1b59d3ec17455177a7ba81))
* default support category sort order ([d16288f](https://github.com/chenasraf/nextcloud-forum/commit/d16288f237e07ad7d3a5726029de39c7bee7b8da))
* emoji picker position ([cdecdce](https://github.com/chenasraf/nextcloud-forum/commit/cdecdce9d18828e227be0994b9ccf065eba9c831))
* user avatar container size ([664ee53](https://github.com/chenasraf/nextcloud-forum/commit/664ee536705bd2d8fab64470a2a2600ab30e3d26))
* user stats table ([e590f73](https://github.com/chenasraf/nextcloud-forum/commit/e590f73fc02f32c6d0f908e895441f4405240ec7))
## [0.1.7](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.6...v0.1.7) (2025-11-16)
### Bug Fixes
* autoload ([2931170](https://github.com/chenasraf/nextcloud-forum/commit/29311708a54fdc3f2f1538fcf068e795f1a3a0c9))
* update tar build tar ([51bcf64](https://github.com/chenasraf/nextcloud-forum/commit/51bcf6421341f73dc46602d0c2aab8842f3b12c6))
## [0.1.6](https://github.com/chenasraf/nextcloud-forum/compare/v0.1.5...v0.1.6) (2025-11-16)

View File

@@ -30,7 +30,7 @@ app_name=forum
repo_path=chenasraf/nextcloud-$(app_name)
build_tools_directory=$(CURDIR)/build/tools
source_build_directory=$(CURDIR)/build/artifacts/source
source_intermediate_directory=$(CURDIR)/build/artifacts/intermediate-source
source_intermediate_directory=$(CURDIR)/build/artifacts/intermediate-source/$(app_name)
source_package_name=$(source_build_directory)/$(app_name)
app_intermediate_directory=$(CURDIR)/build/artifacts/intermediate/$(app_name)
appstore_build_directory=$(CURDIR)/build/artifacts/appstore
@@ -154,8 +154,8 @@ source:
--exclude="dist/js/*.log" \
--exclude="rename-template.sh" \
$(CURDIR)/ $(source_intermediate_directory)
cd $(source_intermediate_directory) && \
tar czf $(source_package_name).tar.gz ../$(app_name)
cd $(CURDIR)/build/artifacts/intermediate-source && \
tar czf $(source_package_name).tar.gz $(app_name)
# appstore:
# - Create an App Store tarball (strips tests, dotfiles, dev configs)
@@ -193,8 +193,8 @@ appstore:
--exclude="/src" \
--exclude="rename-template.sh" \
$(CURDIR)/ $(app_intermediate_directory)
cd $(app_intermediate_directory) && \
tar czf $(appstore_package_name).tar.gz ../$(app_name)
cd $(CURDIR)/build/artifacts/intermediate && \
tar czf $(appstore_package_name).tar.gz $(app_name)
# test:
# - Run PHP unit tests (standard + optional integration config)

View File

@@ -10,6 +10,13 @@ threads, and posts within their Nextcloud instance.
![Screenshot](/screenshots/screenshot-01.png)
## ⚠️ Early Development Notice
**This app is in early stages of development.** While functional, you may encounter bugs or
incomplete features. Please report any issues on
[GitHub](https://github.com/chenasraf/nextcloud-forum/issues) and consider backing up your data
regularly.
## Features
- **Category Management**: Organize discussions into categories with headers and custom permissions

11
appinfo/console.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
use OCA\Forum\Command\TestNotifier;
/** @var Symfony\Component\Console\Application $application */
$application->add(\OC::$server->get(TestNotifier::class));

View File

@@ -10,6 +10,9 @@
<description><![CDATA[
Create discussions, share ideas, and collaborate with your community directly in Nextcloud.
**⚠️ Early Development Notice:**
This app is in early stages of development. While functional, you may encounter bugs or incomplete features. Please report any issues on GitHub and consider backing up your data regularly.
**Key Features:**
- **Thread-based Discussions** - Create and reply to organized discussion threads
- **Category Organization** - Structure your forum with customizable categories and headers
@@ -33,7 +36,7 @@ Create discussions, share ideas, and collaborate with your community directly in
The forum integrates seamlessly with your Nextcloud instance, using your existing users and groups for authentication and access control.
]]></description>
<version>0.1.6</version>
<version>0.2.1</version>
<licence>agpl</licence>
<author mail="contact@casraf.dev" homepage="https://casraf.dev">Chen Asraf</author>
<namespace>Forum</namespace>
@@ -52,6 +55,13 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
<dependencies>
<nextcloud min-version="29" max-version="33"/>
</dependencies>
<background-jobs>
<job>OCA\Forum\Cron\RebuildUserStatsTask</job>
</background-jobs>
<commands>
<command>OCA\Forum\Command\TestNotifier</command>
<command>OCA\Forum\Command\RebuildUserStats</command>
</commands>
<navigations>
<navigation role="all">
<name>Forum</name>

View File

@@ -1,6 +1,6 @@
{
"name": "nextcloud/forum",
"description": "Automatically fills the currency rates for your Cospend projects daily.",
"description": "A community-driven forum built right into your Nextcloud instance",
"license": "AGPL-3.0-or-later",
"authors": [
{

8
composer/autoload.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';

View File

@@ -6,6 +6,7 @@ namespace OCA\Forum\AppInfo;
use OCA\Forum\Listener\UserEventListener;
use OCA\Forum\Middleware\PermissionMiddleware;
use OCA\Forum\Notification\Notifier;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@@ -33,6 +34,9 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(UserCreatedEvent::class, UserEventListener::class);
$context->registerEventListener(UserDeletedEvent::class, UserEventListener::class);
$context->registerEventListener(UserChangedEvent::class, UserEventListener::class);
// Register notification notifier
$context->registerNotifierService(Notifier::class);
}
public function boot(IBootContext $context): void {

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Command;
use OCA\Forum\Service\UserStatsService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildUserStats extends Command {
public function __construct(
private UserStatsService $userStatsService,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('forum:rebuild-user-stats')
->setDescription('Rebuild user statistics for all users in the system');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$output->writeln('<info>Rebuilding user statistics for all users...</info>');
$result = $this->userStatsService->createStatsForAllUsers();
$output->writeln(sprintf('Processed %d users', $result['users']));
$output->writeln(sprintf('Created %d new user stats', $result['created']));
$output->writeln(sprintf('Updated %d existing user stats', $result['updated']));
$output->writeln('<info>User statistics rebuilt successfully!</info>');
return 0;
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Command;
use OCA\Forum\Notification\Notifier;
use OCP\L10N\IFactory;
use OCP\Notification\IManager as INotificationManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class TestNotifier extends Command {
public function __construct(
private INotificationManager $notificationManager,
private IFactory $l10nFactory,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('forum:test-notifier')
->setDescription('Test the forum notification system');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$output->writeln('<info>Testing Forum Notifier...</info>');
// Instantiate the notifier
$notifier = new Notifier($this->l10nFactory);
$output->writeln('✓ Notifier instantiated successfully');
$output->writeln(' ID: ' . $notifier->getID());
$output->writeln(' Name: ' . $notifier->getName());
// Create a test notification (matching production structure)
$notification = $this->notificationManager->createNotification();
$notification->setApp('forum')
->setUser('admin')
->setDateTime(new \DateTime())
->setObject('thread', '1')
->setSubject('new_posts', [
'threadId' => 1,
'threadTitle' => 'Test Thread',
'threadSlug' => 'test-thread',
'lastPostId' => 1,
'postCount' => 1,
])
->setLink('http://localhost/apps/forum/t/test-thread')
->setIcon('http://localhost/apps/forum/img/app-dark.svg');
$output->writeln('✓ Test notification created');
// Try to prepare it
$prepared = $notifier->prepare($notification, 'en');
$output->writeln('✓ Notification prepared successfully');
$output->writeln(' Subject: ' . $prepared->getParsedSubject());
$output->writeln(' Link: ' . $prepared->getLink());
$output->writeln(' Icon: ' . $prepared->getIcon());
$output->writeln('');
$output->writeln('<info>All tests passed! The notifier is working correctly.</info>');
return 0;
} catch (\Exception $e) {
$output->writeln('<error>✗ Error: ' . $e->getMessage() . '</error>');
$output->writeln('<error>Trace:</error>');
$output->writeln($e->getTraceAsString());
return 1;
}
}
}

View File

@@ -17,6 +17,7 @@ use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\BBCodeService;
use OCA\Forum\Service\NotificationService;
use OCA\Forum\Service\PermissionService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -41,6 +42,7 @@ class PostController extends OCSController {
private BBCodeMapper $bbCodeMapper,
private PermissionService $permissionService,
private ReadMarkerMapper $readMarkerMapper,
private NotificationService $notificationService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -268,6 +270,14 @@ class PostController extends OCSController {
// Don't fail the request if category update fails
}
// Notify registered users about the new post
try {
$this->notificationService->notifyThreadSubscribers($threadId, $createdPost->getId(), $user->getUID());
} catch (\Exception $e) {
$this->logger->warning('Failed to send notifications for new post: ' . $e->getMessage());
// Don't fail the request if notification sending fails
}
return new DataResponse(Post::enrichPostContent($createdPost), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating post: ' . $e->getMessage());
@@ -295,12 +305,13 @@ class PostController extends OCSController {
$post = $this->postMapper->find($id);
// Check if user is the author OR has moderator permission
// Check if user is the author OR has moderator permission OR is admin/moderator
$isAuthor = $post->getAuthorId() === $user->getUID();
$categoryId = $this->permissionService->getCategoryIdFromPost($id);
$isModerator = $this->permissionService->hasCategoryPermission($user->getUID(), $categoryId, 'canModerate');
$isAdminOrMod = $this->permissionService->hasAdminOrModeratorRole($user->getUID());
if (!$isAuthor && !$isModerator) {
if (!$isAuthor && !$isModerator && !$isAdminOrMod) {
return new DataResponse(['error' => 'Insufficient permissions to edit this post'], Http::STATUS_FORBIDDEN);
}
@@ -341,12 +352,13 @@ class PostController extends OCSController {
$post = $this->postMapper->find($id);
// Check if user is the author OR has moderator permission
// Check if user is the author OR has moderator permission OR is admin/moderator
$isAuthor = $post->getAuthorId() === $user->getUID();
$categoryId = $this->permissionService->getCategoryIdFromPost($id);
$isModerator = $this->permissionService->hasCategoryPermission($user->getUID(), $categoryId, 'canModerate');
$isAdminOrMod = $this->permissionService->hasAdminOrModeratorRole($user->getUID());
if (!$isAuthor && !$isModerator) {
if (!$isAuthor && !$isModerator && !$isAdminOrMod) {
return new DataResponse(['error' => 'Insufficient permissions to delete this post'], Http::STATUS_FORBIDDEN);
}
@@ -355,17 +367,53 @@ class PostController extends OCSController {
$post->setUpdatedAt(time());
$this->postMapper->update($post);
// Update thread post count
// Update thread post count and lastPostId
try {
$thread = $this->threadMapper->find($post->getThreadId());
$thread->setPostCount(max(0, $thread->getPostCount() - 1));
$thread->setUpdatedAt(time());
// If the deleted post was the last post, update lastPostId to the previous non-deleted post
if ($thread->getLastPostId() === $post->getId()) {
// Find the latest non-deleted post in this thread (excluding the one being deleted)
$latestPost = $this->postMapper->findLatestByThreadId($thread->getId(), $post->getId());
if ($latestPost) {
$thread->setLastPostId($latestPost->getId());
} else {
// No other posts in thread, set to null (or keep first post ID)
$thread->setLastPostId(null);
}
}
$this->threadMapper->update($thread);
} catch (\Exception $e) {
$this->logger->warning('Failed to update thread post count after post deletion: ' . $e->getMessage());
$this->logger->warning('Failed to update thread after post deletion: ' . $e->getMessage());
// Don't fail the request if thread update fails
}
// Update user stats - decrement post count, and thread count if it's the first post
try {
$this->userStatsMapper->decrementPostCount($post->getAuthorId());
// If this is the first post of a thread, also decrement thread count
if ($post->getIsFirstPost()) {
$this->userStatsMapper->decrementThreadCount($post->getAuthorId());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after post deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
// Update category post count
try {
$category = $this->categoryMapper->find($categoryId);
$category->setPostCount(max(0, $category->getPostCount() - 1));
$this->categoryMapper->update($category);
} catch (\Exception $e) {
$this->logger->warning('Failed to update category post count after post deletion: ' . $e->getMessage());
// Don't fail the request if category update fails
}
return new DataResponse(['success' => true]);
} catch (DoesNotExistException $e) {
return new DataResponse(['error' => 'Post not found'], Http::STATUS_NOT_FOUND);

View File

@@ -8,6 +8,7 @@ declare(strict_types=1);
namespace OCA\Forum\Controller;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Service\NotificationService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -23,6 +24,7 @@ class ReadMarkerController extends OCSController {
string $appName,
IRequest $request,
private ReadMarkerMapper $readMarkerMapper,
private NotificationService $notificationService,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -136,6 +138,18 @@ class ReadMarkerController extends OCSController {
$lastReadPostId
);
// Dismiss notifications if the user has caught up with the thread
try {
$this->notificationService->dismissNotificationsIfRead(
$user->getUID(),
$threadId,
$lastReadPostId
);
} catch (\Exception $e) {
$this->logger->warning('Failed to dismiss notifications: ' . $e->getMessage());
// Don't fail the request if notification dismissal fails
}
return new DataResponse($marker->jsonSerialize());
} catch (\Exception $e) {
$this->logger->error('Error marking thread as read: ' . $e->getMessage());

View File

@@ -13,6 +13,7 @@ use OCA\Forum\Db\Post;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\Thread;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCA\Forum\Db\UserStatsMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
@@ -32,6 +33,7 @@ class ThreadController extends OCSController {
private CategoryMapper $categoryMapper,
private PostMapper $postMapper,
private UserStatsMapper $userStatsMapper,
private ThreadSubscriptionMapper $threadSubscriptionMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
@@ -244,6 +246,13 @@ class ThreadController extends OCSController {
$this->logger->warning('Failed to update user stats: ' . $e->getMessage());
}
// Auto-subscribe the thread creator to receive notifications
try {
$this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId());
} catch (\Exception $e) {
$this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage());
}
return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED);
} catch (\Exception $e) {
$this->logger->error('Error creating thread: ' . $e->getMessage());
@@ -387,6 +396,18 @@ class ThreadController extends OCSController {
// Don't fail the request if category update fails
}
// Update author's user stats (decrement thread count and all posts in this thread)
try {
$this->userStatsMapper->decrementThreadCount($thread->getAuthorId());
// Decrement post count by the number of posts in this thread
if ($thread->getPostCount() > 0) {
$this->userStatsMapper->decrementPostCount($thread->getAuthorId(), $thread->getPostCount());
}
} catch (\Exception $e) {
$this->logger->warning('Failed to update user stats after thread deletion: ' . $e->getMessage());
// Don't fail the request if stats update fails
}
return new DataResponse([
'success' => true,
'categorySlug' => $categorySlug,

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Controller;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class ThreadSubscriptionController extends OCSController {
public function __construct(
string $appName,
IRequest $request,
private ThreadSubscriptionMapper $subscriptionMapper,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Subscribe current user to a thread to receive notifications
*
* @param int $threadId Thread ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: User subscribed to thread
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'POST', url: '/api/threads/{threadId}/subscribe')]
public function subscribe(int $threadId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$subscription = $this->subscriptionMapper->subscribe($user->getUID(), $threadId);
return new DataResponse([
'success' => true,
'subscription' => $subscription->jsonSerialize(),
]);
} catch (\Exception $e) {
$this->logger->error('Error subscribing user to thread: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to subscribe to thread'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Unsubscribe current user from a thread
*
* @param int $threadId Thread ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: User unsubscribed from thread
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'DELETE', url: '/api/threads/{threadId}/subscribe')]
public function unsubscribe(int $threadId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$this->subscriptionMapper->unsubscribe($user->getUID(), $threadId);
return new DataResponse(['success' => true]);
} catch (\Exception $e) {
$this->logger->error('Error unsubscribing user from thread: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to unsubscribe from thread'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Check if current user is subscribed to a thread
*
* @param int $threadId Thread ID
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>
*
* 200: Subscription status returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/threads/{threadId}/subscribe')]
public function isSubscribed(int $threadId): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$isSubscribed = $this->subscriptionMapper->isUserSubscribed($user->getUID(), $threadId);
return new DataResponse(['isSubscribed' => $isSubscribed]);
} catch (\Exception $e) {
$this->logger->error('Error checking thread subscription status: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to check subscription status'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get all threads the current user is subscribed to
*
* @return DataResponse<Http::STATUS_OK, list<array<string, mixed>>, array{}>
*
* 200: Thread subscriptions returned
*/
#[NoAdminRequired]
#[ApiRoute(verb: 'GET', url: '/api/thread-subscriptions')]
public function getUserSubscriptions(): DataResponse {
try {
$user = $this->userSession->getUser();
if (!$user) {
return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED);
}
$subscriptions = $this->subscriptionMapper->findByUserId($user->getUID());
return new DataResponse(array_map(fn ($r) => $r->jsonSerialize(), $subscriptions));
} catch (\Exception $e) {
$this->logger->error('Error fetching user thread subscriptions: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to fetch thread subscriptions'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Cron;
use OCA\Forum\Service\UserStatsService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
class RebuildUserStatsTask extends TimedJob {
public function __construct(
ITimeFactory $time,
private UserStatsService $userStatsService,
private LoggerInterface $logger,
) {
parent::__construct($time);
// Run once a week (604800 seconds = 7 days)
$this->setInterval(604800);
}
protected function run($arguments): void {
$this->logger->info('Starting weekly user stats rebuild for all users');
$result = $this->userStatsService->createStatsForAllUsers();
$this->logger->info('User stats rebuild completed', [
'users' => $result['users'],
'created' => $result['created'],
'updated' => $result['updated'],
]);
}
}

View File

@@ -87,22 +87,26 @@ class PostMapper extends QBMapper {
public function findByAuthorId(string $authorId, int $limit = 50, int $offset = 0, bool $excludeFirstPosts = false): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
$qb->expr()->eq('p.author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR))
)
->andWhere(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
if ($excludeFirstPosts) {
$qb->andWhere(
$qb->expr()->eq('is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
$qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))
);
}
$qb->orderBy('created_at', 'DESC')
$qb->orderBy('p.created_at', 'DESC')
->setMaxResults($limit)
->setFirstResult($offset);
return $this->findEntities($qb);
@@ -114,12 +118,16 @@ class PostMapper extends QBMapper {
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
$qb->select('p.*')
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->orderBy('created_at', 'DESC');
->andWhere(
$qb->expr()->isNull('t.deleted_at')
)
->orderBy('p.created_at', 'DESC');
return $this->findEntities($qb);
}
@@ -129,9 +137,13 @@ class PostMapper extends QBMapper {
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
$result = $qb->executeQuery();
$row = $result->fetch();
@@ -145,10 +157,14 @@ class PostMapper extends QBMapper {
public function countSince(int $timestamp): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->gte('created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->from($this->getTableName(), 'p')
->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
->where($qb->expr()->gte('p.created_at', $qb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)))
->andWhere(
$qb->expr()->isNull('deleted_at')
$qb->expr()->isNull('p.deleted_at')
)
->andWhere(
$qb->expr()->isNull('t.deleted_at')
);
$result = $qb->executeQuery();
$row = $result->fetch();
@@ -156,6 +172,58 @@ class PostMapper extends QBMapper {
return (int)($row['count'] ?? 0);
}
/**
* Find the latest non-deleted post in a thread, excluding a specific post ID
*
* @param int $threadId Thread ID
* @param int|null $excludePostId Post ID to exclude (typically the one being deleted)
* @return Post|null Latest post or null if no posts found
*/
public function findLatestByThreadId(int $threadId, ?int $excludePostId = null): ?Post {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'));
if ($excludePostId !== null) {
$qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($excludePostId, IQueryBuilder::PARAM_INT)));
}
$qb->orderBy('created_at', 'DESC')
->setMaxResults(1);
try {
return $this->findEntity($qb);
} catch (DoesNotExistException $e) {
return null;
}
}
/**
* Count unread posts in a thread after a specific post ID
*
* @param int $threadId Thread ID
* @param int $afterPostId Post ID to count after (0 to count all posts)
* @return int Number of posts after the given post ID
*/
public function countUnreadInThread(int $threadId, int $afterPostId = 0): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->isNull('deleted_at'));
if ($afterPostId > 0) {
$qb->andWhere($qb->expr()->gt('id', $qb->createNamedParameter($afterPostId, IQueryBuilder::PARAM_INT)));
}
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
return (int)($row['count'] ?? 0);
}
/**
* Search posts by content (replies only, excluding first posts)
*

View File

@@ -114,6 +114,21 @@ class Thread extends Entity implements JsonSerializable {
$thread['categoryName'] = null;
}
// Add subscription status for the current user
try {
$userSession = \OC::$server->get(\OCP\IUserSession::class);
$user = $userSession->getUser();
if ($user) {
$subscriptionMapper = \OC::$server->get(\OCA\Forum\Db\ThreadSubscriptionMapper::class);
$thread['isSubscribed'] = $subscriptionMapper->isUserSubscribed($user->getUID(), $thread['id']);
} else {
$thread['isSubscribed'] = false;
}
} catch (\Exception $e) {
// If there's an error checking subscription, default to false
$thread['isSubscribed'] = false;
}
return $thread;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $value)
* @method string getUserId()
* @method void setUserId(string $value)
* @method int getThreadId()
* @method void setThreadId(int $value)
* @method int getCreatedAt()
* @method void setCreatedAt(int $value)
*/
class ThreadSubscription extends Entity implements JsonSerializable {
protected $userId;
protected $threadId;
protected $createdAt;
public function __construct() {
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('threadId', 'integer');
$this->addType('createdAt', 'integer');
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'userId' => $this->getUserId(),
'threadId' => $this->getThreadId(),
'createdAt' => $this->getCreatedAt(),
];
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Db;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<ThreadSubscription>
*/
class ThreadSubscriptionMapper extends QBMapper {
public function __construct(
IDBConnection $db,
) {
parent::__construct($db, Application::tableName('forum_thread_subs'), ThreadSubscription::class);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function find(int $id): ThreadSubscription {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
* @throws DoesNotExistException
*/
public function findByUserAndThread(string $userId, int $threadId): ThreadSubscription {
/* @var $qb IQueryBuilder */
$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('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
);
return $this->findEntity($qb);
}
/**
* Check if a user is subscribed to a thread
*/
public function isUserSubscribed(string $userId, int $threadId): bool {
try {
$this->findByUserAndThread($userId, $threadId);
return true;
} catch (DoesNotExistException $e) {
return false;
}
}
/**
* Get all subscribed users for a thread
*
* @return array<ThreadSubscription>
*/
public function findByThread(int $threadId): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('thread_id', $qb->createNamedParameter($threadId, IQueryBuilder::PARAM_INT))
);
return $this->findEntities($qb);
}
/**
* Get all thread subscriptions for a user
*
* @return array<ThreadSubscription>
*/
public function findByUserId(string $userId): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->getTableName())
->where(
$qb->expr()->eq('user_id', $qb->createNamedParameter($userId, IQueryBuilder::PARAM_STR))
);
return $this->findEntities($qb);
}
/**
* Subscribe a user to a thread
*/
public function subscribe(string $userId, int $threadId): ThreadSubscription {
// Check if already subscribed
if ($this->isUserSubscribed($userId, $threadId)) {
return $this->findByUserAndThread($userId, $threadId);
}
// Create new subscription
$subscription = new ThreadSubscription();
$subscription->setUserId($userId);
$subscription->setThreadId($threadId);
$subscription->setCreatedAt(time());
return $this->insert($subscription);
}
/**
* Unsubscribe a user from a thread
*/
public function unsubscribe(string $userId, int $threadId): void {
try {
$subscription = $this->findByUserAndThread($userId, $threadId);
$this->delete($subscription);
} catch (DoesNotExistException $e) {
// Already not subscribed, nothing to do
}
}
/**
* @return array<ThreadSubscription>
*/
public function findAll(): array {
/* @var $qb IQueryBuilder */
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName());
return $this->findEntities($qb);
}
}

View File

@@ -11,6 +11,8 @@ use JsonSerializable;
use OCP\AppFramework\Db\Entity;
/**
* @method int getId()
* @method void setId(int $id)
* @method string getUserId()
* @method void setUserId(string $userId)
* @method int getPostCount()
@@ -27,6 +29,7 @@ use OCP\AppFramework\Db\Entity;
* @method void setUpdatedAt(int $updatedAt)
*/
class UserStats extends Entity implements JsonSerializable {
public $id;
protected string $userId = '';
protected int $postCount = 0;
protected int $threadCount = 0;
@@ -36,7 +39,7 @@ class UserStats extends Entity implements JsonSerializable {
protected int $updatedAt = 0;
public function __construct() {
// User ID is the primary key, not an auto-increment id
$this->addType('id', 'integer');
$this->addType('userId', 'string');
$this->addType('postCount', 'integer');
$this->addType('threadCount', 'integer');

View File

@@ -8,27 +8,47 @@ declare(strict_types=1);
namespace OCA\Forum\Listener;
use OCA\Forum\Db\UserStatsMapper;
use OCA\Forum\Service\UserStatsService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\User\Events\UserCreatedEvent;
use OCP\User\Events\UserDeletedEvent;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<UserDeletedEvent>
* @template-implements IEventListener<UserCreatedEvent|UserDeletedEvent>
*/
class UserEventListener implements IEventListener {
public function __construct(
private UserStatsMapper $userStatsMapper,
private UserStatsService $userStatsService,
private LoggerInterface $logger,
) {
}
public function handle(Event $event): void {
if ($event instanceof UserDeletedEvent) {
if ($event instanceof UserCreatedEvent) {
$this->handleUserCreated($event);
} elseif ($event instanceof UserDeletedEvent) {
$this->handleUserDeleted($event);
}
}
private function handleUserCreated(UserCreatedEvent $event): void {
$user = $event->getUser();
$userId = $user->getUID();
try {
// Create user stats with zero counts for new user
$this->userStatsService->rebuildUserStats($userId);
$this->logger->info("Created user stats for new Nextcloud user: {$userId}");
} catch (\Exception $ex) {
$this->logger->error("Failed to create user stats for new user: {$userId}", [
'exception' => $ex->getMessage(),
]);
}
}
private function handleUserDeleted(UserDeletedEvent $event): void {
$user = $event->getUser();
$userId = $user->getUID();

View File

@@ -651,7 +651,7 @@ class Version1Date20251106004226 extends SimpleMigrationStep {
'name' => $qb->createNamedParameter('Support'),
'description' => $qb->createNamedParameter('Ask questions about the forum, provide feedback or report issues.'),
'slug' => $qb->createNamedParameter('support'),
'sort_order' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'sort_order' => $qb->createNamedParameter(1, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'thread_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $qb->createNamedParameter(0, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),

View File

@@ -0,0 +1,119 @@
<?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 OCA\Forum\Service\UserStatsService;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version2Date20251114222614 extends SimpleMigrationStep {
public function __construct(
private UserStatsService $userStatsService,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$this->createForumThreadSubsTable($schema);
$this->fixForumUserStatsTable($schema);
return $schema;
}
private function createForumThreadSubsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('forum_thread_subs')) {
return;
}
$table = $schema->createTable('forum_thread_subs');
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('user_id', 'string', [
'notnull' => true,
'length' => 64,
]);
$table->addColumn('thread_id', 'bigint', [
'notnull' => true,
'unsigned' => true,
]);
$table->addColumn('created_at', 'integer', [
'notnull' => true,
'unsigned' => true,
]);
$table->setPrimaryKey(['id']);
$table->addIndex(['user_id'], 'thread_subs_uid_idx');
$table->addIndex(['thread_id'], 'thread_subs_tid_idx');
$table->addUniqueIndex(['user_id', 'thread_id'], 'thread_subs_uniq_idx');
}
private function fixForumUserStatsTable(ISchemaWrapper $schema): void {
if (!$schema->hasTable('forum_user_stats')) {
return;
}
$table = $schema->getTable('forum_user_stats');
// Check if already fixed (has id column)
if ($table->hasColumn('id')) {
return;
}
// Add id column as auto-increment
$table->addColumn('id', 'bigint', [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
// Drop the old primary key on user_id
$table->dropPrimaryKey();
// Set id as the new primary key
$table->setPrimaryKey(['id']);
// Add unique index on user_id (since it's no longer the primary key)
if (!$table->hasIndex('user_stats_user_id_uniq')) {
$table->addUniqueIndex(['user_id'], 'user_stats_user_id_uniq');
}
// Add thread_count index
if (!$table->hasIndex('user_stats_thread_count_idx')) {
$table->addIndex(['thread_count'], 'user_stats_thread_count_idx');
}
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
$output->info('Creating user statistics for all users...');
$result = $this->userStatsService->createStatsForAllUsers();
$output->info(sprintf('Processed %d users', $result['users']));
$output->info(sprintf('Created %d new user stats', $result['created']));
$output->info(sprintf('Updated %d existing user stats', $result['updated']));
$output->info('User statistics created successfully!');
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Notification;
use OCA\Forum\AppInfo\Application;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
class Notifier implements INotifier {
public function __construct(
private IFactory $l10nFactory,
) {
}
/**
* Identifier of the notifier, only use [a-z0-9_]
*/
public function getID(): string {
return Application::APP_ID;
}
/**
* Human-readable name describing the notifier
*/
public function getName(): string {
return $this->l10nFactory->get(Application::APP_ID)->t('Forum');
}
/**
* Prepare the notification for display
*
* @param INotification $notification
* @param string $languageCode The code of the language that should be used to prepare the notification
* @return INotification
* @throws \InvalidArgumentException When the notification was not prepared by this app or is not of the expected type
*/
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_ID) {
throw new UnknownNotificationException();
}
$l = $this->l10nFactory->get(Application::APP_ID, $languageCode);
switch ($notification->getSubject()) {
case 'new_posts':
$parameters = $notification->getSubjectParameters();
$threadId = $parameters['threadId'] ?? 0;
$threadTitle = $parameters['threadTitle'] ?? 'Unknown Thread';
$postCount = $parameters['postCount'] ?? 1;
// Set the rich subject with thread title
$notification->setRichSubject(
$l->n(
'New reply in {thread}',
'{count} new replies in {thread}',
$postCount
),
[
'thread' => [
'type' => 'highlight',
'id' => (string)$threadId,
'name' => $threadTitle,
],
'count' => [
'type' => 'highlight',
'id' => (string)$postCount,
'name' => (string)$postCount,
],
]
);
// Set the parsed subject from rich subject
$this->setParsedSubjectFromRichSubject($notification);
return $notification;
default:
throw new UnknownNotificationException();
}
}
/**
* Helper function to set the parsed subject from the rich subject
* This extracts the parameter names from rich subject placeholders
*
* @param INotification $notification
*/
protected function setParsedSubjectFromRichSubject(INotification $notification): void {
$placeholders = $replacements = [];
$richParams = $notification->getRichSubjectParameters();
$richSubject = $notification->getRichSubject();
foreach ($richParams as $placeholder => $parameter) {
$placeholders[] = '{' . $placeholder . '}';
if (isset($parameter['type']) && $parameter['type'] === 'file') {
$replacements[] = $parameter['path'] ?? $parameter['name'] ?? '';
} else {
$replacements[] = $parameter['name'] ?? '';
}
}
$parsedSubject = str_replace($placeholders, $replacements, $richSubject);
$notification->setParsedSubject($parsedSubject);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\ReadMarkerMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\ThreadSubscriptionMapper;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IURLGenerator;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;
class NotificationService {
public function __construct(
private INotificationManager $notificationManager,
private ThreadSubscriptionMapper $subscriptionMapper,
private ThreadMapper $threadMapper,
private PostMapper $postMapper,
private ReadMarkerMapper $readMarkerMapper,
private IURLGenerator $urlGenerator,
private LoggerInterface $logger,
) {
}
/**
* Notify subscribed users when a new post is added to a thread
*/
public function notifyThreadSubscribers(int $threadId, int $postId, string $authorId): void {
// Get all subscribed users for this thread
$subscriptions = $this->subscriptionMapper->findByThread($threadId);
// Get thread information
try {
$thread = $this->threadMapper->find($threadId);
} catch (\Exception $e) {
$this->logger->warning('Thread not found for notifications', [
'threadId' => $threadId,
'error' => $e->getMessage(),
]);
return;
}
foreach ($subscriptions as $subscription) {
$userId = $subscription->getUserId();
// Don't notify the author of the post
if ($userId === $authorId) {
continue;
}
// Create or update notification (collating multiple posts)
$this->createOrUpdateNotification($userId, $threadId, $postId, $thread->getTitle(), $thread->getSlug());
}
}
/**
* Create or update a notification for a user about a thread
* This allows collating multiple posts into a single notification
*/
private function createOrUpdateNotification(string $userId, int $threadId, int $postId, string $threadTitle, string $threadSlug): void {
// Calculate the number of unread posts
$postCount = $this->getUnreadPostCount($userId, $threadId, $postId);
// Mark existing notifications for this thread/user as processed (to update them)
$existingNotification = $this->notificationManager->createNotification();
$existingNotification->setApp('forum')
->setUser($userId)
->setObject('thread', (string)$threadId)
->setSubject('new_posts');
$this->notificationManager->markProcessed($existingNotification);
// Create new notification with updated post count
$notification = $this->notificationManager->createNotification();
// Generate the thread link and icon
$threadLink = $this->urlGenerator->linkToRouteAbsolute('forum.page.index') . 't/' . $threadSlug;
$iconPath = $this->urlGenerator->imagePath('forum', 'app-dark.svg');
$iconUrl = $this->urlGenerator->getAbsoluteURL($iconPath);
$notification->setApp('forum')
->setUser($userId)
->setDateTime(new \DateTime())
->setObject('thread', (string)$threadId)
->setSubject('new_posts', [
'threadId' => $threadId,
'threadTitle' => $threadTitle,
'threadSlug' => $threadSlug,
'lastPostId' => $postId,
'postCount' => $postCount,
])
->setLink($threadLink)
->setIcon($iconUrl);
$this->notificationManager->notify($notification);
}
/**
* Get the count of unread posts for a user in a thread
* Uses an efficient DB COUNT query instead of fetching all posts
*/
private function getUnreadPostCount(string $userId, int $threadId, int $latestPostId): int {
try {
// Get the user's read marker for this thread
$readMarker = $this->readMarkerMapper->findByUserAndThread($userId, $threadId);
$lastReadPostId = $readMarker->getLastReadPostId();
// Count posts after the last read post using DB query
$unreadCount = $this->postMapper->countUnreadInThread($threadId, $lastReadPostId);
return max(1, $unreadCount); // At least 1 (the current post)
} catch (DoesNotExistException $e) {
// No read marker, count all posts in the thread
$count = $this->postMapper->countUnreadInThread($threadId, 0);
return max(1, $count); // At least 1
}
}
/**
* Dismiss notifications for a user when they view a thread
*/
public function dismissThreadNotifications(string $userId, int $threadId): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp('forum')
->setUser($userId)
->setObject('thread', (string)$threadId);
$this->notificationManager->markProcessed($notification);
}
/**
* Dismiss notifications when read marker catches up
*/
public function dismissNotificationsIfRead(string $userId, int $threadId, int $lastReadPostId): void {
// Get the thread to check the last post
try {
$thread = $this->threadMapper->find($threadId);
$lastPostId = $thread->getLastPostId();
// If user has read up to or past the last post, dismiss notifications
if ($lastPostId && $lastReadPostId >= $lastPostId) {
$this->dismissThreadNotifications($userId, $threadId);
}
} catch (\Exception $e) {
// Thread not found or error, just dismiss anyway
$this->dismissThreadNotifications($userId, $threadId);
}
}
}

View File

@@ -28,6 +28,31 @@ class PermissionService {
) {
}
/**
* Check if user has Admin or Moderator role
*
* @param string $userId Nextcloud user ID
* @return bool True if user has Admin (roleId 1) or Moderator (roleId 2) role
*/
public function hasAdminOrModeratorRole(string $userId): bool {
try {
$userRoles = $this->userRoleMapper->findByUserId($userId);
foreach ($userRoles as $userRole) {
$roleId = $userRole->getRoleId();
// Admin role = 1, Moderator role = 2
if ($roleId === 1 || $roleId === 2) {
return true;
}
}
return false;
} catch (\Exception $e) {
$this->logger->error("Error checking admin/moderator role for user $userId: " . $e->getMessage());
return false;
}
}
/**
* Check if user has global permission
*

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\Forum\Service;
use OCP\IDBConnection;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
class UserStatsService {
public function __construct(
private IDBConnection $db,
private IUserManager $userManager,
private LoggerInterface $logger,
) {
}
/**
* Create user statistics for all users in the system (including those who haven't posted)
*
* @return array{users: int, updated: int, created: int} Statistics about the creation
*/
public function createStatsForAllUsers(): array {
// Get all user IDs from Nextcloud
$users = [];
$this->userManager->callForAllUsers(function ($user) use (&$users) {
$users[] = $user->getUID();
});
$updated = 0;
$created = 0;
foreach ($users as $userId) {
$wasCreated = $this->rebuildUserStats($userId);
if ($wasCreated) {
$created++;
} else {
$updated++;
}
}
return [
'users' => count($users),
'updated' => $updated,
'created' => $created,
];
}
/**
* Rebuild user statistics from actual post and thread counts
*
* @return array{users: int, updated: int, created: int} Statistics about the rebuild
*/
public function rebuildAllUserStats(): array {
// Delegate to createStatsForAllUsers which processes all Nextcloud users
return $this->createStatsForAllUsers();
}
/**
* Rebuild statistics for a single user
*
* @param string $userId The user ID to rebuild stats for
* @return bool True if stats were created, false if they were updated
*/
public function rebuildUserStats(string $userId): bool {
// Count non-deleted threads created by this user
$threadQb = $this->db->getQueryBuilder();
$threadQb->select($threadQb->func()->count('*', 'count'))
->from('forum_threads')
->where($threadQb->expr()->eq('author_id', $threadQb->createNamedParameter($userId)))
->andWhere($threadQb->expr()->isNull('deleted_at'));
$threadResult = $threadQb->executeQuery();
$threadCount = (int)($threadResult->fetchOne() ?? 0);
$threadResult->closeCursor();
// Count non-deleted posts created by this user (from non-deleted threads)
$postQb = $this->db->getQueryBuilder();
$postQb->select($postQb->func()->count('*', 'count'))
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $postQb->expr()->eq('p.thread_id', 't.id'))
->where($postQb->expr()->eq('p.author_id', $postQb->createNamedParameter($userId)))
->andWhere($postQb->expr()->isNull('p.deleted_at'))
->andWhere($postQb->expr()->isNull('t.deleted_at'));
$postResult = $postQb->executeQuery();
$postCount = (int)($postResult->fetchOne() ?? 0);
$postResult->closeCursor();
// Get the timestamp of the last non-deleted post (from non-deleted threads)
$lastPostQb = $this->db->getQueryBuilder();
$lastPostQb->select('p.created_at')
->from('forum_posts', 'p')
->innerJoin('p', 'forum_threads', 't', $lastPostQb->expr()->eq('p.thread_id', 't.id'))
->where($lastPostQb->expr()->eq('p.author_id', $lastPostQb->createNamedParameter($userId)))
->andWhere($lastPostQb->expr()->isNull('p.deleted_at'))
->andWhere($lastPostQb->expr()->isNull('t.deleted_at'))
->orderBy('p.created_at', 'DESC')
->setMaxResults(1);
$lastPostResult = $lastPostQb->executeQuery();
$lastPostAt = $lastPostResult->fetchOne();
$lastPostResult->closeCursor();
// Check if user stats already exist
$checkQb = $this->db->getQueryBuilder();
$checkQb->select('id')
->from('forum_user_stats')
->where($checkQb->expr()->eq('user_id', $checkQb->createNamedParameter($userId)));
$checkResult = $checkQb->executeQuery();
$exists = $checkResult->fetch();
$checkResult->closeCursor();
$timestamp = time();
if ($exists) {
// Update existing stats
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats')
->set('thread_count', $updateQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('user_id', $updateQb->createNamedParameter($userId)));
if ($lastPostAt) {
$updateQb->set('last_post_at', $updateQb->createNamedParameter((int)$lastPostAt, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT));
}
$updateQb->executeStatement();
return false;
} else {
// Create new stats
$insertQb = $this->db->getQueryBuilder();
$insertQb->insert('forum_user_stats')
->values([
'user_id' => $insertQb->createNamedParameter($userId),
'thread_count' => $insertQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'post_count' => $insertQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'last_post_at' => $insertQb->createNamedParameter($lastPostAt ? (int)$lastPostAt : null, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'created_at' => $insertQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
'updated_at' => $insertQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT),
]);
try {
$insertQb->executeStatement();
return true;
} catch (\Exception $e) {
// If insert fails (race condition), try updating instead
$this->logger->warning('Failed to create user stats, attempting update', [
'userId' => $userId,
'exception' => $e->getMessage(),
]);
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('forum_user_stats')
->set('thread_count', $updateQb->createNamedParameter($threadCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('post_count', $updateQb->createNamedParameter($postCount, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->set('updated_at', $updateQb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT))
->where($updateQb->expr()->eq('user_id', $updateQb->createNamedParameter($userId)));
if ($lastPostAt) {
$updateQb->set('last_post_at', $updateQb->createNamedParameter((int)$lastPostAt, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT));
}
$updateQb->executeStatement();
return false;
}
}
}
}

View File

@@ -7802,6 +7802,403 @@
}
}
},
"/ocs/v2.php/apps/forum/api/threads/{threadId}/subscribe": {
"post": {
"operationId": "thread_subscription-subscribe",
"summary": "Subscribe current user to a thread to receive notifications",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User subscribed to thread",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "thread_subscription-unsubscribe",
"summary": "Unsubscribe current user from a thread",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "User unsubscribed from thread",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"get": {
"operationId": "thread_subscription-is-subscribed",
"summary": "Check if current user is subscribed to a thread",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "threadId",
"in": "path",
"description": "Thread ID",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Subscription status returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/thread-subscriptions": {
"get": {
"operationId": "thread_subscription-get-user-subscriptions",
"summary": "Get all threads the current user is subscribed to",
"tags": [
"thread_subscription"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Thread subscriptions returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/users/{userId}/roles": {
"get": {
"operationId": "user_role-by-user",

View File

@@ -142,8 +142,7 @@
<template #footer>
<div v-if="userId" class="sidebar-footer">
<NcAvatar :user="userId" :size="32" />
<span class="user-display-name">{{ displayName }}</span>
<UserInfo :user-id="userId" :display-name="displayName" :avatar-size="32" />
</div>
</template>
</NcAppNavigation>
@@ -156,7 +155,7 @@ import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import UserInfo from '@/components/UserInfo.vue'
import HomeIcon from '@icons/Home.vue'
import ForumIcon from '@icons/Forum.vue'
import FolderIcon from '@icons/Folder.vue'
@@ -182,7 +181,7 @@ export default defineComponent({
NcAppNavigationItem,
NcAppNavigationSearch,
NcActionButton,
NcAvatar,
UserInfo,
HomeIcon,
ForumIcon,
FolderIcon,
@@ -352,17 +351,6 @@ export default defineComponent({
<style scoped lang="scss">
.sidebar-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
.user-display-name {
font-weight: 500;
color: var(--color-main-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -2,30 +2,23 @@
<div class="post-card" :class="{ 'first-post': isFirstPost, unread: isUnread }">
<div class="post-header">
<div class="author-info">
<div v-if="!post.authorIsDeleted" class="avatar-link" @click.stop="navigateToProfile">
<NcAvatar :user="post.authorId" :size="32" />
</div>
<NcAvatar v-else :display-name="post.authorDisplayName" :size="32" />
<div class="author-details">
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<span
v-if="!post.authorIsDeleted"
class="author-name author-name-link"
@click.stop="navigateToProfile"
>
{{ post.authorDisplayName || post.authorId }}
</span>
<span v-else class="author-name deleted-user">
{{ post.authorDisplayName || post.authorId }}
</span>
<div class="post-meta">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
<span v-if="post.isEdited" class="edited-badge">
<span class="edited-label">{{ strings.edited }}</span>
<NcDateTime v-if="post.editedAt" :timestamp="post.editedAt * 1000" />
</span>
</div>
</div>
<span v-if="isUnread" class="unread-indicator" :title="strings.unread"></span>
<UserInfo
:user-id="post.authorId"
:display-name="post.authorDisplayName || post.authorId"
:is-deleted="post.authorIsDeleted"
:avatar-size="32"
>
<template #meta>
<div class="post-meta">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
<span v-if="post.isEdited" class="edited-badge">
<span class="edited-label">{{ strings.edited }}</span>
<NcDateTime v-if="post.editedAt" :timestamp="post.editedAt * 1000" />
</span>
</div>
</template>
</UserInfo>
</div>
<div class="post-actions">
<NcActions ref="actionsMenu">
@@ -77,30 +70,31 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import ReplyIcon from '@icons/Reply.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import UserInfo from './UserInfo.vue'
import PostReactions from './PostReactions.vue'
import PostEditForm from './PostEditForm.vue'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { useUserRole } from '@/composables/useUserRole'
import type { Post } from '@/types'
import type { ReactionGroup } from '@/composables/useReactions'
export default defineComponent({
name: 'PostCard',
components: {
NcAvatar,
NcDateTime,
NcActions,
NcActionButton,
ReplyIcon,
PencilIcon,
DeleteIcon,
UserInfo,
PostReactions,
PostEditForm,
},
@@ -119,6 +113,14 @@ export default defineComponent({
},
},
emits: ['reply', 'edit', 'delete', 'update'],
setup() {
const { isAdmin, isModerator } = useUserRole()
return {
isAdmin,
isModerator,
}
},
data() {
return {
isEditing: false,
@@ -140,11 +142,20 @@ export default defineComponent({
return getCurrentUser()
},
canEdit(): boolean {
return this.currentUser !== null && this.currentUser.uid === this.post.authorId
// Authors can edit their own posts
// Admins and moderators can edit any post
if (!this.currentUser) {
return false
}
return this.currentUser.uid === this.post.authorId || this.isAdmin || this.isModerator
},
canDelete(): boolean {
// For now, only author can delete. Later add admin/moderator check
return this.currentUser !== null && this.currentUser.uid === this.post.authorId
// Authors can delete their own posts
// Admins and moderators can delete any post
if (!this.currentUser) {
return false
}
return this.currentUser.uid === this.post.authorId || this.isAdmin || this.isModerator
},
formattedContent(): string {
// Content is already parsed by BBCodeService on the backend
@@ -160,10 +171,6 @@ export default defineComponent({
}
},
navigateToProfile() {
this.$router.push(`/u/${this.post.authorId}`)
},
handleReply() {
this.closeActionsMenu()
this.$emit('reply', this.post)
@@ -274,46 +281,12 @@ export default defineComponent({
align-items: flex-start;
gap: 12px;
flex: 1;
}
.author-details {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
.unread-indicator {
position: absolute;
left: -14px;
top: 6px;
}
}
.avatar-link {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
.author-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
&.author-name-link {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
left: 0;
top: 8px;
}
}

View File

@@ -23,11 +23,16 @@
>
<span class="icon">+</span>
</button>
</div>
<!-- Emoji picker -->
<!-- Emoji picker (teleported to body for proper fixed positioning) -->
<Teleport to="body">
<Transition name="fade">
<div v-if="showPicker" class="emoji-picker-overlay" @click="closePicker">
<div class="emoji-picker-container" @click.stop>
<button class="emoji-picker-close" :title="strings.close" @click="closePicker">
<Close :size="20" />
</button>
<div class="emoji-picker-content">
<h3>{{ strings.pickEmoji }}</h3>
<div class="emoji-categories">
@@ -50,7 +55,7 @@
</div>
</div>
</Transition>
</div>
</Teleport>
</div>
</template>
@@ -60,9 +65,13 @@ import { t, n } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { useReactions, type ReactionGroup } from '@/composables/useReactions'
import { EMOJI_GROUPS } from '@/constants/emojis'
import Close from '@icons/Close.vue'
export default defineComponent({
name: 'PostReactions',
components: {
Close,
},
props: {
postId: {
type: Number,
@@ -86,6 +95,7 @@ export default defineComponent({
strings: {
addReaction: t('forum', 'Add reaction'),
pickEmoji: t('forum', 'Pick an emoji'),
close: t('forum', 'Close'),
},
emojiGroups: EMOJI_GROUPS,
}
@@ -363,112 +373,6 @@ export default defineComponent({
color: var(--color-main-text);
}
}
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
.emoji-picker-container {
background: var(--color-main-background);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
.emoji-picker-content {
padding: 20px;
h3 {
margin: 0 0 16px 0;
font-size: 1.1rem;
color: var(--color-main-text);
}
.emoji-categories {
max-height: 500px;
overflow-y: auto;
padding: 4px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--color-background-dark);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-dark);
border-radius: 4px;
&:hover {
background: var(--color-text-maxcontrast);
}
}
.emoji-category {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.category-header {
margin: 0 0 12px 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
text-transform: uppercase;
letter-spacing: 0.5px;
padding-left: 4px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
.emoji-option {
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 8px;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-border);
transform: scale(1.15);
}
&:active {
transform: scale(0.9);
}
}
}
}
}
}
}
}
}
}
@@ -483,3 +387,141 @@ export default defineComponent({
opacity: 0;
}
</style>
<style lang="scss">
// Emoji picker overlay - not scoped because it's teleported to body
.emoji-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.2);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
.emoji-picker-container {
background: var(--color-main-background);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
max-width: 90vw;
max-height: 80vh;
overflow: hidden;
position: relative;
.emoji-picker-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--color-text-maxcontrast);
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
z-index: 1;
padding: 0;
&:hover {
background: var(--color-background-hover);
color: var(--color-main-text);
}
&:active {
transform: scale(0.9);
}
}
.emoji-picker-content {
padding: 20px;
h3 {
margin: 0 0 16px 0;
font-size: 1.1rem;
color: var(--color-main-text);
}
.emoji-categories {
max-height: 500px;
overflow-y: auto;
padding: 4px;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: var(--color-background-dark);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-dark);
border-radius: 4px;
&:hover {
background: var(--color-text-maxcontrast);
}
}
.emoji-category {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.category-header {
margin: 0 0 12px 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
text-transform: uppercase;
letter-spacing: 0.5px;
padding-left: 4px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
.emoji-option {
border: 1px solid transparent;
background: transparent;
border-radius: 8px;
padding: 8px;
font-size: 1.5rem;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-border);
transform: scale(1.15);
}
&:active {
transform: scale(0.9);
}
}
}
}
}
}
}
}
</style>

View File

@@ -1,11 +1,12 @@
<template>
<div class="post-reply-form">
<div class="reply-header">
<div class="user-info">
<NcAvatar v-if="userId" :user="userId" :size="40" />
<NcAvatar v-else :display-name="displayName" :size="40" />
<span class="user-name">{{ displayName }}</span>
</div>
<div v-if="userId" class="reply-header">
<UserInfo
:user-id="userId"
:display-name="displayName"
:avatar-size="40"
:clickable="false"
/>
</div>
<div class="reply-body">
@@ -38,10 +39,10 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import SendIcon from '@icons/Send.vue'
import UserInfo from './UserInfo.vue'
import BBCodeEditor from './BBCodeEditor.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -49,10 +50,10 @@ import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'PostReplyForm',
components: {
NcAvatar,
NcButton,
NcLoadingIcon,
SendIcon,
UserInfo,
BBCodeEditor,
},
emits: ['submit', 'cancel'],
@@ -145,18 +146,6 @@ export default defineComponent({
margin-bottom: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.reply-body {
display: flex;
flex-direction: column;

View File

@@ -18,23 +18,18 @@
</h4>
</div>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span
v-if="!thread.authorIsDeleted"
class="meta-value meta-value-link"
@click.stop="navigateToProfile"
>
{{ thread.authorDisplayName || thread.authorId }}
</span>
<span v-else class="meta-value deleted-user">
{{ thread.authorDisplayName || thread.authorId }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<UserInfo
:user-id="thread.authorId"
:display-name="thread.authorDisplayName || thread.authorId"
:is-deleted="thread.authorIsDeleted"
:avatar-size="32"
layout="inline"
@click.stop
>
<template #meta>
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</template>
</UserInfo>
</div>
</div>
@@ -61,6 +56,7 @@
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import UserInfo from '@/components/UserInfo.vue'
import PinIcon from '@icons/Pin.vue'
import LockIcon from '@icons/Lock.vue'
import CommentIcon from '@icons/Comment.vue'
@@ -72,6 +68,7 @@ export default defineComponent({
name: 'ThreadCard',
components: {
NcDateTime,
UserInfo,
PinIcon,
LockIcon,
CommentIcon,
@@ -90,7 +87,6 @@ export default defineComponent({
data() {
return {
strings: {
by: t('forum', 'by'),
replies: t('forum', 'Replies'),
views: t('forum', 'Views'),
pinned: t('forum', 'Pinned thread'),
@@ -99,11 +95,6 @@ export default defineComponent({
},
}
},
methods: {
navigateToProfile() {
this.$router.push(`/u/${this.thread.authorId}`)
},
},
})
</script>
@@ -120,10 +111,11 @@ export default defineComponent({
cursor: inherit;
}
&:hover {
&:hover,
&.unread:hover,
&.pinned:hover {
border-color: var(--color-primary-element);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
&.pinned {
@@ -143,7 +135,7 @@ export default defineComponent({
.thread-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
align-items: center;
gap: 16px;
}
@@ -205,39 +197,6 @@ export default defineComponent({
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-label {
font-style: italic;
}
.meta-value {
font-weight: 500;
color: var(--color-text-lighter);
&.meta-value-link {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
}
}
.meta-divider {
opacity: 0.5;
}
.thread-stats {
display: flex;
/* flex-direction: column; */

View File

@@ -1,11 +1,12 @@
<template>
<div class="thread-create-form">
<div class="form-header">
<div class="user-info">
<NcAvatar v-if="userId" :user="userId" :size="40" />
<NcAvatar v-else :display-name="displayName" :size="40" />
<span class="user-name">{{ displayName }}</span>
</div>
<div v-if="userId" class="form-header">
<UserInfo
:user-id="userId"
:display-name="displayName"
:avatar-size="40"
:clickable="false"
/>
</div>
<div class="form-body">
@@ -47,11 +48,11 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import CheckIcon from '@icons/Check.vue'
import UserInfo from './UserInfo.vue'
import BBCodeEditor from './BBCodeEditor.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -59,11 +60,11 @@ import { useCurrentUser } from '@/composables/useCurrentUser'
export default defineComponent({
name: 'ThreadCreateForm',
components: {
NcAvatar,
NcButton,
NcLoadingIcon,
NcTextField,
CheckIcon,
UserInfo,
BBCodeEditor,
},
emits: ['submit', 'cancel'],
@@ -156,18 +157,6 @@ export default defineComponent({
margin-bottom: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
}
.form-body {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,87 @@
<template>
<div
v-if="!isDeleted"
class="user-avatar"
:style="{ height: size + 'px' }"
:class="{ clickable: isClickable }"
@click="handleClick"
>
<NcAvatar :user="userId" :size="size" />
</div>
<div v-else class="user-avatar">
<NcAvatar :display-name="displayName" :size="size" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
export default defineComponent({
name: 'UserAvatar',
components: {
NcAvatar,
},
props: {
userId: {
type: String,
required: true,
},
displayName: {
type: String,
default: '',
},
size: {
type: Number,
default: 32,
},
isDeleted: {
type: Boolean,
default: false,
},
clickable: {
type: Boolean,
default: true,
},
},
emits: ['click'],
computed: {
isClickable(): boolean {
return this.clickable && !this.isDeleted
},
},
methods: {
handleClick(event: MouseEvent): void {
if (this.isClickable) {
event.stopPropagation()
this.$emit('click', this.userId)
this.$router.push(`/u/${this.userId}`)
}
},
},
})
</script>
<style scoped lang="scss">
.user-avatar {
&.clickable {
cursor: pointer !important;
:deep(.avatardiv) {
cursor: pointer !important;
}
:deep(.avatardiv *) {
cursor: pointer !important;
}
&:hover {
opacity: 0.8;
}
&:hover :deep(.avatardiv) {
opacity: 0.8;
}
}
}
</style>

146
src/components/UserInfo.vue Normal file
View File

@@ -0,0 +1,146 @@
<template>
<div class="user-info-component" :class="{ 'layout-inline': layout === 'inline' }">
<UserAvatar
:user-id="userId"
:display-name="displayName"
:size="avatarSize"
:is-deleted="isDeleted"
:clickable="clickable"
/>
<div class="user-details" :class="{ 'details-inline': layout === 'inline' }">
<div class="name-and-meta">
<span
v-if="!isDeleted"
class="user-name"
:class="{ clickable: isClickable }"
@click="handleNameClick"
>
{{ displayName || userId }}
</span>
<span v-else class="user-name deleted-user">
{{ displayName || userId }}
</span>
<template v-if="layout === 'inline'">
<span class="meta-separator">·</span>
<span class="meta-content">
<slot name="meta"></slot>
</span>
</template>
</div>
<slot v-if="layout !== 'inline'" name="meta"></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import UserAvatar from './UserAvatar.vue'
export default defineComponent({
name: 'UserInfo',
components: {
UserAvatar,
},
props: {
userId: {
type: String,
required: true,
},
displayName: {
type: String,
default: '',
},
avatarSize: {
type: Number,
default: 32,
},
isDeleted: {
type: Boolean,
default: false,
},
clickable: {
type: Boolean,
default: true,
},
layout: {
type: String as () => 'column' | 'inline',
default: 'column',
validator: (value: string) => ['column', 'inline'].includes(value),
},
},
computed: {
isClickable(): boolean {
return this.clickable && !this.isDeleted
},
},
methods: {
handleNameClick(event: MouseEvent): void {
if (this.isClickable) {
event.stopPropagation()
this.$router.push(`/u/${this.userId}`)
}
},
},
})
</script>
<style scoped lang="scss">
.user-info-component {
display: flex;
align-items: center;
gap: 12px;
// When there's metadata in the slot, align to flex-start (only for column layout)
&:not(.layout-inline):has(.user-details > :nth-child(2)) {
align-items: flex-start;
}
}
.user-details {
display: flex;
flex-direction: column;
gap: 4px;
&.details-inline {
flex-direction: row;
align-items: center;
}
}
.name-and-meta {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-weight: 600;
color: var(--color-main-text);
font-size: 1rem;
&.clickable {
cursor: pointer;
transition: color 0.2s;
&:hover {
color: var(--color-primary-element);
}
}
&.deleted-user {
font-style: italic;
opacity: 0.7;
}
}
.meta-separator {
color: var(--color-text-maxcontrast);
opacity: 0.5;
font-size: 0.85rem;
}
.meta-content {
font-size: 0.85rem;
color: var(--color-text-maxcontrast);
}
</style>

View File

@@ -34,6 +34,11 @@ export function useUserRole() {
return userRoles.value.some((role) => role.roleId === 1)
})
const isModerator = computed<boolean>(() => {
// Moderator role has ID 2 (from migration)
return userRoles.value.some((role) => role.roleId === 2)
})
const refresh = () => {
loaded.value = false
const userId = userRoles.value[0]?.userId
@@ -54,6 +59,7 @@ export function useUserRole() {
error,
loaded,
isAdmin,
isModerator,
fetchUserRoles,
refresh,
clear,

View File

@@ -44,6 +44,7 @@ export interface Thread {
authorIsDeleted?: boolean
categorySlug?: string | null
categoryName?: string | null
isSubscribed?: boolean
}
export interface Post {

View File

@@ -12,6 +12,19 @@
</template>
<template #right>
<!-- Subscription toggle switch -->
<NcCheckboxRadioSwitch
v-if="!loading && thread"
v-model="thread.isSubscribed"
@update:model-value="handleToggleSubscription"
type="switch"
>
<span class="icon-label">
<BellIcon :size="20" />
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
</span>
</NcCheckboxRadioSwitch>
<NcButton
@click="refresh"
:disabled="loading"
@@ -182,6 +195,7 @@
<script lang="ts">
import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
@@ -193,6 +207,7 @@ import PinOffIcon from '@icons/PinOff.vue'
import LockIcon from '@icons/Lock.vue'
import LockOpenIcon from '@icons/LockOpen.vue'
import EyeIcon from '@icons/Eye.vue'
import BellIcon from '@icons/Bell.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
import ReplyIcon from '@icons/Reply.vue'
@@ -207,6 +222,7 @@ export default defineComponent({
name: 'ThreadView',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
@@ -218,6 +234,7 @@ export default defineComponent({
LockIcon,
LockOpenIcon,
EyeIcon,
BellIcon,
ArrowLeftIcon,
RefreshIcon,
ReplyIcon,
@@ -268,6 +285,10 @@ export default defineComponent({
threadUnlocked: t('forum', 'Thread unlocked'),
threadPinned: t('forum', 'Thread pinned'),
threadUnpinned: t('forum', 'Thread unpinned'),
subscribe: t('forum', 'Subscribe to thread'),
subscribed: t('forum', 'Subscribed'),
threadSubscribed: t('forum', 'Subscribed to thread'),
threadUnsubscribed: t('forum', 'Unsubscribed from thread'),
},
}
},
@@ -610,6 +631,29 @@ export default defineComponent({
}
},
async handleToggleSubscription(newValue: boolean): Promise<void> {
if (!this.thread) return
try {
if (newValue) {
// Subscribe to thread
await ocs.post(`/threads/${this.thread.id}/subscribe`)
this.thread.isSubscribed = true
showSuccess(this.strings.threadSubscribed)
} else {
// Unsubscribe from thread
await ocs.delete(`/threads/${this.thread.id}/subscribe`)
this.thread.isSubscribed = false
showSuccess(this.strings.threadUnsubscribed)
}
} catch (e) {
console.error('Failed to toggle thread subscription', e)
showError(t('forum', 'Failed to update subscription'))
// Revert the state on error
this.thread.isSubscribed = !newValue
}
},
scrollToPostFromHash(): void {
// Check if there's a hash in the URL like #post-123
const hash = window.location.hash || this.$route.hash
@@ -686,6 +730,12 @@ export default defineComponent({
.thread-view {
margin-bottom: 3rem;
.icon-label {
display: flex;
align-items: center;
gap: 4px;
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -820,6 +870,7 @@ export default defineComponent({
background-color: var(--color-primary-element-light);
box-shadow: 0 0 0 4px var(--color-primary-element-light);
}
100% {
background-color: transparent;
box-shadow: none;

View File

@@ -117,13 +117,17 @@
class="contributor-item"
>
<div class="contributor-rank">{{ index + 1 }}</div>
<NcAvatar :user="contributor.userId" :size="40" />
<div class="contributor-info">
<div class="contributor-name">{{ contributor.userId }}</div>
<div class="contributor-stats muted">
{{ strings.postsCount(contributor.postCount) }}
</div>
</div>
<UserInfo
:user-id="contributor.userId"
:display-name="contributor.userId"
:avatar-size="40"
>
<template #meta>
<div class="contributor-stats muted">
{{ strings.postsCount(contributor.postCount) }}
</div>
</template>
</UserInfo>
</div>
</div>
<div v-else class="muted">{{ strings.noContributors }}</div>
@@ -137,7 +141,7 @@ import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import UserInfo from '@/components/UserInfo.vue'
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
import AccountPlusIcon from '@icons/AccountPlus.vue'
import ForumIcon from '@icons/Forum.vue'
@@ -170,7 +174,7 @@ export default defineComponent({
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcAvatar,
UserInfo,
AccountMultipleIcon,
AccountPlusIcon,
ForumIcon,
@@ -346,18 +350,9 @@ export default defineComponent({
font-size: 0.9rem;
}
.contributor-info {
flex: 1;
.contributor-name {
font-weight: 500;
color: var(--color-main-text);
}
.contributor-stats {
font-size: 0.85rem;
margin-top: 2px;
}
.contributor-stats {
font-size: 0.85rem;
margin-top: 2px;
}
}
}

View File

@@ -41,11 +41,11 @@
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<NcAvatar :user="user.userId" :size="40" />
<div class="user-info">
<div class="user-name">{{ user.displayName }}</div>
<div class="user-id muted">@{{ user.userId }}</div>
</div>
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
<template #meta>
<div class="user-id muted">@{{ user.userId }}</div>
</template>
</UserInfo>
</div>
<div class="col-posts">
@@ -147,9 +147,9 @@ import { defineComponent } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import UserInfo from '@/components/UserInfo.vue'
import PencilIcon from '@icons/Pencil.vue'
import CheckIcon from '@icons/Check.vue'
import CloseIcon from '@icons/Close.vue'
@@ -180,9 +180,9 @@ export default defineComponent({
NcButton,
NcEmptyContent,
NcLoadingIcon,
NcAvatar,
NcDateTime,
NcSelect,
UserInfo,
PencilIcon,
CheckIcon,
CloseIcon,
@@ -395,23 +395,8 @@ export default defineComponent({
}
.col-user {
display: flex;
align-items: center;
gap: 12px;
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
.user-name {
font-weight: 500;
color: var(--color-main-text);
}
.user-id {
font-size: 0.85rem;
}
.user-id {
font-size: 0.85rem;
}
}

View File

@@ -1 +1 @@
0.1.6
0.2.1