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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(¬e_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 ¬e_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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user