use std::collections::HashMap;
use GORBIE::prelude::CardCtx;
use GORBIE::themes::colorhash;
use triblespace::core::id::Id;
use triblespace::core::metadata;
use triblespace::core::repo::pile::Pile;
use triblespace::core::repo::{CommitHandle, Workspace};
use triblespace::core::trible::TribleSet;
use triblespace::core::inline::encodings::hash::{Blake3, Handle};
use triblespace::core::inline::Inline;
use triblespace::macros::{find, pattern};
use triblespace::prelude::blobencodings::LongString;
use triblespace::prelude::View;
use crate::schemas::local_messages::{local, KIND_MESSAGE_ID, KIND_READ_ID};
use crate::schemas::relations::{relations as rel, KIND_PERSON_ID};
type TextHandle = Inline<Handle<LongString>>;
fn id_hex(id: Id) -> String {
format!("{id:x}")
}
fn now_tai_ns() -> i128 {
hifitime::Epoch::now()
.map(|e| e.to_tai_duration().total_nanoseconds())
.unwrap_or(0)
}
fn format_age(now_key: i128, maybe_key: Option<i128>) -> String {
let Some(key) = maybe_key else {
return "-".to_string();
};
let delta_ns = now_key.saturating_sub(key);
let delta_s = (delta_ns / 1_000_000_000).max(0) as i64;
if delta_s < 60 {
format!("{delta_s}s")
} else if delta_s < 60 * 60 {
format!("{}m", delta_s / 60)
} else if delta_s < 24 * 60 * 60 {
format!("{}h", delta_s / 3600)
} else {
format!("{}d", delta_s / 86_400)
}
}
fn format_age_key(now_key: i128, past_key: i128) -> String {
format_age(now_key, Some(past_key))
}
fn format_timestamp_key(key: i128) -> String {
let ns = hifitime::Duration::from_total_nanoseconds(key);
let epoch = hifitime::Epoch::from_tai_duration(ns);
let (y, m, d, h, min, s, _) = epoch.to_gregorian_utc();
format!("{y:04}-{m:02}-{d:02} {h:02}:{min:02}:{s:02} UTC")
}
fn color_frame(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x29, 0x32, 0x36) } else {
egui::Color32::from_rgb(0xec, 0xec, 0xec)
}
}
fn color_muted(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x9a, 0x9a, 0x9a)
} else {
egui::Color32::from_rgb(0x6a, 0x6a, 0x6a)
}
}
fn color_read() -> egui::Color32 {
egui::Color32::from_rgb(0x4a, 0x77, 0x29)
}
fn person_color(id: Id) -> egui::Color32 {
colorhash::ral_categorical(id.as_ref())
}
#[derive(Clone, Debug)]
struct MessageRow {
id: Id,
from: Id,
to: Id,
body: String,
created_at: Option<i128>,
reads: Vec<(Id, i128)>,
}
impl MessageRow {
fn sort_key(&self) -> i128 {
self.created_at.unwrap_or(i128::MIN)
}
}
#[derive(Clone, Debug, Default)]
struct Person {
alias: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
display_name: Option<String>,
}
impl Person {
fn display(&self, fallback_id: Id) -> String {
if let Some(a) = self.alias.as_ref() {
if !a.trim().is_empty() {
return a.clone();
}
}
match (self.first_name.as_ref(), self.last_name.as_ref()) {
(Some(f), Some(l)) if !f.trim().is_empty() && !l.trim().is_empty() => {
return format!("{f} {l}");
}
(Some(f), _) if !f.trim().is_empty() => return f.clone(),
(_, Some(l)) if !l.trim().is_empty() => return l.clone(),
_ => {}
}
if let Some(d) = self.display_name.as_ref() {
if !d.trim().is_empty() {
return d.clone();
}
}
id_hex(fallback_id)
}
}
struct MessagesLive {
space: TribleSet,
cached_head: Option<CommitHandle>,
relations_cached_head: Option<CommitHandle>,
people: HashMap<Id, Person>,
}
impl MessagesLive {
fn refresh(
ws: &mut Workspace<Pile>,
relations_ws: Option<&mut Workspace<Pile>>,
) -> Self {
let space = ws
.checkout(..)
.map(|co| co.into_facts())
.unwrap_or_else(|e| {
eprintln!("[messages] checkout: {e:?}");
TribleSet::new()
});
let cached_head = ws.head();
let (relations_cached_head, people) = match relations_ws {
Some(rws) => {
let head = rws.head();
let rspace = rws
.checkout(..)
.map(|co| co.into_facts())
.unwrap_or_else(|e| {
eprintln!("[messages] relations checkout: {e:?}");
TribleSet::new()
});
let people = build_people(&rspace, rws);
(head, people)
}
None => (None, HashMap::new()),
};
MessagesLive {
space,
cached_head,
relations_cached_head,
people,
}
}
fn text(&self, ws: &mut Workspace<Pile>, h: TextHandle) -> String {
ws.get::<View<str>, LongString>(h)
.map(|v| {
let s: &str = v.as_ref();
s.to_string()
})
.unwrap_or_default()
}
fn display_name(&self, id: Id) -> String {
match self.people.get(&id) {
Some(p) => p.display(id),
None => id_hex(id),
}
}
fn messages(&self, ws: &mut Workspace<Pile>) -> Vec<MessageRow> {
let mut by_id: HashMap<Id, MessageRow> = HashMap::new();
let rows: Vec<(Id, Id, Id, TextHandle, (i128, i128))> = find!(
(
mid: Id,
from: Id,
to: Id,
body: TextHandle,
ts: (i128, i128)
),
pattern!(&self.space, [{
?mid @
metadata::tag: &KIND_MESSAGE_ID,
local::from: ?from,
local::to: ?to,
local::body: ?body,
metadata::created_at: ?ts,
}])
)
.collect();
for (mid, from, to, body_handle, ts) in rows {
if by_id.contains_key(&mid) {
continue;
}
let body = self.text(ws, body_handle);
by_id.insert(
mid,
MessageRow {
id: mid,
from,
to,
body,
created_at: Some(ts.0),
reads: Vec::new(),
},
);
}
let mut latest: HashMap<(Id, Id), i128> = HashMap::new();
for (mid, reader, ts) in find!(
(mid: Id, reader: Id, ts: (i128, i128)),
pattern!(&self.space, [{
_?event @
metadata::tag: &KIND_READ_ID,
local::about_message: ?mid,
local::reader: ?reader,
local::read_at: ?ts,
}])
) {
let key = (mid, reader);
let entry = latest.entry(key).or_insert(i128::MIN);
if ts.0 > *entry {
*entry = ts.0;
}
}
for ((mid, reader), ts) in latest {
if let Some(row) = by_id.get_mut(&mid) {
row.reads.push((reader, ts));
}
}
for row in by_id.values_mut() {
row.reads.sort_by(|a, b| b.1.cmp(&a.1));
}
by_id.into_values().collect()
}
}
fn build_people(
relations_space: &TribleSet,
relations_ws: &mut Workspace<Pile>,
) -> HashMap<Id, Person> {
let mut people: HashMap<Id, Person> = HashMap::new();
let person_ids: Vec<Id> = find!(
pid: Id,
pattern!(relations_space, [{ ?pid @ metadata::tag: &KIND_PERSON_ID }])
)
.collect();
for pid in &person_ids {
people.insert(*pid, Person::default());
}
let alias_rows: Vec<(Id, String)> = find!(
(pid: Id, alias: String),
pattern!(relations_space, [{ ?pid @ rel::alias: ?alias }])
)
.collect();
for (pid, alias) in alias_rows {
if let Some(p) = people.get_mut(&pid) {
match p.alias.as_ref() {
Some(existing) if existing.as_str() <= alias.as_str() => {}
_ => p.alias = Some(alias),
}
}
}
let relations_text = |ws: &mut Workspace<Pile>, h: TextHandle| -> Option<String> {
ws.get::<View<str>, LongString>(h).ok().map(|v| {
let s: &str = v.as_ref();
s.to_string()
})
};
let first_rows: Vec<(Id, TextHandle)> = find!(
(pid: Id, h: TextHandle),
pattern!(relations_space, [{ ?pid @ rel::first_name: ?h }])
)
.collect();
for (pid, h) in first_rows {
if people.contains_key(&pid) {
if let Some(v) = relations_text(relations_ws, h) {
if let Some(p) = people.get_mut(&pid) {
p.first_name.get_or_insert(v);
}
}
}
}
let last_rows: Vec<(Id, TextHandle)> = find!(
(pid: Id, h: TextHandle),
pattern!(relations_space, [{ ?pid @ rel::last_name: ?h }])
)
.collect();
for (pid, h) in last_rows {
if people.contains_key(&pid) {
if let Some(v) = relations_text(relations_ws, h) {
if let Some(p) = people.get_mut(&pid) {
p.last_name.get_or_insert(v);
}
}
}
}
let display_rows: Vec<(Id, TextHandle)> = find!(
(pid: Id, h: TextHandle),
pattern!(relations_space, [{ ?pid @ rel::display_name: ?h }])
)
.collect();
for (pid, h) in display_rows {
if people.contains_key(&pid) {
if let Some(v) = relations_text(relations_ws, h) {
if let Some(p) = people.get_mut(&pid) {
p.display_name.get_or_insert(v);
}
}
}
}
people
}
pub struct MessagesPanel {
live: Option<MessagesLive>,
}
impl Default for MessagesPanel {
fn default() -> Self {
Self { live: None }
}
}
impl MessagesPanel {
pub fn new() -> Self {
Self::default()
}
pub fn with_height(self, _height: f32) -> Self {
self
}
pub fn render(
&mut self,
ctx: &mut CardCtx<'_>,
ws: &mut Workspace<Pile>,
mut relations_ws: Option<&mut Workspace<Pile>>,
) {
let head = ws.head();
let rhead = relations_ws.as_ref().and_then(|w| w.head());
let need_refresh = match self.live.as_ref() {
None => true,
Some(l) => l.cached_head != head || l.relations_cached_head != rhead,
};
if need_refresh {
self.live = Some(MessagesLive::refresh(
ws,
relations_ws.as_mut().map(|w| &mut **w),
));
}
ctx.section("Messages", |ctx| {
let Some(live) = self.live.as_ref() else { return };
let mut messages = live.messages(ws);
messages.sort_by(|a, b| {
a.sort_key()
.cmp(&b.sort_key())
.then_with(|| a.id.cmp(&b.id))
});
let mut names: HashMap<Id, String> = HashMap::new();
for m in &messages {
names
.entry(m.from)
.or_insert_with(|| live.display_name(m.from));
names.entry(m.to).or_insert_with(|| live.display_name(m.to));
for (r, _) in &m.reads {
names.entry(*r).or_insert_with(|| live.display_name(*r));
}
}
let now = now_tai_ns();
let count = messages.len();
let latest_age = messages
.iter()
.filter_map(|m| m.created_at)
.max()
.map(|k| format_age_key(now, k));
let mut search = ctx.search();
let needle = search.query().to_lowercase();
let search_active = !needle.is_empty();
ctx.grid(|g| {
g.full(|ctx| {
let ui = ctx.ui_mut();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(
egui::RichText::new(format!("{count} MESSAGES"))
.monospace()
.strong()
.small()
.color(color_muted(ui)),
);
if let Some(age) = latest_age.as_ref() {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
egui::RichText::new(format!(
"LAST {}",
age.to_uppercase()
))
.monospace()
.small()
.strong()
.color(color_muted(ui)),
);
},
);
}
});
});
if messages.is_empty() {
g.full(|ctx| {
render_messages_empty_state(
ctx.ui_mut(),
"No messages yet.",
None,
);
});
return;
}
for msg in &messages {
if search_active && !message_matches_search(msg, &names, &needle) {
continue;
}
let match_info = if search_active {
Some(search.report(egui::Id::new(("messages_match", msg.id))))
} else {
None
};
let is_focused =
match_info.as_ref().map_or(false, |i| i.is_focused);
g.full(|ctx| {
let ui = ctx.ui_mut();
let pre_y = ui.cursor().min.y;
render_message(ui, msg, now, &names, &needle, is_focused);
if let Some(info) = match_info {
if info.should_scroll_to {
let post_y = ui.cursor().min.y;
let msg_rect = egui::Rect::from_min_max(
egui::pos2(ui.min_rect().left(), pre_y),
egui::pos2(ui.min_rect().right(), post_y),
);
ui.scroll_to_rect(
msg_rect,
Some(egui::Align::Center),
);
}
}
});
}
});
let _ = ws;
});
}
}
fn message_matches_search(
msg: &MessageRow,
names: &HashMap<Id, String>,
needle: &str,
) -> bool {
if msg.body.to_lowercase().contains(needle) {
return true;
}
for id in [msg.from, msg.to] {
if let Some(name) = names.get(&id) {
if name.to_lowercase().contains(needle) {
return true;
}
}
}
false
}
fn render_message(
ui: &mut egui::Ui,
msg: &MessageRow,
now: i128,
names: &HashMap<Id, String>,
search_needle: &str,
focused: bool,
) {
let bubble_fill = ui.visuals().window_fill;
let from_color = person_color(msg.from);
let to_color = person_color(msg.to);
const STRIPE_WIDTH: f32 = 18.0;
const STRIPE_GAP: f32 = 8.0;
const STROKE_INSET: f32 = 1.0;
ui.vertical(|ui| {
let inner_margin = egui::Margin {
left: (STROKE_INSET + STRIPE_WIDTH + STRIPE_GAP) as i8,
right: (STROKE_INSET + STRIPE_WIDTH + STRIPE_GAP) as i8,
top: 6,
bottom: 6,
};
let frame_resp = egui::Frame::NONE
.fill(bubble_fill)
.stroke(egui::Stroke::new(STROKE_INSET, color_frame(ui)))
.shadow(egui::epaint::Shadow {
offset: [2, 2],
blur: 0,
spread: 0,
color: egui::Color32::from_black_alpha(48),
})
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(inner_margin)
.show(ui, |ui| {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Min),
|ui| {
let (age, hover) = match msg.created_at {
Some(k) => {
(format_age_key(now, k), Some(format_timestamp_key(k)))
}
None => ("-".to_string(), None),
};
let resp = ui.label(
egui::RichText::new(age)
.monospace()
.small()
.color(color_muted(ui)),
);
if let Some(h) = hover {
resp.on_hover_text(h);
}
},
);
ui.add_space(2.0);
let base = egui::TextFormat {
font_id: egui::TextStyle::Body.resolve(ui.style()),
color: ui.visuals().text_color(),
..Default::default()
};
GORBIE::search::highlight_label(
ui,
&msg.body,
search_needle,
base,
focused,
);
if !msg.reads.is_empty() {
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(
egui::RichText::new("\u{2713}\u{2713}")
.small()
.color(color_read()),
);
let mut first = true;
for (reader, ts) in &msg.reads {
if !first {
ui.label(
egui::RichText::new("\u{00b7}")
.small()
.color(color_muted(ui)),
);
}
first = false;
let name = names
.get(reader)
.cloned()
.unwrap_or_else(|| id_hex(*reader));
let response = ui.label(
egui::RichText::new(name)
.small()
.color(person_color(*reader)),
);
response.on_hover_text(format!(
"read {} · {}",
format_age_key(now, *ts),
format_timestamp_key(*ts),
));
}
if let Some((_, newest_ts)) =
msg.reads.iter().max_by_key(|(_, t)| *t)
{
ui.label(
egui::RichText::new(format!(
"\u{00b7} {}",
format_age_key(now, *newest_ts)
))
.small()
.color(color_muted(ui)),
);
}
});
}
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(id_hex(msg.id))
.monospace()
.small()
.color(color_muted(ui)),
);
});
});
let outer = frame_resp.response.rect;
let from_name = names
.get(&msg.from)
.cloned()
.unwrap_or_else(|| id_hex(msg.from));
let to_name = names
.get(&msg.to)
.cloned()
.unwrap_or_else(|| id_hex(msg.to));
paint_party_stripe(
ui.painter(),
outer,
StripeSide::Left,
from_color,
&from_name.to_uppercase(),
);
paint_party_stripe(
ui.painter(),
outer,
StripeSide::Right,
to_color,
&to_name.to_uppercase(),
);
});
}
#[derive(Clone, Copy)]
enum StripeSide {
Left,
Right,
}
fn paint_party_stripe(
painter: &egui::Painter,
outer: egui::Rect,
side: StripeSide,
color: egui::Color32,
label: &str,
) {
const STRIPE_WIDTH: f32 = 18.0;
const STROKE_INSET: f32 = 1.0;
let stripe_min = match side {
StripeSide::Left => outer.min + egui::vec2(STROKE_INSET, STROKE_INSET),
StripeSide::Right => egui::pos2(
outer.right() - STROKE_INSET - STRIPE_WIDTH,
outer.top() + STROKE_INSET,
),
};
let stripe_rect = egui::Rect::from_min_size(
stripe_min,
egui::vec2(STRIPE_WIDTH, outer.height() - 2.0 * STROKE_INSET),
);
painter.rect_filled(stripe_rect, egui::CornerRadius::ZERO, color);
let font = egui::FontId::monospace(9.0);
let text_color = colorhash::text_color_on(color);
let galley = painter.layout_no_wrap(label.to_string(), font, text_color);
if galley.size().x + 6.0 > stripe_rect.height() {
return;
}
let gh = galley.size().y;
let mut text_shape = match side {
StripeSide::Left => {
let pos = egui::pos2(
stripe_rect.left() + (STRIPE_WIDTH + gh) * 0.5,
stripe_rect.top() + 5.0,
);
let mut s = egui::epaint::TextShape::new(pos, galley, text_color);
s.angle = std::f32::consts::FRAC_PI_2;
s
}
StripeSide::Right => {
let pos = egui::pos2(
stripe_rect.left() + (STRIPE_WIDTH - gh) * 0.5,
stripe_rect.bottom() - 5.0,
);
let mut s = egui::epaint::TextShape::new(pos, galley, text_color);
s.angle = -std::f32::consts::FRAC_PI_2;
s
}
};
text_shape.fallback_color = text_color;
painter.add(text_shape);
}
fn render_messages_empty_state(ui: &mut egui::Ui, headline: &str, hint: Option<&str>) {
ui.add_space(24.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("\u{2709}")
.size(32.0)
.color(color_muted(ui)),
);
ui.add_space(6.0);
ui.label(
egui::RichText::new(headline)
.monospace()
.small()
.strong()
.color(color_muted(ui)),
);
if let Some(h) = hint {
ui.add_space(2.0);
ui.label(
egui::RichText::new(h)
.small()
.color(color_muted(ui)),
);
}
});
ui.add_space(24.0);
}