mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish desktop Pulse and Home views (#764)
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -17,46 +17,13 @@ import {
|
||||
} from "@/features/channels/ui/BotActivityBar";
|
||||
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import type { useChannelFind } from "@/features/search/useChannelFind";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
|
||||
const THREAD_PANEL_DEFAULT_WIDTH_PX = 380;
|
||||
const THREAD_PANEL_MIN_WIDTH_PX = 320;
|
||||
const THREAD_PANEL_MAX_WIDTH_PX = 720;
|
||||
const THREAD_PANEL_WIDTH_SESSION_KEY = "sprout.desktop.thread-panel-width";
|
||||
|
||||
function clampThreadPanelWidth(width: number): number {
|
||||
return Math.max(
|
||||
THREAD_PANEL_MIN_WIDTH_PX,
|
||||
Math.min(THREAD_PANEL_MAX_WIDTH_PX, width),
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialThreadPanelWidth(): number {
|
||||
if (typeof window === "undefined") {
|
||||
return THREAD_PANEL_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(THREAD_PANEL_WIDTH_SESSION_KEY);
|
||||
if (!raw) {
|
||||
return THREAD_PANEL_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return THREAD_PANEL_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
|
||||
return clampThreadPanelWidth(parsed);
|
||||
} catch {
|
||||
return THREAD_PANEL_DEFAULT_WIDTH_PX;
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelPaneProps = {
|
||||
activeChannel: Channel | null;
|
||||
activityAgents?: BotActivityAgent[];
|
||||
@@ -174,66 +141,17 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
threadReplyTargetMessage,
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
const [threadPanelWidthPx, setThreadPanelWidthPx] = React.useState<number>(
|
||||
() => getInitialThreadPanelWidth(),
|
||||
);
|
||||
const {
|
||||
canReset: canResetThreadPanelWidth,
|
||||
onResetWidth: handleThreadPanelWidthReset,
|
||||
onResizeStart: handleThreadPanelResizeStart,
|
||||
widthPx: threadPanelWidthPx,
|
||||
} = useThreadPanelWidth();
|
||||
|
||||
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
useComposerHeightPadding(timelineScrollRef, composerWrapperRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
THREAD_PANEL_WIDTH_SESSION_KEY,
|
||||
String(threadPanelWidthPx),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage failures and keep in-memory width for this session.
|
||||
}
|
||||
}, [threadPanelWidthPx]);
|
||||
|
||||
const handleThreadPanelResizeStart = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = threadPanelWidthPx;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = startX - moveEvent.clientX;
|
||||
const nextWidth = clampThreadPanelWidth(startWidth + deltaX);
|
||||
setThreadPanelWidthPx(nextWidth);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
},
|
||||
[threadPanelWidthPx],
|
||||
);
|
||||
|
||||
const handleThreadPanelWidthReset = React.useCallback(() => {
|
||||
setThreadPanelWidthPx(THREAD_PANEL_DEFAULT_WIDTH_PX);
|
||||
}, []);
|
||||
|
||||
const canResetThreadPanelWidth =
|
||||
threadPanelWidthPx !== THREAD_PANEL_DEFAULT_WIDTH_PX;
|
||||
|
||||
// Scope the edit target to the correct composer: if the message being edited
|
||||
// lives inside the open thread (thread head or a reply), show the editing UI
|
||||
// only in the thread panel; otherwise show it in the main channel composer.
|
||||
|
||||
@@ -159,6 +159,26 @@ function categoryLabelFor(category: FeedItemCategory) {
|
||||
: "Activity";
|
||||
}
|
||||
|
||||
export function formatInboxTypeLabel(item: InboxItem) {
|
||||
const channelName = item.channelLabel;
|
||||
const channelSuffix = channelName ? ` in #${channelName}` : "";
|
||||
|
||||
if (item.item.channelType === "dm") {
|
||||
return item.senderLabel ? `DM from ${item.senderLabel}` : "DM";
|
||||
}
|
||||
|
||||
const category = item.categories[0] ?? item.item.category;
|
||||
if (category === "mention") {
|
||||
return channelName ? `Mentioned in #${channelName}` : "Mentioned";
|
||||
}
|
||||
|
||||
if (category === "needs_action") {
|
||||
return channelName ? `Needs action in #${channelName}` : "Needs action";
|
||||
}
|
||||
|
||||
return `${feedHeadline(item.item)}${channelSuffix}`;
|
||||
}
|
||||
|
||||
function categoryPriority(category: FeedItemCategory) {
|
||||
switch (category) {
|
||||
case "needs_action":
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
InboxItem,
|
||||
InboxReply,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { formatInboxTypeLabel } from "@/features/home/lib/inbox";
|
||||
import {
|
||||
type InboxDisplayMessage,
|
||||
InboxMessageRow,
|
||||
@@ -193,8 +194,8 @@ export function InboxDetailPane({
|
||||
: null;
|
||||
const channelContextName = contextChannelName ?? item.channelLabel;
|
||||
const contextLabel = channelContextName
|
||||
? `#${channelContextName}`
|
||||
: item.categoryLabel;
|
||||
? formatInboxTypeLabel({ ...item, channelLabel: channelContextName })
|
||||
: formatInboxTypeLabel(item);
|
||||
const contextChannelId = item.item.channelId;
|
||||
|
||||
const handleSelectReplyTarget = (message: InboxDisplayMessage) => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { InboxFilter, InboxItem } from "@/features/home/lib/inbox";
|
||||
import {
|
||||
formatInboxTypeLabel,
|
||||
type InboxFilter,
|
||||
type InboxItem,
|
||||
} from "@/features/home/lib/inbox";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
@@ -31,20 +35,22 @@ export function InboxListPane({
|
||||
return (
|
||||
<section className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-background/60">
|
||||
<div className="px-5 pb-3 pt-14">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<Button
|
||||
className="h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"
|
||||
data-active={filter === option.value}
|
||||
key={option.value}
|
||||
onClick={() => onFilterChange(option.value)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="-mx-5 overflow-x-auto px-5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<Button
|
||||
className="h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"
|
||||
data-active={filter === option.value}
|
||||
key={option.value}
|
||||
onClick={() => onFilterChange(option.value)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +74,7 @@ export function InboxListPane({
|
||||
{items.map((item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
const isDone = doneSet.has(item.id);
|
||||
const typeLabel = formatInboxTypeLabel(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -130,16 +137,14 @@ export function InboxListPane({
|
||||
</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{item.channelLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
#{item.channelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] text-muted-foreground",
|
||||
isDone ? "font-normal" : "font-semibold",
|
||||
)}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
getUsersBatch,
|
||||
updateProfile,
|
||||
} from "@/shared/api/tauri";
|
||||
import { getContactList, setContactList } from "@/shared/api/social";
|
||||
import type { ContactListResponse } from "@/shared/api/socialTypes";
|
||||
import type {
|
||||
Profile,
|
||||
UpdateProfileInput,
|
||||
@@ -16,6 +18,9 @@ import type {
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export const profileQueryKey = ["profile"] as const;
|
||||
export const contactListQueryKey = (pubkey: string) =>
|
||||
["contact-list", pubkey] as const;
|
||||
export const allPulseTimelinesQueryKey = ["pulse-timeline"] as const;
|
||||
|
||||
export function useProfileQuery(enabled = true) {
|
||||
return useQuery({
|
||||
@@ -26,6 +31,71 @@ export function useProfileQuery(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useContactListQuery(pubkey?: string) {
|
||||
return useQuery<ContactListResponse>({
|
||||
queryKey: contactListQueryKey(pubkey ?? ""),
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey
|
||||
queryFn: () => getContactList(pubkey!),
|
||||
enabled: !!pubkey,
|
||||
staleTime: 60_000,
|
||||
gcTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow mutation re-fetches the contact list inside the mutationFn to prevent
|
||||
* race conditions when clicking Follow on multiple users quickly. The kind:3
|
||||
* contact list is a full-snapshot replaceable event — stale reads cause data loss.
|
||||
*/
|
||||
export function useFollowMutation(currentPubkey?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (targetPubkey: string) => {
|
||||
if (!currentPubkey) throw new Error("No identity");
|
||||
const current = await getContactList(currentPubkey);
|
||||
if (current.contacts.some((c) => c.pubkey === targetPubkey)) {
|
||||
return;
|
||||
}
|
||||
const updated = [...current.contacts, { pubkey: targetPubkey }];
|
||||
return setContactList(updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (currentPubkey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: contactListQueryKey(currentPubkey),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: allPulseTimelinesQueryKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnfollowMutation(currentPubkey?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (targetPubkey: string) => {
|
||||
if (!currentPubkey) throw new Error("No identity");
|
||||
const current = await getContactList(currentPubkey);
|
||||
const updated = current.contacts.filter((c) => c.pubkey !== targetPubkey);
|
||||
return setContactList(updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (currentPubkey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: contactListQueryKey(currentPubkey),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: allPulseTimelinesQueryKey,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserProfileQuery(pubkey?: string) {
|
||||
return useQuery({
|
||||
enabled: typeof pubkey === "string" && pubkey.length > 0,
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import {
|
||||
useContactListQuery,
|
||||
useFollowMutation,
|
||||
useUnfollowMutation,
|
||||
useUserProfileQuery,
|
||||
} from "@/features/profile/hooks";
|
||||
import {
|
||||
useRelayAgentsQuery,
|
||||
useManagedAgentsQuery,
|
||||
@@ -102,6 +107,9 @@ export function UserProfilePanel({
|
||||
pubkey.toLowerCase() !== currentPubkey.toLowerCase(),
|
||||
);
|
||||
const isArchived = useIsIdentityArchived(pubkey);
|
||||
const contactListQuery = useContactListQuery(currentPubkey);
|
||||
const followMutation = useFollowMutation(currentPubkey);
|
||||
const unfollowMutation = useUnfollowMutation(currentPubkey);
|
||||
const archiveMutation = useArchiveIdentityMutation();
|
||||
const unarchiveMutation = useUnarchiveIdentityMutation();
|
||||
const { onOpenAgentSession } = useAgentSession();
|
||||
@@ -121,6 +129,12 @@ export function UserProfilePanel({
|
||||
const isSelf =
|
||||
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
|
||||
const canViewActivity = isBot && Boolean(onOpenAgentSession);
|
||||
const isFollowing =
|
||||
!isSelf &&
|
||||
(contactListQuery.data?.contacts.some(
|
||||
(contact) => contact.pubkey.toLowerCase() === pubkeyLower,
|
||||
) ??
|
||||
false);
|
||||
|
||||
// NIP-IA gates. Button shows when ANY of: self path (acting on own pubkey),
|
||||
// admin path (current user is owner/admin in relay_members), or owner path
|
||||
@@ -323,6 +337,43 @@ export function UserProfilePanel({
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{!isSelf ? (
|
||||
isFollowing ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={unfollowMutation.isPending}
|
||||
onClick={() =>
|
||||
unfollowMutation.mutate(pubkey, {
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
`Unfollow failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Unfollow
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={followMutation.isPending}
|
||||
onClick={() =>
|
||||
followMutation.mutate(pubkey, {
|
||||
onError: (error) =>
|
||||
toast.error(
|
||||
`Follow failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
variant="default"
|
||||
>
|
||||
Follow
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
{onOpenDm && !isSelf ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
getContactList,
|
||||
getGlobalNotes,
|
||||
getLikedNotes,
|
||||
getNote,
|
||||
getNoteReactions,
|
||||
getNotesTimeline,
|
||||
getUserNotes,
|
||||
publishNote,
|
||||
setContactList,
|
||||
} from "@/shared/api/social";
|
||||
import type {
|
||||
ContactListResponse,
|
||||
UserNotesResponse,
|
||||
} from "@/shared/api/socialTypes";
|
||||
import { allPulseTimelinesQueryKey } from "@/features/profile/hooks";
|
||||
import type { UserNote, UserNotesResponse } from "@/shared/api/socialTypes";
|
||||
|
||||
// ── Query keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const pulseQueryKeys = {
|
||||
contactList: (pubkey: string) => ["contact-list", pubkey] as const,
|
||||
globalNotes: ["global-notes"] as const,
|
||||
likedNotes: (pubkey: string) => ["liked-notes", pubkey] as const,
|
||||
myNotes: (pubkey: string) => ["my-notes", pubkey] as const,
|
||||
note: (noteId: string) => ["pulse-note", noteId] as const,
|
||||
reactions: (noteIds: string[]) =>
|
||||
["pulse-reactions", [...noteIds].sort().join(",")] as const,
|
||||
// Use a stable sorted string key to avoid reference-equality refetch churn.
|
||||
timeline: (pubkeys: string[]) =>
|
||||
["pulse-timeline", [...pubkeys].sort().join(",")] as const,
|
||||
allTimelines: ["pulse-timeline"] as const,
|
||||
allTimelines: allPulseTimelinesQueryKey,
|
||||
};
|
||||
|
||||
// ── Contact list ────────────────────────────────────────────────────────────
|
||||
// ── Own notes ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useContactListQuery(pubkey?: string) {
|
||||
return useQuery<ContactListResponse>({
|
||||
queryKey: pulseQueryKeys.contactList(pubkey ?? ""),
|
||||
export function useLikedNotesQuery(pubkey?: string, enabled = true) {
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.likedNotes(pubkey ?? ""),
|
||||
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey
|
||||
queryFn: () => getContactList(pubkey!),
|
||||
enabled: !!pubkey,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => getLikedNotes(pubkey!, 50),
|
||||
enabled: enabled && !!pubkey,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Own notes ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useMyNotesQuery(pubkey?: string) {
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.myNotes(pubkey ?? ""),
|
||||
@@ -63,6 +66,61 @@ export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
export type PulseReactionState = {
|
||||
count: number;
|
||||
reactedByCurrentUser: boolean;
|
||||
};
|
||||
|
||||
export function usePulseReactionsQuery(
|
||||
noteIds: string[],
|
||||
currentPubkey?: string,
|
||||
) {
|
||||
return useQuery<Map<string, PulseReactionState>>({
|
||||
queryKey: pulseQueryKeys.reactions(noteIds),
|
||||
queryFn: async () => {
|
||||
const summaries = await getNoteReactions(noteIds);
|
||||
const result = new Map<string, PulseReactionState>();
|
||||
for (const summary of summaries) {
|
||||
if (summary.emoji !== "+") {
|
||||
continue;
|
||||
}
|
||||
result.set(summary.noteId, {
|
||||
count: summary.count,
|
||||
reactedByCurrentUser: currentPubkey
|
||||
? summary.pubkeys.includes(currentPubkey)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
enabled: noteIds.length > 0,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useNoteByIdQuery(noteId: string | null) {
|
||||
return useQuery<UserNote | null>({
|
||||
queryKey: pulseQueryKeys.note(noteId ?? ""),
|
||||
queryFn: () => getNote(noteId ?? ""),
|
||||
enabled: !!noteId,
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 10 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGlobalNotesQuery(enabled: boolean) {
|
||||
return useQuery<UserNotesResponse>({
|
||||
queryKey: pulseQueryKeys.globalNotes,
|
||||
queryFn: () => getGlobalNotes({ limit: 50 }),
|
||||
enabled,
|
||||
staleTime: 15_000,
|
||||
gcTime: 5 * 60_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Publish note mutation ───────────────────────────────────────────────────
|
||||
|
||||
export function usePublishNoteMutation(currentPubkey?: string) {
|
||||
@@ -90,64 +148,9 @@ export function usePublishNoteMutation(currentPubkey?: string) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.allTimelines,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Follow / unfollow mutations ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Follow mutation re-fetches the contact list inside the mutationFn to prevent
|
||||
* race conditions when clicking Follow on multiple users quickly. The kind:3
|
||||
* contact list is a full-snapshot replaceable event — stale reads cause data loss.
|
||||
*/
|
||||
export function useFollowMutation(currentPubkey?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (targetPubkey: string) => {
|
||||
if (!currentPubkey) throw new Error("No identity");
|
||||
// Fresh read to avoid overwriting concurrent mutations.
|
||||
const current = await getContactList(currentPubkey);
|
||||
if (current.contacts.some((c) => c.pubkey === targetPubkey)) {
|
||||
return; // already following
|
||||
}
|
||||
const updated = [...current.contacts, { pubkey: targetPubkey }];
|
||||
return setContactList(updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (currentPubkey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.contactList(currentPubkey),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.allTimelines,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnfollowMutation(currentPubkey?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (targetPubkey: string) => {
|
||||
if (!currentPubkey) throw new Error("No identity");
|
||||
// Fresh read to avoid overwriting concurrent mutations.
|
||||
const current = await getContactList(currentPubkey);
|
||||
const updated = current.contacts.filter((c) => c.pubkey !== targetPubkey);
|
||||
return setContactList(updated);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (currentPubkey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.contactList(currentPubkey),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.allTimelines,
|
||||
});
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.globalNotes,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { applyReactionState, isDuplicateReactionError } from "./noteActions.ts";
|
||||
|
||||
const NOTE_ID = "n".repeat(64);
|
||||
|
||||
test("applyReactionState adds a current-user reaction", () => {
|
||||
const next = applyReactionState(undefined, NOTE_ID, true);
|
||||
assert.deepEqual(next.get(NOTE_ID), {
|
||||
count: 1,
|
||||
reactedByCurrentUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyReactionState removes a current-user reaction", () => {
|
||||
const current = new Map([
|
||||
[NOTE_ID, { count: 2, reactedByCurrentUser: true }],
|
||||
]);
|
||||
const next = applyReactionState(current, NOTE_ID, false);
|
||||
assert.deepEqual(next.get(NOTE_ID), {
|
||||
count: 1,
|
||||
reactedByCurrentUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyReactionState keeps count stable for no-op transitions", () => {
|
||||
const current = new Map([
|
||||
[NOTE_ID, { count: 2, reactedByCurrentUser: false }],
|
||||
]);
|
||||
const next = applyReactionState(current, NOTE_ID, false);
|
||||
assert.deepEqual(next.get(NOTE_ID), {
|
||||
count: 2,
|
||||
reactedByCurrentUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyReactionState never decrements below zero", () => {
|
||||
const next = applyReactionState(undefined, NOTE_ID, false);
|
||||
assert.deepEqual(next.get(NOTE_ID), {
|
||||
count: 0,
|
||||
reactedByCurrentUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("isDuplicateReactionError detects relay duplicate responses", () => {
|
||||
assert.equal(
|
||||
isDuplicateReactionError(
|
||||
new Error("relay rejected event: duplicate: reaction already exists"),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isDuplicateReactionError rejects unrelated errors and non-errors", () => {
|
||||
assert.equal(isDuplicateReactionError(new Error("network failed")), false);
|
||||
assert.equal(
|
||||
isDuplicateReactionError("duplicate: reaction already exists"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { nip19 } from "nostr-tools";
|
||||
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
|
||||
export function buildNoteShareUri(note: Pick<UserNote, "id" | "pubkey">) {
|
||||
return `nostr:${nip19.neventEncode({
|
||||
id: note.id,
|
||||
author: note.pubkey,
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function toggleNoteIdInSet(
|
||||
current: ReadonlySet<string>,
|
||||
noteId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const next = new Set(current);
|
||||
if (enabled) {
|
||||
next.add(noteId);
|
||||
} else {
|
||||
next.delete(noteId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function applyReactionState(
|
||||
current:
|
||||
| Map<string, { count: number; reactedByCurrentUser: boolean }>
|
||||
| undefined,
|
||||
noteId: string,
|
||||
reactedByCurrentUser: boolean,
|
||||
) {
|
||||
const next = new Map(current);
|
||||
const previous = next.get(noteId) ?? {
|
||||
count: 0,
|
||||
reactedByCurrentUser: false,
|
||||
};
|
||||
const count = Math.max(
|
||||
0,
|
||||
previous.count +
|
||||
(reactedByCurrentUser && !previous.reactedByCurrentUser ? 1 : 0) -
|
||||
(!reactedByCurrentUser && previous.reactedByCurrentUser ? 1 : 0),
|
||||
);
|
||||
next.set(noteId, {
|
||||
count,
|
||||
reactedByCurrentUser,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isDuplicateReactionError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
error.message.toLowerCase().includes("duplicate: reaction already exists")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getReplyParent } from "./replies.ts";
|
||||
|
||||
function note(tags) {
|
||||
return {
|
||||
id: "note",
|
||||
pubkey: "a".repeat(64),
|
||||
createdAt: 0,
|
||||
content: "",
|
||||
tags,
|
||||
};
|
||||
}
|
||||
|
||||
const A = "a".repeat(64);
|
||||
const B = "b".repeat(64);
|
||||
const C = "c".repeat(64);
|
||||
|
||||
test("getReplyParent prefers the last marked reply tag", () => {
|
||||
assert.equal(
|
||||
getReplyParent(
|
||||
note([
|
||||
["e", A, "", "reply"],
|
||||
["e", B, "", "reply"],
|
||||
]),
|
||||
),
|
||||
B,
|
||||
);
|
||||
});
|
||||
|
||||
test("getReplyParent falls back to the last unmarked e tag", () => {
|
||||
assert.equal(
|
||||
getReplyParent(
|
||||
note([
|
||||
["e", A],
|
||||
["e", B],
|
||||
]),
|
||||
),
|
||||
B,
|
||||
);
|
||||
});
|
||||
|
||||
test("getReplyParent uses a root marker when no closer parent exists", () => {
|
||||
assert.equal(getReplyParent(note([["e", A, "", "root"]])), A);
|
||||
});
|
||||
|
||||
test("getReplyParent uses reply before later root", () => {
|
||||
assert.equal(
|
||||
getReplyParent(
|
||||
note([
|
||||
["e", A, "", "reply"],
|
||||
["e", B, "", "root"],
|
||||
]),
|
||||
),
|
||||
A,
|
||||
);
|
||||
});
|
||||
|
||||
test("getReplyParent keeps reply precedence over root and unmarked fallbacks", () => {
|
||||
assert.equal(
|
||||
getReplyParent(
|
||||
note([
|
||||
["e", A, "", "root"],
|
||||
["e", B],
|
||||
["e", C, "", "reply"],
|
||||
]),
|
||||
),
|
||||
C,
|
||||
);
|
||||
});
|
||||
|
||||
test("getReplyParent returns null without e tags", () => {
|
||||
assert.equal(getReplyParent(note([])), null);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
|
||||
export function getReplyParent(note: UserNote): string | null {
|
||||
const eTags = note.tags.filter((tag) => tag[0] === "e" && tag[1]);
|
||||
for (let index = eTags.length - 1; index >= 0; index -= 1) {
|
||||
const tag = eTags[index];
|
||||
if (tag[3] === "reply") {
|
||||
return tag[1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = eTags.length - 1; index >= 0; index -= 1) {
|
||||
const tag = eTags[index];
|
||||
if (tag[3] == null) {
|
||||
return tag[1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = eTags.length - 1; index >= 0; index -= 1) {
|
||||
const tag = eTags[index];
|
||||
if (tag[3] === "root") {
|
||||
return tag[1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function noteSnippet(content: string) {
|
||||
return content.trim().replace(/\s+/g, " ").slice(0, 120);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as React from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import { useToggleReactionMutation } from "@/features/messages/hooks";
|
||||
import {
|
||||
pulseQueryKeys,
|
||||
type PulseReactionState,
|
||||
usePublishNoteMutation,
|
||||
} from "@/features/pulse/hooks";
|
||||
import {
|
||||
applyReactionState,
|
||||
buildNoteShareUri,
|
||||
isDuplicateReactionError,
|
||||
toggleNoteIdInSet,
|
||||
} from "@/features/pulse/lib/noteActions";
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
|
||||
export type PulseNoteActions = {
|
||||
isReplySending: boolean;
|
||||
isUpvotePending: (noteId: string) => boolean;
|
||||
reactionCount: (noteId: string) => number;
|
||||
isUpvoted: (noteId: string) => boolean;
|
||||
reply: (
|
||||
note: UserNote,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => Promise<void>;
|
||||
share: (note: UserNote) => Promise<void>;
|
||||
startDm: (pubkey: string) => Promise<void>;
|
||||
toggleUpvote: (note: UserNote, remove: boolean) => Promise<void>;
|
||||
};
|
||||
|
||||
export function usePulseNoteActions({
|
||||
currentPubkey,
|
||||
reactionQueryKey,
|
||||
reactions,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
reactionQueryKey: ReturnType<typeof pulseQueryKeys.reactions>;
|
||||
reactions: Map<string, PulseReactionState>;
|
||||
}): PulseNoteActions {
|
||||
const [pendingUpvoteNoteIds, setPendingUpvoteNoteIds] = React.useState<
|
||||
ReadonlySet<string>
|
||||
>(() => new Set());
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const replyMutation = usePublishNoteMutation(currentPubkey);
|
||||
const toggleReactionMutation = useToggleReactionMutation();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
|
||||
const toggleUpvote = React.useCallback(
|
||||
async (note: UserNote, remove: boolean) => {
|
||||
if (pendingUpvoteNoteIds.has(note.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingUpvoteNoteIds((current) =>
|
||||
toggleNoteIdInSet(current, note.id, true),
|
||||
);
|
||||
const previousReactions =
|
||||
queryClient.getQueryData<Map<string, PulseReactionState>>(
|
||||
reactionQueryKey,
|
||||
);
|
||||
queryClient.setQueryData<Map<string, PulseReactionState>>(
|
||||
reactionQueryKey,
|
||||
(current) => applyReactionState(current, note.id, !remove),
|
||||
);
|
||||
|
||||
try {
|
||||
await toggleReactionMutation.mutateAsync({
|
||||
eventId: note.id,
|
||||
emoji: "+",
|
||||
remove,
|
||||
});
|
||||
if (currentPubkey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.likedNotes(currentPubkey),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDuplicateReactionError(error)) {
|
||||
queryClient.setQueryData<Map<string, PulseReactionState>>(
|
||||
reactionQueryKey,
|
||||
(current) => applyReactionState(current, note.id, true),
|
||||
);
|
||||
if (currentPubkey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: pulseQueryKeys.likedNotes(currentPubkey),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(reactionQueryKey, previousReactions);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update reaction",
|
||||
);
|
||||
} finally {
|
||||
setPendingUpvoteNoteIds((current) =>
|
||||
toggleNoteIdInSet(current, note.id, false),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
currentPubkey,
|
||||
pendingUpvoteNoteIds,
|
||||
queryClient,
|
||||
reactionQueryKey,
|
||||
toggleReactionMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const reply = React.useCallback(
|
||||
async (
|
||||
note: UserNote,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => {
|
||||
const replyMentionPubkeys = [
|
||||
...new Set([note.pubkey, ...mentionPubkeys]),
|
||||
];
|
||||
|
||||
try {
|
||||
await replyMutation.mutateAsync({
|
||||
content,
|
||||
replyTo: note.id,
|
||||
mentionPubkeys: replyMentionPubkeys,
|
||||
mediaTags,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to post reply",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[replyMutation],
|
||||
);
|
||||
|
||||
const share = React.useCallback(async (note: UserNote) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(buildNoteShareUri(note));
|
||||
toast.success("Copied note link");
|
||||
} catch {
|
||||
toast.error("Failed to copy note link");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startDm = React.useCallback(
|
||||
async (pubkey: string) => {
|
||||
try {
|
||||
const directMessage = await openDmMutation.mutateAsync({
|
||||
pubkeys: [pubkey],
|
||||
});
|
||||
await navigate({
|
||||
to: "/channels/$channelId",
|
||||
params: { channelId: directMessage.id },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open DM",
|
||||
);
|
||||
}
|
||||
},
|
||||
[navigate, openDmMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
isReplySending: replyMutation.isPending,
|
||||
isUpvotePending: (noteId) => pendingUpvoteNoteIds.has(noteId),
|
||||
reactionCount: (noteId) => reactions.get(noteId)?.count ?? 0,
|
||||
isUpvoted: (noteId) => reactions.get(noteId)?.reactedByCurrentUser ?? false,
|
||||
reply,
|
||||
share,
|
||||
startDm,
|
||||
toggleUpvote,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Bot, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { AgentNoteGroup } from "@/features/pulse/lib/groupAgentNotes";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import type { UserProfileSummary } from "@/shared/api/types";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
@@ -54,10 +55,20 @@ export function AgentActivityCard({
|
||||
<div className="rounded-2xl px-1 py-4 sm:px-2">
|
||||
{/* Header */}
|
||||
<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>
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={displayName}
|
||||
pubkey={group.pubkey}
|
||||
role={"bot" as const}
|
||||
>
|
||||
<button
|
||||
aria-label={`Open profile for ${displayName}`}
|
||||
className="relative flex shrink-0 rounded-xl pt-1 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold leading-none">
|
||||
|
||||
@@ -1,36 +1,116 @@
|
||||
import {
|
||||
ALargeSmall,
|
||||
AtSign,
|
||||
Bookmark,
|
||||
Bot,
|
||||
Heart,
|
||||
MessageCircle,
|
||||
PenSquare,
|
||||
Paperclip,
|
||||
SmilePlus,
|
||||
SquareArrowOutUpRight,
|
||||
ThumbsUp,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { useNoteByIdQuery } from "@/features/pulse/hooks";
|
||||
import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies";
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
import type { UserProfileSummary } from "@/shared/api/types";
|
||||
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
export type NoteCardActions = {
|
||||
reply?: (
|
||||
note: UserNote,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => Promise<unknown>;
|
||||
share?: (note: UserNote) => void;
|
||||
startDm?: (pubkey: string) => void;
|
||||
toggleUpvote?: (note: UserNote, remove: boolean) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type NoteCardProps = {
|
||||
note: UserNote;
|
||||
profile?: UserProfileSummary | null;
|
||||
currentUserDisplayName?: string;
|
||||
currentUserProfile?: UserProfileSummary | null;
|
||||
composerProfiles?: Record<string, UserProfileSummary>;
|
||||
isReplySending?: boolean;
|
||||
reactionCount?: number;
|
||||
isUpvotePending?: boolean;
|
||||
isUpvoted?: boolean;
|
||||
members?: ChannelMember[];
|
||||
isAgent?: boolean;
|
||||
isOwnNote: boolean;
|
||||
isFollowing: boolean;
|
||||
onFollow?: (pubkey: string) => void;
|
||||
onReply?: (note: UserNote) => void;
|
||||
onShare?: (note: UserNote) => void;
|
||||
onUnfollow?: (pubkey: string) => void;
|
||||
actions?: NoteCardActions;
|
||||
};
|
||||
|
||||
function ReplyParentContext({
|
||||
parentId,
|
||||
profiles,
|
||||
}: {
|
||||
parentId: string;
|
||||
profiles: Record<string, UserProfileSummary>;
|
||||
}) {
|
||||
const parentNoteQuery = useNoteByIdQuery(parentId);
|
||||
const parentNote = parentNoteQuery.data ?? null;
|
||||
const cachedProfile = parentNote
|
||||
? profiles[parentNote.pubkey.toLowerCase()]
|
||||
: null;
|
||||
const parentProfileQuery = useUserProfileQuery(
|
||||
parentNote && !cachedProfile ? parentNote.pubkey : undefined,
|
||||
);
|
||||
const fetchedProfile = parentProfileQuery.data ?? null;
|
||||
const parentDisplayName = parentNote
|
||||
? (cachedProfile?.displayName ??
|
||||
fetchedProfile?.displayName ??
|
||||
`${parentNote.pubkey.slice(0, 8)}...`)
|
||||
: null;
|
||||
const parentAvatarUrl =
|
||||
cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null;
|
||||
const parentSnippet = parentNote ? noteSnippet(parentNote.content) : null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 truncate rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
{parentNote ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<UserProfilePopover pubkey={parentNote.pubkey} triggerElement="span">
|
||||
<button
|
||||
className="flex shrink-0 rounded-md focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={parentAvatarUrl}
|
||||
className="!h-4 !w-4 shrink-0 rounded-md"
|
||||
displayName={parentDisplayName ?? "Parent note author"}
|
||||
/>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
<span className="min-w-0 truncate">
|
||||
<UserProfilePopover
|
||||
pubkey={parentNote.pubkey}
|
||||
triggerElement="span"
|
||||
>
|
||||
<button
|
||||
className="rounded font-medium text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{parentDisplayName}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
: {parentSnippet || "No text"}
|
||||
</span>
|
||||
</div>
|
||||
) : parentNoteQuery.isLoading ? (
|
||||
"Loading reply context…"
|
||||
) : (
|
||||
"Replying to an unavailable note"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(unixSeconds: number): string {
|
||||
const now = Date.now() / 1_000;
|
||||
const diff = now - unixSeconds;
|
||||
@@ -51,57 +131,66 @@ export function NoteCard({
|
||||
profile,
|
||||
currentUserDisplayName = "You",
|
||||
currentUserProfile,
|
||||
composerProfiles = {},
|
||||
isAgent,
|
||||
isOwnNote,
|
||||
isFollowing,
|
||||
onFollow,
|
||||
onReply,
|
||||
onShare,
|
||||
onUnfollow,
|
||||
isReplySending = false,
|
||||
reactionCount = 0,
|
||||
isUpvotePending = false,
|
||||
isUpvoted = false,
|
||||
members = [],
|
||||
actions,
|
||||
}: 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-hidden focus-visible:ring-2 focus-visible:ring-ring";
|
||||
const activeActionClass = "text-foreground";
|
||||
const activeActionClass = "text-primary";
|
||||
const countPlaceholder = <span aria-hidden className="w-2.5" />;
|
||||
const reactionCountLabel =
|
||||
reactionCount > 0 ? (
|
||||
<span className="tabular-nums">{reactionCount}</span>
|
||||
) : null;
|
||||
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);
|
||||
};
|
||||
const replyParentId = getReplyParent(note);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={displayName}
|
||||
pubkey={note.pubkey}
|
||||
role={isAgent ? "bot" : undefined}
|
||||
>
|
||||
<button
|
||||
className="relative flex shrink-0 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<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}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={displayName}
|
||||
pubkey={note.pubkey}
|
||||
role={isAgent ? "bot" : undefined}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded text-sm font-semibold leading-none tracking-tight focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
{isAgent ? (
|
||||
<span className="inline-flex h-4 items-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
bot
|
||||
@@ -117,156 +206,119 @@ export function NoteCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{replyParentId ? (
|
||||
<ReplyParentContext
|
||||
parentId={replyParentId}
|
||||
profiles={composerProfiles}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={isUpvoted ? "Unlike" : "Like"}
|
||||
aria-pressed={isUpvoted}
|
||||
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:cursor-not-allowed disabled:opacity-45`}
|
||||
disabled={isUpvotePending}
|
||||
onClick={() => {
|
||||
if (!isUpvotePending) {
|
||||
void actions?.toggleUpvote?.(note, isUpvoted);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${isUpvoted ? "fill-current" : ""}`}
|
||||
/>
|
||||
{reactionCountLabel}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isUpvoted ? "Unlike" : "Like"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Reply"
|
||||
aria-expanded={isReplyComposerOpen}
|
||||
className={actionButtonClass}
|
||||
onClick={() => setIsReplyComposerOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{countPlaceholder}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reply</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Share"
|
||||
className={actionButtonClass}
|
||||
onClick={() => actions?.share?.(note)}
|
||||
type="button"
|
||||
>
|
||||
<SquareArrowOutUpRight className="h-4 w-4" />
|
||||
{countPlaceholder}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Share</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isOwnNote ? (
|
||||
isFollowing ? (
|
||||
<button
|
||||
className="text-muted-foreground/60 transition-colors hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:outline-hidden 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-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onFollow?.(note.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="Start direct message"
|
||||
className={actionButtonClass}
|
||||
onClick={() => actions?.startDm?.(note.pubkey)}
|
||||
type="button"
|
||||
>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Start direct message</TooltipContent>
|
||||
</Tooltip>
|
||||
) : 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="mt-4 rounded-2xl border border-border/60 bg-background/60 p-3">
|
||||
<ForumComposer
|
||||
compact
|
||||
className="pulse-reply-composer border-0 bg-transparent p-0 shadow-none"
|
||||
disabled={!actions?.reply}
|
||||
header={
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={currentUserAvatarUrl}
|
||||
className="!h-8 !w-8 shrink-0"
|
||||
displayName={currentUserDisplayName}
|
||||
/>
|
||||
<span className="max-w-32 truncate text-sm font-medium text-foreground">
|
||||
{currentUserDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
isSending={isReplySending}
|
||||
members={members}
|
||||
onCancel={() => setIsReplyComposerOpen(false)}
|
||||
onSubmit={(content, mentionPubkeys, mediaTags) =>
|
||||
actions
|
||||
?.reply?.(note, content, mentionPubkeys, mediaTags)
|
||||
?.then(() => {
|
||||
setIsReplyComposerOpen(false);
|
||||
})
|
||||
}
|
||||
placeholder="Post your reply"
|
||||
profiles={composerProfiles}
|
||||
/>
|
||||
<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-hidden"
|
||||
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-hidden 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-hidden 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-hidden 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-hidden 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-hidden 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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,21 +1,57 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
|
||||
import { PulseView } from "@/features/pulse/ui/PulseView";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
|
||||
export function PulseScreen() {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const threadPanelWidth = useThreadPanelWidth();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const { goChannel } = useAppNavigation();
|
||||
const handleOpenDm = React.useCallback(
|
||||
async (pubkeys: string[]) => {
|
||||
const dm = await openDmMutation.mutateAsync({ pubkeys });
|
||||
await goChannel(dm.id);
|
||||
},
|
||||
[goChannel, openDmMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatHeader
|
||||
description="Notes from people and agents you follow"
|
||||
mode="pulse"
|
||||
overlaysContent
|
||||
title="Pulse"
|
||||
/>
|
||||
<ProfilePanelProvider onOpenProfilePanel={setProfilePanelPubkey}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<PulseView currentPubkey={identityQuery.data?.pubkey} />
|
||||
<ChatHeader
|
||||
description="Notes from people and agents you follow"
|
||||
mode="pulse"
|
||||
overlaysContent
|
||||
title="Pulse"
|
||||
/>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<PulseView currentPubkey={identityQuery.data?.pubkey} />
|
||||
</div>
|
||||
{profilePanelPubkey ? (
|
||||
<UserProfilePanel
|
||||
canResetWidth={threadPanelWidth.canReset}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onClose={() => setProfilePanelPubkey(null)}
|
||||
onOpenDm={handleOpenDm}
|
||||
onResetWidth={threadPanelWidth.onResetWidth}
|
||||
onResizeStart={threadPanelWidth.onResizeStart}
|
||||
pubkey={profilePanelPubkey}
|
||||
widthPx={threadPanelWidth.widthPx}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProfilePanelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import type { PulseTab } from "@/features/pulse/ui/PulseView";
|
||||
import type { RelayAgent } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
type PulseTabBarProps = {
|
||||
activeTab: PulseTab;
|
||||
getPanelId: (tab: PulseTab) => string;
|
||||
getTabId: (tab: PulseTab) => string;
|
||||
relayAgents: RelayAgent[];
|
||||
onTabChange: (tab: PulseTab) => void;
|
||||
};
|
||||
|
||||
const tabButtonClassName =
|
||||
"h-7 rounded-full border border-transparent px-1.5 text-[10.5px] font-medium text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm";
|
||||
|
||||
export function PulseTabBar({
|
||||
activeTab,
|
||||
getPanelId,
|
||||
getTabId,
|
||||
relayAgents,
|
||||
onTabChange,
|
||||
}: PulseTabBarProps) {
|
||||
return (
|
||||
<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="min-w-0 max-w-full">
|
||||
<div className="-mx-4 overflow-x-auto px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div
|
||||
aria-label="Pulse sections"
|
||||
className="flex items-center gap-1"
|
||||
role="tablist"
|
||||
>
|
||||
<Button
|
||||
aria-controls={getPanelId("search")}
|
||||
aria-label="Search Pulse"
|
||||
aria-selected={activeTab === "search"}
|
||||
className="h-7 w-7 shrink-0 rounded-full border border-transparent p-0 text-muted-foreground data-[active=true]:border-border/70 data-[active=true]:bg-background/80 data-[active=true]:text-foreground data-[active=true]:shadow-xs data-[active=true]:backdrop-blur-sm"
|
||||
data-active={activeTab === "search"}
|
||||
id={getTabId("search")}
|
||||
onClick={() => onTabChange("search")}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-controls={getPanelId("everyone")}
|
||||
aria-selected={activeTab === "everyone"}
|
||||
className={tabButtonClassName}
|
||||
data-active={activeTab === "everyone"}
|
||||
id={getTabId("everyone")}
|
||||
onClick={() => onTabChange("everyone")}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Everyone
|
||||
</Button>
|
||||
<Button
|
||||
aria-controls={getPanelId("people")}
|
||||
aria-selected={activeTab === "people"}
|
||||
className={tabButtonClassName}
|
||||
data-active={activeTab === "people"}
|
||||
id={getTabId("people")}
|
||||
onClick={() => onTabChange("people")}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Following
|
||||
</Button>
|
||||
<Button
|
||||
aria-controls={getPanelId("liked")}
|
||||
aria-selected={activeTab === "liked"}
|
||||
className={tabButtonClassName}
|
||||
data-active={activeTab === "liked"}
|
||||
id={getTabId("liked")}
|
||||
onClick={() => onTabChange("liked")}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Liked
|
||||
</Button>
|
||||
<Button
|
||||
aria-controls={getPanelId("agents")}
|
||||
aria-selected={activeTab === "agents"}
|
||||
className={tabButtonClassName}
|
||||
data-active={activeTab === "agents"}
|
||||
id={getTabId("agents")}
|
||||
onClick={() => onTabChange("agents")}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Agents
|
||||
{relayAgents.length > 0 ? (
|
||||
<span className="ml-0.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}
|
||||
</Button>
|
||||
<Button
|
||||
aria-controls={getPanelId("mine")}
|
||||
aria-selected={activeTab === "mine"}
|
||||
className={tabButtonClassName}
|
||||
data-active={activeTab === "mine"}
|
||||
id={getTabId("mine")}
|
||||
onClick={() => onTabChange("mine")}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Mine
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,45 @@
|
||||
import { Check, Filter, Search } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useRelayAgentsQuery } from "@/features/agents/hooks";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import {
|
||||
useManagedAgentsQuery,
|
||||
useRelayAgentsQuery,
|
||||
} from "@/features/agents/hooks";
|
||||
import {
|
||||
useContactListQuery,
|
||||
useFollowMutation,
|
||||
useUsersBatchQuery,
|
||||
} from "@/features/profile/hooks";
|
||||
import {
|
||||
useGlobalNotesQuery,
|
||||
pulseQueryKeys,
|
||||
useLikedNotesQuery,
|
||||
useMyNotesQuery,
|
||||
usePublishNoteMutation,
|
||||
usePulseReactionsQuery,
|
||||
useTimelineQuery,
|
||||
useUnfollowMutation,
|
||||
} from "@/features/pulse/hooks";
|
||||
import { groupAgentNotes } from "@/features/pulse/lib/groupAgentNotes";
|
||||
import { usePulseNoteActions } from "@/features/pulse/lib/useNoteActions";
|
||||
import { AgentActivityCard } from "@/features/pulse/ui/AgentActivityCard";
|
||||
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
|
||||
import { NoteCard } from "@/features/pulse/ui/NoteCard";
|
||||
import { PulseTabBar } from "@/features/pulse/ui/PulseTabBar";
|
||||
import type { UserNote } from "@/shared/api/socialTypes";
|
||||
import type {
|
||||
ChannelMember,
|
||||
RelayAgent,
|
||||
UserProfileSummary,
|
||||
} from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
|
||||
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 = "search" | "foryou" | "people" | "agents" | "mine";
|
||||
export type PulseTab =
|
||||
| "search"
|
||||
| "everyone"
|
||||
| "people"
|
||||
| "liked"
|
||||
| "agents"
|
||||
| "mine";
|
||||
|
||||
const tabTriggerClassName =
|
||||
"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-xs dark:!text-white/35 dark:hover:!text-white/70 dark:data-[state=active]:!text-white";
|
||||
const pulsePanelId = (tab: PulseTab) => `pulse-panel-${tab}`;
|
||||
const pulseTabId = (tab: PulseTab) => `pulse-tab-${tab}`;
|
||||
|
||||
type PulseViewProps = {
|
||||
currentPubkey?: string;
|
||||
@@ -67,102 +70,49 @@ function TimelineSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Agent filter dropdown ──────────────────────────────────────────────────
|
||||
|
||||
function AgentFilter({
|
||||
agents,
|
||||
profiles,
|
||||
selectedPubkey,
|
||||
onSelect,
|
||||
}: {
|
||||
agents: RelayAgent[];
|
||||
profiles: Record<string, UserProfileSummary>;
|
||||
selectedPubkey: string | null;
|
||||
onSelect: (pubkey: string | null) => void;
|
||||
}) {
|
||||
const selectedName = selectedPubkey
|
||||
? (profiles[selectedPubkey.toLowerCase()]?.displayName ??
|
||||
agents.find((a) => a.pubkey === selectedPubkey)?.name ??
|
||||
`${selectedPubkey.slice(0, 8)}...`)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
size="sm"
|
||||
variant={selectedPubkey ? "secondary" : "ghost"}
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
{selectedName ?? "All agents"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-48 overflow-y-auto">
|
||||
<DropdownMenuItem onClick={() => onSelect(null)}>
|
||||
{!selectedPubkey ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5" />
|
||||
)}
|
||||
All agents
|
||||
</DropdownMenuItem>
|
||||
{agents.map((agent) => {
|
||||
const name =
|
||||
profiles[agent.pubkey.toLowerCase()]?.displayName ??
|
||||
agent.name ??
|
||||
`${agent.pubkey.slice(0, 8)}...`;
|
||||
const isSelected = selectedPubkey === agent.pubkey;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={agent.pubkey}
|
||||
onClick={() => onSelect(agent.pubkey)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main PulseView ─────────────────────────────────────────────────────────
|
||||
|
||||
export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
const [activeTab, setActiveTab] = React.useState<PulseTab>("foryou");
|
||||
const [agentFilter, setAgentFilter] = React.useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = React.useState<PulseTab>("everyone");
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
|
||||
// ── Contact list & follow state ────────────────────────────────────────
|
||||
const contactListQuery = useContactListQuery(currentPubkey);
|
||||
const contacts = contactListQuery.data?.contacts ?? [];
|
||||
const contactPubkeys = React.useMemo(
|
||||
() => contacts.map((c) => c.pubkey),
|
||||
[contacts],
|
||||
);
|
||||
const followingSet = React.useMemo(
|
||||
const contactPubkeySet = React.useMemo(
|
||||
() => new Set(contactPubkeys),
|
||||
[contactPubkeys],
|
||||
);
|
||||
const peoplePubkeys = React.useMemo(() => contactPubkeys, [contactPubkeys]);
|
||||
|
||||
// People-only pubkeys (contacts + self, no agents)
|
||||
const peoplePubkeys = React.useMemo(
|
||||
() =>
|
||||
currentPubkey
|
||||
? [...new Set([currentPubkey, ...contactPubkeys])]
|
||||
: contactPubkeys,
|
||||
[currentPubkey, contactPubkeys],
|
||||
);
|
||||
|
||||
// ── Agents ─────────────────────────────────────────────────────────────
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
const relayAgents = relayAgentsQuery.data ?? [];
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgents = React.useMemo(() => {
|
||||
const agentsByPubkey = new Map<
|
||||
string,
|
||||
NonNullable<typeof relayAgentsQuery.data>[number]
|
||||
>();
|
||||
for (const agent of relayAgentsQuery.data ?? []) {
|
||||
agentsByPubkey.set(agent.pubkey, agent);
|
||||
}
|
||||
for (const agent of managedAgentsQuery.data ?? []) {
|
||||
if (!agentsByPubkey.has(agent.pubkey)) {
|
||||
agentsByPubkey.set(agent.pubkey, {
|
||||
pubkey: agent.pubkey,
|
||||
name: agent.name,
|
||||
agentType: agent.agentCommand,
|
||||
channels: [],
|
||||
channelIds: [],
|
||||
capabilities: [],
|
||||
status:
|
||||
agent.status === "running" || agent.status === "deployed"
|
||||
? "online"
|
||||
: "offline",
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...agentsByPubkey.values()];
|
||||
}, [managedAgentsQuery.data, relayAgentsQuery.data]);
|
||||
const agentPubkeys = React.useMemo(
|
||||
() => relayAgents.map((a) => a.pubkey),
|
||||
[relayAgents],
|
||||
@@ -179,57 +129,77 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
return map;
|
||||
}, [relayAgents]);
|
||||
|
||||
// ── "For You" combined pubkeys (contacts + agents + self) ──────────────
|
||||
const forYouPubkeys = React.useMemo(
|
||||
() => [...new Set([...peoplePubkeys, ...agentPubkeys])],
|
||||
[peoplePubkeys, agentPubkeys],
|
||||
const mentionPubkeys = React.useMemo(
|
||||
() =>
|
||||
[...new Set([currentPubkey, ...peoplePubkeys, ...agentPubkeys])].filter(
|
||||
(pubkey): pubkey is string =>
|
||||
typeof pubkey === "string" && pubkey.length > 0,
|
||||
),
|
||||
[currentPubkey, peoplePubkeys, agentPubkeys],
|
||||
);
|
||||
|
||||
// ── Queries per tab ────────────────────────────────────────────────────
|
||||
const forYouQuery = useTimelineQuery(forYouPubkeys, activeTab === "foryou");
|
||||
const everyoneQuery = useGlobalNotesQuery(activeTab === "everyone");
|
||||
const peopleQuery = useTimelineQuery(peoplePubkeys, activeTab === "people");
|
||||
const likedNotesQuery = useLikedNotesQuery(
|
||||
currentPubkey,
|
||||
activeTab === "liked",
|
||||
);
|
||||
const agentTimelineQuery = useTimelineQuery(
|
||||
agentFilter ? [agentFilter] : agentPubkeys,
|
||||
agentPubkeys,
|
||||
activeTab === "agents",
|
||||
);
|
||||
const myNotesQuery = useMyNotesQuery(
|
||||
activeTab === "mine" ? currentPubkey : undefined,
|
||||
);
|
||||
const publishMutation = usePublishNoteMutation(currentPubkey);
|
||||
const followMutation = useFollowMutation(currentPubkey);
|
||||
const unfollowMutation = useUnfollowMutation(currentPubkey);
|
||||
|
||||
// ── Visible notes per tab ──────────────────────────────────────────────
|
||||
const visibleNotes: UserNote[] = React.useMemo(() => {
|
||||
if (activeTab === "foryou") {
|
||||
return forYouQuery.data?.notes ?? [];
|
||||
if (activeTab === "everyone") {
|
||||
return everyoneQuery.data?.notes ?? [];
|
||||
}
|
||||
if (activeTab === "people") {
|
||||
// Filter out agent notes from the people timeline.
|
||||
// Filter out agent notes from the people timeline unless the user follows them.
|
||||
return (peopleQuery.data?.notes ?? []).filter(
|
||||
(n) => !agentPubkeySet.has(n.pubkey),
|
||||
(n) => !agentPubkeySet.has(n.pubkey) || contactPubkeySet.has(n.pubkey),
|
||||
);
|
||||
}
|
||||
if (activeTab === "liked") {
|
||||
return likedNotesQuery.data?.notes ?? [];
|
||||
}
|
||||
if (activeTab === "agents") {
|
||||
return agentTimelineQuery.data?.notes ?? [];
|
||||
}
|
||||
return myNotesQuery.data?.notes ?? [];
|
||||
}, [
|
||||
activeTab,
|
||||
forYouQuery.data,
|
||||
everyoneQuery.data,
|
||||
peopleQuery.data,
|
||||
agentTimelineQuery.data,
|
||||
likedNotesQuery.data,
|
||||
myNotesQuery.data,
|
||||
agentPubkeySet,
|
||||
contactPubkeySet,
|
||||
]);
|
||||
|
||||
// Agent note groups for the agents tab.
|
||||
const visibleNoteIds = React.useMemo(
|
||||
() => visibleNotes.map((note) => note.id),
|
||||
[visibleNotes],
|
||||
);
|
||||
const reactionsQuery = usePulseReactionsQuery(visibleNoteIds, currentPubkey);
|
||||
const reactionQueryKey = React.useMemo(
|
||||
() => pulseQueryKeys.reactions(visibleNoteIds),
|
||||
[visibleNoteIds],
|
||||
);
|
||||
const noteActions = usePulseNoteActions({
|
||||
currentPubkey,
|
||||
reactionQueryKey,
|
||||
reactions: reactionsQuery.data ?? new Map(),
|
||||
});
|
||||
|
||||
const agentNoteGroups = React.useMemo(
|
||||
() => (activeTab === "agents" ? groupAgentNotes(visibleNotes) : []),
|
||||
[activeTab, visibleNotes],
|
||||
);
|
||||
|
||||
// ── Profile lookups ────────────────────────────────────────────────────
|
||||
const notePubkeys = React.useMemo(
|
||||
() => [...new Set(visibleNotes.map((n) => n.pubkey))],
|
||||
[visibleNotes],
|
||||
@@ -240,9 +210,8 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
const profiles: Record<string, UserProfileSummary> =
|
||||
profilesQuery.data?.profiles ?? {};
|
||||
|
||||
// ── Mention members for ForumComposer ─────────────────────────────────
|
||||
const mentionProfilesQuery = useUsersBatchQuery(forYouPubkeys, {
|
||||
enabled: forYouPubkeys.length > 0,
|
||||
const mentionProfilesQuery = useUsersBatchQuery(mentionPubkeys, {
|
||||
enabled: mentionPubkeys.length > 0,
|
||||
});
|
||||
const mentionProfiles = mentionProfilesQuery.data?.profiles ?? {};
|
||||
const currentProfile = currentPubkey
|
||||
@@ -254,7 +223,7 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
|
||||
const pulseMentionMembers = React.useMemo<ChannelMember[]>(() => {
|
||||
const members: ChannelMember[] = [];
|
||||
for (const pubkey of forYouPubkeys) {
|
||||
for (const pubkey of mentionPubkeys) {
|
||||
const profile = mentionProfiles[pubkey.toLowerCase()];
|
||||
members.push({
|
||||
pubkey,
|
||||
@@ -264,37 +233,29 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
});
|
||||
}
|
||||
return members;
|
||||
}, [forYouPubkeys, mentionProfiles]);
|
||||
}, [mentionPubkeys, mentionProfiles]);
|
||||
|
||||
// ── Loading / refresh state ────────────────────────────────────────────
|
||||
const activeQuery =
|
||||
activeTab === "foryou"
|
||||
? forYouQuery
|
||||
activeTab === "everyone"
|
||||
? everyoneQuery
|
||||
: activeTab === "people"
|
||||
? peopleQuery
|
||||
: activeTab === "agents"
|
||||
? agentTimelineQuery
|
||||
: myNotesQuery;
|
||||
: activeTab === "liked"
|
||||
? likedNotesQuery
|
||||
: activeTab === "agents"
|
||||
? agentTimelineQuery
|
||||
: myNotesQuery;
|
||||
const isLoading = activeQuery.isLoading;
|
||||
|
||||
function handleFollow(pubkey: string) {
|
||||
followMutation.mutate(pubkey);
|
||||
}
|
||||
|
||||
function handleUnfollow(pubkey: string) {
|
||||
unfollowMutation.mutate(pubkey);
|
||||
}
|
||||
|
||||
// ── 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.",
|
||||
everyone: "No public notes yet.",
|
||||
people: "No notes yet. Follow people to see their updates here.",
|
||||
liked: "No likes yet — tap the heart on a note to save it here.",
|
||||
agents:
|
||||
agentPubkeys.length === 0
|
||||
? "No agents registered yet."
|
||||
: "No agent notes yet. Agents will post updates as they work.",
|
||||
: "No agent notes yet. Agents post here when they publish.",
|
||||
mine: "You haven't posted any notes yet.",
|
||||
};
|
||||
|
||||
@@ -321,15 +282,24 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
) : (
|
||||
visibleNotes.map((note) => (
|
||||
<NoteCard
|
||||
actions={{
|
||||
reply: noteActions.reply,
|
||||
share: noteActions.share,
|
||||
startDm: noteActions.startDm,
|
||||
toggleUpvote: noteActions.toggleUpvote,
|
||||
}}
|
||||
composerProfiles={mentionProfiles}
|
||||
currentUserDisplayName={currentDisplayName}
|
||||
currentUserProfile={currentProfile}
|
||||
isAgent={agentPubkeySet.has(note.pubkey)}
|
||||
isFollowing={followingSet.has(note.pubkey)}
|
||||
isOwnNote={note.pubkey === currentPubkey}
|
||||
isReplySending={noteActions.isReplySending}
|
||||
isUpvotePending={noteActions.isUpvotePending(note.id)}
|
||||
isUpvoted={noteActions.isUpvoted(note.id)}
|
||||
reactionCount={noteActions.reactionCount(note.id)}
|
||||
key={note.id}
|
||||
members={pulseMentionMembers}
|
||||
note={note}
|
||||
onFollow={handleFollow}
|
||||
onUnfollow={handleUnfollow}
|
||||
profile={profiles[note.pubkey.toLowerCase()] ?? null}
|
||||
/>
|
||||
))
|
||||
@@ -337,146 +307,101 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as PulseTab)}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<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-xs 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="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<PulseTabBar
|
||||
activeTab={activeTab}
|
||||
getPanelId={pulsePanelId}
|
||||
getTabId={pulseTabId}
|
||||
onTabChange={setActiveTab}
|
||||
relayAgents={relayAgents}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Timeline — TabsContent with forceMount so aria-controls resolves */}
|
||||
<TabsContent
|
||||
value={activeTab}
|
||||
forceMount
|
||||
className="mt-0 min-h-0 flex-1 overflow-y-auto"
|
||||
<div className="mt-0 min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
aria-labelledby={pulseTabId(activeTab)}
|
||||
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"
|
||||
}`}
|
||||
id={pulsePanelId(activeTab)}
|
||||
role="tabpanel"
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
) : 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>
|
||||
) : 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>
|
||||
)}
|
||||
<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}
|
||||
}
|
||||
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>
|
||||
{activeTab !== "search" ? (
|
||||
<div className="space-y-4">{renderTimeline()}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user