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/components/app-workspace.tsx b/src/components/app-workspace.tsx index cdbd904..e6694ea 100644 --- a/src/components/app-workspace.tsx +++ b/src/components/app-workspace.tsx @@ -38,6 +38,12 @@ 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; @@ -85,7 +91,12 @@ export function AppWorkspace({ id="profile" key={`profile:${resetKeys["/app/profile"] ?? 0}`} > - + ); 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..1d90dc5 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,15 +62,15 @@ 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; @@ -103,6 +105,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,6 +128,8 @@ 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)); @@ -170,6 +177,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 +200,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 +258,10 @@ export function FriendsDashboard() { setFriendRequests(snapshot.friendRequests ?? []); setSuperNudges(snapshot.superNudges ?? []); setIsLoaded(true); + void refreshDirectMessageUnreadCount( + supabase, + snapshot.currentUserId, + ); return; } } catch { @@ -267,7 +302,7 @@ export function FriendsDashboard() { return () => { cancelled = true; }; - }, []); + }, [refreshDirectMessageUnreadCount]); useEffect(() => { if (!isLoaded || remoteClient) { @@ -285,10 +320,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 +369,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 +389,6 @@ export function FriendsDashboard() { }); }, [friendList]); - const studyingCount = friendList.filter((friend) => friend.studying).length; const incomingRequests = friendRequests.filter( (request) => request.direction === "incoming", ); @@ -356,6 +399,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", @@ -914,6 +959,19 @@ export function FriendsDashboard() { ) : null} + +
- ) : null} -
+
+ + +
+
+ + {friendList.length ? ( + + ) : null} +
+ -
-
- - -
- -
+ + ) : null} - {feedback ? ( + {feedback && !directConversationVisible ? (

setMessageFriendId(null)} + onConversationOpenChange={setIsDirectConversationOpen} + onUnreadCountChange={setDirectMessageUnreadCount} remoteClient={remoteClient} /> ) : ( @@ -1804,17 +1869,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 +1896,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 +1957,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} +
([]); 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)); const refreshRemoteSocial = useCallback(async (supabase: SupabaseClient) => { @@ -141,12 +148,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 +205,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 +216,7 @@ export function GroupsDashboard() { setCurrentUserId(snapshot.currentUserId); setSocialState(snapshot.socialState); setGroupInvites(snapshot.groupInvites ?? []); + setGroupUnreadCounts(unreadCounts); if (timerState) { cacheRemoteTimerState(timerState); setTimerSubjects(timerState.subjects); @@ -248,11 +283,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 +312,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 +349,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 +776,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 +787,7 @@ export function GroupsDashboard() { key={selectedGroup.id} members={members} onBack={() => setGroupView("class")} + onRead={clearGroupUnreadCount} remoteClient={remoteClient} /> ); @@ -805,7 +859,15 @@ export function GroupsDashboard() { } type="button" > - {view.label} + + {view.id === "chat" ? ( + + ) : null} + {view.label} + {view.id === "chat" && selectedGroupUnreadCount ? ( + + ) : null} + ))}
@@ -817,8 +879,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)} @@ -1027,61 +1089,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 +1162,20 @@ export function GroupsDashboard() { type="button" >
-

- {group.name} -

+
+

+ {group.name} +

+ {groupUnreadCounts[group.id] ? ( + + + + + ) : null} +
{activeNow} active
@@ -1428,6 +1490,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 ( (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} window.clearInterval(interval); }, []); - const elapsedSeconds = activeSession - ? getElapsedSeconds(activeSession.startedAt, now) - : 0; const todayKey = getLocalDateKey(now); - - const todaySessions = useMemo( + const todayStart = useMemo( + () => getAustralianDateStart(todayKey), + [todayKey], + ); + const todayEnd = useMemo( + () => getAustralianDateStart(addDateKeyDays(todayKey, 1)), + [todayKey], + ); + const completedToday = useMemo( () => - sessions.filter( - (session) => getLocalDateKey(new Date(session.endedAt)) === todayKey, + sessions.reduce( + (total, session) => + total + + getIntervalOverlapSeconds( + session.startedAt, + session.endedAt, + todayStart, + todayEnd, + ), + 0, ), - [sessions, todayKey], + [sessions, todayEnd, todayStart], ); - const subjectTotals = groupSessionsBySubject(todaySessions); - const completedToday = sumCompletedSeconds(todaySessions); - const totalToday = completedToday + elapsedSeconds; + const subjectTotals = useMemo( + () => + sessions.reduce>((totals, session) => { + if (!session.subjectId) return totals; + + totals[session.subjectId] = + (totals[session.subjectId] ?? 0) + + getIntervalOverlapSeconds( + session.startedAt, + session.endedAt, + todayStart, + todayEnd, + ); + return totals; + }, {}), + [sessions, todayEnd, todayStart], + ); + const activeToday = activeSession + ? getIntervalOverlapSeconds( + activeSession.startedAt, + now, + todayStart, + todayEnd, + ) + : 0; + const totalToday = completedToday + activeToday; const sortedSessions = useMemo( () => [...sessions].sort( @@ -648,7 +683,7 @@ export function TimerDashboard() { const isActive = activeSession?.subjectId === subject.id; const subjectSeconds = (subjectTotals[subject.id] ?? 0) + - (isActive ? elapsedSeconds : 0); + (isActive ? activeToday : 0); return (
onChange("friend_requests"), ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "direct_messages" }, + () => onChange("direct_messages"), + ) .on( "postgres_changes", { event: "*", schema: "public", table: "super_nudge_requests" }, @@ -1804,6 +1816,16 @@ export function subscribeToRemoteAppChanges( { event: "*", schema: "public", table: "app_notifications" }, () => onChange("app_notifications"), ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "group_chat_messages" }, + () => onChange("group_chat_messages"), + ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "group_chat_read_receipts" }, + () => onChange("group_chat_read_receipts"), + ) .on( "postgres_changes", { event: "*", schema: "public", table: "groups" }, @@ -1846,6 +1868,24 @@ export function subscribeToRemoteAppChanges( }; } +export async function fetchRemoteDirectMessageUnreadCount({ + supabase, + userId, +}: { + supabase: SupabaseClient; + userId: string; +}) { + const { count, error } = await supabase + .from("direct_messages") + .select("id", { count: "exact", head: true }) + .eq("recipient_id", userId) + .is("read_at", null); + + if (error) throw error; + + return count ?? 0; +} + async function fetchRemoteSubjects(supabase: SupabaseClient, userId: string) { const { data: existing, error: fetchError } = await supabase .from("subjects") @@ -2006,7 +2046,8 @@ function friendFromProfile( ); const activeSession = userSessions.find((session) => session.status === "active") ?? null; - const totals = getSessionTotals(userSessions, now); + const dailyStudySeconds = getDailySessionTotals(userSessions, now); + const totals = getSessionTotals(userSessions, dailyStudySeconds, now); return { id: profile.id, @@ -2023,50 +2064,51 @@ function friendFromProfile( weekSeconds: totals.week, monthSeconds: totals.month, allTimeSeconds: totals.allTime, - dailyStudySeconds: getDailySessionTotals(userSessions, now), + dailyStudySeconds, activeStartedAt: activeSession?.started_at ?? null, activeUpdatedAt: activeSession ? now.toISOString() : null, subjectSeconds: {}, }; } -function getSessionTotals(sessions: SessionRow[], now = new Date()) { - const todayKey = now.toISOString().slice(0, 10); - const weekStart = new Date(now); - weekStart.setDate(now.getDate() - 6); - weekStart.setHours(0, 0, 0, 0); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); - - return sessions.reduce( - (totals, session) => { - if (session.status === "voided") { - return totals; - } - - const startedAt = new Date(session.started_at); - const seconds = session.ended_at - ? (session.duration_seconds ?? - getElapsedSeconds(session.started_at, new Date(session.ended_at))) - : getElapsedSeconds(session.started_at, now); - - totals.allTime += seconds; - - if (startedAt.toISOString().slice(0, 10) === todayKey) { - totals.day += seconds; - } - - if (startedAt >= weekStart) { - totals.week += seconds; +function getSessionTotals( + sessions: SessionRow[], + dailyStudySeconds: Record, + now = new Date(), +) { + const todayKey = getLocalDateKey(now); + const calendarDay = new Date(`${todayKey}T00:00:00Z`).getUTCDay(); + const weekStartKey = addDateKeyDays(todayKey, -((calendarDay + 6) % 7)); + const monthStartKey = `${todayKey.slice(0, 7)}-01`; + const totals = Object.entries(dailyStudySeconds).reduce( + (result, [dateKey, seconds]) => { + if (dateKey >= weekStartKey && dateKey <= todayKey) { + result.week += seconds; } - if (startedAt >= monthStart) { - totals.month += seconds; + if (dateKey >= monthStartKey && dateKey <= todayKey) { + result.month += seconds; } - return totals; + return result; + }, + { + day: dailyStudySeconds[todayKey] ?? 0, + month: 0, + week: 0, }, - { allTime: 0, day: 0, month: 0, week: 0 }, ); + + return { + ...totals, + allTime: sessions.reduce( + (total, session) => + session.status === "voided" + ? total + : total + getSessionDurationSeconds(session, now), + 0, + ), + }; } function getDailySessionTotals(sessions: SessionRow[], now = new Date()) { @@ -2075,17 +2117,36 @@ function getDailySessionTotals(sessions: SessionRow[], now = new Date()) { return totals; } - const seconds = session.ended_at - ? (session.duration_seconds ?? - getElapsedSeconds(session.started_at, new Date(session.ended_at))) - : getElapsedSeconds(session.started_at, now); - const key = getLocalDateKey(new Date(session.started_at)); + const sessionEnd = session.ended_at + ? new Date(session.ended_at) + : new Date(now); + let cursor = new Date(session.started_at); + + while (cursor < sessionEnd) { + const key = getLocalDateKey(cursor); + const nextDay = getAustralianDateStart(addDateKeyDays(key, 1)); + const segmentEnd = nextDay < sessionEnd ? nextDay : new Date(sessionEnd); + const seconds = Math.max( + 0, + Math.floor((segmentEnd.getTime() - cursor.getTime()) / 1000), + ); + + totals[key] = (totals[key] ?? 0) + seconds; + if (segmentEnd <= cursor) break; + cursor = segmentEnd; + } - totals[key] = (totals[key] ?? 0) + seconds; return totals; }, {}); } +function getSessionDurationSeconds(session: SessionRow, now: Date) { + return session.ended_at + ? (session.duration_seconds ?? + getElapsedSeconds(session.started_at, new Date(session.ended_at))) + : getElapsedSeconds(session.started_at, now); +} + function normalizeGroupIcon(icon: string | null | undefined): GroupIconKey { return GROUP_ICON_KEYS.includes(icon as GroupIconKey) ? (icon as GroupIconKey) diff --git a/src/lib/supabase/group-chat-read-receipts.ts b/src/lib/supabase/group-chat-read-receipts.ts index cd0dcc2..7839c09 100644 --- a/src/lib/supabase/group-chat-read-receipts.ts +++ b/src/lib/supabase/group-chat-read-receipts.ts @@ -12,6 +12,24 @@ type GroupChatReadReceiptRow = { user_id: string; }; +type GroupChatUnreadCountRow = { + group_id: string; + unread_count: number | string; +}; + +export async function fetchGroupChatUnreadCounts(supabase: SupabaseClient) { + const { data, error } = await supabase.rpc("list_group_chat_unread_counts"); + + if (error) throw error; + + return Object.fromEntries( + ((data ?? []) as GroupChatUnreadCountRow[]).map((row) => [ + row.group_id, + Number(row.unread_count) || 0, + ]), + ) as Record; +} + export async function fetchGroupChatReadReceipts( supabase: SupabaseClient, groupId: string, diff --git a/src/lib/supabase/profile.ts b/src/lib/supabase/profile.ts index f383494..6f06791 100644 --- a/src/lib/supabase/profile.ts +++ b/src/lib/supabase/profile.ts @@ -10,6 +10,7 @@ export type Profile = { course: string | null; study_icon: string; profile_color: string; + is_discoverable: boolean; access_status: AccessStatus; access_granted_at: string | null; created_at: string; @@ -27,7 +28,7 @@ export async function getProfileById( const { data, error } = await supabase .from("profiles") .select( - "id, display_name, username, avatar_url, course, study_icon, profile_color, access_status, access_granted_at, created_at, updated_at", + "id, display_name, username, avatar_url, course, study_icon, profile_color, is_discoverable, access_status, access_granted_at, created_at, updated_at", ) .eq("id", userId) .maybeSingle(); diff --git a/src/lib/timer.ts b/src/lib/timer.ts index 8cf78c8..8276aa8 100644 --- a/src/lib/timer.ts +++ b/src/lib/timer.ts @@ -4,6 +4,19 @@ type CompletedSession = { endedAt: string; }; +export const STUDY_TIME_ZONE = "Australia/Sydney"; + +const australianDateTimeFormatter = new Intl.DateTimeFormat("en-AU", { + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + month: "2-digit", + second: "2-digit", + timeZone: STUDY_TIME_ZONE, + year: "numeric", +}); + export function getElapsedSeconds(startedAt: string, now = new Date()) { const started = new Date(startedAt).getTime(); return Math.max(0, Math.floor((now.getTime() - started) / 1000)); @@ -20,11 +33,68 @@ export function formatDuration(totalSeconds: number) { } export function getLocalDateKey(date: Date) { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); + const parts = getAustralianDateTimeParts(date); + + return `${parts.year}-${`${parts.month}`.padStart(2, "0")}-${`${parts.day}`.padStart(2, "0")}`; +} + +export function addDateKeyDays(dateKey: string, days: number) { + const { day, month, year } = parseDateKey(dateKey); + const next = new Date(Date.UTC(year, month - 1, day + days)); + + return [ + next.getUTCFullYear(), + `${next.getUTCMonth() + 1}`.padStart(2, "0"), + `${next.getUTCDate()}`.padStart(2, "0"), + ].join("-"); +} + +export function getAustralianDayRange(date = new Date()) { + const dateKey = getLocalDateKey(date); + + return { + end: getAustralianDateStart(addDateKeyDays(dateKey, 1)), + start: getAustralianDateStart(dateKey), + }; +} + +export function getAustralianDateStart(dateKey: string) { + const { day, month, year } = parseDateKey(dateKey); + const desiredWallClock = Date.UTC(year, month - 1, day); + let candidate = desiredWallClock; + + for (let attempt = 0; attempt < 3; attempt += 1) { + const parts = getAustralianDateTimeParts(new Date(candidate)); + const representedWallClock = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + const adjustment = desiredWallClock - representedWallClock; - return `${year}-${month}-${day}`; + candidate += adjustment; + if (!adjustment) break; + } + + return new Date(candidate); +} + +export function getIntervalOverlapSeconds( + intervalStart: Date | string, + intervalEnd: Date | string, + rangeStart: Date, + rangeEnd: Date, +) { + const start = Math.max( + new Date(intervalStart).getTime(), + rangeStart.getTime(), + ); + const end = Math.min(new Date(intervalEnd).getTime(), rangeEnd.getTime()); + + return Math.max(0, Math.floor((end - start) / 1000)); } export function getSessionSeconds(session: CompletedSession) { @@ -57,3 +127,27 @@ export function groupSessionsBySubject(sessions: CompletedSession[]) { export function isLongSession(startedAt: string, now = new Date()) { return getElapsedSeconds(startedAt, now) >= 6 * 60 * 60; } + +function getAustralianDateTimeParts(date: Date) { + const values = Object.fromEntries( + australianDateTimeFormatter + .formatToParts(date) + .filter((part) => part.type !== "literal") + .map((part) => [part.type, Number(part.value)]), + ); + + return { + day: values.day, + hour: values.hour, + minute: values.minute, + month: values.month, + second: values.second, + year: values.year, + }; +} + +function parseDateKey(dateKey: string) { + const [year, month, day] = dateKey.split("-").map(Number); + + return { day, month, year }; +} diff --git a/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql b/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql new file mode 100644 index 0000000..9a8839c --- /dev/null +++ b/supabase/migrations/20260730050000_group_message_notification_service_permissions.sql @@ -0,0 +1,16 @@ +-- Allow server-only notification delivery code to read recipients and +-- subscriptions, then create and mark notification records as delivered. + +grant select on table + public.groups, + public.group_members, + public.profiles, + public.user_group_notification_settings, + public.user_notification_preferences, + public.push_subscriptions +to service_role; + +grant select, insert, update on table public.app_notifications +to service_role; + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/20260730060000_group_chat_unread_counts.sql b/supabase/migrations/20260730060000_group_chat_unread_counts.sql new file mode 100644 index 0000000..2e19c1f --- /dev/null +++ b/supabase/migrations/20260730060000_group_chat_unread_counts.sql @@ -0,0 +1,37 @@ +-- Efficient unread group-message counts for the signed-in member. + +create or replace function public.list_group_chat_unread_counts() +returns table ( + group_id uuid, + unread_count bigint +) +language sql +stable +security definer +set search_path = public +as $$ + select + membership.group_id, + count(message.id)::bigint as unread_count + from public.group_members as membership + left join public.group_chat_read_receipts as receipt + on receipt.group_id = membership.group_id + and receipt.user_id = membership.user_id + left join public.group_chat_messages as message + on message.group_id = membership.group_id + and message.user_id <> membership.user_id + and message.deleted_at is null + and message.created_at > coalesce( + receipt.last_read_at, + membership.joined_at + ) + where membership.user_id = auth.uid() + and membership.status = 'active' + group by membership.group_id; +$$; + +revoke all on function public.list_group_chat_unread_counts() from public; +grant execute on function public.list_group_chat_unread_counts() +to authenticated; + +notify pgrst, 'reload schema'; diff --git a/supabase/migrations/20260731010000_profile_discoverability.sql b/supabase/migrations/20260731010000_profile_discoverability.sql new file mode 100644 index 0000000..f2ac5b6 --- /dev/null +++ b/supabase/migrations/20260731010000_profile_discoverability.sql @@ -0,0 +1,104 @@ +alter table public.profiles +add column if not exists is_discoverable boolean not null default true; + +grant select (is_discoverable) on table public.profiles to authenticated; + +create or replace function public.set_profile_discoverability( + next_is_discoverable boolean +) +returns boolean +language plpgsql +security definer +set search_path = public +as $$ +begin + if auth.uid() is null then + raise exception 'AUTH_REQUIRED'; + end if; + + update public.profiles + set is_discoverable = coalesce(next_is_discoverable, true) + where id = auth.uid(); + + if not found then + raise exception 'PROFILE_NOT_FOUND'; + end if; + + return coalesce(next_is_discoverable, true); +end; +$$; + +revoke all on function public.set_profile_discoverability(boolean) from public; +grant execute on function public.set_profile_discoverability(boolean) +to authenticated; + +create or replace function public.list_friend_candidates() +returns table ( + user_id uuid, + display_name text, + username text, + avatar_url text, + study_icon text, + profile_color text, + mutual_friend_count bigint, + request_direction text +) +language sql +security definer +set search_path = public +stable +as $$ + select + candidate.id as user_id, + candidate.display_name, + candidate.username, + candidate.avatar_url, + candidate.study_icon, + candidate.profile_color, + ( + select count(*) + from public.friendships mine + join public.friendships theirs + on theirs.friend_id = mine.friend_id + where mine.user_id = auth.uid() + and theirs.user_id = candidate.id + ) as mutual_friend_count, + pending.direction as request_direction + from public.profiles candidate + left join lateral ( + select + case + when request.sender_id = auth.uid() then 'outgoing' + else 'incoming' + end as direction + from public.friend_requests request + where request.status = 'pending' + and ( + ( + request.sender_id = auth.uid() + and request.recipient_id = candidate.id + ) + or + ( + request.sender_id = candidate.id + and request.recipient_id = auth.uid() + ) + ) + order by request.created_at desc + limit 1 + ) pending on true + where candidate.id <> auth.uid() + and coalesce(candidate.is_discoverable, true) + and not exists ( + select 1 + from public.friendships friendship + where friendship.user_id = auth.uid() + and friendship.friend_id = candidate.id + ) + order by mutual_friend_count desc, candidate.display_name, candidate.username; +$$; + +revoke all on function public.list_friend_candidates() from public; +grant execute on function public.list_friend_candidates() to authenticated; + +notify pgrst, 'reload schema';