diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 49c42a8..9131bc9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import "../styles/buttons.css"; import "../styles/avatar.css"; import "../styles/signin.css"; import "../styles/post.css"; +import "../styles/composer.css"; import "../styles/legacy.css"; const manrope = Manrope({ diff --git a/src/components/App.tsx b/src/components/App.tsx index 78eb0be..1ce4491 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -43,7 +43,7 @@ export function App() { } - feed={ setState({ kind: "out" })} />} + feed={ setState({ kind: "out" })} />} discover={} /> ); diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index c0e4d3c..ba84a0b 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -1,6 +1,18 @@ "use client"; -import { useEffect, useState, type ChangeEvent, type FormEvent } from "react"; +// Composer (spec "Composer", ADR 0001). Styles live in src/styles/composer.css. +import { + useEffect, + useRef, + useState, + type ChangeEvent, + type FocusEvent, + type FormEvent, + type KeyboardEvent, +} from "react"; +import { FlipButton, type FlipState } from "./Buttons"; +import { HexAvatar } from "./HexAvatar"; +import { attachmentMeta, COMPOSER_EMOJI, insertBullets } from "./composer-text"; export interface ComposerProps { value: string; @@ -12,17 +24,42 @@ export interface ComposerProps { /** When present, the composer shows image and video pickers and reports the choice. */ onFile?: (file: File | null) => void; onSubmit: () => void; + /** The signed-in person, shown as a hex avatar beside the field. */ + person?: { name: string; email?: string; pictureUrl?: string | null } | null; } // Content types the picker offers (spec: allowed image types). const IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic"; const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm"; -export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit }: ComposerProps) { +const iconProps = { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.7, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + "aria-hidden": true, +}; + +/** True for Ctrl + Enter or Cmd + Enter. */ +export function isPublishShortcut(e: { key: string; ctrlKey: boolean; metaKey: boolean }): boolean { + return e.key === "Enter" && (e.ctrlKey || e.metaKey); +} + +export function Composer({ value, busy, error, file = null, onChange, onFile, onSubmit, person }: ComposerProps) { const empty = value.trim().length === 0; // A post needs either text or a file. const canSubmit = (!empty || !!file) && !busy; const [preview, setPreview] = useState(null); + const [focused, setFocused] = useState(false); + const [emojiOpen, setEmojiOpen] = useState(false); + const text = useRef(null); + const imageInput = useRef(null); + const videoInput = useRef(null); + const emojiWrap = useRef(null); + // Caret to restore after a toolbar edit re-renders the text. + const pendingSelection = useRef<[number, number] | null>(null); // Build a preview URL client-side only, and revoke it when the file changes. useEffect(() => { @@ -35,10 +72,44 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on return () => URL.revokeObjectURL(url); }, [file]); + useEffect(() => { + const sel = pendingSelection.current; + if (!sel || !text.current) return; + pendingSelection.current = null; + text.current.focus(); + text.current.setSelectionRange(sel[0], sel[1]); + }, [value]); + + // Close the emoji popover on an outside click or Escape. + useEffect(() => { + if (!emojiOpen) return; + function onDown(e: MouseEvent) { + if (emojiWrap.current && !emojiWrap.current.contains(e.target as Node)) setEmojiOpen(false); + } + function onKey(e: globalThis.KeyboardEvent) { + if (e.key === "Escape") setEmojiOpen(false); + } + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [emojiOpen]); + + useEffect(() => { + if (busy) setEmojiOpen(false); + }, [busy]); + function submit(e: FormEvent) { e.preventDefault(); if (canSubmit) onSubmit(); } + function onKeyDown(e: KeyboardEvent) { + if (!isPublishShortcut(e)) return; + e.preventDefault(); + if (canSubmit) onSubmit(); + } function pick(e: ChangeEvent) { const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null; onFile?.(chosen); @@ -48,56 +119,202 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on function remove() { onFile?.(null); } + function addEmoji(emoji: string) { + // Spec: the emoji goes at the end of the text. + const next = value + emoji; + pendingSelection.current = [next.length, next.length]; + onChange(next); + setEmojiOpen(false); + } + function addBullets() { + const el = text.current; + const start = el ? el.selectionStart : value.length; + const end = el ? el.selectionEnd : value.length; + const edit = insertBullets(value, start, end); + pendingSelection.current = [edit.selectionStart, edit.selectionEnd]; + if (edit.text !== value) onChange(edit.text); + else el?.focus(); + } + function onFocus() { + setFocused(true); + } + function onBlur(e: FocusEvent) { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); + } + const isVideo = !!file && file.type.startsWith("video/"); + const open = focused || !empty || !!file; + const flip: FlipState = busy ? "posting" : canSubmit ? "ready" : "disabled"; return ( -
- -