diff --git a/.env.example b/.env.example index 2438289..7cd8a7b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,10 @@ +NODE_ENV=production +PORT=3000 + +#Nixpacks chooses node 18 but next.js needs node 20.9+. +NIXPACKS_NODE_VERSION=22 + + NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= @@ -8,3 +15,4 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_MAC_AUTH_URL=https://auth.monashcoding.com VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +STUDY_REMINDER_CRON_SECRET= diff --git a/README.md b/README.md index df3cc98..435266f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +TEST # MAC Study MAC Study is a mobile-first PWA for Monash Association of Coding students to @@ -73,6 +74,20 @@ NEXT_PUBLIC_MAC_AUTH_URL=https://auth.monashcoding.com The signing JWK is server-only. Never prefix it with `NEXT_PUBLIC_`, expose it to browser code, or commit it to Git. +### Study reminder scheduler + +Study reminders are claimed atomically by `claim_due_study_reminders` and sent +by `POST /api/study-reminders/run`. After applying +`20260805010000_study_session_reminders.sql`, configure a once-per-minute +Supabase Cron or Dokploy job to call that endpoint with: + +```text +Authorization: Bearer +``` + +The scheduler secret is server-only. Do not add it to a migration or browser +environment variable. + ## Scripts ```bash diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(app)/app/profile/page.tsx b/src/app/(app)/app/profile/page.tsx index 764ff75..859e34b 100644 --- a/src/app/(app)/app/profile/page.tsx +++ b/src/app/(app)/app/profile/page.tsx @@ -8,6 +8,8 @@ export default async function ProfilePage() { return ( ); diff --git a/src/app/api/study-reminders/run/route.ts b/src/app/api/study-reminders/run/route.ts new file mode 100644 index 0000000..fbacb02 --- /dev/null +++ b/src/app/api/study-reminders/run/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { sendWebPush } from "@/lib/push/send-web-push"; +import { createSupabaseAdminClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +type DueStudyReminder = { + reminder_interval_minutes: number; + session_id: string; + started_at: string; + user_id: string; +}; + +export async function POST(request: Request) { + if (!isAuthorized(request)) { + return NextResponse.json({ message: "Unauthorized." }, { status: 401 }); + } + + const admin = createSupabaseAdminClient(); + if (!admin) { + return NextResponse.json( + { message: "Supabase is not configured." }, + { status: 503 }, + ); + } + + const { data, error } = await admin.rpc("claim_due_study_reminders", { + batch_size: 100, + }); + + if (error) { + return NextResponse.json({ message: error.message }, { status: 500 }); + } + + const reminders = (data ?? []) as DueStudyReminder[]; + const deliveries = await Promise.allSettled( + reminders.map((reminder) => + sendWebPush({ + body: `Your timer has been running for ${formatElapsed(reminder.started_at)}. Tap to check in.`, + category: "study_reminder", + tag: `mac-study-reminder-${reminder.session_id}`, + title: "Still studying?", + url: "/app?study-reminder=check", + userId: reminder.user_id, + }), + ), + ); + const delivered = deliveries.reduce( + (count, delivery) => + count + (delivery.status === "fulfilled" ? delivery.value.sent : 0), + 0, + ); + + return NextResponse.json({ + claimed: reminders.length, + delivered, + }); +} + +function isAuthorized(request: Request) { + const secret = process.env.STUDY_REMINDER_CRON_SECRET; + if (!secret) return false; + + return request.headers.get("authorization") === `Bearer ${secret}`; +} + +function formatElapsed(startedAt: string) { + const elapsedMinutes = Math.max( + 1, + Math.floor((Date.now() - new Date(startedAt).getTime()) / 60_000), + ); + + if (elapsedMinutes < 60) return `${elapsedMinutes} minutes`; + + const hours = Math.floor(elapsedMinutes / 60); + const minutes = elapsedMinutes % 60; + return minutes ? `${hours} hr ${minutes} min` : `${hours} hr`; +} diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 3d9f404..3e52c99 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -27,6 +27,7 @@ import { import { createSupabaseBrowserClient } from "@/lib/supabase/browser"; import { AppWorkspace } from "@/components/app-workspace"; import { AppHeaderDetailProvider } from "@/components/app-header-detail"; +import { InstallOnboarding } from "@/components/pwa/install-onboarding"; import { NotificationOnboarding } from "@/components/pwa/notification-onboarding"; import { AppNotifications } from "@/components/social/app-notifications"; import { NudgeNotifications } from "@/components/social/nudge-notifications"; @@ -95,6 +96,9 @@ export function AppShell({ const [workspaceResetKeys, setWorkspaceResetKeys] = useState< Record >({}); + const [navUnread, setNavUnread] = useState({ friends: false, groups: false }); + const [installOnboardingComplete, setInstallOnboardingComplete] = + useState(false); const scrollContainerRef = useRef(null); const scrollPositionsRef = useRef>({}); const currentNav = @@ -106,6 +110,9 @@ export function AppShell({ (isActive(displayPathname, "/app/groups") || isActive(displayPathname, "/app/units")); const currentTitle = isNestedDetail ? headerDetail : currentNav.title; + const isFriendsView = isActive(displayPathname, "/app/friends"); + const isShortMobileView = + displayPathname === "/app/statistics" || displayPathname === "/app/profile"; const accountName = authState.mode === "authenticated" ? authState.profile.display_name?.trim() || "Student" @@ -125,6 +132,9 @@ export function AppShell({ }, [], ); + const handleInstallOnboardingComplete = useCallback(() => { + setInstallOnboardingComplete(true); + }, []); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -290,7 +300,10 @@ export function AppShell({ <>
-
+
@@ -371,12 +395,38 @@ export function AppShell({
-
-
+
+
+ setNavUnread((current) => + current.friends === hasUnread + ? current + : { ...current, friends: hasUnread }, + ) + } + onGroupChatUnreadChange={(hasUnread) => + setNavUnread((current) => + current.groups === hasUnread + ? current + : { ...current, groups: hasUnread }, + ) + } resetKeys={workspaceResetKeys} />
@@ -389,6 +439,10 @@ export function AppShell({ {navItems.map((item) => { const Icon = item.icon; const active = isActive(displayPathname, item.href); + const hasUnread = + item.href === "/app/friends" + ? navUnread.friends + : item.href === "/app/groups" && navUnread.groups; return ( warmRoute(item.href)} prefetch > - + + + {hasUnread ? : null} + {"mobileLabel" in item ? item.mobileLabel : item.label} @@ -422,7 +479,14 @@ export function AppShell({ <> - + + ) : null} @@ -466,6 +530,7 @@ function LogoMark({ size = "md" }: { size?: "sm" | "md" }) { function NavLink({ href, + hasUnread, icon: Icon, isActive, label, @@ -473,6 +538,7 @@ function NavLink({ onNavigate, }: { href: string; + hasUnread: boolean; icon: React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>; isActive: boolean; label: string; @@ -495,15 +561,18 @@ function NavLink({ onPointerEnter={() => onIntent(href)} prefetch > - - + + + + + {hasUnread ? : null} {label} + + Unread messages + + ); +} + function DesktopAccount({ handle, mode, diff --git a/src/components/app-workspace.tsx b/src/components/app-workspace.tsx index cdbd904..eaa42e2 100644 --- a/src/components/app-workspace.tsx +++ b/src/components/app-workspace.tsx @@ -24,11 +24,15 @@ export function AppWorkspace({ activePathname, authState, fallback, + onDirectMessageUnreadChange, + onGroupChatUnreadChange, resetKeys, }: { activePathname: string; authState: AppAuthState; fallback: React.ReactNode; + onDirectMessageUnreadChange: (hasUnread: boolean) => void; + onGroupChatUnreadChange: (hasUnread: boolean) => void; resetKeys: Record; }) { const activeView = getWorkspaceView(activePathname); @@ -38,13 +42,24 @@ export function AppWorkspace({ : "Student"; const username = authState.mode === "authenticated" ? authState.profile.username : null; + const userId = + authState.mode === "authenticated" ? authState.profile.id : null; + const isDiscoverable = + authState.mode === "authenticated" + ? authState.profile.is_discoverable + : true; if (!activeView) { return fallback; } return ( -
+
- + - + - +
); diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx index 73e13e5..c0a3c4b 100644 --- a/src/components/friends/direct-messages.tsx +++ b/src/components/friends/direct-messages.tsx @@ -68,12 +68,16 @@ export function DirectMessages({ friends, initialFriendId, onConversationClosed, + onConversationOpenChange, + onUnreadCountChange, remoteClient, }: { currentUserId: string | null; friends: SocialFriend[]; initialFriendId: string | null; onConversationClosed: () => void; + onConversationOpenChange?: (open: boolean) => void; + onUnreadCountChange?: (count: number) => void; remoteClient: SupabaseClient | null; }) { const [conversations, setConversations] = useState([]); @@ -173,6 +177,21 @@ export function DirectMessages({ friendsRef.current = friends; }, [friends]); + useEffect(() => { + onConversationOpenChange?.(Boolean(selectedFriend)); + }, [onConversationOpenChange, selectedFriend]); + + useEffect(() => { + if (!hasLoadedConversationsRef.current) return; + + onUnreadCountChange?.( + conversations.reduce( + (total, conversation) => total + conversation.unreadCount, + 0, + ), + ); + }, [conversations, isLoadingConversations, onUnreadCountChange]); + const markConversationRead = useCallback( async (friendId: string) => { if (!remoteClient || !currentUserId) return; @@ -449,6 +468,7 @@ export function DirectMessages({ } function openConversation(friendId: string) { + onConversationOpenChange?.(true); setSelectedFriendId(friendId); setMessages([]); setFeedback(null); @@ -466,6 +486,7 @@ export function DirectMessages({ aria-label="Back to messages" className="mac-focus inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-md text-[var(--color-text-muted)]" onClick={() => { + onConversationOpenChange?.(false); setSelectedFriendId(null); setMessages([]); setFeedback(null); diff --git a/src/components/friends/friends-dashboard.tsx b/src/components/friends/friends-dashboard.tsx index 11dbec4..abb4bb4 100644 --- a/src/components/friends/friends-dashboard.tsx +++ b/src/components/friends/friends-dashboard.tsx @@ -18,6 +18,7 @@ import { ChevronRight, CircleHelp, Clock3, + MessageCircle, Plus, Send, Users, @@ -43,6 +44,7 @@ import { } from "@/lib/client-cache"; import { addRemoteFriend, + fetchRemoteDirectMessageUnreadCount, fetchRemoteGlobalNudgeMutes, fetchRemoteSocialSnapshot, inviteRemoteFriendToGroup, @@ -60,21 +62,25 @@ import { createSupabaseBrowserClient } from "@/lib/supabase/browser"; import { NudgePill } from "@/components/social/nudge-pill"; import { useNudgeQueue } from "@/components/social/use-nudge-queue"; import { TransientToast } from "@/components/transient-toast"; -import { formatDuration, getLocalDateKey } from "@/lib/timer"; +import { addDateKeyDays, formatDuration, getLocalDateKey } from "@/lib/timer"; import { cn } from "@/lib/utils"; const emptySocialState: SocialState = { friends: [], groups: [] }; const friendTimeOptions = [ { label: "Today", value: "today" }, - { label: "Last week", value: "lastWeek" }, - { label: "Last month", value: "lastMonth" }, - { label: "Last year", value: "lastYear" }, + { label: "This week", value: "thisWeek" }, + { label: "This month", value: "thisMonth" }, + { label: "This year", value: "thisYear" }, { label: "All time", value: "allTime" }, ] as const; type FriendTimeRange = (typeof friendTimeOptions)[number]["value"]; -export function FriendsDashboard() { +export function FriendsDashboard({ + onUnreadChange, +}: { + onUnreadChange?: (hasUnread: boolean) => void; +}) { const [socialState, setSocialState] = useState(emptySocialState); const [isLoaded, setIsLoaded] = useState(false); const [isAdding, setIsAdding] = useState(false); @@ -103,6 +109,9 @@ export function FriendsDashboard() { "friends" | "messages" | "requests" >("friends"); const [messageFriendId, setMessageFriendId] = useState(null); + const [isDirectConversationOpen, setIsDirectConversationOpen] = + useState(false); + const [directMessageUnreadCount, setDirectMessageUnreadCount] = useState(0); const [busyKey, setBusyKey] = useState(null); const [feedback, setFeedback] = useState(null); const [toastMessage, setToastMessage] = useState(null); @@ -123,10 +132,16 @@ export function FriendsDashboard() { "back" | "forward" >("forward"); const [now, setNow] = useState(() => new Date()); + const studyDateKey = getLocalDateKey(now); + const previousStudyDateKeyRef = useRef(studyDateKey); const pendingFriendRequestIdsRef = useRef(new Set()); const pendingCancelledRequestsRef = useRef(new Map()); const nudgeQueue = useNudgeQueue(Boolean(remoteClient)); + useEffect(() => { + onUnreadChange?.(directMessageUnreadCount > 0); + }, [directMessageUnreadCount, onUnreadChange]); + const refreshRemoteSocial = useCallback(async (supabase: SupabaseClient) => { const snapshot = await fetchRemoteSocialSnapshot(supabase); @@ -170,6 +185,21 @@ export function FriendsDashboard() { } }, []); + const refreshDirectMessageUnreadCount = useCallback( + async (supabase: SupabaseClient, userId: string | null) => { + if (!userId) return; + + try { + setDirectMessageUnreadCount( + await fetchRemoteDirectMessageUnreadCount({ supabase, userId }), + ); + } catch { + // Keep the last known count when realtime or the network is unavailable. + } + }, + [], + ); + useEffect(() => { if (activeTab !== "friends") return; @@ -178,6 +208,15 @@ export function FriendsDashboard() { return () => window.clearInterval(interval); }, [activeTab]); + useEffect(() => { + if (previousStudyDateKeyRef.current === studyDateKey) return; + + previousStudyDateKeyRef.current = studyDateKey; + if (remoteClient) { + window.queueMicrotask(() => void refreshRemoteSocial(remoteClient)); + } + }, [refreshRemoteSocial, remoteClient, studyDateKey]); + useEffect(() => { if (!remoteClient) return; @@ -227,6 +266,10 @@ export function FriendsDashboard() { setFriendRequests(snapshot.friendRequests ?? []); setSuperNudges(snapshot.superNudges ?? []); setIsLoaded(true); + void refreshDirectMessageUnreadCount( + supabase, + snapshot.currentUserId, + ); return; } } catch { @@ -267,7 +310,7 @@ export function FriendsDashboard() { return () => { cancelled = true; }; - }, []); + }, [refreshDirectMessageUnreadCount]); useEffect(() => { if (!isLoaded || remoteClient) { @@ -285,10 +328,20 @@ export function FriendsDashboard() { return; } - return subscribeToRemoteAppChanges(remoteClient, () => { + return subscribeToRemoteAppChanges(remoteClient, (table) => { + if (table === "direct_messages") { + void refreshDirectMessageUnreadCount(remoteClient, currentUserId); + return; + } + void refreshRemoteSocial(remoteClient); }); - }, [refreshRemoteSocial, remoteClient]); + }, [ + currentUserId, + refreshDirectMessageUnreadCount, + refreshRemoteSocial, + remoteClient, + ]); useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -324,8 +377,7 @@ export function FriendsDashboard() { socialState.friends .filter((friend) => friend.id !== selfId) .sort( - (first, second) => - Number(second.studying) - Number(first.studying), + (first, second) => Number(second.studying) - Number(first.studying), ), [selfId, socialState.friends], ); @@ -345,7 +397,6 @@ export function FriendsDashboard() { }); }, [friendList]); - const studyingCount = friendList.filter((friend) => friend.studying).length; const incomingRequests = friendRequests.filter( (request) => request.direction === "incoming", ); @@ -356,6 +407,8 @@ export function FriendsDashboard() { (request) => request.direction === "incoming" && request.status === "pending", ); + const directConversationVisible = + isDirectConversationOpen || Boolean(messageFriendId); const outgoingSuperNudges = superNudges.filter( (request) => request.direction === "outgoing" && request.status === "pending", @@ -613,9 +666,10 @@ export function FriendsDashboard() { } } - function nudgeFriend(friendId: string) { + function nudgeFriend(friendId: string, superNudgeMode: boolean) { nudgeQueue.enqueue({ key: friendId, + maxPerMinute: superNudgeMode ? 10 : 1, recipientId: friendId, }); } @@ -799,10 +853,11 @@ export function FriendsDashboard() {
nudgeFriend(selectedFriend.id)} + onClick={() => nudgeFriend(selectedFriend.id, superNudgeMode)} pendingCount={nudgeState.pending} /> @@ -914,6 +969,19 @@ export function FriendsDashboard() { ) : null} + +
- ) : null} -
- -
-
- - -
- -
- - {feedback ? ( -

- {feedback} -

- ) : null} - - {activeTab === "friends" ? ( -
- {friendList.length ? ( - ( -
- - -
- )} - resetKey="friends" - /> - ) : ( - + + +
+
+ + {friendList.length ? ( - } - description="Find someone by username and send a request." - icon={} - title="Build your study circle" - /> - )} - - ) : activeTab === "messages" ? ( - setMessageFriendId(null)} - remoteClient={remoteClient} - /> - ) : ( -
- {incomingSuperNudges.length ? ( - - {incomingSuperNudges.map((request) => { - const friend = friendList.find( - (item) => item.id === request.friendId, - ); - - return friend ? ( - void changeSuperNudge(request, "accept")} - onSecondary={() => - void changeSuperNudge(request, "decline") - } - secondaryLabel="Decline" - /> - ) : null; - })} - - ) : null} + ) : null} +
+
- {incomingRequests.length ? ( - - ( - - void updateFriendRequest(request, action) - } - request={request} - /> - )} - resetKey="incoming" - /> - + {activeTab === "friends" ? ( +

+ {friendList.length + ? `${friendList.length} ${friendList.length === 1 ? "friend" : "friends"}` + : "No friends yet"} +

) : null} +
+ ) : null} - {outgoingRequests.length ? ( - +
+ {feedback && !directConversationVisible ? ( +

+ {feedback} +

+ ) : null} + + {activeTab === "friends" ? ( +
+ {friendList.length ? ( ( - - void updateFriendRequest(request, action) - } - request={request} - /> + className="grid gap-2 lg:grid-cols-2 lg:gap-3" + items={friendList} + pageSize={12} + renderItem={(friend) => ( +
+ + +
)} - resetKey="outgoing" + resetKey="friends" /> - - ) : null} + ) : ( + setIsAdding(true)} + type="button" + > + + Add friend + + } + description="Find someone by username and send a request." + icon={} + title="Build your study circle" + /> + )} +
+ ) : activeTab === "messages" ? ( + setMessageFriendId(null)} + onConversationOpenChange={setIsDirectConversationOpen} + onUnreadCountChange={setDirectMessageUnreadCount} + remoteClient={remoteClient} + /> + ) : ( +
+ {incomingSuperNudges.length ? ( + + {incomingSuperNudges.map((request) => { + const friend = friendList.find( + (item) => item.id === request.friendId, + ); + + return friend ? ( + void changeSuperNudge(request, "accept")} + onSecondary={() => + void changeSuperNudge(request, "decline") + } + secondaryLabel="Decline" + /> + ) : null; + })} + + ) : null} + + {incomingRequests.length ? ( + + ( + + void updateFriendRequest(request, action) + } + request={request} + /> + )} + resetKey="incoming" + /> + + ) : null} + + {outgoingRequests.length ? ( + + ( + + void updateFriendRequest(request, action) + } + request={request} + /> + )} + resetKey="outgoing" + /> + + ) : null} - {outgoingSuperNudges.length ? ( - - {outgoingSuperNudges.map((request) => { - const friend = friendList.find( - (item) => item.id === request.friendId, - ); - - return friend ? ( - void changeSuperNudge(request, "cancel")} - secondaryLabel="Cancel" - /> - ) : null; - })} - - ) : null} + {outgoingSuperNudges.length ? ( + + {outgoingSuperNudges.map((request) => { + const friend = friendList.find( + (item) => item.id === request.friendId, + ); + + return friend ? ( + + void changeSuperNudge(request, "cancel") + } + secondaryLabel="Cancel" + /> + ) : null; + })} + + ) : null} + + {!friendRequests.length && + !incomingSuperNudges.length && + !outgoingSuperNudges.length ? ( +
+

No friend requests

+

+ Incoming and sent requests will appear here. +

+
+ ) : null} +
+ )} - {!friendRequests.length && - !incomingSuperNudges.length && - !outgoingSuperNudges.length ? ( -
-

No friend requests

-

- Incoming and sent requests will appear here. -

-
- ) : null} - - )} + {isAdding ? ( + + void addRemoteFriendFromCandidate(friendId) + } + onClose={() => { + setIsAdding(false); + setFriendName(""); + setFriendHandle(""); + setFriendColor(PROFILE_COLORS[1]); + }} + onColorChange={setFriendColor} + onHandleChange={setFriendHandle} + onNameChange={setFriendName} + onShowRequests={() => { + setIsAdding(false); + setActiveTab("requests"); + }} + remoteCandidates={remoteClient ? availableFriends : null} + /> + ) : null} - {isAdding ? ( - - void addRemoteFriendFromCandidate(friendId) - } - onClose={() => { - setIsAdding(false); - setFriendName(""); - setFriendHandle(""); - setFriendColor(PROFILE_COLORS[1]); - }} - onColorChange={setFriendColor} - onHandleChange={setFriendHandle} - onNameChange={setFriendName} - onShowRequests={() => { - setIsAdding(false); - setActiveTab("requests"); - }} - remoteCandidates={remoteClient ? availableFriends : null} + setToastMessage(null)} /> - ) : null} - - setToastMessage(null)} - /> +
); } @@ -1804,17 +1885,6 @@ function GroupInviteDialog({ ); } -function SummaryStat({ label, value }: { label: string; value: string }) { - return ( -
-

{value}

-

- {label} -

-
- ); -} - function formatCompactStudyTime(totalSeconds: number) { const seconds = Math.max(0, Math.round(totalSeconds)); if (seconds < 60) return `${seconds}s`; @@ -1842,30 +1912,27 @@ function getFriendTimeSeconds( return getLiveRankingSeconds(friend, "day", now); } - if (range === "lastWeek") { + if (range === "thisWeek") { return getLiveRankingSeconds(friend, "week", now); } - if (range === "lastMonth") { + if (range === "thisMonth") { return getLiveRankingSeconds(friend, "month", now); } return getLiveRankingSeconds(friend, "allTime", now); } - const days = - range === "today" - ? 1 - : range === "lastWeek" - ? 7 - : range === "lastMonth" - ? 30 - : 365; - const start = new Date(now); - start.setHours(0, 0, 0, 0); - start.setDate(start.getDate() - (days - 1)); - const startKey = getLocalDateKey(start); const todayKey = getLocalDateKey(now); + const calendarDay = new Date(`${todayKey}T00:00:00Z`).getUTCDay(); + const startKey = + range === "today" + ? todayKey + : range === "thisWeek" + ? addDateKeyDays(todayKey, -((calendarDay + 6) % 7)) + : range === "thisMonth" + ? `${todayKey.slice(0, 7)}-01` + : `${todayKey.slice(0, 4)}-01-01`; const storedSeconds = Object.entries(dailySeconds).reduce( (total, [dateKey, seconds]) => dateKey >= startKey && dateKey <= todayKey ? total + seconds : total, @@ -1906,6 +1973,14 @@ function ProfileBadge({ ); } +function UnreadBadge({ count }: { count: number }) { + return ( + + {count > 9 ? "9+" : count} + + ); +} + function getInitials(value: string) { return value .split(/\s+/) diff --git a/src/components/groups/group-chat.tsx b/src/components/groups/group-chat.tsx index ae49f64..ee6fd68 100644 --- a/src/components/groups/group-chat.tsx +++ b/src/components/groups/group-chat.tsx @@ -84,6 +84,7 @@ export function GroupChat({ groupName, members, onBack, + onRead, remoteClient, }: { currentUserId: string | null; @@ -91,6 +92,7 @@ export function GroupChat({ groupName: string; members: SocialFriend[]; onBack: () => void; + onRead?: (groupId: string) => void; remoteClient: SupabaseClient | null; }) { const [messages, setMessages] = useState(() => @@ -226,11 +228,12 @@ export function GroupChat({ if (receipt) { setReadReceipts((current) => mergeReadReceipts(current, [receipt])); + onRead?.(groupId); } } catch { // Read receipts are best-effort and should never interrupt chat. } - }, [currentUserId, groupId, remoteClient]); + }, [currentUserId, groupId, onRead, remoteClient]); useEffect(() => { onBackRef.current = onBack; @@ -377,13 +380,34 @@ export function GroupChat({ if (!isInitialPosition && !shouldScrollToBottomRef.current) return; - messageList.scrollTo({ - behavior: isInitialPosition ? "auto" : "smooth", - top: messageList.scrollHeight, - }); - shouldScrollToBottomRef.current = false; + const positionAtBottom = (behavior: ScrollBehavior = "auto") => { + messageList.scrollTo({ + behavior, + top: messageList.scrollHeight, + }); + }; + + positionAtBottom(isInitialPosition ? "auto" : "smooth"); isNearBottomRef.current = true; setUnreadCount(0); + + if (isInitialPosition) { + let secondFrame = 0; + const firstFrame = window.requestAnimationFrame(() => { + positionAtBottom(); + secondFrame = window.requestAnimationFrame(() => { + positionAtBottom(); + shouldScrollToBottomRef.current = false; + }); + }); + + return () => { + window.cancelAnimationFrame(firstFrame); + if (secondFrame) window.cancelAnimationFrame(secondFrame); + }; + } + + shouldScrollToBottomRef.current = false; }, [isReady, messages, pendingMessages]); useEffect(() => { @@ -944,136 +968,140 @@ export function GroupChat({ isOwn ? "items-end" : "items-start", )} > -
event.preventDefault()} - onPointerCancel={cancelMessageHold} - onPointerDown={(event) => - beginMessageHold(event, message) - } - onPointerMove={moveMessageHold} - onPointerUp={cancelMessageHold} - > - {!isOwn && startsSenderGroup ? ( -

- {sender?.handle ?? "@member"} -

- ) : null} - {message.replyToId ? ( -
-

- {replySender?.handle ?? "Message"} -

-

- {replyTarget - ? replyTarget.body || "Photo" - : "Message unavailable"} -

-
- ) : null} - {message.imageUrl ? ( - - {/* Private signed URLs cannot use the static Next image loader. */} - {`Photo - - ) : message.imagePath ? ( -
- Photo unavailable -
- ) : null} -
- {message.body ? ( -

- {message.body} -

- ) : null} -
-
- {!pending ? ( +
+ {!pending ? ( + - ) : null} + + {canDelete ? ( + + ) : null} +
+ ) : null} +
+ ) : null} +
void; +}) { const [socialState, setSocialState] = useState(emptySocialState); const [timerSubjects, setTimerSubjects] = useState( fallbackStudySubjects, @@ -115,11 +121,20 @@ export function GroupsDashboard() { const [groupInvites, setGroupInvites] = useState([]); const [requestBusyKey, setRequestBusyKey] = useState(null); const [requestFeedback, setRequestFeedback] = useState(null); + const [groupUnreadCounts, setGroupUnreadCounts] = useState< + Record + >({}); const [remoteClient, setRemoteClient] = useState(null); const [currentUserId, setCurrentUserId] = useState(null); const [now, setNow] = useState(() => new Date()); + const studyDateKey = getLocalDateKey(now); + const previousStudyDateKeyRef = useRef(studyDateKey); const nudgeQueue = useNudgeQueue(Boolean(remoteClient)); + useEffect(() => { + onUnreadChange?.(Object.values(groupUnreadCounts).some((count) => count > 0)); + }, [groupUnreadCounts, onUnreadChange]); + const refreshRemoteSocial = useCallback(async (supabase: SupabaseClient) => { const snapshot = await fetchRemoteSocialSnapshot(supabase); @@ -141,12 +156,38 @@ export function GroupsDashboard() { } }, []); + const refreshGroupUnreadCounts = useCallback( + async (supabase: SupabaseClient) => { + const counts = await fetchGroupChatUnreadCounts(supabase); + setGroupUnreadCounts(counts); + }, + [], + ); + const clearGroupUnreadCount = useCallback((groupId: string) => { + setGroupUnreadCounts((current) => ({ + ...current, + [groupId]: 0, + })); + }, []); + useEffect(() => { const interval = window.setInterval(() => setNow(new Date()), 1000); return () => window.clearInterval(interval); }, []); + useEffect(() => { + if (previousStudyDateKeyRef.current === studyDateKey) return; + + previousStudyDateKeyRef.current = studyDateKey; + if (remoteClient) { + window.queueMicrotask(() => { + void refreshRemoteSocial(remoteClient); + void refreshRemoteTimer(remoteClient); + }); + } + }, [refreshRemoteSocial, refreshRemoteTimer, remoteClient, studyDateKey]); + useEffect(() => { let cancelled = false; @@ -172,9 +213,10 @@ export function GroupsDashboard() { if (!cancelled) { setRemoteClient(supabase); } - const [snapshot, timerState] = await Promise.all([ + const [snapshot, timerState, unreadCounts] = await Promise.all([ fetchRemoteSocialSnapshot(supabase), fetchRemoteTimerState(supabase), + fetchGroupChatUnreadCounts(supabase).catch(() => ({})), ]); if (!cancelled && snapshot) { @@ -182,6 +224,7 @@ export function GroupsDashboard() { setCurrentUserId(snapshot.currentUserId); setSocialState(snapshot.socialState); setGroupInvites(snapshot.groupInvites ?? []); + setGroupUnreadCounts(unreadCounts); if (timerState) { cacheRemoteTimerState(timerState); setTimerSubjects(timerState.subjects); @@ -248,11 +291,24 @@ export function GroupsDashboard() { return; } - return subscribeToRemoteAppChanges(remoteClient, () => { + return subscribeToRemoteAppChanges(remoteClient, (table) => { + if ( + table === "group_chat_messages" || + table === "group_chat_read_receipts" + ) { + void refreshGroupUnreadCounts(remoteClient); + return; + } + void refreshRemoteSocial(remoteClient); void refreshRemoteTimer(remoteClient); }); - }, [refreshRemoteSocial, refreshRemoteTimer, remoteClient]); + }, [ + refreshGroupUnreadCounts, + refreshRemoteSocial, + refreshRemoteTimer, + remoteClient, + ]); const selectedGroup = socialState.groups.find( (group) => group.id === selectedGroupId, @@ -264,10 +320,12 @@ export function GroupsDashboard() { return; } - setSelectedGroupId(groupId); - if (searchParams.get("view") === "chat") { - setGroupView("chat"); - } + window.queueMicrotask(() => { + setSelectedGroupId(groupId); + if (searchParams.get("view") === "chat") { + setGroupView("chat"); + } + }); const url = new URL(window.location.href); url.searchParams.delete("group"); url.searchParams.delete("view"); @@ -299,8 +357,10 @@ export function GroupsDashboard() { useEffect(() => { if (new URLSearchParams(window.location.search).get("tab") === "requests") { - setSelectedGroupId(null); - setActiveTab("requests"); + window.queueMicrotask(() => { + setSelectedGroupId(null); + setActiveTab("requests"); + }); } const openRequests = () => { @@ -724,6 +784,7 @@ export function GroupsDashboard() { .filter((invite) => invite.group.id === selectedGroup.id) .map((invite) => invite.user.id), ); + const selectedGroupUnreadCount = groupUnreadCounts[selectedGroup.id] ?? 0; if (groupView === "chat") { return ( @@ -734,6 +795,7 @@ export function GroupsDashboard() { key={selectedGroup.id} members={members} onBack={() => setGroupView("class")} + onRead={clearGroupUnreadCount} remoteClient={remoteClient} /> ); @@ -805,7 +867,15 @@ export function GroupsDashboard() { } type="button" > - {view.label} + + {view.id === "chat" ? ( + + ) : null} + {view.label} + {view.id === "chat" && selectedGroupUnreadCount ? ( + + ) : null} + ))}
@@ -817,8 +887,8 @@ export function GroupsDashboard() { className={cn( "mac-focus h-11 rounded px-3 text-xs font-semibold transition", rankingWindow === window.id - ? "bg-[var(--color-surface-raised)] text-[var(--color-text)]" - : "text-[var(--color-text-muted)]", + ? "border border-[var(--color-mac-yellow)] bg-[rgb(255_227_48/0.08)] text-[var(--color-mac-yellow)]" + : "border border-transparent text-[var(--color-text-muted)]", )} key={window.id} onClick={() => setRankingWindow(window.id)} @@ -881,6 +951,7 @@ export function GroupsDashboard() { } group={selectedGroup} member={selectedMember} + nudgeAtLimit={selectedMemberNudgeState?.atLimit ?? false} nudgeFeedback={selectedMemberNudgeState?.feedback ?? null} now={now} onClose={() => { @@ -1027,61 +1098,50 @@ export function GroupsDashboard() {
-

- {socialState.groups.length - ? `${socialState.groups.length} ${socialState.groups.length === 1 ? "group" : "groups"}` - : "No groups yet"} -

- {socialState.groups.length ? ( + {activeTab === "requests" ? ( - ) : null} -
- -
- - + {socialState.groups.length ? ( + ) : null} - +
{requestFeedback ? ( @@ -1111,9 +1171,20 @@ export function GroupsDashboard() { type="button" >
-

- {group.name} -

+
+

+ {group.name} +

+ {groupUnreadCounts[group.id] ? ( + + + + + ) : null} +
{activeNow} active
@@ -1428,6 +1499,14 @@ function SummaryStat({ label, value }: { label: string; value: string }) { ); } +function UnreadBadge({ count }: { count: number }) { + return ( + + {count > 9 ? "9+" : count} + + ); +} + function ProfileBadge({ friend }: { friend: SocialFriend }) { return ( void; @@ -1647,8 +1728,14 @@ function GroupMemberDialog({
diff --git a/src/components/profile/discoverability-setting.tsx b/src/components/profile/discoverability-setting.tsx new file mode 100644 index 0000000..9b17e06 --- /dev/null +++ b/src/components/profile/discoverability-setting.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { UserSearch } from "lucide-react"; +import { createSupabaseBrowserClient } from "@/lib/supabase/browser"; +import { cn } from "@/lib/utils"; + +export function DiscoverabilitySetting({ + initialDiscoverable, + userId, +}: { + initialDiscoverable: boolean; + userId: string; +}) { + const [enabled, setEnabled] = useState(initialDiscoverable); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const supabase = createSupabaseBrowserClient(); + + void supabase + .from("profiles") + .select("is_discoverable") + .eq("id", userId) + .maybeSingle<{ is_discoverable: boolean }>() + .then(({ data }) => { + if (!cancelled && data) setEnabled(data.is_discoverable); + }); + + return () => { + cancelled = true; + }; + }, [userId]); + + async function toggleDiscoverability() { + if (saving) return; + + const previous = enabled; + const next = !previous; + setEnabled(next); + setSaving(true); + setError(null); + + try { + const supabase = createSupabaseBrowserClient(); + const { error: updateError } = await supabase.rpc( + "set_profile_discoverability", + { next_is_discoverable: next }, + ); + + if (updateError) throw updateError; + } catch { + setEnabled(previous); + setError("Could not update discoverability."); + } finally { + setSaving(false); + } + } + + return ( +
+
+ + + + + Discoverable + + Let people find you when adding friends. + + + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/src/components/profile/profile-dashboard.tsx b/src/components/profile/profile-dashboard.tsx index 049029c..9dd29b2 100644 --- a/src/components/profile/profile-dashboard.tsx +++ b/src/components/profile/profile-dashboard.tsx @@ -1,11 +1,16 @@ import { LogOut, PencilLine, UserRound } from "lucide-react"; +import { DiscoverabilitySetting } from "@/components/profile/discoverability-setting"; import { PushNotificationSettings } from "@/components/pwa/push-notification-settings"; export function ProfileDashboard({ displayName, + initialDiscoverable, + userId, username, }: { displayName: string; + initialDiscoverable: boolean; + userId: string | null; username: string | null; }) { const handle = username ? `@${username}` : "@set_username"; @@ -41,6 +46,12 @@ export function ProfileDashboard({

Settings

+ {userId ? ( + + ) : null} Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; +}; + +export function InstallOnboarding({ + onComplete, + userId, +}: { + onComplete: () => void; + userId: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [isInstalling, setIsInstalling] = useState(false); + const [canInstall, setCanInstall] = useState(false); + const deferredPromptRef = useRef(null); + const storageKey = `mac-install-onboarding:${userId}`; + + useEffect(() => { + const standalone = + window.matchMedia("(display-mode: standalone)").matches || + ("standalone" in navigator && + (navigator as Navigator & { standalone?: boolean }).standalone === + true); + const mobile = window.matchMedia("(max-width: 63.999rem)").matches; + + if ( + standalone || + !mobile || + window.localStorage.getItem(storageKey) === "seen" + ) { + onComplete(); + return; + } + + function captureInstallPrompt(event: Event) { + event.preventDefault(); + deferredPromptRef.current = event as BeforeInstallPromptEvent; + setCanInstall(true); + } + + function handleInstalled() { + window.localStorage.setItem(storageKey, "seen"); + setIsOpen(false); + onComplete(); + } + + window.addEventListener("beforeinstallprompt", captureInstallPrompt); + window.addEventListener("appinstalled", handleInstalled); + const openingFrame = window.requestAnimationFrame(() => setIsOpen(true)); + + return () => { + window.cancelAnimationFrame(openingFrame); + window.removeEventListener("beforeinstallprompt", captureInstallPrompt); + window.removeEventListener("appinstalled", handleInstalled); + }; + }, [onComplete, storageKey]); + + function dismiss() { + window.localStorage.setItem(storageKey, "seen"); + setIsOpen(false); + onComplete(); + } + + async function install() { + const deferredPrompt = deferredPromptRef.current; + if (!deferredPrompt) return; + + setIsInstalling(true); + try { + await deferredPrompt.prompt(); + const choice = await deferredPrompt.userChoice; + if (choice.outcome === "accepted") { + window.localStorage.setItem(storageKey, "seen"); + setIsOpen(false); + onComplete(); + } + } finally { + deferredPromptRef.current = null; + setCanInstall(false); + setIsInstalling(false); + } + } + + if (!isOpen) return null; + + return ( + + {canInstall ? ( + + ) : null} + +
+ } + maxWidthClassName="max-w-sm" + onClose={dismiss} + title="Put MAC Study on your phone" + > +

+ Open MAC Study from your Home Screen for the full app experience. +

+
    +
  1. + + 1 + + Open this page in Safari on iPhone. +
  2. +
  3. + + 2 + + + Tap Share then Add to Home Screen. + +
  4. +
  5. + + 3 + + Turn on Open as Web App, then tap Add. Bang. +
  6. +
+

+ On Android, use Chrome’s three-dot menu, then Add to Home screen and + Install. +

+ + ); +} diff --git a/src/components/pwa/notification-onboarding.tsx b/src/components/pwa/notification-onboarding.tsx index dd9cc2d..f15db33 100644 --- a/src/components/pwa/notification-onboarding.tsx +++ b/src/components/pwa/notification-onboarding.tsx @@ -9,13 +9,21 @@ import { supportsPushNotifications, } from "@/lib/push/client"; -export function NotificationOnboarding({ userId }: { userId: string }) { +export function NotificationOnboarding({ + enabled = true, + userId, +}: { + enabled?: boolean; + userId: string; +}) { const [isOpen, setIsOpen] = useState(false); const [isEnabling, setIsEnabling] = useState(false); const [feedback, setFeedback] = useState(null); const storageKey = `mac-notification-onboarding:${userId}`; useEffect(() => { + if (!enabled) return; + const isMobile = window.matchMedia("(max-width: 63.999rem)").matches; const alreadySeen = window.localStorage.getItem(storageKey) === "seen"; @@ -27,7 +35,7 @@ export function NotificationOnboarding({ userId }: { userId: string }) { ) { setIsOpen(true); } - }, [storageKey]); + }, [enabled, storageKey]); function dismiss() { window.localStorage.setItem(storageKey, "seen"); diff --git a/src/components/social/nudge-pill.tsx b/src/components/social/nudge-pill.tsx index 379e3a0..a6a3cfd 100644 --- a/src/components/social/nudge-pill.tsx +++ b/src/components/social/nudge-pill.tsx @@ -3,32 +3,42 @@ import { BellRing } from "lucide-react"; export function NudgePill({ + burstCount = 0, disabled = false, disabledLabel, mode = "standard", onClick, pendingCount = 0, }: { + burstCount?: number; disabled?: boolean; disabledLabel?: string; mode?: "standard" | "super"; onClick: () => void; pendingCount?: number; }) { - const label = disabledLabel ?? (pendingCount ? "Sending..." : "Nudge"); + const label = + disabledLabel ?? + (mode === "super" && burstCount + ? `Nudge ×${burstCount}` + : pendingCount + ? "Sending..." + : "Nudge"); return ( + {activeSession ? ( + + ) : null}
@@ -647,8 +780,7 @@ export function TimerDashboard() { renderItem={(subject) => { const isActive = activeSession?.subjectId === subject.id; const subjectSeconds = - (subjectTotals[subject.id] ?? 0) + - (isActive ? elapsedSeconds : 0); + (subjectTotals[subject.id] ?? 0) + (isActive ? activeToday : 0); return (
) : null} + {isReminderDialogOpen && activeSession ? ( + void updateStudyReminder(null)} + type="button" + > + Turn reminders off + + } + maxWidthClassName="max-w-sm" + onClose={() => setIsReminderDialogOpen(false)} + title="Study reminder" + > +

+ Get a quick check-in while this session is still running. The first + reminder arrives after the interval you choose. +

+
+ {REMINDER_INTERVALS.map((interval) => { + const selected = + activeSession.reminderIntervalMinutes === interval; + + return ( + + ); + })} +
+ {pushStatus && pushStatus.state !== "enabled" ? ( +

+ Device alerts will be enabled when you choose an interval. +

+ ) : null} + {reminderFeedback ? ( +

+ {reminderFeedback} +

+ ) : null} +
+ ) : null} + + {isStudyCheckInOpen && activeSession ? ( + + + +
+ } + maxWidthClassName="max-w-sm" + onClose={() => setIsStudyCheckInOpen(false)} + title="Still studying?" + > +

+ Your study timer is still running. Keep it going or stop the session + now. +

+ + ) : null} + {isSessionHistoryOpen ? ( setIsSessionHistoryOpen(false)} @@ -792,6 +1018,10 @@ export function TimerDashboard() { ); } +function formatReminderInterval(minutes: number) { + return minutes % 60 === 0 ? `${minutes / 60} hr` : `${minutes} min`; +} + const GENERAL_SESSION_SUBJECT = "__general__"; function SessionHistoryDialog({ @@ -1306,12 +1536,12 @@ function SubjectEditor({ ) : ( (
@@ -1324,7 +1554,7 @@ function SubjectEditor({