Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The context menu to send a file can be accessed by right clicking on the file/fo
<developer>https://github.com/nextcloud/integration_zulip</developer>
</documentation>
<category>integration</category>
<category>dashboard</category>
<website>https://github.com/nextcloud/integration_zulip</website>
<bugs>https://github.com/nextcloud/integration_zulip/issues</bugs>
<screenshot>https://raw.githubusercontent.com/nextcloud/integration_zulip/main/img/screenshot1.png</screenshot>
Expand Down
2 changes: 2 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
21 changes: 21 additions & 0 deletions lib/Controller/ZulipAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Comment thread
edward-ly marked this conversation as resolved.
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
Expand Down
161 changes: 161 additions & 0 deletions lib/Dashboard/ZulipDashboardWidget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
<?php

declare(strict_types=1);

namespace OCA\Zulip\Dashboard;

use OCA\Zulip\AppInfo\Application;
use OCA\Zulip\Service\SecretService;
use OCA\Zulip\Service\ZulipAPIService;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\IReloadableWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;

class ZulipDashboardWidget implements IReloadableWidget, IButtonWidget, IIconWidget {

public function __construct(
private IL10N $l10n,
private IConfig $config,
private IURLGenerator $urlGenerator,
private SecretService $secretService,
private ZulipAPIService $zulipAPIService,
private ?string $userId,
) {
}

public function getId(): string {
return 'zulip_feed';
}

public function getTitle(): string {
return $this->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'] ?? '');
}
}
98 changes: 98 additions & 0 deletions lib/Service/ZulipAPIService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span class="emoji emoji-1f603">:emoji:</span>
// where the hex code point is in the class name (supports compound emoji like flags: 1f1e8-1f1e6).
$text = preg_replace_callback(
'/<span\b[^>]*\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 <img> 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
Expand Down
2 changes: 2 additions & 0 deletions lib/Settings/Personal.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ 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,
'email' => $email,
'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');
Expand Down
5 changes: 5 additions & 0 deletions src/components/PersonalSettings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
@update:model-value="onCheckboxChanged($event, 'search_messages_enabled')">
{{ t('integration_zulip', 'Enable searching for messages') }}
</NcFormBoxSwitch>
<NcFormBoxSwitch
v-model="state.dashboard_show_unread"
@update:model-value="onCheckboxChanged($event, 'dashboard_show_unread')">
{{ t('integration_zulip', 'Show only unread messages in dashboard widget') }}
</NcFormBoxSwitch>
</NcFormBox>
</div>
</div>
Expand Down
Loading