feat: sharpen chat experiences (#9)

* feat: add global account and refactor chat registry

* chore: improve last seen

* chore: reduce string alloc

* wip: refactor room

* chore: fix edit profile panel

* chore: refactor open window in main

* chore: refactor sidebar

* chore: refactor room
This commit is contained in:
reya
2025-02-23 08:29:05 +07:00
committed by GitHub
parent cfa628a8a6
commit bbc778d5ca
23 changed files with 1167 additions and 1150 deletions

View File

@@ -1,55 +1,54 @@
use chrono::{Datelike, Local, TimeZone};
use chrono::{Local, TimeZone};
use gpui::SharedString;
use nostr_sdk::prelude::*;
const NOW: &str = "now";
const SECONDS_IN_MINUTE: i64 = 60;
const MINUTES_IN_HOUR: i64 = 60;
const HOURS_IN_DAY: i64 = 24;
const DAYS_IN_MONTH: i64 = 30;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct LastSeen(pub Timestamp);
impl LastSeen {
pub fn ago(&self) -> SharedString {
let now = Local::now();
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap();
let diff = (now - input_time).num_hours();
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "Invalid timestamp".into(),
};
let duration = now.signed_duration_since(input_time);
if diff < 24 {
let duration = now.signed_duration_since(input_time);
if duration.num_seconds() < 60 {
"now".to_string().into()
} else if duration.num_minutes() == 1 {
"1m".to_string().into()
} else if duration.num_minutes() < 60 {
format!("{}m", duration.num_minutes()).into()
} else if duration.num_hours() == 1 {
"1h".to_string().into()
} else if duration.num_hours() < 24 {
format!("{}h", duration.num_hours()).into()
} else if duration.num_days() == 1 {
"1d".to_string().into()
} else {
format!("{}d", duration.num_days()).into()
}
} else {
input_time.format("%b %d").to_string().into()
match duration {
d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(),
d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()),
d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()),
d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()),
_ => input_time.format("%b %d").to_string(),
}
.into()
}
pub fn human_readable(&self) -> SharedString {
let now = Local::now();
let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap();
let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) {
chrono::LocalResult::Single(time) => time,
_ => return "Invalid timestamp".into(),
};
if input_time.day() == now.day() {
format!("Today at {}", input_time.format("%H:%M %p")).into()
} else if input_time.day() == now.day() - 1 {
format!("Yesterday at {}", input_time.format("%H:%M %p")).into()
} else {
format!(
"{}, {}",
input_time.format("%d/%m/%y"),
input_time.format("%H:%M %p")
)
.into()
let input_date = input_time.date_naive();
let now_date = now.date_naive();
let yesterday_date = (now - chrono::Duration::days(1)).date_naive();
let time_format = input_time.format("%H:%M %p");
match input_date {
date if date == now_date => format!("Today at {time_format}"),
date if date == yesterday_date => format!("Yesterday at {time_format}"),
_ => format!("{}, {time_format}", input_time.format("%d/%m/%y")),
}
.into()
}
pub fn set(&mut self, created_at: Timestamp) {

View File

@@ -1,37 +1,24 @@
use crate::constants::IMAGE_SERVICE;
use gpui::SharedString;
use nostr_sdk::prelude::*;
#[derive(Debug, Clone)]
use crate::constants::IMAGE_SERVICE;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NostrProfile {
public_key: PublicKey,
metadata: Metadata,
}
impl AsRef<PublicKey> for NostrProfile {
fn as_ref(&self) -> &PublicKey {
&self.public_key
}
}
impl AsRef<Metadata> for NostrProfile {
fn as_ref(&self) -> &Metadata {
&self.metadata
}
}
impl Eq for NostrProfile {}
impl PartialEq for NostrProfile {
fn eq(&self, other: &Self) -> bool {
self.public_key() == other.public_key()
}
avatar: SharedString,
name: SharedString,
}
impl NostrProfile {
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
let name = Self::extract_name(&public_key, &metadata);
let avatar = Self::extract_avatar(&metadata);
Self {
public_key,
metadata,
name,
avatar,
}
}
@@ -40,47 +27,44 @@ impl NostrProfile {
self.public_key
}
/// Get contact's avatar
pub fn avatar(&self) -> String {
if let Some(picture) = &self.metadata.picture {
if picture.len() > 1 {
pub fn avatar(&self) -> SharedString {
self.avatar.clone()
}
pub fn name(&self) -> SharedString {
self.name.clone()
}
fn extract_avatar(metadata: &Metadata) -> SharedString {
metadata
.picture
.as_ref()
.filter(|picture| !picture.is_empty())
.map(|picture| {
format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE, picture
)
} else {
"brand/avatar.png".into()
}
} else {
"brand/avatar.png".into()
}
.into()
})
.unwrap_or_else(|| "brand/avatar.png".into())
}
/// Get contact's name, fallback to public key as shorted format
pub fn name(&self) -> String {
if let Some(display_name) = &self.metadata.display_name {
fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString {
if let Some(display_name) = metadata.display_name.as_ref() {
if !display_name.is_empty() {
return display_name.to_owned();
return display_name.into();
}
}
if let Some(name) = &self.metadata.name {
if let Some(name) = metadata.name.as_ref() {
if !name.is_empty() {
return name.to_owned();
return name.into();
}
}
let pubkey = self.public_key.to_string();
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..])
}
let pubkey = public_key.to_hex();
/// Get contact's metadata
pub fn metadata(&mut self) -> &Metadata {
&self.metadata
}
/// Set contact's metadata
pub fn set_metadata(&mut self, metadata: &Metadata) {
self.metadata = metadata.clone()
format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into()
}
}

View File

@@ -1,62 +1,12 @@
use crate::constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP96_SERVER};
use crate::constants::NIP96_SERVER;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use rnglib::{Language, RNG};
use std::{
collections::HashSet,
hash::{DefaultHasher, Hash, Hasher},
time::Duration,
};
pub async fn signer_public_key(client: &Client) -> anyhow::Result<PublicKey, anyhow::Error> {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
Ok(public_key)
}
pub async fn preload(client: &Client, public_key: PublicKey) -> anyhow::Result<(), anyhow::Error> {
let sync_opts = SyncOptions::default();
let subscription = Filter::new()
.kind(Kind::ContactList)
.author(public_key)
.limit(1);
// Get contact list
_ = client.sync(subscription, &sync_opts).await;
let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);
let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID);
// Create a filter for getting all gift wrapped events send to current user
let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key);
// Create a filter for getting new message
let new_message = Filter::new()
.kind(Kind::GiftWrap)
.pubkey(public_key)
.limit(0);
// Subscribe for all messages
_ = client
.subscribe_with_id(
all_messages_sub_id,
all_messages,
Some(
SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::WaitDurationAfterEOSE(Duration::from_secs(3))),
),
)
.await;
// Subscribe for new message
_ = client
.subscribe_with_id(new_message_sub_id, new_message, None)
.await;
Ok(())
}
pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url, anyhow::Error> {
let signer = client.signer().await?;
let server_url = Url::parse(NIP96_SERVER)?;
@@ -68,10 +18,28 @@ pub async fn nip96_upload(client: &Client, file: Vec<u8>) -> anyhow::Result<Url,
}
pub fn room_hash(event: &Event) -> u64 {
let pubkeys: Vec<&PublicKey> = event.tags.public_keys().unique().collect();
let mut hasher = DefaultHasher::new();
let mut pubkeys: Vec<&PublicKey> = vec![];
// Add all public keys from event
pubkeys.push(&event.pubkey);
pubkeys.extend(
event
.tags
.public_keys()
.unique()
.sorted()
.collect::<Vec<_>>(),
);
// Generate unique hash
pubkeys.hash(&mut hasher);
pubkeys
.into_iter()
.unique()
.sorted()
.collect::<Vec<_>>()
.hash(&mut hasher);
hasher.finish()
}