commit 1a93663b1a88a798cebc427080252d49a72c640e
parent 4992e25b3acf7017e860640912eb867bd6842c6b
Author: kernelkind <kernelkind@gmail.com>
Date: Sun, 24 Aug 2025 23:32:25 -0400
replace `HybridSet` with `NoteUnits`
This will unify the collections that hold the notes to timelines
and threads and allow the notifications timeline to have grouped
notifications, among other things
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
7 files changed, 756 insertions(+), 106 deletions(-)
diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs
@@ -385,7 +385,7 @@ pub fn process_thread_notes(
created_at,
};
- if thread.replies.contains(¬e_ref) {
+ if thread.replies.contains_key(¬e_ref.key) {
continue;
}
diff --git a/crates/notedeck_columns/src/timeline/hybrid_set.rs b/crates/notedeck_columns/src/timeline/hybrid_set.rs
@@ -1,99 +0,0 @@
-use std::{
- collections::{BTreeSet, HashSet},
- hash::Hash,
-};
-
-use crate::timeline::MergeKind;
-
-/// Affords:
-/// - O(1) contains
-/// - O(log n) sorted insertion
-pub struct HybridSet<T> {
- reversed: bool,
- lookup: HashSet<T>, // fast deduplication
- ordered: BTreeSet<T>, // sorted iteration
-}
-
-impl<T> Default for HybridSet<T> {
- fn default() -> Self {
- Self {
- reversed: Default::default(),
- lookup: Default::default(),
- ordered: Default::default(),
- }
- }
-}
-
-pub enum InsertionResponse {
- AlreadyExists,
- Merged(MergeKind),
-}
-
-impl<T: Copy + Ord + Eq + Hash> HybridSet<T> {
- pub fn insert(&mut self, val: T) -> InsertionResponse {
- if !self.lookup.insert(val) {
- return InsertionResponse::AlreadyExists;
- }
-
- let front_insertion = match self.ordered.iter().next() {
- Some(first) => (val >= *first) == self.reversed,
- None => true,
- };
-
- self.ordered.insert(val); // O(log n)
-
- InsertionResponse::Merged(if front_insertion {
- MergeKind::FrontInsert
- } else {
- MergeKind::Spliced
- })
- }
-}
-
-impl<T: Eq + Hash> HybridSet<T> {
- pub fn contains(&self, val: &T) -> bool {
- self.lookup.contains(val) // O(1)
- }
-}
-
-impl<T> HybridSet<T> {
- pub fn iter(&self) -> HybridIter<'_, T> {
- HybridIter {
- inner: self.ordered.iter(),
- reversed: self.reversed,
- }
- }
-
- pub fn new(reversed: bool) -> Self {
- Self {
- reversed,
- ..Default::default()
- }
- }
-}
-
-impl<'a, T> IntoIterator for &'a HybridSet<T> {
- type Item = &'a T;
- type IntoIter = HybridIter<'a, T>;
-
- fn into_iter(self) -> Self::IntoIter {
- self.iter()
- }
-}
-
-pub struct HybridIter<'a, T> {
- inner: std::collections::btree_set::Iter<'a, T>,
- reversed: bool,
-}
-
-impl<'a, T> Iterator for HybridIter<'a, T> {
- type Item = &'a T;
-
- fn next(&mut self) -> Option<Self::Item> {
- if self.reversed {
- self.inner.next_back()
- } else {
- self.inner.next()
- }
- }
-}
diff --git a/crates/notedeck_columns/src/timeline/mod.rs b/crates/notedeck_columns/src/timeline/mod.rs
@@ -26,14 +26,16 @@ use std::{rc::Rc, time::SystemTime};
use tracing::{debug, error, info, warn};
pub mod cache;
-mod hybrid_set;
pub mod kind;
+mod note_units;
pub mod route;
pub mod thread;
+mod unit;
pub use cache::TimelineCache;
-pub use hybrid_set::{HybridSet, InsertionResponse};
pub use kind::{ColumnTitle, PubkeySource, ThreadSelection, TimelineKind};
+pub use note_units::{InsertionResponse, NoteUnits};
+pub use unit::{CompositeUnit, NoteUnit, ReactionUnit};
#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
pub enum ViewFilter {
diff --git a/crates/notedeck_columns/src/timeline/note_units.rs b/crates/notedeck_columns/src/timeline/note_units.rs
@@ -0,0 +1,511 @@
+use std::collections::{HashMap, HashSet};
+
+use nostrdb::NoteKey;
+use notedeck::NoteRef;
+
+use crate::timeline::{
+ unit::{CompositeUnit, NoteUnit, NoteUnitFragment},
+ MergeKind,
+};
+
+type StorageIndex = usize;
+
+/// Provides efficient access to `NoteUnit`s
+/// Useful for threads and timelines
+/// when reversed=false, sorts from newest to oldest
+#[derive(Debug, Default)]
+pub struct NoteUnits {
+ reversed: bool,
+ storage: Vec<NoteUnit>,
+ lookup: HashMap<NoteKey, StorageIndex>, // `NoteKey` to index in `NoteUnits::storage`
+ order: Vec<StorageIndex>, // the sorted order of the `NoteUnit`s in `NoteUnits::storage`
+}
+
+impl NoteUnits {
+ pub fn values(&self) -> Values<'_> {
+ Values {
+ set: self,
+ front: 0,
+ back: self.order.len(),
+ }
+ }
+
+ pub fn contains_key(&self, k: &NoteKey) -> bool {
+ self.lookup.contains_key(k)
+ }
+
+ pub fn new_with_cap(cap: usize, reversed: bool) -> Self {
+ Self {
+ reversed,
+ storage: Vec::with_capacity(cap),
+ lookup: HashMap::with_capacity(cap),
+ order: Vec::with_capacity(cap),
+ }
+ }
+
+ pub fn len(&self) -> usize {
+ self.storage.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.storage.is_empty()
+ }
+
+ /// Get the kth index from 0..Self::len
+ pub fn kth(&self, k: usize) -> Option<&NoteUnit> {
+ if k >= self.order.len() {
+ return None;
+ }
+ let idx = if self.reversed {
+ self.order[self.order.len() - 1 - k]
+ } else {
+ self.order[k]
+ };
+ Some(&self.storage[idx])
+ }
+
+ /// Core bulk insert for already-built `NoteUnit`s
+ /// Merges new `NoteUnit`s into `Self::storage`
+ /// Updates `Self::order`
+ fn merge_many_internal(
+ &mut self,
+ mut units: Vec<NoteUnit>,
+ touched_indices: &[usize],
+ ) -> InsertManyResponse {
+ units.retain(|e| !self.lookup.contains_key(&e.key()));
+ if units.is_empty() && touched_indices.is_empty() {
+ return InsertManyResponse::Zero;
+ }
+
+ if !touched_indices.is_empty() {
+ self.order.retain(|i| !touched_indices.contains(i));
+ }
+
+ units.sort_unstable();
+ units.dedup_by_key(|u| u.key());
+
+ let base = self.storage.len();
+ let mut new_order = Vec::with_capacity(units.len());
+ self.storage.reserve(units.len());
+ for (i, unit) in units.into_iter().enumerate() {
+ let idx = base + i;
+ let key = unit.key();
+ self.storage.push(unit);
+ self.lookup.insert(key, idx);
+ new_order.push(idx);
+ }
+
+ let front_insertion = if self.order.is_empty() || new_order.is_empty() {
+ true
+ } else if !self.reversed {
+ let first_new = *new_order.first().unwrap();
+ let last_old = *self.order.last().unwrap();
+ self.storage[first_new] >= self.storage[last_old]
+ } else {
+ let last_new = *new_order.last().unwrap();
+ let first_old = *self.order.first().unwrap();
+ self.storage[last_new] <= self.storage[first_old]
+ };
+
+ let mut merged = Vec::with_capacity(self.order.len() + new_order.len());
+ let (mut i, mut j) = (0, 0);
+ while i < self.order.len() && j < new_order.len() {
+ let index_left = self.order[i];
+ let index_right = new_order[j];
+ let left_item = &self.storage[index_left];
+ let right_item = &self.storage[index_right];
+ if left_item <= right_item {
+ // left_item is newer than right_item
+ merged.push(index_left);
+ i += 1;
+ } else {
+ merged.push(index_right);
+ j += 1;
+ }
+ }
+ merged.extend_from_slice(&self.order[i..]);
+ merged.extend_from_slice(&new_order[j..]);
+
+ for &touched_index in touched_indices {
+ let pos = merged
+ .binary_search_by(|&i2| self.storage[i2].cmp(&self.storage[touched_index]))
+ .unwrap_or_else(|p| p);
+ merged.insert(pos, touched_index);
+ }
+
+ let inserted = merged.len() - self.order.len();
+ self.order = merged;
+
+ if inserted == 0 {
+ InsertManyResponse::Zero
+ } else if front_insertion {
+ InsertManyResponse::Some {
+ entries_merged: inserted,
+ merge_kind: MergeKind::FrontInsert,
+ }
+ } else {
+ InsertManyResponse::Some {
+ entries_merged: inserted,
+ merge_kind: MergeKind::Spliced,
+ }
+ }
+ }
+
+ /// Merges `NoteUnitFragment`s
+ /// `NoteUnitFragment::Single` is added normally
+ /// if `NoteUnitFragment::Composite` exists already, it will fold the fragment into the `CompositeUnit`
+ /// otherwise, it will generate the `NoteUnit::CompositeUnit` from the `NoteUnitFragment::Composite`
+ pub fn merge_fragments(&mut self, frags: Vec<NoteUnitFragment>) -> InsertManyResponse {
+ let mut to_build: HashMap<NoteKey, CompositeUnit> = HashMap::new(); // new composites by key
+ let mut singles_to_build: Vec<NoteRef> = Vec::new();
+ let mut singles_seen: HashSet<NoteKey> = HashSet::new();
+
+ let mut touched = Vec::new();
+ for frag in frags {
+ match frag {
+ NoteUnitFragment::Single(note_ref) => {
+ let key = note_ref.key;
+ if self.lookup.contains_key(&key) {
+ continue;
+ }
+ if singles_seen.insert(key) {
+ singles_to_build.push(note_ref);
+ }
+ }
+ NoteUnitFragment::Composite(c_frag) => {
+ let key = c_frag.get_underlying_noteref().key;
+
+ if let Some(&storage_idx) = self.lookup.get(&key) {
+ if let Some(NoteUnit::Composite(c_unit)) = self.storage.get_mut(storage_idx)
+ {
+ if c_frag.get_latest_ref() < c_unit.get_latest_ref() {
+ touched.push(storage_idx);
+ }
+ c_frag.fold_into(c_unit);
+ continue;
+ }
+ }
+ // aggregate for new composite
+ use std::collections::hash_map::Entry;
+ match to_build.entry(key) {
+ Entry::Occupied(mut o) => {
+ c_frag.fold_into(o.get_mut());
+ }
+ Entry::Vacant(v) => {
+ v.insert(c_frag.into());
+ }
+ }
+ }
+ }
+ }
+
+ let mut items: Vec<NoteUnit> = Vec::with_capacity(singles_to_build.len() + to_build.len());
+ items.extend(singles_to_build.into_iter().map(NoteUnit::Single));
+ items.extend(to_build.into_values().map(NoteUnit::Composite));
+
+ self.merge_many_internal(items, &touched)
+ }
+
+ /// Convienience method to merge a single note
+ pub fn merge_single_unit(&mut self, note_ref: NoteRef) -> InsertionResponse {
+ match self.merge_many_internal(vec![NoteUnit::Single(note_ref)], &[]) {
+ InsertManyResponse::Zero => InsertionResponse::AlreadyExists,
+ InsertManyResponse::Some {
+ entries_merged: _,
+ merge_kind,
+ } => InsertionResponse::Merged(merge_kind),
+ }
+ }
+
+ pub fn latest_ref(&self) -> Option<&NoteRef> {
+ if self.reversed {
+ self.order.last().map(|&i| &self.storage[i])
+ } else {
+ self.order.first().map(|&i| &self.storage[i])
+ }
+ .map(NoteUnit::get_latest_ref)
+ }
+}
+
+pub enum InsertManyResponse {
+ Zero,
+ Some {
+ entries_merged: usize,
+ merge_kind: MergeKind,
+ },
+}
+
+pub struct Values<'a> {
+ set: &'a NoteUnits,
+ front: usize,
+ back: usize,
+}
+
+impl<'a> Iterator for Values<'a> {
+ type Item = &'a NoteUnit;
+ fn next(&mut self) -> Option<Self::Item> {
+ if self.front >= self.back {
+ return None;
+ }
+ let idx = if !self.set.reversed {
+ let i = self.front;
+ self.front += 1;
+ self.set.order[i]
+ } else {
+ self.back -= 1;
+ self.set.order[self.back]
+ };
+ Some(&self.set.storage[idx])
+ }
+}
+
+impl<'a> DoubleEndedIterator for Values<'a> {
+ fn next_back(&mut self) -> Option<Self::Item> {
+ if self.front >= self.back {
+ return None;
+ }
+ let idx = if !self.set.reversed {
+ self.back -= 1;
+ self.set.order[self.back]
+ } else {
+ let i = self.front;
+ self.front += 1;
+ self.set.order[i]
+ };
+ Some(&self.set.storage[idx])
+ }
+}
+
+pub enum InsertionResponse {
+ AlreadyExists,
+ Merged(MergeKind),
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::{BTreeMap, HashSet};
+
+ use egui::ahash::HashMap;
+ use enostr::Pubkey;
+ use nostrdb::NoteKey;
+ use notedeck::NoteRef;
+ use pretty_assertions::assert_eq;
+
+ use uuid::Uuid;
+
+ use crate::timeline::{
+ unit::{
+ CompositeFragment, CompositeUnit, NoteUnit, NoteUnitFragment, Reaction,
+ ReactionFragment, ReactionUnit,
+ },
+ NoteUnits,
+ };
+
+ #[derive(Default)]
+ struct UnitBuilder {
+ counter: u64,
+ frags: HashMap<String, NoteUnitFragment>,
+ units: NoteUnits,
+ }
+
+ impl UnitBuilder {
+ fn counter(&mut self) -> u64 {
+ let res = self.counter;
+ self.counter += 1;
+ res
+ }
+
+ fn random_sender(&mut self) -> Pubkey {
+ let mut out = [0u8; 32];
+ out[..8].copy_from_slice(&self.counter().to_le_bytes());
+
+ Pubkey::new(out)
+ }
+
+ fn fragment(&mut self, reacted_to: NoteRef) -> String {
+ let frag = NoteUnitFragment::Composite(CompositeFragment::Reaction(ReactionFragment {
+ noteref_reacted_to: reacted_to,
+ reaction_note_ref: NoteRef {
+ key: NoteKey::new(self.counter()),
+ created_at: self.counter(),
+ },
+ reaction: Reaction {
+ reaction: "+".to_owned(),
+ sender: self.random_sender(),
+ },
+ }));
+ let id = Uuid::new_v4().to_string();
+ self.frags.insert(id.clone(), frag.clone());
+
+ self.units.merge_fragments(vec![frag]);
+
+ id
+ }
+
+ fn generate_reaction_note(&mut self) -> NoteRef {
+ NoteRef {
+ key: NoteKey::new(self.counter()),
+ created_at: self.counter(),
+ }
+ }
+
+ fn insert_note(&mut self) -> String {
+ let note_ref = NoteRef {
+ key: NoteKey::new(self.counter()),
+ created_at: self.counter(),
+ };
+
+ let id = Uuid::new_v4().to_string();
+ self.frags
+ .insert(id.clone(), NoteUnitFragment::Single(note_ref.clone()));
+
+ self.units.merge_single_unit(note_ref);
+
+ id
+ }
+
+ fn expected_reactions(&mut self, ids: Vec<&String>) -> NoteUnit {
+ let mut reactions = BTreeMap::new();
+ let mut reaction_id = None;
+ let mut senders = HashSet::new();
+ for id in ids {
+ let NoteUnitFragment::Composite(CompositeFragment::Reaction(reac)) =
+ self.frags.get(id).unwrap()
+ else {
+ panic!("got something other than reaction");
+ };
+
+ if let Some(prev_reac_id) = reaction_id {
+ if prev_reac_id != reac.noteref_reacted_to {
+ panic!("internal error");
+ }
+ }
+
+ reaction_id = Some(reac.noteref_reacted_to);
+
+ reactions.insert(reac.reaction_note_ref, reac.reaction.clone());
+ senders.insert(reac.reaction.sender);
+ }
+
+ NoteUnit::Composite(CompositeUnit::Reaction(ReactionUnit {
+ note_reacted_to: reaction_id.unwrap(),
+ reactions,
+ senders: senders,
+ }))
+ }
+
+ fn expected_single(&mut self, id: &String) -> NoteUnit {
+ let Some(NoteUnitFragment::Single(note_ref)) = self.frags.get(id) else {
+ panic!("fail");
+ };
+
+ NoteUnit::Single(*note_ref)
+ }
+
+ fn asserted_at(&self, index: usize) -> NoteUnit {
+ self.units.kth(index).unwrap().clone()
+ }
+
+ fn aeq(&mut self, units_kth: usize, expect: Expect) {
+ assert_eq!(
+ self.asserted_at(units_kth),
+ match expect {
+ Expect::Single(id) => self.expected_single(id),
+ Expect::Reaction(items) => self.expected_reactions(items),
+ }
+ );
+ }
+ }
+
+ enum Expect<'a> {
+ Single(&'a String),
+ Reaction(Vec<&'a String>),
+ }
+
+ #[test]
+ fn test() {
+ let mut builder = UnitBuilder::default();
+ let reaction_note = builder.generate_reaction_note();
+
+ let single0 = builder.insert_note();
+ builder.aeq(0, Expect::Single(&single0));
+
+ let reac1 = builder.fragment(reaction_note);
+ builder.aeq(0, Expect::Reaction(vec![&reac1]));
+ builder.aeq(1, Expect::Single(&single0));
+
+ let single1 = builder.insert_note();
+ builder.aeq(0, Expect::Single(&single1));
+ builder.aeq(1, Expect::Reaction(vec![&reac1]));
+ builder.aeq(2, Expect::Single(&single0));
+
+ let reac2 = builder.fragment(reaction_note);
+ builder.aeq(0, Expect::Reaction(vec![&reac2, &reac1]));
+ builder.aeq(1, Expect::Single(&single1));
+ builder.aeq(2, Expect::Single(&single0));
+
+ let single2 = builder.insert_note();
+ builder.aeq(0, Expect::Single(&single2));
+ builder.aeq(1, Expect::Reaction(vec![&reac2, &reac1]));
+ builder.aeq(2, Expect::Single(&single1));
+ builder.aeq(3, Expect::Single(&single0));
+
+ let reac3 = builder.fragment(reaction_note);
+ builder.aeq(0, Expect::Reaction(vec![&reac1, &reac2, &reac3]));
+ builder.aeq(1, Expect::Single(&single2));
+ builder.aeq(2, Expect::Single(&single1));
+ builder.aeq(3, Expect::Single(&single0));
+ }
+
+ #[test]
+ fn test2() {
+ let mut builder = UnitBuilder::default();
+ let reaction_note1 = builder.generate_reaction_note();
+ let reaction_note2 = builder.generate_reaction_note();
+
+ let single0 = builder.insert_note();
+ builder.aeq(0, Expect::Single(&single0));
+
+ let reac1_1 = builder.fragment(reaction_note1);
+ builder.aeq(0, Expect::Reaction(vec![&reac1_1]));
+ builder.aeq(1, Expect::Single(&single0));
+
+ let reac2_1 = builder.fragment(reaction_note2);
+ builder.aeq(0, Expect::Reaction(vec![&reac2_1]));
+ builder.aeq(1, Expect::Reaction(vec![&reac1_1]));
+ builder.aeq(2, Expect::Single(&single0));
+
+ let single1 = builder.insert_note();
+ builder.aeq(0, Expect::Single(&single1));
+ builder.aeq(1, Expect::Reaction(vec![&reac2_1]));
+ builder.aeq(2, Expect::Reaction(vec![&reac1_1]));
+ builder.aeq(3, Expect::Single(&single0));
+
+ let reac1_2 = builder.fragment(reaction_note1);
+ builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1]));
+ builder.aeq(1, Expect::Single(&single1));
+ builder.aeq(2, Expect::Reaction(vec![&reac2_1]));
+ builder.aeq(3, Expect::Single(&single0));
+
+ let single2 = builder.insert_note();
+ builder.aeq(0, Expect::Single(&single2));
+ builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1]));
+ builder.aeq(2, Expect::Single(&single1));
+ builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
+ builder.aeq(4, Expect::Single(&single0));
+
+ let reac1_3 = builder.fragment(reaction_note1);
+ builder.aeq(0, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
+ builder.aeq(1, Expect::Single(&single2));
+ builder.aeq(2, Expect::Single(&single1));
+ builder.aeq(3, Expect::Reaction(vec![&reac2_1]));
+ builder.aeq(4, Expect::Single(&single0));
+
+ let reac2_2 = builder.fragment(reaction_note2);
+ builder.aeq(0, Expect::Reaction(vec![&reac2_1, &reac2_2]));
+ builder.aeq(1, Expect::Reaction(vec![&reac1_2, &reac1_1, &reac1_3]));
+ builder.aeq(2, Expect::Single(&single2));
+ builder.aeq(3, Expect::Single(&single1));
+ builder.aeq(4, Expect::Single(&single0));
+ }
+}
diff --git a/crates/notedeck_columns/src/timeline/thread.rs b/crates/notedeck_columns/src/timeline/thread.rs
@@ -8,13 +8,13 @@ use notedeck::{NoteCache, NoteRef, UnknownIds};
use crate::{
actionbar::{process_thread_notes, NewThreadNotes},
multi_subscriber::ThreadSubs,
- timeline::hybrid_set::HybridSet,
+ timeline::{note_units::NoteUnits, unit::NoteUnit, InsertionResponse},
};
use super::ThreadSelection;
pub struct ThreadNode {
- pub replies: HybridSet<NoteRef>,
+ pub replies: SingleNoteUnits,
pub prev: ParentState,
pub have_all_ancestors: bool,
pub list: VirtualList,
@@ -31,7 +31,7 @@ pub enum ParentState {
impl ThreadNode {
pub fn new(parent: ParentState) -> Self {
Self {
- replies: HybridSet::new(true),
+ replies: SingleNoteUnits::new(true),
prev: parent,
have_all_ancestors: false,
list: VirtualList::new(),
@@ -389,3 +389,34 @@ impl NoteSeenFlags {
self.flags.contains_key(¬e_id)
}
}
+
+#[derive(Default)]
+pub struct SingleNoteUnits {
+ units: NoteUnits,
+}
+
+impl SingleNoteUnits {
+ pub fn new(reversed: bool) -> Self {
+ Self {
+ units: NoteUnits::new_with_cap(0, reversed),
+ }
+ }
+
+ pub fn insert(&mut self, note_ref: NoteRef) -> InsertionResponse {
+ self.units.merge_single_unit(note_ref)
+ }
+
+ pub fn values(&self) -> impl Iterator<Item = &NoteRef> {
+ self.units.values().filter_map(|entry| {
+ if let NoteUnit::Single(note_ref) = entry {
+ Some(note_ref)
+ } else {
+ None
+ }
+ })
+ }
+
+ pub fn contains_key(&self, k: &NoteKey) -> bool {
+ self.units.contains_key(k)
+ }
+}
diff --git a/crates/notedeck_columns/src/timeline/unit.rs b/crates/notedeck_columns/src/timeline/unit.rs
@@ -0,0 +1,205 @@
+use std::collections::{BTreeMap, HashSet};
+
+use enostr::Pubkey;
+use nostrdb::NoteKey;
+use notedeck::NoteRef;
+
+/// A `NoteUnit` represents a cohesive piece of data derived from notes
+#[derive(Debug, Clone)]
+pub enum NoteUnit {
+ Single(NoteRef), // A single note
+ Composite(CompositeUnit),
+}
+
+impl NoteUnit {
+ pub fn key(&self) -> NoteKey {
+ match self {
+ NoteUnit::Single(note_ref) => note_ref.key,
+ NoteUnit::Composite(clustered_entry) => clustered_entry.key(),
+ }
+ }
+
+ pub fn get_underlying_noteref(&self) -> &NoteRef {
+ match self {
+ NoteUnit::Single(note_ref) => note_ref,
+ NoteUnit::Composite(clustered) => match clustered {
+ CompositeUnit::Reaction(reaction_entry) => &reaction_entry.note_reacted_to,
+ },
+ }
+ }
+
+ pub fn get_latest_ref(&self) -> &NoteRef {
+ match self {
+ NoteUnit::Single(note_ref) => note_ref,
+ NoteUnit::Composite(composite_unit) => composite_unit.get_latest_ref(),
+ }
+ }
+}
+
+impl Ord for NoteUnit {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ self.get_latest_ref().cmp(other.get_latest_ref())
+ }
+}
+
+impl PartialEq for NoteUnit {
+ fn eq(&self, other: &Self) -> bool {
+ self.get_latest_ref() == other.get_latest_ref()
+ }
+}
+
+impl Eq for NoteUnit {}
+
+impl PartialOrd for NoteUnit {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+/// Combines potentially many notes into one cohesive piece of data
+#[derive(Debug, Clone)]
+pub enum CompositeUnit {
+ Reaction(ReactionUnit),
+}
+
+impl CompositeUnit {
+ pub fn get_latest_ref(&self) -> &NoteRef {
+ match self {
+ CompositeUnit::Reaction(reaction_unit) => reaction_unit.get_latest_ref(),
+ }
+ }
+}
+
+impl PartialEq for CompositeUnit {
+ fn eq(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::Reaction(l0), Self::Reaction(r0)) => l0 == r0,
+ }
+ }
+}
+
+impl CompositeUnit {
+ pub fn key(&self) -> NoteKey {
+ match self {
+ CompositeUnit::Reaction(reaction_entry) => reaction_entry.note_reacted_to.key,
+ }
+ }
+}
+
+impl From<CompositeFragment> for CompositeUnit {
+ fn from(value: CompositeFragment) -> Self {
+ match value {
+ CompositeFragment::Reaction(reaction_fragment) => {
+ CompositeUnit::Reaction(reaction_fragment.into())
+ }
+ }
+ }
+}
+
+/// Represents all the reactions to a specific note `ReactionUnit::note_reacted_to`
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct ReactionUnit {
+ pub note_reacted_to: NoteRef, // NOTE: this should not be modified after it's created
+ pub reactions: BTreeMap<NoteRef, Reaction>,
+ pub senders: HashSet<Pubkey>, // useful for making sure the same user can't add more than one reaction to a note
+}
+
+impl ReactionUnit {
+ pub fn get_latest_ref(&self) -> &NoteRef {
+ self.reactions
+ .first_key_value()
+ .map(|(r, _)| r)
+ .unwrap_or(&self.note_reacted_to)
+ }
+}
+
+impl From<ReactionFragment> for ReactionUnit {
+ fn from(frag: ReactionFragment) -> Self {
+ let mut senders = HashSet::new();
+ senders.insert(frag.reaction.sender);
+
+ let mut reactions = BTreeMap::new();
+ reactions.insert(frag.reaction_note_ref, frag.reaction);
+
+ Self {
+ note_reacted_to: frag.noteref_reacted_to,
+ reactions,
+ senders,
+ }
+ }
+}
+
+#[derive(Clone)]
+pub enum NoteUnitFragment {
+ Single(NoteRef),
+ Composite(CompositeFragment),
+}
+
+#[derive(Debug, Clone)]
+pub enum CompositeFragment {
+ Reaction(ReactionFragment),
+}
+
+impl CompositeFragment {
+ pub fn fold_into(self, unit: &mut CompositeUnit) {
+ match self {
+ CompositeFragment::Reaction(reaction_fragment) => reaction_fragment.fold_into(unit),
+ }
+ }
+
+ pub fn key(&self) -> NoteKey {
+ match self {
+ CompositeFragment::Reaction(reaction_fragment) => {
+ reaction_fragment.reaction_note_ref.key
+ }
+ }
+ }
+
+ pub fn get_underlying_noteref(&self) -> &NoteRef {
+ match self {
+ CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.noteref_reacted_to,
+ }
+ }
+
+ pub fn get_latest_ref(&self) -> &NoteRef {
+ match self {
+ CompositeFragment::Reaction(reaction_fragment) => &reaction_fragment.reaction_note_ref,
+ }
+ }
+}
+
+/// A singluar reaction to a note
+#[derive(Debug, Clone)]
+pub struct ReactionFragment {
+ pub noteref_reacted_to: NoteRef,
+ pub reaction_note_ref: NoteRef,
+ pub reaction: Reaction,
+}
+
+impl ReactionFragment {
+ /// Add all the contents of Self into `CompositeUnit`
+ pub fn fold_into(self, unit: &mut CompositeUnit) {
+ match unit {
+ CompositeUnit::Reaction(reaction_unit) => {
+ if self.noteref_reacted_to != reaction_unit.note_reacted_to {
+ return;
+ }
+
+ if reaction_unit.senders.contains(&self.reaction.sender) {
+ return;
+ }
+
+ reaction_unit.senders.insert(self.reaction.sender);
+ reaction_unit
+ .reactions
+ .insert(self.reaction_note_ref, self.reaction);
+ }
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq, Clone)]
+pub struct Reaction {
+ pub reaction: String, // can't use char because some emojis are 'grapheme clusters'
+ pub sender: Pubkey,
+}
diff --git a/crates/notedeck_columns/src/ui/thread.rs b/crates/notedeck_columns/src/ui/thread.rs
@@ -111,7 +111,7 @@ impl<'a, 'd> ThreadView<'a, 'd> {
parent_state = ParentState::Unknown;
}
- for note_ref in &cur_node.replies {
+ for note_ref in cur_node.replies.values() {
if let Ok(note) = self.note_context.ndb.get_note_by_key(txn, note_ref.key) {
note_builder.add_reply(note);
}