Merge branch 'dmchat' into unstable

This commit is contained in:
Mike Dilger 2023-08-31 11:09:17 +12:00
commit 0eb3bb7c59
13 changed files with 389 additions and 40 deletions

4
Cargo.lock generated
View File

@ -1871,7 +1871,7 @@ dependencies = [
[[package]]
name = "gossip-relay-picker"
version = "0.2.0-unstable"
source = "git+https://github.com/mikedilger/gossip-relay-picker?rev=7e33f6c35ce0147310545cc984eb263cd96eb380#7e33f6c35ce0147310545cc984eb263cd96eb380"
source = "git+https://github.com/mikedilger/gossip-relay-picker?rev=1dc2d9a8e9bce1cf54c6ab4cdbba2f2b317cbfc7#1dc2d9a8e9bce1cf54c6ab4cdbba2f2b317cbfc7"
dependencies = [
"async-trait",
"dashmap",
@ -2698,7 +2698,7 @@ dependencies = [
[[package]]
name = "nostr-types"
version = "0.7.0-unstable"
source = "git+https://github.com/mikedilger/nostr-types?rev=232713f21141c0a7331a6a7a93bcc9343dfbb3d9#232713f21141c0a7331a6a7a93bcc9343dfbb3d9"
source = "git+https://github.com/mikedilger/nostr-types?rev=0df20de534cf226910d382087a30880039372ef2#0df20de534cf226910d382087a30880039372ef2"
dependencies = [
"aes",
"base64 0.21.2",

View File

@ -38,7 +38,7 @@ fallible-iterator = "0.2"
filetime = "0.2"
futures = "0.3"
futures-util = "0.3"
gossip-relay-picker = { git = "https://github.com/mikedilger/gossip-relay-picker", rev = "7e33f6c35ce0147310545cc984eb263cd96eb380" }
gossip-relay-picker = { git = "https://github.com/mikedilger/gossip-relay-picker", rev = "1dc2d9a8e9bce1cf54c6ab4cdbba2f2b317cbfc7" }
heed = { git = "https://github.com/meilisearch/heed", rev = "02030e3bf3d26ee98d4f5343fc086a7b63289159" }
hex = "0.4"
http = "0.2"
@ -49,7 +49,7 @@ lazy_static = "1.4"
linkify = "0.9"
memoize = "0.4"
mime = "0.3"
nostr-types = { git = "https://github.com/mikedilger/nostr-types", rev = "232713f21141c0a7331a6a7a93bcc9343dfbb3d9", features = [ "speedy" ] }
nostr-types = { git = "https://github.com/mikedilger/nostr-types", rev = "0df20de534cf226910d382087a30880039372ef2", features = [ "speedy" ] }
parking_lot = "0.12"
paste = "1.0"
qrcode = { git = "https://github.com/mikedilger/qrcode-rust", rev = "519b77b3efa3f84961169b47d3de08c5ddd86548" }

View File

@ -197,7 +197,6 @@ pub fn events_of_pubkey_and_kind(mut args: env::Args) -> Result<(), Error> {
}
};
let kind: EventKind = match args.next() {
Some(integer) => integer.parse::<u32>()?.into(),
None => {

View File

@ -78,7 +78,7 @@ pub enum ToMinionPayloadDetail {
PullFollowing,
Shutdown,
SubscribeAugments(Vec<IdHex>),
SubscribeConfig,
SubscribeOutbox,
SubscribeDiscover(Vec<PublicKey>),
SubscribeGeneralFeed(Vec<PublicKey>),
SubscribeMentions,

57
src/dm_channel.rs Normal file
View File

@ -0,0 +1,57 @@
use nostr_types::{PublicKey, Unixtime};
use sha2::Digest;
/// This represents a DM (direct message) channel which includes a set
/// of participants (usually just one, but can be a small group).
// internally the pubkeys are kept sorted so they can be compared
// that is why we don't expose the inner field directly.
//
// The pubkey of the gossip user is not included. If they send themselves
// a note, that channel has an empty vec.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DmChannel(Vec<PublicKey>);
impl DmChannel {
pub fn new(public_keys: &[PublicKey]) -> DmChannel {
let mut vec = public_keys.to_owned();
vec.sort();
vec.dedup();
DmChannel(vec)
}
pub fn keys(&self) -> &[PublicKey] {
&self.0
}
pub fn name(&self) -> String {
let mut output = String::new();
let mut first = true;
for pubkey in &self.0 {
if first {
first = false;
} else {
output.push_str(", ");
}
let name = crate::names::display_name_from_pubkey_lookup(pubkey);
output.push_str(&name);
}
output
}
pub fn unique_id(&self) -> String {
let mut hasher = sha2::Sha256::new();
for pk in &self.0 {
hasher.update(pk.as_bytes());
}
hex::encode(hasher.finalize())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DmChannelData {
pub dm_channel: DmChannel,
pub latest_message: Unixtime,
pub message_count: usize,
pub unread_message_count: usize,
}

View File

@ -1,4 +1,5 @@
use crate::comms::{ToMinionMessage, ToMinionPayload, ToMinionPayloadDetail, ToOverlordMessage};
use crate::dm_channel::DmChannel;
use crate::error::Error;
use crate::globals::GLOBALS;
use nostr_types::{EventDelegation, EventKind, Id, PublicKey, RelayUrl, Unixtime};
@ -18,6 +19,7 @@ pub enum FeedKind {
author: Option<PublicKey>,
},
Person(PublicKey),
DmChat(DmChannel),
}
pub struct Feed {
@ -28,6 +30,7 @@ pub struct Feed {
followed_feed: RwLock<Vec<Id>>,
inbox_feed: RwLock<Vec<Id>>,
person_feed: RwLock<Vec<Id>>,
dm_chat_feed: RwLock<Vec<Id>>,
// We only recompute the feed at specified intervals (or when they switch)
interval_ms: RwLock<u32>,
@ -44,6 +47,7 @@ impl Feed {
followed_feed: RwLock::new(Vec::new()),
inbox_feed: RwLock::new(Vec::new()),
person_feed: RwLock::new(Vec::new()),
dm_chat_feed: RwLock::new(Vec::new()),
interval_ms: RwLock::new(10000), // Every 10 seconds, until we load from settings
last_computed: RwLock::new(None),
thread_parent: RwLock::new(None),
@ -151,6 +155,16 @@ impl Feed {
});
}
pub fn set_feed_to_dmchat(&self, channel: DmChannel) {
*self.current_feed_kind.write() = FeedKind::DmChat(channel);
*self.thread_parent.write() = None;
// Recompute as they switch
self.sync_recompute();
self.unlisten();
}
pub fn get_feed_kind(&self) -> FeedKind {
self.current_feed_kind.read().to_owned()
}
@ -170,6 +184,11 @@ impl Feed {
self.person_feed.read().clone()
}
pub fn get_dm_chat_feed(&self) -> Vec<Id> {
self.sync_maybe_periodic_recompute();
self.dm_chat_feed.read().clone()
}
pub fn get_thread_parent(&self) -> Option<Id> {
self.sync_maybe_periodic_recompute();
*self.thread_parent.read()
@ -374,6 +393,10 @@ impl Feed {
*self.person_feed.write() = events;
}
FeedKind::DmChat(channel) => {
let ids = GLOBALS.storage.dm_events(&channel)?;
*self.dm_chat_feed.write() = ids;
}
}
self.recompute_lock.store(false, Ordering::Relaxed);

View File

@ -29,6 +29,7 @@ mod commands;
mod comms;
mod date_ago;
mod delegation;
mod dm_channel;
mod error;
mod feed;
mod fetcher;

View File

@ -367,8 +367,8 @@ impl Minion {
ToMinionPayloadDetail::SubscribeMentions => {
self.subscribe_mentions(message.job_id).await?;
}
ToMinionPayloadDetail::SubscribeConfig => {
self.subscribe_config(message.job_id).await?;
ToMinionPayloadDetail::SubscribeOutbox => {
self.subscribe_outbox(message.job_id).await?;
}
ToMinionPayloadDetail::SubscribeDiscover(pubkeys) => {
self.subscribe_discover(message.job_id, pubkeys).await?;
@ -473,20 +473,8 @@ impl Minion {
}
};
// Allow all feed related event kinds (excluding DMs)
let event_kinds = crate::feed::feed_related_event_kinds(false);
if let Some(pubkey) = GLOBALS.signer.public_key() {
// feed related by me
// FIXME copy this to listening to my write relays
let pkh: PublicKeyHex = pubkey.into();
filters.push(Filter {
authors: vec![pkh.into()],
kinds: event_kinds.clone(),
since: Some(feed_since),
..Default::default()
});
}
// Allow all feed related event kinds (including DMs)
let event_kinds = crate::feed::feed_related_event_kinds(true);
if !followed_pubkeys.is_empty() {
let pkp: Vec<PublicKeyHexPrefix> = followed_pubkeys
@ -634,22 +622,43 @@ impl Minion {
Ok(())
}
// Subscribe to the user's config which is on their own write relays
async fn subscribe_config(&mut self, job_id: u64) -> Result<(), Error> {
// Subscribe to the user's output (config, DMs, etc) which is on their own write relays
async fn subscribe_outbox(&mut self, job_id: u64) -> Result<(), Error> {
if let Some(pubkey) = GLOBALS.signer.public_key() {
let pkh: PublicKeyHex = pubkey.into();
let filters: Vec<Filter> = vec![Filter {
authors: vec![pkh.into()],
kinds: vec![
EventKind::Metadata,
//EventKind::RecommendRelay,
EventKind::ContactList,
EventKind::RelayList,
],
// these are all replaceable, no since required
..Default::default()
}];
// Read back in things that we wrote out to our write relays
// that we need
let filters: Vec<Filter> = vec![
// Actual config stuff
Filter {
authors: vec![pkh.clone().into()],
kinds: vec![
EventKind::Metadata,
//EventKind::RecommendRelay,
EventKind::ContactList,
EventKind::RelayList,
],
// these are all replaceable, no since required
..Default::default()
},
// DMs I wrote, all the way back (temp?)
Filter {
authors: vec![pkh.clone().into()],
kinds: vec![EventKind::EncryptedDirectMessage],
// all the way back
..Default::default()
},
// GiftWraps I wrote anonymously to myself
// Posts I wrote, recently
Filter {
authors: vec![pkh.into()],
kinds: crate::feed::feed_related_event_kinds(false), // not DMs
// all the way back
..Default::default()
},
];
self.subscribe(filters, "temp_config_feed", job_id).await?;
}

View File

@ -151,7 +151,7 @@ impl Overlord {
.await?;
}
// Separately subscribe to our config on our write relays
// Separately subscribe to our outbox events on our write relays
let write_relay_urls: Vec<RelayUrl> = GLOBALS
.storage
.filter_relays(|r| r.has_usage_bits(Relay::WRITE))?
@ -165,7 +165,7 @@ impl Overlord {
reason: RelayConnectionReason::Config,
payload: ToMinionPayload {
job_id: rand::random::<u64>(),
detail: ToMinionPayloadDetail::SubscribeConfig,
detail: ToMinionPayloadDetail::SubscribeOutbox,
},
}],
)

View File

@ -14,6 +14,7 @@ mod import;
mod migrations;
mod types;
use crate::dm_channel::{DmChannel, DmChannelData};
use crate::error::{Error, ErrorKind};
use crate::globals::GLOBALS;
use crate::people::Person;
@ -2293,6 +2294,169 @@ impl Storage {
Ok(ranked_relays)
}
/// Get all the DM channels with associated data
pub fn dm_channels(&self) -> Result<Vec<DmChannelData>, Error> {
let my_pubkey = match GLOBALS.signer.public_key() {
Some(pk) => pk,
None => return Ok(Vec::new()),
};
let events = self.find_events(
&[EventKind::EncryptedDirectMessage, EventKind::GiftWrap],
&[],
Some(Unixtime(0)),
|event| {
if event.kind == EventKind::EncryptedDirectMessage {
event.pubkey == my_pubkey || event.is_tagged(&my_pubkey)
// Make sure if it has tags, only author and my_pubkey
// TBD
} else {
event.kind == EventKind::GiftWrap
}
},
false,
)?;
// Map from channel to latest-message-time and unread-count
let mut map: HashMap<DmChannel, DmChannelData> = HashMap::new();
for event in &events {
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,
}
}
};
map.entry(dmchannel.clone())
.and_modify(|d| {
d.latest_message = d.latest_message.max(time);
d.message_count += 1;
d.unread_message_count += unread;
})
.or_insert(DmChannelData {
dm_channel: dmchannel,
latest_message: time,
message_count: 1,
unread_message_count: unread,
});
} 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 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)
};
map.entry(dmchannel.clone())
.and_modify(|d| {
d.latest_message = d.latest_message.max(time);
d.message_count += 1;
d.unread_message_count += unread;
})
.or_insert(DmChannelData {
dm_channel: dmchannel,
latest_message: time,
message_count: 1,
unread_message_count: unread,
});
}
}
}
let mut output: Vec<DmChannelData> = map.drain().map(|e| e.1).collect();
output.sort_by(|a, b| b.latest_message.cmp(&a.latest_message));
Ok(output)
}
/// Get DM events (by id) in a channel
pub fn dm_events(&self, channel: &DmChannel) -> Result<Vec<Id>, Error> {
let my_pubkey = match GLOBALS.signer.public_key() {
Some(pk) => pk,
None => return Ok(Vec::new()),
};
let mut pass1 = 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,
}
} else if event.kind == EventKind::GiftWrap {
// Decrypt in next pass, else we would have to decrypt twice
true
} else {
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));
Ok(pass2.iter().map(|e| e.id).collect())
}
pub fn rebuild_event_indices(&self) -> Result<(), Error> {
let mut wtxn = self.env.write_txn()?;
let mut last_key = Id([0; 32]);

65
src/ui/dm_chat_list.rs Normal file
View File

@ -0,0 +1,65 @@
use super::{GossipUi, Page};
use crate::dm_channel::DmChannelData;
use crate::feed::FeedKind;
use crate::globals::GLOBALS;
use eframe::egui;
use egui::{Context, Label, RichText, ScrollArea, Sense, Ui};
pub(super) fn update(app: &mut GossipUi, _ctx: &Context, _frame: &mut eframe::Frame, ui: &mut Ui) {
let mut channels: Vec<DmChannelData> = match GLOBALS.storage.dm_channels() {
Ok(channels) => channels,
Err(_) => {
ui.label("ERROR");
return;
}
};
ui.heading("Direct Private Message Channels");
ui.add_space(12.0);
ui.separator();
ScrollArea::vertical()
.id_source("dm_chat_list")
.max_width(f32::INFINITY)
.auto_shrink([false, false])
.show(ui, |ui| {
let color = app.settings.theme.accent_color();
for channeldata in channels.drain(..) {
ui.horizontal_wrapped(|ui| {
let channel_name = channeldata.dm_channel.name();
ui.label(format!(
"({}/{})",
channeldata.unread_message_count, channeldata.message_count
));
ui.label(
RichText::new(crate::date_ago::date_ago(channeldata.latest_message))
.italics()
.weak(),
)
.on_hover_ui(|ui| {
if let Ok(stamp) =
time::OffsetDateTime::from_unix_timestamp(channeldata.latest_message.0)
{
if let Ok(formatted) =
stamp.format(&time::format_description::well_known::Rfc2822)
{
ui.label(formatted);
}
}
});
if ui
.add(
Label::new(RichText::new(channel_name).color(color))
.sense(Sense::click()),
)
.clicked()
{
app.set_page(Page::Feed(FeedKind::DmChat(channeldata.dm_channel)));
}
});
ui.add_space(20.0);
}
});
}

View File

@ -145,6 +145,15 @@ pub(super) fn update(app: &mut GossipUi, ctx: &Context, frame: &mut eframe::Fram
let feed = GLOBALS.feed.get_person_feed();
render_a_feed(app, ctx, frame, ui, feed, false, &pubkey.as_hex_string());
}
FeedKind::DmChat(channel) => {
ui.horizontal(|ui| {
recompute_btn(app, ui);
});
let feed = GLOBALS.feed.get_dm_chat_feed();
let id = channel.unique_id();
render_a_feed(app, ctx, frame, ui, feed, false, &id);
}
}
// Handle any changes due to changes in which notes are visible

