diff --git a/lib/Controller/ForumUserController.php b/lib/Controller/ForumUserController.php index 245377b..13f0922 100644 --- a/lib/Controller/ForumUserController.php +++ b/lib/Controller/ForumUserController.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\Forum\Controller; use OCA\Forum\Db\ForumUserMapper; +use OCA\Forum\Service\UserService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -28,6 +29,7 @@ class ForumUserController extends OCSController { string $appName, IRequest $request, private ForumUserMapper $forumUserMapper, + private UserService $userService, private IUserSession $userSession, private LoggerInterface $logger, ) { @@ -54,6 +56,28 @@ class ForumUserController extends OCSController { } } + /** + * Search Nextcloud users for autocomplete + * Returns users matching the search query in the format expected by NcRichContenteditable + * + * @param string $search Search query (matches against user ID and display name) + * @param int $limit Maximum number of results to return + * @return DataResponse, array{}> + * + * 200: Users returned + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/users/autocomplete')] + public function autocomplete(string $search = '', int $limit = 10): DataResponse { + try { + $users = $this->userService->searchUsersForAutocomplete($search, $limit); + return new DataResponse($users); + } catch (\Exception $e) { + $this->logger->error('Error searching users for autocomplete: ' . $e->getMessage()); + return new DataResponse(['error' => 'Failed to search users'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Get forum user by Nextcloud user ID * Special case: use "me" to get current forum user diff --git a/lib/Service/BBCodeService.php b/lib/Service/BBCodeService.php index 6d8b507..3f42ba9 100644 --- a/lib/Service/BBCodeService.php +++ b/lib/Service/BBCodeService.php @@ -13,6 +13,7 @@ use OCA\Forum\Db\BBCodeMapper; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\IURLGenerator; +use OCP\IUserManager; use Psr\Log\LoggerInterface; class BBCodeService { @@ -23,6 +24,7 @@ class BBCodeService { private LoggerInterface $logger, private IRootFolder $rootFolder, private IURLGenerator $urlGenerator, + private IUserManager $userManager, ) { } @@ -189,6 +191,9 @@ class BBCodeService { $html = str_replace($placeholder, htmlspecialchars($original, ENT_QUOTES | ENT_HTML5, 'UTF-8'), $html); } + // Parse @mentions and convert them to user profile links + $html = $this->parseMentions($html); + return $html; } catch (\Exception $e) { $this->logger->error('BBCode parsing error: ' . $e->getMessage()); @@ -496,4 +501,62 @@ class BBCodeService { default => 'icon-file', }; } + + /** + * Parse @mentions in HTML content and convert them to user profile links + * + * Supports two formats: + * - @username (for usernames without spaces) + * - @"username with spaces" (for usernames with spaces) + * + * @param string $html The HTML content to parse + * @return string The HTML with mentions converted to links + */ + private function parseMentions(string $html): string { + // Pattern to match @"username with spaces" or @username + // Must not be preceded by a word character (to avoid matching email addresses) + $pattern = '/(?userManager->get($userId); + if ($user === null) { + // User doesn't exist, return original text + return $matches[0]; + } + + $displayName = $user->getDisplayName(); + $escapedUserId = htmlspecialchars($userId, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $escapedDisplayName = htmlspecialchars($displayName, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Generate link to user profile in the forum app + $profileUrl = $this->urlGenerator->linkToRouteAbsolute('forum.page.index') . 'u/' . urlencode($userId); + $escapedUrl = htmlspecialchars($profileUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + // Generate avatar URLs for both light and dark themes + $avatarUrlLight = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $userId, 'size' => 64]); + $avatarUrlDark = $avatarUrlLight . '/dark'; + $escapedAvatarUrlLight = htmlspecialchars($avatarUrlLight, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $escapedAvatarUrlDark = htmlspecialchars($avatarUrlDark, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return sprintf( + '' + . '' + . '' + . '' + . '%s' + . '' + . '' + . '', + $escapedUrl, + $escapedUserId, + $escapedAvatarUrlLight, + $escapedAvatarUrlDark, + $escapedDisplayName + ); + }, $html) ?? $html; + } } diff --git a/lib/Service/UserService.php b/lib/Service/UserService.php index f7099e8..4559105 100644 --- a/lib/Service/UserService.php +++ b/lib/Service/UserService.php @@ -219,6 +219,34 @@ class UserService { return $rolesMap; } + /** + * Search users for autocomplete + * Returns users matching the search query in the format expected by NcRichContenteditable + * + * @param string $search Search query (matches against user ID and display name) + * @param int $limit Maximum number of results to return + * @return array List of matching users + */ + public function searchUsersForAutocomplete(string $search = '', int $limit = 10): array { + $results = []; + $search = strtolower(trim($search)); + + // Use IUserManager to search users + // The search method searches both user ID and display name + $users = $this->userManager->search($search, $limit); + + foreach ($users as $user) { + $results[] = [ + 'id' => $user->getUID(), + 'label' => $user->getDisplayName(), + 'icon' => 'icon-user', + 'source' => 'users', + ]; + } + + return $results; + } + /** * Fetch signatures for multiple users efficiently * diff --git a/openapi.json b/openapi.json index 21bac67..d2bb9d1 100644 --- a/openapi.json +++ b/openapi.json @@ -3769,6 +3769,137 @@ } } }, + "/ocs/v2.php/apps/forum/api/users/autocomplete": { + "get": { + "operationId": "forum_user-autocomplete", + "summary": "Search Nextcloud users for autocomplete Returns users matching the search query in the format expected by NcRichContenteditable", + "tags": [ + "forum_user" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "search", + "in": "query", + "description": "Search query (matches against user ID and display name)", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of results to return", + "schema": { + "type": "integer", + "format": "int64", + "default": 10 + } + }, + { + "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": "Users 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", + "required": [ + "id", + "label", + "icon", + "source" + ], + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "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}": { "get": { "operationId": "forum_user-show", diff --git a/src/components/BBCodeEditor.vue b/src/components/BBCodeEditor.vue index 7a7e1b7..9915993 100644 --- a/src/components/BBCodeEditor.vue +++ b/src/components/BBCodeEditor.vue @@ -6,16 +6,23 @@ @dragleave="handleDragLeave" @drop="handleDrop" > - - + @@ -33,16 +40,33 @@