fix: stickers and chat UI fix
This commit is contained in:
@@ -13,13 +13,16 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Paperclip, Smile, SendHorizonal, X } from "lucide-react";
|
||||
import { Paperclip, Sticker, SendHorizonal, X } from "lucide-react";
|
||||
import { LinkPreviewCard } from "@/components/chat/link-preview-card";
|
||||
import {
|
||||
ComposerAttachments,
|
||||
type StagedAttachment,
|
||||
} from "@/components/chat/composer-attachments";
|
||||
import { StickerPicker } from "@/components/chat/sticker-picker";
|
||||
import {
|
||||
StickerPicker,
|
||||
type StickerAnchor,
|
||||
} from "@/components/chat/sticker-picker";
|
||||
import { compressImage } from "@/lib/compress-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -70,7 +73,9 @@ export function ChatComposer({
|
||||
const [staged, setStaged] = useState<StagedAttachment[]>([]);
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [pickingSticker, setPickingSticker] = useState(false);
|
||||
// Non-null opens the sticker picker AND positions it: the popover hangs
|
||||
// off the button's viewport rect, which only exists at click time.
|
||||
const [stickerAnchor, setStickerAnchor] = useState<StickerAnchor | null>(null);
|
||||
const lookupSeq = useRef(0);
|
||||
|
||||
// Grow with the content, up to a cap.
|
||||
@@ -248,61 +253,84 @@ export function ChatComposer({
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach a file"
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickingSticker(true)}
|
||||
disabled={disabled}
|
||||
aria-label="Send a sticker"
|
||||
className="inline-flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Smile className="size-4" />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
// Fired on every keystroke; the handler throttles itself, so the
|
||||
// thread hears "still typing" every few seconds rather than per
|
||||
// character.
|
||||
onTyping?.();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends on a physical keyboard; Shift+Enter makes a newline.
|
||||
// On touch keyboards Enter must insert a newline instead — there is
|
||||
// no Shift to reach for — so the send button is the only way there.
|
||||
if (e.key !== "Enter" || e.shiftKey) return;
|
||||
const touch =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(pointer: coarse)").matches;
|
||||
if (touch) return;
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
{/* One field group: attach on the left, sticker on the right, the
|
||||
text between them. The border and background live here rather than
|
||||
on the textarea so all three read as a single control, and
|
||||
focus-within moves the ring to the group. */}
|
||||
<div
|
||||
className={cn(
|
||||
// 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",
|
||||
"disabled:opacity-60",
|
||||
// Minimal horizontal padding: the icon buttons keep a full-height
|
||||
// tap target, but sit close to the group's edges and to the text
|
||||
// rather than floating in their own gutters.
|
||||
"flex flex-1 items-end gap-0.5 rounded-2xl border border-border bg-muted/40 px-1 py-1",
|
||||
"focus-within:ring-1 focus-within:ring-primary/40",
|
||||
disabled && "opacity-60",
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach a file"
|
||||
className="inline-flex h-8 w-8 mr-1 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Paperclip className="size-5" />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={1}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
// Fired on every keystroke; the handler throttles itself, so the
|
||||
// thread hears "still typing" every few seconds rather than per
|
||||
// character.
|
||||
onTyping?.();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends on a physical keyboard; Shift+Enter makes a
|
||||
// newline. On touch keyboards Enter must insert a newline
|
||||
// instead — there is no Shift to reach for — so the send button
|
||||
// is the only way there.
|
||||
if (e.key !== "Enter" || e.shiftKey) return;
|
||||
const touch =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(pointer: coarse)").matches;
|
||||
if (touch) return;
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className={cn(
|
||||
// Bare: the group above owns the frame. Only the type size and
|
||||
// the growth behaviour belong to the field itself.
|
||||
"min-w-0 flex-1 resize-none bg-transparent py-1.5",
|
||||
// 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.
|
||||
"text-sm pointer-coarse:text-base",
|
||||
"placeholder:text-muted-foreground focus:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
// Top-centre of the button: the popover opens upward from here.
|
||||
setStickerAnchor({ x: r.left + r.width / 2, y: r.top });
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Send a sticker"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<Sticker className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
@@ -317,13 +345,14 @@ export function ChatComposer({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pickingSticker && (
|
||||
{stickerAnchor && (
|
||||
<StickerPicker
|
||||
anchor={stickerAnchor}
|
||||
// Sent on its own, with no text: a sticker IS the message.
|
||||
onPick={(sticker) =>
|
||||
onSend("", undefined, undefined, undefined, sticker.key)
|
||||
}
|
||||
onClose={() => setPickingSticker(false)}
|
||||
onClose={() => setStickerAnchor(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user