Polish desktop Pulse and Home views (#764)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-05-28 08:17:02 -07:00
committed by GitHub
parent 516dc8303f
commit 30654e95f6
29 changed files with 1947 additions and 686 deletions
+6
View File
@@ -164,6 +164,8 @@ jobs:
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Desktop lint and format
run: just desktop-check
- name: Desktop unit tests
run: just desktop-test
- name: Desktop build
run: just desktop-build
- name: Desktop smoke e2e
@@ -172,6 +174,10 @@ jobs:
run: just desktop-tauri-check
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Desktop Tauri tests
run: just desktop-tauri-test
env:
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
- name: Upload desktop e2e artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+1
View File
@@ -11,6 +11,7 @@
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes",
"format": "biome format --write .",
"test": "node --test 'src/**/*.test.mjs'",
"preview": "vite preview",
"tauri": "tauri",
"test:e2e": "pnpm build && playwright test",
+350 -7
View File
@@ -1,14 +1,55 @@
use nostr::EventId;
use std::collections::{HashMap, HashSet};
use nostr::{Event, EventId, Tag};
use tauri::State;
use crate::{
app_state::AppState,
events,
models::{ContactEntry, ContactListResponse, UserNoteInfo, UserNotesResponse},
models::{
ContactEntry, ContactListResponse, NoteReactionSummary, UserNoteInfo, UserNotesResponse,
},
nostr_convert,
relay::{query_relay, submit_event, SubmitEventResponse},
};
fn e_tag_id(tag: &Tag) -> Option<&String> {
let values = tag.as_slice();
match (values.first().map(String::as_str), values.get(1)) {
(Some("e"), Some(id)) => Some(id),
_ => None,
}
}
fn deleted_event_ids(events: &[Event]) -> HashSet<String> {
events
.iter()
.flat_map(|event| event.tags.iter().filter_map(e_tag_id).cloned())
.collect()
}
fn last_event_tag_id(event: &Event) -> Option<String> {
event.tags.iter().rev().find_map(e_tag_id).cloned()
}
fn last_matching_event_tag_id(event: &Event, targets: &HashSet<String>) -> Option<String> {
event
.tags
.iter()
.rev()
.filter_map(e_tag_id)
.find(|id| targets.contains(*id))
.cloned()
}
fn reaction_emoji(event: &Event) -> String {
if event.content.is_empty() {
"+".to_string()
} else {
event.content.clone()
}
}
/// Publish a global kind:1 text note (NIP-01).
#[tauri::command]
pub async fn publish_note(
@@ -44,11 +85,17 @@ pub async fn get_contact_list(
)
.await?;
events
.first()
.map(nostr_convert::contact_list_from_event)
.transpose()?
.ok_or_else(|| "contact list not found".to_string())
if let Some(event) = events.first() {
return nostr_convert::contact_list_from_event(event);
}
Ok(ContactListResponse {
id: String::new(),
pubkey,
created_at: 0,
tags: Vec::new(),
content: String::new(),
})
}
/// Replace the full contact list (kind:3, NIP-02). Read-before-write required
@@ -73,6 +120,230 @@ pub async fn set_contact_list(
submit_event(builder, &state).await
}
/// Fetch global NIP-01 kind:1 notes without an author filter.
#[tauri::command]
pub async fn get_global_notes(
limit: Option<u32>,
before: Option<i64>,
before_id: Option<String>,
state: State<'_, AppState>,
) -> Result<UserNotesResponse, String> {
let _ = before_id;
let mut filter = serde_json::Map::new();
filter.insert("kinds".to_string(), serde_json::json!([1]));
filter.insert(
"limit".to_string(),
serde_json::json!(limit.unwrap_or(50).min(200)),
);
if let Some(t) = before {
filter.insert("until".to_string(), serde_json::json!(t));
}
let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?;
Ok(nostr_convert::user_notes_from_events(&events))
}
fn validate_note_id(note_id: &str) -> Result<(), String> {
if note_id.len() == 64 && note_id.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(())
} else {
Err("invalid note id".to_string())
}
}
/// Fetch a single NIP-01 kind:1 note by event id.
#[tauri::command]
pub async fn get_note(
note_id: String,
state: State<'_, AppState>,
) -> Result<Option<UserNoteInfo>, String> {
validate_note_id(&note_id)?;
let events = query_relay(
&state,
&[serde_json::json!({
"kinds": [1],
"ids": [note_id],
"limit": 1,
})],
)
.await?;
Ok(nostr_convert::user_notes_from_events(&events)
.notes
.into_iter()
.next())
}
const MAX_NOTE_IDS: usize = 200;
/// Fetch and fold kind:7 reactions for visible Pulse notes.
#[tauri::command]
pub async fn get_note_reactions(
note_ids: Vec<String>,
state: State<'_, AppState>,
) -> Result<Vec<NoteReactionSummary>, String> {
if note_ids.is_empty() {
return Ok(Vec::new());
}
if note_ids.len() > MAX_NOTE_IDS {
return Err(format!(
"too many note ids (max {MAX_NOTE_IDS}, got {})",
note_ids.len()
));
}
for note_id in &note_ids {
validate_note_id(note_id)?;
}
let events = query_relay(
&state,
&[serde_json::json!({
"kinds": [7],
"#e": note_ids,
"limit": 500,
})],
)
.await?;
let reaction_ids: Vec<String> = events.iter().map(|event| event.id.to_hex()).collect();
let deletion_events = if reaction_ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({
"kinds": [5],
"#e": reaction_ids,
"limit": 500,
})],
)
.await?
};
let deleted_reaction_ids = deleted_event_ids(&deletion_events);
let targets: HashSet<String> = note_ids.into_iter().collect();
let mut by_note_and_emoji = HashMap::<(String, String), HashSet<String>>::new();
for event in events {
if deleted_reaction_ids.contains(&event.id.to_hex()) {
continue;
}
let Some(target_id) = last_matching_event_tag_id(&event, &targets) else {
continue;
};
let emoji = reaction_emoji(&event);
by_note_and_emoji
.entry((target_id, emoji))
.or_default()
.insert(event.pubkey.to_hex());
}
let mut summaries: Vec<NoteReactionSummary> = by_note_and_emoji
.into_iter()
.map(|((note_id, emoji), pubkey_set)| {
let mut pubkeys: Vec<String> = pubkey_set.into_iter().collect();
pubkeys.sort();
NoteReactionSummary {
note_id,
emoji,
count: pubkeys.len(),
pubkeys,
}
})
.collect();
summaries.sort_by(|left, right| {
left.note_id
.cmp(&right.note_id)
.then_with(|| left.emoji.cmp(&right.emoji))
});
Ok(summaries)
}
/// Fetch notes liked by a user, excluding deleted reaction events.
#[tauri::command]
pub async fn get_liked_notes(
author_pubkey: String,
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<UserNotesResponse, String> {
let cap = limit.unwrap_or(50).min(MAX_NOTE_IDS as u32) as usize;
let reaction_fetch_limit = (cap * 4).min(1000);
let mut reactions = query_relay(
&state,
&[serde_json::json!({
"kinds": [7],
"authors": [author_pubkey],
"limit": reaction_fetch_limit,
})],
)
.await?;
reactions.sort_by(|left, right| right.created_at.cmp(&left.created_at));
let reaction_ids: Vec<String> = reactions.iter().map(|event| event.id.to_hex()).collect();
let deletions = if reaction_ids.is_empty() {
Vec::new()
} else {
query_relay(
&state,
&[serde_json::json!({
"kinds": [5],
"authors": [author_pubkey],
"#e": reaction_ids,
"limit": 500,
})],
)
.await?
};
let deleted_reaction_ids = deleted_event_ids(&deletions);
let mut target_ids = Vec::<String>::new();
let mut target_liked_at = HashMap::<String, i64>::new();
let mut seen_targets = HashSet::<String>::new();
for reaction in reactions {
if target_ids.len() >= cap {
break;
}
if deleted_reaction_ids.contains(&reaction.id.to_hex()) {
continue;
}
let Some(target_id) = last_event_tag_id(&reaction) else {
continue;
};
if seen_targets.insert(target_id.clone()) {
target_liked_at.insert(target_id.clone(), reaction.created_at.as_secs() as i64);
target_ids.push(target_id);
}
}
if target_ids.is_empty() {
return Ok(UserNotesResponse {
notes: Vec::new(),
next_cursor: None,
});
}
let events = query_relay(
&state,
&[serde_json::json!({
"kinds": [1],
"ids": target_ids,
"limit": cap,
})],
)
.await?;
let mut response = nostr_convert::user_notes_from_events(&events);
response.notes.sort_by(|left, right| {
target_liked_at
.get(&right.id)
.unwrap_or(&0)
.cmp(target_liked_at.get(&left.id).unwrap_or(&0))
});
response.notes.truncate(cap);
Ok(response)
}
/// Maximum number of pubkeys per timeline request to keep filter size bounded.
const MAX_TIMELINE_PUBKEYS: usize = 100;
@@ -119,6 +390,7 @@ pub async fn get_notes_timeline(
pubkey: ev.pubkey.to_hex(),
created_at: ev.created_at.as_secs() as i64,
content: ev.content.clone(),
tags: ev.tags.iter().map(|tag| tag.as_slice().to_vec()).collect(),
})
.collect();
@@ -131,3 +403,74 @@ pub async fn get_notes_timeline(
next_cursor: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
fn tag(values: &[&str]) -> Tag {
Tag::parse(values.iter().copied()).expect("parse tag")
}
fn event(tags: Vec<Tag>, content: &str) -> Event {
EventBuilder::new(Kind::Custom(7), content)
.tags(tags)
.sign_with_keys(&Keys::generate())
.expect("sign event")
}
#[test]
fn e_tag_id_returns_only_event_tag_values() {
let e = tag(&["e", "a"]);
let p = tag(&["p", "b"]);
assert_eq!(e_tag_id(&e), Some(&"a".to_string()));
assert_eq!(e_tag_id(&p), None);
}
#[test]
fn last_event_tag_id_uses_last_e_tag() {
let ev = event(
vec![tag(&["e", "a"]), tag(&["p", "x"]), tag(&["e", "b"])],
"+",
);
assert_eq!(last_event_tag_id(&ev), Some("b".to_string()));
}
#[test]
fn last_matching_event_tag_id_uses_last_visible_target() {
let ev = event(
vec![tag(&["e", "x"]), tag(&["e", "y"]), tag(&["e", "z"])],
"+",
);
let targets = HashSet::from(["y".to_string(), "z".to_string()]);
assert_eq!(
last_matching_event_tag_id(&ev, &targets),
Some("z".to_string())
);
}
#[test]
fn deleted_event_ids_collects_all_e_tags() {
let first = event(vec![tag(&["e", "a"]), tag(&["e", "b"])], "");
let second = event(vec![tag(&["p", "ignored"]), tag(&["e", "c"])], "");
let deleted = deleted_event_ids(&[first, second]);
assert!(deleted.contains("a"));
assert!(deleted.contains("b"));
assert!(deleted.contains("c"));
assert!(!deleted.contains("ignored"));
}
#[test]
fn reaction_emoji_defaults_empty_content_to_plus() {
assert_eq!(reaction_emoji(&event(Vec::new(), "")), "+");
assert_eq!(reaction_emoji(&event(Vec::new(), "🔥")), "🔥");
}
#[test]
fn validate_note_id_requires_hex64() {
assert!(validate_note_id(&"a".repeat(64)).is_ok());
assert!(validate_note_id(&"g".repeat(64)).is_err());
assert!(validate_note_id(&"a".repeat(63)).is_err());
}
}
+4 -4
View File
@@ -453,15 +453,11 @@ pub fn run() {
try_regenerate_nest(&app_handle);
// Pre-download voice models in the background so they're ready
// when the user starts their first huddle. Idempotent — no-op if
// already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS).
if let Some(mgr) = huddle::models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
mgr.start_tts_download(state.http_client.clone());
}
// Register PTT global shortcut (Ctrl+Space).
// Non-fatal: huddle works without the shortcut (user can switch to VAD mode).
#[cfg(desktop)]
{
@@ -614,6 +610,10 @@ pub fn run() {
get_contact_list,
set_contact_list,
get_notes_timeline,
get_global_notes,
get_note,
get_note_reactions,
get_liked_notes,
start_huddle,
join_huddle,
leave_huddle,
+9
View File
@@ -51,6 +51,15 @@ pub struct UserNoteInfo {
pub pubkey: String,
pub created_at: i64,
pub content: String,
pub tags: Vec<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
pub struct NoteReactionSummary {
pub note_id: String,
pub emoji: String,
pub count: usize,
pub pubkeys: Vec<String>,
}
#[derive(Serialize, Deserialize)]
+1
View File
@@ -465,6 +465,7 @@ pub fn user_notes_from_events(events: &[Event]) -> UserNotesResponse {
pubkey: ev.pubkey.to_hex(),
created_at: ev.created_at.as_secs() as i64,
content: ev.content.clone(),
tags: ev.tags.iter().map(|tag| tag.as_slice().to_vec()).collect(),
})
.collect();
@@ -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.
+20
View File
@@ -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) => {
+30 -25
View File
@@ -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>
+70
View File
@@ -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"
+78 -75
View File
@@ -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);
});
+31
View File
@@ -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">
+237 -185
View File
@@ -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>
+45 -9
View File
@@ -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>
);
}
+206 -281
View File
@@ -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>
);
}
+74
View File
@@ -1,5 +1,6 @@
import type {
ContactListResponse,
NoteReactionSummary,
PublishNoteResult,
UserNote,
UserNotesResponse,
@@ -12,6 +13,14 @@ type RawUserNote = {
pubkey: string;
created_at: number;
content: string;
tags: string[][];
};
type RawNoteReactionSummary = {
note_id: string;
emoji: string;
count: number;
pubkeys: string[];
};
type RawUserNotesCursor = {
@@ -38,9 +47,32 @@ function fromRawUserNote(note: RawUserNote): UserNote {
pubkey: note.pubkey,
createdAt: note.created_at,
content: note.content,
tags: note.tags,
};
}
export async function getNoteReactions(
noteIds: string[],
): Promise<NoteReactionSummary[]> {
const response = await invokeTauri<RawNoteReactionSummary[]>(
"get_note_reactions",
{ noteIds },
);
return response.map((summary) => ({
noteId: summary.note_id,
emoji: summary.emoji,
count: summary.count,
pubkeys: summary.pubkeys,
}));
}
export async function getNote(noteId: string): Promise<UserNote | null> {
const response = await invokeTauri<RawUserNote | null>("get_note", {
noteId,
});
return response ? fromRawUserNote(response) : null;
}
export async function getUserNotes(
pubkey: string,
options?: {
@@ -137,6 +169,48 @@ export async function setContactList(
};
}
export async function getLikedNotes(
authorPubkey: string,
limit?: number,
): Promise<UserNotesResponse> {
const response = await invokeTauri<RawUserNotesResponse>("get_liked_notes", {
authorPubkey,
limit: limit ?? null,
});
return {
notes: response.notes.map(fromRawUserNote),
nextCursor: response.next_cursor
? {
before: response.next_cursor.before,
beforeId: response.next_cursor.before_id,
}
: null,
};
}
export async function getGlobalNotes(options?: {
limit?: number;
before?: number;
beforeId?: string;
}): Promise<UserNotesResponse> {
const response = await invokeTauri<RawUserNotesResponse>("get_global_notes", {
limit: options?.limit ?? null,
before: options?.before ?? null,
beforeId: options?.beforeId ?? null,
});
return {
notes: response.notes.map(fromRawUserNote),
nextCursor: response.next_cursor
? {
before: response.next_cursor.before,
beforeId: response.next_cursor.before_id,
}
: null,
};
}
export async function getNotesTimeline(
pubkeys: string[],
limitPerUser?: number,
+8
View File
@@ -3,6 +3,14 @@ export type UserNote = {
pubkey: string;
createdAt: number;
content: string;
tags: string[][];
};
export type NoteReactionSummary = {
noteId: string;
emoji: string;
count: number;
pubkeys: string[];
};
export type UserNotesCursor = {
@@ -0,0 +1,97 @@
import * as React from "react";
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;
}
}
export function useThreadPanelWidth() {
const [widthPx, setWidthPx] = React.useState<number>(() =>
getInitialThreadPanelWidth(),
);
React.useEffect(() => {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(
THREAD_PANEL_WIDTH_SESSION_KEY,
String(widthPx),
);
} catch {
// Ignore storage failures and keep in-memory width for this session.
}
}, [widthPx]);
const onResizeStart = React.useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = widthPx;
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);
setWidthPx(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 });
},
[widthPx],
);
const onResetWidth = React.useCallback(() => {
setWidthPx(THREAD_PANEL_DEFAULT_WIDTH_PX);
}, []);
return {
canReset: widthPx !== THREAD_PANEL_DEFAULT_WIDTH_PX,
onResetWidth,
onResizeStart,
widthPx,
};
}
+81 -2
View File
@@ -231,6 +231,7 @@ type RawUserNote = {
pubkey: string;
created_at: number;
content: string;
tags: string[][];
};
type RawUserNotesCursor = {
@@ -1930,12 +1931,14 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
pubkey,
created_at: now - 20 * 60,
content: "Shipped the new desktop sidebar polish today.",
tags: [],
},
{
id: "mock-note-forum",
pubkey,
created_at: now - 3 * 60 * 60,
content: "Forum threads feel like the right home for slower decisions.",
tags: [],
},
];
}
@@ -1947,12 +1950,14 @@ function getMockUserNotes(pubkey: string): RawUserNote[] {
pubkey,
created_at: now - 45 * 60,
content: "Release checklist is ready for async feedback.",
tags: [],
},
{
id: "mock-alice-note-design",
pubkey,
created_at: now - 5 * 60 * 60,
content: "Trying a lighter forum layout for longer-form notes.",
tags: [],
},
];
}
@@ -1998,13 +2003,72 @@ async function handleGetUserNotes(
pubkey: ev.pubkey,
content: ev.content,
created_at: ev.created_at,
kind: ev.kind,
tags: ev.tags,
sig: ev.sig,
}));
return { notes, next_cursor: null };
}
async function handleGetGlobalNotes(
args: { limit?: number | null; before?: number | null } | null,
config: E2eConfig | undefined,
): Promise<RawUserNotesResponse> {
const notes = [
...getMockUserNotes(DEFAULT_MOCK_IDENTITY.pubkey),
...getMockUserNotes(ALICE_PUBKEY),
]
.filter((note) => (args?.before ? note.created_at < args.before : true))
.sort((left, right) => right.created_at - left.created_at)
.slice(0, args?.limit ?? 50);
if (!getIdentity(config)) {
return { notes, next_cursor: null };
}
const events = await relayQuery(config, [
{ kinds: [1], limit: args?.limit ?? 50, until: args?.before ?? undefined },
]);
return {
notes: events.map((ev) => ({
id: ev.id,
pubkey: ev.pubkey,
content: ev.content,
created_at: ev.created_at,
tags: ev.tags,
})),
next_cursor: null,
};
}
function handleGetNotesTimeline(args: {
pubkeys?: string[];
limitPerUser?: number | null;
}) {
const pubkeys = args.pubkeys ?? [];
const limitPerUser = args.limitPerUser ?? 10;
const notes = pubkeys
.flatMap((pubkey) => getMockUserNotes(pubkey).slice(0, limitPerUser))
.sort((left, right) => right.created_at - left.created_at);
return { notes, next_cursor: null };
}
function handleGetNote(args: { noteId?: string }) {
const noteId = args.noteId;
return (
[
...getMockUserNotes(DEFAULT_MOCK_IDENTITY.pubkey),
...getMockUserNotes(ALICE_PUBKEY),
].find((note) => note.id === noteId) ?? null
);
}
function handleGetNoteReactions() {
return [];
}
function handleGetLikedNotes(): RawUserNotesResponse {
return { notes: [], next_cursor: null };
}
function createMockEvent(
kind: number,
content: string,
@@ -4727,6 +4791,21 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleGetUserNotes>[0],
activeConfig,
);
case "get_global_notes":
return handleGetGlobalNotes(
payload as Parameters<typeof handleGetGlobalNotes>[0],
activeConfig,
);
case "get_notes_timeline":
return handleGetNotesTimeline(
payload as Parameters<typeof handleGetNotesTimeline>[0],
);
case "get_note":
return handleGetNote(payload as Parameters<typeof handleGetNote>[0]);
case "get_note_reactions":
return handleGetNoteReactions();
case "get_liked_notes":
return handleGetLikedNotes();
case "search_users":
return handleSearchUsers(
payload as Parameters<typeof handleSearchUsers>[0],
+10 -2
View File
@@ -82,6 +82,10 @@ desktop-install-ci:
desktop-check:
cd {{desktop_dir}} && pnpm check
# Run desktop TS helper unit tests
desktop-test:
cd {{desktop_dir}} && pnpm test
# Run desktop TypeScript checks
desktop-typecheck:
cd {{desktop_dir}} && pnpm typecheck
@@ -115,6 +119,10 @@ _ensure-sidecar-stubs:
desktop-tauri-check: _ensure-sidecar-stubs
cargo check --manifest-path {{desktop_tauri_manifest}}
# Run desktop Tauri Rust unit tests
desktop-tauri-test: _ensure-sidecar-stubs
cd desktop/src-tauri && cargo test
# Build the full desktop Tauri app locally (unsigned, for testing)
desktop-release-build target="aarch64-apple-darwin":
#!/usr/bin/env bash
@@ -131,7 +139,7 @@ desktop-release-build target="aarch64-apple-darwin":
cd {{desktop_dir}} && pnpm tauri build --target {{target}}
# Run desktop checks suitable for CI / pre-push
desktop-ci: desktop-check desktop-tauri-fmt-check desktop-build desktop-tauri-check
desktop-ci: desktop-check desktop-test desktop-tauri-fmt-check desktop-build desktop-tauri-check desktop-tauri-test
# Seed deterministic channel data for desktop Playwright tests
desktop-e2e-seed:
@@ -146,7 +154,7 @@ desktop-e2e-integration:
cd {{desktop_dir}} && pnpm test:e2e:integration
# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-build desktop-tauri-check web-build mobile-test
ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test
# ─── Test ─────────────────────────────────────────────────────────────────────
+6
View File
@@ -7,6 +7,8 @@ pre-commit:
run: just desktop-tauri-fmt-check
desktop-check:
run: just desktop-check
desktop-test:
run: just desktop-test
web-check:
run: just web-check
mobile-check:
@@ -23,12 +25,16 @@ pre-push:
run: just test-unit
desktop-check:
run: just desktop-check
desktop-test:
run: just desktop-test
desktop-tauri-fmt:
run: just desktop-tauri-fmt-check
desktop-build:
run: just desktop-build
desktop-tauri-check:
run: just desktop-tauri-check
desktop-tauri-test:
run: just desktop-tauri-test
web-check:
run: just web-check
web-build: