Merge branch 'dmchat' into unstable

This commit is contained in:
Mike Dilger 2023-09-01 08:19:26 +12:00
commit 90913c7270
11 changed files with 323 additions and 256 deletions

View File

@ -1,3 +1,4 @@
use crate::dm_channel::DmChannel;
use nostr_types::{
Event, Id, IdHex, Metadata, MilliSatoshi, PublicKey, RelayUrl, Tag, UncheckedUrl,
};
@ -28,7 +29,7 @@ pub enum ToOverlordMessage {
MinionJobComplete(RelayUrl, u64),
MinionJobUpdated(RelayUrl, u64, u64),
PickRelays,
Post(String, Vec<Tag>, Option<Id>),
Post(String, Vec<Tag>, Option<Id>, Option<DmChannel>),
PruneCache,
PruneDatabase,
PullFollow,

View File

@ -1,4 +1,5 @@
use nostr_types::{PublicKey, Unixtime};
use crate::globals::GLOBALS;
use nostr_types::{Event, EventKind, PublicKey, Unixtime};
use sha2::Digest;
/// This represents a DM (direct message) channel which includes a set
@ -46,6 +47,57 @@ impl DmChannel {
}
hex::encode(hasher.finalize())
}
pub fn from_event(event: &Event, my_pubkey: Option<PublicKey>) -> Option<DmChannel> {
let my_pubkey = match my_pubkey {
Some(pk) => pk,
None => match GLOBALS.signer.public_key() {
Some(pk) => pk,
None => return None,
},
};
if event.kind == EventKind::EncryptedDirectMessage {
let mut people: Vec<PublicKey> = event
.people()
.iter()
.filter_map(|(pk, _, _)| PublicKey::try_from(pk).ok())
.collect();
people.push(event.pubkey);
people.retain(|p| *p != my_pubkey);
if people.len() > 1 {
None
} else {
Some(Self::new(&people))
}
} else if event.kind == EventKind::GiftWrap {
if let Ok(rumor) = GLOBALS.signer.unwrap_giftwrap(event) {
let rumor_event = rumor.into_event_with_bad_signature();
let mut people: Vec<PublicKey> = rumor_event
.people()
.iter()
.filter_map(|(pk, _, _)| PublicKey::try_from(pk).ok())
.collect();
people.push(rumor_event.pubkey); // include author too
people.retain(|p| *p != my_pubkey);
Some(Self::new(&people))
} else {
None
}
} else if event.kind == EventKind::DmChat {
// unwrapped rumor
let mut people: Vec<PublicKey> = event
.people()
.iter()
.filter_map(|(pk, _, _)| PublicKey::try_from(pk).ok())
.collect();
people.push(event.pubkey); // include author too
people.retain(|p| *p != my_pubkey);
Some(Self::new(&people))
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]

View File

@ -8,6 +8,7 @@ pub enum ErrorKind {
Empty(String),
EventNotFound,
General(String),
GroupDmsNotYetSupported,
HttpError(http::Error),
JoinError(tokio::task::JoinError),
Lmdb(heed::Error),
@ -65,6 +66,7 @@ impl std::fmt::Display for Error {
Delegation(s) => write!(f, "NIP-26 Delegation Error: {s}"),
Empty(s) => write!(f, "{s} is empty"),
EventNotFound => write!(f, "Event not found"),
GroupDmsNotYetSupported => write!(f, "Group DMs are not yet supported"),
General(s) => write!(f, "{s}"),
HttpError(e) => write!(f, "HTTP error: {e}"),
JoinError(e) => write!(f, "Task join error: {e}"),

View File

@ -4,6 +4,7 @@ use crate::comms::{
RelayConnectionReason, RelayJob, ToMinionMessage, ToMinionPayload, ToMinionPayloadDetail,
ToOverlordMessage,
};
use crate::dm_channel::DmChannel;
use crate::error::{Error, ErrorKind};
use crate::globals::{ZapState, GLOBALS};
use crate::people::Person;
@ -709,8 +710,8 @@ impl Overlord {
count
));
}
ToOverlordMessage::Post(content, tags, reply_to) => {
self.post(content, tags, reply_to).await?;
ToOverlordMessage::Post(content, tags, reply_to, dm_channel) => {
self.post(content, tags, reply_to, dm_channel).await?;
}
ToOverlordMessage::PullFollow => {
self.pull_following().await?;
@ -935,155 +936,181 @@ impl Overlord {
content: String,
mut tags: Vec<Tag>,
reply_to: Option<Id>,
dm_channel: Option<DmChannel>,
) -> Result<(), Error> {
// We will fill this just before we create the event
let mut tagged_pubkeys: Vec<PublicKey>;
let public_key = match GLOBALS.signer.public_key() {
Some(pk) => pk,
None => {
tracing::warn!("No public key! Not posting");
return Ok(());
}
};
let event = {
let public_key = match GLOBALS.signer.public_key() {
Some(pk) => pk,
None => {
tracing::warn!("No public key! Not posting");
return Ok(());
let pre_event = match dm_channel {
Some(dmc) => {
if dmc.keys().len() > 1 {
return Err((ErrorKind::GroupDmsNotYetSupported, file!(), line!()).into());
}
};
if GLOBALS.storage.read_setting_set_client_tag() {
tags.push(Tag::Other {
tag: "client".to_owned(),
data: vec!["gossip".to_owned()],
});
}
// Add Tags based on references in the content
//
// FIXME - this function takes a 'tags' variable. We may want to let
// the user determine which tags to keep and which to delete, so we
// should probably move this processing into the post editor instead.
// For now, I'm just trying to remove the old #[0] type substitutions
// and use the new NostrBech32 parsing.
for bech32 in NostrBech32::find_all_in_string(&content).iter() {
match bech32 {
NostrBech32::EventAddr(ea) => {
add_addr_to_tags(
&mut tags,
ea.kind,
ea.author.into(),
ea.d.clone(),
ea.relays.get(0).cloned(),
)
.await;
}
NostrBech32::EventPointer(ep) => {
// NIP-10: "Those marked with "mention" denote a quoted or reposted event id."
add_event_to_tags(&mut tags, ep.id, "mention").await;
}
NostrBech32::Id(id) => {
// NIP-10: "Those marked with "mention" denote a quoted or reposted event id."
add_event_to_tags(&mut tags, *id, "mention").await;
}
NostrBech32::Profile(prof) => {
add_pubkey_to_tags(&mut tags, &prof.pubkey).await;
}
NostrBech32::Pubkey(pk) => {
add_pubkey_to_tags(&mut tags, pk).await;
}
NostrBech32::Relay(_) => {
// we don't need to add this to tags I don't think.
}
}
}
// Standardize nostr links (prepend 'nostr:' where missing)
// (This was a bad idea to do this late in the process, it breaks links that contain
// nostr urls)
// content = NostrUrl::urlize(&content);
// Find and tag all hashtags
for capture in GLOBALS.hashtag_regex.captures_iter(&content) {
tags.push(Tag::Hashtag {
hashtag: capture[1][1..].to_string(),
trailing: Vec::new(),
});
}
if let Some(parent_id) = reply_to {
// Get the event we are replying to
let parent = match GLOBALS.storage.read_event(parent_id)? {
Some(e) => e,
None => return Err("Cannot find event we are replying to.".into()),
let recipient = if dmc.keys().is_empty() {
public_key // must be to yourself
} else {
dmc.keys()[0]
};
// Add a 'p' tag for the author we are replying to (except if it is our own key)
if parent.pubkey != public_key {
add_pubkey_to_tags(&mut tags, &parent.pubkey).await;
// On a DM, we ignore tags and reply_to
GLOBALS.signer.new_nip04(recipient, &content)?
}
_ => {
if GLOBALS.storage.read_setting_set_client_tag() {
tags.push(Tag::Other {
tag: "client".to_owned(),
data: vec!["gossip".to_owned()],
});
}
// Add all the 'p' tags from the note we are replying to (except our own)
// FIXME: Should we avoid taging people who are muted?
for tag in &parent.tags {
if let Tag::Pubkey { pubkey, .. } = tag {
if pubkey.as_str() != public_key.as_hex_string() {
add_pubkey_hex_to_tags(&mut tags, pubkey).await;
// Add Tags based on references in the content
//
// FIXME - this function takes a 'tags' variable. We may want to let
// the user determine which tags to keep and which to delete, so we
// should probably move this processing into the post editor instead.
// For now, I'm just trying to remove the old #[0] type substitutions
// and use the new NostrBech32 parsing.
for bech32 in NostrBech32::find_all_in_string(&content).iter() {
match bech32 {
NostrBech32::EventAddr(ea) => {
add_addr_to_tags(
&mut tags,
ea.kind,
ea.author.into(),
ea.d.clone(),
ea.relays.get(0).cloned(),
)
.await;
}
NostrBech32::EventPointer(ep) => {
// NIP-10: "Those marked with "mention" denote a quoted or reposted event id."
add_event_to_tags(&mut tags, ep.id, "mention").await;
}
NostrBech32::Id(id) => {
// NIP-10: "Those marked with "mention" denote a quoted or reposted event id."
add_event_to_tags(&mut tags, *id, "mention").await;
}
NostrBech32::Profile(prof) => {
if dm_channel.is_none() {
add_pubkey_to_tags(&mut tags, &prof.pubkey).await;
}
}
NostrBech32::Pubkey(pk) => {
if dm_channel.is_none() {
add_pubkey_to_tags(&mut tags, pk).await;
}
}
NostrBech32::Relay(_) => {
// we don't need to add this to tags I don't think.
}
}
}
if let Some((root, _maybeurl)) = parent.replies_to_root() {
// Add an 'e' tag for the root
add_event_to_tags(&mut tags, root, "root").await;
// Standardize nostr links (prepend 'nostr:' where missing)
// (This was a bad idea to do this late in the process, it breaks links that contain
// nostr urls)
// content = NostrUrl::urlize(&content);
// Find and tag all hashtags
for capture in GLOBALS.hashtag_regex.captures_iter(&content) {
tags.push(Tag::Hashtag {
hashtag: capture[1][1..].to_string(),
trailing: Vec::new(),
});
}
if let Some(parent_id) = reply_to {
// Get the event we are replying to
let parent = match GLOBALS.storage.read_event(parent_id)? {
Some(e) => e,
None => return Err("Cannot find event we are replying to.".into()),
};
// Add a 'p' tag for the author we are replying to (except if it is our own key)
if parent.pubkey != public_key {
if dm_channel.is_none() {
add_pubkey_to_tags(&mut tags, &parent.pubkey).await;
}
}
// Add all the 'p' tags from the note we are replying to (except our own)
// FIXME: Should we avoid taging people who are muted?
if dm_channel.is_none() {
for tag in &parent.tags {
if let Tag::Pubkey { pubkey, .. } = tag {
if pubkey.as_str() != public_key.as_hex_string() {
add_pubkey_hex_to_tags(&mut tags, pubkey).await;
}
}
}
}
if let Some((root, _maybeurl)) = parent.replies_to_root() {
// Add an 'e' tag for the root
add_event_to_tags(&mut tags, root, "root").await;
// Add an 'e' tag for the note we are replying to
add_event_to_tags(&mut tags, parent_id, "reply").await;
} else {
let ancestors = parent.referred_events();
if ancestors.is_empty() {
// parent is the root
add_event_to_tags(&mut tags, parent_id, "root").await;
} else {
// Add an 'e' tag for the note we are replying to
// (and we don't know about the root, the parent is malformed).
add_event_to_tags(&mut tags, parent_id, "reply").await;
} else {
let ancestors = parent.referred_events();
if ancestors.is_empty() {
// parent is the root
add_event_to_tags(&mut tags, parent_id, "root").await;
} else {
// Add an 'e' tag for the note we are replying to
// (and we don't know about the root, the parent is malformed).
add_event_to_tags(&mut tags, parent_id, "reply").await;
}
}
// Possibly propagate a subject tag
for tag in &parent.tags {
if let Tag::Subject { subject, .. } = tag {
let mut subject = subject.to_owned();
if !subject.starts_with("Re: ") {
subject = format!("Re: {}", subject);
}
subject = subject.chars().take(80).collect();
add_subject_to_tags_if_missing(&mut tags, subject);
}
}
}
// Possibly propagate a subject tag
for tag in &parent.tags {
if let Tag::Subject { subject, .. } = tag {
let mut subject = subject.to_owned();
if !subject.starts_with("Re: ") {
subject = format!("Re: {}", subject);
}
subject = subject.chars().take(80).collect();
add_subject_to_tags_if_missing(&mut tags, subject);
}
PreEvent {
pubkey: public_key,
created_at: Unixtime::now().unwrap(),
kind: EventKind::TextNote,
tags,
content,
ots: None,
}
}
};
// Copy the tagged pubkeys for determine which relays to send to
tagged_pubkeys = tags
.iter()
.filter_map(|t| {
if let Tag::Pubkey { pubkey, .. } = t {
match PublicKey::try_from_hex_string(pubkey, true) {
Ok(pk) => Some(pk),
_ => None,
}
} else {
None
// Copy the tagged pubkeys for determine which relays to send to
let mut tagged_pubkeys: Vec<PublicKey> = pre_event
.tags
.iter()
.filter_map(|t| {
if let Tag::Pubkey { pubkey, .. } = t {
match PublicKey::try_from_hex_string(pubkey, true) {
Ok(pk) => Some(pk),
_ => None,
}
})
.collect();
let pre_event = PreEvent {
pubkey: public_key,
created_at: Unixtime::now().unwrap(),
kind: EventKind::TextNote,
tags,
content,
ots: None,
};
} else {
None
}
})
.collect();
let event = {
let powint = GLOBALS.storage.read_setting_pow();
let pow = if powint > 0 { Some(powint) } else { None };
let (work_sender, work_receiver) = mpsc::channel();

View File

@ -197,6 +197,17 @@ impl Signer {
}
}
pub fn new_nip04(
&self,
recipient_public_key: PublicKey,
message: &str,
) -> Result<PreEvent, Error> {
match &*self.private.read() {
Some(pk) => Ok(PreEvent::new_nip04(pk, recipient_public_key, message)?),
_ => Err((ErrorKind::NoPrivateKey, file!(), line!()).into()),
}
}
pub fn export_private_key_bech32(&self, pass: &str) -> Result<String, Error> {
let maybe_encrypted = self.encrypted.read().to_owned();
match maybe_encrypted {

View File

@ -2324,27 +2324,9 @@ impl Storage {
let unread = 1 - self.is_event_viewed(event.id)? as usize;
if event.kind == EventKind::EncryptedDirectMessage {
let time = event.created_at;
let dmchannel = {
if event.pubkey != my_pubkey {
// DM sent to me
DmChannel::new(&[event.pubkey])
} else {
// DM sent from me
let mut maybe_channel: Option<DmChannel> = None;
for tag in event.tags.iter() {
if let Tag::Pubkey { pubkey, .. } = tag {
if let Ok(pk) = PublicKey::try_from(pubkey) {
if pk != my_pubkey {
maybe_channel = Some(DmChannel::new(&[pk]));
}
}
}
}
match maybe_channel {
Some(dmchannel) => dmchannel,
None => continue,
}
}
let dmchannel = match DmChannel::from_event(event, Some(my_pubkey)) {
Some(dmc) => dmc,
None => continue,
};
map.entry(dmchannel.clone())
.and_modify(|d| {
@ -2362,15 +2344,9 @@ impl Storage {
if let Ok(rumor) = GLOBALS.signer.unwrap_giftwrap(event) {
let rumor_event = rumor.into_event_with_bad_signature();
let time = rumor_event.created_at;
let dmchannel = {
let mut people: Vec<PublicKey> = rumor_event
.people()
.iter()
.filter_map(|(pk, _, _)| PublicKey::try_from(pk).ok())
.filter(|pk| *pk != my_pubkey)
.collect();
people.push(rumor_event.pubkey); // include author too
DmChannel::new(&people)
let dmchannel = match DmChannel::from_event(&rumor_event, Some(my_pubkey)) {
Some(dmc) => dmc,
None => continue,
};
map.entry(dmchannel.clone())
.and_modify(|d| {
@ -2400,61 +2376,25 @@ impl Storage {
None => return Ok(Vec::new()),
};
let mut pass1 = self.find_events(
let mut output: Vec<Event> = self.find_events(
&[EventKind::EncryptedDirectMessage, EventKind::GiftWrap],
&[],
Some(Unixtime(0)),
|event| {
if event.kind == EventKind::EncryptedDirectMessage {
if channel.keys().len() > 1 { return false; }
if channel.keys().len() == 0 { return true; } // self-channel
let other = &channel.keys()[0];
let people = event.people();
match people.len() {
1 => (event.pubkey == my_pubkey && event.is_tagged(other))
|| (event.pubkey == *other && event.is_tagged(&my_pubkey)),
2 => (event.pubkey == my_pubkey || event.pubkey == *other)
&& (event.is_tagged(&my_pubkey) && event.is_tagged(other)),
_ => false,
if let Some(event_dm_channel) = DmChannel::from_event(event, Some(my_pubkey)) {
if event_dm_channel == *channel {
return true;
}
} else if event.kind == EventKind::GiftWrap {
// Decrypt in next pass, else we would have to decrypt twice
true
} else {
false
}
false
},
false,
)?;
let mut pass2: Vec<Event> = Vec::new();
for event in pass1.drain(..) {
if event.kind == EventKind::EncryptedDirectMessage {
pass2.push(event); // already validated
} else if event.kind == EventKind::GiftWrap {
if let Ok(rumor) = GLOBALS.signer.unwrap_giftwrap(&event) {
let mut rumor_event = rumor.into_event_with_bad_signature();
rumor_event.id = event.id; // lie, so it indexes it under the giftwrap
let mut tagged: Vec<PublicKey> = rumor_event
.people()
.drain(..)
.filter_map(|(pkh, _, _)| PublicKey::try_from(pkh).ok())
.collect();
tagged.push(rumor_event.pubkey); // include author
tagged.retain(|pk| *pk != my_pubkey); // never include user
let this_channel = DmChannel::new(&tagged);
if this_channel == *channel {
pass2.push(event);
}
}
}
}
// sort
pass2.sort_by(|a, b| b.created_at.cmp(&a.created_at));
output.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(pass2.iter().map(|e| e.id).collect())
Ok(output.iter().map(|e| e.id).collect())
}
pub fn rebuild_event_indices(&self) -> Result<(), Error> {

View File

@ -16,7 +16,6 @@ pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Fr
ui.heading("Direct Private Message Channels");
ui.add_space(12.0);
ui.separator();
ScrollArea::vertical()
.id_source("dm_chat_list")

View File

@ -8,6 +8,7 @@ use super::notedata::{NoteData, RepostType};
use super::FeedNoteParams;
use crate::comms::ToOverlordMessage;
use crate::dm_channel::DmChannel;
use crate::feed::FeedKind;
use crate::globals::{ZapState, GLOBALS};
use crate::ui::widgets::CopyButton;
@ -615,61 +616,65 @@ fn render_note_inner(
ui.add_space(24.0);
if render_data.can_post
&& note.event.kind != EventKind::EncryptedDirectMessage
{
// Button to Repost
if ui
.add(
Label::new(RichText::new("").size(18.0))
.sense(Sense::click()),
)
.on_hover_text("Repost")
.clicked()
if render_data.can_post {
if note.event.kind != EventKind::EncryptedDirectMessage
&& note.event.kind != EventKind::DmChat
{
app.draft_repost = Some(note.event.id);
app.replying_to = None;
app.show_post_area = true;
}
ui.add_space(24.0);
// Button to quote note
if ui
.add(
Label::new(RichText::new("“…”").size(18.0))
.sense(Sense::click()),
)
.on_hover_text("Quote")
.clicked()
{
if !app.draft.ends_with(' ') && !app.draft.is_empty() {
app.draft.push(' ');
// Button to Repost
if ui
.add(
Label::new(RichText::new("").size(18.0))
.sense(Sense::click()),
)
.on_hover_text("Repost")
.clicked()
{
app.draft_repost = Some(note.event.id);
app.replying_to = None;
app.draft_dm_channel = None;
app.show_post_area = true;
}
let event_pointer = EventPointer {
id: note.event.id,
relays: match GLOBALS
.storage
.get_event_seen_on_relay(note.event.id)
{
Err(_) => vec![],
Ok(vec) => vec
.iter()
.map(|(url, _)| url.to_unchecked_url())
.collect(),
},
author: None,
kind: None,
};
let nostr_url: NostrUrl = event_pointer.into();
app.draft.push_str(&format!("{}", nostr_url));
app.draft_repost = None;
app.replying_to = None;
app.show_post_area = true;
app.draft_needs_focus = true;
}
ui.add_space(24.0);
ui.add_space(24.0);
// Button to quote note
if ui
.add(
Label::new(RichText::new("“…”").size(18.0))
.sense(Sense::click()),
)
.on_hover_text("Quote")
.clicked()
{
if !app.draft.ends_with(' ') && !app.draft.is_empty() {
app.draft.push(' ');
}
let event_pointer = EventPointer {
id: note.event.id,
relays: match GLOBALS
.storage
.get_event_seen_on_relay(note.event.id)
{
Err(_) => vec![],
Ok(vec) => vec
.iter()
.map(|(url, _)| url.to_unchecked_url())
.collect(),
},
author: None,
kind: None,
};
let nostr_url: NostrUrl = event_pointer.into();
app.draft.push_str(&format!("{}", nostr_url));
app.draft_repost = None;
app.replying_to = None;
app.draft_dm_channel = None;
app.show_post_area = true;
app.draft_needs_focus = true;
}
ui.add_space(24.0);
}
// Button to reply
if ui
@ -683,6 +688,8 @@ fn render_note_inner(
app.replying_to = Some(note.event.id);
app.draft_repost = None;
app.show_post_area = true;
app.draft_dm_channel =
DmChannel::from_event(&note.event, None);
app.draft_needs_focus = true;
}

View File

@ -163,6 +163,12 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
});
}
if let Some(dm_channel) = &app.draft_dm_channel {
ui.horizontal(|ui| {
ui.label(format!("DIRECT MESSAGE TO: {}", dm_channel.name()));
});
}
let draft_response = ui.add(
text_edit_multiline!(app, app.draft)
.id_source("compose_area")
@ -296,6 +302,7 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
app.draft.clone(),
tags,
Some(replying_to_id),
app.draft_dm_channel.clone(),
));
}
None => {
@ -308,6 +315,7 @@ fn real_posting_area(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
app.draft.clone(),
tags,
None,
app.draft_dm_channel.clone(),
));
}
}

View File

@ -26,6 +26,7 @@ mod you;
use crate::about::About;
use crate::comms::ToOverlordMessage;
use crate::dm_channel::DmChannel;
use crate::error::Error;
use crate::feed::FeedKind;
use crate::globals::{ZapState, GLOBALS};
@ -243,6 +244,7 @@ struct GossipUi {
subject: String,
include_content_warning: bool,
content_warning: String,
draft_dm_channel: Option<DmChannel>,
replying_to: Option<Id>,
// User entry: metadata
@ -460,6 +462,7 @@ impl GossipUi {
subject: "".to_owned(),
include_content_warning: false,
content_warning: "".to_owned(),
draft_dm_channel: None,
replying_to: None,
editing_metadata: false,
metadata: Metadata::new(),
@ -551,6 +554,7 @@ impl GossipUi {
self.include_subject = false;
self.subject = "".to_owned();
self.replying_to = None;
self.draft_dm_channel = None;
self.include_content_warning = false;
self.content_warning = "".to_owned();
}
@ -802,6 +806,11 @@ impl eframe::App for GossipUi {
let response = ui.add_sized([crate::AVATAR_SIZE_F32, crate::AVATAR_SIZE_F32], egui::Button::new(text.color(self.settings.theme.navigation_text_color())).stroke(egui::Stroke::NONE).rounding(egui::Rounding::same(crate::AVATAR_SIZE_F32)).fill(self.settings.theme.navigation_bg_fill()));
if response.clicked() {
self.show_post_area = true;
if let Page::Feed(FeedKind::DmChat(channel)) = &self.page {
self.draft_dm_channel = Some(channel.clone());
} else {
self.draft_dm_channel = None;
}
if GLOBALS.signer.is_ready() {
self.draft_needs_focus = true;
} else {
@ -947,6 +956,15 @@ impl GossipUi {
app.set_page(Page::Feed(FeedKind::Person(person.pubkey)));
}
}
if GLOBALS.signer.is_ready() {
if ui.button("Send DM").clicked() {
app.replying_to = None;
app.draft_repost = None;
app.show_post_area = true;
app.draft_dm_channel = Some(DmChannel::new(&[person.pubkey]));
app.draft_needs_focus = true;
}
}
});
if person.followed {

View File

@ -73,12 +73,14 @@ macro_rules! theme_dispatch {
}
}
#[allow(dead_code)]
pub fn highlight_color(&self) -> Color32 {
match self.variant {
$( $variant => $class::highlight_color(self.dark_mode), )+
}
}
#[allow(dead_code)]
pub fn accent_complementary_color(&self) -> Color32 {
match self.variant {
$( $variant => $class::accent_complementary_color(self.dark_mode), )+