View File

@ -13,6 +13,7 @@ macro_rules! text_edit_multiline {
}
mod components;
mod dm_chat_list;
mod feed;
mod help;
mod people;
@ -94,6 +95,7 @@ pub fn run() -> Result<(), Error> {
#[derive(Debug, Clone, PartialEq)]
enum Page {
DmChatList,
Feed(FeedKind),
PeopleList,
PeopleFollow,
@ -114,6 +116,7 @@ enum Page {
#[derive(Eq, Hash, PartialEq)]
enum SubMenu {
DmChat,
People,
Relays,
Account,
@ -123,6 +126,7 @@ enum SubMenu {
impl SubMenu {
fn to_id_str(&self) -> &'static str {
match self {
SubMenu::DmChat => "dmchat_submenu",
SubMenu::People => "people_submenu",
SubMenu::Account => "account_submenu",
SubMenu::Relays => "relays_submenu",
@ -148,6 +152,7 @@ enum SettingsTab {
impl SubMenuState {
fn new() -> Self {
let mut submenu_states: HashMap<SubMenu, bool> = HashMap::new();
submenu_states.insert(SubMenu::DmChat, false);
submenu_states.insert(SubMenu::People, false);
submenu_states.insert(SubMenu::Relays, false);
submenu_states.insert(SubMenu::Account, false);
@ -321,6 +326,7 @@ impl GossipUi {
}
let mut submenu_ids: HashMap<SubMenu, egui::Id> = HashMap::new();
submenu_ids.insert(SubMenu::DmChat, egui::Id::new(SubMenu::DmChat.to_id_str()));
submenu_ids.insert(SubMenu::People, egui::Id::new(SubMenu::People.to_id_str()));
submenu_ids.insert(
SubMenu::Account,
@ -525,6 +531,9 @@ impl GossipUi {
Page::Feed(FeedKind::Person(pubkey)) => {
GLOBALS.feed.set_feed_to_person(pubkey.to_owned());
}
Page::Feed(FeedKind::DmChat(pubkeys)) => {
GLOBALS.feed.set_feed_to_dmchat(pubkeys.to_owned());
}
Page::Search => {
self.entering_search_page = true;
}
@ -658,13 +667,25 @@ impl eframe::App for GossipUi {
if self.add_selected_label(ui, matches!(&self.page, Page::Feed(FeedKind::Person(key)) if *key == pubkey), "My Notes").clicked() {
self.set_page(Page::Feed(FeedKind::Person(pubkey)));
}
}
if self.add_selected_label(ui, matches!(self.page, Page::Feed(FeedKind::Inbox(_))), "Inbox").clicked() {
self.set_page(Page::Feed(FeedKind::Inbox(self.inbox_include_indirect)));
if self.add_selected_label(ui, matches!(self.page, Page::Feed(FeedKind::Inbox(_))), "Inbox").clicked() {
self.set_page(Page::Feed(FeedKind::Inbox(self.inbox_include_indirect)));
}
}
ui.add_space(8.0);
// ---- DM Chat Submenu ----
if GLOBALS.signer.public_key().is_some() {
let (mut submenu, header_response) = self.get_openable_menu(ui, SubMenu::DmChat, "DM Chat");
submenu.show_body_indented(&header_response, ui, |ui| {
self.add_menu_item_page(ui, Page::DmChatList, "List");
if let Page::Feed(FeedKind::DmChat(channel)) = &self.page {
self.add_menu_item_page(ui, Page::Feed(FeedKind::DmChat(channel.clone())),
&channel.name());
}
});
self.after_openable_menu(ui, &submenu);
}
// ---- People Submenu ----
{
let (mut submenu, header_response) = self.get_openable_menu(ui, SubMenu::People, "People");
@ -870,6 +891,7 @@ impl eframe::App for GossipUi {
.show(ctx, |ui| {
self.begin_ui(ui);
match self.page {
Page::DmChatList => dm_chat_list::update(self, ctx, frame, ui),
Page::Feed(_) => feed::update(self, ctx, frame, ui),
Page::PeopleList | Page::PeopleFollow | Page::PeopleMuted | Page::Person(_) => {
people::update(self, ctx, frame, ui)