From 06faea0b03654d307d2214c956c3f2f2231f7597 Mon Sep 17 00:00:00 2001
From: adi3433 <96505738+adi3433@users.noreply.github.com>
Date: Wed, 8 Apr 2026 19:54:20 +0530
Subject: [PATCH] feat: add forum monitor and batch download queue
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Forum Monitor:
- New Moodle AJAX service (mod_forum_get_forums_by_courses, mod_forum_get_forum_discussions) to fetch recent discussions across all enrolled courses
- Forum store with persistence, staleness check, and recent-discussions selector
- Dashboard "Forum Activity" section showing latest discussions with course name, reply count, and relative timestamps
- Deferred fetch after initial dashboard load to avoid blocking startup
- Forum state cleared on logout
Download Queue:
- New download queue store with concurrency-limited processor (2 parallel downloads)
- "Download All" button on course resources header that enqueues all resource/folder files
- Queue badge showing progress (completed/total) with live status
- Full-screen queue modal with per-item progress bars, retry for failed items, remove/clear actions
- Automatic queue processing — items download sequentially without user intervention
TypeScript compiles clean. No changes to existing service logic.
---
src/app/(tabs)/index.tsx | 23 ++
src/app/course/[courseid].tsx | 103 ++++++-
src/components/dashboard/forum-section.tsx | 171 +++++++++++
.../download/download-queue-modal.tsx | 277 ++++++++++++++++++
src/services/forum.ts | 162 ++++++++++
src/stores/auth-store.ts | 2 +
src/stores/download-queue-store.ts | 270 +++++++++++++++++
src/stores/forum-store.ts | 96 ++++++
src/types/download-queue.ts | 31 ++
src/types/forum.ts | 52 ++++
src/types/index.ts | 2 +
11 files changed, 1179 insertions(+), 10 deletions(-)
create mode 100644 src/components/dashboard/forum-section.tsx
create mode 100644 src/components/download/download-queue-modal.tsx
create mode 100644 src/services/forum.ts
create mode 100644 src/stores/download-queue-store.ts
create mode 100644 src/stores/forum-store.ts
create mode 100644 src/types/download-queue.ts
create mode 100644 src/types/forum.ts
diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx
index ecf9acc..36b321a 100644
--- a/src/app/(tabs)/index.tsx
+++ b/src/app/(tabs)/index.tsx
@@ -1,5 +1,6 @@
import { startBackgroundRefresh } from "@/background/dashboard-background";
import { EventCard } from "@/components/dashboard/event-card";
+import { ForumSection } from "@/components/dashboard/forum-section";
import { TimelineSection } from "@/components/dashboard/timeline-section";
import { UpNextSection } from "@/components/dashboard/up-next-section";
import { NoticePopup } from "@/components/dashboard/popup/notice-popup";
@@ -12,6 +13,7 @@ import { useColorScheme } from "@/hooks/use-color-scheme";
import { useAuthStore } from "@/stores/auth-store";
import { useAttendanceStore } from "@/stores/attendance-store";
import { useDashboardStore } from "@/stores/dashboard-store";
+import { useForumStore } from "@/stores/forum-store";
import { useLmsResourcesStore } from "@/stores/lms-resources-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePopupStore } from "@/stores/popup-store";
@@ -80,9 +82,13 @@ export default function DashboardScreen() {
);
const markAllAsSeen = usePopupStore((state) => state.markAllAsSeen);
const isFocused = useIsFocused();
+ const fetchForumDiscussions = useForumStore(
+ (state) => state.fetchForumDiscussions,
+ );
const hasAutoRefreshed = useRef(false);
const hasCompletedInitialRefresh = useRef(false);
const hasDeferredResourcePrefetch = useRef(false);
+ const hasDeferredForumFetch = useRef(false);
const isAttendanceRefreshQueued = useRef(false);
const queueInvisibleAttendanceRefresh = useCallback(() => {
@@ -183,6 +189,20 @@ export default function DashboardScreen() {
resourcesHydrated,
]);
+ // Deferred forum discussions fetch
+ useEffect(() => {
+ if (!hasHydrated || isOffline) return;
+ if (hasDeferredForumFetch.current) return;
+ if (!hasCompletedInitialRefresh.current) return;
+ hasDeferredForumFetch.current = true;
+
+ const task = InteractionManager.runAfterInteractions(() => {
+ void fetchForumDiscussions({ silent: true });
+ });
+
+ return () => task.cancel();
+ }, [fetchForumDiscussions, hasHydrated, isOffline]);
+
useEffect(() => {
if (isOffline && lastSyncTime) {
setOffline(false);
@@ -457,6 +477,9 @@ export default function DashboardScreen() {
{/* Up Next Section */}
+ {/* Forum Activity */}
+
+
{/* Loading */}
{(isHydratingFromCache || (isLoading && isEmpty)) && (
diff --git a/src/app/course/[courseid].tsx b/src/app/course/[courseid].tsx
index a422a5b..9e89a35 100644
--- a/src/app/course/[courseid].tsx
+++ b/src/app/course/[courseid].tsx
@@ -1,3 +1,4 @@
+import { DownloadQueueModal } from "@/components/download/download-queue-modal";
import { Toast } from "@/components/shared/ui/molecules/toast";
import { Container } from "@/components/ui/container";
import { Colors } from "@/constants/theme";
@@ -5,6 +6,10 @@ import { useColorScheme } from "@/hooks/use-color-scheme";
import { downloadLmsResourceWithSession } from "@/services/lms-download";
import { useAuthStore } from "@/stores/auth-store";
import { useBunkStore } from "@/stores/bunk-store";
+import {
+ selectQueueStats,
+ useDownloadQueueStore,
+} from "@/stores/download-queue-store";
import {
LMS_RESOURCES_STALE_MS,
useLmsResourcesStore,
@@ -144,6 +149,28 @@ export default function CourseResourcesScreen() {
);
const totalSections = visibleSections.length;
+ const [showDownloadQueue, setShowDownloadQueue] = useState(false);
+ const enqueueAllFromCourse = useDownloadQueueStore(
+ (state) => state.enqueueAllFromCourse,
+ );
+ const queueItems = useDownloadQueueStore((state) => state.items);
+ const queueStats = selectQueueStats(
+ queueItems.filter((i) => i.courseId === courseId),
+ );
+
+ const handleDownloadAll = () => {
+ if (!tree) return;
+ const count = enqueueAllFromCourse(tree);
+ if (count > 0) {
+ Toast.show(`Queued ${count} file${count === 1 ? "" : "s"} for download`, {
+ type: "success",
+ });
+ setShowDownloadQueue(true);
+ } else {
+ Toast.show("All files already in queue", { type: "info" });
+ }
+ };
+
const [downloadProgressByUrl, setDownloadProgressByUrl] = useState<
Record
>({});
@@ -569,19 +596,70 @@ export default function CourseResourcesScreen() {
-
-
+ {tree && (
+
+
+
+ All
+
+
+ )}
+
+ {queueStats.total > 0 && (
+ setShowDownloadQueue(true)}
+ className="h-10 flex-row items-center gap-1.5 rounded-full border px-3"
+ style={{
+ backgroundColor: isDark ? Colors.gray[900] : Colors.white,
+ borderColor: theme.border,
+ }}
+ >
+ 0 ? Colors.status.info : theme.text}
+ />
+ 0 ? Colors.status.info : theme.text,
+ fontVariant: ["tabular-nums"],
+ }}
+ >
+ {queueStats.completed}/{queueStats.total}
+
+
+ )}
+
+
+
{formatSyncTime(entry?.lastSyncTime ?? null)}
+
@@ -859,6 +937,11 @@ export default function CourseResourcesScreen() {
)}
+
+ setShowDownloadQueue(false)}
+ />
);
}
diff --git a/src/components/dashboard/forum-section.tsx b/src/components/dashboard/forum-section.tsx
new file mode 100644
index 0000000..93e59df
--- /dev/null
+++ b/src/components/dashboard/forum-section.tsx
@@ -0,0 +1,171 @@
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from "@/hooks/use-color-scheme";
+import {
+ selectRecentDiscussions,
+ useForumStore,
+} from "@/stores/forum-store";
+import type { ForumDiscussionWithCourse } from "@/types";
+import { Ionicons } from "@expo/vector-icons";
+import { formatDistanceToNowStrict } from "date-fns";
+import { Linking } from "react-native";
+import { Pressable, Text, View } from "react-native";
+import { getCurrentBaseUrl } from "@/services/api";
+
+const formatTimeAgo = (unixSeconds: number): string => {
+ try {
+ return formatDistanceToNowStrict(new Date(unixSeconds * 1000), {
+ addSuffix: true,
+ });
+ } catch {
+ return "";
+ }
+};
+
+function ForumDiscussionCard({
+ discussion,
+}: {
+ discussion: ForumDiscussionWithCourse;
+}) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const theme = isDark ? Colors.dark : Colors.light;
+
+ const handlePress = () => {
+ const baseUrl = getCurrentBaseUrl();
+ const url = `${baseUrl}/mod/forum/discuss.php?d=${discussion.id}`;
+ void Linking.openURL(url);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {discussion.subject || discussion.name}
+
+
+
+
+ {discussion.courseName}
+
+
+ ·
+
+
+ {formatTimeAgo(discussion.timemodified)}
+
+
+
+ {discussion.numreplies > 0 && (
+
+
+
+ {discussion.numreplies}{" "}
+ {discussion.numreplies === 1 ? "reply" : "replies"}
+
+
+ )}
+
+
+
+ );
+}
+
+export function ForumSection() {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const theme = isDark ? Colors.dark : Colors.light;
+
+ const discussions = useForumStore((state) => state.discussions);
+ const isLoading = useForumStore((state) => state.isLoading);
+ const recentDiscussions = selectRecentDiscussions(discussions, 7);
+
+ if (recentDiscussions.length === 0 && !isLoading) {
+ return null;
+ }
+
+ // Show max 5 in the dashboard
+ const displayDiscussions = recentDiscussions.slice(0, 5);
+
+ return (
+
+
+
+
+ Forum Activity
+
+ {recentDiscussions.length > 5 && (
+
+
+ +{recentDiscussions.length - 5} more
+
+
+ )}
+
+
+
+ {displayDiscussions.map((discussion) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/download/download-queue-modal.tsx b/src/components/download/download-queue-modal.tsx
new file mode 100644
index 0000000..18b8f44
--- /dev/null
+++ b/src/components/download/download-queue-modal.tsx
@@ -0,0 +1,277 @@
+import { Colors } from "@/constants/theme";
+import { useColorScheme } from "@/hooks/use-color-scheme";
+import {
+ selectQueueStats,
+ useDownloadQueueStore,
+} from "@/stores/download-queue-store";
+import type { DownloadQueueItem } from "@/types";
+import { Ionicons } from "@expo/vector-icons";
+import { FlatList, Modal, Pressable, Text, View } from "react-native";
+
+function ProgressBar({
+ progress,
+ color,
+}: {
+ progress: number;
+ color: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+function QueueItem({
+ item,
+ onRetry,
+ onRemove,
+}: {
+ item: DownloadQueueItem;
+ onRetry: () => void;
+ onRemove: () => void;
+}) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const theme = isDark ? Colors.dark : Colors.light;
+
+ const statusConfig = {
+ pending: { icon: "time-outline" as const, color: theme.textSecondary, label: "Waiting" },
+ downloading: { icon: "cloud-download-outline" as const, color: Colors.status.info, label: "Downloading" },
+ completed: { icon: "checkmark-circle" as const, color: Colors.status.success, label: "Done" },
+ failed: { icon: "alert-circle" as const, color: Colors.status.danger, label: "Failed" },
+ };
+
+ const config = statusConfig[item.status];
+ const progressPercent =
+ item.progress !== null ? Math.round(item.progress * 100) : null;
+
+ return (
+
+
+
+
+
+ {item.fileName}
+
+
+ {item.courseName}
+
+
+ {item.status === "downloading" && (
+
+
+ {progressPercent !== null && (
+
+ {progressPercent}%
+
+ )}
+
+ )}
+
+ {item.error && (
+
+ {item.error}
+
+ )}
+
+
+
+ {item.status === "failed" && (
+
+
+
+ )}
+ {(item.status === "completed" || item.status === "failed" || item.status === "pending") && (
+
+
+
+ )}
+
+
+
+ );
+}
+
+interface DownloadQueueModalProps {
+ visible: boolean;
+ onClose: () => void;
+}
+
+export function DownloadQueueModal({
+ visible,
+ onClose,
+}: DownloadQueueModalProps) {
+ const colorScheme = useColorScheme();
+ const isDark = colorScheme === "dark";
+ const theme = isDark ? Colors.dark : Colors.light;
+
+ const items = useDownloadQueueStore((state) => state.items);
+ const retry = useDownloadQueueStore((state) => state.retry);
+ const remove = useDownloadQueueStore((state) => state.remove);
+ const clearFinished = useDownloadQueueStore((state) => state.clearFinished);
+ const clearAll = useDownloadQueueStore((state) => state.clearAll);
+
+ const stats = selectQueueStats(items);
+ const hasFinished = stats.completed > 0 || stats.failed > 0;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ Downloads
+
+ {stats.total > 0 && (
+
+ {stats.completed}/{stats.total} completed
+ {stats.failed > 0 ? ` · ${stats.failed} failed` : ""}
+
+ )}
+
+
+
+ {hasFinished && (
+
+
+ Clear done
+
+
+ )}
+
+
+
+
+
+
+ {/* Overall progress */}
+ {stats.total > 0 && stats.downloading > 0 && (
+
+
+
+ )}
+
+ {/* Queue list */}
+ {items.length === 0 ? (
+
+
+
+ No downloads in queue
+
+
+ ) : (
+ item.id}
+ contentContainerClassName="gap-2 p-4"
+ renderItem={({ item }) => (
+ retry(item.id)}
+ onRemove={() => remove(item.id)}
+ />
+ )}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/services/forum.ts b/src/services/forum.ts
new file mode 100644
index 0000000..19eaf49
--- /dev/null
+++ b/src/services/forum.ts
@@ -0,0 +1,162 @@
+import type {
+ ForumDiscussion,
+ ForumDiscussionWithCourse,
+ ForumInfo,
+ MoodleAjaxRequest,
+ MoodleAjaxResponse,
+} from "@/types";
+import { debug } from "@/utils/debug";
+import { api } from "./api";
+import { fetchCourses } from "./scraper";
+
+/** Extract sesskey from the Moodle dashboard page. */
+const getSesskey = async (): Promise => {
+ const response = await api.get("/my/");
+ const match = response.data.match(/"sesskey":"([^"]+)"/);
+ return match?.[1] ?? null;
+};
+
+interface MoodleForumsResponse {
+ error: boolean;
+ exception?: { message: string };
+ data: ForumInfo[];
+}
+
+interface MoodleDiscussionsResponse {
+ error: boolean;
+ exception?: { message: string };
+ data: {
+ discussions: ForumDiscussion[];
+ };
+}
+
+/** Fetch all forums for a set of course ids. */
+const fetchForumsByCourses = async (
+ sesskey: string,
+ courseIds: number[],
+): Promise => {
+ const payload: MoodleAjaxRequest[] = [
+ {
+ index: 0,
+ methodname: "mod_forum_get_forums_by_courses",
+ args: { courseids: courseIds },
+ },
+ ];
+
+ const response = await api.post(
+ `/lib/ajax/service.php?sesskey=${sesskey}&info=mod_forum_get_forums_by_courses`,
+ JSON.stringify(payload),
+ { headers: { "Content-Type": "application/json" } },
+ );
+
+ const data = response.data;
+ if (!Array.isArray(data) || data[0]?.error) {
+ throw new Error(
+ data[0]?.exception?.message ?? "Failed to fetch forums",
+ );
+ }
+
+ return data[0]?.data ?? [];
+};
+
+/** Fetch recent discussions for a single forum. */
+const fetchForumDiscussions = async (
+ sesskey: string,
+ forumId: number,
+ limit = 5,
+): Promise => {
+ const payload: MoodleAjaxRequest[] = [
+ {
+ index: 0,
+ methodname: "mod_forum_get_forum_discussions",
+ args: {
+ forumid: forumId,
+ sortby: "timemodified",
+ sortdirection: "DESC",
+ page: 0,
+ perpage: limit,
+ },
+ },
+ ];
+
+ const response = await api.post(
+ `/lib/ajax/service.php?sesskey=${sesskey}&info=mod_forum_get_forum_discussions`,
+ JSON.stringify(payload),
+ { headers: { "Content-Type": "application/json" } },
+ );
+
+ const data = response.data;
+ if (!Array.isArray(data) || data[0]?.error) {
+ return [];
+ }
+
+ return data[0]?.data?.discussions ?? [];
+};
+
+/** Fetch recent forum discussions across all enrolled courses. */
+export const fetchAllForumDiscussions = async (
+ maxPerForum = 5,
+): Promise => {
+ debug.scraper("=== FETCHING FORUM DISCUSSIONS ===");
+
+ const sesskey = await getSesskey();
+ if (!sesskey) {
+ throw new Error("Session key not found");
+ }
+
+ const courses = await fetchCourses();
+ if (courses.length === 0) {
+ return [];
+ }
+
+ const courseIds = courses.map((c) => Number(c.id));
+ const courseNameById = new Map(
+ courses.map((c) => [Number(c.id), c.name]),
+ );
+
+ let forums: ForumInfo[];
+ try {
+ forums = await fetchForumsByCourses(sesskey, courseIds);
+ } catch {
+ debug.scraper("Forum API not available, returning empty");
+ return [];
+ }
+
+ if (forums.length === 0) {
+ return [];
+ }
+
+ debug.scraper(`Found ${forums.length} forums across ${courses.length} courses`);
+
+ const allDiscussions: ForumDiscussionWithCourse[] = [];
+
+ // Fetch discussions in batches of 3 to avoid overloading
+ const batchSize = 3;
+ for (let i = 0; i < forums.length; i += batchSize) {
+ const batch = forums.slice(i, i + batchSize);
+ const results = await Promise.allSettled(
+ batch.map((forum) => fetchForumDiscussions(sesskey, forum.id, maxPerForum)),
+ );
+
+ for (let j = 0; j < results.length; j++) {
+ const result = results[j];
+ const forum = batch[j];
+ if (result.status !== "fulfilled" || !forum) continue;
+
+ for (const discussion of result.value) {
+ allDiscussions.push({
+ ...discussion,
+ courseId: forum.course,
+ courseName: courseNameById.get(forum.course) ?? `Course ${forum.course}`,
+ forumName: forum.name,
+ });
+ }
+ }
+ }
+
+ // Sort by most recent first
+ allDiscussions.sort((a, b) => b.timemodified - a.timemodified);
+
+ debug.scraper(`Found ${allDiscussions.length} total forum discussions`);
+ return allDiscussions;
+};
diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts
index b7aa5de..da833c3 100644
--- a/src/stores/auth-store.ts
+++ b/src/stores/auth-store.ts
@@ -16,6 +16,7 @@ import { useDashboardStore } from "@/stores/dashboard-store";
import { useFacultyStore } from "@/stores/faculty-store";
import { useLmsResourcesStore } from "@/stores/lms-resources-store";
import { useAssignmentStore } from "@/stores/assignment-store";
+import { useForumStore } from "@/stores/forum-store";
import { useTimetableStore } from "@/stores/timetable-store";
import type { AuthState } from "@/types";
import { scheduleIdleTask } from "@/utils/scheduling";
@@ -115,6 +116,7 @@ export const useAuthStore = create((set) => ({
useFacultyStore.getState().clearRecentSearches();
useLmsResourcesStore.getState().clearCourseResources();
useAssignmentStore.getState().clearAssignmentCache();
+ useForumStore.getState().clearForum();
useAttendanceUIStore.getState().resetUI();
await authService.logout();
diff --git a/src/stores/download-queue-store.ts b/src/stores/download-queue-store.ts
new file mode 100644
index 0000000..6ed66b4
--- /dev/null
+++ b/src/stores/download-queue-store.ts
@@ -0,0 +1,270 @@
+import { downloadLmsResourceWithSession } from "@/services/lms-download";
+import type {
+ DownloadQueueItem,
+ DownloadQueueItemStatus,
+ DownloadQueueState,
+ LmsCourseResourcesTree,
+ LmsResourceFileNode,
+ LmsResourceItemNode,
+} from "@/types";
+import { create } from "zustand";
+
+const generateId = () =>
+ `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+
+const MAX_CONCURRENT = 2;
+
+interface DownloadQueueActions {
+ /** Add a single URL to the download queue. */
+ enqueue: (params: {
+ url: string;
+ fileName: string;
+ courseId: string;
+ courseName: string;
+ }) => void;
+
+ /** Add all downloadable resources from a course tree. */
+ enqueueAllFromCourse: (tree: LmsCourseResourcesTree) => number;
+
+ /** Remove a single item from the queue. */
+ remove: (id: string) => void;
+
+ /** Retry a failed download. */
+ retry: (id: string) => void;
+
+ /** Clear completed and failed items. */
+ clearFinished: () => void;
+
+ /** Clear the entire queue. */
+ clearAll: () => void;
+}
+
+type DownloadQueueStore = DownloadQueueState & DownloadQueueActions;
+
+/** Internal: process the queue, downloading up to maxConcurrent items. */
+const processQueue = () => {
+ const state = useDownloadQueueStore.getState();
+ const activeCount = state.items.filter(
+ (item) => item.status === "downloading",
+ ).length;
+ const slotsAvailable = state.maxConcurrent - activeCount;
+ if (slotsAvailable <= 0) return;
+
+ const pendingItems = state.items.filter(
+ (item) => item.status === "pending",
+ );
+ const toStart = pendingItems.slice(0, slotsAvailable);
+
+ for (const item of toStart) {
+ void downloadItem(item.id);
+ }
+};
+
+/** Internal: download a single queue item. */
+const downloadItem = async (itemId: string) => {
+ const store = useDownloadQueueStore;
+ const item = store.getState().items.find((i) => i.id === itemId);
+ if (!item || item.status !== "pending") return;
+
+ // Mark as downloading
+ store.setState((state) => ({
+ items: state.items.map((i) =>
+ i.id === itemId ? { ...i, status: "downloading" as const, error: null } : i,
+ ),
+ }));
+
+ const result = await downloadLmsResourceWithSession(
+ item.url,
+ item.fileName,
+ {
+ onProgress: (progress) => {
+ store.setState((state) => ({
+ items: state.items.map((i) =>
+ i.id === itemId ? { ...i, progress: progress.fraction } : i,
+ ),
+ }));
+ },
+ },
+ );
+
+ if (result.success) {
+ store.setState((state) => ({
+ items: state.items.map((i) =>
+ i.id === itemId
+ ? {
+ ...i,
+ status: "completed" as const,
+ progress: 1,
+ localUri: result.uri,
+ contentType: result.contentType,
+ completedAt: Date.now(),
+ }
+ : i,
+ ),
+ }));
+ } else {
+ store.setState((state) => ({
+ items: state.items.map((i) =>
+ i.id === itemId
+ ? {
+ ...i,
+ status: "failed" as const,
+ error: result.message,
+ progress: null,
+ }
+ : i,
+ ),
+ }));
+ }
+
+ // Process next in queue
+ processQueue();
+};
+
+/** Collect all downloadable URLs from a course resource tree. */
+const collectDownloadableUrls = (
+ tree: LmsCourseResourcesTree,
+): { url: string; fileName: string }[] => {
+ const results: { url: string; fileName: string }[] = [];
+
+ for (const section of tree.sections) {
+ for (const item of section.items) {
+ if (item.moduleType === "resource") {
+ results.push({ url: item.url, fileName: item.title });
+ }
+ if (item.moduleType === "folder" && item.children.length > 0) {
+ for (const child of item.children) {
+ results.push({ url: child.url, fileName: child.name });
+ }
+ }
+ }
+ }
+
+ return results;
+};
+
+export const useDownloadQueueStore = create((set, get) => ({
+ items: [],
+ maxConcurrent: MAX_CONCURRENT,
+
+ enqueue: (params) => {
+ // Skip if already in queue (same URL, not failed)
+ const existing = get().items.find(
+ (i) => i.url === params.url && i.status !== "failed",
+ );
+ if (existing) return;
+
+ const newItem: DownloadQueueItem = {
+ id: generateId(),
+ url: params.url,
+ fileName: params.fileName,
+ courseId: params.courseId,
+ courseName: params.courseName,
+ status: "pending",
+ progress: null,
+ error: null,
+ localUri: null,
+ contentType: null,
+ addedAt: Date.now(),
+ completedAt: null,
+ };
+
+ set((state) => ({
+ items: [...state.items, newItem],
+ }));
+
+ processQueue();
+ },
+
+ enqueueAllFromCourse: (tree) => {
+ const downloadables = collectDownloadableUrls(tree);
+ const existingUrls = new Set(
+ get()
+ .items.filter((i) => i.status !== "failed")
+ .map((i) => i.url),
+ );
+
+ const newItems: DownloadQueueItem[] = downloadables
+ .filter((d) => !existingUrls.has(d.url))
+ .map((d) => ({
+ id: generateId(),
+ url: d.url,
+ fileName: d.fileName,
+ courseId: tree.courseId,
+ courseName: tree.courseTitle,
+ status: "pending" as const,
+ progress: null,
+ error: null,
+ localUri: null,
+ contentType: null,
+ addedAt: Date.now(),
+ completedAt: null,
+ }));
+
+ if (newItems.length === 0) return 0;
+
+ set((state) => ({
+ items: [...state.items, ...newItems],
+ }));
+
+ processQueue();
+ return newItems.length;
+ },
+
+ remove: (id) => {
+ set((state) => ({
+ items: state.items.filter((i) => i.id !== id),
+ }));
+ },
+
+ retry: (id) => {
+ set((state) => ({
+ items: state.items.map((i) =>
+ i.id === id
+ ? {
+ ...i,
+ status: "pending" as const,
+ error: null,
+ progress: null,
+ localUri: null,
+ completedAt: null,
+ }
+ : i,
+ ),
+ }));
+ processQueue();
+ },
+
+ clearFinished: () => {
+ set((state) => ({
+ items: state.items.filter(
+ (i) => i.status !== "completed" && i.status !== "failed",
+ ),
+ }));
+ },
+
+ clearAll: () => {
+ set({ items: [] });
+ },
+}));
+
+// Selectors
+export const selectQueueStats = (items: DownloadQueueItem[]) => {
+ const pending = items.filter((i) => i.status === "pending").length;
+ const downloading = items.filter((i) => i.status === "downloading").length;
+ const completed = items.filter((i) => i.status === "completed").length;
+ const failed = items.filter((i) => i.status === "failed").length;
+ const total = items.length;
+
+ const totalProgress =
+ total > 0
+ ? items.reduce((sum, i) => {
+ if (i.status === "completed") return sum + 1;
+ if (i.status === "downloading" && i.progress !== null)
+ return sum + i.progress;
+ return sum;
+ }, 0) / total
+ : 0;
+
+ return { pending, downloading, completed, failed, total, totalProgress };
+};
diff --git a/src/stores/forum-store.ts b/src/stores/forum-store.ts
new file mode 100644
index 0000000..69175dd
--- /dev/null
+++ b/src/stores/forum-store.ts
@@ -0,0 +1,96 @@
+import { fetchAllForumDiscussions } from "@/services/forum";
+import type { ForumDiscussionWithCourse, ForumState } from "@/types";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+import { zustandStorage } from "./storage";
+
+interface ForumStoreState extends ForumState {
+ hasHydrated: boolean;
+}
+
+interface ForumActions {
+ fetchForumDiscussions: (options?: {
+ silent?: boolean;
+ }) => Promise;
+ clearForum: () => void;
+ setHasHydrated: (hasHydrated: boolean) => void;
+}
+
+const FORUM_STALE_MS = 15 * 60 * 1000; // 15 minutes
+
+export const useForumStore = create()(
+ persist(
+ (set, get) => ({
+ discussions: [],
+ isLoading: false,
+ lastSyncTime: null,
+ error: null,
+ hasHydrated: false,
+
+ setHasHydrated: (hasHydrated) => set({ hasHydrated }),
+
+ fetchForumDiscussions: async (options) => {
+ const silent = options?.silent ?? false;
+
+ // Skip if recently synced
+ const { lastSyncTime } = get();
+ if (lastSyncTime && Date.now() - lastSyncTime < FORUM_STALE_MS && !silent) {
+ return;
+ }
+
+ if (!silent) {
+ set({ isLoading: true, error: null });
+ }
+
+ try {
+ const discussions = await fetchAllForumDiscussions();
+
+ set({
+ discussions,
+ lastSyncTime: Date.now(),
+ isLoading: false,
+ error: null,
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : "Failed to fetch forum discussions";
+
+ if (!silent) {
+ set({ error: message, isLoading: false });
+ }
+ }
+ },
+
+ clearForum: () => {
+ set({
+ discussions: [],
+ lastSyncTime: null,
+ error: null,
+ isLoading: false,
+ });
+ },
+ }),
+ {
+ name: "forum-storage",
+ storage: createJSONStorage(() => zustandStorage),
+ partialize: (state) => ({
+ discussions: state.discussions,
+ lastSyncTime: state.lastSyncTime,
+ }),
+ onRehydrateStorage: () => (state) => {
+ state?.setHasHydrated(true);
+ },
+ },
+ ),
+);
+
+/** Select discussions from the last N days. */
+export const selectRecentDiscussions = (
+ discussions: ForumDiscussionWithCourse[],
+ days = 7,
+): ForumDiscussionWithCourse[] => {
+ const cutoff = Math.floor(Date.now() / 1000) - days * 24 * 60 * 60;
+ return discussions.filter((d) => d.timemodified >= cutoff);
+};
diff --git a/src/types/download-queue.ts b/src/types/download-queue.ts
new file mode 100644
index 0000000..0adddc4
--- /dev/null
+++ b/src/types/download-queue.ts
@@ -0,0 +1,31 @@
+/**
+ * Download queue types for batch resource downloads
+ */
+
+export type DownloadQueueItemStatus =
+ | "pending"
+ | "downloading"
+ | "completed"
+ | "failed";
+
+export interface DownloadQueueItem {
+ id: string;
+ url: string;
+ fileName: string;
+ courseId: string;
+ courseName: string;
+ status: DownloadQueueItemStatus;
+ /** 0-1 fraction, null if unknown */
+ progress: number | null;
+ error: string | null;
+ /** Local file URI after download */
+ localUri: string | null;
+ contentType: string | null;
+ addedAt: number;
+ completedAt: number | null;
+}
+
+export interface DownloadQueueState {
+ items: DownloadQueueItem[];
+ maxConcurrent: number;
+}
diff --git a/src/types/forum.ts b/src/types/forum.ts
new file mode 100644
index 0000000..eaf499c
--- /dev/null
+++ b/src/types/forum.ts
@@ -0,0 +1,52 @@
+/**
+ * Moodle forum / discussion types
+ */
+
+export interface ForumInfo {
+ /** Moodle forum cmid */
+ id: number;
+ /** Parent course id */
+ course: number;
+ name: string;
+ type: string;
+}
+
+export interface ForumDiscussion {
+ id: number;
+ name: string;
+ /** Unix seconds */
+ timemodified: number;
+ /** Unix seconds */
+ created: number;
+ usermodified: number;
+ userfullname: string;
+ subject: string;
+ message: string;
+ numreplies: number;
+ pinned: boolean;
+}
+
+export interface ForumPost {
+ id: number;
+ discussionid: number;
+ subject: string;
+ message: string;
+ /** Unix seconds */
+ timecreated: number;
+ /** Unix seconds */
+ timemodified: number;
+ userfullname: string;
+}
+
+export interface ForumDiscussionWithCourse extends ForumDiscussion {
+ courseId: number;
+ courseName: string;
+ forumName: string;
+}
+
+export interface ForumState {
+ discussions: ForumDiscussionWithCourse[];
+ isLoading: boolean;
+ lastSyncTime: number | null;
+ error: string | null;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index e2f8a8a..4fc1e14 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -20,3 +20,5 @@ export * from "./resources";
export * from "./timetable";
export * from "./wifix";
export * from "./popup";
+export * from "./forum";
+export * from "./download-queue";