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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/(auth)/auth/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default async function ProfileSetupPage({
<header className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-center">
<Link
aria-label="Back to profile"
className="mac-focus inline-flex h-10 w-10 items-center justify-center rounded-md text-[var(--color-text-muted)] transition hover:bg-[rgb(255_255_255/0.04)] hover:text-[var(--color-text)]"
className="mac-focus inline-flex h-10 w-10 items-center justify-center rounded-md text-[var(--color-mac-yellow)] transition hover:bg-[rgb(255_255_255/0.04)]"
href={next}
>
<ArrowLeft aria-hidden size={20} />
Expand Down
58 changes: 58 additions & 0 deletions src/components/chunked-list.tsx
Original file line number Diff line number Diff line change
@@ -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<T>({
chunkSize = 24,
className,
items,
renderItem,
resetKey,
}: ChunkedListProps<T>) {
return (
<ChunkedListContent
chunkSize={chunkSize}
className={className}
items={items}
key={resetKey}
renderItem={renderItem}
/>
);
}

type ChunkedListProps<T> = {
chunkSize?: number;
className?: string;
items: T[];
renderItem: (item: T, index: number) => ReactNode;
resetKey?: string;
};

function ChunkedListContent<T>({
chunkSize,
className,
items,
renderItem,
}: Omit<ChunkedListProps<T>, "chunkSize" | "resetKey"> & {
chunkSize: number;
}) {
const [visibleCount, setVisibleCount] = useState(chunkSize);
const visibleItems = items.slice(0, visibleCount);

return (
<>
<div className={className}>{visibleItems.map(renderItem)}</div>
{/* Keyed by count so a sentinel still in view after a chunk renders
gets a fresh observer and reveals the next chunk too. */}
<InfiniteScrollSentinel
hasMore={visibleCount < items.length}
isLoading={false}
key={visibleCount}
onLoadMore={() => setVisibleCount((count) => count + chunkSize)}
/>
</>
);
}
112 changes: 91 additions & 21 deletions src/components/friends/direct-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,11 @@ export function DirectMessages({
Boolean(initialFriendId && remoteClient),
);
const [isLoadingEarlier, setIsLoadingEarlier] = useState(false);
const [isSending, setIsSending] = useState(false);
const [feedback, setFeedback] = useState<string | null>(null);
const [isClosing, setIsClosing] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const chatRef = useRef<HTMLElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null);
const closeTimerRef = useRef<number | null>(null);
const finishCloseRef = useRef<() => void>(() => {});
const loadSequenceRef = useRef(0);
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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;
}

Expand All @@ -561,7 +567,6 @@ export function DirectMessages({

setDraft("");
setFeedback(null);
setIsSending(true);
shouldScrollToBottomRef.current = true;
setMessages((current) => mergeMessages(current, [pendingMessage]));

Expand Down Expand Up @@ -600,8 +605,6 @@ export function DirectMessages({
),
);
setFeedback(getErrorMessage(error, "Message could not be sent."));
} finally {
setIsSending(false);
}
}

Expand Down Expand Up @@ -631,11 +634,11 @@ export function DirectMessages({
<header className="flex shrink-0 items-center gap-3 border-b border-[var(--color-border)] bg-[rgb(23_23_23/0.96)] px-3 pb-2 pt-[calc(var(--safe-area-top)+0.5rem)] backdrop-blur-xl lg:pt-2">
<button
aria-label="Back to messages"
className="mac-focus inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-[var(--color-text-muted)] transition hover:bg-[rgb(255_255_255/0.045)] hover:text-[var(--color-text)]"
className="mac-focus inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-[var(--color-mac-yellow)] transition active:scale-[0.94]"
onClick={closeConversation}
type="button"
>
<ArrowLeft aria-hidden size={19} />
<ArrowLeft aria-hidden size={22} />
</button>
<button
aria-label={`View ${selectedFriend.name}'s profile`}
Expand All @@ -660,6 +663,11 @@ export function DirectMessages({
</span>
</span>
</button>
<ChatMuteButton
disabled={!remoteClient || muteBusyFriendIds.has(selectedFriend.id)}
muted={mutedFriendIds.has(selectedFriend.id)}
onToggle={() => onToggleMute(selectedFriend)}
/>
</header>

<div
Expand Down Expand Up @@ -765,29 +773,35 @@ export function DirectMessages({
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1 focus-within:border-[rgb(255_227_48/0.7)] focus-within:shadow-[0_0_0_3px_rgb(255_227_48/0.1)]">
<textarea
aria-label={`Message ${selectedFriend.name}`}
className="max-h-28 min-h-10 min-w-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-[0.62rem] text-sm leading-snug outline-none"
className="min-h-10 min-w-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-[0.62rem] text-sm leading-snug outline-none"
maxLength={2000}
onBlur={() => setComposerFocused(false)}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => setComposerFocused(true)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
event.key !== "Enter" ||
event.shiftKey ||
event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
return;
}

event.preventDefault();
void sendMessage();
}}
placeholder="Message…"
ref={composerRef}
rows={1}
value={draft}
/>
{/* Keep the textarea focused on tap so the mobile keyboard stays
up and the chat doesn't resize mid-send. */}
<button
aria-label="Send message"
className="mac-focus inline-flex h-10 w-10 items-center justify-center rounded-md bg-[var(--color-mac-yellow)] text-[#141414] transition active:scale-[0.97] disabled:opacity-45"
disabled={!draft.trim() || isSending || !remoteClient}
disabled={!draft.trim() || !remoteClient}
onPointerDown={(event) => event.preventDefault()}
type="submit"
>
<Send aria-hidden size={17} />
Expand Down Expand Up @@ -909,6 +923,43 @@ export function DirectMessages({
);
}

// Bell in a chat header that mutes notifications for that conversation.
// Shared with the group chat.
export function ChatMuteButton({
disabled,
muted,
onToggle,
}: {
disabled: boolean;
muted: boolean;
onToggle: () => void;
}) {
return (
<button
aria-label={
muted ? "Unmute message notifications" : "Mute message notifications"
}
aria-pressed={muted}
className={cn(
"mac-focus ml-auto inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition disabled:opacity-50",
muted
? "bg-[rgb(255_227_48/0.12)] text-[var(--color-mac-yellow)]"
: "text-[var(--color-text-muted)] hover:bg-[rgb(255_255_255/0.045)] hover:text-[var(--color-text)]",
)}
disabled={disabled}
onClick={onToggle}
title={muted ? "Notifications muted" : "Mute notifications"}
type="button"
>
{muted ? (
<BellOff aria-hidden size={19} />
) : (
<Bell aria-hidden size={19} />
)}
</button>
);
}

function FriendAvatar({ friend }: { friend: SocialFriend }) {
return (
<span
Expand Down Expand Up @@ -945,6 +996,25 @@ function directMessageFromInsertRow(
};
}

// Realtime can echo our own message before the send request returns; swap
// out the matching pending copy so the message doesn't show twice.
function dropMatchingPending(
current: DirectMessage[],
row: DirectMessageInsertRow,
) {
const pendingIndex = current.findIndex(
(message) =>
message.delivery === "sending" &&
message.senderId === row.sender_id &&
message.recipientId === row.recipient_id &&
message.body === row.body,
);

return pendingIndex === -1
? current
: current.filter((_, index) => index !== pendingIndex);
}

function mergeMessages(current: DirectMessage[], incoming: DirectMessage[]) {
const byId = new Map(current.map((message) => [message.id, message]));
incoming.forEach((message) => byId.set(message.id, message));
Expand Down
Loading
Loading