feat: add support for reaction #37
@@ -1,5 +1,5 @@
|
|||||||
use std::cmp::Reverse;
|
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::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, LazyLock, RwLock};
|
use std::sync::{Arc, LazyLock, RwLock};
|
||||||
|
|
||||||
@@ -80,6 +80,9 @@ pub struct ChatRegistry {
|
|||||||
/// Chat rooms
|
/// Chat rooms
|
||||||
rooms: Vec<Entity<Room>>,
|
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
|
/// Events that failed to unwrap for any reason
|
||||||
trash: Entity<BTreeSet<FailedMessage>>,
|
trash: Entity<BTreeSet<FailedMessage>>,
|
||||||
|
|
||||||
@@ -170,6 +173,7 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
rooms: vec![],
|
rooms: vec![],
|
||||||
|
room_index: HashMap::new(),
|
||||||
trash: cx.new(|_| BTreeSet::default()),
|
trash: cx.new(|_| BTreeSet::default()),
|
||||||
seen: Arc::new(RwLock::new(HashMap::default())),
|
seen: Arc::new(RwLock::new(HashMap::default())),
|
||||||
event_map: 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)
|
self.tracking.load(Ordering::Acquire)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a weak reference to a room by its ID.
|
/// Get a weak reference to a room by its ID
|
||||||
pub fn room(&self, id: &u64, cx: &App) -> Option<WeakEntity<Room>> {
|
pub fn room(&self, id: &u64, _cx: &App) -> Option<WeakEntity<Room>> {
|
||||||
self.rooms
|
self.room_index.get(id).map(|room| room.downgrade())
|
||||||
.iter()
|
|
||||||
.find(|this| &this.read(cx).id == id)
|
|
||||||
.map(|this| this.downgrade())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all rooms based on the filter.
|
/// Get all rooms based on the filter.
|
||||||
@@ -511,7 +512,11 @@ impl ChatRegistry {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let room: Room = room.into().organize(&public_key);
|
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.emit(ChatEvent::Ping);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -524,9 +529,11 @@ impl ChatRegistry {
|
|||||||
// Get the room's ID.
|
// Get the room's ID.
|
||||||
let id = room.read(cx).id;
|
let id = room.read(cx).id;
|
||||||
|
|
||||||
// If the room is new, add it to the registry.
|
// If the room is new, add it to the registry and index.
|
||||||
if !self.rooms.iter().any(|r| r.read(cx).id == id) {
|
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
|
||||||
self.rooms.insert(0, room.to_owned());
|
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
|
// Emit the open room event deferred to avoid re-entrant reads
|
||||||
@@ -537,18 +544,24 @@ impl ChatRegistry {
|
|||||||
|
|
||||||
/// Close a room.
|
/// Close a room.
|
||||||
pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
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.defer_in(window, move |_this, _window, cx| {
|
||||||
cx.emit(ChatEvent::CloseRoom(id));
|
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>) {
|
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));
|
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();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Finding rooms based on a query.
|
/// Finding rooms based on a query.
|
||||||
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
pub fn find(&self, query: &str, cx: &App) -> Vec<Entity<Room>> {
|
||||||
@@ -574,6 +587,7 @@ impl ChatRegistry {
|
|||||||
/// Reset the registry.
|
/// Reset the registry.
|
||||||
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
pub fn reset(&mut self, cx: &mut Context<Self>) {
|
||||||
self.rooms.clear();
|
self.rooms.clear();
|
||||||
|
self.room_index.clear();
|
||||||
self.trash.update(cx, |this, cx| {
|
self.trash.update(cx, |this, cx| {
|
||||||
this.clear();
|
this.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
@@ -601,7 +615,9 @@ impl ChatRegistry {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let new_room_id = new_room.id;
|
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();
|
let new_index = self.rooms.len();
|
||||||
room_map.insert(new_room_id, new_index);
|
room_map.insert(new_room_id, new_index);
|
||||||
@@ -713,7 +729,7 @@ impl ChatRegistry {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.rooms.iter().find(|e| e.read(cx).id == message.room) {
|
match self.room_index.get(&message.room).cloned() {
|
||||||
Some(room) => {
|
Some(room) => {
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
if this.kind == RoomKind::Request && message.rumor.pubkey == public_key {
|
||||||
|
|||||||
@@ -271,8 +271,8 @@ impl Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the members of the room
|
/// Returns the members of the room
|
||||||
pub fn members(&self) -> Vec<PublicKey> {
|
pub fn members(&self) -> &[PublicKey] {
|
||||||
self.members.clone()
|
&self.members
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks if the room has more than two members (group)
|
/// 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>> {
|
pub fn connect(&self, cx: &App) -> Task<Result<(), Error>> {
|
||||||
let nostr = NostrRegistry::global(cx);
|
let nostr = NostrRegistry::global(cx);
|
||||||
let client = nostr.read(cx).client();
|
let client = nostr.read(cx).client();
|
||||||
let members = self.members();
|
let members = self.members().to_vec();
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
|
||||||
pub use actions::*;
|
pub use actions::*;
|
||||||
@@ -59,6 +59,9 @@ pub struct ChatPanel {
|
|||||||
/// All messages (sorted by created_at)
|
/// All messages (sorted by created_at)
|
||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
|
|
||||||
|
/// O(1) message lookup by EventId
|
||||||
|
message_index: HashMap<EventId, usize>,
|
||||||
|
|
||||||
/// All reactions
|
/// All reactions
|
||||||
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ impl ChatPanel {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
id,
|
id,
|
||||||
messages,
|
messages,
|
||||||
|
message_index: HashMap::new(),
|
||||||
reactions: BTreeMap::new(),
|
reactions: BTreeMap::new(),
|
||||||
room,
|
room,
|
||||||
list_state,
|
list_state,
|
||||||
@@ -231,23 +235,29 @@ impl ChatPanel {
|
|||||||
while let Ok(status) = rx.recv_async().await {
|
while let Ok(status) = rx.recv_async().await {
|
||||||
{
|
{
|
||||||
let mut map = reports.write().unwrap();
|
let mut map = reports.write().unwrap();
|
||||||
for reports in map.values_mut() {
|
let status_id = match &*status {
|
||||||
for report in reports.iter_mut() {
|
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 {
|
let Some(output) = report.output.as_mut() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if *output.id() != status_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
match &*status {
|
match &*status {
|
||||||
SendStatus::Ok { id, relay } => {
|
SendStatus::Ok { relay, .. } => {
|
||||||
if output.id() == id {
|
|
||||||
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
output.success.insert(relay.clone(), EventSendStatus::Sent);
|
||||||
}
|
}
|
||||||
}
|
SendStatus::Failed { relay, message, .. } => {
|
||||||
SendStatus::Failed { id, relay, message } => {
|
|
||||||
if output.id() == id {
|
|
||||||
output.failed.insert(relay.clone(), message.clone());
|
output.failed.insert(relay.clone(), message.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
break 'outer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,6 +482,10 @@ impl ChatPanel {
|
|||||||
|
|
||||||
if let Err(pos) = self.messages.binary_search(&msg) {
|
if let Err(pos) = self.messages.binary_search(&msg) {
|
||||||
self.messages.insert(pos, 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);
|
self.list_state.splice(old_len..old_len, 1);
|
||||||
|
|
||||||
if scroll {
|
if scroll {
|
||||||
@@ -514,22 +528,25 @@ impl ChatPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reports
|
/// Check if a message has any reports
|
||||||
fn has_reports(&self, id: &EventId, _cx: &App) -> bool {
|
fn has_reports(&self, id: &EventId) -> bool {
|
||||||
self.reports_by_id.read().unwrap().get(id).is_some()
|
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()
|
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> {
|
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
|
/// Get a reaction by its target ID (returns reference, no allocation)
|
||||||
fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> {
|
fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] {
|
||||||
self.reactions.get(id).cloned().unwrap_or_default()
|
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message has any reactions
|
/// Check if a message has any reactions
|
||||||
@@ -912,7 +929,7 @@ impl ChatPanel {
|
|||||||
let replies = message.replies_to.as_slice();
|
let replies = message.replies_to.as_slice();
|
||||||
let has_replies = !replies.is_empty();
|
let has_replies = !replies.is_empty();
|
||||||
let has_reactions = self.has_reaction(&id);
|
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
|
// Hide avatar setting
|
||||||
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
let hide_avatar = AppSettings::get_hide_avatar(cx);
|
||||||
@@ -1095,7 +1112,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
// Group reactions by emoji and collect authors for each
|
// Group reactions by emoji and collect authors for each
|
||||||
let mut grouped: BTreeMap<SharedString, Vec<PublicKey>> = BTreeMap::new();
|
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);
|
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 {
|
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
|
let pending = reports
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
Reference in New Issue
Block a user