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";