From e30eda3d6b784dd605a9a4abdbfc984bdaccaff4 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 15:25:13 +1100 Subject: [PATCH 1/8] fix: disallow sending friend requests through units page when friend req already been sent --- next-env.d.ts | 4 +- src/components/units/units-dashboard.tsx | 11 +- src/lib/supabase/app-data/units.ts | 10 ++ src/lib/supabase/database.types.ts | 2 +- src/lib/units.ts | 3 + ...1009010000_unit_cohort_friend_requests.sql | 143 ++++++++++++++++++ 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 supabase/migrations/20261009010000_unit_cohort_friend_requests.sql diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.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/components/units/units-dashboard.tsx b/src/components/units/units-dashboard.tsx index af8d7a5..9c77c1a 100644 --- a/src/components/units/units-dashboard.tsx +++ b/src/components/units/units-dashboard.tsx @@ -1982,6 +1982,9 @@ function CohortMemberCard({ const availableGroups = manageableGroups.filter( (group) => !member.sharedGroupIds.includes(group.id), ); + const isOutgoing = requested || member.friendRequest === "outgoing"; + const isIncoming = !isOutgoing && member.friendRequest === "incoming"; + const isPending = isOutgoing || isIncoming; const sharedGroupNames = member.sharedGroupIds .map((groupId) => allGroups.find((group) => group.id === groupId)?.name) .filter((name): name is string => Boolean(name)); @@ -2031,16 +2034,16 @@ function CohortMemberCard({ ) : availableGroups.length ? ( ({ color: friend.color, displayName: friend.name, + friendRequest: null, + friendRequestId: null, handle: friend.handle, id: friend.id, isFriend: index < 2, diff --git a/src/lib/supabase/app-data/units.ts b/src/lib/supabase/app-data/units.ts index 6e570a6..25dd837 100644 --- a/src/lib/supabase/app-data/units.ts +++ b/src/lib/supabase/app-data/units.ts @@ -47,12 +47,16 @@ type UnitCohortResult = type UnitCohortRow = Omit< UnitCohortResult, | "display_name" + | "friend_request_direction" + | "friend_request_id" | "profile_color" | "shared_group_ids" | "study_icon" | "username" > & { display_name: string | null; + friend_request_direction: string | null; + friend_request_id: string | null; profile_color: string | null; shared_group_ids: string[] | null; study_icon: string | null; @@ -435,6 +439,12 @@ export async function fetchRemoteUnitCohortPage({ members: rows.slice(0, UNIT_COHORT_PAGE_SIZE).map((member) => ({ color: member.profile_color || "#FFE330", displayName: member.display_name || member.username || "Student", + friendRequest: + member.friend_request_direction === "incoming" || + member.friend_request_direction === "outgoing" + ? member.friend_request_direction + : null, + friendRequestId: member.friend_request_id, handle: member.username ? `@${member.username}` : "@student", id: member.user_id, isFriend: member.is_friend, diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 09ccb68..a1cbb8f 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -797,7 +797,7 @@ isOneToOne: false }, "get_unit_cohort_page": { Args: { "friends_only"?: boolean,"input_offering_id": string,"result_limit"?: number,"result_offset"?: number,"search_query"?: string }; Returns: { - "display_name": string,"is_friend": boolean,"mutual_friend_count": number,"profile_color": string,"shared_group_ids": (string)[],"study_icon": string,"user_id": string,"username": string + "display_name": string,"friend_request_direction": string,"friend_request_id": string,"is_friend": boolean,"mutual_friend_count": number,"profile_color": string,"shared_group_ids": (string)[],"study_icon": string,"user_id": string,"username": string }[] }, "get_unit_cohort_v2": diff --git a/src/lib/units.ts b/src/lib/units.ts index 09df3c1..0559ad6 100644 --- a/src/lib/units.ts +++ b/src/lib/units.ts @@ -26,6 +26,9 @@ export type UnitEnrollmentFilter = { export type UnitCohortMember = { color: string; displayName: string; + // A pending friend request with this student, if any. + friendRequest: "incoming" | "outgoing" | null; + friendRequestId: string | null; handle: string; id: string; isFriend: boolean; diff --git a/supabase/migrations/20261009010000_unit_cohort_friend_requests.sql b/supabase/migrations/20261009010000_unit_cohort_friend_requests.sql new file mode 100644 index 0000000..9e6e132 --- /dev/null +++ b/supabase/migrations/20261009010000_unit_cohort_friend_requests.sql @@ -0,0 +1,143 @@ +-- The unit class list also says whether a friend request is already pending +-- with each student, so the Request button stays "Requested" after a reload. +-- 'outgoing' means the viewer sent it, 'incoming' means the student did; the +-- request id lets the viewer accept an incoming one from the class list. + +drop function if exists public.get_unit_cohort_page(uuid, text, boolean, integer, integer); + +create function public.get_unit_cohort_page( + input_offering_id uuid, + search_query text default null, + friends_only boolean default false, + result_limit integer default 30, + result_offset integer default 0 +) +returns table ( + user_id uuid, + display_name text, + username text, + profile_color text, + study_icon text, + is_friend boolean, + mutual_friend_count bigint, + shared_group_ids uuid[], + friend_request_direction text, + friend_request_id uuid +) +language sql +stable +security definer +set search_path = public +as $$ + with query as ( + select nullif(lower(left(btrim(coalesce(search_query, '')), 64)), '') as term + ), + members as ( + select + profile.id, + profile.display_name, + profile.username, + profile.profile_color, + profile.study_icon, + exists ( + select 1 + from public.friendships + where friendships.user_id = auth.uid() + and friendships.friend_id = profile.id + ) as is_friend + from public.unit_enrolments as enrolment + join public.profiles as profile on profile.id = enrolment.user_id + cross join query + where public.is_active_mac_member(auth.uid()) + -- Only students enrolled in this offering can list its class. + and exists ( + select 1 + from public.unit_enrolments mine + where mine.offering_id = input_offering_id + and mine.user_id = auth.uid() + and mine.left_at is null + ) + and enrolment.offering_id = input_offering_id + and enrolment.left_at is null + and enrolment.user_id <> auth.uid() + and profile.access_status = 'active' + and ( + query.term is null + or strpos(lower(coalesce(profile.username, '')), ltrim(query.term, '@')) > 0 + or strpos(lower(coalesce(profile.display_name, '')), query.term) > 0 + ) + ), + page as ( + select * + from members + where not coalesce(friends_only, false) or members.is_friend + order by + members.is_friend desc, + members.display_name nulls last, + members.username nulls last, + members.id + limit least(greatest(coalesce(result_limit, 30), 1), 100) + offset greatest(coalesce(result_offset, 0), 0) + ) + select + page.id, + page.display_name, + page.username, + page.profile_color, + page.study_icon, + page.is_friend, + ( + 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 = page.id + ), + coalesce( + array( + select viewer.group_id + from public.group_members as viewer + join public.group_members as peer + on peer.group_id = viewer.group_id + where viewer.user_id = auth.uid() + and viewer.status = 'active' + and peer.user_id = page.id + and peer.status = 'active' + order by viewer.group_id + ), + '{}'::uuid[] + ), + pending.direction, + pending.id + from page + left join lateral ( + select + request.id, + case + when request.sender_id = auth.uid() then 'outgoing' + else 'incoming' + end as direction + from public.friend_requests as request + where not page.is_friend + and request.status = 'pending' + and ( + (request.sender_id = auth.uid() and request.recipient_id = page.id) + or (request.sender_id = page.id and request.recipient_id = auth.uid()) + ) + order by request.created_at desc + limit 1 + ) as pending on true + order by + page.is_friend desc, + page.display_name nulls last, + page.username nulls last, + page.id; +$$; + +revoke all on function public.get_unit_cohort_page(uuid, text, boolean, integer, integer) +from public; +grant execute on function public.get_unit_cohort_page(uuid, text, boolean, integer, integer) +to authenticated; + +notify pgrst, 'reload schema'; From 87e517a956e0492b5a318c15671400c96790c805 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 15:31:01 +1100 Subject: [PATCH 2/8] feat: allow accepting friend requests for unit cohorts --- src/components/units/units-dashboard.tsx | 62 +++++++- ...1009010000_unit_cohort_friend_requests.sql | 38 ++--- ...9020000_unit_cohort_friend_request_ids.sql | 142 ++++++++++++++++++ 3 files changed, 213 insertions(+), 29 deletions(-) create mode 100644 supabase/migrations/20261009020000_unit_cohort_friend_request_ids.sql diff --git a/src/components/units/units-dashboard.tsx b/src/components/units/units-dashboard.tsx index 9c77c1a..01ba879 100644 --- a/src/components/units/units-dashboard.tsx +++ b/src/components/units/units-dashboard.tsx @@ -54,6 +54,7 @@ import { requestRemoteSpecialUnit, setRemoteSubjectUnitOffering, createRemoteSubjectForUnit, + updateRemoteFriendRequest, upsertRemoteUnitEnrollment, type RemoteUnitState, } from "@/lib/supabase/app-data"; @@ -652,6 +653,36 @@ export function UnitsDashboard({ } } + async function acceptFriendRequest(member: UnitCohortMember) { + if (!member.friendRequestId) return; + setBusyKey(`friend:${member.id}`); + setFeedback(null); + + try { + await updateRemoteFriendRequest({ + action: "accept", + requestId: member.friendRequestId, + }); + setCohort((current) => + current.map((item) => + item.id === member.id + ? { + ...item, + friendRequest: null, + friendRequestId: null, + isFriend: true, + } + : item, + ), + ); + setToastMessage("Friend request accepted"); + } catch (error) { + setFeedback(getErrorMessage(error, "Could not accept this request.")); + } finally { + setBusyKey(null); + } + } + async function addToGroup(memberId: string, groupId: string) { if (!groupId) return; setBusyKey(`group:${memberId}`); @@ -700,6 +731,7 @@ export function UnitsDashboard({ enrollment={selectedEnrollment} feedback={feedback} manageableGroups={manageableGroups} + onAcceptFriend={(member) => void acceptFriendRequest(member)} onAddFriend={(memberId) => void addFriend(memberId)} onAddToGroup={(memberId, groupId) => void addToGroup(memberId, groupId) @@ -1215,6 +1247,7 @@ function OfferingDetail({ enrollment, feedback, manageableGroups, + onAcceptFriend, onAddFriend, onAddToGroup, onBack, @@ -1240,6 +1273,7 @@ function OfferingDetail({ enrollment: UnitEnrollment; feedback: string | null; manageableGroups: SocialGroup[]; + onAcceptFriend: (member: UnitCohortMember) => void; onAddFriend: (memberId: string) => void; onAddToGroup: (memberId: string, groupId: string) => void; onBack: () => void; @@ -1550,6 +1584,7 @@ function OfferingDetail({ key={member.id} manageableGroups={manageableGroups} member={member} + onAcceptFriend={onAcceptFriend} onAddFriend={onAddFriend} onAddToGroup={onAddToGroup} requested={sentFriendRequestIds.includes(member.id)} @@ -1967,6 +2002,7 @@ function CohortMemberCard({ busyKey, manageableGroups, member, + onAcceptFriend, onAddFriend, onAddToGroup, requested, @@ -1975,6 +2011,7 @@ function CohortMemberCard({ busyKey: string | null; manageableGroups: SocialGroup[]; member: UnitCohortMember; + onAcceptFriend: (member: UnitCohortMember) => void; onAddFriend: (memberId: string) => void; onAddToGroup: (memberId: string, groupId: string) => void; requested: boolean; @@ -1983,8 +2020,10 @@ function CohortMemberCard({ (group) => !member.sharedGroupIds.includes(group.id), ); const isOutgoing = requested || member.friendRequest === "outgoing"; - const isIncoming = !isOutgoing && member.friendRequest === "incoming"; - const isPending = isOutgoing || isIncoming; + const canAccept = + !isOutgoing && + member.friendRequest === "incoming" && + Boolean(member.friendRequestId); const sharedGroupNames = member.sharedGroupIds .map((groupId) => allGroups.find((group) => group.id === groupId)?.name) .filter((name): name is string => Boolean(name)); @@ -2034,16 +2073,25 @@ function CohortMemberCard({ ) : availableGroups.length ? ( auth.uid() + and profile.access_status = 'active' + and ( + query.term is null + or strpos(lower(coalesce(profile.username, '')), ltrim(query.term, '@')) > 0 + or strpos(lower(coalesce(profile.display_name, '')), query.term) > 0 + ) + ), + page as ( + select * + from members + where not coalesce(friends_only, false) or members.is_friend + order by + members.is_friend desc, + members.display_name nulls last, + members.username nulls last, + members.id + limit least(greatest(coalesce(result_limit, 30), 1), 100) + offset greatest(coalesce(result_offset, 0), 0) + ) + select + page.id, + page.display_name, + page.username, + page.profile_color, + page.study_icon, + page.is_friend, + ( + 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 = page.id + ), + coalesce( + array( + select viewer.group_id + from public.group_members as viewer + join public.group_members as peer + on peer.group_id = viewer.group_id + where viewer.user_id = auth.uid() + and viewer.status = 'active' + and peer.user_id = page.id + and peer.status = 'active' + order by viewer.group_id + ), + '{}'::uuid[] + ), + pending.direction, + pending.id + from page + left join lateral ( + select + request.id, + case + when request.sender_id = auth.uid() then 'outgoing' + else 'incoming' + end as direction + from public.friend_requests as request + where not page.is_friend + and request.status = 'pending' + and ( + (request.sender_id = auth.uid() and request.recipient_id = page.id) + or (request.sender_id = page.id and request.recipient_id = auth.uid()) + ) + order by request.created_at desc + limit 1 + ) as pending on true + order by + page.is_friend desc, + page.display_name nulls last, + page.username nulls last, + page.id; +$$; + +revoke all on function public.get_unit_cohort_page(uuid, text, boolean, integer, integer) +from public; +grant execute on function public.get_unit_cohort_page(uuid, text, boolean, integer, integer) +to authenticated; + +notify pgrst, 'reload schema'; From e9df048a0fe4c6d47fdde5989d2d6d5c8785d184 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 15:52:03 +1100 Subject: [PATCH 3/8] fix: chat composer flicker on mobile send Direct messages: - keep textarea focused on send tap so the mobile keyboard stays up - don't lock the composer while sending; allow multiple pending sends - auto-grow the textarea and send on Enter, matching group chat - drop the pending copy when the realtime echo arrives first Group chat: - swap the pending bubble for the sent message in one render using the returned message id, instead of clearing it before the refresh lands - drop the pending copy when the realtime echo arrives first - keep the shared message cache in step with locally added messages --- src/components/friends/direct-messages.tsx | 66 +++++++++++++++------- src/components/groups/group-chat.tsx | 64 ++++++++++++++++----- 2 files changed, 98 insertions(+), 32 deletions(-) diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx index 61b0b9f..49577a4 100644 --- a/src/components/friends/direct-messages.tsx +++ b/src/components/friends/direct-messages.tsx @@ -112,11 +112,11 @@ export function DirectMessages({ Boolean(initialFriendId && remoteClient), ); const [isLoadingEarlier, setIsLoadingEarlier] = useState(false); - const [isSending, setIsSending] = useState(false); const [feedback, setFeedback] = useState(null); const [isClosing, setIsClosing] = useState(false); const listRef = useRef(null); const chatRef = useRef(null); + const composerRef = useRef(null); const closeTimerRef = useRef(null); const finishCloseRef = useRef<() => void>(() => {}); const loadSequenceRef = useRef(0); @@ -294,6 +294,14 @@ export function DirectMessages({ }; }, [isConversationOpen]); + useEffect(() => { + const composer = composerRef.current; + if (!composer) return; + + composer.style.height = "auto"; + composer.style.height = `${Math.min(composer.scrollHeight, 112)}px`; + }, [draft, isConversationOpen]); + function setComposerFocused(focused: boolean) { document.body.classList.toggle("mac-chat-composer-active", focused); window.dispatchEvent(new Event("resize")); @@ -488,7 +496,9 @@ export function DirectMessages({ if (counterpartId === selectedFriendId) { shouldScrollToBottomRef.current = true; setMessages((current) => - mergeMessages(current, [directMessageFromInsertRow(row)]), + mergeMessages(dropMatchingPending(current, row), [ + directMessageFromInsertRow(row), + ]), ); if ( @@ -536,15 +546,11 @@ export function DirectMessages({ } } + // Like the group chat, sends don't block the composer: each message shows + // as pending straight away and several can be in flight at once. async function sendMessage(messageBody = draft) { const body = messageBody.trim(); - if ( - !body || - !selectedFriend || - !currentUserId || - !remoteClient || - isSending - ) { + if (!body || !selectedFriend || !currentUserId || !remoteClient) { return; } @@ -561,7 +567,6 @@ export function DirectMessages({ setDraft(""); setFeedback(null); - setIsSending(true); shouldScrollToBottomRef.current = true; setMessages((current) => mergeMessages(current, [pendingMessage])); @@ -600,8 +605,6 @@ export function DirectMessages({ ), ); setFeedback(getErrorMessage(error, "Message could not be sent.")); - } finally { - setIsSending(false); } } @@ -765,29 +768,35 @@ export function DirectMessages({