mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-18 01:28:58 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1671baf2d | |||
| 71ee133ac6 | |||
| 1add8db287 | |||
| e1e3ede1d8 | |||
| 9833e51997 | |||
| 664ee53670 | |||
| 7a80c19613 | |||
| 8cc34d9d7a | |||
| 364226fdc8 | |||
| 11aa3af887 | |||
| 0de120f2bf | |||
| e590f73fc0 | |||
| 4ca6388923 | |||
| cdecdce9d1 | |||
| bf59b47b2a | |||
| 2fbe180d5e | |||
| d16288f237 | |||
| 6ba8034b75 | |||
| 860092d6a9 | |||
| 29311708a5 | |||
| 51bcf64213 | |||
| 4e6ba7cb28 |
@@ -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'],
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"0.1.6"}
|
||||
{".":"0.2.1"}
|
||||
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
10
Makefile
10
Makefile
@@ -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)
|
||||
|
||||
@@ -10,6 +10,13 @@ threads, and posts within their Nextcloud instance.
|
||||
|
||||

|
||||
|
||||
## ⚠️ 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
11
appinfo/console.php
Normal 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));
|
||||
@@ -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>
|
||||
|
||||
@@ -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
8
composer/autoload.php
Normal 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';
|
||||
@@ -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 {
|
||||
|
||||
40
lib/Command/RebuildUserStats.php
Normal file
40
lib/Command/RebuildUserStats.php
Normal 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;
|
||||
}
|
||||
}
|
||||
81
lib/Command/TestNotifier.php
Normal file
81
lib/Command/TestNotifier.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
lib/Controller/ThreadSubscriptionController.php
Normal file
132
lib/Controller/ThreadSubscriptionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
lib/Cron/RebuildUserStatsTask.php
Normal file
38
lib/Cron/RebuildUserStatsTask.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
44
lib/Db/ThreadSubscription.php
Normal file
44
lib/Db/ThreadSubscription.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
142
lib/Db/ThreadSubscriptionMapper.php
Normal file
142
lib/Db/ThreadSubscriptionMapper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
119
lib/Migration/Version2Date20251114222614.php
Normal file
119
lib/Migration/Version2Date20251114222614.php
Normal 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!');
|
||||
}
|
||||
|
||||
}
|
||||
112
lib/Notification/Notifier.php
Normal file
112
lib/Notification/Notifier.php
Normal 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);
|
||||
}
|
||||
}
|
||||
155
lib/Service/NotificationService.php
Normal file
155
lib/Service/NotificationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
|
||||
171
lib/Service/UserStatsService.php
Normal file
171
lib/Service/UserStatsService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
397
openapi.json
397
openapi.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; */
|
||||
|
||||
@@ -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;
|
||||
|
||||
87
src/components/UserAvatar.vue
Normal file
87
src/components/UserAvatar.vue
Normal 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
146
src/components/UserInfo.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface Thread {
|
||||
authorIsDeleted?: boolean
|
||||
categorySlug?: string | null
|
||||
categoryName?: string | null
|
||||
isSubscribed?: boolean
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.6
|
||||
0.2.1
|
||||
|
||||
Reference in New Issue
Block a user