Refine Pulse composer and post actions (#629)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-20 13:04:47 -04:00
committed by GitHub
parent 2da3402f10
commit b5e5a39bf0
11 changed files with 755 additions and 229 deletions

View File

@@ -13,46 +13,28 @@ import {
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import {
ComposerAttachments,
DropZoneOverlay,
} from "@/features/messages/ui/ComposerAttachments";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
import type { ChannelMember } from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
type ForumComposerProps = {
channelId?: string | null;
/** Override mention source when no channel is available (e.g. Pulse). */
members?: ChannelMember[];
placeholder: string;
disabled?: boolean;
isSending?: boolean;
onCancel?: () => void;
onSubmit: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => undefined | Promise<unknown>;
/** When true, autocomplete renders below the input (for top-of-view composers). */
autocompleteBelow?: boolean;
profiles?: UserProfileLookup;
};
import { cn } from "@/shared/lib/cn";
import type { ForumComposerProps } from "./ForumComposer.types";
import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes";
import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout";
import { ForumComposerMediaStatus } from "./ForumComposerMediaStatus";
import { useCompactComposerInteractions } from "./useCompactComposerInteractions";
export function ForumComposer({
channelId = null,
members,
className,
placeholder,
disabled,
header,
isSending,
onCancel,
onSubmit,
compact = false,
autocompleteBelow = false,
profiles,
}: ForumComposerProps) {
@@ -60,6 +42,7 @@ export function ForumComposer({
const contentRef = React.useRef(content);
contentRef.current = content;
const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
@@ -67,10 +50,19 @@ export function ForumComposer({
if (pressed) setIsEmojiPickerOpen(false);
setIsFormattingOpen(pressed);
}, []);
const expandCompactComposer = React.useCallback(() => {
if (compact) setIsCompactExpanded(true);
}, [compact]);
const mentions = useMentions(channelId, members, profiles);
const channelLinks = useChannelLinks();
const media = useMediaUpload();
const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } =
useCompactComposerInteractions({
compact,
onExpand: expandCompactComposer,
onPaperclip: media.handlePaperclip,
});
const disabledRef = React.useRef(disabled);
const isSendingRef = React.useRef(isSending);
@@ -140,7 +132,6 @@ export function ForumComposer({
],
);
// ── Emoji insertion ─────────────────────────────────────────────────
const insertEmoji = React.useCallback(
(emoji: string) => {
if (!richText.editor) return;
@@ -230,17 +221,24 @@ export function ForumComposer({
setIsEmojiPickerOpen(false);
const result = onSubmitRef.current(finalContent, pubkeys, mediaTags);
const collapseCompactComposer = () => {
if (compact) setIsCompactExpanded(false);
};
// If onSubmit returns a promise, restore draft on failure.
if (result && typeof result.then === "function") {
result.catch(() => {
result.then(collapseCompactComposer).catch(() => {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
if (compact) setIsCompactExpanded(true);
});
} else {
collapseCompactComposer();
}
}, [
compact,
media.pendingImetaRef,
media.setPendingImeta,
mentions.extractMentionPubkeys,
@@ -329,109 +327,166 @@ export function ForumComposer({
});
}, [richText.editor]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
disabled ||
(content.trim().length === 0 && media.pendingImeta.length === 0),
[disabled, content, media.pendingImeta.length],
);
const hasComposerContent =
content.trim().length > 0 ||
media.pendingImeta.length > 0 ||
media.isUploading ||
media.uploadState.status === "error";
const isExpanded =
!compact ||
isCompactExpanded ||
hasComposerContent ||
isEmojiPickerOpen ||
isFormattingOpen ||
mentions.isMentionOpen ||
channelLinks.isChannelOpen;
const isCompactLayout = compact && !isExpanded;
const handleFormBlur = React.useCallback(
(event: React.FocusEvent<HTMLFormElement>) => {
if (!compact) return;
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
event.currentTarget.contains(nextTarget)
) {
return;
}
if (shouldIgnoreBlur()) {
return;
}
// ── Render ──────────────────────────────────────────────────────────
const hasDraft =
contentRef.current.trim().length > 0 ||
media.pendingImetaRef.current.length > 0 ||
media.isUploading ||
media.uploadState.status === "error" ||
isEmojiPickerOpen ||
isFormattingOpen;
if (!hasDraft) setIsCompactExpanded(false);
},
[
compact,
isEmojiPickerOpen,
isFormattingOpen,
media.isUploading,
media.pendingImetaRef,
media.uploadState.status,
shouldIgnoreBlur,
],
);
const wasCompactExpandedRef = React.useRef(isCompactExpanded);
React.useEffect(() => {
const wasExpanded = wasCompactExpandedRef.current;
wasCompactExpandedRef.current = isCompactExpanded;
if (!compact || !isCompactExpanded || wasExpanded) return;
const frame = window.requestAnimationFrame(() => {
richText.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [compact, isCompactExpanded, richText.focus]);
const autocompletePosition = autocompleteBelow ? "below" : "above";
return (
<form
className="relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4"
onDragEnter={media.handleDragEnter}
className={cn(
"relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4",
className,
)}
onBlurCapture={handleFormBlur}
onDragEnter={(event) => {
expandCompactComposer();
media.handleDragEnter(event);
}}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onFocusCapture={expandCompactComposer}
onSubmit={handleSubmit}
>
{media.isDragOver && <DropZoneOverlay />}
<ChannelAutocomplete
onSelect={applyChannelInsert}
position={autocompletePosition}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
/>
<MentionAutocomplete
onSelect={applyMentionInsert}
position={autocompletePosition}
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
{(media.pendingImeta.length > 0 || media.isUploading) && (
<div className="mb-2 flex items-center gap-2">
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
uploadingCount={media.uploadingCount}
onRemove={media.removeAttachment}
/>
</div>
)}
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
</div>
<MessageComposerToolbar
composerDisabled={disabled ?? false}
editor={richText.editor}
extraActions={
onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
{isCompactLayout ? (
<ForumComposerCompactLayout
editor={richText.editor}
header={header}
isSending={isSending}
onEditorKeyDown={handleEditorKeyDown}
sendDisabled={sendDisabled}
/>
) : (
<>
{header ? (
<div
className={cn("mb-2", compact && "flex min-h-10 items-center")}
>
Cancel
</Button>
) : undefined
}
formattingDisabled={disabled ?? false}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending ?? false}
isUploading={media.isUploading}
onCaptureSelection={() => {}}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
{header}
</div>
) : null}
<ForumComposerAutocompletes
channelSelectedIndex={channelLinks.channelSelectedIndex}
channelSuggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
mentionSelectedIndex={mentions.mentionSelectedIndex}
mentionSuggestions={
mentions.isMentionOpen ? mentions.suggestions : []
}
onChannelSelect={applyChannelInsert}
onMentionSelect={applyMentionInsert}
position={autocompletePosition}
/>
<ForumComposerMediaStatus media={media} />
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
</div>
<MessageComposerToolbar
composerDisabled={disabled ?? false}
editor={richText.editor}
extraActions={
onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
) : undefined
}
formattingDisabled={disabled ?? false}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending ?? false}
isUploading={media.isUploading}
onCaptureSelection={handleToolbarMouseDown}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
</>
)}
</form>
);
}

View File

@@ -0,0 +1,26 @@
import type * as React from "react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelMember } from "@/shared/api/types";
export type ForumComposerProps = {
channelId?: string | null;
/** Override mention source when no channel is available (e.g. Pulse). */
members?: ChannelMember[];
className?: string;
placeholder: string;
disabled?: boolean;
header?: React.ReactNode;
isSending?: boolean;
onCancel?: () => void;
onSubmit: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => undefined | Promise<unknown>;
/** Render as a single-line composer until the user focuses it. */
compact?: boolean;
/** When true, autocomplete renders below the input (for top-of-view composers). */
autocompleteBelow?: boolean;
profiles?: UserProfileLookup;
};

View File

@@ -0,0 +1,43 @@
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
type ForumComposerAutocompletesProps = {
channelSelectedIndex: number;
channelSuggestions: ChannelSuggestion[];
mentionSelectedIndex: number;
mentionSuggestions: MentionSuggestion[];
onChannelSelect: (suggestion: ChannelSuggestion) => void;
onMentionSelect: (suggestion: MentionSuggestion) => void;
position: "above" | "below";
};
export function ForumComposerAutocompletes({
channelSelectedIndex,
channelSuggestions,
mentionSelectedIndex,
mentionSuggestions,
onChannelSelect,
onMentionSelect,
position,
}: ForumComposerAutocompletesProps) {
return (
<>
<ChannelAutocomplete
onSelect={onChannelSelect}
position={position}
selectedIndex={channelSelectedIndex}
suggestions={channelSuggestions}
/>
<MentionAutocomplete
onSelect={onMentionSelect}
position={position}
selectedIndex={mentionSelectedIndex}
suggestions={mentionSuggestions}
/>
</>
);
}

View File

@@ -0,0 +1,58 @@
import type * as React from "react";
import { EditorContent, type Editor } from "@tiptap/react";
import { Plus } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type ForumComposerCompactLayoutProps = {
editor: Editor | null;
header?: React.ReactNode;
isSending?: boolean;
onEditorKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
sendDisabled?: boolean;
};
export function ForumComposerCompactLayout({
editor,
header,
isSending,
onEditorKeyDown,
sendDisabled,
}: ForumComposerCompactLayoutProps) {
return (
<div className="flex min-h-10 items-center gap-3">
{header ? (
<div className="flex min-w-0 shrink-0 items-center">{header}</div>
) : null}
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-10 min-w-0 flex-1 overflow-y-auto"
onKeyDown={onEditorKeyDown}
>
<EditorContent editor={editor} />
</div>
<Button
aria-label={isSending ? "Sending" : "Send message"}
className={cn(
"h-7 w-7 shrink-0 rounded-full border border-border/70 bg-transparent p-0 text-muted-foreground shadow-none hover:bg-transparent hover:text-foreground",
)}
data-testid="send-message"
disabled={sendDisabled || isSending}
size="icon"
type="submit"
variant="ghost"
>
{isSending ? (
<span
aria-hidden
className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
/>
) : (
<Plus aria-hidden className="h-3.5 w-3.5" />
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
type ComposerMedia = Pick<
ReturnType<typeof useMediaUpload>,
| "isUploading"
| "pendingImeta"
| "removeAttachment"
| "setUploadState"
| "uploadState"
| "uploadingCount"
>;
type ForumComposerMediaStatusProps = {
media: ComposerMedia;
};
export function ForumComposerMediaStatus({
media,
}: ForumComposerMediaStatusProps) {
return (
<>
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
{(media.pendingImeta.length > 0 || media.isUploading) && (
<div className="mb-2 flex items-center gap-2">
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
onRemove={media.removeAttachment}
uploadingCount={media.uploadingCount}
/>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,40 @@
import * as React from "react";
type UseCompactComposerInteractionsArgs = {
compact: boolean;
onExpand: () => void;
onPaperclip: () => Promise<unknown>;
};
export function useCompactComposerInteractions({
compact,
onExpand,
onPaperclip,
}: UseCompactComposerInteractionsArgs) {
const isMediaPickerActiveRef = React.useRef(false);
const isToolbarInteractionActiveRef = React.useRef(false);
const handlePaperclipClick = React.useCallback(() => {
if (compact) onExpand();
isMediaPickerActiveRef.current = true;
void onPaperclip().finally(() => {
isMediaPickerActiveRef.current = false;
});
}, [compact, onExpand, onPaperclip]);
const handleToolbarMouseDown = React.useCallback(() => {
if (compact) onExpand();
isToolbarInteractionActiveRef.current = true;
window.setTimeout(() => {
isToolbarInteractionActiveRef.current = false;
}, 0);
}, [compact, onExpand]);
const shouldIgnoreBlur = React.useCallback(
() =>
isMediaPickerActiveRef.current || isToolbarInteractionActiveRef.current,
[],
);
return { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur };
}

View File

@@ -90,6 +90,7 @@ export const MessageComposerToolbar = React.memo(
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
onClick={() => onFormattingToggle(!isFormattingOpen)}
onMouseDown={onCaptureSelection}
className={cn(
"inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",
@@ -120,6 +121,7 @@ export const MessageComposerToolbar = React.memo(
aria-label="Close formatting"
disabled={composerDisabled}
onClick={() => onFormattingToggle(false)}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant="ghost"
@@ -180,6 +182,7 @@ export const MessageComposerToolbar = React.memo(
aria-label="Attach image"
disabled={composerDisabled || isUploading}
onClick={onPaperclip}
onMouseDown={onCaptureSelection}
size="icon"
type="button"
variant="ghost"
@@ -214,6 +217,7 @@ export const MessageComposerToolbar = React.memo(
aria-pressed={isFormattingOpen}
disabled={composerDisabled}
onClick={() => onFormattingToggle(!isFormattingOpen)}
onMouseDown={onCaptureSelection}
className={cn(
"inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors",
"hover:bg-muted hover:text-foreground",

View File

@@ -51,27 +51,27 @@ export function AgentActivityCard({
const summaryNote = group.notes[0];
return (
<div className="border-b border-border/50 px-4 py-3 sm:px-6">
<div className="rounded-2xl px-1 py-4 sm:px-2">
{/* Header */}
<div className="flex items-center gap-2.5">
<div className="relative shrink-0">
<div className="flex items-center gap-3">
<div className="relative shrink-0 pt-1">
<UserAvatar avatarUrl={avatarUrl} displayName={displayName} />
<Bot className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-background p-0.5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">
<span className="truncate text-sm font-semibold leading-none">
{displayName}
</span>
{agentStatus ? <StatusDot status={agentStatus} /> : null}
<span className="shrink-0 text-xs text-muted-foreground">
<span className="shrink-0 text-[11px] text-muted-foreground">
{formatRelativeTime(group.latestAt)}
</span>
</div>
</div>
{!isSingleNote ? (
<button
className="flex h-6 items-center gap-1 rounded px-1.5 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
className="flex h-6 items-center gap-1 rounded-full px-2 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
onClick={() => setExpanded(!expanded)}
type="button"
>
@@ -87,14 +87,14 @@ export function AgentActivityCard({
{/* Content */}
{isSingleNote || !expanded ? (
<div className="mt-2 ml-[42px] rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-sm">
<div className="mt-1.5 ml-[44px] text-sm leading-relaxed text-foreground">
<Markdown content={summaryNote.content} />
</div>
) : (
<div className="mt-2 ml-[42px] space-y-1.5">
<div className="mt-2 ml-[44px] space-y-2">
{group.notes.map((note, idx) => (
<div
className="flex gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
className="flex gap-2 rounded-xl border border-border/50 bg-muted/20 px-3 py-2"
key={note.id}
>
<span className="mt-0.5 shrink-0 text-xs font-medium text-muted-foreground">

View File

@@ -1,18 +1,33 @@
import { Bot, UserMinus, UserPlus } from "lucide-react";
import {
ALargeSmall,
AtSign,
Bookmark,
Bot,
MessageCircle,
PenSquare,
Paperclip,
SmilePlus,
SquareArrowOutUpRight,
ThumbsUp,
} from "lucide-react";
import * as React from "react";
import type { UserNote } from "@/shared/api/socialTypes";
import type { UserProfileSummary } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type NoteCardProps = {
note: UserNote;
profile?: UserProfileSummary | null;
currentUserDisplayName?: string;
currentUserProfile?: UserProfileSummary | null;
isAgent?: boolean;
isOwnNote: boolean;
isFollowing: boolean;
onFollow?: (pubkey: string) => void;
onReply?: (note: UserNote) => void;
onShare?: (note: UserNote) => void;
onUnfollow?: (pubkey: string) => void;
};
@@ -34,27 +49,59 @@ function formatRelativeTime(unixSeconds: number): string {
export function NoteCard({
note,
profile,
currentUserDisplayName = "You",
currentUserProfile,
isAgent,
isOwnNote,
isFollowing,
onFollow,
onReply,
onShare,
onUnfollow,
}: NoteCardProps) {
const displayName = profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`;
const avatarUrl = profile?.avatarUrl ?? null;
const [isUpvoted, setIsUpvoted] = React.useState(false);
const [isBookmarked, setIsBookmarked] = React.useState(false);
const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false);
const [replyDraft, setReplyDraft] = React.useState("");
const replyInputRef = React.useRef<HTMLTextAreaElement>(null);
const actionButtonClass =
"inline-flex min-w-7 items-center gap-1.5 text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
const activeActionClass = "text-foreground";
const countPlaceholder = <span aria-hidden className="w-2.5" />;
const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null;
React.useEffect(() => {
if (!isReplyComposerOpen) return;
replyInputRef.current?.focus();
}, [isReplyComposerOpen]);
const handleReplySubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (replyDraft.trim().length === 0) return;
setReplyDraft("");
setIsReplyComposerOpen(false);
};
return (
<article className="group flex gap-3 border-b border-border/50 px-4 py-3 transition-colors hover:bg-muted/30 sm:px-6">
<div className="relative shrink-0 pt-0.5">
<UserAvatar avatarUrl={avatarUrl} displayName={displayName} />
<article className="flex items-start gap-2.5 rounded-2xl px-1 pb-1 pt-4 sm:px-2">
<div className="relative shrink-0">
<UserAvatar
avatarUrl={avatarUrl}
className="!h-9 !w-9 shrink-0"
displayName={displayName}
/>
{isAgent ? (
<Bot className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-background p-0.5 text-muted-foreground" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{displayName}</span>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<span className="truncate text-sm font-semibold leading-none tracking-tight">
{displayName}
</span>
{isAgent ? (
<span className="inline-flex h-4 items-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
bot
@@ -65,39 +112,162 @@ export function NoteCard({
{profile.nip05Handle}
</span>
) : null}
<span className="shrink-0 text-xs text-muted-foreground">
<span className="shrink-0 text-xs text-muted-foreground/70">
{formatRelativeTime(note.createdAt)}
</span>
{!isOwnNote ? (
<div className="ml-auto hidden shrink-0 group-hover:block">
{isFollowing ? (
<Button
className="h-6 gap-1 px-2 text-xs"
onClick={() => onUnfollow?.(note.pubkey)}
size="sm"
variant="ghost"
>
<UserMinus className="h-3 w-3" />
Unfollow
</Button>
) : (
<Button
className="h-6 gap-1 px-2 text-xs"
onClick={() => onFollow?.(note.pubkey)}
size="sm"
variant="ghost"
>
<UserPlus className="h-3 w-3" />
Follow
</Button>
)}
</div>
) : null}
</div>
<div className="mt-1 text-sm leading-relaxed">
<Markdown content={note.content} />
<div className="mt-0.5 pb-3 text-sm text-foreground">
<Markdown content={note.content} tight />
</div>
<div className="flex flex-wrap items-center gap-5 text-xs font-medium">
<div className="flex flex-wrap items-center gap-5">
<button
aria-label={isUpvoted ? "Remove upvote" : "Upvote"}
aria-pressed={isUpvoted}
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""}`}
onClick={() => setIsUpvoted((current) => !current)}
type="button"
>
<ThumbsUp
className={`h-4 w-4 ${isUpvoted ? "fill-current" : ""}`}
/>
{countPlaceholder}
</button>
<button
aria-label="Reply"
aria-expanded={isReplyComposerOpen}
className={actionButtonClass}
onClick={() => {
setIsReplyComposerOpen(true);
onReply?.(note);
}}
type="button"
>
<MessageCircle className="h-4 w-4" />
{countPlaceholder}
</button>
<button
aria-label="Share"
className={actionButtonClass}
onClick={() => onShare?.(note)}
type="button"
>
<SquareArrowOutUpRight className="h-4 w-4" />
{countPlaceholder}
</button>
<button
aria-label="Start direct message"
className={actionButtonClass}
type="button"
>
<PenSquare className="h-4 w-4" />
</button>
{!isOwnNote ? (
isFollowing ? (
<button
className="text-muted-foreground/60 transition-colors hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onUnfollow?.(note.pubkey)}
type="button"
>
Unfollow
</button>
) : (
<button
className="text-muted-foreground/60 transition-colors hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onFollow?.(note.pubkey)}
type="button"
>
Follow
</button>
)
) : null}
</div>
<button
aria-label={isBookmarked ? "Remove bookmark" : "Save"}
aria-pressed={isBookmarked}
className={`${actionButtonClass} ${isBookmarked ? activeActionClass : ""}`}
onClick={() => setIsBookmarked((current) => !current)}
type="button"
>
<Bookmark
className={`h-4 w-4 ${isBookmarked ? "fill-current" : ""}`}
/>
</button>
</div>
{isReplyComposerOpen ? (
<form
className="mt-4 flex gap-2 rounded-2xl border border-border/60 bg-background/60 p-3"
onSubmit={handleReplySubmit}
>
<UserAvatar
avatarUrl={currentUserAvatarUrl}
className="!h-8 !w-8 shrink-0"
displayName={currentUserDisplayName}
/>
<div className="min-w-0 flex-1">
<textarea
className="min-h-16 w-full resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground/70 focus:outline-none"
onChange={(event) => setReplyDraft(event.target.value)}
placeholder="Post your reply"
ref={replyInputRef}
value={replyDraft}
/>
<div className="mt-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-1 text-muted-foreground">
<button
aria-label="Mention someone"
className="inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<AtSign className="h-4 w-4" />
</button>
<button
aria-label="Attach media"
className="inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<Paperclip className="h-4 w-4" />
</button>
<button
aria-label="Add emoji"
className="inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<SmilePlus className="h-4 w-4" />
</button>
<button
aria-label="Formatting"
className="inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<ALargeSmall className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2">
<button
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
setReplyDraft("");
setIsReplyComposerOpen(false);
}}
type="button"
>
Cancel
</button>
<button
className="rounded-full bg-foreground px-3 py-1 text-xs font-semibold text-background transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
disabled={replyDraft.trim().length === 0}
type="submit"
>
Reply
</button>
</div>
</div>
</div>
</form>
) : null}
</div>
</article>
);

View File

@@ -1,4 +1,4 @@
import { Check, Filter, RefreshCw } from "lucide-react";
import { Check, Filter, Search } from "lucide-react";
import * as React from "react";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
@@ -28,13 +28,15 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type PulseTab = "foryou" | "people" | "agents" | "mine";
type PulseTab = "search" | "foryou" | "people" | "agents" | "mine";
const tabTriggerClassName =
"rounded-none border-b-2 border-transparent px-3 py-2.5 text-sm font-medium shadow-none transition-colors data-[state=active]:border-primary data-[state=active]:text-foreground data-[state=active]:shadow-none text-muted-foreground hover:text-foreground data-[state=active]:bg-transparent";
"h-7 rounded-full px-3.5 py-0 text-xs font-semibold shadow-none transition-colors !text-muted-foreground hover:!text-foreground data-[state=active]:bg-background data-[state=active]:!text-foreground data-[state=active]:shadow-sm dark:!text-white/35 dark:hover:!text-white/70 dark:data-[state=active]:!text-white";
type PulseViewProps = {
currentPubkey?: string;
@@ -42,7 +44,7 @@ type PulseViewProps = {
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center">
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/60 px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
@@ -50,9 +52,9 @@ function EmptyState({ message }: { message: string }) {
function TimelineSkeleton() {
return (
<div className="space-y-1">
<div className="space-y-5">
{[1, 2, 3, 4].map((i) => (
<div className="flex gap-3 px-4 py-3 sm:px-6" key={i}>
<div className="flex gap-3 px-1 py-2 sm:px-2" key={i}>
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3.5 w-32" />
@@ -135,6 +137,7 @@ function AgentFilter({
export function PulseView({ currentPubkey }: PulseViewProps) {
const [activeTab, setActiveTab] = React.useState<PulseTab>("foryou");
const [agentFilter, setAgentFilter] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState("");
// ── Contact list & follow state ────────────────────────────────────────
const contactListQuery = useContactListQuery(currentPubkey);
@@ -242,6 +245,12 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
enabled: forYouPubkeys.length > 0,
});
const mentionProfiles = mentionProfilesQuery.data?.profiles ?? {};
const currentProfile = currentPubkey
? (mentionProfiles[currentPubkey.toLowerCase()] ?? null)
: null;
const currentDisplayName =
currentProfile?.displayName ??
(currentPubkey ? `${currentPubkey.slice(0, 8)}...` : "You");
const pulseMentionMembers = React.useMemo<ChannelMember[]>(() => {
const members: ChannelMember[] = [];
@@ -267,11 +276,6 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
? agentTimelineQuery
: myNotesQuery;
const isLoading = activeQuery.isLoading;
const isRefetching = activeQuery.isRefetching;
function handleRefresh() {
void activeQuery.refetch();
}
function handleFollow(pubkey: string) {
followMutation.mutate(pubkey);
@@ -283,6 +287,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
// ── Render ─────────────────────────────────────────────────────────────
const emptyMessages: Record<PulseTab, string> = {
search: "Search Pulse notes by author or text.",
foryou:
"No notes yet. Follow people and agents to build your personalized feed.",
people: "No notes yet. Follow people to see their updates here.",
@@ -316,6 +321,8 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
) : (
visibleNotes.map((note) => (
<NoteCard
currentUserDisplayName={currentDisplayName}
currentUserProfile={currentProfile}
isAgent={agentPubkeySet.has(note.pubkey)}
isFollowing={followingSet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
@@ -337,47 +344,47 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border/60 px-4 pt-11 sm:px-6">
<TabsList className="h-auto gap-1 rounded-none border-none bg-transparent p-0">
<TabsTrigger value="foryou" className={tabTriggerClassName}>
For You
</TabsTrigger>
<TabsTrigger value="people" className={tabTriggerClassName}>
People
</TabsTrigger>
<TabsTrigger value="agents" className={tabTriggerClassName}>
Agents
{relayAgents.length > 0 ? (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[10px] font-medium text-muted-foreground">
{relayAgents.length}
</span>
) : null}
</TabsTrigger>
<TabsTrigger value="mine" className={tabTriggerClassName}>
My Notes
</TabsTrigger>
</TabsList>
<div className="relative z-40 shrink-0 px-4 pt-2 sm:px-6">
<div className="relative mx-auto flex w-full max-w-2xl items-center justify-center">
<div className="flex items-center">
<TabsList className="h-8 gap-0.5 rounded-full border border-border/50 bg-muted/40 p-0.5">
<TabsTrigger
aria-label="Search Pulse"
value="search"
className="h-7 w-7 rounded-full p-0 !text-muted-foreground shadow-none transition-colors hover:!text-foreground data-[state=active]:bg-background data-[state=active]:!text-foreground data-[state=active]:shadow-sm dark:!text-white/35 dark:hover:!text-white/70 dark:data-[state=active]:!text-white"
>
<Search className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="foryou" className={tabTriggerClassName}>
Everyone
</TabsTrigger>
<TabsTrigger value="people" className={tabTriggerClassName}>
Following
</TabsTrigger>
<TabsTrigger value="agents" className={tabTriggerClassName}>
Agents
{relayAgents.length > 0 ? (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[10px] font-medium text-muted-foreground dark:text-white/45">
{relayAgents.length}
</span>
) : null}
</TabsTrigger>
<TabsTrigger value="mine" className={tabTriggerClassName}>
Mine
</TabsTrigger>
</TabsList>
</div>
<div className="ml-auto flex items-center gap-1">
{activeTab === "agents" && relayAgents.length > 1 ? (
<AgentFilter
agents={relayAgents}
onSelect={setAgentFilter}
profiles={profiles}
selectedPubkey={agentFilter}
/>
) : null}
<Button
className="h-7 w-7"
disabled={isRefetching}
onClick={handleRefresh}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-3.5 w-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
<div className="absolute right-0 flex items-center gap-1">
{activeTab === "agents" && relayAgents.length > 1 ? (
<AgentFilter
agents={relayAgents}
onSelect={setAgentFilter}
profiles={profiles}
selectedPubkey={agentFilter}
/>
) : null}
</div>
</div>
</div>
@@ -387,28 +394,89 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
forceMount
className="mt-0 min-h-0 flex-1 overflow-y-auto"
>
{renderTimeline()}
<div
className={`mx-auto flex w-full max-w-2xl flex-col px-4 pb-10 sm:px-6 ${
activeTab !== "search" && activeTab !== "agents" ? "pt-0" : "pt-7"
}`}
>
{activeTab === "search" ? (
<div className="flex min-h-[calc(100vh-96px)] items-center justify-center">
<div className="relative flex w-full max-w-xl flex-col items-center px-2">
<h2 className="mb-5 text-center text-2xl font-semibold tracking-tight text-foreground">
What are you looking for?
</h2>
<div className="relative w-full max-w-lg">
<div className="relative rounded-full border border-foreground/10 bg-background/80 p-1 shadow-[0_12px_48px_rgba(0,0,0,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.04] dark:shadow-[0_16px_70px_rgba(0,0,0,0.55)]">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground dark:text-white/55" />
<Input
autoFocus
className="h-9 rounded-full border-0 bg-transparent pl-10 pr-12 text-sm shadow-none placeholder:text-muted-foreground/80 focus-visible:ring-0 dark:text-white dark:placeholder:text-white/60"
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="What would you like to know?"
type="search"
value={searchQuery}
/>
<button
aria-label="Search Pulse"
className="absolute right-1.5 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/10 text-foreground transition-colors hover:bg-foreground/15 dark:bg-white/85 dark:text-black dark:hover:bg-white"
type="button"
>
<Search className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
) : activeTab !== "agents" ? (
<div className="sticky top-0 z-10 mb-7 pb-3 pt-7">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-[-1px] h-8 bg-background"
/>
{publishMutation.isError && (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
{publishMutation.error instanceof Error
? publishMutation.error.message
: "Failed to publish note"}
</div>
)}
<ForumComposer
autocompleteBelow
className="pulse-composer overflow-hidden rounded-2xl border-border/50 bg-background/70 p-2 shadow-none backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
compact
header={
<div className="flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={currentProfile?.avatarUrl ?? null}
className="!h-7 !w-7 shrink-0"
displayName={currentDisplayName}
/>
<span className="max-w-32 truncate text-sm font-medium text-foreground">
{currentDisplayName}
</span>
</div>
}
members={pulseMentionMembers}
placeholder="What's on your mind?"
isSending={publishMutation.isPending}
onSubmit={(content, mentionPubkeys, mediaTags) =>
publishMutation.mutateAsync({
content,
mentionPubkeys,
mediaTags,
})
}
profiles={mentionProfiles}
/>
</div>
) : null}
{activeTab !== "search" ? (
<div className="space-y-4">{renderTimeline()}</div>
) : null}
</div>
</TabsContent>
</Tabs>
<div className="border-t border-border/60 px-4 py-3 sm:px-6">
{publishMutation.isError && (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
{publishMutation.error instanceof Error
? publishMutation.error.message
: "Failed to publish note"}
</div>
)}
<ForumComposer
members={pulseMentionMembers}
placeholder="Post to Pulse..."
isSending={publishMutation.isPending}
onSubmit={(content, mentionPubkeys, mediaTags) =>
publishMutation.mutateAsync({ content, mentionPubkeys, mediaTags })
}
profiles={mentionProfiles}
/>
</div>
</div>
);
}

View File

@@ -22,6 +22,20 @@
height: 0;
}
.pulse-composer
.rich-text-composer
.tiptap
p.is-editor-empty:first-child::before {
color: hsl(var(--muted-foreground) / 0.65);
}
.pulse-composer:focus-within
.rich-text-composer
.tiptap
p.is-editor-empty:first-child::before {
color: hsl(var(--foreground));
}
.rich-text-composer .tiptap strong {
font-weight: 600;
}