mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Refine Pulse composer and post actions (#629)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
26
desktop/src/features/forum/ui/ForumComposer.types.ts
Normal file
26
desktop/src/features/forum/ui/ForumComposer.types.ts
Normal 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;
|
||||
};
|
||||
43
desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx
Normal file
43
desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx
Normal file
58
desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Normal file
48
desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user