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({
) : null}
+ {/* An open chat is fixed full-screen on mobile. Drop the scroll
+ container then: on iOS it traps the chat's z-index, letting the app
+ header cover the chat header. */}
{feedback && !directConversationVisible ? (
From 2727fb08e3e4b42f7acdc5a5213ebf5e4bdce937 Mon Sep 17 00:00:00 2001
From: Steven
Date: Fri, 9 Oct 2026 16:15:08 +1100
Subject: [PATCH 6/8] feat(friends): search friends list and hide pending
requests from add-friend
- Add-friend search and suggestions skip anyone with a pending request;
they already appear under Requests
- Friends list reveals more as you scroll instead of paging
- Add a search bar beside Requests to filter friends by name or @handle
EOF
---
next-env.d.ts | 4 +-
src/components/chunked-list.tsx | 58 +++++++
src/components/friends/friends-dashboard.tsx | 61 ++++++-
...9030000_hide_pending_friend_candidates.sql | 159 ++++++++++++++++++
4 files changed, 271 insertions(+), 11 deletions(-)
create mode 100644 src/components/chunked-list.tsx
create mode 100644 supabase/migrations/20261009030000_hide_pending_friend_candidates.sql
diff --git a/next-env.d.ts b/next-env.d.ts
index a419cbe..ce4e94a 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/dev/types/routes.d.ts";
-import "./.next/dev/types/root-params.d.ts";
+import "./.next/types/routes.d.ts";
+import "./.next/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/chunked-list.tsx b/src/components/chunked-list.tsx
new file mode 100644
index 0000000..643fd17
--- /dev/null
+++ b/src/components/chunked-list.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { useState, type ReactNode } from "react";
+import { InfiniteScrollSentinel } from "@/components/infinite-scroll-sentinel";
+
+// Renders one long list, revealing the next chunk as the end scrolls into
+// view, so everything stays reachable by scrolling instead of paging.
+export function ChunkedList({
+ chunkSize = 24,
+ className,
+ items,
+ renderItem,
+ resetKey,
+}: ChunkedListProps) {
+ return (
+
+ );
+}
+
+type ChunkedListProps = {
+ chunkSize?: number;
+ className?: string;
+ items: T[];
+ renderItem: (item: T, index: number) => ReactNode;
+ resetKey?: string;
+};
+
+function ChunkedListContent({
+ chunkSize,
+ className,
+ items,
+ renderItem,
+}: Omit, "chunkSize" | "resetKey"> & {
+ chunkSize: number;
+}) {
+ const [visibleCount, setVisibleCount] = useState(chunkSize);
+ const visibleItems = items.slice(0, visibleCount);
+
+ return (
+ <>
+ {visibleItems.map(renderItem)}
+ {/* Keyed by count so a sentinel still in view after a chunk renders
+ gets a fresh observer and reveals the next chunk too. */}
+ setVisibleCount((count) => count + chunkSize)}
+ />
+ >
+ );
+}
diff --git a/src/components/friends/friends-dashboard.tsx b/src/components/friends/friends-dashboard.tsx
index 95022a0..af92123 100644
--- a/src/components/friends/friends-dashboard.tsx
+++ b/src/components/friends/friends-dashboard.tsx
@@ -30,6 +30,7 @@ import {
import { AppDialog } from "@/components/app-dialog";
import { EmptyStateCta } from "@/components/empty-state-cta";
import { DirectMessages } from "@/components/friends/direct-messages";
+import { ChunkedList } from "@/components/chunked-list";
import { PaginatedList } from "@/components/paginated-list";
import { StudyHeatmap } from "@/components/study-heatmap";
import {
@@ -147,6 +148,7 @@ export function FriendsDashboard({
"friends" | "messages" | "requests"
>("friends");
const [messageFriendId, setMessageFriendId] = useState(null);
+ const [friendSearch, setFriendSearch] = useState("");
const [isDirectConversationOpen, setIsDirectConversationOpen] =
useState(false);
const [directMessageUnreadCount, setDirectMessageUnreadCount] = useState(0);
@@ -460,8 +462,21 @@ export function FriendsDashboard({
),
[selfId, socialState.friends],
);
- const favouriteFriends = friendList.filter((friend) => friend.isFavourite);
- const otherFriends = friendList.filter((friend) => !friend.isFavourite);
+ const friendSearchTerm = friendSearch.trim().toLowerCase().replace(/^@/, "");
+ const matchingFriends = friendSearchTerm
+ ? friendList.filter(
+ (friend) =>
+ friend.name.toLowerCase().includes(friendSearchTerm) ||
+ friend.handle
+ .toLowerCase()
+ .replace(/^@/, "")
+ .includes(friendSearchTerm),
+ )
+ : friendList;
+ const favouriteFriends = matchingFriends.filter(
+ (friend) => friend.isFavourite,
+ );
+ const otherFriends = matchingFriends.filter((friend) => !friend.isFavourite);
function setFriendFavouriteLocally(friendId: string, favourite: boolean) {
setSocialState((current) => ({
@@ -638,6 +653,8 @@ export function FriendsDashboard({
);
const directConversationVisible =
isDirectConversationOpen || Boolean(messageFriendId);
+ const showFriendSearch =
+ activeTab === "friends" && isLoaded && friendList.length > 0;
function addFriend() {
const name = friendName.trim();
@@ -743,8 +760,11 @@ export function FriendsDashboard({
setIsLoadingCandidates(true);
try {
+ // The server leaves out anyone with a pending request, so people
+ // requested since this list loaded no longer count toward the offset.
+ const loaded = query ? (searchResults ?? []) : availableFriends;
const page = await fetchRemoteFriendCandidatesPage({
- offset: query ? (searchResults?.length ?? 0) : availableFriends.length,
+ offset: loaded.filter((friend) => !friend.requestDirection).length,
query,
supabase: remoteClient,
});
@@ -1423,9 +1443,9 @@ export function FriendsDashboard({
-
+
{activeTab === "friends" ? (
-
+
{!isLoaded
? "Loading friendsā¦"
: friendList.length
@@ -1445,7 +1465,8 @@ export function FriendsDashboard({
+ {showFriendSearch ? (
+
+ ) : null}
) : null}
@@ -1495,6 +1535,10 @@ export function FriendsDashboard({
{!isLoaded ? (
+ ) : friendList.length && !matchingFriends.length ? (
+
+ No friends match “{friendSearch.trim()}”
+
) : friendList.length ? (
{favouriteFriends.length ? (
@@ -1508,12 +1552,11 @@ export function FriendsDashboard({
-
) : null}
diff --git a/supabase/migrations/20261009030000_hide_pending_friend_candidates.sql b/supabase/migrations/20261009030000_hide_pending_friend_candidates.sql
new file mode 100644
index 0000000..d6c80c5
--- /dev/null
+++ b/supabase/migrations/20261009030000_hide_pending_friend_candidates.sql
@@ -0,0 +1,159 @@
+-- Add-friend search and "People you may know" leave out anyone with a
+-- pending friend request either way; they already show in the Requests
+-- section. request_direction stays in the result for the client's optimistic
+-- updates but is now always null from the server.
+
+create or replace function public.list_friend_candidates_page(
+ result_limit integer default 50,
+ result_offset integer default 0,
+ search_query text default null
+)
+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 $$
+ with query as (
+ select nullif(lower(left(btrim(coalesce(search_query, '')), 64)), '') as term
+ ),
+ page as (
+ select candidate.*
+ from public.profiles candidate
+ cross join query
+ where candidate.id <> auth.uid()
+ and candidate.is_discoverable
+ and not exists (
+ select 1
+ from public.friendships friendship
+ where friendship.user_id = auth.uid()
+ and friendship.friend_id = candidate.id
+ )
+ and not exists (
+ select 1
+ 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())
+ )
+ )
+ and (
+ query.term is null
+ or strpos(lower(coalesce(candidate.username, '')), ltrim(query.term, '@')) > 0
+ or strpos(lower(coalesce(candidate.display_name, '')), query.term) > 0
+ )
+ order by
+ candidate.display_name nulls last,
+ candidate.username nulls last,
+ candidate.id
+ limit least(greatest(coalesce(result_limit, 50), 1), 100)
+ offset greatest(coalesce(result_offset, 0), 0)
+ )
+ select
+ page.id,
+ page.display_name,
+ page.username,
+ page.avatar_url,
+ page.study_icon,
+ page.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 = page.id
+ ),
+ null::text
+ from page
+ order by
+ page.display_name nulls last,
+ page.username nulls last,
+ page.id;
+$$;
+
+create or replace function public.list_friend_suggestions(
+ result_limit integer default 10
+)
+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 $$
+ with mutuals as (
+ select theirs.user_id as candidate_id, count(*) as mutual_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 <> auth.uid()
+ group by theirs.user_id
+ ),
+ ranked as (
+ select candidate.*, mutuals.mutual_count
+ from mutuals
+ join public.profiles candidate on candidate.id = mutuals.candidate_id
+ where candidate.is_discoverable
+ and candidate.access_status = 'active'
+ and not exists (
+ select 1
+ from public.friendships friendship
+ where friendship.user_id = auth.uid()
+ and friendship.friend_id = candidate.id
+ )
+ and not exists (
+ select 1
+ 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
+ mutuals.mutual_count desc,
+ candidate.display_name nulls last,
+ candidate.username nulls last,
+ candidate.id
+ limit least(greatest(coalesce(result_limit, 10), 1), 25)
+ )
+ select
+ ranked.id,
+ ranked.display_name,
+ ranked.username,
+ ranked.avatar_url,
+ ranked.study_icon,
+ ranked.profile_color,
+ ranked.mutual_count,
+ null::text
+ from ranked
+ order by
+ ranked.mutual_count desc,
+ ranked.display_name nulls last,
+ ranked.username nulls last,
+ ranked.id;
+$$;
+
+notify pgrst, 'reload schema';
From 1cde60fc70165e1b133304be2793e2300f404dd1 Mon Sep 17 00:00:00 2001
From: Steven
Date: Fri, 9 Oct 2026 16:15:32 +1100
Subject: [PATCH 7/8] ui: mute friend in chat
---
src/components/friends/direct-messages.tsx | 42 ++++++++++++++
src/components/groups/group-chat.tsx | 66 +++++++++++++++++++++-
2 files changed, 106 insertions(+), 2 deletions(-)
diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx
index 46755c2..8b0dc68 100644
--- a/src/components/friends/direct-messages.tsx
+++ b/src/components/friends/direct-messages.tsx
@@ -664,6 +664,11 @@ export function DirectMessages({
+ onToggleMute(selectedFriend)}
+ />
void;
+}) {
+ return (
+
+ );
+}
+
function FriendAvatar({ friend }: { friend: SocialFriend }) {
return (
(null);
+ const [notificationSettings, setNotificationSettings] =
+ useState(null);
+ const [isSavingMute, setIsSavingMute] = useState(false);
const chatRef = useRef(null);
const composerRef = useRef(null);
const closeTimerRef = useRef(null);
@@ -231,6 +240,24 @@ export function GroupChat({
messageIdsRef.current = new Set(messages.map((message) => message.id));
}, [messages]);
+ useEffect(() => {
+ if (!remoteClient) return;
+
+ let cancelled = false;
+ void fetchRemoteGroupNotificationSettings({
+ groupId,
+ supabase: remoteClient,
+ })
+ .then((settings) => {
+ if (!cancelled) setNotificationSettings(settings);
+ })
+ .catch(() => undefined);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [groupId, remoteClient]);
+
useEffect(() => {
if (!remoteClient) return;
@@ -528,6 +555,33 @@ export function GroupChat({
window.dispatchEvent(new Event("resize"));
}
+ async function toggleChatMute() {
+ if (!remoteClient || !notificationSettings || isSavingMute) return;
+
+ const previous = notificationSettings;
+ const next = { ...previous, chatMuted: !previous.chatMuted };
+ setNotificationSettings(next);
+ setIsSavingMute(true);
+
+ try {
+ await saveRemoteGroupNotificationSettings({
+ groupId,
+ settings: next,
+ supabase: remoteClient,
+ });
+ setToastMessage(
+ next.chatMuted
+ ? `${groupName} messages muted`
+ : `${groupName} message alerts on`,
+ );
+ } catch {
+ setNotificationSettings(previous);
+ setFeedback("Notification setting could not be saved.");
+ } finally {
+ setIsSavingMute(false);
+ }
+ }
+
function closeChat() {
if (isClosing) return;
@@ -805,7 +859,15 @@ export function GroupChat({
{members.length} {members.length === 1 ? "member" : "members"}
-
+ {remoteClient ? (
+ void toggleChatMute()}
+ />
+ ) : (
+
+ )}
From 9ff9865b27e20ef33022cdf854e5fac8228ca0f9 Mon Sep 17 00:00:00 2001
From: Steven
Date: Fri, 9 Oct 2026 16:22:19 +1100
Subject: [PATCH 8/8] fix(ui): polish friends and back navigation
- Keep requested people in place in add-friend until the dialog closes,
instead of removing them mid-list
- Remove redundant back button from friend requests view
- Make all back arrows mac yellow; friend chat back is a plain arrow
- Tighten the friends search bar on mobile
EOF
---
src/app/(auth)/auth/profile/page.tsx | 2 +-
src/components/friends/direct-messages.tsx | 5 +-
src/components/friends/friends-dashboard.tsx | 66 ++++++++++++++------
src/components/groups/group-chat.tsx | 2 +-
src/components/groups/groups-dashboard.tsx | 8 ++-
src/components/units/units-dashboard.tsx | 4 +-
6 files changed, 60 insertions(+), 27 deletions(-)
diff --git a/src/app/(auth)/auth/profile/page.tsx b/src/app/(auth)/auth/profile/page.tsx
index 3893acb..dbec8ba 100644
--- a/src/app/(auth)/auth/profile/page.tsx
+++ b/src/app/(auth)/auth/profile/page.tsx
@@ -59,7 +59,7 @@ export default async function ProfileSetupPage({
diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx
index 8b0dc68..f128d0f 100644
--- a/src/components/friends/direct-messages.tsx
+++ b/src/components/friends/direct-messages.tsx
@@ -634,12 +634,11 @@ export function DirectMessages({
- ) : activeTab === "requests" ? (
- setActiveTab("friends")}
- type="button"
- >
-
- Friends
-
) : null}
{showFriendSearch ? (
-