-
Notifications
You must be signed in to change notification settings - Fork 11
feat: forum monitor + batch download queue #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]); | ||
|
Comment on lines
+192
to
+204
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This effect can miss the deferred forum fetch entirely.
💡 One deterministic pattern- const hasCompletedInitialRefresh = useRef(false);
- const hasDeferredForumFetch = useRef(false);
+ const [hasCompletedInitialRefresh, setHasCompletedInitialRefresh] =
+ useState(false);
...
- hasCompletedInitialRefresh.current = true;
+ setHasCompletedInitialRefresh(true);
...
- hasCompletedInitialRefresh.current = true;
+ setHasCompletedInitialRefresh(true);
...
useEffect(() => {
- if (!hasHydrated || isOffline) return;
- if (hasDeferredForumFetch.current) return;
- if (!hasCompletedInitialRefresh.current) return;
- hasDeferredForumFetch.current = true;
+ if (!hasHydrated || isOffline || !hasCompletedInitialRefresh) return;
const task = InteractionManager.runAfterInteractions(() => {
void fetchForumDiscussions({ silent: true });
});
return () => task.cancel();
- }, [fetchForumDiscussions, hasHydrated, isOffline]);
+ }, [
+ fetchForumDiscussions,
+ hasCompletedInitialRefresh,
+ hasHydrated,
+ isOffline,
+ ]);🤖 Prompt for AI Agents |
||
|
|
||
| useEffect(() => { | ||
| if (isOffline && lastSyncTime) { | ||
| setOffline(false); | ||
|
|
@@ -457,6 +477,9 @@ export default function DashboardScreen() { | |
| {/* Up Next Section */} | ||
| <UpNextSection /> | ||
|
|
||
| {/* Forum Activity */} | ||
| <ForumSection /> | ||
|
|
||
| {/* Loading */} | ||
| {(isHydratingFromCache || (isLoading && isEmpty)) && ( | ||
| <View className="items-center gap-4 py-12"> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,15 @@ | ||
| 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"; | ||
| 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), | ||
| ); | ||
|
Comment on lines
+157
to
+159
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
modal_file="$(fd -i 'download-queue-modal\.tsx$' src/components/download | head -n1)"
if [ -z "$modal_file" ]; then
echo "download-queue-modal.tsx not found" >&2
exit 1
fi
echo "== $modal_file =="
sed -n '1,320p' "$modal_file"
echo
echo "== course filtering signals =="
rg -n -C2 'courseId|useDownloadQueueStore|filter\(|items\b' "$modal_file" 'src/app/course/[courseid].tsx'Repository: Noelithub77/bunkialo2 Length of output: 30525 Pass The header stats are scoped to the current course ( 🤖 Prompt for AI Agents |
||
|
|
||
| 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" }); | ||
|
Comment on lines
+161
to
+170
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🤖 Prompt for AI Agents |
||
| } | ||
| }; | ||
|
|
||
| const [downloadProgressByUrl, setDownloadProgressByUrl] = useState< | ||
| Record<string, LmsDownloadProgress> | ||
| >({}); | ||
|
|
@@ -569,19 +596,70 @@ export default function CourseResourcesScreen() { | |
| <Ionicons name="arrow-back" size={21} color={theme.text} /> | ||
| </Pressable> | ||
|
|
||
| <View | ||
| className="rounded-full border px-3 py-1.5" | ||
| style={{ | ||
| backgroundColor: isDark ? Colors.gray[900] : Colors.white, | ||
| borderColor: theme.border, | ||
| }} | ||
| > | ||
| <Text | ||
| className="text-[10px]" | ||
| style={{ color: theme.textSecondary }} | ||
| <View className="flex-row items-center gap-2"> | ||
| {tree && ( | ||
| <Pressable | ||
| onPress={handleDownloadAll} | ||
| 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, | ||
| }} | ||
| > | ||
| <Ionicons | ||
| name="download-outline" | ||
| size={15} | ||
| color={theme.text} | ||
| /> | ||
| <Text | ||
| className="text-[11px] font-semibold" | ||
| style={{ color: theme.text }} | ||
| > | ||
| All | ||
| </Text> | ||
| </Pressable> | ||
| )} | ||
|
|
||
| {queueStats.total > 0 && ( | ||
| <Pressable | ||
| onPress={() => 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, | ||
| }} | ||
| > | ||
| <Ionicons | ||
| name="cloud-download-outline" | ||
| size={15} | ||
| color={queueStats.downloading > 0 ? Colors.status.info : theme.text} | ||
| /> | ||
| <Text | ||
| className="text-[11px] font-semibold" | ||
| style={{ | ||
| color: queueStats.downloading > 0 ? Colors.status.info : theme.text, | ||
| fontVariant: ["tabular-nums"], | ||
| }} | ||
| > | ||
| {queueStats.completed}/{queueStats.total} | ||
| </Text> | ||
| </Pressable> | ||
| )} | ||
|
|
||
| <View | ||
| className="rounded-full border px-3 py-1.5" | ||
| style={{ | ||
| backgroundColor: isDark ? Colors.gray[900] : Colors.white, | ||
| borderColor: theme.border, | ||
| }} | ||
| > | ||
| <Text | ||
| className="text-[10px]" | ||
| style={{ color: theme.textSecondary }} | ||
| > | ||
| {formatSyncTime(entry?.lastSyncTime ?? null)} | ||
| </Text> | ||
| </View> | ||
| </View> | ||
| </View> | ||
|
|
||
|
|
@@ -859,6 +937,11 @@ export default function CourseResourcesScreen() { | |
| </View> | ||
| )} | ||
| </ScrollView> | ||
|
|
||
| <DownloadQueueModal | ||
| visible={showDownloadQueue} | ||
| onClose={() => setShowDownloadQueue(false)} | ||
| /> | ||
| </Container> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <Pressable | ||
| onPress={handlePress} | ||
| className="rounded-2xl border px-3.5 py-3" | ||
| style={{ | ||
| backgroundColor: theme.backgroundSecondary, | ||
| borderColor: theme.border, | ||
| }} | ||
| > | ||
| <View className="flex-row items-start gap-2.5"> | ||
| <View | ||
| className="mt-0.5 h-8 w-8 items-center justify-center rounded-full" | ||
| style={{ | ||
| backgroundColor: isDark ? "rgba(99,102,241,0.15)" : "rgba(99,102,241,0.1)", | ||
| }} | ||
| > | ||
| <Ionicons | ||
| name="chatbubbles-outline" | ||
| size={15} | ||
| color={isDark ? "#818cf8" : "#6366f1"} | ||
| /> | ||
| </View> | ||
|
|
||
| <View className="flex-1"> | ||
| <Text | ||
| className="text-[13px] font-semibold" | ||
| style={{ color: theme.text }} | ||
| numberOfLines={2} | ||
| > | ||
| {discussion.subject || discussion.name} | ||
| </Text> | ||
|
|
||
| <View className="mt-1 flex-row items-center gap-1.5"> | ||
| <Text | ||
| className="text-[11px]" | ||
| style={{ color: theme.textSecondary }} | ||
| numberOfLines={1} | ||
| > | ||
| {discussion.courseName} | ||
| </Text> | ||
| <Text className="text-[9px]" style={{ color: theme.textSecondary }}> | ||
| · | ||
| </Text> | ||
| <Text | ||
| className="text-[11px]" | ||
| style={{ color: theme.textSecondary }} | ||
| > | ||
| {formatTimeAgo(discussion.timemodified)} | ||
| </Text> | ||
| </View> | ||
|
|
||
| {discussion.numreplies > 0 && ( | ||
| <View className="mt-1.5 flex-row items-center gap-1"> | ||
| <Ionicons | ||
| name="return-down-forward-outline" | ||
| size={11} | ||
| color={theme.textSecondary} | ||
| /> | ||
| <Text | ||
| className="text-[10px]" | ||
| style={{ color: theme.textSecondary }} | ||
| > | ||
| {discussion.numreplies}{" "} | ||
| {discussion.numreplies === 1 ? "reply" : "replies"} | ||
| </Text> | ||
| </View> | ||
| )} | ||
| </View> | ||
| </View> | ||
| </Pressable> | ||
| ); | ||
| } | ||
|
|
||
| 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 ( | ||
| <View className="mb-6"> | ||
| <View className="mb-3 flex-row items-center gap-2"> | ||
| <Ionicons | ||
| name="chatbubbles-outline" | ||
| size={16} | ||
| color={theme.textSecondary} | ||
| /> | ||
| <Text | ||
| className="text-lg font-bold tracking-tight" | ||
| style={{ color: theme.text }} | ||
| > | ||
| Forum Activity | ||
| </Text> | ||
| {recentDiscussions.length > 5 && ( | ||
| <View | ||
| className="rounded-full px-2 py-0.5" | ||
| style={{ | ||
| backgroundColor: isDark | ||
| ? Colors.gray[800] | ||
| : Colors.gray[200], | ||
| }} | ||
| > | ||
| <Text | ||
| className="text-[10px] font-semibold" | ||
| style={{ color: theme.textSecondary }} | ||
| > | ||
| +{recentDiscussions.length - 5} more | ||
| </Text> | ||
| </View> | ||
| )} | ||
| </View> | ||
|
|
||
| <View className="gap-2"> | ||
| {displayDiscussions.map((discussion) => ( | ||
| <ForumDiscussionCard | ||
| key={`${discussion.id}-${discussion.timemodified}`} | ||
| discussion={discussion} | ||
| /> | ||
| ))} | ||
| </View> | ||
| </View> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The deferred forum fetch effect relies on
hasCompletedInitialRefresh.current, but that ref changing totruedoes not trigger thisuseEffectto rerun (it’s not a dependency). IfhasHydratedis already true when the initial refresh completes, this effect can exit early and never fetch forum discussions. Consider triggering the forum fetch directly when the initial refresh finishes (same async block where the ref is set), or replace the ref with state (e.g.,initialRefreshCompleted) and include it in the dependency list.