From bb5454182c9dc81195b9b21cd774eca8befcad17 Mon Sep 17 00:00:00 2001 From: Thomas Ritou Date: Thu, 11 Jun 2026 15:01:02 +0200 Subject: [PATCH] feat(dashboard): implement Zulip dashboard widget Add a dashboard widget showing recent or unread Zulip messages. - Register ZulipDashboardWidget implementing IReloadableWidget, IButtonWidget, and IIconWidget for standardised server-side item fetching (getItemsV2), auto-reload every 60s (getReloadInterval), a "show more" button (getWidgetButtons) pointing to /#feed or /#inbox depending on mode, and a theme-aware icon (getIconUrl) - Add getRecentMessages() and getUnreadMessages() to ZulipAPIService; both share a _cleanMessageContent() helper that converts Zulip emoji spans (emoji-1f603 class) to Unicode and strips remaining HTML tags - Expose a "Show only unread messages" toggle in PersonalSettings, stored as dashboard_show_unread; the widget adapts its empty-state copy and button link accordingly - Register the widget in Application bootstrap and appinfo/info.xml Assisted-by: Claude Sonnet 4.6 Signed-off-by: Thomas Ritou --- appinfo/info.xml | 1 + lib/AppInfo/Application.php | 2 + lib/Controller/ZulipAPIController.php | 21 ++++ lib/Dashboard/ZulipDashboardWidget.php | 161 +++++++++++++++++++++++++ lib/Service/ZulipAPIService.php | 98 +++++++++++++++ lib/Settings/Personal.php | 2 + src/components/PersonalSettings.vue | 5 + 7 files changed, 290 insertions(+) create mode 100644 lib/Dashboard/ZulipDashboardWidget.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 801a22b1..4eb745a5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,6 +26,7 @@ The context menu to send a file can be accessed by right clicking on the file/fo https://github.com/nextcloud/integration_zulip integration + dashboard https://github.com/nextcloud/integration_zulip https://github.com/nextcloud/integration_zulip/issues https://raw.githubusercontent.com/nextcloud/integration_zulip/main/img/screenshot1.png diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b34391b0..179274d9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,6 +18,7 @@ namespace OCA\Zulip\AppInfo; +use OCA\Zulip\Dashboard\ZulipDashboardWidget; use OCA\Zulip\Listener\FilesMenuListener; use OCA\Zulip\Search\ZulipSearchMessagesProvider; use OCP\AppFramework\App; @@ -39,6 +40,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(LoadAdditionalScriptsEvent::class, FilesMenuListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); $context->registerSearchProvider(ZulipSearchMessagesProvider::class); + $context->registerDashboardWidget(ZulipDashboardWidget::class); } public function boot(IBootContext $context): void { diff --git a/lib/Controller/ZulipAPIController.php b/lib/Controller/ZulipAPIController.php index 88500e15..bb51d8d1 100644 --- a/lib/Controller/ZulipAPIController.php +++ b/lib/Controller/ZulipAPIController.php @@ -20,6 +20,7 @@ use Exception; use OC\User\NoUserException; +use OCA\Zulip\AppInfo\Application; use OCA\Zulip\Service\ZulipAPIService; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; @@ -73,6 +74,26 @@ public function getUserAvatar(int $zulipUserId, int $useFallback = 1): DataDispl return new DataDisplayResponse('', Http::STATUS_NOT_FOUND); } + /** + * @param int $limit + * @return DataResponse + * @throws Exception + */ + #[NoAdminRequired] + #[FrontpageRoute(verb: 'GET', url: '/feed')] + public function getFeed(int $limit = 7): DataResponse { + $showUnread = $this->config->getUserValue($this->userId, Application::APP_ID, 'dashboard_show_unread', '0') === '1'; + if ($showUnread) { + $result = $this->zulipAPIService->getUnreadMessages($this->userId, $limit); + } else { + $result = $this->zulipAPIService->getRecentMessages($this->userId, min($limit, 50)); + } + if (isset($result['error'])) { + return new DataResponse($result, Http::STATUS_BAD_REQUEST); + } + return new DataResponse($result); + } + /** * @return DataResponse * @throws Exception diff --git a/lib/Dashboard/ZulipDashboardWidget.php b/lib/Dashboard/ZulipDashboardWidget.php new file mode 100644 index 00000000..c6c7b3ea --- /dev/null +++ b/lib/Dashboard/ZulipDashboardWidget.php @@ -0,0 +1,161 @@ +l10n->t('Zulip'); + } + + public function getOrder(): int { + return 10; + } + + public function getIconClass(): string { + return ''; + } + + public function getIconUrl(): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg') + ); + } + + public function getUrl(): ?string { + $zulipUrl = rtrim($this->config->getUserValue($this->userId ?? '', Application::APP_ID, 'url', ''), '/'); + if ($zulipUrl !== '') { + return $zulipUrl . '/#feed'; + } + return $this->urlGenerator->linkToRouteAbsolute('settings.PersonalSettings.index', ['section' => 'connected-accounts']); + } + + public function load(): void { + } + + public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems { + $url = $this->config->getUserValue($userId, Application::APP_ID, 'url', ''); + $email = $this->config->getUserValue($userId, Application::APP_ID, 'email', ''); + $apiKey = $this->secretService->getEncryptedUserValue($userId, 'api_key'); + $isConnected = ($url !== '' && $email !== '' && $apiKey !== ''); + + if (!$isConnected) { + return new WidgetItems([], $this->l10n->t('Connect your Zulip account in the settings')); + } + + $showUnread = $this->config->getUserValue($userId, Application::APP_ID, 'dashboard_show_unread', '0') === '1'; + + if ($showUnread) { + $messages = $this->zulipAPIService->getUnreadMessages($userId, $limit); + $emptyMessage = $this->l10n->t('No unread messages'); + } else { + $messages = $this->zulipAPIService->getRecentMessages($userId, $limit); + $emptyMessage = $this->l10n->t('No recent messages'); + } + + if (isset($messages['error'])) { + return new WidgetItems([], $this->l10n->t('Failed to load messages')); + } + + $zulipUrl = rtrim($this->config->getUserValue($userId, Application::APP_ID, 'url', ''), '/'); + + $items = array_map(function (array $msg) use ($zulipUrl): WidgetItem { + return new WidgetItem( + $msg['content'] ?? '', + $this->_buildSubtitle($msg), + $this->_buildMessageUrl($msg, $zulipUrl), + $this->urlGenerator->linkToRouteAbsolute( + 'integration_zulip.ZulipAPI.getUserAvatar', + ['zulipUserId' => $msg['sender_id'] ?? 0] + ), + (string)($msg['id'] ?? ''), + ); + }, $messages); + + return new WidgetItems($items, $emptyMessage); + } + + public function getReloadInterval(): int { + return 60; + } + + public function getWidgetButtons(string $userId): array { + $zulipUrl = rtrim($this->config->getUserValue($userId, Application::APP_ID, 'url', ''), '/'); + if ($zulipUrl === '') { + return []; + } + $showUnread = $this->config->getUserValue($userId, Application::APP_ID, 'dashboard_show_unread', '0') === '1'; + if ($showUnread) { + return [ + new WidgetButton( + WidgetButton::TYPE_MORE, + $zulipUrl . '/#inbox', + $this->l10n->t('Open Zulip inbox'), + ), + ]; + } + return [ + new WidgetButton( + WidgetButton::TYPE_MORE, + $zulipUrl . '/#feed', + $this->l10n->t('Open Zulip feed'), + ), + ]; + } + + private function _buildSubtitle(array $msg): string { + if (($msg['type'] ?? '') === 'stream') { + $subject = $msg['subject'] ?? ''; + $isDefaultTopic = $subject === '' || strtolower($subject) === 'general chat'; + return '#' . ($msg['display_recipient'] ?? '') . ($isDefaultTopic ? '' : ' › ' . $subject); + } + return $this->l10n->t('Direct message from {name}', ['name' => $msg['sender_full_name'] ?? '']); + } + + private function _buildMessageUrl(array $msg, string $zulipUrl): string { + if ($zulipUrl === '') { + return ''; + } + if (($msg['type'] ?? '') === 'private') { + $recipients = is_array($msg['display_recipient'] ?? null) ? $msg['display_recipient'] : []; + $userIds = implode(',', array_column($recipients, 'id')); + return $zulipUrl . '/#narrow/dm/' . $userIds . '/near/' . ($msg['id'] ?? ''); + } + $subject = $msg['subject'] ?? ''; + $isDefaultTopic = $subject === '' || strtolower($subject) === 'general chat'; + $base = $zulipUrl . '/#narrow/channel/' . ($msg['stream_id'] ?? ''); + if ($isDefaultTopic) { + return $base . '/near/' . ($msg['id'] ?? ''); + } + return $base . '/topic/' . str_replace('%', '.', rawurlencode($subject)) . '/with/' . ($msg['id'] ?? ''); + } +} diff --git a/lib/Service/ZulipAPIService.php b/lib/Service/ZulipAPIService.php index 5719f42a..ce3e7a1c 100644 --- a/lib/Service/ZulipAPIService.php +++ b/lib/Service/ZulipAPIService.php @@ -59,6 +59,104 @@ public function __construct( $this->client = $clientService->newClient(); } + /** + * @param string $userId + * @param int $limit + * @return array + * @throws PreConditionNotMetException + */ + public function getRecentMessages(string $userId, int $limit = 7): array { + // Fetch own Zulip user ID for reliable own-message filtering + $meResult = $this->request($userId, 'users/me'); + $myZulipId = isset($meResult['user_id']) ? (int)$meResult['user_id'] : null; + + $result = $this->request($userId, 'messages', [ + 'anchor' => 'newest', + 'num_before' => $limit * 3, + 'num_after' => 0, + 'narrow' => '[]', + 'client_gravatar' => 'true', + ]); + + if (isset($result['error'])) { + return (array)$result; + } + + $messages = array_reverse($result['messages'] ?? []); + + if ($myZulipId !== null) { + $messages = array_values(array_filter( + $messages, + fn ($msg) => ($msg['sender_id'] ?? null) !== $myZulipId + )); + } else { + // Fallback: filter by email (case-insensitive) + $email = strtolower(trim($this->config->getUserValue($userId, Application::APP_ID, 'email', ''))); + if ($email !== '') { + $messages = array_values(array_filter( + $messages, + fn ($msg) => strtolower(trim($msg['sender_email'] ?? '')) !== $email + )); + } + } + + $messages = array_slice($messages, 0, $limit); + + return array_map(fn (array $msg) => $this->_cleanMessageContent($msg), $messages); + } + + /** + * @param string $userId + * @param int $limit + * @return array + * @throws PreConditionNotMetException + */ + public function getUnreadMessages(string $userId, int $limit = 7): array { + $result = $this->request($userId, 'messages', [ + 'anchor' => 'newest', + 'num_before' => $limit, + 'num_after' => 0, + 'narrow' => '[{"operator": "is", "operand": "unread"}]', + 'client_gravatar' => 'true', + ]); + + if (isset($result['error'])) { + return (array)$result; + } + + $messages = array_reverse($result['messages'] ?? []); + + return array_map(fn (array $msg) => $this->_cleanMessageContent($msg), $messages); + } + + /** + * Strip HTML tags from message content and convert emoji spans to Unicode. + */ + private function _cleanMessageContent(array $msg): array { + if (!isset($msg['content'])) { + return $msg; + } + $text = $msg['content']; + // Convert standard Zulip emoji spans to Unicode characters. + // Zulip renders :emoji: as :emoji: + // where the hex code point is in the class name (supports compound emoji like flags: 1f1e8-1f1e6). + $text = preg_replace_callback( + '/]*\bemoji-([0-9a-f][0-9a-f-]*)\b[^>]*>.*?<\/span>/si', + function (array $m): string { + return implode('', array_map( + fn (string $hex): string => mb_chr(hexdec($hex), 'UTF-8') ?: '', + explode('-', $m[1]) + )); + }, + $text + ); + // Strip remaining HTML (custom emoji tags, markup, etc.) + $text = preg_replace('/<[^>]+>/', ' ', $text ?? ''); + $text = html_entity_decode($text ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $msg['content'] = preg_replace('/\s+/', ' ', trim($text)); + return $msg; + } + /** * @param string $userId * @return array diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php index 92e67a14..615d9ed6 100644 --- a/lib/Settings/Personal.php +++ b/lib/Settings/Personal.php @@ -30,6 +30,7 @@ public function getForm(): TemplateResponse { $apiKey = $this->secretService->getEncryptedUserValue($this->userId, 'api_key') ? 'dummyKey' : ''; $fileActionEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'file_action_enabled', '1') === '1'; $searchMessagesEnabled = $this->config->getUserValue($this->userId, Application::APP_ID, 'search_messages_enabled', '0') === '1'; + $dashboardShowUnread = $this->config->getUserValue($this->userId, Application::APP_ID, 'dashboard_show_unread', '0') === '1'; $userConfig = [ 'url' => $url, @@ -37,6 +38,7 @@ public function getForm(): TemplateResponse { 'api_key' => $apiKey, 'file_action_enabled' => $fileActionEnabled, 'search_messages_enabled' => $searchMessagesEnabled, + 'dashboard_show_unread' => $dashboardShowUnread, ]; $this->initialStateService->provideInitialState('user-config', $userConfig); return new TemplateResponse(Application::APP_ID, 'personalSettings'); diff --git a/src/components/PersonalSettings.vue b/src/components/PersonalSettings.vue index 696d66f3..a18b3136 100644 --- a/src/components/PersonalSettings.vue +++ b/src/components/PersonalSettings.vue @@ -58,6 +58,11 @@ @update:model-value="onCheckboxChanged($event, 'search_messages_enabled')"> {{ t('integration_zulip', 'Enable searching for messages') }} + + {{ t('integration_zulip', 'Show only unread messages in dashboard widget') }} +