mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
feat(desktop): add scoped reply thread sidebars
Keep forum and channel thread panes anchored to the selected branch, scope typing indicators to the active composer, and surface clearer relay errors when staging auth returns HTML. Made-with: Cursor
This commit is contained in:
@@ -21,7 +21,7 @@ use nostr::ToBech32;
|
||||
use pool::{
|
||||
AgentPool, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState,
|
||||
};
|
||||
use queue::{EventQueue, QueuedEvent};
|
||||
use queue::{parse_thread_tags, EventQueue, QueuedEvent};
|
||||
use relay::HarnessRelay;
|
||||
use sprout_core::kind::{
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE,
|
||||
@@ -805,7 +805,9 @@ async fn tokio_main() -> Result<()> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut typing_channels: HashSet<Uuid> = HashSet::new();
|
||||
// Channels where an agent turn is in flight, mapped to the NIP-10 parent of
|
||||
// the prompt's first event (for scoped kind:20002), or `None` if unknown.
|
||||
let mut typing_channels: HashMap<Uuid, Option<String>> = HashMap::new();
|
||||
let mut presence_task: Option<tokio::task::JoinHandle<()>> = None;
|
||||
|
||||
// ── Step 6d: Maintenance (slot refill + queue compaction) ────────────────
|
||||
@@ -931,7 +933,9 @@ async fn tokio_main() -> Result<()> {
|
||||
// called on relay events or pool results, neither of which
|
||||
// arrive when the channel is silent.
|
||||
if queue.has_flushable_work() {
|
||||
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
|
||||
for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) {
|
||||
typing_channels.insert(ch, parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,7 +966,9 @@ async fn tokio_main() -> Result<()> {
|
||||
// this, batches requeued during crash recovery sit idle until the
|
||||
// next relay event arrives — which can be minutes on quiet channels.
|
||||
if respawn_collected {
|
||||
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
|
||||
for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) {
|
||||
typing_channels.insert(ch, parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Borrow result_rx and join_set simultaneously via split-borrow helper.
|
||||
@@ -1272,7 +1278,9 @@ async fn tokio_main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
// ── End mode gate ────────────────────────────────
|
||||
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
|
||||
for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) {
|
||||
typing_channels.insert(ch, parent);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("relay event stream ended — requesting reconnect");
|
||||
@@ -1294,7 +1302,9 @@ async fn tokio_main() -> Result<()> {
|
||||
let _ = result_rx;
|
||||
if queue.has_flushable_work() {
|
||||
tracing::debug!("heartbeat_skipped_events");
|
||||
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
|
||||
for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) {
|
||||
typing_channels.insert(ch, parent);
|
||||
}
|
||||
} else if pool.any_idle() {
|
||||
dispatch_heartbeat(&mut pool, &ctx, &mut heartbeat_in_flight);
|
||||
} else {
|
||||
@@ -1331,8 +1341,8 @@ async fn tokio_main() -> Result<()> {
|
||||
// Use try_publish (non-blocking) for typing indicators —
|
||||
// they're ephemeral and must not block the main loop during
|
||||
// relay reconnection (#35).
|
||||
for &ch in &typing_channels {
|
||||
if let Ok(event) = relay.build_typing_event(ch) {
|
||||
for (ch, reply_parent) in &typing_channels {
|
||||
if let Ok(event) = relay.build_typing_event(*ch, reply_parent.as_deref()) {
|
||||
if let Err(e) = relay.try_publish_event(event) {
|
||||
tracing::debug!("typing indicator dropped for {ch}: {e}");
|
||||
}
|
||||
@@ -1381,7 +1391,9 @@ async fn tokio_main() -> Result<()> {
|
||||
{
|
||||
break;
|
||||
}
|
||||
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
|
||||
for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) {
|
||||
typing_channels.insert(ch, parent);
|
||||
}
|
||||
}
|
||||
Some(PoolEvent::Panic(join_error)) => {
|
||||
tracing::error!("agent task panicked: {join_error}");
|
||||
@@ -1401,7 +1413,9 @@ async fn tokio_main() -> Result<()> {
|
||||
tracing::error!("all agents dead — exiting");
|
||||
break;
|
||||
}
|
||||
typing_channels.extend(dispatch_pending(&mut pool, &mut queue, &ctx));
|
||||
for (ch, parent) in dispatch_pending(&mut pool, &mut queue, &ctx) {
|
||||
typing_channels.insert(ch, parent);
|
||||
}
|
||||
}
|
||||
None => {} // relay/heartbeat/shutdown branches handled inline above
|
||||
}
|
||||
@@ -1540,7 +1554,7 @@ fn dispatch_pending(
|
||||
pool: &mut AgentPool,
|
||||
queue: &mut EventQueue,
|
||||
ctx: &Arc<PromptContext>,
|
||||
) -> Vec<Uuid> {
|
||||
) -> Vec<(Uuid, Option<String>)> {
|
||||
let mut dispatched_channels = Vec::new();
|
||||
loop {
|
||||
let batch = match queue.flush_next() {
|
||||
@@ -1548,6 +1562,10 @@ fn dispatch_pending(
|
||||
None => break,
|
||||
};
|
||||
let channel_id = batch.channel_id;
|
||||
let reply_parent = batch
|
||||
.events
|
||||
.first()
|
||||
.and_then(|be| parse_thread_tags(&be.event).parent_event_id);
|
||||
let affinity_hit = pool.has_session_for(channel_id);
|
||||
let agent = match pool.try_claim(Some(channel_id)) {
|
||||
Some(a) => a,
|
||||
@@ -1595,7 +1613,7 @@ fn dispatch_pending(
|
||||
cancel_tx: Some(cancel_tx),
|
||||
},
|
||||
);
|
||||
dispatched_channels.push(channel_id);
|
||||
dispatched_channels.push((channel_id, reply_parent));
|
||||
}
|
||||
tracing::debug!(
|
||||
dispatched = dispatched_channels.len(),
|
||||
@@ -1779,7 +1797,7 @@ fn recover_panicked_agent(
|
||||
join_error: tokio::task::JoinError,
|
||||
heartbeat_in_flight: &mut bool,
|
||||
removed_channels: &HashSet<Uuid>,
|
||||
typing_channels: &mut HashSet<Uuid>,
|
||||
typing_channels: &mut HashMap<Uuid, Option<String>>,
|
||||
crash_history: &mut [SlotCircuit],
|
||||
respawn_tx: &mpsc::Sender<RespawnResult>,
|
||||
respawn_tasks: &mut tokio::task::JoinSet<()>,
|
||||
@@ -1863,7 +1881,7 @@ fn drain_ready_join_results(
|
||||
config: &Config,
|
||||
heartbeat_in_flight: &mut bool,
|
||||
removed_channels: &HashSet<Uuid>,
|
||||
typing_channels: &mut HashSet<Uuid>,
|
||||
typing_channels: &mut HashMap<Uuid, Option<String>>,
|
||||
crash_history: &mut [SlotCircuit],
|
||||
respawn_tx: &mpsc::Sender<RespawnResult>,
|
||||
respawn_tasks: &mut tokio::task::JoinSet<()>,
|
||||
|
||||
@@ -564,11 +564,30 @@ impl HarnessRelay {
|
||||
}
|
||||
|
||||
/// Build a typing indicator event (kind:20002) for a channel.
|
||||
pub fn build_typing_event(&self, channel_id: Uuid) -> Result<Event, RelayError> {
|
||||
///
|
||||
/// When `reply_parent_event_id_hex` is set, adds `["e", id, "", "reply"]` so
|
||||
/// clients can align typing with the composer's NIP-10 parent (matches desktop).
|
||||
pub fn build_typing_event(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
reply_parent_event_id_hex: Option<&str>,
|
||||
) -> Result<Event, RelayError> {
|
||||
let h_tag = Tag::parse(&["h", &channel_id.to_string()])
|
||||
.map_err(|e| RelayError::AuthFailed(e.to_string()))?;
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", [h_tag])
|
||||
.sign_with_keys(&self.keys)?;
|
||||
let event = match reply_parent_event_id_hex {
|
||||
Some(pid) => {
|
||||
let e_tag = Tag::parse(&["e", pid, "", "reply"])
|
||||
.map_err(|e| RelayError::AuthFailed(e.to_string()))?;
|
||||
EventBuilder::new(
|
||||
Kind::Custom(KIND_TYPING_INDICATOR as u16),
|
||||
"",
|
||||
[h_tag, e_tag],
|
||||
)
|
||||
.sign_with_keys(&self.keys)?
|
||||
}
|
||||
None => EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", [h_tag])
|
||||
.sign_with_keys(&self.keys)?,
|
||||
};
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const overrides = new Map([
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
|
||||
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
|
||||
["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
|
||||
["src/shared/api/relayClientSession.ts", 805], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator (optional e tag) + fetchChannelHistoryBefore
|
||||
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
|
||||
["src-tauri/src/lib.rs", 570], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + persona pack commands
|
||||
["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
@@ -54,6 +54,7 @@ const overrides = new Map([
|
||||
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
|
||||
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
|
||||
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
|
||||
["src/features/forum/ui/ForumThreadPanel.tsx", 520], // two-column thread + branch panel + scoped typing hook wiring
|
||||
["src/shared/api/types.ts", 540], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
use reqwest::Method;
|
||||
use reqwest::{header::CONTENT_TYPE, Method, StatusCode};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -245,6 +245,77 @@ pub async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
format!("relay returned {status}: {body}")
|
||||
}
|
||||
|
||||
fn summarize_response_body(body: &str) -> String {
|
||||
let trimmed = body.trim();
|
||||
let preview: String = trimmed.chars().take(160).collect();
|
||||
if trimmed.chars().count() > 160 {
|
||||
format!("{preview}...")
|
||||
} else {
|
||||
preview
|
||||
}
|
||||
}
|
||||
|
||||
fn cloudflare_access_error(final_url: &str, body: &str) -> Option<String> {
|
||||
let lower_body = body.to_ascii_lowercase();
|
||||
if final_url.contains("cloudflareaccess.com")
|
||||
|| lower_body.contains("warp client")
|
||||
|| lower_body.contains("cdn-cgi/access/login")
|
||||
{
|
||||
return Some(
|
||||
"Relay returned a Cloudflare Access login page instead of JSON. Connect via the Warp client or use a local relay (`just relay` + `just dev`).".to_string(),
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decode_json_body<T>(
|
||||
status: StatusCode,
|
||||
content_type: Option<&str>,
|
||||
final_url: &str,
|
||||
body: &str,
|
||||
operation: &str,
|
||||
) -> Result<T, String>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
if let Some(message) = cloudflare_access_error(final_url, body) {
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
serde_json::from_str::<T>(body).map_err(|error| {
|
||||
let content_type = content_type.unwrap_or("unknown");
|
||||
let preview = summarize_response_body(body);
|
||||
format!(
|
||||
"{operation} failed to decode JSON (status {status}, content-type {content_type}). Response preview: {preview}. Error: {error}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn parse_json_response<T>(response: reqwest::Response, operation: &str) -> Result<T, String>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let status = response.status();
|
||||
let final_url = response.url().to_string();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("{operation} failed to read response body: {error}"))?;
|
||||
|
||||
decode_json_body(
|
||||
status,
|
||||
content_type.as_deref(),
|
||||
&final_url,
|
||||
&body,
|
||||
operation,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn send_json_request<T>(request: reqwest::RequestBuilder) -> Result<T, String>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
@@ -258,10 +329,7 @@ where
|
||||
return Err(relay_error_message(response).await);
|
||||
}
|
||||
|
||||
response
|
||||
.json::<T>()
|
||||
.await
|
||||
.map_err(|error| format!("parse failed: {error}"))
|
||||
parse_json_response(response, "Relay response").await
|
||||
}
|
||||
|
||||
pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> {
|
||||
@@ -279,7 +347,14 @@ pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(),
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{api_path, validate_api_path};
|
||||
use super::{api_path, cloudflare_access_error, decode_json_body, validate_api_path};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct TestPayload {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_path_encodes_path_segments() {
|
||||
@@ -297,6 +372,60 @@ mod tests {
|
||||
fn validate_api_path_allows_encoded_segments() {
|
||||
assert!(validate_api_path("/api/tokens/..%2Fadmin").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloudflare_access_error_detects_login_redirect() {
|
||||
let message = cloudflare_access_error(
|
||||
"https://sqprod.cloudflareaccess.com/cdn-cgi/access/login/example",
|
||||
"Please authenticate via the warp client",
|
||||
);
|
||||
assert!(message.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_json_body_parses_valid_json() {
|
||||
let parsed: TestPayload = decode_json_body(
|
||||
StatusCode::OK,
|
||||
Some("application/json"),
|
||||
"https://sprout.example/api/channels",
|
||||
r#"{"ok":true}"#,
|
||||
"Relay response",
|
||||
)
|
||||
.expect("valid JSON should parse");
|
||||
|
||||
assert_eq!(parsed, TestPayload { ok: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_json_body_surfaces_cloudflare_hint() {
|
||||
let error = decode_json_body::<TestPayload>(
|
||||
StatusCode::OK,
|
||||
Some("text/html"),
|
||||
"https://sqprod.cloudflareaccess.com/cdn-cgi/access/login/example",
|
||||
"<html>Please authenticate via the warp client</html>",
|
||||
"Relay response",
|
||||
)
|
||||
.expect_err("Cloudflare Access HTML should not parse");
|
||||
|
||||
assert!(error.contains("Cloudflare Access"));
|
||||
assert!(error.contains("Warp client"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_json_body_includes_preview_for_non_json() {
|
||||
let error = decode_json_body::<TestPayload>(
|
||||
StatusCode::OK,
|
||||
Some("text/plain"),
|
||||
"https://sprout.example/api/channels",
|
||||
"not json",
|
||||
"Relay response",
|
||||
)
|
||||
.expect_err("plain text should fail");
|
||||
|
||||
assert!(error.contains("failed to decode JSON"));
|
||||
assert!(error.contains("content-type text/plain"));
|
||||
assert!(error.contains("Response preview: not json"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Signed-event submission ──────────────────────────────────────────────────
|
||||
@@ -352,10 +481,8 @@ pub async fn submit_event(
|
||||
return Err(relay_error_message(response).await);
|
||||
}
|
||||
|
||||
let result: SubmitEventResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {e}"))?;
|
||||
let result: SubmitEventResponse =
|
||||
parse_json_response(response, "Relay submit response").await?;
|
||||
|
||||
if !result.accepted {
|
||||
return Err(format!("relay rejected event: {}", result.message));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { MessageThreadSidebar } from "@/features/messages/ui/MessageThreadSidebar";
|
||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
@@ -10,6 +12,7 @@ import type { Channel } from "@/shared/api/types";
|
||||
type ChannelPaneProps = {
|
||||
activeChannel: Channel | null;
|
||||
currentPubkey?: string;
|
||||
collapsedThreadSummaryByMessageId?: Map<string, CollapsedThreadPreview>;
|
||||
editTarget?: {
|
||||
author: string;
|
||||
body: string;
|
||||
@@ -21,11 +24,15 @@ type ChannelPaneProps = {
|
||||
isSending: boolean;
|
||||
isTimelineLoading: boolean;
|
||||
messages: TimelineMessage[];
|
||||
activeReplyTargetId: string | null;
|
||||
onCancelEdit?: () => void;
|
||||
onCancelReply: () => void;
|
||||
onCancelThreadReply: () => void;
|
||||
onCloseThread: () => void;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onEditSave?: (content: string) => Promise<void>;
|
||||
onOpenThread?: (message: TimelineMessage) => void;
|
||||
onReply: (message: TimelineMessage) => void;
|
||||
onSend: (
|
||||
content: string,
|
||||
@@ -33,6 +40,12 @@ type ChannelPaneProps = {
|
||||
mediaTags?: string[][],
|
||||
) => Promise<void>;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
onThreadReply: (message: TimelineMessage) => void;
|
||||
onThreadSend: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => Promise<void>;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
@@ -41,14 +54,18 @@ type ChannelPaneProps = {
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
replyTargetId: string | null;
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
targetMessageId: string | null;
|
||||
threadHeadMessage: TimelineMessage | null;
|
||||
threadMessages: TimelineMessage[];
|
||||
threadReplyTargetMessage: TimelineMessage | null;
|
||||
threadTypingPubkeys: string[];
|
||||
typingPubkeys: string[];
|
||||
};
|
||||
|
||||
export const ChannelPane = React.memo(function ChannelPane({
|
||||
activeChannel,
|
||||
activeReplyTargetId,
|
||||
currentPubkey,
|
||||
editTarget = null,
|
||||
fetchOlder,
|
||||
@@ -57,97 +74,131 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
isSending,
|
||||
isTimelineLoading,
|
||||
messages,
|
||||
collapsedThreadSummaryByMessageId,
|
||||
onCancelEdit,
|
||||
onCancelReply,
|
||||
onCancelThreadReply,
|
||||
onCloseThread,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onEditSave,
|
||||
onOpenThread,
|
||||
onReply,
|
||||
onSend,
|
||||
onTargetReached,
|
||||
onThreadReply,
|
||||
onThreadSend,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
profiles,
|
||||
replyTargetId,
|
||||
replyTargetMessage,
|
||||
targetMessageId,
|
||||
threadHeadMessage,
|
||||
threadMessages,
|
||||
threadReplyTargetMessage,
|
||||
threadTypingPubkeys,
|
||||
typingPubkeys,
|
||||
}: ChannelPaneProps) {
|
||||
const composerDisabled =
|
||||
!activeChannel ||
|
||||
!activeChannel.isMember ||
|
||||
activeChannel.archivedAt !== null ||
|
||||
activeChannel.channelType === "forum" ||
|
||||
isSending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MessageTimeline
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={replyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
: "Messages and sub-replies will appear here once the relay has history for this channel."
|
||||
}
|
||||
emptyTitle={
|
||||
activeChannel
|
||||
? activeChannel.channelType === "forum"
|
||||
? "Forum channels are next"
|
||||
: "No messages yet"
|
||||
: "No channel selected"
|
||||
}
|
||||
isLoading={isTimelineLoading}
|
||||
messages={messages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onReply={onReply}
|
||||
onTargetReached={onTargetReached}
|
||||
onToggleReaction={onToggleReaction}
|
||||
targetMessageId={targetMessageId}
|
||||
/>
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={
|
||||
!activeChannel ||
|
||||
!activeChannel.isMember ||
|
||||
activeChannel.archivedAt !== null ||
|
||||
activeChannel.channelType === "forum" ||
|
||||
isSending
|
||||
}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelReply}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={
|
||||
activeChannel?.archivedAt
|
||||
? "Archived channels are read-only."
|
||||
: activeChannel && !activeChannel.isMember
|
||||
? "Join this channel to message."
|
||||
: activeChannel?.channelType === "forum"
|
||||
? "Forum posting is not wired in this pass."
|
||||
: activeChannel
|
||||
? `Message #${activeChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
replyTarget={
|
||||
replyTargetMessage
|
||||
? {
|
||||
author: replyTargetMessage.author,
|
||||
body: replyTargetMessage.body,
|
||||
id: replyTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<MessageTimeline
|
||||
channelId={activeChannel?.id}
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
collapsedThreadSummaryByMessageId={collapsedThreadSummaryByMessageId}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
emptyDescription={
|
||||
activeChannel?.channelType === "forum"
|
||||
? "Select a stream or DM to load real message history in this first integration pass."
|
||||
: "Messages and sub-replies will appear here once the relay has history for this channel."
|
||||
}
|
||||
emptyTitle={
|
||||
activeChannel
|
||||
? activeChannel.channelType === "forum"
|
||||
? "Forum channels are next"
|
||||
: "No messages yet"
|
||||
: "No channel selected"
|
||||
}
|
||||
isLoading={isTimelineLoading}
|
||||
messages={messages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onOpenThread={onOpenThread}
|
||||
onReply={onReply}
|
||||
onTargetReached={onTargetReached}
|
||||
onToggleReaction={onToggleReaction}
|
||||
targetMessageId={targetMessageId}
|
||||
/>
|
||||
<TypingIndicatorRow
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
<MessageComposer
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
disabled={composerDisabled}
|
||||
editTarget={editTarget}
|
||||
isSending={isSending}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelReply}
|
||||
onEditSave={onEditSave}
|
||||
onSend={onSend}
|
||||
placeholder={
|
||||
activeChannel?.archivedAt
|
||||
? "Archived channels are read-only."
|
||||
: activeChannel && !activeChannel.isMember
|
||||
? "Join this channel to message."
|
||||
: activeChannel?.channelType === "forum"
|
||||
? "Forum posting is not wired in this pass."
|
||||
: activeChannel
|
||||
? `Message #${activeChannel.name}`
|
||||
: "Select a channel"
|
||||
}
|
||||
replyTarget={
|
||||
replyTargetMessage
|
||||
? {
|
||||
author: replyTargetMessage.author,
|
||||
body: replyTargetMessage.body,
|
||||
id: replyTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{threadHeadMessage ? (
|
||||
<MessageThreadSidebar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
headMessage={threadHeadMessage}
|
||||
isSending={isSending}
|
||||
messages={threadMessages}
|
||||
onCancelReply={onCancelThreadReply}
|
||||
onClose={onCloseThread}
|
||||
onDelete={onDelete}
|
||||
onReply={onThreadReply}
|
||||
onSend={onThreadSend}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
replyTargetMessage={threadReplyTargetMessage}
|
||||
typingPubkeys={threadTypingPubkeys}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,12 +27,15 @@ import {
|
||||
collectMessageAuthorPubkeys,
|
||||
formatTimelineMessages,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { buildCollapsedThreadTimeline } from "@/features/messages/lib/collapsedThreads";
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getThreadReference,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { collectThreadBranch } from "@/features/messages/lib/threadBranch";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
|
||||
import { useChannelTyping } from "@/features/messages/useChannelTyping";
|
||||
import { useChannelThreadTyping } from "@/features/messages/useChannelThreadTyping";
|
||||
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
@@ -83,6 +86,12 @@ export function ChannelScreen({
|
||||
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
||||
const [focusedThreadHeadId, setFocusedThreadHeadId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [threadReplyTargetId, setThreadReplyTargetId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const currentPubkey = currentIdentity?.pubkey;
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
|
||||
@@ -134,14 +143,61 @@ export function ChannelScreen({
|
||||
() => resolvedMessages[resolvedMessages.length - 1] ?? null,
|
||||
[resolvedMessages],
|
||||
);
|
||||
const typingPubkeys = useChannelTyping(
|
||||
activeChannel,
|
||||
currentPubkey,
|
||||
latestMessageEvent,
|
||||
);
|
||||
const openThreadMessageIds = React.useMemo(() => {
|
||||
if (!focusedThreadHeadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const event of resolvedMessages) {
|
||||
const thread = getThreadReference(event.tags);
|
||||
if (!thread.parentId) {
|
||||
continue;
|
||||
}
|
||||
const children = childrenByParent.get(thread.parentId) ?? [];
|
||||
children.push(event.id);
|
||||
childrenByParent.set(thread.parentId, children);
|
||||
}
|
||||
|
||||
const ids = new Set<string>([focusedThreadHeadId]);
|
||||
const stack = [focusedThreadHeadId];
|
||||
while (stack.length > 0) {
|
||||
const parentId = stack.pop();
|
||||
if (!parentId) {
|
||||
continue;
|
||||
}
|
||||
const children = childrenByParent.get(parentId) ?? [];
|
||||
for (const childId of children) {
|
||||
if (ids.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
ids.add(childId);
|
||||
stack.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}, [focusedThreadHeadId, resolvedMessages]);
|
||||
const { mainComposerTypingPubkeys, threadComposerTypingPubkeys } =
|
||||
useChannelThreadTyping(
|
||||
activeChannel,
|
||||
currentPubkey,
|
||||
latestMessageEvent,
|
||||
openThreadMessageIds,
|
||||
);
|
||||
const messageProfilePubkeys = React.useMemo(
|
||||
() => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])],
|
||||
[messageAuthorPubkeys, typingPubkeys],
|
||||
() => [
|
||||
...new Set([
|
||||
...messageAuthorPubkeys,
|
||||
...mainComposerTypingPubkeys,
|
||||
...threadComposerTypingPubkeys,
|
||||
]),
|
||||
],
|
||||
[
|
||||
messageAuthorPubkeys,
|
||||
mainComposerTypingPubkeys,
|
||||
threadComposerTypingPubkeys,
|
||||
],
|
||||
);
|
||||
const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, {
|
||||
enabled: messageProfilePubkeys.length > 0,
|
||||
@@ -213,16 +269,44 @@ export function ChannelScreen({
|
||||
resolvedMessages,
|
||||
],
|
||||
);
|
||||
const messageById = React.useMemo(
|
||||
() => new Map(timelineMessages.map((message) => [message.id, message])),
|
||||
[timelineMessages],
|
||||
);
|
||||
const replyTargetMessage = React.useMemo(
|
||||
() =>
|
||||
timelineMessages.find((message) => message.id === replyTargetId) ?? null,
|
||||
[replyTargetId, timelineMessages],
|
||||
() => (replyTargetId ? (messageById.get(replyTargetId) ?? null) : null),
|
||||
[messageById, replyTargetId],
|
||||
);
|
||||
const editTargetMessage = React.useMemo(
|
||||
() =>
|
||||
timelineMessages.find((message) => message.id === editTargetId) ?? null,
|
||||
[editTargetId, timelineMessages],
|
||||
() => (editTargetId ? (messageById.get(editTargetId) ?? null) : null),
|
||||
[editTargetId, messageById],
|
||||
);
|
||||
const threadBranch = React.useMemo(
|
||||
() => collectThreadBranch(timelineMessages, focusedThreadHeadId),
|
||||
[focusedThreadHeadId, timelineMessages],
|
||||
);
|
||||
const {
|
||||
summaryByMessageId: collapsedThreadSummaryByMessageId,
|
||||
visibleMessages,
|
||||
} = React.useMemo(
|
||||
() => buildCollapsedThreadTimeline(timelineMessages),
|
||||
[timelineMessages],
|
||||
);
|
||||
const { headMessage: threadHeadMessage, messageIds: threadMessageIds } =
|
||||
threadBranch;
|
||||
const threadMessages = threadBranch.messages;
|
||||
const threadReplyTargetMessage = React.useMemo(() => {
|
||||
if (
|
||||
!threadReplyTargetId ||
|
||||
!threadHeadMessage ||
|
||||
threadReplyTargetId === threadHeadMessage.id ||
|
||||
!threadMessageIds.has(threadReplyTargetId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return messageById.get(threadReplyTargetId) ?? null;
|
||||
}, [messageById, threadHeadMessage, threadMessageIds, threadReplyTargetId]);
|
||||
|
||||
const {
|
||||
handleCancelEdit,
|
||||
@@ -230,7 +314,6 @@ export function ChannelScreen({
|
||||
handleDelete,
|
||||
handleEdit,
|
||||
handleEditSave,
|
||||
handleReply,
|
||||
handleSend,
|
||||
handleToggleReaction,
|
||||
} = useChannelPaneHandlers({
|
||||
@@ -243,6 +326,63 @@ export function ChannelScreen({
|
||||
setReplyTargetId,
|
||||
toggleReactionMutation,
|
||||
});
|
||||
const handleCloseThread = React.useCallback(() => {
|
||||
setFocusedThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
}, []);
|
||||
const handleReply = React.useCallback((message: TimelineMessage) => {
|
||||
if (message.depth < 1) {
|
||||
setFocusedThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
setReplyTargetId((current) =>
|
||||
current === message.id ? null : message.id,
|
||||
);
|
||||
setEditTargetId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setReplyTargetId(null);
|
||||
setEditTargetId(null);
|
||||
setFocusedThreadHeadId(message.id);
|
||||
setThreadReplyTargetId(message.id);
|
||||
}, []);
|
||||
const handleOpenThread = React.useCallback((message: TimelineMessage) => {
|
||||
setReplyTargetId(null);
|
||||
setEditTargetId(null);
|
||||
setFocusedThreadHeadId(message.id);
|
||||
setThreadReplyTargetId(message.id);
|
||||
}, []);
|
||||
const handleThreadReply = React.useCallback((message: TimelineMessage) => {
|
||||
setThreadReplyTargetId(message.id);
|
||||
}, []);
|
||||
const handleCancelThreadReply = React.useCallback(() => {
|
||||
setThreadReplyTargetId((current) => focusedThreadHeadId ?? current);
|
||||
}, [focusedThreadHeadId]);
|
||||
const handleThreadSend = React.useCallback(
|
||||
async (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => {
|
||||
const parentEventId = threadReplyTargetId ?? focusedThreadHeadId;
|
||||
if (!parentEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendMessageMutation.mutateAsync({
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
parentEventId,
|
||||
});
|
||||
setThreadReplyTargetId(focusedThreadHeadId ?? parentEventId);
|
||||
},
|
||||
[focusedThreadHeadId, sendMessageMutation, threadReplyTargetId],
|
||||
);
|
||||
const activeReplyTargetId =
|
||||
focusedThreadHeadId && threadReplyTargetId !== focusedThreadHeadId
|
||||
? threadReplyTargetId
|
||||
: replyTargetId;
|
||||
|
||||
const canReact = activeChannel !== null && activeChannel.archivedAt === null;
|
||||
const effectiveToggleReaction = React.useMemo(
|
||||
@@ -275,6 +415,8 @@ export function ChannelScreen({
|
||||
(_channelId: string | null) => {
|
||||
setReplyTargetId(null);
|
||||
setEditTargetId(null);
|
||||
setFocusedThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -296,12 +438,38 @@ export function ChannelScreen({
|
||||
if (editTargetId && !editTargetMessage) {
|
||||
setEditTargetId(null);
|
||||
}
|
||||
}, [editTargetId, editTargetMessage, replyTargetId, replyTargetMessage]);
|
||||
if (focusedThreadHeadId && !threadHeadMessage) {
|
||||
setFocusedThreadHeadId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
} else if (
|
||||
threadReplyTargetId &&
|
||||
focusedThreadHeadId &&
|
||||
threadReplyTargetId !== focusedThreadHeadId &&
|
||||
!threadReplyTargetMessage
|
||||
) {
|
||||
setThreadReplyTargetId(focusedThreadHeadId);
|
||||
}
|
||||
}, [
|
||||
editTargetId,
|
||||
editTargetMessage,
|
||||
focusedThreadHeadId,
|
||||
replyTargetId,
|
||||
replyTargetMessage,
|
||||
threadHeadMessage,
|
||||
threadReplyTargetId,
|
||||
threadReplyTargetMessage,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
resetRequestedAncestors(activeChannelId);
|
||||
}, [activeChannelId, resetRequestedAncestors]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (threadHeadMessage && !threadReplyTargetId) {
|
||||
setThreadReplyTargetId(threadHeadMessage.id);
|
||||
}
|
||||
}, [threadHeadMessage, threadReplyTargetId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeChannel || activeChannel.channelType === "forum") {
|
||||
return;
|
||||
@@ -437,6 +605,7 @@ export function ChannelScreen({
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
@@ -452,21 +621,32 @@ export function ChannelScreen({
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
messages={visibleMessages}
|
||||
onOpenThread={handleOpenThread}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={handleCancelReply}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onThreadReply={handleThreadReply}
|
||||
onThreadSend={handleThreadSend}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
collapsedThreadSummaryByMessageId={
|
||||
collapsedThreadSummaryByMessageId
|
||||
}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={targetMessageId}
|
||||
typingPubkeys={typingPubkeys}
|
||||
threadHeadMessage={threadHeadMessage}
|
||||
threadMessages={threadMessages}
|
||||
threadReplyTargetMessage={threadReplyTargetMessage}
|
||||
threadTypingPubkeys={threadComposerTypingPubkeys}
|
||||
typingPubkeys={mainComposerTypingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
|
||||
@@ -129,6 +129,7 @@ export function useCreateForumReplyMutation(channel: Channel | null) {
|
||||
}: {
|
||||
content: string;
|
||||
parentEventId: string;
|
||||
rootEventId: string;
|
||||
mentionPubkeys?: string[];
|
||||
}) => {
|
||||
if (!channel) {
|
||||
@@ -147,7 +148,7 @@ export function useCreateForumReplyMutation(channel: Channel | null) {
|
||||
onSuccess: (_data, variables) => {
|
||||
if (channel) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumThreadQueryKey(channel.id, variables.parentEventId),
|
||||
queryKey: forumThreadQueryKey(channel.id, variables.rootEventId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: forumPostsQueryKey(channel.id),
|
||||
|
||||
45
desktop/src/features/forum/lib/threadTree.ts
Normal file
45
desktop/src/features/forum/lib/threadTree.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ThreadReply } from "@/shared/api/types";
|
||||
|
||||
export type ThreadNode = ThreadReply & { children: ThreadNode[] };
|
||||
|
||||
/**
|
||||
* Groups flat thread replies into a tree by NIP-10 parent (`parentEventId`),
|
||||
* attaching orphans to `rootPostId`.
|
||||
*/
|
||||
export function buildReplyTree(
|
||||
replies: ThreadReply[],
|
||||
rootPostId: string,
|
||||
): ThreadNode[] {
|
||||
const byParent = new Map<string, ThreadReply[]>();
|
||||
for (const reply of replies) {
|
||||
const pid = reply.parentEventId ?? rootPostId;
|
||||
const list = byParent.get(pid) ?? [];
|
||||
list.push(reply);
|
||||
byParent.set(pid, list);
|
||||
}
|
||||
|
||||
function nest(parentId: string): ThreadNode[] {
|
||||
return (byParent.get(parentId) ?? []).map((reply) => ({
|
||||
...reply,
|
||||
children: nest(reply.eventId),
|
||||
}));
|
||||
}
|
||||
|
||||
return nest(rootPostId);
|
||||
}
|
||||
|
||||
export function findNodeInTree(
|
||||
nodes: ThreadNode[],
|
||||
id: string,
|
||||
): ThreadNode | null {
|
||||
for (const n of nodes) {
|
||||
if (n.eventId === id) {
|
||||
return n;
|
||||
}
|
||||
const inner = findNodeInTree(n.children, id);
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
|
||||
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import { useMentions } from "@/features/messages/lib/useMentions";
|
||||
import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
|
||||
import { MentionAutocomplete } from "@/features/messages/ui/MentionAutocomplete";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -15,6 +16,11 @@ type ForumComposerProps = {
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
onCancel?: () => void;
|
||||
/** When set, shows a compact “replying to …” strip with cancel. */
|
||||
replyToAuthorLabel?: string | null;
|
||||
onCancelReplyTo?: () => void;
|
||||
/** Passed to kind 20002 as the `e` reply target — same as the next message parent. */
|
||||
typingReplyParentId?: string | null;
|
||||
onSubmit: (content: string, mentionPubkeys: string[]) => void;
|
||||
};
|
||||
|
||||
@@ -25,6 +31,9 @@ export function ForumComposer({
|
||||
disabled,
|
||||
isSending,
|
||||
onCancel,
|
||||
replyToAuthorLabel = null,
|
||||
onCancelReplyTo,
|
||||
typingReplyParentId = null,
|
||||
onSubmit,
|
||||
}: ForumComposerProps) {
|
||||
const [value, setValue] = React.useState("");
|
||||
@@ -32,6 +41,7 @@ export function ForumComposer({
|
||||
|
||||
const mentions = useMentions(channelId);
|
||||
const channelLinks = useChannelLinks();
|
||||
const notifyTyping = useTypingBroadcast(channelId, typingReplyParentId);
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
@@ -49,6 +59,9 @@ export function ForumComposer({
|
||||
setValue(next);
|
||||
mentions.updateMentionQuery(next, event.target.selectionStart);
|
||||
channelLinks.updateChannelQuery(next, event.target.selectionStart);
|
||||
if (next.trim().length > 0) {
|
||||
notifyTyping();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
@@ -105,6 +118,27 @@ export function ForumComposer({
|
||||
|
||||
return (
|
||||
<form className="relative flex flex-col gap-2" onSubmit={handleSubmit}>
|
||||
{replyToAuthorLabel ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border/60 bg-muted/40 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">
|
||||
Replying to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{replyToAuthorLabel}
|
||||
</span>
|
||||
</span>
|
||||
{onCancelReplyTo ? (
|
||||
<Button
|
||||
className="h-6 shrink-0 px-2"
|
||||
onClick={onCancelReplyTo}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ChannelAutocomplete
|
||||
onSelect={(suggestion) => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ArrowLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
@@ -10,16 +16,6 @@ import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -30,18 +26,36 @@ import {
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import {
|
||||
buildReplyTree,
|
||||
findNodeInTree,
|
||||
type ThreadNode,
|
||||
} from "../lib/threadTree";
|
||||
import { formatRelativeTime } from "../lib/time";
|
||||
import { useForumThreadTyping } from "../useForumThreadTyping";
|
||||
import { ForumComposer } from "./ForumComposer";
|
||||
import {
|
||||
BranchReplyList,
|
||||
DeleteConfirmDialog,
|
||||
ForumTypingStrip,
|
||||
MainColumnReplyRow,
|
||||
} from "./ForumThreadPanelSections";
|
||||
|
||||
type ForumThreadPanelProps = {
|
||||
thread: ForumThreadResponse | undefined;
|
||||
isLoading: boolean;
|
||||
isSendingReply: boolean;
|
||||
channelId: string;
|
||||
/** Thread root (forum post) event id — used for reply tree and cache keys. */
|
||||
threadRootEventId: string;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
onBack: () => void;
|
||||
onReply: (content: string, mentionPubkeys: string[]) => void;
|
||||
onReply: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
parentEventId: string,
|
||||
) => void;
|
||||
onDeletePost?: (eventId: string) => void;
|
||||
onDeleteReply?: (eventId: string) => void;
|
||||
onTargetReached?: (eventId: string) => void;
|
||||
@@ -50,138 +64,12 @@ type ForumThreadPanelProps = {
|
||||
targetEventId?: string | null;
|
||||
};
|
||||
|
||||
function canDeleteReply(
|
||||
reply: ThreadReply,
|
||||
currentPubkey: string | undefined,
|
||||
): boolean {
|
||||
if (!currentPubkey) return false;
|
||||
return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
label,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {label}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this {label} and cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={onConfirm} type="button" variant="destructive">
|
||||
Delete {label}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyRow({
|
||||
reply,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
channelNames,
|
||||
onDelete,
|
||||
}: {
|
||||
reply: ThreadReply;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
channelNames?: string[];
|
||||
onDelete?: (eventId: string) => void;
|
||||
}) {
|
||||
const [isDeleteOpen, setIsDeleteOpen] = React.useState(false);
|
||||
const replyAuthorLabel = resolveUserLabel({
|
||||
pubkey: reply.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const replyAvatarUrl =
|
||||
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
|
||||
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
|
||||
|
||||
return (
|
||||
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
displayName={replyAuthorLabel}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{replyAuthorLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(reply.createdAt)}
|
||||
</span>
|
||||
|
||||
{showDelete ? (
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete reply
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteConfirmDialog
|
||||
label="reply"
|
||||
onConfirm={() => onDelete(reply.eventId)}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
open={isDeleteOpen}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1.5 pl-8">
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
compact
|
||||
content={reply.content}
|
||||
mentionNames={replyMentionNames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumThreadPanel({
|
||||
thread,
|
||||
isLoading,
|
||||
isSendingReply,
|
||||
channelId,
|
||||
threadRootEventId,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
onBack,
|
||||
@@ -195,6 +83,17 @@ export function ForumThreadPanel({
|
||||
}: ForumThreadPanelProps) {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false);
|
||||
const [focusedBranchId, setFocusedBranchId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [branchReplyParentId, setBranchReplyParentId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
/** When set, main composer replies to this event id (else thread root). */
|
||||
const [mainReplyParentId, setMainReplyParentId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { channels } = useChannelNavigation();
|
||||
const channelNames = React.useMemo(
|
||||
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
|
||||
@@ -218,6 +117,78 @@ export function ForumThreadPanel({
|
||||
onTargetReached?.(targetEventId);
|
||||
}, [onTargetReached, targetEventId, thread]);
|
||||
|
||||
const tree = React.useMemo(() => {
|
||||
if (!thread) {
|
||||
return [];
|
||||
}
|
||||
return buildReplyTree(thread.replies, threadRootEventId);
|
||||
}, [thread, threadRootEventId]);
|
||||
|
||||
const branchNodes = React.useMemo(() => {
|
||||
if (!focusedBranchId || tree.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const stack = [...tree];
|
||||
while (stack.length > 0) {
|
||||
const n = stack.pop();
|
||||
if (!n) break;
|
||||
if (n.eventId === focusedBranchId) {
|
||||
return n.children;
|
||||
}
|
||||
for (const c of n.children) {
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [focusedBranchId, tree]);
|
||||
|
||||
const focusedHead = React.useMemo(() => {
|
||||
if (!focusedBranchId || !thread) {
|
||||
return null;
|
||||
}
|
||||
const findIn = (nodes: ThreadNode[]): ThreadNode | null => {
|
||||
for (const n of nodes) {
|
||||
if (n.eventId === focusedBranchId) {
|
||||
return n;
|
||||
}
|
||||
const inner = findIn(n.children);
|
||||
if (inner) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return findIn(tree);
|
||||
}, [focusedBranchId, thread, tree]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focusedBranchId) {
|
||||
setBranchReplyParentId((prev) => prev ?? focusedBranchId);
|
||||
} else {
|
||||
setBranchReplyParentId(null);
|
||||
}
|
||||
}, [focusedBranchId]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset reply target when opening a different thread
|
||||
React.useEffect(() => {
|
||||
setMainReplyParentId(null);
|
||||
}, [threadRootEventId]);
|
||||
|
||||
const effectiveMainParentId = mainReplyParentId ?? threadRootEventId;
|
||||
const branchTypingParentForHook =
|
||||
focusedBranchId !== null ? (branchReplyParentId ?? focusedBranchId) : null;
|
||||
|
||||
const { branchComposerTypingPubkeys, mainComposerTypingPubkeys } =
|
||||
useForumThreadTyping(
|
||||
channelId,
|
||||
currentPubkey,
|
||||
Boolean(!isLoading && thread),
|
||||
threadRootEventId,
|
||||
effectiveMainParentId,
|
||||
focusedBranchId !== null,
|
||||
branchTypingParentForHook,
|
||||
);
|
||||
|
||||
if (isLoading || !thread) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -252,8 +223,54 @@ export function ForumThreadPanel({
|
||||
const postAvatarUrl =
|
||||
profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
|
||||
/** Open the right thread column and reply under this comment (Slack-style: Reply opens the panel). */
|
||||
const handleOpenThreadForReply = (reply: ThreadReply) => {
|
||||
setFocusedBranchId(reply.eventId);
|
||||
setBranchReplyParentId(reply.eventId);
|
||||
setMainReplyParentId(null);
|
||||
};
|
||||
|
||||
const handlePressReplyBranch = (reply: ThreadReply) => {
|
||||
setBranchReplyParentId(reply.eventId);
|
||||
};
|
||||
|
||||
const branchHeadLabel = focusedHead
|
||||
? resolveUserLabel({
|
||||
pubkey: focusedHead.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
const mainReplyParent = mainReplyParentId
|
||||
? findNodeInTree(tree, mainReplyParentId)
|
||||
: null;
|
||||
const mainReplyAuthorLabel = mainReplyParent
|
||||
? resolveUserLabel({
|
||||
pubkey: mainReplyParent.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
const branchReplyParentNode =
|
||||
branchReplyParentId && branchReplyParentId !== focusedBranchId
|
||||
? findNodeInTree(tree, branchReplyParentId)
|
||||
: null;
|
||||
const branchReplyAuthorLabel =
|
||||
branchReplyParentNode && focusedBranchId
|
||||
? resolveUserLabel({
|
||||
pubkey: branchReplyParentNode.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border/60 px-4 py-3">
|
||||
<Button
|
||||
className="gap-1.5 text-muted-foreground"
|
||||
@@ -266,108 +283,214 @@ export function ForumThreadPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
data-scroll-restoration-id={`forum-thread:${channelId}`}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* Original post */}
|
||||
<div
|
||||
className={cn(
|
||||
"group border-b border-border/60 p-4",
|
||||
isDeletingPost && "pointer-events-none opacity-50",
|
||||
)}
|
||||
data-forum-event-id={post.eventId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={postAvatarUrl}
|
||||
displayName={postAuthorLabel}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{postAuthorLabel}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<div className="flex min-w-0 min-h-0 flex-1 flex-col" ref={scrollRef}>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto"
|
||||
data-scroll-restoration-id={`forum-thread:${channelId}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group border-b border-border/60 p-4",
|
||||
isDeletingPost && "pointer-events-none opacity-50",
|
||||
)}
|
||||
data-forum-event-id={post.eventId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={postAvatarUrl}
|
||||
displayName={postAuthorLabel}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{postAuthorLabel}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canDeletePost && onDeletePost ? (
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeletePostOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete post
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteConfirmDialog
|
||||
label="post"
|
||||
onConfirm={() => onDeletePost(post.eventId)}
|
||||
onOpenChange={setIsDeletePostOpen}
|
||||
open={isDeletePostOpen}
|
||||
{canDeletePost && onDeletePost ? (
|
||||
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeletePostOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete post
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DeleteConfirmDialog
|
||||
label="post"
|
||||
onConfirm={() => onDeletePost(post.eventId)}
|
||||
onOpenChange={setIsDeletePostOpen}
|
||||
open={isDeletePostOpen}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
content={post.content}
|
||||
mentionNames={postMentionNames}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
content={post.content}
|
||||
mentionNames={postMentionNames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replies header */}
|
||||
<div className="flex items-center gap-1.5 border-b border-border/60 px-4 py-2.5 text-sm font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{replies.length} {replies.length === 1 ? "reply" : "replies"}
|
||||
</div>
|
||||
|
||||
{/* Reply list */}
|
||||
<div className="divide-y divide-border/40">
|
||||
{replies.map((reply) => (
|
||||
<ReplyRow
|
||||
channelNames={channelNames}
|
||||
currentPubkey={currentPubkey}
|
||||
key={reply.eventId}
|
||||
onDelete={onDeleteReply}
|
||||
profiles={profiles}
|
||||
reply={reply}
|
||||
/>
|
||||
))}
|
||||
|
||||
{replies.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No replies yet. Be the first to respond.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reply composer */}
|
||||
<div className="border-t border-border/60 p-4">
|
||||
<ForumComposer
|
||||
channelId={channelId}
|
||||
isSending={isSendingReply}
|
||||
onSubmit={onReply}
|
||||
placeholder="Reply to this post..."
|
||||
submitLabel="Reply"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 border-b border-border/60 px-4 py-2.5 text-sm font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
{replies.length} {replies.length === 1 ? "reply" : "replies"}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border/40">
|
||||
{tree.map((node) => (
|
||||
<MainColumnReplyRow
|
||||
channelNames={channelNames}
|
||||
currentPubkey={currentPubkey}
|
||||
key={node.eventId}
|
||||
node={node}
|
||||
onDelete={onDeleteReply}
|
||||
onOpenBranch={handleOpenThreadForReply}
|
||||
onPressReply={handleOpenThreadForReply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
|
||||
{replies.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No replies yet. Be the first to respond.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border/60 p-4">
|
||||
{mainComposerTypingPubkeys.length > 0 ? (
|
||||
<ForumTypingStrip
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={mainComposerTypingPubkeys}
|
||||
/>
|
||||
) : null}
|
||||
<ForumComposer
|
||||
channelId={channelId}
|
||||
isSending={isSendingReply}
|
||||
onCancelReplyTo={
|
||||
mainReplyAuthorLabel
|
||||
? () => {
|
||||
setMainReplyParentId(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSubmit={(content, mentionPubkeys) => {
|
||||
onReply(content, mentionPubkeys, effectiveMainParentId);
|
||||
setMainReplyParentId(null);
|
||||
}}
|
||||
placeholder="Reply to this post..."
|
||||
replyToAuthorLabel={mainReplyAuthorLabel}
|
||||
submitLabel="Reply"
|
||||
typingReplyParentId={effectiveMainParentId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{focusedBranchId && focusedHead ? (
|
||||
<aside className="flex w-[min(100%,24rem)] shrink-0 flex-col border-l border-border/60 bg-muted/20">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground">
|
||||
Thread with {branchHeadLabel}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Close thread panel"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setFocusedBranchId(null);
|
||||
setBranchReplyParentId(null);
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
className="border-b border-border/60 bg-background/50 px-3 py-2"
|
||||
data-forum-event-id={focusedHead.eventId}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">Root of thread</p>
|
||||
<div className="mt-1 text-sm text-foreground">
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
compact
|
||||
content={focusedHead.content}
|
||||
mentionNames={resolveMentionNames(
|
||||
focusedHead.tags,
|
||||
profiles,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{branchNodes.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No replies in this thread yet.
|
||||
</div>
|
||||
) : (
|
||||
<BranchReplyList
|
||||
channelNames={channelNames}
|
||||
currentPubkey={currentPubkey}
|
||||
nodes={branchNodes}
|
||||
onDelete={onDeleteReply}
|
||||
onPressReply={handlePressReplyBranch}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-border/60 p-3">
|
||||
{branchComposerTypingPubkeys.length > 0 ? (
|
||||
<ForumTypingStrip
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={branchComposerTypingPubkeys}
|
||||
/>
|
||||
) : null}
|
||||
<ForumComposer
|
||||
channelId={channelId}
|
||||
isSending={isSendingReply}
|
||||
onCancelReplyTo={
|
||||
branchReplyAuthorLabel
|
||||
? () => {
|
||||
setBranchReplyParentId(focusedBranchId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSubmit={(content, mentionPubkeys) => {
|
||||
const parent = branchReplyParentId ?? focusedBranchId;
|
||||
onReply(content, mentionPubkeys, parent);
|
||||
}}
|
||||
placeholder="Reply in thread..."
|
||||
replyToAuthorLabel={branchReplyAuthorLabel}
|
||||
submitLabel="Reply"
|
||||
typingReplyParentId={
|
||||
branchReplyParentId ?? focusedBranchId ?? null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
418
desktop/src/features/forum/ui/ForumThreadPanelSections.tsx
Normal file
418
desktop/src/features/forum/ui/ForumThreadPanelSections.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
CornerUpLeft,
|
||||
MoreHorizontal,
|
||||
PanelRightClose,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type { ThreadReply } from "@/shared/api/types";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
|
||||
import type { ThreadNode } from "../lib/threadTree";
|
||||
import { formatRelativeTime } from "../lib/time";
|
||||
|
||||
export function canDeleteReply(
|
||||
reply: ThreadReply,
|
||||
currentPubkey: string | undefined,
|
||||
): boolean {
|
||||
if (!currentPubkey) return false;
|
||||
return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase();
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
label,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {label}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this {label} and cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={onConfirm} type="button" variant="destructive">
|
||||
Delete {label}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
type ReplyRowProps = {
|
||||
node: ThreadNode;
|
||||
depth: number;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
channelNames?: string[];
|
||||
onDelete?: (eventId: string) => void;
|
||||
onPressReply: (reply: ThreadReply) => void;
|
||||
onOpenBranch: (reply: ThreadReply) => void;
|
||||
showThreadAction: boolean;
|
||||
};
|
||||
|
||||
/** Nested replies under a parent — used in the branch column only. */
|
||||
export function ReplyRow({
|
||||
node,
|
||||
depth,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
channelNames,
|
||||
onDelete,
|
||||
onPressReply,
|
||||
onOpenBranch,
|
||||
showThreadAction,
|
||||
}: ReplyRowProps) {
|
||||
const [isDeleteOpen, setIsDeleteOpen] = React.useState(false);
|
||||
const reply = node;
|
||||
const replyAuthorLabel = resolveUserLabel({
|
||||
pubkey: reply.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const replyAvatarUrl =
|
||||
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
|
||||
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
|
||||
const visibleDepth = Math.min(depth, 6);
|
||||
const indentPx = visibleDepth * 12;
|
||||
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/40 last:border-b-0">
|
||||
<div
|
||||
className="group px-4 py-3"
|
||||
data-forum-event-id={reply.eventId}
|
||||
style={{ paddingLeft: `${16 + indentPx}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
displayName={replyAuthorLabel}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{replyAuthorLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(reply.createdAt)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
aria-label="Reply"
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
onClick={() => onPressReply(reply)}
|
||||
size="sm"
|
||||
title="Reply"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{showThreadAction ? (
|
||||
<Button
|
||||
aria-label="Open thread"
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
onClick={() => onOpenBranch(reply)}
|
||||
size="sm"
|
||||
title={hasChildren ? "Open thread" : "Focus thread"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelRightClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{showDelete ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete reply
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{showDelete ? (
|
||||
<DeleteConfirmDialog
|
||||
label="reply"
|
||||
onConfirm={() => onDelete?.(reply.eventId)}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
open={isDeleteOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 pl-8">
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
compact
|
||||
content={reply.content}
|
||||
mentionNames={replyMentionNames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{node.children.map((child) => (
|
||||
<ReplyRow
|
||||
channelNames={channelNames}
|
||||
currentPubkey={currentPubkey}
|
||||
depth={depth + 1}
|
||||
key={child.eventId}
|
||||
onDelete={onDelete}
|
||||
onOpenBranch={onOpenBranch}
|
||||
onPressReply={onPressReply}
|
||||
node={child}
|
||||
profiles={profiles}
|
||||
showThreadAction={showThreadAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single top-level reply in the main column — nested replies appear in the branch panel. */
|
||||
export function MainColumnReplyRow({
|
||||
node,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
channelNames,
|
||||
onDelete,
|
||||
onPressReply,
|
||||
onOpenBranch,
|
||||
}: {
|
||||
node: ThreadNode;
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
channelNames?: string[];
|
||||
onDelete?: (eventId: string) => void;
|
||||
onPressReply: (reply: ThreadReply) => void;
|
||||
onOpenBranch: (reply: ThreadReply) => void;
|
||||
}) {
|
||||
const [isDeleteOpen, setIsDeleteOpen] = React.useState(false);
|
||||
const reply = node;
|
||||
const replyAuthorLabel = resolveUserLabel({
|
||||
pubkey: reply.pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
});
|
||||
const replyAvatarUrl =
|
||||
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
||||
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
|
||||
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/40 last:border-b-0">
|
||||
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
displayName={replyAuthorLabel}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{replyAuthorLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(reply.createdAt)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
aria-label="Reply"
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
onClick={() => onPressReply(reply)}
|
||||
size="sm"
|
||||
title="Reply"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Open thread"
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
onClick={() => onOpenBranch(reply)}
|
||||
size="sm"
|
||||
title={hasChildren ? "Open thread" : "Focus thread"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelRightClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
{showDelete ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete reply
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{showDelete ? (
|
||||
<DeleteConfirmDialog
|
||||
label="reply"
|
||||
onConfirm={() => onDelete?.(reply.eventId)}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
open={isDeleteOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 pl-8">
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
compact
|
||||
content={reply.content}
|
||||
mentionNames={replyMentionNames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a flat list of nodes (no recursion into children) — for the branch column. */
|
||||
export function BranchReplyList({
|
||||
nodes,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
channelNames,
|
||||
onDelete,
|
||||
onPressReply,
|
||||
}: {
|
||||
nodes: ThreadNode[];
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
channelNames?: string[];
|
||||
onDelete?: (eventId: string) => void;
|
||||
onPressReply: (reply: ThreadReply) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="divide-y divide-border/40">
|
||||
{nodes.map((node) => (
|
||||
<ReplyRow
|
||||
channelNames={channelNames}
|
||||
currentPubkey={currentPubkey}
|
||||
depth={0}
|
||||
key={node.eventId}
|
||||
onDelete={onDelete}
|
||||
onOpenBranch={() => {}}
|
||||
onPressReply={onPressReply}
|
||||
node={node}
|
||||
profiles={profiles}
|
||||
showThreadAction={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForumTypingStrip({
|
||||
currentPubkey,
|
||||
profiles,
|
||||
typingPubkeys,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
typingPubkeys: string[];
|
||||
}) {
|
||||
if (typingPubkeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const names = typingPubkeys.map((pubkey) =>
|
||||
resolveUserLabel({
|
||||
pubkey,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
preferResolvedSelfLabel: true,
|
||||
}),
|
||||
);
|
||||
const label =
|
||||
names.length === 1
|
||||
? `${names[0]} is typing...`
|
||||
: names.length === 2
|
||||
? `${names[0]} and ${names[1]} are typing...`
|
||||
: `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-2 flex items-center gap-2 text-xs text-muted-foreground"
|
||||
data-testid="forum-typing-indicator"
|
||||
>
|
||||
<span className="truncate" data-testid="forum-typing-indicator-label">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,6 +114,7 @@ export function ForumView({
|
||||
return (
|
||||
<ForumThreadPanel
|
||||
canDeletePost={canDeleteExpandedPost}
|
||||
channelId={channel.id}
|
||||
currentPubkey={effectiveCurrentPubkey}
|
||||
isDeletingPost={deletePostMutation.isPending}
|
||||
isLoading={threadQuery.isLoading}
|
||||
@@ -125,18 +126,19 @@ export function ForumView({
|
||||
onDeleteReply={(eventId) => {
|
||||
deleteReplyMutation.mutate({ eventId });
|
||||
}}
|
||||
channelId={channel.id}
|
||||
onReply={(content, mentionPubkeys) => {
|
||||
onReply={(content, mentionPubkeys, parentEventId) => {
|
||||
createReplyMutation.mutate({
|
||||
content,
|
||||
parentEventId: selectedPostId,
|
||||
mentionPubkeys,
|
||||
parentEventId,
|
||||
rootEventId: selectedPostId,
|
||||
});
|
||||
}}
|
||||
onTargetReached={onTargetReached}
|
||||
profiles={profiles}
|
||||
targetEventId={targetReplyId}
|
||||
thread={threadQuery.data}
|
||||
threadRootEventId={selectedPostId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
177
desktop/src/features/forum/useForumThreadTyping.ts
Normal file
177
desktop/src/features/forum/useForumThreadTyping.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useEffectEvent, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getTypingReplyParentFromTags,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_TYPING_INDICATOR } from "@/shared/constants/kinds";
|
||||
|
||||
type TypingEntry = {
|
||||
expiresAt: number;
|
||||
firstSeenAt: number;
|
||||
/** `null` = legacy event without `e` tag — treat as “typing toward thread root only”. */
|
||||
replyParentId: string | null;
|
||||
};
|
||||
|
||||
type TypingState = Record<string, TypingEntry>;
|
||||
|
||||
const TYPING_INDICATOR_TTL_MS = 8_000;
|
||||
const TYPING_PRUNE_INTERVAL_MS = 1_000;
|
||||
|
||||
function pruneTypingState(state: TypingState, now = Date.now()) {
|
||||
let changed = false;
|
||||
const next: TypingState = {};
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(state)) {
|
||||
if (entry.expiresAt > now) {
|
||||
next[pubkey] = entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to kind 20002 for a forum channel and splits typers by optional
|
||||
* `["e", parent, "", "reply"]` tag so indicators can sit beside the matching composer.
|
||||
*/
|
||||
export function useForumThreadTyping(
|
||||
channelId: string | null | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
enabled: boolean,
|
||||
threadRootEventId: string,
|
||||
mainComposerParentId: string,
|
||||
branchPanelOpen: boolean,
|
||||
branchComposerParentId: string | null,
|
||||
) {
|
||||
const normalizedSelf = currentPubkey?.toLowerCase();
|
||||
const [typingByPubkey, setTypingByPubkey] = useState<TypingState>({});
|
||||
|
||||
const registerTyping = useEffectEvent((event: RelayEvent) => {
|
||||
if (!channelId || event.kind !== KIND_TYPING_INDICATOR) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getChannelIdFromTags(event.tags) !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typingPubkey = event.pubkey.toLowerCase();
|
||||
if (normalizedSelf && typingPubkey === normalizedSelf) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replyParentId = getTypingReplyParentFromTags(event.tags);
|
||||
|
||||
const now = Date.now();
|
||||
setTypingByPubkey((current) => {
|
||||
const pruned = pruneTypingState(current, now);
|
||||
const existing = pruned[typingPubkey];
|
||||
return {
|
||||
...pruned,
|
||||
[typingPubkey]: {
|
||||
expiresAt: now + TYPING_INDICATOR_TTL_MS,
|
||||
firstSeenAt: existing?.firstSeenAt ?? now,
|
||||
replyParentId,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reset when channel or thread changes
|
||||
useEffect(() => {
|
||||
setTypingByPubkey({});
|
||||
}, [channelId, threadRootEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
relayClient
|
||||
.subscribeToTypingIndicators(channelId, (event) => {
|
||||
if (!isDisposed) {
|
||||
registerTyping(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to subscribe to typing indicators",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [channelId, enabled]);
|
||||
|
||||
const hasActiveTypers = Object.keys(typingByPubkey).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveTypers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setTypingByPubkey((current) => pruneTypingState(current));
|
||||
}, TYPING_PRUNE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [hasActiveTypers]);
|
||||
|
||||
const mainComposerTypingPubkeys = useMemo(() => {
|
||||
const list: { pubkey: string; firstSeenAt: number }[] = [];
|
||||
for (const [pubkey, entry] of Object.entries(typingByPubkey)) {
|
||||
const { replyParentId } = entry;
|
||||
const matchesMain =
|
||||
replyParentId === mainComposerParentId ||
|
||||
(replyParentId === null && mainComposerParentId === threadRootEventId);
|
||||
if (matchesMain) {
|
||||
list.push({ pubkey, firstSeenAt: entry.firstSeenAt });
|
||||
}
|
||||
}
|
||||
return list
|
||||
.sort((a, b) => a.firstSeenAt - b.firstSeenAt)
|
||||
.map((x) => x.pubkey);
|
||||
}, [mainComposerParentId, threadRootEventId, typingByPubkey]);
|
||||
|
||||
const branchComposerTypingPubkeys = useMemo(() => {
|
||||
if (!branchPanelOpen || !branchComposerParentId) {
|
||||
return [];
|
||||
}
|
||||
const list: { pubkey: string; firstSeenAt: number }[] = [];
|
||||
for (const [pubkey, entry] of Object.entries(typingByPubkey)) {
|
||||
if (entry.replyParentId === branchComposerParentId) {
|
||||
list.push({ pubkey, firstSeenAt: entry.firstSeenAt });
|
||||
}
|
||||
}
|
||||
return list
|
||||
.sort((a, b) => a.firstSeenAt - b.firstSeenAt)
|
||||
.map((x) => x.pubkey);
|
||||
}, [branchComposerParentId, branchPanelOpen, typingByPubkey]);
|
||||
|
||||
return { branchComposerTypingPubkeys, mainComposerTypingPubkeys };
|
||||
}
|
||||
74
desktop/src/features/messages/lib/collapsedThreads.ts
Normal file
74
desktop/src/features/messages/lib/collapsedThreads.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
export type CollapsedThreadPreview = {
|
||||
participants: Array<{
|
||||
avatarUrl: string | null | undefined;
|
||||
id: string;
|
||||
label: string;
|
||||
}>;
|
||||
replyCount: number;
|
||||
};
|
||||
|
||||
export function buildCollapsedThreadTimeline(messages: TimelineMessage[]) {
|
||||
const messageById = new Map(messages.map((message) => [message.id, message]));
|
||||
const visibleMessages: TimelineMessage[] = [];
|
||||
const hiddenRepliesByAnchor = new Map<string, TimelineMessage[]>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.depth < 2) {
|
||||
visibleMessages.push(message);
|
||||
continue;
|
||||
}
|
||||
|
||||
let anchorId = message.parentId ?? null;
|
||||
while (anchorId) {
|
||||
const ancestor = messageById.get(anchorId);
|
||||
if (!ancestor) {
|
||||
anchorId = null;
|
||||
break;
|
||||
}
|
||||
if (ancestor.depth < 2) {
|
||||
const group = hiddenRepliesByAnchor.get(ancestor.id) ?? [];
|
||||
group.push(message);
|
||||
hiddenRepliesByAnchor.set(ancestor.id, group);
|
||||
break;
|
||||
}
|
||||
anchorId = ancestor.parentId ?? null;
|
||||
}
|
||||
|
||||
if (!anchorId) {
|
||||
visibleMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
const summaryByMessageId = new Map<string, CollapsedThreadPreview>();
|
||||
for (const [messageId, hiddenReplies] of hiddenRepliesByAnchor) {
|
||||
const participants = new Map<
|
||||
string,
|
||||
{ avatarUrl: string | null | undefined; id: string; label: string }
|
||||
>();
|
||||
|
||||
for (let index = hiddenReplies.length - 1; index >= 0; index -= 1) {
|
||||
const reply = hiddenReplies[index];
|
||||
const key =
|
||||
reply.pubkey?.toLowerCase() ?? `author:${reply.author.toLowerCase()}`;
|
||||
if (!participants.has(key)) {
|
||||
participants.set(key, {
|
||||
avatarUrl: reply.avatarUrl,
|
||||
id: key,
|
||||
label: reply.author,
|
||||
});
|
||||
}
|
||||
if (participants.size >= 3) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
summaryByMessageId.set(messageId, {
|
||||
participants: [...participants.values()],
|
||||
replyCount: hiddenReplies.length,
|
||||
});
|
||||
}
|
||||
|
||||
return { summaryByMessageId, visibleMessages };
|
||||
}
|
||||
80
desktop/src/features/messages/lib/threadBranch.ts
Normal file
80
desktop/src/features/messages/lib/threadBranch.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
type ThreadBranch = {
|
||||
headMessage: TimelineMessage | null;
|
||||
messageIds: Set<string>;
|
||||
messages: TimelineMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect the visible branch for a selected thread head.
|
||||
*
|
||||
* The underlying NIP-10 root may point to an older message, but the UI thread
|
||||
* panel should anchor to the specific message the user chose to discuss and
|
||||
* then show only descendants from that branch.
|
||||
*/
|
||||
export function collectThreadBranch(
|
||||
messages: TimelineMessage[],
|
||||
branchHeadId: string | null,
|
||||
): ThreadBranch {
|
||||
if (!branchHeadId) {
|
||||
return {
|
||||
headMessage: null,
|
||||
messageIds: new Set<string>(),
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
const headMessage =
|
||||
messages.find((message) => message.id === branchHeadId) ?? null;
|
||||
if (!headMessage) {
|
||||
return {
|
||||
headMessage: null,
|
||||
messageIds: new Set<string>(),
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const message of messages) {
|
||||
if (!message.parentId) {
|
||||
continue;
|
||||
}
|
||||
const children = childrenByParent.get(message.parentId) ?? [];
|
||||
children.push(message.id);
|
||||
childrenByParent.set(message.parentId, children);
|
||||
}
|
||||
|
||||
const branchIds = new Set<string>([branchHeadId]);
|
||||
const stack = [branchHeadId];
|
||||
while (stack.length > 0) {
|
||||
const parentId = stack.pop();
|
||||
if (!parentId) {
|
||||
continue;
|
||||
}
|
||||
const children = childrenByParent.get(parentId) ?? [];
|
||||
for (const childId of children) {
|
||||
if (branchIds.has(childId)) {
|
||||
continue;
|
||||
}
|
||||
branchIds.add(childId);
|
||||
stack.push(childId);
|
||||
}
|
||||
}
|
||||
|
||||
const depthOffset = headMessage.depth + 1;
|
||||
const branchMessages = messages
|
||||
.filter(
|
||||
(message) => message.id !== branchHeadId && branchIds.has(message.id),
|
||||
)
|
||||
.map((message) => ({
|
||||
...message,
|
||||
depth: Math.max(0, message.depth - depthOffset),
|
||||
}));
|
||||
|
||||
return {
|
||||
headMessage,
|
||||
messageIds: branchIds,
|
||||
messages: branchMessages,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,21 @@ export function getChannelIdFromTags(tags: string[][]) {
|
||||
return tags.find((tag) => tag[0] === "h")?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** NIP-10 `e` reply marker on kind 20002 — which composer surface the typer is using. */
|
||||
export function getTypingReplyParentFromTags(
|
||||
tags: string[][] | undefined,
|
||||
): string | null {
|
||||
if (!tags) {
|
||||
return null;
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (tag[0] === "e" && tag[3] === "reply" && tag[1]) {
|
||||
return tag[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getThreadReference(tags: string[][]): ThreadReference {
|
||||
const eventTags = getEventTags(tags);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { MessageComposerToolbar } from "./MessageComposerToolbar";
|
||||
type MessageComposerProps = {
|
||||
channelId?: string | null;
|
||||
channelName: string;
|
||||
draftKey?: string | null;
|
||||
disabled?: boolean;
|
||||
editTarget?: {
|
||||
author: string;
|
||||
@@ -40,6 +41,7 @@ type MessageComposerProps = {
|
||||
body: string;
|
||||
id: string;
|
||||
} | null;
|
||||
typingReplyParentId?: string | null;
|
||||
};
|
||||
|
||||
const MAX_TEXTAREA_ROWS = 4;
|
||||
@@ -47,6 +49,7 @@ const MAX_TEXTAREA_ROWS = 4;
|
||||
export function MessageComposer({
|
||||
channelId = null,
|
||||
channelName,
|
||||
draftKey = null,
|
||||
disabled = false,
|
||||
editTarget = null,
|
||||
isSending = false,
|
||||
@@ -56,6 +59,7 @@ export function MessageComposer({
|
||||
onSend,
|
||||
placeholder,
|
||||
replyTarget = null,
|
||||
typingReplyParentId = null,
|
||||
}: MessageComposerProps) {
|
||||
const [content, setContent] = React.useState("");
|
||||
const contentRef = React.useRef(content);
|
||||
@@ -70,11 +74,12 @@ export function MessageComposer({
|
||||
contentRef.current = content;
|
||||
|
||||
const drafts = useDrafts();
|
||||
const previousChannelIdRef = React.useRef<string | null>(null);
|
||||
const draftStorageKey = draftKey ?? channelId;
|
||||
const previousDraftKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const mentions = useMentions(channelId);
|
||||
const channelLinks = useChannelLinks();
|
||||
const notifyTyping = useTypingBroadcast(channelId);
|
||||
const notifyTyping = useTypingBroadcast(channelId, typingReplyParentId);
|
||||
|
||||
const media = useMediaUpload(setContent);
|
||||
|
||||
@@ -93,27 +98,29 @@ export function MessageComposer({
|
||||
editTargetRef.current = editTarget;
|
||||
channelIdRef.current = channelId;
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — save draft for previous channel, restore draft for new channel, reset transient state
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: draftStorageKey is the sole trigger — save draft for previous scope, restore draft for new scope, reset transient state
|
||||
React.useEffect(() => {
|
||||
// Save draft for the channel we're leaving
|
||||
const prevId = previousChannelIdRef.current;
|
||||
if (prevId) {
|
||||
// Save draft for the composer scope we're leaving.
|
||||
const previousDraftKey = previousDraftKeyRef.current;
|
||||
if (previousDraftKey) {
|
||||
const currentContent = contentRef.current;
|
||||
const sel = draftSelectionRef.current;
|
||||
if (currentContent.trim().length > 0) {
|
||||
drafts.saveDraft(prevId, {
|
||||
drafts.saveDraft(previousDraftKey, {
|
||||
content: currentContent,
|
||||
selectionEnd: sel.end,
|
||||
selectionStart: sel.start,
|
||||
});
|
||||
} else {
|
||||
drafts.clearDraft(prevId);
|
||||
drafts.clearDraft(previousDraftKey);
|
||||
}
|
||||
}
|
||||
previousChannelIdRef.current = channelId;
|
||||
previousDraftKeyRef.current = draftStorageKey;
|
||||
|
||||
// Restore draft for the channel we're entering
|
||||
const saved = channelId ? drafts.loadDraft(channelId) : undefined;
|
||||
// Restore draft for the composer scope we're entering.
|
||||
const saved = draftStorageKey
|
||||
? drafts.loadDraft(draftStorageKey)
|
||||
: undefined;
|
||||
if (saved) {
|
||||
setContent(saved.content);
|
||||
contentRef.current = saved.content;
|
||||
@@ -136,7 +143,7 @@ export function MessageComposer({
|
||||
mentions.clearMentions();
|
||||
channelLinks.clearChannels();
|
||||
lineHeightRef.current = null;
|
||||
}, [channelId]);
|
||||
}, [draftStorageKey]);
|
||||
|
||||
const applyMentionInsert = React.useCallback(
|
||||
(suggestion: MentionSuggestion) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { MessageSquareReply } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -11,15 +14,9 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"
|
||||
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
|
||||
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { BotIdenticon } from "./BotIdenticon";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
|
||||
/** Returns true if this message is from a bot instance. */
|
||||
function isBotInstance(role?: string): boolean {
|
||||
return role === "bot";
|
||||
}
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
|
||||
@@ -30,15 +27,18 @@ export const MessageRow = React.memo(
|
||||
message,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenThread,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
profiles,
|
||||
threadSummary = null,
|
||||
}: {
|
||||
activeReplyTargetId?: string | null;
|
||||
highlighted?: boolean;
|
||||
message: TimelineMessage;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onOpenThread?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
@@ -46,6 +46,7 @@ export const MessageRow = React.memo(
|
||||
) => Promise<void>;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
threadSummary?: CollapsedThreadPreview | null;
|
||||
}) {
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
@@ -70,8 +71,6 @@ export const MessageRow = React.memo(
|
||||
[channels],
|
||||
);
|
||||
|
||||
const visibleDepth = Math.min(message.depth, 6);
|
||||
const indentPx = visibleDepth * 28;
|
||||
const getTag = (name: string) =>
|
||||
message.tags?.find((tag) => tag[0] === name)?.[1];
|
||||
|
||||
@@ -153,18 +152,7 @@ export const MessageRow = React.memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
style={indentPx > 0 ? { paddingLeft: `${indentPx}px` } : undefined}
|
||||
>
|
||||
{message.depth > 0 ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute bottom-1.5 left-3 top-1.5 rounded-full border-l border-border/70"
|
||||
style={{ left: `${Math.max(indentPx - 14, 12)}px` }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="relative">
|
||||
<article
|
||||
className={cn(
|
||||
"group/message flex items-start gap-2.5 rounded-2xl px-2 py-1 transition-colors",
|
||||
@@ -177,13 +165,6 @@ export const MessageRow = React.memo(
|
||||
data-testid="message-row"
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isBotInstance(message.role) ? (
|
||||
<BotIdenticon
|
||||
value={message.author}
|
||||
size={20}
|
||||
className="rounded"
|
||||
/>
|
||||
) : null}
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover pubkey={message.pubkey}>
|
||||
<button
|
||||
@@ -279,6 +260,38 @@ export const MessageRow = React.memo(
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{threadSummary ? (
|
||||
<button
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-full px-1 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => {
|
||||
onOpenThread?.(message);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{threadSummary.participants.map((participant, index) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={`relative h-5 w-5 rounded-full ring-1 ring-background ${
|
||||
index > 0 ? "-ml-1.5" : ""
|
||||
}`}
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatarUrl={participant.avatarUrl ?? null}
|
||||
className="h-5 w-5 rounded-full text-[8px]"
|
||||
iconClassName="h-3 w-3"
|
||||
label={participant.label}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{threadSummary.replyCount}{" "}
|
||||
{threadSummary.replyCount === 1 ? "reply" : "replies"}
|
||||
</span>
|
||||
<MessageSquareReply className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
{reactionErrorMessage}
|
||||
@@ -326,7 +339,8 @@ export const MessageRow = React.memo(
|
||||
prev.message.personaDisplayName === next.message.personaDisplayName &&
|
||||
prev.highlighted === next.highlighted &&
|
||||
prev.activeReplyTargetId === next.activeReplyTargetId &&
|
||||
prev.profiles === next.profiles,
|
||||
prev.profiles === next.profiles &&
|
||||
prev.threadSummary === next.threadSummary,
|
||||
);
|
||||
|
||||
MessageRow.displayName = "MessageRow";
|
||||
|
||||
149
desktop/src/features/messages/ui/MessageThreadSidebar.tsx
Normal file
149
desktop/src/features/messages/ui/MessageThreadSidebar.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import { TypingIndicatorRow } from "./TypingIndicatorRow";
|
||||
|
||||
type MessageThreadSidebarProps = {
|
||||
channel: Channel | null;
|
||||
currentPubkey?: string;
|
||||
headMessage: TimelineMessage;
|
||||
isSending: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onCancelReply: () => void;
|
||||
onClose: () => void;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onReply: (message: TimelineMessage) => void;
|
||||
onSend: (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => Promise<void>;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
replyTargetMessage: TimelineMessage | null;
|
||||
typingPubkeys: string[];
|
||||
};
|
||||
|
||||
export function MessageThreadSidebar({
|
||||
channel,
|
||||
currentPubkey,
|
||||
headMessage,
|
||||
isSending,
|
||||
messages,
|
||||
onCancelReply,
|
||||
onClose,
|
||||
onDelete,
|
||||
onReply,
|
||||
onSend,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
profiles,
|
||||
replyTargetMessage,
|
||||
typingPubkeys,
|
||||
}: MessageThreadSidebarProps) {
|
||||
const threadReplyTarget =
|
||||
replyTargetMessage && replyTargetMessage.id !== headMessage.id
|
||||
? {
|
||||
author: replyTargetMessage.author,
|
||||
body: replyTargetMessage.body,
|
||||
id: replyTargetMessage.id,
|
||||
}
|
||||
: null;
|
||||
const canReplyInChannel =
|
||||
!!channel &&
|
||||
channel.isMember &&
|
||||
channel.archivedAt === null &&
|
||||
channel.channelType !== "forum";
|
||||
|
||||
return (
|
||||
<aside className="flex w-[min(100%,24rem)] shrink-0 flex-col border-l border-border/60 bg-muted/20">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">Thread</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{messages.length} {messages.length === 1 ? "reply" : "replies"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close thread panel"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="border-b border-border/60 bg-background/70 px-3 py-3">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Original message</p>
|
||||
<MessageRow
|
||||
message={{ ...headMessage, depth: 0 }}
|
||||
onDelete={
|
||||
currentPubkey && headMessage.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/70 bg-background/50 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No replies in this thread yet.
|
||||
</div>
|
||||
) : (
|
||||
<TimelineMessageList
|
||||
activeReplyTargetId={threadReplyTarget?.id ?? null}
|
||||
currentPubkey={currentPubkey}
|
||||
messages={messages}
|
||||
onDelete={onDelete}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{typingPubkeys.length > 0 ? (
|
||||
<TypingIndicatorRow
|
||||
channel={channel}
|
||||
currentPubkey={currentPubkey}
|
||||
profiles={profiles}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MessageComposer
|
||||
channelId={channel?.id ?? null}
|
||||
channelName={channel?.name ?? "channel"}
|
||||
disabled={!canReplyInChannel || isSending}
|
||||
draftKey={channel ? `${channel.id}:thread:${headMessage.id}` : null}
|
||||
isSending={isSending}
|
||||
onCancelReply={threadReplyTarget ? onCancelReply : undefined}
|
||||
onSend={onSend}
|
||||
placeholder="Reply in thread..."
|
||||
replyTarget={threadReplyTarget}
|
||||
typingReplyParentId={replyTargetMessage?.id ?? headMessage.id}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown, Loader2 } from "lucide-react";
|
||||
|
||||
import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -19,6 +20,7 @@ type MessageTimelineProps = {
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
activeReplyTargetId?: string | null;
|
||||
collapsedThreadSummaryByMessageId?: Map<string, CollapsedThreadPreview>;
|
||||
currentPubkey?: string;
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages?: boolean;
|
||||
@@ -28,6 +30,7 @@ type MessageTimelineProps = {
|
||||
profiles?: UserProfileLookup;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onOpenThread?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
@@ -45,6 +48,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
emptyTitle = "No messages yet",
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
activeReplyTargetId = null,
|
||||
collapsedThreadSummaryByMessageId,
|
||||
currentPubkey,
|
||||
fetchOlder,
|
||||
hasOlderMessages = true,
|
||||
@@ -53,6 +57,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
profiles,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenThread,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
targetMessageId = null,
|
||||
@@ -154,11 +159,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
{!isLoading && messages.length > 0 ? (
|
||||
<TimelineMessageList
|
||||
activeReplyTargetId={activeReplyTargetId}
|
||||
collapsedThreadSummaryByMessageId={
|
||||
collapsedThreadSummaryByMessageId
|
||||
}
|
||||
currentPubkey={currentPubkey}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
messages={messages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onOpenThread={onOpenThread}
|
||||
onReply={onReply}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { CollapsedThreadPreview } from "@/features/messages/lib/collapsedThreads";
|
||||
import {
|
||||
formatDayHeading,
|
||||
isSameDay,
|
||||
@@ -13,11 +14,13 @@ import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
type TimelineMessageListProps = {
|
||||
activeReplyTargetId?: string | null;
|
||||
collapsedThreadSummaryByMessageId?: Map<string, CollapsedThreadPreview>;
|
||||
currentPubkey?: string;
|
||||
highlightedMessageId?: string | null;
|
||||
messages: TimelineMessage[];
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onOpenThread?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
@@ -31,11 +34,13 @@ type TimelineMessageListProps = {
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
activeReplyTargetId = null,
|
||||
collapsedThreadSummaryByMessageId,
|
||||
currentPubkey,
|
||||
highlightedMessageId = null,
|
||||
messages,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onOpenThread,
|
||||
onReply,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
@@ -86,8 +91,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
: undefined
|
||||
}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onOpenThread={onOpenThread}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
threadSummary={
|
||||
collapsedThreadSummaryByMessageId?.get(message.id) ?? null
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
236
desktop/src/features/messages/useChannelThreadTyping.ts
Normal file
236
desktop/src/features/messages/useChannelThreadTyping.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
getChannelIdFromTags,
|
||||
getTypingReplyParentFromTags,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_TYPING_INDICATOR,
|
||||
} from "@/shared/constants/kinds";
|
||||
|
||||
type TypingEntry = {
|
||||
expiresAt: number;
|
||||
firstSeenAt: number;
|
||||
replyParentId: string | null;
|
||||
};
|
||||
|
||||
type TypingState = Record<string, TypingEntry>;
|
||||
|
||||
const TYPING_INDICATOR_TTL_MS = 8_000;
|
||||
const TYPING_PRUNE_INTERVAL_MS = 1_000;
|
||||
const TYPING_POST_MESSAGE_SUPPRESS_MS = 2_000;
|
||||
|
||||
function pruneTypingState(state: TypingState, now = Date.now()) {
|
||||
let changed = false;
|
||||
const next: TypingState = {};
|
||||
|
||||
for (const [pubkey, entry] of Object.entries(state)) {
|
||||
if (entry.expiresAt > now) {
|
||||
next[pubkey] = entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? next : state;
|
||||
}
|
||||
|
||||
function isTypingCompletionEvent(event: RelayEvent | null | undefined) {
|
||||
if (!event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
event.kind === KIND_STREAM_MESSAGE ||
|
||||
event.kind === KIND_STREAM_MESSAGE_DIFF
|
||||
);
|
||||
}
|
||||
|
||||
export function useChannelThreadTyping(
|
||||
channel: Channel | null,
|
||||
currentPubkey: string | undefined,
|
||||
latestMessageEvent: RelayEvent | null | undefined,
|
||||
openThreadMessageIds?: ReadonlySet<string> | null,
|
||||
) {
|
||||
const channelId = channel?.id ?? null;
|
||||
const channelType = channel?.channelType ?? null;
|
||||
const [typingByPubkey, setTypingByPubkey] = useState<TypingState>({});
|
||||
const normalizedCurrentPubkey = currentPubkey?.toLowerCase();
|
||||
const typingSuppressUntilByPubkeyRef = useRef<Record<string, number>>({});
|
||||
const latestMessageCreatedAtByPubkeyRef = useRef<Record<string, number>>({});
|
||||
|
||||
const registerTyping = useEffectEvent((event: RelayEvent) => {
|
||||
if (!channelId || event.kind !== KIND_TYPING_INDICATOR) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getChannelIdFromTags(event.tags) !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const typingPubkey = event.pubkey.toLowerCase();
|
||||
if (normalizedCurrentPubkey && typingPubkey === normalizedCurrentPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const suppressUntil =
|
||||
typingSuppressUntilByPubkeyRef.current[typingPubkey] ?? 0;
|
||||
if (suppressUntil > Date.now()) {
|
||||
return;
|
||||
}
|
||||
if (suppressUntil > 0) {
|
||||
delete typingSuppressUntilByPubkeyRef.current[typingPubkey];
|
||||
}
|
||||
|
||||
const latestMessageCreatedAt =
|
||||
latestMessageCreatedAtByPubkeyRef.current[typingPubkey] ?? 0;
|
||||
if (event.created_at <= latestMessageCreatedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replyParentId = getTypingReplyParentFromTags(event.tags);
|
||||
const now = Date.now();
|
||||
setTypingByPubkey((current) => {
|
||||
const pruned = pruneTypingState(current, now);
|
||||
const existing = pruned[typingPubkey];
|
||||
return {
|
||||
...pruned,
|
||||
[typingPubkey]: {
|
||||
expiresAt: now + TYPING_INDICATOR_TTL_MS,
|
||||
firstSeenAt: existing?.firstSeenAt ?? now,
|
||||
replyParentId,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channel changes should clear local typing state
|
||||
useEffect(() => {
|
||||
setTypingByPubkey({});
|
||||
typingSuppressUntilByPubkeyRef.current = {};
|
||||
latestMessageCreatedAtByPubkeyRef.current = {};
|
||||
}, [channelId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!channelId ||
|
||||
!latestMessageEvent ||
|
||||
!isTypingCompletionEvent(latestMessageEvent)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getChannelIdFromTags(latestMessageEvent.tags) !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authorPubkey = latestMessageEvent.pubkey.toLowerCase();
|
||||
latestMessageCreatedAtByPubkeyRef.current[authorPubkey] = Math.max(
|
||||
latestMessageCreatedAtByPubkeyRef.current[authorPubkey] ?? 0,
|
||||
latestMessageEvent.created_at,
|
||||
);
|
||||
typingSuppressUntilByPubkeyRef.current[authorPubkey] =
|
||||
Date.now() + TYPING_POST_MESSAGE_SUPPRESS_MS;
|
||||
setTypingByPubkey((current) => {
|
||||
const next = pruneTypingState(current);
|
||||
if (!(authorPubkey in next)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const updated = { ...next };
|
||||
delete updated[authorPubkey];
|
||||
return updated;
|
||||
});
|
||||
}, [channelId, latestMessageEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channelId || channelType === "forum") {
|
||||
return;
|
||||
}
|
||||
|
||||
let isDisposed = false;
|
||||
let cleanup: (() => Promise<void>) | undefined;
|
||||
|
||||
relayClient
|
||||
.subscribeToTypingIndicators(channelId, (event) => {
|
||||
if (!isDisposed) {
|
||||
registerTyping(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"Failed to subscribe to typing indicators",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (cleanup) {
|
||||
void cleanup();
|
||||
}
|
||||
};
|
||||
}, [channelId, channelType]);
|
||||
|
||||
const hasActiveTypers = Object.keys(typingByPubkey).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveTypers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setTypingByPubkey((current) => pruneTypingState(current));
|
||||
}, TYPING_PRUNE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [hasActiveTypers]);
|
||||
|
||||
const orderedEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(typingByPubkey)
|
||||
.map(([pubkey, entry]) => ({ pubkey, ...entry }))
|
||||
.sort((left, right) => left.firstSeenAt - right.firstSeenAt),
|
||||
[typingByPubkey],
|
||||
);
|
||||
|
||||
const mainComposerTypingPubkeys = useMemo(
|
||||
() =>
|
||||
orderedEntries
|
||||
.filter((entry) => entry.replyParentId === null)
|
||||
.map((entry) => entry.pubkey),
|
||||
[orderedEntries],
|
||||
);
|
||||
|
||||
const threadComposerTypingPubkeys = useMemo(() => {
|
||||
if (!openThreadMessageIds || openThreadMessageIds.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return orderedEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.replyParentId !== null &&
|
||||
openThreadMessageIds.has(entry.replyParentId),
|
||||
)
|
||||
.map((entry) => entry.pubkey);
|
||||
}, [openThreadMessageIds, orderedEntries]);
|
||||
|
||||
return { mainComposerTypingPubkeys, threadComposerTypingPubkeys };
|
||||
}
|
||||
@@ -7,12 +7,21 @@ const TYPING_SEND_INTERVAL_MS = 3_000;
|
||||
/**
|
||||
* Publishes kind:20002 typing indicators for the current user,
|
||||
* throttled to at most once every 3 seconds per channel.
|
||||
*
|
||||
* @param replyParentEventId When set, adds `["e", id, "", "reply"]` so receivers can
|
||||
* scope indicators to a composer (main vs sub-thread).
|
||||
*/
|
||||
export function useTypingBroadcast(channelId: string | null | undefined) {
|
||||
export function useTypingBroadcast(
|
||||
channelId: string | null | undefined,
|
||||
replyParentEventId?: string | null,
|
||||
) {
|
||||
const lastSentRef = useRef(0);
|
||||
const lastChannelRef = useRef(channelId);
|
||||
const lastScopeRef = useRef<string | null | undefined>(undefined);
|
||||
const channelIdRef = useRef(channelId);
|
||||
const replyParentRef = useRef(replyParentEventId);
|
||||
channelIdRef.current = channelId;
|
||||
replyParentRef.current = replyParentEventId;
|
||||
|
||||
const notifyTyping = useCallback(() => {
|
||||
const id = channelIdRef.current;
|
||||
@@ -20,9 +29,13 @@ export function useTypingBroadcast(channelId: string | null | undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset throttle when channel changes.
|
||||
if (lastChannelRef.current !== id) {
|
||||
// Reset throttle when channel or reply scope changes.
|
||||
if (
|
||||
lastChannelRef.current !== id ||
|
||||
lastScopeRef.current !== replyParentRef.current
|
||||
) {
|
||||
lastChannelRef.current = id;
|
||||
lastScopeRef.current = replyParentRef.current;
|
||||
lastSentRef.current = 0;
|
||||
}
|
||||
|
||||
@@ -32,7 +45,9 @@ export function useTypingBroadcast(channelId: string | null | undefined) {
|
||||
}
|
||||
|
||||
lastSentRef.current = now;
|
||||
relayClient.sendTypingIndicator(id).catch(() => {});
|
||||
relayClient
|
||||
.sendTypingIndicator(id, replyParentRef.current ?? undefined)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return notifyTyping;
|
||||
|
||||
@@ -135,15 +135,26 @@ export class RelayClient {
|
||||
);
|
||||
}
|
||||
|
||||
async sendTypingIndicator(channelId: string) {
|
||||
/**
|
||||
* @param replyParentEventId Optional NIP-10 reply target — same id you pass as `parentEventId`
|
||||
* when sending a message. Enables scoped “is typing” in thread UIs.
|
||||
*/
|
||||
async sendTypingIndicator(
|
||||
channelId: string,
|
||||
replyParentEventId?: string | null,
|
||||
) {
|
||||
// Bail when disconnected — not worth triggering a reconnect for ephemeral typing events.
|
||||
if (this.wsId === null) {
|
||||
return;
|
||||
}
|
||||
const tags: string[][] = [["h", channelId]];
|
||||
if (replyParentEventId) {
|
||||
tags.push(["e", replyParentEventId, "", "reply"]);
|
||||
}
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_TYPING_INDICATOR,
|
||||
content: "",
|
||||
tags: [["h", channelId]],
|
||||
tags,
|
||||
});
|
||||
|
||||
// Fire-and-forget: no need to wait for relay acknowledgement.
|
||||
|
||||
Reference in New Issue
Block a user