diff --git a/.gitignore b/.gitignore
index 837d6d2..0e5062c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,7 +10,8 @@
/node_modules/
/dist
/js
-/css
+/css/*
+!/css/dashboard.css
.DS_Store
build/
tsconfig.app.tsbuildinfo
diff --git a/css/dashboard.css b/css/dashboard.css
new file mode 100644
index 0000000..d96fe94
--- /dev/null
+++ b/css/dashboard.css
@@ -0,0 +1,8 @@
+/**
+ * Dashboard widget icon theming
+ * Inverts dark icons in dark mode using Nextcloud's CSS variable
+ */
+img[src*="/forum/img/thread-dark.svg"],
+img[src*="/forum/img/folder-dark.svg"] {
+ filter: var(--background-invert-if-dark);
+}
diff --git a/img/folder-dark.svg b/img/folder-dark.svg
new file mode 100644
index 0000000..c5498de
--- /dev/null
+++ b/img/folder-dark.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/img/thread-dark.svg b/img/thread-dark.svg
new file mode 100644
index 0000000..26f0053
--- /dev/null
+++ b/img/thread-dark.svg
@@ -0,0 +1,5 @@
+
+
+
diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php
index 9a9f78c..ffd5524 100644
--- a/lib/AppInfo/Application.php
+++ b/lib/AppInfo/Application.php
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace OCA\Forum\AppInfo;
+use OCA\Forum\Dashboard\RecentActivityWidget;
+use OCA\Forum\Dashboard\TopActivityWidget;
+use OCA\Forum\Dashboard\TopCategoriesWidget;
+use OCA\Forum\Dashboard\TopThreadsWidget;
use OCA\Forum\Listener\UserEventListener;
use OCA\Forum\Middleware\PermissionMiddleware;
use OCA\Forum\Notification\Notifier;
@@ -37,6 +41,12 @@ class Application extends App implements IBootstrap {
// Register notification notifier
$context->registerNotifierService(Notifier::class);
+
+ // Register dashboard widgets
+ $context->registerDashboardWidget(RecentActivityWidget::class);
+ $context->registerDashboardWidget(TopActivityWidget::class);
+ $context->registerDashboardWidget(TopCategoriesWidget::class);
+ $context->registerDashboardWidget(TopThreadsWidget::class);
}
public function boot(IBootContext $context): void {
diff --git a/lib/Dashboard/RecentActivityWidget.php b/lib/Dashboard/RecentActivityWidget.php
new file mode 100644
index 0000000..fc7f246
--- /dev/null
+++ b/lib/Dashboard/RecentActivityWidget.php
@@ -0,0 +1,101 @@
+
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+namespace OCA\Forum\Dashboard;
+
+use OCA\Forum\AppInfo\Application;
+use OCP\Dashboard\IAPIWidgetV2;
+use OCP\Dashboard\IButtonWidget;
+use OCP\Dashboard\IIconWidget;
+use OCP\Dashboard\Model\WidgetButton;
+use OCP\Dashboard\Model\WidgetItem;
+use OCP\Dashboard\Model\WidgetItems;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+
+class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
+ public function __construct(
+ private IL10N $l,
+ private IURLGenerator $urlGenerator,
+ private WidgetService $widgetService,
+ ) {
+ }
+
+ public function getId(): string {
+ return 'forum-recent-activity';
+ }
+
+ public function getTitle(): string {
+ return $this->l->t('Recent Forum activity');
+ }
+
+ public function getOrder(): int {
+ return 10;
+ }
+
+ public function getIconClass(): string {
+ return 'icon-forum';
+ }
+
+ public function getIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
+ }
+
+ public function getUrl(): ?string {
+ return $this->widgetService->getForumUrl();
+ }
+
+ public function load(): void {
+ \OCP\Util::addStyle(Application::APP_ID, 'dashboard');
+ }
+
+ public function getWidgetButtons(string $userId): array {
+ return [
+ new WidgetButton(
+ WidgetButton::TYPE_MORE,
+ $this->widgetService->getForumUrl(),
+ $this->l->t('More activity')
+ ),
+ ];
+ }
+
+ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
+ $activity = $this->widgetService->getRecentActivity($userId, $limit);
+
+ $items = [];
+ foreach ($activity as $entry) {
+ $thread = $entry['thread'];
+ if ($thread === null) {
+ continue;
+ }
+
+ $title = $thread->getTitle();
+ $link = $this->widgetService->getThreadUrl($thread);
+ $sinceId = (string)$entry['createdAt'];
+
+ if ($entry['type'] === 'thread') {
+ $subtitle = $this->l->t('New thread by %1$s', [$thread->getAuthorId()]);
+ } else {
+ $post = $entry['item'];
+ $subtitle = $this->l->t('Reply by %1$s', [$post->getAuthorId()]);
+ }
+
+ $items[] = new WidgetItem(
+ $title,
+ $subtitle,
+ $link,
+ $this->widgetService->getThreadIconUrl(),
+ $sinceId
+ );
+ }
+
+ return new WidgetItems(
+ $items,
+ $this->l->t('No recent forum activity')
+ );
+ }
+}
diff --git a/lib/Dashboard/TopActivityWidget.php b/lib/Dashboard/TopActivityWidget.php
new file mode 100644
index 0000000..ec84eee
--- /dev/null
+++ b/lib/Dashboard/TopActivityWidget.php
@@ -0,0 +1,108 @@
+
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+namespace OCA\Forum\Dashboard;
+
+use OCA\Forum\AppInfo\Application;
+use OCP\Dashboard\IAPIWidgetV2;
+use OCP\Dashboard\IButtonWidget;
+use OCP\Dashboard\IIconWidget;
+use OCP\Dashboard\Model\WidgetButton;
+use OCP\Dashboard\Model\WidgetItem;
+use OCP\Dashboard\Model\WidgetItems;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+
+class TopActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
+ public function __construct(
+ private IL10N $l,
+ private IURLGenerator $urlGenerator,
+ private WidgetService $widgetService,
+ ) {
+ }
+
+ public function getId(): string {
+ return 'forum-top-activity';
+ }
+
+ public function getTitle(): string {
+ return $this->l->t('Top Forum activity');
+ }
+
+ public function getOrder(): int {
+ return 13;
+ }
+
+ public function getIconClass(): string {
+ return 'icon-forum';
+ }
+
+ public function getIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
+ }
+
+ public function getUrl(): ?string {
+ return $this->widgetService->getForumUrl();
+ }
+
+ public function load(): void {
+ \OCP\Util::addStyle(Application::APP_ID, 'dashboard');
+ }
+
+ public function getWidgetButtons(string $userId): array {
+ return [
+ new WidgetButton(
+ WidgetButton::TYPE_MORE,
+ $this->widgetService->getForumUrl(),
+ $this->l->t('Browse forum')
+ ),
+ ];
+ }
+
+ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
+ $items = [];
+
+ // Get top categories (half of limit, rounded up)
+ $categoryLimit = (int)ceil($limit / 2);
+ $categories = $this->widgetService->getTopCategories($userId, $categoryLimit);
+
+ foreach ($categories as $category) {
+ $threadCount = $category->getThreadCount();
+
+ $items[] = new WidgetItem(
+ $category->getName(),
+ $this->l->n('%n thread', '%n threads', $threadCount),
+ $this->widgetService->getCategoryUrl($category),
+ $this->widgetService->getCategoryIconUrl(),
+ 'cat-' . $category->getId()
+ );
+ }
+
+ // Get top threads (remaining slots)
+ $threadLimit = $limit - count($items);
+ if ($threadLimit > 0) {
+ $threads = $this->widgetService->getTopThreads($userId, $threadLimit);
+
+ foreach ($threads as $thread) {
+ $viewCount = $thread->getViewCount();
+
+ $items[] = new WidgetItem(
+ $thread->getTitle(),
+ $this->l->n('%n view', '%n views', $viewCount),
+ $this->widgetService->getThreadUrl($thread),
+ $this->widgetService->getThreadIconUrl(),
+ 'thread-' . $thread->getId()
+ );
+ }
+ }
+
+ return new WidgetItems(
+ $items,
+ $this->l->t('No forum activity')
+ );
+ }
+}
diff --git a/lib/Dashboard/TopCategoriesWidget.php b/lib/Dashboard/TopCategoriesWidget.php
new file mode 100644
index 0000000..c2fab69
--- /dev/null
+++ b/lib/Dashboard/TopCategoriesWidget.php
@@ -0,0 +1,87 @@
+
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+namespace OCA\Forum\Dashboard;
+
+use OCA\Forum\AppInfo\Application;
+use OCP\Dashboard\IAPIWidgetV2;
+use OCP\Dashboard\IButtonWidget;
+use OCP\Dashboard\IIconWidget;
+use OCP\Dashboard\Model\WidgetButton;
+use OCP\Dashboard\Model\WidgetItem;
+use OCP\Dashboard\Model\WidgetItems;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+
+class TopCategoriesWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
+ public function __construct(
+ private IL10N $l,
+ private IURLGenerator $urlGenerator,
+ private WidgetService $widgetService,
+ ) {
+ }
+
+ public function getId(): string {
+ return 'forum-top-categories';
+ }
+
+ public function getTitle(): string {
+ return $this->l->t('Top Forum categories');
+ }
+
+ public function getOrder(): int {
+ return 11;
+ }
+
+ public function getIconClass(): string {
+ return 'icon-forum';
+ }
+
+ public function getIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
+ }
+
+ public function getUrl(): ?string {
+ return $this->widgetService->getForumUrl();
+ }
+
+ public function load(): void {
+ \OCP\Util::addStyle(Application::APP_ID, 'dashboard');
+ }
+
+ public function getWidgetButtons(string $userId): array {
+ return [
+ new WidgetButton(
+ WidgetButton::TYPE_MORE,
+ $this->widgetService->getForumUrl(),
+ $this->l->t('Browse forum')
+ ),
+ ];
+ }
+
+ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
+ $categories = $this->widgetService->getTopCategories($userId, $limit);
+
+ $items = [];
+ foreach ($categories as $category) {
+ $threadCount = $category->getThreadCount();
+
+ $items[] = new WidgetItem(
+ $category->getName(),
+ $this->l->n('%n thread', '%n threads', $threadCount),
+ $this->widgetService->getCategoryUrl($category),
+ $this->widgetService->getCategoryIconUrl(),
+ (string)$category->getId()
+ );
+ }
+
+ return new WidgetItems(
+ $items,
+ $this->l->t('No categories available')
+ );
+ }
+}
diff --git a/lib/Dashboard/TopThreadsWidget.php b/lib/Dashboard/TopThreadsWidget.php
new file mode 100644
index 0000000..6a0b345
--- /dev/null
+++ b/lib/Dashboard/TopThreadsWidget.php
@@ -0,0 +1,87 @@
+
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+namespace OCA\Forum\Dashboard;
+
+use OCA\Forum\AppInfo\Application;
+use OCP\Dashboard\IAPIWidgetV2;
+use OCP\Dashboard\IButtonWidget;
+use OCP\Dashboard\IIconWidget;
+use OCP\Dashboard\Model\WidgetButton;
+use OCP\Dashboard\Model\WidgetItem;
+use OCP\Dashboard\Model\WidgetItems;
+use OCP\IL10N;
+use OCP\IURLGenerator;
+
+class TopThreadsWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget {
+ public function __construct(
+ private IL10N $l,
+ private IURLGenerator $urlGenerator,
+ private WidgetService $widgetService,
+ ) {
+ }
+
+ public function getId(): string {
+ return 'forum-top-threads';
+ }
+
+ public function getTitle(): string {
+ return $this->l->t('Top Forum threads');
+ }
+
+ public function getOrder(): int {
+ return 12;
+ }
+
+ public function getIconClass(): string {
+ return 'icon-forum';
+ }
+
+ public function getIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
+ }
+
+ public function getUrl(): ?string {
+ return $this->widgetService->getForumUrl();
+ }
+
+ public function load(): void {
+ \OCP\Util::addStyle(Application::APP_ID, 'dashboard');
+ }
+
+ public function getWidgetButtons(string $userId): array {
+ return [
+ new WidgetButton(
+ WidgetButton::TYPE_MORE,
+ $this->widgetService->getForumUrl(),
+ $this->l->t('Browse forum')
+ ),
+ ];
+ }
+
+ public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
+ $threads = $this->widgetService->getTopThreads($userId, $limit);
+
+ $items = [];
+ foreach ($threads as $thread) {
+ $viewCount = $thread->getViewCount();
+
+ $items[] = new WidgetItem(
+ $thread->getTitle(),
+ $this->l->n('%n view', '%n views', $viewCount),
+ $this->widgetService->getThreadUrl($thread),
+ $this->widgetService->getThreadIconUrl(),
+ (string)$thread->getId()
+ );
+ }
+
+ return new WidgetItems(
+ $items,
+ $this->l->t('No threads available')
+ );
+ }
+}
diff --git a/lib/Dashboard/WidgetService.php b/lib/Dashboard/WidgetService.php
new file mode 100644
index 0000000..cf455dc
--- /dev/null
+++ b/lib/Dashboard/WidgetService.php
@@ -0,0 +1,180 @@
+
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+namespace OCA\Forum\Dashboard;
+
+use OCA\Forum\AppInfo\Application;
+use OCA\Forum\Db\Category;
+use OCA\Forum\Db\CategoryMapper;
+use OCA\Forum\Db\Post;
+use OCA\Forum\Db\PostMapper;
+use OCA\Forum\Db\Thread;
+use OCA\Forum\Db\ThreadMapper;
+use OCA\Forum\Service\PermissionService;
+use OCP\IURLGenerator;
+
+class WidgetService {
+ public function __construct(
+ private PermissionService $permissionService,
+ private ThreadMapper $threadMapper,
+ private PostMapper $postMapper,
+ private CategoryMapper $categoryMapper,
+ private IURLGenerator $urlGenerator,
+ ) {
+ }
+
+ /**
+ * Get category IDs accessible by the user
+ *
+ * @param string $userId
+ * @return array
+ */
+ public function getAccessibleCategoryIds(string $userId): array {
+ return $this->permissionService->getAccessibleCategories($userId);
+ }
+
+ /**
+ * Get recent activity (threads and replies combined)
+ *
+ * @param string $userId
+ * @param int $limit
+ * @return array
+ */
+ public function getRecentActivity(string $userId, int $limit = 7): array {
+ $categoryIds = $this->getAccessibleCategoryIds($userId);
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ // Get recent threads and replies
+ $threads = $this->threadMapper->findRecentThreads($categoryIds, $limit);
+ $replies = $this->postMapper->findRecentReplies($categoryIds, $limit);
+
+ // Combine and sort by created_at
+ $activity = [];
+
+ foreach ($threads as $thread) {
+ $activity[] = [
+ 'type' => 'thread',
+ 'item' => $thread,
+ 'thread' => $thread,
+ 'createdAt' => $thread->getCreatedAt(),
+ ];
+ }
+
+ // Get thread info for replies
+ $threadIds = array_unique(array_map(fn ($post) => $post->getThreadId(), $replies));
+ $threadMap = [];
+ if (!empty($threadIds)) {
+ $threadEntities = $this->threadMapper->findByIds($threadIds);
+ foreach ($threadEntities as $thread) {
+ $threadMap[$thread->getId()] = $thread;
+ }
+ }
+
+ foreach ($replies as $post) {
+ $thread = $threadMap[$post->getThreadId()] ?? null;
+ if ($thread !== null) {
+ $activity[] = [
+ 'type' => 'reply',
+ 'item' => $post,
+ 'thread' => $thread,
+ 'createdAt' => $post->getCreatedAt(),
+ ];
+ }
+ }
+
+ // Sort by createdAt descending
+ usort($activity, fn ($a, $b) => $b['createdAt'] <=> $a['createdAt']);
+
+ return array_slice($activity, 0, $limit);
+ }
+
+ /**
+ * Get top categories by thread count
+ *
+ * @param string $userId
+ * @param int $limit
+ * @return array
+ */
+ public function getTopCategories(string $userId, int $limit = 7): array {
+ $categoryIds = $this->getAccessibleCategoryIds($userId);
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ return $this->categoryMapper->findTopByThreadCount($categoryIds, $limit);
+ }
+
+ /**
+ * Get top threads by view count
+ *
+ * @param string $userId
+ * @param int $limit
+ * @return array
+ */
+ public function getTopThreads(string $userId, int $limit = 7): array {
+ $categoryIds = $this->getAccessibleCategoryIds($userId);
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ return $this->threadMapper->findTopByViews($categoryIds, $limit);
+ }
+
+ /**
+ * Get URL for forum home
+ */
+ public function getForumUrl(): string {
+ return $this->urlGenerator->linkToRouteAbsolute(Application::APP_ID . '.page.index');
+ }
+
+ /**
+ * Get URL for a thread
+ */
+ public function getThreadUrl(Thread $thread): string {
+ return $this->urlGenerator->linkToRouteAbsolute(
+ Application::APP_ID . '.page.catchAll',
+ ['path' => 't/' . $thread->getSlug()]
+ );
+ }
+
+ /**
+ * Get URL for a category
+ */
+ public function getCategoryUrl(Category $category): string {
+ return $this->urlGenerator->linkToRouteAbsolute(
+ Application::APP_ID . '.page.catchAll',
+ ['path' => 'c/' . $category->getSlug()]
+ );
+ }
+
+ /**
+ * Get the forum app icon URL (dark/black icon for widget header - auto-inverted by Nextcloud)
+ */
+ public function getIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'app-dark.svg');
+ }
+
+ /**
+ * Get the thread icon URL for widget items (black icon, inverted by CSS in dark mode)
+ *
+ * @return string
+ */
+ public function getThreadIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'thread-dark.svg');
+ }
+
+ /**
+ * Get the folder icon URL for category items (black icon, inverted by CSS in dark mode)
+ *
+ * @return string
+ */
+ public function getCategoryIconUrl(): string {
+ return $this->urlGenerator->imagePath(Application::APP_ID, 'folder-dark.svg');
+ }
+}
diff --git a/lib/Db/CategoryMapper.php b/lib/Db/CategoryMapper.php
index 748c707..682f9f4 100644
--- a/lib/Db/CategoryMapper.php
+++ b/lib/Db/CategoryMapper.php
@@ -212,6 +212,28 @@ class CategoryMapper extends QBMapper {
return (int)($row['count'] ?? 0);
}
+ /**
+ * Find top categories by thread count
+ *
+ * @param array $categoryIds Category IDs to filter by (already permission-filtered)
+ * @param int $limit Maximum results
+ * @return array
+ */
+ public function findTopByThreadCount(array $categoryIds, int $limit = 7): array {
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->in('id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->orderBy('thread_count', 'DESC')
+ ->setMaxResults($limit);
+
+ return $this->findEntities($qb);
+ }
+
/**
* Move all categories from one header to another
*
diff --git a/lib/Db/PostMapper.php b/lib/Db/PostMapper.php
index 0466919..4b1ed6d 100644
--- a/lib/Db/PostMapper.php
+++ b/lib/Db/PostMapper.php
@@ -336,6 +336,33 @@ class PostMapper extends QBMapper {
return (int)($row['position'] ?? 0);
}
+ /**
+ * Find recent replies (non-first posts) in specified categories
+ *
+ * @param array $categoryIds Category IDs to filter by
+ * @param int $limit Maximum results
+ * @return array
+ */
+ public function findRecentReplies(array $categoryIds, int $limit = 7): array {
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('p.*')
+ ->from($this->getTableName(), 'p')
+ ->innerJoin('p', 'forum_threads', 't', $qb->expr()->eq('p.thread_id', 't.id'))
+ ->where($qb->expr()->in('t.category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->andWhere($qb->expr()->eq('p.is_first_post', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('p.deleted_at'))
+ ->andWhere($qb->expr()->isNull('t.deleted_at'))
+ ->andWhere($qb->expr()->eq('t.is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->orderBy('p.created_at', 'DESC')
+ ->setMaxResults($limit);
+
+ return $this->findEntities($qb);
+ }
+
/**
* Search posts by content (replies only, excluding first posts)
*
diff --git a/lib/Db/ThreadMapper.php b/lib/Db/ThreadMapper.php
index 6995537..2b8ede7 100644
--- a/lib/Db/ThreadMapper.php
+++ b/lib/Db/ThreadMapper.php
@@ -220,6 +220,54 @@ class ThreadMapper extends QBMapper {
return $this->findEntities($qb);
}
+ /**
+ * Find recent threads in specified categories
+ *
+ * @param array $categoryIds Category IDs to filter by
+ * @param int $limit Maximum results
+ * @return array
+ */
+ public function findRecentThreads(array $categoryIds, int $limit = 7): array {
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->in('category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('deleted_at'))
+ ->orderBy('created_at', 'DESC')
+ ->setMaxResults($limit);
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * Find top threads by view count in specified categories
+ *
+ * @param array $categoryIds Category IDs to filter by
+ * @param int $limit Maximum results
+ * @return array
+ */
+ public function findTopByViews(array $categoryIds, int $limit = 7): array {
+ if (empty($categoryIds)) {
+ return [];
+ }
+
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->in('category_id', $qb->createNamedParameter($categoryIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->andWhere($qb->expr()->eq('is_hidden', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
+ ->andWhere($qb->expr()->isNull('deleted_at'))
+ ->orderBy('view_count', 'DESC')
+ ->setMaxResults($limit);
+
+ return $this->findEntities($qb);
+ }
+
/**
* Search threads by title and first post content
*