icknpcyaf: Redesign the composer with toolbar, emoji popover, attachment row and flip Post
This commit is contained in:
@@ -43,7 +43,7 @@ export function App() {
|
||||
<AppShell
|
||||
person={state.person}
|
||||
search={<ExpertSearch />}
|
||||
feed={<Feed feedUserId={state.person.feedUserId} onSignedOut={() => setState({ kind: "out" })} />}
|
||||
feed={<Feed feedUserId={state.person.feedUserId} person={state.person} onSignedOut={() => setState({ kind: "out" })} />}
|
||||
discover={<DiscoverRails />}
|
||||
/>
|
||||
);
|
||||
|
||||
+260
-43
@@ -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<string | null>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const text = useRef<HTMLTextAreaElement | null>(null);
|
||||
const imageInput = useRef<HTMLInputElement | null>(null);
|
||||
const videoInput = useRef<HTMLInputElement | null>(null);
|
||||
const emojiWrap = useRef<HTMLDivElement | null>(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<HTMLTextAreaElement>) {
|
||||
if (!isPublishShortcut(e)) return;
|
||||
e.preventDefault();
|
||||
if (canSubmit) onSubmit();
|
||||
}
|
||||
function pick(e: ChangeEvent<HTMLInputElement>) {
|
||||
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<HTMLFormElement>) {
|
||||
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 (
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<label htmlFor="composer-text" className="composer-label">New post</label>
|
||||
<textarea
|
||||
id="composer-text"
|
||||
value={value}
|
||||
rows={3}
|
||||
placeholder="Share something with the feed"
|
||||
disabled={busy}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{onFile ? (
|
||||
<div className="composer-media">
|
||||
<label className="composer-image">
|
||||
<span>Add image</span>
|
||||
<input
|
||||
type="file"
|
||||
accept={IMAGE_ACCEPT}
|
||||
disabled={busy}
|
||||
onChange={pick}
|
||||
/>
|
||||
</label>
|
||||
<label className="composer-image composer-video">
|
||||
<span>Add video</span>
|
||||
<input type="file" accept={VIDEO_ACCEPT} disabled={busy} onChange={pick} />
|
||||
</label>
|
||||
{file ? (
|
||||
<div className="composer-preview">
|
||||
{preview && isVideo ? (
|
||||
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
|
||||
) : preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt={`Selected image: ${file.name}`} />
|
||||
) : null}
|
||||
<span className="composer-filename">{file.name}</span>
|
||||
<button type="button" className="composer-remove" onClick={remove} disabled={busy}>
|
||||
Remove
|
||||
</button>
|
||||
<form
|
||||
className={`composer${open ? " composer-open" : ""}`}
|
||||
onSubmit={submit}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<div className="composer-top">
|
||||
{person ? <HexAvatar name={person.name || person.email || ""} pictureUrl={person.pictureUrl} size="lg" /> : null}
|
||||
<label htmlFor="composer-text" className="composer-label">New post</label>
|
||||
<textarea
|
||||
id="composer-text"
|
||||
ref={text}
|
||||
value={value}
|
||||
placeholder="Share an insight with the feed"
|
||||
disabled={busy}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{onFile && file ? (
|
||||
<div className={`composer-att${person ? " composer-att-indent" : ""}`}>
|
||||
<span className="composer-thumb">
|
||||
{preview && isVideo ? (
|
||||
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
|
||||
) : preview ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={preview} alt={`Selected image: ${file.name}`} />
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="composer-att-text">
|
||||
<strong className="composer-filename">{file.name}</strong>
|
||||
<span>{attachmentMeta(file)}{busy ? ", uploading" : ""}</span>
|
||||
<div
|
||||
className={`composer-prog${busy ? " composer-prog-busy" : ""}`}
|
||||
role="progressbar"
|
||||
aria-label={busy ? "Uploading" : "Attached"}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={busy ? undefined : 100}
|
||||
>
|
||||
<i />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool composer-remove"
|
||||
aria-label="Remove attachment"
|
||||
title="Remove attachment"
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<path d="M6 6l12 12M18 6 6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
|
||||
) : null}
|
||||
<button type="submit" className="composer-post" disabled={!canSubmit}>
|
||||
{busy ? "Posting" : "Post"}
|
||||
</button>
|
||||
<div className="composer-bar">
|
||||
{onFile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool"
|
||||
aria-label="Add image"
|
||||
title="Add image"
|
||||
disabled={busy}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<rect x="3" y="4" width="18" height="16" rx="3" />
|
||||
<circle cx="9" cy="10" r="1.8" />
|
||||
<path d="m21 16-5-5-9 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={imageInput}
|
||||
className="composer-file"
|
||||
type="file"
|
||||
accept={IMAGE_ACCEPT}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={pick}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool"
|
||||
aria-label="Add video"
|
||||
title="Add video"
|
||||
disabled={busy}
|
||||
onClick={() => videoInput.current?.click()}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<rect x="2.5" y="5.5" width="13" height="13" rx="3" />
|
||||
<path d="m15.5 10 6-3.5v11l-6-3.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
ref={videoInput}
|
||||
className="composer-file"
|
||||
type="file"
|
||||
accept={VIDEO_ACCEPT}
|
||||
disabled={busy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onChange={pick}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<div className="composer-emoji-wrap" ref={emojiWrap}>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-tool${emojiOpen ? " composer-tool-on" : ""}`}
|
||||
aria-label="Add emoji"
|
||||
title="Emoji"
|
||||
aria-expanded={emojiOpen}
|
||||
aria-haspopup="dialog"
|
||||
disabled={busy}
|
||||
onClick={() => setEmojiOpen((o) => !o)}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8.5 14.5a4.5 4.5 0 0 0 7 0" />
|
||||
<path d="M9 9.5h.01M15 9.5h.01" strokeWidth={2.8} />
|
||||
</svg>
|
||||
</button>
|
||||
{emojiOpen ? (
|
||||
<div className="composer-emoji" role="dialog" aria-label="Emoji">
|
||||
<span className="composer-eyebrow">Frequently used</span>
|
||||
<div className="composer-emoji-grid">
|
||||
{COMPOSER_EMOJI.map((m) => (
|
||||
<button type="button" key={m} aria-label={`Insert ${m}`} onClick={() => addEmoji(m)}>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="composer-sep" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className="composer-tool"
|
||||
aria-label="Bulleted list"
|
||||
title="Bulleted list"
|
||||
disabled={busy}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={addBullets}
|
||||
>
|
||||
<svg {...iconProps}>
|
||||
<path d="M9 6h11M9 12h11M9 18h11" />
|
||||
<path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" strokeWidth={2.8} />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="composer-spacer" />
|
||||
<span className="composer-kbd" aria-hidden="true">
|
||||
<b>Ctrl</b> + <b>Enter</b>
|
||||
</span>
|
||||
<FlipButton state={flip} className="composer-post" type="submit" aria-keyshortcuts="Control+Enter Meta+Enter" />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-2
@@ -6,11 +6,21 @@ import { applyLikeOutcome, LikeError, sendLike } from "@/lib/like-client";
|
||||
import { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||
import type { ComposerProps } from "./Composer";
|
||||
import { FeedView, type FeedStatus } from "./FeedView";
|
||||
|
||||
const POLL_MS = 45_000;
|
||||
|
||||
export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; feedUserId?: string | null }) {
|
||||
export function Feed({
|
||||
onSignedOut,
|
||||
feedUserId,
|
||||
person,
|
||||
}: {
|
||||
onSignedOut: () => void;
|
||||
feedUserId?: string | null;
|
||||
/** The signed-in person, shown beside the composer. */
|
||||
person?: ComposerProps["person"];
|
||||
}) {
|
||||
const [posts, setPosts] = useState<CardPost[]>([]);
|
||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
@@ -246,7 +256,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
||||
deleteBusy={deleteBusy}
|
||||
deleteErrors={deleteErrors}
|
||||
onSignedOut={onSignedOut}
|
||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish() }}
|
||||
composer={{ value: draft, busy: posting, error: postFailed, file, onChange: changeDraft, onFile: changeFile, onSubmit: () => void publish(), person }}
|
||||
sentinelRef={sentinel}
|
||||
listRef={list}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Plain-text helpers for the composer (spec "Composer", formatting decided by
|
||||
// Cesar on 2026-10-19: posts are plain text, no bold or italic).
|
||||
|
||||
export const BULLET = "• ";
|
||||
|
||||
export interface TextEdit {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the plain character "• " at the start of the line holding the caret,
|
||||
* or of every line the selection touches. A line that already starts with
|
||||
* "• " is left as it is. Nothing else in the text changes. The returned
|
||||
* selection is shifted so it still covers the same characters.
|
||||
*/
|
||||
export function insertBullets(text: string, selectionStart: number, selectionEnd: number): TextEdit {
|
||||
const start = Math.max(0, Math.min(selectionStart, selectionEnd, text.length));
|
||||
let end = Math.max(0, Math.min(Math.max(selectionStart, selectionEnd), text.length));
|
||||
// A selection that ends right after a line break does not include the next line.
|
||||
if (end > start && text[end - 1] === "\n") end -= 1;
|
||||
|
||||
// Offsets of every line start from the caret's line to the selection's last line.
|
||||
const firstLine = text.lastIndexOf("\n", start - 1) + 1;
|
||||
const lineStarts: number[] = [];
|
||||
for (let i = firstLine; i <= end; ) {
|
||||
lineStarts.push(i);
|
||||
const next = text.indexOf("\n", i);
|
||||
if (next === -1 || next >= end) break;
|
||||
i = next + 1;
|
||||
}
|
||||
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let newStart = selectionStart;
|
||||
let newEnd = selectionEnd;
|
||||
for (const at of lineStarts) {
|
||||
out += text.slice(last, at);
|
||||
last = at;
|
||||
if (text.startsWith(BULLET, at)) continue;
|
||||
out += BULLET;
|
||||
if (at <= selectionStart) newStart += BULLET.length;
|
||||
if (at <= selectionEnd) newEnd += BULLET.length;
|
||||
}
|
||||
out += text.slice(last);
|
||||
return { text: out, selectionStart: newStart, selectionEnd: newEnd };
|
||||
}
|
||||
|
||||
/** "2.4 MB", "820 KB" or "512 B". */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** The attachment row's meta line: type and size, for example "Image, 2.4 MB". */
|
||||
export function attachmentMeta(file: { type: string; size: number }): string {
|
||||
const kind = file.type.startsWith("video/") ? "Video" : "Image";
|
||||
return `${kind}, ${formatBytes(file.size)}`;
|
||||
}
|
||||
|
||||
/** The 32 frequent emoji in the composer's emoji popover (mockup order). */
|
||||
export const COMPOSER_EMOJI = [
|
||||
"😀", "😂", "😍", "🥳", "😎", "🤔", "🙌", "👏",
|
||||
"🔥", "💡", "🚀", "🎯", "✅", "❤️", "💯", "✨",
|
||||
"🙏", "👀", "📈", "🧠", "💬", "📣", "🎉", "☕",
|
||||
"🌍", "📚", "🤝", "💪", "🌱", "⚡", "🏆", "👋",
|
||||
] as const;
|
||||
Reference in New Issue
Block a user