feat: add group reaction reaction and deletion
This commit is contained in:
@@ -27,6 +27,13 @@ enum Absorbed {
|
||||
Updated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reaction {
|
||||
pub id: EventId,
|
||||
pub emoji: String,
|
||||
pub author: PublicKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Row {
|
||||
Message(Box<Message>),
|
||||
@@ -78,6 +85,7 @@ pub struct Group {
|
||||
state_ids: HashMap<Kind, EventId>,
|
||||
rows: Vec<Row>,
|
||||
index: HashSet<EventId>,
|
||||
reactions: HashMap<EventId, Vec<Reaction>>,
|
||||
leave_requests: HashMap<PublicKey, Timestamp>,
|
||||
window: TimelineWindow,
|
||||
oldest: Option<Timestamp>,
|
||||
@@ -105,6 +113,7 @@ impl Group {
|
||||
state_ids: HashMap::new(),
|
||||
rows: Vec::new(),
|
||||
index: HashSet::new(),
|
||||
reactions: HashMap::new(),
|
||||
leave_requests: HashMap::new(),
|
||||
window: TimelineWindow::new(),
|
||||
oldest: None,
|
||||
@@ -180,6 +189,10 @@ impl Group {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reactions(&self, id: &EventId) -> &[Reaction] {
|
||||
self.reactions.get(id).map(Vec::as_slice).unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn loading_more(&self) -> bool {
|
||||
self.loading_more
|
||||
}
|
||||
@@ -247,6 +260,10 @@ impl Group {
|
||||
self.dispatch(move |gid, prev| gid.delete_event(id, prev), cx)
|
||||
}
|
||||
|
||||
pub fn delete_group(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(GroupId::delete_group, cx)
|
||||
}
|
||||
|
||||
pub fn create_invite(&mut self, code: &str, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
self.dispatch(move |id, prev| id.create_invite(code, prev), cx)
|
||||
}
|
||||
@@ -309,6 +326,15 @@ impl Group {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mark_member(&mut self, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
}
|
||||
self.membership = Membership::Member;
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub(crate) fn mark_refused(&mut self, reason: String, cx: &mut Context<Self>) {
|
||||
if self.membership == Membership::Member {
|
||||
return;
|
||||
@@ -504,6 +530,14 @@ impl Group {
|
||||
}
|
||||
|
||||
self.rows.retain(|row| row.id() != id);
|
||||
self.reactions.remove(&id);
|
||||
|
||||
for reactions in self.reactions.values_mut() {
|
||||
reactions.retain(|reaction| reaction.id != id);
|
||||
}
|
||||
|
||||
self.reactions.retain(|_, reactions| !reactions.is_empty());
|
||||
|
||||
cx.emit(GroupEvent::Updated);
|
||||
cx.notify();
|
||||
}
|
||||
@@ -541,6 +575,14 @@ impl Group {
|
||||
};
|
||||
}
|
||||
|
||||
if event.kind == Kind::Reaction {
|
||||
return if self.push_reaction(event) {
|
||||
Absorbed::Updated
|
||||
} else {
|
||||
Absorbed::Ignored
|
||||
};
|
||||
}
|
||||
|
||||
if RENDER_KINDS.contains(&event.kind) {
|
||||
return Absorbed::Shown {
|
||||
fresh: self.push_row(event),
|
||||
@@ -626,6 +668,24 @@ impl Group {
|
||||
true
|
||||
}
|
||||
|
||||
fn push_reaction(&mut self, event: &Event) -> bool {
|
||||
let Some(target) = event.tags.event_ids().next() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if !self.index.insert(event.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.reactions.entry(target).or_default().push(Reaction {
|
||||
id: event.id,
|
||||
emoji: event.content.clone(),
|
||||
author: event.pubkey,
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn push_activity(&mut self, event: &Event) -> bool {
|
||||
let activity = match Activity::parse(event) {
|
||||
Ok(activity) => activity,
|
||||
|
||||
+31
-2
@@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, Result};
|
||||
use anyhow::{Error, Result, anyhow};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -186,11 +186,16 @@ impl GroupsRegistry {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
if let Err(error) = sent.await {
|
||||
let reason = error.to_string();
|
||||
let duplicate = reason.starts_with("duplicate");
|
||||
|
||||
if let Err(error) = this.update(cx, |this, cx| {
|
||||
if let Some(group) = this.group(&key, cx) {
|
||||
group.update(cx, |group, cx| {
|
||||
group.mark_refused(reason, cx);
|
||||
if duplicate {
|
||||
group.mark_member(cx);
|
||||
} else {
|
||||
group.mark_refused(reason, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}) {
|
||||
@@ -412,6 +417,30 @@ impl GroupsRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Ask the relay to delete the group (kind 9008).
|
||||
pub fn delete_group(&mut self, key: &GroupKey, cx: &mut Context<Self>) -> Task<Result<()>> {
|
||||
let Some(group) = self.group(key, cx) else {
|
||||
return Task::ready(Err(anyhow!("nip29: group not found")));
|
||||
};
|
||||
|
||||
let sent = group.update(cx, |group, cx| group.delete_group(cx));
|
||||
let key = key.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let result = sent.await;
|
||||
|
||||
if result.is_ok()
|
||||
&& let Err(error) = this.update(cx, |this, cx| {
|
||||
this.forget(key, cx);
|
||||
})
|
||||
{
|
||||
log::warn!("forgetting the deleted group failed: {error}");
|
||||
}
|
||||
|
||||
result
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
|
||||
self.notification_listener = None;
|
||||
self.signal_consumer = None;
|
||||
|
||||
@@ -212,6 +212,12 @@ impl GroupId {
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupDeleteGroup, "")
|
||||
.tag(self.h_tag())
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
pub fn create_invite(&self, code: &str, previous: Option<Tag>) -> EventBuilder {
|
||||
EventBuilder::new(Kind::GroupCreateInvite, "")
|
||||
.tag(self.h_tag())
|
||||
|
||||
@@ -105,6 +105,7 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
let edit = group.clone();
|
||||
let invite = group.clone();
|
||||
let channel = group.clone();
|
||||
let delete = group.clone();
|
||||
let leave = group;
|
||||
|
||||
h_flex()
|
||||
@@ -161,6 +162,16 @@ pub fn actions(group: WeakEntity<Group>, cx: &App) -> AnyElement {
|
||||
);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("group-delete")
|
||||
.icon(IconName::CloseCircle)
|
||||
.tooltip("Delete group")
|
||||
.ghost()
|
||||
.small()
|
||||
.on_click(move |_event, window, cx| {
|
||||
confirm_delete(delete.clone(), window, cx);
|
||||
}),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
Button::new("leave")
|
||||
@@ -211,6 +222,42 @@ pub fn confirm_leave(group: WeakEntity<Group>, window: &mut Window, cx: &mut App
|
||||
});
|
||||
}
|
||||
|
||||
pub fn confirm_delete(group: WeakEntity<Group>, window: &mut Window, cx: &mut App) {
|
||||
let Some(name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(key) = group.read_with(cx, |group, _cx| group.key().clone()).ok() else {
|
||||
return;
|
||||
};
|
||||
|
||||
window.open_dialog(cx, move |this, _window, _cx| {
|
||||
let key = key.clone();
|
||||
|
||||
this.confirm()
|
||||
.width(px(360.))
|
||||
.title(format!("Delete {name}?"))
|
||||
.button_props(
|
||||
DialogButtonProps::default()
|
||||
.cancel_text("Cancel")
|
||||
.ok_text("Delete")
|
||||
.ok_variant(ButtonVariant::Danger),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_style(TextStyle::Callout)
|
||||
.child("The relay will remove this group for everyone."),
|
||||
)
|
||||
.on_ok(move |_event, window, cx| {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let sent = registry.update(cx, |registry, cx| registry.delete_group(&key, cx));
|
||||
report(window, cx, sent);
|
||||
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a member from the group and surface any relay error as a notification.
|
||||
pub fn remove_member(
|
||||
group: WeakEntity<Group>,
|
||||
@@ -823,6 +870,7 @@ impl GroupPanel {
|
||||
message,
|
||||
content,
|
||||
show_author,
|
||||
group.reactions(&message.id),
|
||||
message::Actions {
|
||||
group: self.group.clone(),
|
||||
panel: cx.weak_entity(),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chat::Message;
|
||||
use common::TimestampExt;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
@@ -5,11 +7,12 @@ use gpui::{
|
||||
AnyElement, App, InteractiveElement, IntoElement, ObjectFit, ParentElement, SharedString,
|
||||
SharedUri, Styled, StyledImage, WeakEntity, div, img, px,
|
||||
};
|
||||
use nip29::{Activity, Group};
|
||||
use nostr_sdk::prelude::{EventId, Kind};
|
||||
use nip29::{Activity, Group, Reaction};
|
||||
use nostr_sdk::prelude::{EventId, Kind, PublicKey};
|
||||
use person::PersonRegistry;
|
||||
use settings::AppSettings;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius};
|
||||
use state::NostrRegistry;
|
||||
use theme::{ActiveTheme, TextStyle, Typeset as _, bubble_radius, control_radius};
|
||||
use ui::avatar::Avatar;
|
||||
use ui::menu::{ContextMenu, PopupMenu, PopupMenuItem};
|
||||
use ui::message::MessageRow;
|
||||
@@ -31,6 +34,7 @@ pub(crate) fn render(
|
||||
message: &Message,
|
||||
content: AnyElement,
|
||||
show_author: bool,
|
||||
reactions: &[Reaction],
|
||||
actions: Actions,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
@@ -52,6 +56,9 @@ pub(crate) fn render(
|
||||
.when(!message.media.is_empty(), |this| {
|
||||
this.child(media(&message.media, cx))
|
||||
})
|
||||
.when(!reactions.is_empty(), |this| {
|
||||
this.child(reaction_bar(reactions, cx))
|
||||
})
|
||||
.into_any_element();
|
||||
|
||||
let id = message.id;
|
||||
@@ -205,6 +212,43 @@ fn action(activity: &Activity) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn reaction_bar(reactions: &[Reaction], cx: &App) -> AnyElement {
|
||||
let me = NostrRegistry::global(cx).read(cx).current_user();
|
||||
|
||||
let mut grouped: BTreeMap<String, Vec<PublicKey>> = BTreeMap::new();
|
||||
|
||||
for reaction in reactions {
|
||||
grouped
|
||||
.entry(reaction.emoji.clone())
|
||||
.or_default()
|
||||
.push(reaction.author);
|
||||
}
|
||||
|
||||
h_flex()
|
||||
.mt_1()
|
||||
.gap_1()
|
||||
.children(grouped.into_iter().map(|(emoji, authors)| {
|
||||
let mine = me.is_some_and(|me| authors.contains(&me));
|
||||
|
||||
h_flex()
|
||||
.gap(px(theme::SPACE))
|
||||
.py_0p5()
|
||||
.px_1()
|
||||
.rounded(px(control_radius()))
|
||||
.text_style(TextStyle::Caption)
|
||||
.border_1()
|
||||
.when(mine, |this| {
|
||||
this.text_color(cx.theme().text)
|
||||
.bg(cx.theme().surface_raised)
|
||||
.border_color(cx.theme().element_active)
|
||||
})
|
||||
.when(!mine, |this| this.border_color(cx.theme().border))
|
||||
.child(SharedString::from(emoji))
|
||||
.child(SharedString::from(authors.len().to_string()))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn media(media: &[SharedUri], cx: &App) -> AnyElement {
|
||||
if let [only] = media {
|
||||
return div()
|
||||
|
||||
Reference in New Issue
Block a user