Feed browser: Desktop bento layout #3
@@ -9,6 +9,7 @@ import "../styles/buttons.css";
|
|||||||
import "../styles/avatar.css";
|
import "../styles/avatar.css";
|
||||||
import "../styles/signin.css";
|
import "../styles/signin.css";
|
||||||
import "../styles/post.css";
|
import "../styles/post.css";
|
||||||
|
import "../styles/composer.css";
|
||||||
import "../styles/legacy.css";
|
import "../styles/legacy.css";
|
||||||
|
|
||||||
const manrope = Manrope({
|
const manrope = Manrope({
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function App() {
|
|||||||
<AppShell
|
<AppShell
|
||||||
person={state.person}
|
person={state.person}
|
||||||
search={<ExpertSearch />}
|
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 />}
|
discover={<DiscoverRails />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
+246
-29
@@ -1,6 +1,18 @@
|
|||||||
"use client";
|
"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 {
|
export interface ComposerProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -12,17 +24,42 @@ export interface ComposerProps {
|
|||||||
/** When present, the composer shows image and video pickers and reports the choice. */
|
/** When present, the composer shows image and video pickers and reports the choice. */
|
||||||
onFile?: (file: File | null) => void;
|
onFile?: (file: File | null) => void;
|
||||||
onSubmit: () => 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).
|
// 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 IMAGE_ACCEPT = "image/jpeg,image/jpg,image/png,image/gif,image/webp,image/heic";
|
||||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime,video/webm";
|
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;
|
const empty = value.trim().length === 0;
|
||||||
// A post needs either text or a file.
|
// A post needs either text or a file.
|
||||||
const canSubmit = (!empty || !!file) && !busy;
|
const canSubmit = (!empty || !!file) && !busy;
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
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.
|
// Build a preview URL client-side only, and revoke it when the file changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,10 +72,44 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
|
|||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(url);
|
||||||
}, [file]);
|
}, [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) {
|
function submit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (canSubmit) onSubmit();
|
if (canSubmit) onSubmit();
|
||||||
}
|
}
|
||||||
|
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (!isPublishShortcut(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (canSubmit) onSubmit();
|
||||||
|
}
|
||||||
function pick(e: ChangeEvent<HTMLInputElement>) {
|
function pick(e: ChangeEvent<HTMLInputElement>) {
|
||||||
const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
const chosen = e.target.files && e.target.files[0] ? e.target.files[0] : null;
|
||||||
onFile?.(chosen);
|
onFile?.(chosen);
|
||||||
@@ -48,56 +119,202 @@ export function Composer({ value, busy, error, file = null, onChange, onFile, on
|
|||||||
function remove() {
|
function remove() {
|
||||||
onFile?.(null);
|
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 isVideo = !!file && file.type.startsWith("video/");
|
||||||
|
const open = focused || !empty || !!file;
|
||||||
|
const flip: FlipState = busy ? "posting" : canSubmit ? "ready" : "disabled";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="composer" onSubmit={submit}>
|
<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>
|
<label htmlFor="composer-text" className="composer-label">New post</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="composer-text"
|
id="composer-text"
|
||||||
|
ref={text}
|
||||||
value={value}
|
value={value}
|
||||||
rows={3}
|
placeholder="Share an insight with the feed"
|
||||||
placeholder="Share something with the feed"
|
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
{onFile ? (
|
</div>
|
||||||
<div className="composer-media">
|
{onFile && file ? (
|
||||||
<label className="composer-image">
|
<div className={`composer-att${person ? " composer-att-indent" : ""}`}>
|
||||||
<span>Add image</span>
|
<span className="composer-thumb">
|
||||||
<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 ? (
|
{preview && isVideo ? (
|
||||||
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
|
<video src={preview} muted playsInline preload="metadata" aria-label={`Selected video: ${file.name}`} />
|
||||||
) : preview ? (
|
) : preview ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={preview} alt={`Selected image: ${file.name}`} />
|
<img src={preview} alt={`Selected image: ${file.name}`} />
|
||||||
) : null}
|
) : null}
|
||||||
<span className="composer-filename">{file.name}</span>
|
{isVideo ? (
|
||||||
<button type="button" className="composer-remove" onClick={remove} disabled={busy}>
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
Remove
|
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
|
||||||
) : null}
|
) : 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>
|
||||||
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
|
<p className="error" role="alert">Publishing the post failed. Your text is still here.</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button type="submit" className="composer-post" disabled={!canSubmit}>
|
<div className="composer-bar">
|
||||||
{busy ? "Posting" : "Post"}
|
{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>
|
</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>
|
</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 { mergePosts, toCardPosts, upgradeVideos, type CardPost } from "@/lib/feed-model";
|
||||||
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
import { PostError, publishAndConfirm } from "@/lib/post-client";
|
||||||
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
import { DeleteError, sendDelete } from "@/lib/delete-client";
|
||||||
|
import type { ComposerProps } from "./Composer";
|
||||||
import { FeedView, type FeedStatus } from "./FeedView";
|
import { FeedView, type FeedStatus } from "./FeedView";
|
||||||
|
|
||||||
const POLL_MS = 45_000;
|
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 [posts, setPosts] = useState<CardPost[]>([]);
|
||||||
const [status, setStatus] = useState<FeedStatus>("loading");
|
const [status, setStatus] = useState<FeedStatus>("loading");
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
@@ -246,7 +256,7 @@ export function Feed({ onSignedOut, feedUserId }: { onSignedOut: () => void; fee
|
|||||||
deleteBusy={deleteBusy}
|
deleteBusy={deleteBusy}
|
||||||
deleteErrors={deleteErrors}
|
deleteErrors={deleteErrors}
|
||||||
onSignedOut={onSignedOut}
|
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}
|
sentinelRef={sentinel}
|
||||||
listRef={list}
|
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;
|
||||||
+15
-1
@@ -23,6 +23,7 @@ import {
|
|||||||
export function FeedPreview() {
|
export function FeedPreview() {
|
||||||
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
|
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [category, setCategory] = useState<string | null>(null);
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -84,7 +85,20 @@ export function FeedPreview() {
|
|||||||
onToggleLike={toggleLike}
|
onToggleLike={toggleLike}
|
||||||
feedUserId={FIXTURE_PERSON.feedUserId}
|
feedUserId={FIXTURE_PERSON.feedUserId}
|
||||||
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
|
||||||
composer={{ value: draft, busy: false, error: false, onChange: setDraft, onSubmit: () => setDraft("") }}
|
composer={{
|
||||||
|
value: draft,
|
||||||
|
busy: false,
|
||||||
|
error: false,
|
||||||
|
file,
|
||||||
|
onChange: setDraft,
|
||||||
|
onFile: setFile,
|
||||||
|
// No network here: "publishing" just clears the composer.
|
||||||
|
onSubmit: () => {
|
||||||
|
setDraft("");
|
||||||
|
setFile(null);
|
||||||
|
},
|
||||||
|
person: FIXTURE_PERSON,
|
||||||
|
}}
|
||||||
now={FIXTURE_NOW}
|
now={FIXTURE_NOW}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/*
|
||||||
|
* Composer, toolbar, emoji popover and attachment row (spec "Composer",
|
||||||
|
* ADR 0001). Ported from the mockup's .cmp, .bar, .emo and .att rules.
|
||||||
|
* Tokens only. The Post button is the flip reveal from buttons.css.
|
||||||
|
*/
|
||||||
|
.composer {
|
||||||
|
--composer-soft: color-mix(in srgb, var(--ink), transparent 93%);
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: var(--tile-2);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
padding: 10px 10px 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: box-shadow .3s, background-color .3s;
|
||||||
|
}
|
||||||
|
.composer.composer-open {
|
||||||
|
background: var(--tile);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-top { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
|
.composer-top .hex-avatar { flex: none; }
|
||||||
|
.composer .composer-label {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.composer textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 12px 2px;
|
||||||
|
height: 50px;
|
||||||
|
transition: height .3s var(--ease);
|
||||||
|
}
|
||||||
|
.composer.composer-open textarea { height: 92px; }
|
||||||
|
.composer textarea::placeholder { color: var(--ink-3); }
|
||||||
|
.composer textarea:disabled { color: var(--ink-2); }
|
||||||
|
|
||||||
|
/* Toolbar: photo, video, emoji | bulleted list ... Ctrl + Enter, Post. */
|
||||||
|
.composer-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.composer-tool {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--r-ctl);
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
transition: background-color .2s, color .2s, transform .15s;
|
||||||
|
}
|
||||||
|
.composer-tool svg { width: 20px; height: 20px; }
|
||||||
|
.composer-tool:hover:not(:disabled),
|
||||||
|
.composer-tool.composer-tool-on { background: var(--composer-soft); color: var(--ink); }
|
||||||
|
.composer-tool:active:not(:disabled) { transform: scale(.9); }
|
||||||
|
.composer-tool:disabled { cursor: not-allowed; color: var(--ink-3); background: transparent; }
|
||||||
|
.composer-file {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.composer-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 6px; flex: none; }
|
||||||
|
.composer-spacer { flex: 1; }
|
||||||
|
.composer-kbd { font-size: 11.5px; color: var(--ink-3); margin-right: 10px; white-space: nowrap; }
|
||||||
|
.composer-kbd b {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--tile-2);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Emoji: a frosted popover with 32 frequent emoji. */
|
||||||
|
.composer-emoji-wrap { position: relative; }
|
||||||
|
.composer-emoji {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: -8px;
|
||||||
|
width: 320px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--glass);
|
||||||
|
-webkit-backdrop-filter: blur(20px) saturate(1.7);
|
||||||
|
backdrop-filter: blur(20px) saturate(1.7);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
box-shadow: var(--shadow-pop);
|
||||||
|
animation: composer-pop .24s cubic-bezier(.2,.9,.3,1.25) both;
|
||||||
|
}
|
||||||
|
.composer-eyebrow {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.composer-emoji-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; }
|
||||||
|
.composer-emoji-grid button {
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform .15s cubic-bezier(.2,.9,.3,1.4), background-color .15s;
|
||||||
|
}
|
||||||
|
.composer-emoji-grid button:hover { background: var(--composer-soft); transform: scale(1.2); }
|
||||||
|
|
||||||
|
/* Attachment row: thumbnail, name, type and size, progress, remove. */
|
||||||
|
.composer-att {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--tile-2);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--line);
|
||||||
|
animation: composer-rise .35s var(--ease) both;
|
||||||
|
}
|
||||||
|
.composer-att.composer-att-indent { margin-left: 58px; }
|
||||||
|
.composer-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--tile-3);
|
||||||
|
}
|
||||||
|
.composer-thumb img,
|
||||||
|
.composer-thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.composer-thumb svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: auto;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--on-accent);
|
||||||
|
filter: drop-shadow(0 1px 3px var(--accent-deep));
|
||||||
|
}
|
||||||
|
.composer-att-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.composer-att-text strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.composer-att-text span { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.composer-prog { height: 3px; border-radius: 3px; background: var(--tile-3); overflow: hidden; }
|
||||||
|
.composer-prog i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transform-origin: left;
|
||||||
|
animation: composer-load 1.6s var(--ease) both;
|
||||||
|
}
|
||||||
|
/* While posting the upload has no byte count, so the bar sweeps. */
|
||||||
|
.composer-prog.composer-prog-busy i { width: 40%; animation: composer-sweep 1.1s ease-in-out infinite; }
|
||||||
|
|
||||||
|
.composer .error { margin: 8px 0 0; }
|
||||||
|
|
||||||
|
/* The flip Post button keeps its own size inside the toolbar. */
|
||||||
|
.composer-bar .composer-post { flex: none; }
|
||||||
|
|
||||||
|
@keyframes composer-pop { from { opacity: 0; transform: translateY(-6px) scale(.96); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes composer-rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||||
|
@keyframes composer-load { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||||
|
@keyframes composer-sweep { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.composer,
|
||||||
|
.composer textarea,
|
||||||
|
.composer-tool,
|
||||||
|
.composer-emoji-grid button { transition: none !important; }
|
||||||
|
.composer-emoji,
|
||||||
|
.composer-att,
|
||||||
|
.composer-prog i { animation: none !important; }
|
||||||
|
}
|
||||||
@@ -16,20 +16,6 @@ button { font: inherit; font-weight: 500; margin-top: 16px; padding: 12px; borde
|
|||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
.new-posts { position: sticky; top: 12px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; }
|
||||||
.sentinel { height: 1px; }
|
.sentinel { height: 1px; }
|
||||||
.composer { background: var(--tile); border: 1px solid var(--line); border-radius: 0; padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.composer-label { margin: 0; }
|
|
||||||
.composer textarea { font: inherit; width: 100%; padding: 12px; border: 1px solid var(--line-2); border-radius: 0; resize: vertical; background: var(--tile-2); color: var(--ink); }
|
|
||||||
.composer textarea:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
|
|
||||||
.composer .composer-post { align-self: flex-end; margin: 0; padding: 10px 24px; border-radius: 0; }
|
|
||||||
.composer-media { display: flex; flex-direction: column; gap: 8px; }
|
|
||||||
.composer-image { display: inline-flex; align-items: center; gap: 8px; align-self: flex-start; cursor: pointer; }
|
|
||||||
.composer-image span { padding: 6px 12px; border: 1px solid var(--line-2); background: var(--tile-2); }
|
|
||||||
.composer-image input[type="file"] { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); border: 0; }
|
|
||||||
.composer-preview { display: flex; align-items: center; gap: 10px; }
|
|
||||||
.composer-preview video { width: 96px; height: 64px; object-fit: cover; border: 1px solid var(--line); background: black; }
|
|
||||||
.composer-preview img { width: 64px; height: 64px; object-fit: cover; border: 1px solid var(--line); }
|
|
||||||
.composer-filename { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.composer-remove { margin: 0; padding: 6px 12px; border-radius: 0; }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { Composer, isPublishShortcut } from "@/components/Composer";
|
||||||
|
import { attachmentMeta, BULLET, COMPOSER_EMOJI, formatBytes, insertBullets } from "@/components/composer-text";
|
||||||
|
|
||||||
|
describe("insertBullets", () => {
|
||||||
|
it("adds a bullet at the start of the caret's line", () => {
|
||||||
|
expect(insertBullets("hello", 3, 3)).toEqual({ text: "• hello", selectionStart: 5, selectionEnd: 5 });
|
||||||
|
expect(insertBullets("one\ntwo\nthree", 5, 5).text).toBe("one\n• two\nthree");
|
||||||
|
});
|
||||||
|
it("adds a bullet to an empty text and to an empty last line", () => {
|
||||||
|
expect(insertBullets("", 0, 0)).toEqual({ text: BULLET, selectionStart: 2, selectionEnd: 2 });
|
||||||
|
expect(insertBullets("a\n", 2, 2).text).toBe("a\n• ");
|
||||||
|
});
|
||||||
|
it("adds a bullet to every selected line and keeps the selection on the same characters", () => {
|
||||||
|
const r = insertBullets("one\ntwo\nthree", 1, 9);
|
||||||
|
expect(r.text).toBe("• one\n• two\n• three");
|
||||||
|
expect(r.text.slice(r.selectionStart, r.selectionEnd)).toBe("ne\n• two\n• t");
|
||||||
|
});
|
||||||
|
it("does not bullet the next line when the selection ends right after a line break", () => {
|
||||||
|
expect(insertBullets("one\ntwo", 0, 4).text).toBe("• one\ntwo");
|
||||||
|
});
|
||||||
|
it("leaves lines that already start with a bullet unchanged", () => {
|
||||||
|
expect(insertBullets("• one\ntwo", 0, 9).text).toBe("• one\n• two");
|
||||||
|
expect(insertBullets("• one", 3, 3).text).toBe("• one");
|
||||||
|
});
|
||||||
|
it("changes nothing but the inserted characters", () => {
|
||||||
|
const text = "**bold** _it_\n indented";
|
||||||
|
const r = insertBullets(text, 0, text.length);
|
||||||
|
expect(r.text.replaceAll(BULLET, "")).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("composer helpers", () => {
|
||||||
|
it("offers 32 distinct emoji", () => {
|
||||||
|
expect(COMPOSER_EMOJI).toHaveLength(32);
|
||||||
|
expect(new Set(COMPOSER_EMOJI).size).toBe(32);
|
||||||
|
});
|
||||||
|
it("describes an attachment by type and size", () => {
|
||||||
|
expect(formatBytes(512)).toBe("512 B");
|
||||||
|
expect(formatBytes(2048)).toBe("2 KB");
|
||||||
|
expect(attachmentMeta({ type: "image/png", size: 2.4 * 1024 * 1024 })).toBe("Image, 2.4 MB");
|
||||||
|
expect(attachmentMeta({ type: "video/mp4", size: 18.2 * 1024 * 1024 })).toBe("Video, 18.2 MB");
|
||||||
|
});
|
||||||
|
it("publishes on Ctrl + Enter and Cmd + Enter only", () => {
|
||||||
|
expect(isPublishShortcut({ key: "Enter", ctrlKey: true, metaKey: false })).toBe(true);
|
||||||
|
expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: true })).toBe(true);
|
||||||
|
expect(isPublishShortcut({ key: "Enter", ctrlKey: false, metaKey: false })).toBe(false);
|
||||||
|
expect(isPublishShortcut({ key: "a", ctrlKey: true, metaKey: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Composer markup", () => {
|
||||||
|
const base = { value: "", busy: false, error: false, onChange: () => {}, onSubmit: () => {} };
|
||||||
|
const html = (p: Partial<Parameters<typeof Composer>[0]>) => renderToStaticMarkup(createElement(Composer, { ...base, ...p }));
|
||||||
|
|
||||||
|
it("is collapsed when empty and open when holding text or a file", () => {
|
||||||
|
expect(html({})).toMatch(/class="composer"/);
|
||||||
|
expect(html({ value: "hi" })).toContain("composer composer-open");
|
||||||
|
const f = new File(["x"], "a.png", { type: "image/png" });
|
||||||
|
expect(html({ file: f, onFile: () => {} })).toContain("composer composer-open");
|
||||||
|
});
|
||||||
|
it("orders the toolbar: photo, video, emoji, divider, bulleted list, hint, Post", () => {
|
||||||
|
const out = html({ onFile: () => {} });
|
||||||
|
const order = ["Add image", "Add video", "Add emoji", "composer-sep", "Bulleted list", "composer-kbd", "btn-flip"];
|
||||||
|
const at = order.map((s) => out.indexOf(s));
|
||||||
|
expect(at.every((i) => i >= 0)).toBe(true);
|
||||||
|
expect([...at].sort((a, b) => a - b)).toEqual(at);
|
||||||
|
expect(out).not.toMatch(/aria-label="(Bold|Italic)"/);
|
||||||
|
});
|
||||||
|
it("shows the three Post states", () => {
|
||||||
|
expect(html({})).toMatch(/btn-flip-front">Post<.*btn-flip-back[^>]*>Write first/);
|
||||||
|
expect(html({ value: "hi" })).toMatch(/btn-flip-back[^>]*>Publish<svg/);
|
||||||
|
const busy = html({ value: "hi", busy: true });
|
||||||
|
expect(busy).toContain("btn-flip-busy");
|
||||||
|
expect(busy).toContain(">Posting<");
|
||||||
|
});
|
||||||
|
it("shows the attachment row with name, type, size, progress and remove", () => {
|
||||||
|
const f = new File([new Uint8Array(3 * 1024)], "clip.mp4", { type: "video/mp4" });
|
||||||
|
const out = html({ file: f, onFile: () => {}, person: { name: "Jordan Rivera" } });
|
||||||
|
expect(out).toContain("clip.mp4");
|
||||||
|
expect(out).toContain("Video, 3 KB");
|
||||||
|
expect(out).toContain('role="progressbar"');
|
||||||
|
expect(out).toContain('aria-label="Remove attachment"');
|
||||||
|
expect(out).toContain("composer-att-indent");
|
||||||
|
expect(out).toContain("hex-avatar");
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-1
@@ -158,7 +158,8 @@ describe("Composer", () => {
|
|||||||
const html = renderToStaticMarkup(createElement(Composer, { ...base, file: f, onFile: () => {} }));
|
const html = renderToStaticMarkup(createElement(Composer, { ...base, file: f, onFile: () => {} }));
|
||||||
expect(html).toContain("Add image");
|
expect(html).toContain("Add image");
|
||||||
expect(html).toContain("photo.jpg");
|
expect(html).toContain("photo.jpg");
|
||||||
expect(html).not.toMatch(/<button[^>]*class="composer-post"[^>]*disabled/);
|
expect(html).toMatch(/<button[^>]*type="submit"[^>]*class="[^"]*composer-post/);
|
||||||
|
expect(html).not.toMatch(/<button[^>]*class="[^"]*composer-post[^"]*"[^>]*disabled/);
|
||||||
});
|
});
|
||||||
it("keeps the text and the image name on a publish failure", () => {
|
it("keeps the text and the image name on a publish failure", () => {
|
||||||
const f = new File(["x"], "sunset.png", { type: "image/png" });
|
const f = new File(["x"], "sunset.png", { type: "image/png" });
|
||||||
|
|||||||
Reference in New Issue
Block a user