feat: add support for reaction #37

Merged
reya merged 4 commits from feat/reaction into master 2026-07-31 02:58:43 +00:00
3 changed files with 74 additions and 41 deletions
Showing only changes of commit a93f827a55 - Show all commits

View File

@@ -1,5 +1,5 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet, hash_map};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, RwLock};
@@ -80,6 +80,9 @@ pub struct ChatRegistry {
/// Chat rooms
rooms: Vec<Entity<Room>>,
/// O(1) room lookup by room ID
room_index: HashMap<u64, Entity<Room>>,
/// Events that failed to unwrap for any reason
trash: Entity<BTreeSet<FailedMessage>>,
@@ -170,6 +173,7 @@ impl ChatRegistry {
Self {
rooms: vec![],
room_index: HashMap::new(),
trash: cx.new(|_| BTreeSet::default()),
seen: Arc::new(RwLock::new(HashMap::default())),
event_map: Arc::new(RwLock::new(HashMap::default())),
@@ -436,12 +440,9 @@ impl ChatRegistry {
self.tracking.load(Ordering::Acquire)
}
/// Get a weak reference to a room by its ID.
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
self.rooms
.iter()
.find(|this| &this.read(cx).id == id)
.map(|this| this.downgrade())
/// Get a weak reference to a room by its ID
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
self.room_index.get(id).map(|room| room.downgrade())
}
/// Get all rooms based on the filter.
@@ -511,7 +512,11 @@ impl ChatRegistry {
};
let room: Room = room.into().organize(&public_key);
self.rooms.insert(0, cx.new(|_| room));
let room_id = room.id;
let entity = cx.new(|_| room);
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
cx.emit(ChatEvent::Ping);
cx.notify();
@@ -524,9 +529,11 @@ impl ChatRegistry {
// Get the room's ID.
let id = room.read(cx).id;
// If the room is new, add it to the registry.
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
self.rooms.insert(0, room.to_owned());
// If the room is new, add it to the registry and index.
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
self.rooms.insert(0, entity);
}
// Emit the open room event deferred to avoid re-entrant reads
@@ -537,18 +544,24 @@ impl ChatRegistry {
/// Close a room.
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if self.rooms.iter().any(|r| r.read(cx).id == id) {
if self.room_index.contains_key(&id) {
self.room_index.remove(&id);
self.rooms.retain(|r| r.read(cx).id != id);
cx.defer_in(window, move |_this, _window, cx| {
cx.emit(ChatEvent::CloseRoom(id));
});
}
}
/// Sort rooms by their created at.
/// Sort rooms by their created at. Only notifies if order changed.
pub fn sort(&mut self, cx: &mut Context<Self>) {
let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect();
if before != after {
cx.notify();
}
}
/// Finding rooms based on a query.
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
@@ -574,6 +587,7 @@ impl ChatRegistry {
/// Reset the registry.
pub fn reset(&mut self, cx: &mut Context<Self>) {
self.rooms.clear();
self.room_index.clear();
self.trash.update(cx, |this, cx| {
this.clear();
cx.notify();
@@ -601,7 +615,9 @@ impl ChatRegistry {
});
} else {
let new_room_id = new_room.id;
self.rooms.push(cx.new(|_| new_room));
let entity = cx.new(|_| new_room);
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
let new_index = self.rooms.len();
room_map.insert(new_room_id, new_index);
@@ -713,7 +729,7 @@ impl ChatRegistry {
return;
};
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
match self.room_index.get(&message.room).cloned() {
Some(room) => {
room.update(cx, |this, cx| {
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {

View File

@@ -271,8 +271,8 @@ impl Room {
}
/// Returns the members of the room
pub fn members(&self) -> Vec<PublicKey> {
self.members.clone()
pub fn members(&self) -> &[PublicKey] {
&self.members
}
/// Checks if the room has more than two members (group)
@@ -356,7 +356,7 @@ impl Room {
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let members = self.members();
let members = self.members().to_vec();
cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default()

View File

@@ -1,4 +1,4 @@
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, RwLock};
pub use actions::*;
@@ -59,6 +59,9 @@ pub struct ChatPanel {
/// All messages (sorted by created_at)
messages: Vec<Message>,
/// O(1) message lookup by EventId
message_index: HashMap<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
@@ -166,6 +169,7 @@ impl ChatPanel {
focus_handle: cx.focus_handle(),
id,
messages,
message_index: HashMap::new(),
reactions: BTreeMap::new(),
room,
list_state,
@@ -231,23 +235,29 @@ impl ChatPanel {
while let Ok(status) = rx.recv_async().await {
{
let mut map = reports.write().unwrap();
for reports in map.values_mut() {
for report in reports.iter_mut() {
let status_id = match &*status {
SendStatus::Ok { id, .. } => *id,
SendStatus::Failed { id, .. } => *id,
};
// Find the matching report and update it (exit early on first match)
'outer: for reports_list in map.values_mut() {
for report in reports_list.iter_mut() {
let Some(output) = report.output.as_mut() else {
continue;
};
if *output.id() != status_id {
continue;
}
match &*status {
SendStatus::Ok { id, relay } => {
if output.id() == id {
SendStatus::Ok { relay, .. } => {
output.success.insert(relay.clone(), EventSendStatus::Sent);
}
}
SendStatus::Failed { id, relay, message } => {
if output.id() == id {
SendStatus::Failed { relay, message, .. } => {
output.failed.insert(relay.clone(), message.clone());
}
}
}
break 'outer;
}
}
}
@@ -472,6 +482,10 @@ impl ChatPanel {
if let Err(pos) = self.messages.binary_search(&msg) {
self.messages.insert(pos, msg);
// Rebuild message index after insertion (indices from pos to end shift)
for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i);
}
self.list_state.splice(old_len..old_len, 1);
if scroll {
@@ -514,22 +528,25 @@ impl ChatPanel {
}
/// Check if a message has any reports
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
self.reports_by_id.read().unwrap().get(id).is_some()
fn has_reports(&self, id: &EventId) -> bool {
self.reports_by_id.read().unwrap().contains_key(id)
}
fn sent_reports(&self, id: &EventId, _cx: &App) -> Option<Vec<SendReport>> {
/// Clone reports for a message (used for modal display, not called during render)
fn sent_reports(&self, id: &EventId) -> Option<Vec<SendReport>> {
self.reports_by_id.read().unwrap().get(id).cloned()
}
/// Get a message by its ID
/// Get a message by its ID (O(1) lookup)
fn message(&self, id: &EventId) -> Option<&Message> {
self.messages.iter().find(|msg| &msg.id == id)
self.message_index
.get(id)
.and_then(|&ix| self.messages.get(ix))
}
/// Get a reaction by its target ID
fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> {
self.reactions.get(id).cloned().unwrap_or_default()
/// Get a reaction by its target ID (returns reference, no allocation)
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Check if a message has any reactions
@@ -912,7 +929,7 @@ impl ChatPanel {
let replies = message.replies_to.as_slice();
let has_replies = !replies.is_empty();
let has_reactions = self.has_reaction(&id);
let has_reports = self.has_reports(&id, cx);
let has_reports = self.has_reports(&id);
// Hide avatar setting
let hide_avatar = AppSettings::get_hide_avatar(cx);
@@ -1095,7 +1112,7 @@ impl ChatPanel {
// Group reactions by emoji and collect authors for each
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
for (emoji, author) in &reactions {
for (emoji, author) in reactions {
grouped.entry(emoji.clone()).or_default().push(*author);
}
@@ -1127,7 +1144,7 @@ impl ChatPanel {
}
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
let reports = self.sent_reports(id, cx);
let reports = self.sent_reports(id);
let pending = reports
.as_ref()