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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/app/(tabs)/index.tsx
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";
Expand All @@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
Comment on lines +196 to +203
Copy link

Copilot AI Apr 8, 2026

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 to true does not trigger this useEffect to rerun (it’s not a dependency). If hasHydrated is 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.

Suggested change
if (!hasCompletedInitialRefresh.current) return;
hasDeferredForumFetch.current = true;
const task = InteractionManager.runAfterInteractions(() => {
void fetchForumDiscussions({ silent: true });
});
return () => task.cancel();
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let task: ReturnType<
typeof InteractionManager.runAfterInteractions
> | null = null;
let cancelled = false;
const scheduleForumFetch = () => {
if (cancelled || hasDeferredForumFetch.current) return;
if (!hasCompletedInitialRefresh.current) {
timeoutId = setTimeout(scheduleForumFetch, 250);
return;
}
hasDeferredForumFetch.current = true;
task = InteractionManager.runAfterInteractions(() => {
void fetchForumDiscussions({ silent: true });
});
};
scheduleForumFetch();
return () => {
cancelled = true;
task?.cancel();
if (timeoutId) {
clearTimeout(timeoutId);
}
};

Copilot uses AI. Check for mistakes.
}, [fetchForumDiscussions, hasHydrated, isOffline]);
Comment on lines +192 to +204
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

This effect can miss the deferred forum fetch entirely.

hasCompletedInitialRefresh.current is mutated inside the dashboard refresh callbacks, but it is not a dependency here. Because ref writes do not retrigger effects, the first pass returns on Line 196 and never reruns when the initial refresh completes. hasDeferredForumFetch.current is also flipped before the InteractionManager task executes, so a cancellation/failure path suppresses later retries too.

💡 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
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(tabs)/index.tsx around lines 192 - 204, The effect that defers
fetchForumDiscussions can never run after initial refresh because it reads and
returns based on hasCompletedInitialRefresh.current (a ref) but does not depend
on its change; update the useEffect to include hasCompletedInitialRefresh (e.g.,
a derived boolean from the ref or a state flag) in its dependency array or
trigger the effect when the refresh completes, and move the
hasDeferredForumFetch.current = true assignment inside the
InteractionManager.runAfterInteractions callback (only after the task actually
starts) so cancellations don't permanently flip the flag; keep
fetchForumDiscussions and the existing guards (hasHydrated, isOffline) but
ensure the returned cleanup cancels the scheduled task correctly.


useEffect(() => {
if (isOffline && lastSyncTime) {
setOffline(false);
Expand Down Expand Up @@ -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">
Expand Down
103 changes: 93 additions & 10 deletions src/app/course/[courseid].tsx
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,
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 courseId to DownloadQueueModal and filter items internally, or make both badge and modal global.

The header stats are scoped to the current course (queueItems.filter((i) => i.courseId === courseId)), but the modal opened from that button has no course awareness. It displays and allows retry/remove on queue items from all courses. Either pass the current courseId into the modal and filter its item list, or remove the filter from the badge/stats so both reflect the full queue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/course/`[courseid].tsx around lines 157 - 159, The header stats use a
course-scoped filter (selectQueueStats(queueItems.filter(i => i.courseId ===
courseId))) but DownloadQueueModal is currently global; update the
DownloadQueueModal invocation to accept a courseId prop and then filter its
displayed items internally (use the passed courseId to filter queueItems inside
DownloadQueueModal before render and for retry/remove actions). Ensure you
update the DownloadQueueModal props/type to include courseId and adjust any
handlers (retry/remove) to only operate on items matching that courseId;
alternatively, if you prefer a global modal, remove the local filter from
selectQueueStats so both badge and modal use the same unfiltered queueItems.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

count === 0 is ambiguous here.

enqueueAllFromCourse() returns the number of newly enqueued items, not whether the course had downloadable files at all. On a course with zero downloadable resources, this branch still tells the user "All files already in queue", which is misleading. Hide the button when there are no downloadables, or have the store return a separate status for that case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/course/`[courseid].tsx around lines 161 - 170, The UI uses
enqueueAllFromCourse(tree) return value (count) to decide message, but count===0
conflates "no files downloadable" with "files exist but already queued"; update
the logic: either change enqueueAllFromCourse to return a richer result (e.g., {
enqueuedCount, hasDownloadables }) or add a selector/function like
hasDownloadablesFromCourse(tree) to detect whether the course contains
downloadable resources, then in handleDownloadAll use hasDownloadables to
early-return or hide/disable the download-all button when false, and use
enqueuedCount strictly to decide the "queued X files" vs "all files already in
queue" Toast messaging; reference functions: enqueueAllFromCourse and
handleDownloadAll (and new hasDownloadablesFromCourse or enriched return) when
making the change.

}
};

const [downloadProgressByUrl, setDownloadProgressByUrl] = useState<
Record<string, LmsDownloadProgress>
>({});
Expand Down Expand Up @@ -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>

Expand Down Expand Up @@ -859,6 +937,11 @@ export default function CourseResourcesScreen() {
</View>
)}
</ScrollView>

<DownloadQueueModal
visible={showDownloadQueue}
onClose={() => setShowDownloadQueue(false)}
/>
</Container>
);
}
171 changes: 171 additions & 0 deletions src/components/dashboard/forum-section.tsx
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>
);
}
Loading
Loading