fix: chat UI fixes
This commit is contained in:
@@ -33,6 +33,7 @@ import { ForwardDialog } from "@/components/chat/forward-dialog";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { channelAuthorizer } from "@/lib/realtime";
|
import { channelAuthorizer } from "@/lib/realtime";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useVisualViewport } from "@/lib/use-visual-viewport";
|
||||||
import {
|
import {
|
||||||
chat,
|
chat,
|
||||||
conversationTitle,
|
conversationTitle,
|
||||||
@@ -63,6 +64,13 @@ export default function ChatThreadPage() {
|
|||||||
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
const [menu, setMenu] = useState<{ message: ChatMessage; point: MenuPoint } | null>(null);
|
||||||
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
const [forwarding, setForwarding] = useState<ChatMessage | null>(null);
|
||||||
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
const [starredIds, setStarredIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// While the keyboard is up the thread is sized from the visual viewport
|
||||||
|
// instead of dvh — see useVisualViewport for why dvh can't do this on iOS.
|
||||||
|
// Declared with the other hooks: the early return below means anything
|
||||||
|
// placed after it would run conditionally.
|
||||||
|
const viewport = useVisualViewport();
|
||||||
|
const keyboardOpen = viewport?.keyboardOpen ?? false;
|
||||||
// Guards against two loads racing (scroll handler + the short-page effect).
|
// Guards against two loads racing (scroll handler + the short-page effect).
|
||||||
const loadingRef = useRef(false);
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
@@ -429,8 +437,30 @@ export default function ChatThreadPage() {
|
|||||||
// space, and calc() requires whitespace around a binary `-` — written
|
// space, and calc() requires whitespace around a binary `-` — written
|
||||||
// without it the whole declaration is invalid and dropped, which
|
// without it the whole declaration is invalid and dropped, which
|
||||||
// collapses this to height:auto.
|
// collapses this to height:auto.
|
||||||
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
// Only while the keyboard is closed; otherwise the inline style below
|
||||||
|
// takes over and these would fight it.
|
||||||
|
!keyboardOpen &&
|
||||||
|
"h-[calc(100dvh_-_3rem_-_4rem)] sm:h-[calc(100dvh_-_3.5rem_-_4rem)] md:h-[calc(100dvh_-_3.5rem)]",
|
||||||
)}
|
)}
|
||||||
|
style={
|
||||||
|
keyboardOpen && viewport
|
||||||
|
? {
|
||||||
|
// Pinned to the visible strip above the keyboard. Fixed rather
|
||||||
|
// than in-flow because iOS scrolls the layout viewport instead
|
||||||
|
// of resizing it, so an in-flow column keeps its old height and
|
||||||
|
// hides the composer underneath. left/right 0 with the existing
|
||||||
|
// mx-auto keeps the desktop column centred.
|
||||||
|
position: "fixed",
|
||||||
|
top: viewport.offsetTop,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: viewport.height,
|
||||||
|
// Above the app's fixed bottom nav (z-40) so the composer can
|
||||||
|
// never be painted over by it.
|
||||||
|
zIndex: 50,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
||||||
import { ogDefault } from "@/lib/og";
|
import { ogDefault } from "@/lib/og";
|
||||||
@@ -43,6 +43,16 @@ const bricolage = Bricolage_Grotesque({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
// Ask the browser to shrink the layout viewport when the software keyboard
|
||||||
|
// opens, which is the native fix for a bottom-anchored composer. Chrome and
|
||||||
|
// Android honour it; Safari does not, which is why the chat thread also
|
||||||
|
// tracks window.visualViewport by hand.
|
||||||
|
interactiveWidget: "resizes-content",
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
// Resolves all relative OG/Twitter image URLs and canonical links to
|
// Resolves all relative OG/Twitter image URLs and canonical links to
|
||||||
// absolute URLs (required for correct social previews + SEO; Next warns
|
// absolute URLs (required for correct social previews + SEO; Next warns
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search for people and add them to a group.
|
* Search for people and add them to a group. Bottom drawer on mobile, modal on
|
||||||
|
* desktop — see ResponsiveDialog.
|
||||||
*
|
*
|
||||||
* Search runs through the same-origin /api/search/users proxy the search page
|
* Search runs through the same-origin /api/search/users proxy the search page
|
||||||
* uses. It returns handles, which is exactly what the member API takes — no id
|
* uses. It returns handles, which is exactly what the member API takes — no id
|
||||||
@@ -9,12 +10,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Check, Search, X } from "lucide-react";
|
import { Check, Search } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chat } from "@/lib/chat";
|
import { chat } from "@/lib/chat";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
|
||||||
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ export function AddMembersDialog({
|
|||||||
onAdded: () => void;
|
onAdded: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// Results are stored with the query they belong to, so what's displayed can
|
// Results are stored with the query they belong to, so what's displayed can
|
||||||
// be derived: a stale page from a previous query simply stops matching, and
|
// be derived: a stale page from a previous query simply stops matching, and
|
||||||
@@ -68,12 +72,6 @@ export function AddMembersDialog({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [q, searchable]);
|
}, [q, searchable]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const toggle = (handle: string) =>
|
const toggle = (handle: string) =>
|
||||||
setPicked((prev) => {
|
setPicked((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -100,85 +98,70 @@ export function AddMembersDialog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
<ResponsiveDialog
|
||||||
<div className="absolute inset-0" onClick={onClose} aria-hidden />
|
title="Add members"
|
||||||
<div className="relative z-10 flex max-h-[75vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
|
description="Search for people to add to this group."
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
onClose={onClose}
|
||||||
<h2 className="text-sm font-semibold text-foreground">Add members</h2>
|
toolbar={
|
||||||
<button
|
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
||||||
type="button"
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
onClick={onClose}
|
<input
|
||||||
aria-label="Close"
|
// Not auto-focused on mobile: the keyboard would open mid-way
|
||||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
// through the drawer's slide-up and fight the animation.
|
||||||
>
|
autoFocus={!isMobile}
|
||||||
<X className="size-4" />
|
value={query}
|
||||||
</button>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by name or handle"
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="border-b border-border p-3">
|
footer={
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
<Button
|
||||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
className="w-full"
|
||||||
<input
|
disabled={picked.size === 0 || busy}
|
||||||
autoFocus
|
onClick={submit}
|
||||||
value={query}
|
>
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
{picked.size === 0
|
||||||
placeholder="Search by name or handle"
|
? "Select people to add"
|
||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
|
||||||
/>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</div>
|
>
|
||||||
|
{!searchable ? (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
{!searchable ? (
|
Type at least two characters to search.
|
||||||
<p className="p-4 text-sm text-muted-foreground">
|
</p>
|
||||||
Type at least two characters to search.
|
) : visible.length === 0 ? (
|
||||||
</p>
|
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||||
) : visible.length === 0 ? (
|
) : (
|
||||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
<ul className="divide-y divide-border">
|
||||||
) : (
|
{visible.map((u) => (
|
||||||
<ul className="divide-y divide-border">
|
<li key={u.handle}>
|
||||||
{visible.map((u) => (
|
<button
|
||||||
<li key={u.handle}>
|
type="button"
|
||||||
<button
|
onClick={() => toggle(u.handle)}
|
||||||
type="button"
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||||
onClick={() => toggle(u.handle)}
|
>
|
||||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
<UserAvatar
|
||||||
>
|
src={u.avatar_url}
|
||||||
<UserAvatar
|
name={u.name}
|
||||||
src={u.avatar_url}
|
className="size-9 shrink-0"
|
||||||
name={u.name}
|
/>
|
||||||
className="size-9 shrink-0"
|
<div className="min-w-0 flex-1">
|
||||||
/>
|
<p className="truncate text-sm text-foreground">{u.name}</p>
|
||||||
<div className="min-w-0 flex-1">
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
<p className="truncate text-sm text-foreground">
|
@{u.handle}
|
||||||
{u.name}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
{picked.has(u.handle) && (
|
||||||
@{u.handle}
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
</p>
|
)}
|
||||||
</div>
|
</button>
|
||||||
{picked.has(u.handle) && (
|
</li>
|
||||||
<Check className="size-4 shrink-0 text-primary" />
|
))}
|
||||||
)}
|
</ul>
|
||||||
</button>
|
)}
|
||||||
</li>
|
</ResponsiveDialog>
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t border-border p-3">
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
disabled={picked.size === 0 || busy}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
{picked.size === 0
|
|
||||||
? "Select people to add"
|
|
||||||
: `Add ${picked.size} ${picked.size === 1 ? "person" : "people"}`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,7 +271,12 @@ export function ChatComposer({
|
|||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2 text-sm",
|
// 16px on touch devices: iOS Safari zooms the whole page when a
|
||||||
|
// focused field computes below that, which on a composer means the
|
||||||
|
// thread jumps every time the keyboard opens. Pointer-based devices
|
||||||
|
// keep the app's 14px chrome.
|
||||||
|
"flex-1 resize-none rounded-2xl border border-border bg-muted/40 px-3 py-2",
|
||||||
|
"text-sm pointer-coarse:text-base",
|
||||||
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
|
"placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40",
|
||||||
"disabled:opacity-60",
|
"disabled:opacity-60",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function GroupSettingsTab({
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"
|
className="flex-1 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
|
<Button size="sm" disabled={!renamed || busy} onClick={rename}>
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a group: name it, pick people, done.
|
* Create a group: name it, pick people, done. Bottom drawer on mobile, modal on
|
||||||
|
* desktop — see ResponsiveDialog.
|
||||||
*
|
*
|
||||||
* Members are named by handle, matching the member-management API — search
|
* Members are named by handle, matching the member-management API — search
|
||||||
* results already identify people that way, so nothing needs id plumbing.
|
* results already identify people that way, so nothing needs id plumbing.
|
||||||
@@ -12,15 +13,18 @@ import { useRouter } from "next/navigation";
|
|||||||
import { Check, Search, X } from "lucide-react";
|
import { Check, Search, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { UserAvatar } from "@/components/ui/user-avatar";
|
import { UserAvatar } from "@/components/ui/user-avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { chat } from "@/lib/chat";
|
import { chat } from "@/lib/chat";
|
||||||
|
import { ResponsiveDialog } from "@/components/chat/responsive-dialog";
|
||||||
|
|
||||||
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
type Candidate = { handle: string; name: string; avatar_url: string | null };
|
||||||
|
|
||||||
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// Results are stored with the query they belong to, so what's displayed can
|
// Results are stored with the query they belong to, so what's displayed can
|
||||||
@@ -56,12 +60,6 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [q, searchable]);
|
}, [q, searchable]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
|
||||||
window.addEventListener("keydown", onKey);
|
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const toggle = (u: Candidate) =>
|
const toggle = (u: Candidate) =>
|
||||||
setPicked((prev) => {
|
setPicked((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -85,29 +83,21 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center">
|
<ResponsiveDialog
|
||||||
<div className="absolute inset-0" onClick={onClose} aria-hidden />
|
title="New group"
|
||||||
<div className="relative z-10 flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-t-2xl border border-border bg-background sm:rounded-2xl">
|
description="Name the group and choose who to include."
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
onClose={onClose}
|
||||||
<h2 className="text-sm font-semibold text-foreground">New group</h2>
|
toolbar={
|
||||||
<button
|
<div className="space-y-3">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 border-b border-border p-3">
|
|
||||||
<input
|
<input
|
||||||
autoFocus
|
// Not auto-focused on mobile: the keyboard would open mid-way
|
||||||
|
// through the drawer's slide-up and fight the animation.
|
||||||
|
autoFocus={!isMobile}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
maxLength={120}
|
maxLength={120}
|
||||||
placeholder="Group name"
|
placeholder="Group name"
|
||||||
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"
|
className="w-full rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm outline-none pointer-coarse:text-base focus:ring-1 focus:ring-primary/40"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2">
|
||||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||||
@@ -115,7 +105,7 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Add people"
|
placeholder="Add people"
|
||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
className="flex-1 bg-transparent text-sm outline-none pointer-coarse:text-base placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,54 +125,51 @@ export function NewGroupDialog({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
<div className="flex-1 overflow-y-auto">
|
footer={
|
||||||
{!searchable ? (
|
<Button
|
||||||
<p className="p-4 text-sm text-muted-foreground">
|
className="w-full"
|
||||||
Search to add people. You can also add them later.
|
disabled={title.trim() === "" || busy}
|
||||||
</p>
|
onClick={create}
|
||||||
) : visible.length === 0 ? (
|
>
|
||||||
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
Create group
|
||||||
) : (
|
</Button>
|
||||||
<ul className="divide-y divide-border">
|
}
|
||||||
{visible.map((u) => (
|
>
|
||||||
<li key={u.handle}>
|
{!searchable ? (
|
||||||
<button
|
<p className="p-4 text-sm text-muted-foreground">
|
||||||
type="button"
|
Search to add people. You can also add them later.
|
||||||
onClick={() => toggle(u)}
|
</p>
|
||||||
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
) : visible.length === 0 ? (
|
||||||
>
|
<p className="p-4 text-sm text-muted-foreground">No one found.</p>
|
||||||
<UserAvatar
|
) : (
|
||||||
src={u.avatar_url}
|
<ul className="divide-y divide-border">
|
||||||
name={u.name}
|
{visible.map((u) => (
|
||||||
className="size-9 shrink-0"
|
<li key={u.handle}>
|
||||||
/>
|
<button
|
||||||
<div className="min-w-0 flex-1">
|
type="button"
|
||||||
<p className="truncate text-sm text-foreground">{u.name}</p>
|
onClick={() => toggle(u)}
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-muted/60"
|
||||||
@{u.handle}
|
>
|
||||||
</p>
|
<UserAvatar
|
||||||
</div>
|
src={u.avatar_url}
|
||||||
{picked.has(u.handle) && (
|
name={u.name}
|
||||||
<Check className="size-4 shrink-0 text-primary" />
|
className="size-9 shrink-0"
|
||||||
)}
|
/>
|
||||||
</button>
|
<div className="min-w-0 flex-1">
|
||||||
</li>
|
<p className="truncate text-sm text-foreground">{u.name}</p>
|
||||||
))}
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
</ul>
|
@{u.handle}
|
||||||
)}
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{picked.has(u.handle) && (
|
||||||
<div className="border-t border-border p-3">
|
<Check className="size-4 shrink-0 text-primary" />
|
||||||
<Button
|
)}
|
||||||
className="w-full"
|
</button>
|
||||||
disabled={title.trim() === "" || busy}
|
</li>
|
||||||
onClick={create}
|
))}
|
||||||
>
|
</ul>
|
||||||
Create group
|
)}
|
||||||
</Button>
|
</ResponsiveDialog>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell the chat dialogs sit in: a bottom drawer on mobile, a centred
|
||||||
|
* modal on desktop.
|
||||||
|
*
|
||||||
|
* Extracted because "Add members" and "New group" are the same shape — title,
|
||||||
|
* a fixed toolbar of inputs, a scrolling list of people, a confirm button —
|
||||||
|
* and each would otherwise carry its own copy of the drawer/modal split.
|
||||||
|
*
|
||||||
|
* Follows the pattern already used by ReportDialog and ShareModal: the same
|
||||||
|
* `useMediaQuery("(max-width: 767px)")` breakpoint, vaul's Drawer below it,
|
||||||
|
* and a portalled modal above.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
|
||||||
|
export function ResponsiveDialog({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onClose,
|
||||||
|
toolbar,
|
||||||
|
footer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fixed block under the title — search fields, selected chips. */
|
||||||
|
toolbar?: ReactNode;
|
||||||
|
/** Pinned action row at the bottom. */
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** The scrolling region between toolbar and footer. */
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
|
|
||||||
|
// Desktop only: Escape to dismiss, and lock the page behind the modal. The
|
||||||
|
// drawer does both itself, and doubling up on the scroll lock fights vaul's
|
||||||
|
// own body handling on iOS.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [isMobile, onClose]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Drawer open onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DrawerContent className="bg-card">
|
||||||
|
<DrawerHeader className="text-left">
|
||||||
|
<DrawerTitle>{title}</DrawerTitle>
|
||||||
|
<DrawerDescription>{description}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
{toolbar && <div className="border-b border-border px-4 pb-3">{toolbar}</div>}
|
||||||
|
{/* Capped rather than flex-1: a drawer sizes to its content, so an
|
||||||
|
unbounded list would grow the sheet past the screen. */}
|
||||||
|
<div className="max-h-[45vh] overflow-y-auto overscroll-contain">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer && <div className="border-t border-border p-4">{footer}</div>}
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-100 bg-foreground/40 backdrop-blur-sm animate-in fade-in-0"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
className="fixed left-1/2 top-1/2 z-101 w-full max-w-md -translate-x-1/2 -translate-y-1/2 px-4"
|
||||||
|
>
|
||||||
|
<div className="flex max-h-[80vh] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-xl">
|
||||||
|
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="font-[family-name:var(--font-title)] text-lg font-semibold text-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{toolbar && <div className="border-b border-border p-3">{toolbar}</div>}
|
||||||
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
|
{footer && <div className="border-t border-border p-3">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the *visual* viewport — the part of the page not covered by the
|
||||||
|
* software keyboard.
|
||||||
|
*
|
||||||
|
* Why this exists: on iOS Safari `100dvh` does not shrink when the keyboard
|
||||||
|
* opens. The dynamic viewport units respond to browser chrome collapsing, not
|
||||||
|
* to the keyboard, so a composer pinned to the bottom of a `100dvh` column
|
||||||
|
* ends up underneath it. `window.visualViewport` is the only thing iOS updates
|
||||||
|
* reliably, so the thread is sized from that instead while the keyboard is up.
|
||||||
|
*
|
||||||
|
* `offsetTop` matters as much as `height`: iOS scrolls the layout viewport to
|
||||||
|
* reveal the focused field rather than resizing it, so a panel pinned to the
|
||||||
|
* visible area has to follow that offset or it drifts off-screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type VisualViewport = {
|
||||||
|
/** Visible height in px, excluding the keyboard. */
|
||||||
|
height: number;
|
||||||
|
/** How far the layout viewport has scrolled to reveal the focused field. */
|
||||||
|
offsetTop: number;
|
||||||
|
/** True once the visible area is materially shorter than the window. */
|
||||||
|
keyboardOpen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL bar collapsing costs ~50–60px; a keyboard costs several hundred. The
|
||||||
|
* threshold keeps ordinary chrome changes from being read as a keyboard.
|
||||||
|
*/
|
||||||
|
const KEYBOARD_THRESHOLD_PX = 120;
|
||||||
|
|
||||||
|
export function useVisualViewport(): VisualViewport | null {
|
||||||
|
const [state, setState] = useState<VisualViewport | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
// Absent on older browsers — callers fall back to their CSS height.
|
||||||
|
if (!vv) return;
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
setState({
|
||||||
|
height: vv.height,
|
||||||
|
offsetTop: vv.offsetTop,
|
||||||
|
keyboardOpen: window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
vv.addEventListener("resize", apply);
|
||||||
|
// iOS fires scroll (not resize) as the layout viewport shifts under the
|
||||||
|
// keyboard, so both are needed to stay pinned.
|
||||||
|
vv.addEventListener("scroll", apply);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener("resize", apply);
|
||||||
|
vv.removeEventListener("scroll", apply);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user