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') }}
+