Dissolve workspace

This commit is contained in:
Joscha 2022-06-23 12:20:20 +02:00
parent 1cc7dd8920
commit e601476d02
29 changed files with 24 additions and 29 deletions

103
src/chat.rs Normal file
View file

@ -0,0 +1,103 @@
mod tree;
use std::sync::Arc;
use crossterm::event::KeyEvent;
use parking_lot::FairMutex;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use self::tree::TreeView;
pub enum Mode {
Tree,
// Thread,
// Flat,
}
pub struct Cursor<I> {
id: I,
/// Where on the screen the cursor is visible (`0.0` = first line, `1.0` =
/// last line).
proportion: f32,
}
impl<I> Cursor<I> {
/// Create a new cursor with arbitrary proportion.
pub fn new(id: I) -> Self {
Self {
id,
proportion: 0.0,
}
}
}
pub struct Chat<M: Msg, S: MsgStore<M>> {
store: S,
cursor: Option<Cursor<M::Id>>,
mode: Mode,
tree: TreeView<M>,
// thread: ThreadView,
// flat: FlatView,
}
impl<M: Msg, S: MsgStore<M>> Chat<M, S> {
pub fn new(store: S) -> Self {
Self {
store,
cursor: None,
mode: Mode::Tree,
tree: TreeView::new(),
}
}
}
impl<M: Msg, S: MsgStore<M>> Chat<M, S> {
pub async fn handle_navigation(
&mut self,
terminal: &mut Terminal,
size: Size,
event: KeyEvent,
) {
match self.mode {
Mode::Tree => {
self.tree
.handle_navigation(&mut self.store, &mut self.cursor, terminal, size, event)
.await
}
}
}
pub async fn handle_messaging(
&mut self,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
event: KeyEvent,
) -> Option<(Option<M::Id>, String)> {
match self.mode {
Mode::Tree => {
self.tree
.handle_messaging(
&mut self.store,
&mut self.cursor,
terminal,
crossterm_lock,
event,
)
.await
}
}
}
pub async fn render(&mut self, frame: &mut Frame, pos: Pos, size: Size) {
match self.mode {
Mode::Tree => {
self.tree
.render(&mut self.store, &self.cursor, frame, pos, size)
.await
}
}
}
}

84
src/chat/tree.rs Normal file
View file

@ -0,0 +1,84 @@
mod action;
mod blocks;
mod cursor;
mod layout;
mod render;
mod util;
use std::marker::PhantomData;
use std::sync::Arc;
use crossterm::event::{KeyCode, KeyEvent};
use parking_lot::FairMutex;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::store::{Msg, MsgStore};
use super::Cursor;
pub struct TreeView<M: Msg> {
// pub focus: Option<M::Id>,
// pub folded: HashSet<M::Id>,
// pub minimized: HashSet<M::Id>,
phantom: PhantomData<M::Id>, // TODO Remove
}
impl<M: Msg> TreeView<M> {
pub fn new() -> Self {
Self {
phantom: PhantomData,
}
}
pub async fn handle_navigation<S: MsgStore<M>>(
&mut self,
s: &mut S,
c: &mut Option<Cursor<M::Id>>,
t: &mut Terminal,
z: Size,
event: KeyEvent,
) {
match event.code {
KeyCode::Char('k') => self.move_up(s, c, t.frame(), z).await,
KeyCode::Char('j') => self.move_down(s, c, t.frame(), z).await,
KeyCode::Char('K') => self.move_up_sibling(s, c, t.frame(), z).await,
KeyCode::Char('J') => self.move_down_sibling(s, c, t.frame(), z).await,
KeyCode::Char('z') | KeyCode::Char('Z') => self.center_cursor(s, c, t.frame(), z).await,
KeyCode::Char('g') => self.move_to_first(s, c, t.frame(), z).await,
KeyCode::Char('G') => self.move_to_last(s, c, t.frame(), z).await,
KeyCode::Esc => *c = None, // TODO Make 'G' do the same thing?
_ => {}
}
}
pub async fn handle_messaging<S: MsgStore<M>>(
&mut self,
s: &mut S,
c: &mut Option<Cursor<M::Id>>,
t: &mut Terminal,
l: &Arc<FairMutex<()>>,
event: KeyEvent,
) -> Option<(Option<M::Id>, String)> {
match event.code {
KeyCode::Char('r') => Self::reply_normal(s, c, t, l).await,
KeyCode::Char('R') => Self::reply_alternate(s, c, t, l).await,
KeyCode::Char('t') | KeyCode::Char('T') => Self::create_new_thread(t, l).await,
_ => None,
}
}
pub async fn render<S: MsgStore<M>>(
&mut self,
store: &mut S,
cursor: &Option<Cursor<M::Id>>,
frame: &mut Frame,
pos: Pos,
size: Size,
) {
let blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
Self::render_blocks(frame, pos, size, &blocks);
}
}

97
src/chat/tree/action.rs Normal file
View file

@ -0,0 +1,97 @@
use std::sync::Arc;
use parking_lot::FairMutex;
use toss::terminal::Terminal;
use crate::chat::Cursor;
use crate::store::{Msg, MsgStore};
use super::TreeView;
impl<M: Msg> TreeView<M> {
fn prompt_msg(crossterm_lock: &Arc<FairMutex<()>>, terminal: &mut Terminal) -> Option<String> {
let content = {
let _guard = crossterm_lock.lock();
terminal.suspend().expect("could not suspend");
let content = edit::edit("").expect("could not edit");
terminal.unsuspend().expect("could not unsuspend");
content
};
if content.trim().is_empty() {
None
} else {
Some(content)
}
}
pub async fn reply_normal<S: MsgStore<M>>(
store: &S,
cursor: &Option<Cursor<M::Id>>,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> Option<(Option<M::Id>, String)> {
if let Some(cursor) = cursor {
let tree = store.tree(store.path(&cursor.id).await.first()).await;
let parent_id = if tree.next_sibling(&cursor.id).is_some() {
// A reply to a message that has further siblings should be a
// direct reply. An indirect reply might end up a lot further
// down in the current conversation.
cursor.id.clone()
} else if let Some(parent) = tree.parent(&cursor.id) {
// A reply to a message without further siblings should be an
// indirect reply so as not to create unnecessarily deep
// threads. In the case that our message has children, this
// might get a bit confusing. I'm not sure yet how well this
// "smart" reply actually works in practice.
parent
} else {
// When replying to a top-level message, it makes sense to avoid
// creating unnecessary new threads.
cursor.id.clone()
};
if let Some(content) = Self::prompt_msg(crossterm_lock, terminal) {
return Some((Some(parent_id), content));
}
}
None
}
/// Does approximately the opposite of [`Self::reply_normal`].
pub async fn reply_alternate<S: MsgStore<M>>(
store: &S,
cursor: &Option<Cursor<M::Id>>,
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> Option<(Option<M::Id>, String)> {
if let Some(cursor) = cursor {
let tree = store.tree(store.path(&cursor.id).await.first()).await;
let parent_id = if tree.next_sibling(&cursor.id).is_none() {
// The opposite of replying normally
cursor.id.clone()
} else if let Some(parent) = tree.parent(&cursor.id) {
// The opposite of replying normally
parent
} else {
// The same as replying normally, still to avoid creating
// unnecessary new threads
cursor.id.clone()
};
if let Some(content) = Self::prompt_msg(crossterm_lock, terminal) {
return Some((Some(parent_id), content));
}
}
None
}
pub async fn create_new_thread(
terminal: &mut Terminal,
crossterm_lock: &Arc<FairMutex<()>>,
) -> Option<(Option<M::Id>, String)> {
Self::prompt_msg(crossterm_lock, terminal).map(|c| (None, c))
}
}

180
src/chat/tree/blocks.rs Normal file
View file

@ -0,0 +1,180 @@
//! Intermediate representation of messages as blocks of lines.
use std::collections::VecDeque;
use chrono::{DateTime, Utc};
use crate::chat::Cursor;
use super::util;
pub struct Block<I> {
pub id: I,
pub line: i32,
pub height: i32,
pub cursor: bool,
pub time: Option<DateTime<Utc>>,
pub indent: usize,
pub body: BlockBody,
}
impl<I> Block<I> {
pub fn msg(
id: I,
indent: usize,
time: DateTime<Utc>,
nick: String,
lines: Vec<String>,
) -> Self {
Self {
id,
line: 0,
height: lines.len() as i32,
indent,
time: Some(time),
cursor: false,
body: BlockBody::Msg(MsgBlock { nick, lines }),
}
}
pub fn placeholder(id: I, indent: usize) -> Self {
Self {
id,
line: 0,
height: 1,
indent,
time: None,
cursor: false,
body: BlockBody::Placeholder,
}
}
pub fn time(mut self, time: DateTime<Utc>) -> Self {
self.time = Some(time);
self
}
}
pub enum BlockBody {
Msg(MsgBlock),
Placeholder,
}
pub struct MsgBlock {
pub nick: String,
pub lines: Vec<String>,
}
/// Pre-layouted messages as a sequence of blocks.
///
/// These blocks are straightforward to render, but also provide a level of
/// abstraction between the layouting and actual displaying of messages. This
/// might be useful in the future to ensure the cursor is always on a visible
/// message, for example.
///
/// The following equation describes the relationship between the
/// [`Blocks::top_line`] and [`Blocks::bottom_line`] fields:
///
/// `bottom_line - top_line + 1 = sum of all heights`
///
/// This ensures that `top_line` is always the first line and `bottom_line` is
/// always the last line in a nonempty [`Blocks`]. In an empty layout, the
/// equation simplifies to
///
/// `top_line = bottom_line + 1`
pub struct Blocks<I> {
pub blocks: VecDeque<Block<I>>,
/// The top line of the first block. Useful for prepending blocks,
/// especially to empty [`Blocks`]s.
pub top_line: i32,
/// The bottom line of the last block. Useful for appending blocks,
/// especially to empty [`Blocks`]s.
pub bottom_line: i32,
}
impl<I: PartialEq> Blocks<I> {
pub fn new() -> Self {
Self::new_below(0)
}
/// Create a new [`Blocks`] such that prepending a single line will result
/// in `top_line = bottom_line = line`.
pub fn new_below(line: i32) -> Self {
Self {
blocks: VecDeque::new(),
top_line: line + 1,
bottom_line: line,
}
}
fn mark_cursor(&mut self, id: &I) -> usize {
let mut cursor = None;
for (i, block) in self.blocks.iter_mut().enumerate() {
if &block.id == id {
block.cursor = true;
if cursor.is_some() {
panic!("more than one cursor in blocks");
}
cursor = Some(i);
}
}
cursor.expect("no cursor in blocks")
}
pub fn calculate_offsets_with_cursor(&mut self, cursor: &Cursor<I>, height: u16) {
let cursor_index = self.mark_cursor(&cursor.id);
let cursor_line = util::proportion_to_line(height, cursor.proportion);
// Propagate lines from cursor to both ends
self.blocks[cursor_index].line = cursor_line;
for i in (0..cursor_index).rev() {
// let succ_line = self.0[i + 1].line;
// let curr = &mut self.0[i];
// curr.line = succ_line - curr.height;
self.blocks[i].line = self.blocks[i + 1].line - self.blocks[i].height;
}
for i in (cursor_index + 1)..self.blocks.len() {
// let pred = &self.0[i - 1];
// self.0[i].line = pred.line + pred.height;
self.blocks[i].line = self.blocks[i - 1].line + self.blocks[i - 1].height;
}
self.top_line = self.blocks.front().expect("blocks nonempty").line;
let bottom = self.blocks.back().expect("blocks nonempty");
self.bottom_line = bottom.line + bottom.height - 1;
}
pub fn push_front(&mut self, mut block: Block<I>) {
self.top_line -= block.height;
block.line = self.top_line;
self.blocks.push_front(block);
}
pub fn push_back(&mut self, mut block: Block<I>) {
block.line = self.bottom_line + 1;
self.bottom_line += block.height;
self.blocks.push_back(block);
}
pub fn prepend(&mut self, mut layout: Self) {
while let Some(block) = layout.blocks.pop_back() {
self.push_front(block);
}
}
pub fn append(&mut self, mut layout: Self) {
while let Some(block) = layout.blocks.pop_front() {
self.push_back(block);
}
}
pub fn offset(&mut self, delta: i32) {
self.top_line += delta;
self.bottom_line += delta;
for block in &mut self.blocks {
block.line += delta;
}
}
pub fn find(&self, id: &I) -> Option<&Block<I>> {
self.blocks.iter().find(|b| &b.id == id)
}
}

382
src/chat/tree/cursor.rs Normal file
View file

@ -0,0 +1,382 @@
//! Moving the cursor around.
use toss::frame::{Frame, Size};
use crate::chat::Cursor;
use crate::store::{Msg, MsgStore, Tree};
use super::blocks::Blocks;
use super::{util, TreeView};
impl<M: Msg> TreeView<M> {
#[allow(clippy::too_many_arguments)]
async fn correct_cursor_offset<S: MsgStore<M>>(
&mut self,
store: &S,
frame: &mut Frame,
size: Size,
old_blocks: &Blocks<M::Id>,
old_cursor_id: &Option<M::Id>,
cursor: &mut Cursor<M::Id>,
) {
if let Some(block) = old_blocks.find(&cursor.id) {
// The cursor is still visible in the old blocks, so we just need to
// adjust the proportion such that the blocks stay still.
cursor.proportion = util::line_to_proportion(size.height, block.line);
} else if let Some(old_cursor_id) = old_cursor_id {
// The cursor is not visible any more. However, we can estimate
// whether it is above or below the previous cursor position by
// lexicographically comparing both positions' paths.
let old_path = store.path(old_cursor_id).await;
let new_path = store.path(&cursor.id).await;
if new_path < old_path {
// Because we moved upwards, the cursor should appear at the top
// of the screen.
cursor.proportion = 0.0;
} else {
// Because we moved downwards, the cursor should appear at the
// bottom of the screen.
cursor.proportion = 1.0;
}
} else {
// We were scrolled all the way to the bottom, so the cursor must
// have been offscreen somewhere above.
cursor.proportion = 0.0;
}
// The cursor should be visible in its entirety on the screen now. If it
// isn't, we need to scroll the screen such that the cursor becomes fully
// visible again. To do this, we'll need to re-layout because the cursor
// could've moved anywhere.
let blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
let cursor_block = blocks.find(&cursor.id).expect("cursor must be in blocks");
// First, ensure the cursor's last line is not below the bottom of the
// screen. Then, ensure its top line is not above the top of the screen.
// If the cursor has more lines than the screen, the user should still
// see the top of the cursor so they can start reading its contents.
let min_line = 0;
let max_line = size.height as i32 - cursor_block.height;
// Not using clamp because it is possible that max_line < min_line
let cursor_line = cursor_block.line.min(max_line).max(min_line);
cursor.proportion = util::line_to_proportion(size.height, cursor_line);
// There is no need to ensure the screen is not scrolled too far up or
// down. The messages in `blocks` are already scrolled correctly and
// this function will not scroll the wrong way. If the cursor moves too
// far up, the screen will only scroll down, not further up. The same
// goes for the other direction.
}
fn find_parent(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(parent) = tree.parent(id) {
*id = parent;
true
} else {
false
}
}
fn find_first_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(child) = tree.children(id).and_then(|c| c.first()) {
*id = child.clone();
true
} else {
false
}
}
fn find_last_child(tree: &Tree<M>, id: &mut M::Id) -> bool {
if let Some(child) = tree.children(id).and_then(|c| c.last()) {
*id = child.clone();
true
} else {
false
}
}
/// Move to the previous sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_prev_sibling<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if let Some(prev_sibling) = tree.prev_sibling(id) {
*id = prev_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the previous tree.
if let Some(prev_tree_id) = store.prev_tree(tree.root()).await {
*tree = store.tree(&prev_tree_id).await;
*id = prev_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the next sibling, or don't move if this is not possible.
///
/// Always stays at the same level of indentation.
async fn find_next_sibling<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if let Some(next_sibling) = tree.next_sibling(id) {
*id = next_sibling;
true
} else if tree.parent(id).is_none() {
// We're at the root of our tree, so we need to move to the root of
// the next tree.
if let Some(next_tree_id) = store.next_tree(tree.root()).await {
*tree = store.tree(&next_tree_id).await;
*id = next_tree_id;
true
} else {
false
}
} else {
false
}
}
/// Move to the previous message, or don't move if this is not possible.
async fn find_prev_msg<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
// Move to previous sibling, then to its last child
// If not possible, move to parent
if self.find_prev_sibling(store, tree, id).await {
while Self::find_last_child(tree, id) {}
true
} else {
Self::find_parent(tree, id)
}
}
/// Move to the next message, or don't move if this is not possible.
async fn find_next_msg<S: MsgStore<M>>(
&self,
store: &S,
tree: &mut Tree<M>,
id: &mut M::Id,
) -> bool {
if Self::find_first_child(tree, id) {
return true;
}
if self.find_next_sibling(store, tree, id).await {
return true;
}
// Temporary id to avoid modifying the original one if no parent-sibling
// can be found.
let mut tmp_id = id.clone();
while Self::find_parent(tree, &mut tmp_id) {
if self.find_next_sibling(store, tree, &mut tmp_id).await {
*id = tmp_id;
return true;
}
}
false
}
pub async fn move_up<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(cursor) = cursor {
// We have a cursor to move around
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_prev_msg(store, &mut tree, &mut cursor.id).await;
} else if let Some(last_tree) = store.last_tree().await {
// We need to select the last message of the last tree
let tree = store.tree(&last_tree).await;
let mut id = last_tree;
while Self::find_last_child(&tree, &mut id) {}
*cursor = Some(Cursor::new(id));
}
// If neither condition holds, we can't set a cursor because there's no
// message to move to.
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_down<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(cursor) = cursor {
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_next_msg(store, &mut tree, &mut cursor.id).await;
}
// If that condition doesn't hold, we're already at the bottom in
// cursor-less mode and can't move further down anyways.
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_up_sibling<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(cursor) = cursor {
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_prev_sibling(store, &mut tree, &mut cursor.id)
.await;
} else if let Some(last_tree) = store.last_tree().await {
// I think moving to the root of the last tree makes the most sense
// here. Alternatively, we could just not move the cursor, but that
// wouldn't be very useful.
*cursor = Some(Cursor::new(last_tree));
}
// If neither condition holds, we can't set a cursor because there's no
// message to move to.
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_down_sibling<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(cursor) = cursor {
let path = store.path(&cursor.id).await;
let mut tree = store.tree(path.first()).await;
self.find_next_sibling(store, &mut tree, &mut cursor.id)
.await;
}
// If that condition doesn't hold, we're already at the bottom in
// cursor-less mode and can't move further down anyways.
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_to_first<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(tree_id) = store.first_tree().await {
*cursor = Some(Cursor::new(tree_id));
}
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
pub async fn move_to_last<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
let old_blocks = self
.layout_blocks(store, cursor.as_ref(), frame, size)
.await;
let old_cursor_id = cursor.as_ref().map(|c| c.id.clone());
if let Some(tree_id) = store.last_tree().await {
let tree = store.tree(&tree_id).await;
let mut id = tree_id;
while Self::find_last_child(&tree, &mut id) {}
*cursor = Some(Cursor::new(id));
}
if let Some(cursor) = cursor {
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
// TODO move_older[_unseen]
// TODO move_newer[_unseen]
pub async fn center_cursor<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: &mut Option<Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) {
if let Some(cursor) = cursor {
cursor.proportion = 0.5;
// Correcting the offset just to make sure that this function
// behaves nicely if the cursor has too many lines.
let old_blocks = self.layout_blocks(store, Some(cursor), frame, size).await;
let old_cursor_id = Some(cursor.id.clone());
self.correct_cursor_offset(store, frame, size, &old_blocks, &old_cursor_id, cursor)
.await;
}
}
}

168
src/chat/tree/layout.rs Normal file
View file

@ -0,0 +1,168 @@
//! Arranging messages as blocks.
use toss::frame::{Frame, Size};
use crate::chat::Cursor;
use crate::store::{Msg, MsgStore, Tree};
use super::blocks::{Block, Blocks};
use super::util::{self, MIN_CONTENT_WIDTH};
use super::TreeView;
fn msg_to_block<M: Msg>(frame: &mut Frame, size: Size, msg: &M, indent: usize) -> Block<M::Id> {
let nick = msg.nick();
let content = msg.content();
let content_width = size.width as i32 - util::after_nick(frame, indent, &nick);
if content_width < MIN_CONTENT_WIDTH as i32 {
Block::placeholder(msg.id(), indent).time(msg.time())
} else {
let content_width = content_width as usize;
let lines = toss::split_at_indices(&content, &frame.wrap(&content, content_width));
let lines = lines.into_iter().map(|s| s.to_string()).collect::<Vec<_>>();
Block::msg(msg.id(), indent, msg.time(), nick, lines)
}
}
fn layout_subtree<M: Msg>(
frame: &mut Frame,
size: Size,
tree: &Tree<M>,
indent: usize,
id: &M::Id,
result: &mut Blocks<M::Id>,
) {
let block = if let Some(msg) = tree.msg(id) {
msg_to_block(frame, size, msg, indent)
} else {
Block::placeholder(id.clone(), indent)
};
result.push_back(block);
if let Some(children) = tree.children(id) {
for child in children {
layout_subtree(frame, size, tree, indent + 1, child, result);
}
}
}
fn layout_tree<M: Msg>(frame: &mut Frame, size: Size, tree: Tree<M>) -> Blocks<M::Id> {
let mut blocks = Blocks::new();
layout_subtree(frame, size, &tree, 0, tree.root(), &mut blocks);
blocks
}
impl<M: Msg> TreeView<M> {
pub async fn expand_blocks_up<S: MsgStore<M>>(
store: &S,
frame: &mut Frame,
size: Size,
blocks: &mut Blocks<M::Id>,
tree_id: &mut Option<M::Id>,
) {
while blocks.top_line > 0 {
*tree_id = if let Some(tree_id) = tree_id {
store.prev_tree(tree_id).await
} else {
break;
};
if let Some(tree_id) = tree_id {
let tree = store.tree(tree_id).await;
blocks.prepend(layout_tree(frame, size, tree));
} else {
break;
}
}
}
pub async fn expand_blocks_down<S: MsgStore<M>>(
store: &S,
frame: &mut Frame,
size: Size,
blocks: &mut Blocks<M::Id>,
tree_id: &mut Option<M::Id>,
) {
while blocks.bottom_line < size.height as i32 {
*tree_id = if let Some(tree_id) = tree_id {
store.next_tree(tree_id).await
} else {
break;
};
if let Some(tree_id) = tree_id {
let tree = store.tree(tree_id).await;
blocks.append(layout_tree(frame, size, tree));
} else {
break;
}
}
}
// TODO Split up based on cursor presence
pub async fn layout_blocks<S: MsgStore<M>>(
&mut self,
store: &S,
cursor: Option<&Cursor<M::Id>>,
frame: &mut Frame,
size: Size,
) -> Blocks<M::Id> {
if let Some(cursor) = cursor {
// TODO Ensure focus lies on cursor path, otherwise unfocus
// TODO Unfold all messages on path to cursor
// Layout cursor subtree (with correct offsets based on cursor)
let cursor_path = store.path(&cursor.id).await;
let cursor_tree_id = cursor_path.first();
let cursor_tree = store.tree(cursor_tree_id).await;
let mut blocks = layout_tree(frame, size, cursor_tree);
blocks.calculate_offsets_with_cursor(cursor, size.height);
// Expand upwards and downwards, ensuring the blocks are not
// scrolled too far in any direction.
//
// If the blocks fill the screen, scrolling stops when the topmost
// message is at the top of the screen or the bottommost message is
// at the bottom. If they don't fill the screen, the bottommost
// message should always be at the bottom.
//
// Because our helper functions always expand the blocks until they
// reach the top or bottom of the screen, we can determine that
// we're at the top/bottom if expansion stopped anywhere in the
// middle of the screen.
//
// TODO Don't expand if there is a focus
let mut top_tree_id = Some(cursor_tree_id.clone());
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut top_tree_id).await;
if blocks.top_line > 0 {
blocks.offset(-blocks.top_line);
}
let mut bot_tree_id = Some(cursor_tree_id.clone());
Self::expand_blocks_down(store, frame, size, &mut blocks, &mut bot_tree_id).await;
if blocks.bottom_line < size.height as i32 - 1 {
blocks.offset(size.height as i32 - 1 - blocks.bottom_line);
}
// If we only moved the blocks down, we need to expand upwards again
// to make sure we fill the screen.
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut top_tree_id).await;
blocks
} else {
// TODO Ensure there is no focus
// Start at the bottom of the screen
let mut blocks = Blocks::new_below(size.height as i32 - 1);
// Expand upwards from last tree
if let Some(last_tree_id) = store.last_tree().await {
let last_tree = store.tree(&last_tree_id).await;
blocks.prepend(layout_tree(frame, size, last_tree));
let mut tree_id = Some(last_tree_id);
Self::expand_blocks_up(store, frame, size, &mut blocks, &mut tree_id).await;
}
blocks
}
}
}

93
src/chat/tree/render.rs Normal file
View file

@ -0,0 +1,93 @@
//! Rendering blocks to a [`Frame`].
use chrono::{DateTime, Utc};
use crossterm::style::ContentStyle;
use toss::frame::{Frame, Pos, Size};
use crate::store::Msg;
use super::blocks::{Block, BlockBody, Blocks};
use super::util::{
self, style_indent, style_indent_inverted, style_placeholder, style_time, style_time_inverted,
INDENT, PLACEHOLDER, TIME_EMPTY, TIME_FORMAT,
};
use super::TreeView;
fn render_time(frame: &mut Frame, x: i32, y: i32, cursor: bool, time: Option<DateTime<Utc>>) {
let pos = Pos::new(x, y);
let style = if cursor {
style_time_inverted()
} else {
style_time()
};
if let Some(time) = time {
let time = format!("{}", time.format(TIME_FORMAT));
frame.write(pos, &time, style);
} else {
frame.write(pos, TIME_EMPTY, style);
}
}
fn render_indent(frame: &mut Frame, x: i32, y: i32, cursor: bool, indent: usize) {
for i in 0..indent {
let pos = Pos::new(x + util::after_indent(i), y);
let style = if cursor {
style_indent_inverted()
} else {
style_indent()
};
frame.write(pos, INDENT, style);
}
}
fn render_nick(frame: &mut Frame, x: i32, y: i32, indent: usize, nick: &str) {
let nick_pos = Pos::new(x + util::after_indent(indent), y);
let nick = format!("[{}]", nick);
frame.write(nick_pos, &nick, ContentStyle::default());
}
fn render_block<M: Msg>(frame: &mut Frame, pos: Pos, size: Size, block: &Block<M::Id>) {
match &block.body {
BlockBody::Msg(msg) => {
let after_nick = util::after_nick(frame, block.indent, &msg.nick);
for (i, line) in msg.lines.iter().enumerate() {
let y = pos.y + block.line + i as i32;
if y < 0 || y >= size.height as i32 {
continue;
}
render_indent(frame, pos.x, y, block.cursor, block.indent);
if i == 0 {
render_time(frame, pos.x, y, block.cursor, block.time);
render_nick(frame, pos.x, y, block.indent, &msg.nick);
} else {
render_time(frame, pos.x, y, block.cursor, None);
}
let line_pos = Pos::new(pos.x + after_nick, y);
frame.write(line_pos, line, ContentStyle::default());
}
}
BlockBody::Placeholder => {
let y = pos.y + block.line;
render_time(frame, pos.x, y, block.cursor, block.time);
render_indent(frame, pos.x, y, block.cursor, block.indent);
let pos = Pos::new(pos.x + util::after_indent(block.indent), y);
frame.write(pos, PLACEHOLDER, style_placeholder());
}
}
}
impl<M: Msg> TreeView<M> {
pub fn render_blocks(frame: &mut Frame, pos: Pos, size: Size, layout: &Blocks<M::Id>) {
for block in &layout.blocks {
render_block::<M>(frame, pos, size, block);
}
}
}

56
src/chat/tree/util.rs Normal file
View file

@ -0,0 +1,56 @@
//! Constants and helper functions.
use crossterm::style::{ContentStyle, Stylize};
use toss::frame::Frame;
pub const TIME_FORMAT: &str = "%H:%M ";
pub const TIME_EMPTY: &str = " ";
pub const TIME_WIDTH: usize = 6;
pub fn style_time() -> ContentStyle {
ContentStyle::default().grey()
}
pub fn style_time_inverted() -> ContentStyle {
ContentStyle::default().black().on_white()
}
pub const INDENT: &str = "";
pub const INDENT_WIDTH: usize = 2;
pub fn style_indent() -> ContentStyle {
ContentStyle::default().dark_grey()
}
pub fn style_indent_inverted() -> ContentStyle {
ContentStyle::default().black().on_white()
}
pub const PLACEHOLDER: &str = "[...]";
pub fn style_placeholder() -> ContentStyle {
ContentStyle::default().dark_grey()
}
// Something like this should fit: [+, 1234 more]
pub const MIN_CONTENT_WIDTH: usize = 14;
pub fn after_indent(indent: usize) -> i32 {
(TIME_WIDTH + indent * INDENT_WIDTH) as i32
}
pub fn after_nick(frame: &mut Frame, indent: usize, nick: &str) -> i32 {
after_indent(indent) + 1 + frame.width(nick) as i32 + 2
}
pub fn proportion_to_line(height: u16, proportion: f32) -> i32 {
((height - 1) as f32 * proportion).ceil() as i32
}
pub fn line_to_proportion(height: u16, line: i32) -> f32 {
if height > 1 {
line as f32 / (height - 1) as f32
} else {
0.0
}
}

5
src/euph.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod api;
pub mod conn;
// mod room;
// pub use room::Room;

13
src/euph/api.rs Normal file
View file

@ -0,0 +1,13 @@
//! Models the euphoria API at <http://api.euphoria.io/>.
mod events;
pub mod packet;
mod room_cmds;
mod session_cmds;
mod types;
pub use events::*;
pub use packet::Data;
pub use room_cmds::*;
pub use session_cmds::*;
pub use types::*;

170
src/euph/api/events.rs Normal file
View file

@ -0,0 +1,170 @@
//! Asynchronous events.
use serde::{Deserialize, Serialize};
use super::{AuthOption, Message, PersonalAccountView, SessionView, Snowflake, Time, UserId};
/// Indicates that access to a room is denied.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BounceEvent {
/// The reason why access was denied.
pub reason: Option<String>,
/// Authentication options that may be used.
pub auth_options: Option<Vec<AuthOption>>,
/// Internal use only.
pub agent_id: Option<UserId>,
/// Internal use only.
pub ip: Option<String>,
}
/// Indicates that the session is being closed. The client will subsequently be
/// disconnected.
///
/// If the disconnect reason is `authentication changed`, the client should
/// immediately reconnect.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DisconnectEvent {
/// The reason for disconnection.
pub reason: String,
}
/// Sent by the server to the client when a session is started.
///
/// It includes information about the client's authentication and associated
/// identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloEvent {
/// The id of the agent or account logged into this session.
pub id: UserId,
/// Details about the user's account, if the session is logged in.
pub account: Option<PersonalAccountView>,
/// Details about the session.
pub session: SessionView,
/// If true, then the account has an explicit access grant to the current
/// room.
pub account_has_access: Option<bool>,
/// Whether the account's email address has been verified.
pub account_email_verified: Option<bool>,
/// If true, the session is connected to a private room.
pub room_is_private: bool,
/// The version of the code being run and served by the server.
pub version: String,
}
/// Indicates a session just joined the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinEvent(pub SessionView);
/// Sent to all sessions of an agent when that agent is logged in (except for
/// the session that issued the login command).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginEvent {
pub account_id: Snowflake,
}
/// Sent to all sessions of an agent when that agent is logged out (except for
/// the session that issued the logout command).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogoutEvent;
/// Indicates some server-side event that impacts the presence of sessions in a
/// room.
///
/// If the network event type is `partition`, then this should be treated as a
/// [`PartEvent`] for all sessions connected to the same server id/era combo.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkEvent {
/// The type of network event; for now, always `partition`.
pub r#type: String,
/// The id of the affected server.
pub server_id: String,
/// The era of the affected server.
pub server_era: String,
}
/// Announces a nick change by another session in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickEvent {
/// The id of the session this name applies to.
pub session_id: String,
/// The id of the agent or account logged into the session.
pub id: UserId,
/// The previous name associated with the session.
pub from: String,
/// The name associated with the session henceforth.
pub to: String,
}
/// Indicates that a message in the room has been modified or deleted.
///
/// If the client offers a user interface and the indicated message is currently
/// displayed, it should update its display accordingly.
///
/// The event packet includes a snapshot of the message post-edit.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditMessageEvent {
/// The id of the edit.
pub edit_id: Snowflake,
/// The snapshot of the message post-edit.
#[serde(flatten)]
pub message: Message,
}
/// Indicates a session just disconnected from the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartEvent(pub SessionView);
/// Represents a server-to-client ping.
///
/// The client should send back a ping-reply with the same value for the time
/// field as soon as possible (or risk disconnection).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingEvent {
/// A unix timestamp according to the server's clock.
pub time: Time,
/// The expected time of the next ping event, according to the server's
/// clock.
pub next: Time,
}
/// Informs the client that another user wants to chat with them privately.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiateEvent {
/// The id of the user inviting the client to chat privately.
pub from: UserId,
/// The nick of the inviting user.
pub from_nick: String,
/// The room where the invitation was sent from.
pub from_room: String,
/// The private chat can be accessed at `/room/pm:<pm_id>`.
pub pm_id: Snowflake,
}
/// Indicates a message received by the room from another session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendEvent(pub Message);
/// Indicates that a session has successfully joined a room.
///
/// It also offers a snapshot of the rooms state and recent history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotEvent {
/// The id of the agent or account logged into this session.
pub identity: UserId,
/// The globally unique id of this session.
pub session_id: String,
/// The servers version identifier.
pub version: String,
/// The list of all other sessions joined to the room (excluding this
/// session).
pub listing: Vec<SessionView>,
/// The most recent messages posted to the room (currently up to 100).
pub log: Vec<Message>,
/// The acting nick of the session; if omitted, client set nick before
/// speaking.
pub nick: Option<String>,
/// If given, this room is for private chat with the given nick.
pub pm_with_nick: Option<String>,
/// If given, this room is for private chat with the given user.
pub pm_with_user_id: Option<String>,
}

222
src/euph/api/packet.rs Normal file
View file

@ -0,0 +1,222 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::PacketType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Packet {
pub id: Option<String>,
pub r#type: PacketType,
pub data: Option<Value>,
#[serde(skip_serializing)]
pub error: Option<String>,
#[serde(default, skip_serializing)]
pub throttled: bool,
#[serde(skip_serializing)]
pub throttled_reason: Option<String>,
}
pub trait Command {
type Reply;
}
macro_rules! packets {
( $( $name:ident, )*) => {
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Data {
$( $name(super::$name), )*
Unimplemented,
}
impl Data {
pub fn from_value(ptype: PacketType, value: Value) -> serde_json::Result<Self> {
Ok(match ptype {
$( PacketType::$name => Self::$name(serde_json::from_value(value)?), )*
_ => Self::Unimplemented,
})
}
pub fn to_value(self) -> serde_json::Result<Value> {
Ok(match self{
$( Self::$name(p) => serde_json::to_value(p)?, )*
Self::Unimplemented => panic!("using unimplemented data"),
})
}
pub fn packet_type(&self) -> PacketType {
match self {
$( Self::$name(p) => PacketType::$name, )*
Self::Unimplemented => panic!("using unimplemented data"),
}
}
}
$(
impl From<super::$name> for Data {
fn from(p: super::$name) -> Self {
Self::$name(p)
}
}
impl TryFrom<Data> for super::$name{
type Error = ();
fn try_from(value: Data) -> Result<Self, Self::Error> {
match value {
Data::$name(p) => Ok(p),
_ => Err(())
}
}
}
)*
};
}
macro_rules! events {
( $( $name:ident, )* ) => {
impl Data {
pub fn is_event(&self) -> bool {
match self {
$( Self::$name(_) => true, )*
_ => false,
}
}
}
};
}
macro_rules! commands {
( $( $cmd:ident => $rpl:ident, )* ) => {
$(
impl Command for super::$cmd {
type Reply = super::$rpl;
}
)*
};
}
packets! {
BounceEvent,
DisconnectEvent,
HelloEvent,
JoinEvent,
LoginEvent,
LogoutEvent,
NetworkEvent,
NickEvent,
EditMessageEvent,
PartEvent,
PingEvent,
PmInitiateEvent,
SendEvent,
SnapshotEvent,
Auth,
AuthReply,
Ping,
PingReply,
GetMessage,
GetMessageReply,
Log,
LogReply,
Nick,
NickReply,
PmInitiate,
PmInitiateReply,
Send,
SendReply,
Who,
WhoReply,
}
events! {
BounceEvent,
DisconnectEvent,
HelloEvent,
JoinEvent,
LoginEvent,
LogoutEvent,
NetworkEvent,
NickEvent,
EditMessageEvent,
PartEvent,
PingEvent,
PmInitiateEvent,
SendEvent,
SnapshotEvent,
}
commands! {
Auth => AuthReply,
Ping => PingReply,
GetMessage => GetMessageReply,
Log => LogReply,
Nick => NickReply,
PmInitiate => PmInitiateReply,
Send => SendReply,
Who => WhoReply,
}
#[derive(Debug, Clone)]
pub struct ParsedPacket {
pub id: Option<String>,
pub r#type: PacketType,
pub content: Result<Data, String>,
pub throttled: Option<String>,
}
impl ParsedPacket {
pub fn from_packet(packet: Packet) -> serde_json::Result<Self> {
let id = packet.id;
let r#type = packet.r#type;
let content = if let Some(error) = packet.error {
Err(error)
} else {
let data = packet.data.unwrap_or_default();
Ok(Data::from_value(r#type, data)?)
};
let throttled = if packet.throttled {
let reason = packet
.throttled_reason
.unwrap_or_else(|| "no reason given".to_string());
Some(reason)
} else {
None
};
Ok(Self {
id,
r#type,
content,
throttled,
})
}
pub fn to_packet(self) -> serde_json::Result<Packet> {
let id = self.id;
let r#type = self.r#type;
let throttled = self.throttled.is_some();
let throttled_reason = self.throttled;
Ok(match self.content {
Ok(data) => Packet {
id,
r#type,
data: Some(data.to_value()?),
error: None,
throttled,
throttled_reason,
},
Err(error) => Packet {
id,
r#type,
data: None,
error: Some(error),
throttled,
throttled_reason,
},
})
}
}

140
src/euph/api/room_cmds.rs Normal file
View file

@ -0,0 +1,140 @@
//! Chat room commands.
use serde::{Deserialize, Serialize};
use super::{Message, SessionView, Snowflake, UserId};
/// Retrieve the full content of a single message in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetMessage {
/// The id of the message to retrieve.
pub id: Snowflake,
}
/// The message retrieved by [`GetMessage`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetMessageReply(pub Message);
/// Request messages from the room's message log.
///
/// This can be used to supplement the log provided by snapshot-event (for
/// example, when scrolling back further in history).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Log {
/// Maximum number of messages to return (up to 1000).
pub n: usize,
/// Return messages prior to this snowflake.
pub before: Option<Snowflake>,
}
/// List of messages from the room's message log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogReply {
/// List of messages returned.
pub log: Vec<Message>,
/// Messages prior to this snowflake were returned.
pub before: Option<Snowflake>,
}
/// Set the name you present to the room.
///
/// This name applies to all messages sent during this session, until the nick
/// command is called again.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Nick {
/// The requested name (maximum length 36 bytes).
pub name: String,
}
impl Nick {
pub fn new<S: ToString>(name: S) -> Self {
Self {
name: name.to_string(),
}
}
}
/// Confirms the [`Nick`] command.
///
/// Returns the session's former and new names (the server may modify the
/// requested nick).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NickReply {
/// The id of the session this name applies to.
pub session_id: String,
/// The id of the agent or account logged into the session.
pub id: UserId,
/// The previous name associated with the session.
pub from: String,
/// The name associated with the session henceforth.
pub to: String,
}
/// Constructs a virtual room for private messaging between the client and the
/// given [`UserId`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiate {
/// The id of the user to invite to chat privately.
pub user_id: UserId,
}
/// Provides the PMID for the requested private messaging room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PmInitiateReply {
/// The private chat can be accessed at `/room/pm:<pm_id>`.
pub pm_id: Snowflake,
/// The nickname of the recipient of the invitation.
pub to_nick: String,
}
/// Send a message to a room.
///
/// The session must be successfully joined with the room. This message will be
/// broadcast to all sessions joined with the room.
///
/// If the room is private, then the message content will be encrypted before it
/// is stored and broadcast to the rest of the room.
///
/// The caller of this command will not receive the corresponding
/// [`SendEvent`](super::SendEvent), but will receive the same information in
/// the [`SendReply`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Send {
/// The content of the message (client-defined).
pub content: String,
/// The id of the parent message, if any.
pub parent: Option<Snowflake>,
}
impl Send {
pub fn new<S: ToString>(content: S) -> Self {
Self {
content: content.to_string(),
parent: None,
}
}
pub fn reply<S: ToString>(parent: Snowflake, content: S) -> Self {
Self {
content: content.to_string(),
parent: Some(parent),
}
}
}
/// The message that was sent.
///
/// this includes the message id, which was populated by the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendReply(pub Message);
/// Request a list of sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Who;
/// Lists the sessions currently joined in the room.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhoReply {
/// A list of session views.
listing: Vec<SessionView>,
}

View file

@ -0,0 +1,43 @@
//! Session commands.
use serde::{Deserialize, Serialize};
use super::{AuthOption, Time};
/// Attempt to join a private room.
///
/// This should be sent in response to a bounce event at the beginning of a
/// session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Auth {
/// The method of authentication.
pub r#type: AuthOption,
/// Use this field for [`AuthOption::Passcode`] authentication.
pub passcode: Option<String>,
}
/// Reports whether the [`Auth`] command succeeded.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthReply {
/// True if authentication succeeded.
pub success: bool,
/// If [`Self::success`] was false, the reason for failure.
pub reason: Option<String>,
}
/// Initiate a client-to-server ping.
///
/// The server will send back a [`PingReply`] with the same timestamp as soon as
/// possible.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ping {
/// An arbitrary value, intended to be a unix timestamp.
pub time: Time,
}
/// Response to a [`Ping`] command or [`PingEvent`](super::PingEvent).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PingReply {
/// The timestamp of the ping being replied to.
pub time: Option<Time>,
}

340
src/euph/api/types.rs Normal file
View file

@ -0,0 +1,340 @@
//! Field types.
// TODO Add newtype wrappers for different kinds of IDs?
// Serde's derive macros generate this warning and I can't turn it off locally,
// so I'm turning it off for the entire module.
#![allow(clippy::use_self)]
use std::fmt;
use chrono::{DateTime, Utc};
use serde::{de, ser, Deserialize, Serialize};
use serde_json::Value;
/// Describes an account and its preferred name.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountView {
/// The id of the account.
pub id: Snowflake,
/// The name that the holder of the account goes by.
pub name: String,
}
/// Mode of authentication.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuthOption {
/// Authentication with a passcode, where a key is derived from the passcode
/// to unlock an access grant.
Passcode,
}
/// A node in a room's log.
///
/// It corresponds to a chat message, or a post, or any broadcasted event in a
/// room that should appear in the log.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
/// The id of the message (unique within a room).
pub id: Snowflake,
/// The id of the message's parent, or null if top-level.
pub parent: Option<Snowflake>,
/// The edit id of the most recent edit of this message, or null if it's
/// never been edited.
pub previous_edit_id: Option<Snowflake>,
/// The unix timestamp of when the message was posted.
pub time: Time,
/// The view of the sender's session.
pub sender: SessionView,
/// The content of the message (client-defined).
pub content: String,
/// The id of the key that encrypts the message in storage.
pub encryption_key_id: Option<String>,
/// The unix timestamp of when the message was last edited.
pub edited: Option<Time>,
/// The unix timestamp of when the message was deleted.
pub deleted: Option<Time>,
/// If true, then the full content of this message is not included (see
/// [`GetMessage`](super::GetMessage) to obtain the message with full
/// content).
#[serde(default)]
pub truncated: bool,
}
/// The type of a packet.
///
/// Not all of these types have their corresponding data modeled as a struct.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PacketType {
// Asynchronous events
/// See [`BounceEvent`](super::BounceEvent).
BounceEvent,
/// See [`DisconnectEvent`](super::DisconnectEvent).
DisconnectEvent,
/// See [`HelloEvent`](super::HelloEvent).
HelloEvent,
/// See [`JoinEvent`](super::JoinEvent).
JoinEvent,
/// See [`LoginEvent`](super::LoginEvent).
LoginEvent,
/// See [`LogoutEvent`](super::LogoutEvent).
LogoutEvent,
/// See [`NetworkEvent`](super::NetworkEvent).
NetworkEvent,
/// See [`NickEvent`](super::NickEvent).
NickEvent,
/// See [`EditMessageEvent`](super::EditMessageEvent).
EditMessageEvent,
/// See [`PartEvent`](super::PartEvent).
PartEvent,
/// See [`PingEvent`](super::PingEvent).
PingEvent,
/// See [`PmInitiateEvent`](super::PmInitiateEvent).
PmInitiateEvent,
/// See [`SendEvent`](super::SendEvent).
SendEvent,
/// See [`SnapshotEvent`](super::SnapshotEvent).
SnapshotEvent,
// Session commands
/// See [`Auth`](super::Auth).
Auth,
/// See [`AuthReply`](super::AuthReply).
AuthReply,
/// See [`Ping`](super::Ping).
Ping,
/// See [`PingReply`](super::PingReply).
PingReply,
// Chat room commands
/// See [`GetMessage`](super::GetMessage).
GetMessage,
/// See [`GetMessageReply`](super::GetMessageReply).
GetMessageReply,
/// See [`Log`](super::Log).
Log,
/// See [`LogReply`](super::LogReply).
LogReply,
/// See [`Nick`](super::Nick).
Nick,
/// See [`NickReply`](super::NickReply).
NickReply,
/// See [`PmInitiate`](super::PmInitiate).
PmInitiate,
/// See [`PmInitiateReply`](super::PmInitiateReply).
PmInitiateReply,
/// See [`Send`](super::Send).
Send,
/// See [`SendReply`](super::SendReply).
SendReply,
/// See [`Who`](super::Who).
Who,
/// See [`WhoReply`](super::WhoReply).
WhoReply,
// Account commands
/// Not implemented.
ChangeEmail,
/// Not implemented.
ChangeEmailReply,
/// Not implemented.
ChangeName,
/// Not implemented.
ChangeNameReply,
/// Not implemented.
ChangePassword,
/// Not implemented.
ChangePasswordReply,
/// Not implemented.
Login,
/// Not implemented.
LoginReply,
/// Not implemented.
Logout,
/// Not implemented.
LogoutReply,
/// Not implemented.
RegisterAccount,
/// Not implemented.
RegisterAccountReply,
/// Not implemented.
ResendVerificationEmail,
/// Not implemented.
ResendVerificationEmailReply,
/// Not implemented.
ResetPassword,
/// Not implemented.
ResetPasswordReply,
// Room host commands
/// Not implemented.
Ban,
/// Not implemented.
BanReply,
/// Not implemented.
EditMessage,
/// Not implemented.
EditMessageReply,
/// Not implemented.
GrantAccess,
/// Not implemented.
GrantAccessReply,
/// Not implemented.
GrantManager,
/// Not implemented.
GrantManagerReply,
/// Not implemented.
RevokeAccess,
/// Not implemented.
RevokeAccessReply,
/// Not implemented.
RevokeManager,
/// Not implemented.
RevokeManagerReply,
/// Not implemented.
Unban,
/// Not implemented.
UnbanReply,
// Staff commands
/// Not implemented.
StaffCreateRoom,
/// Not implemented.
StaffCreateRoomReply,
/// Not implemented.
StaffEnrollOtp,
/// Not implemented.
StaffEnrollOtpReply,
/// Not implemented.
StaffGrantManager,
/// Not implemented.
StaffGrantManagerReply,
/// Not implemented.
StaffInvade,
/// Not implemented.
StaffInvadeReply,
/// Not implemented.
StaffLockRoom,
/// Not implemented.
StaffLockRoomReply,
/// Not implemented.
StaffRevokeAccess,
/// Not implemented.
StaffRevokeAccessReply,
/// Not implemented.
StaffValidateOtp,
/// Not implemented.
StaffValidateOtpReply,
/// Not implemented.
UnlockStaffCapability,
/// Not implemented.
UnlockStaffCapabilityReply,
}
impl fmt::Display for PacketType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match serde_json::to_value(self) {
Ok(Value::String(s)) => write!(f, "{}", s),
_ => Err(fmt::Error),
}
}
}
/// Describes an account to its owner.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonalAccountView {
/// The id of the account.
pub id: Snowflake,
/// The name that the holder of the account goes by.
pub name: String,
/// The account's email address.
pub email: String,
}
/// Describes a session and its identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionView {
/// The id of an agent or account (or bot).
pub id: UserId,
/// The name-in-use at the time this view was captured.
pub name: String,
/// The id of the server that captured this view.
pub server_id: String,
/// The era of the server that captured this view.
pub server_era: String,
/// Id of the session, unique across all sessions globally.
pub session_id: String,
/// If true, this session belongs to a member of staff.
#[serde(default)]
pub is_staff: bool,
/// If true, this session belongs to a manager of the room.
#[serde(default)]
pub is_manager: bool,
/// For hosts and staff, the virtual address of the client.
pub client_address: Option<String>,
/// For staff, the real address of the client.
pub real_client_address: Option<String>,
}
/// A 13-character string, usually used as aunique identifier for some type of object.
///
/// It is the base-36 encoding of an unsigned, 64-bit integer.
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct Snowflake(pub u64);
impl Serialize for Snowflake {
fn serialize<S: ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// Convert u64 to base36 string
let mut n = self.0;
let mut result = String::with_capacity(13);
for _ in 0..13 {
let c = char::from_digit((n % 36) as u32, 36).unwrap();
result.insert(0, c);
n /= 36;
}
result.serialize(serializer)
}
}
struct SnowflakeVisitor;
impl<'de> de::Visitor<'de> for SnowflakeVisitor {
type Value = Snowflake;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "a base36 string of length 13")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
// Convert base36 string to u64
if v.len() != 13 {
return Err(E::invalid_length(v.len(), &self));
}
let n = u64::from_str_radix(v, 36)
.map_err(|_| E::invalid_value(de::Unexpected::Str(v), &self))?;
Ok(Snowflake(n))
}
}
impl<'de> Deserialize<'de> for Snowflake {
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_str(SnowflakeVisitor)
}
}
/// Time is specified as a signed 64-bit integer, giving the number of seconds
/// since the Unix Epoch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Time(#[serde(with = "chrono::serde::ts_seconds")] pub DateTime<Utc>);
/// Identifies a user.
///
/// The prefix of this value (up to the colon) indicates a type of session,
/// while the suffix is a unique value for that type of session.
///
/// It is possible for this value to have no prefix and colon, and there is no
/// fixed format for the unique value.
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserId(pub String);

440
src/euph/conn.rs Normal file
View file

@ -0,0 +1,440 @@
//! Connection state modeling.
use std::collections::HashMap;
use std::convert::Infallible;
use std::time::Duration;
use anyhow::bail;
use chrono::Utc;
use futures::channel::oneshot;
use futures::stream::{SplitSink, SplitStream};
use futures::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::{select, task, time};
use tokio_tungstenite::{tungstenite, MaybeTlsStream, WebSocketStream};
use crate::replies::{self, PendingReply, Replies};
use super::api::packet::{Command, Packet, ParsedPacket};
use super::api::{
BounceEvent, Data, HelloEvent, PersonalAccountView, Ping, PingReply, SessionView,
SnapshotEvent, Time, UserId,
};
pub type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("connection closed")]
ConnectionClosed,
#[error("packet timed out")]
TimedOut,
#[error("incorrect reply type")]
IncorrectReplyType,
#[error("{0}")]
Euph(String),
}
#[derive(Debug)]
enum Event {
Message(tungstenite::Message),
SendCmd(Data, oneshot::Sender<PendingReply<Result<Data, String>>>),
SendRpl(Option<String>, Data),
Status(oneshot::Sender<Status>),
DoPings,
}
impl Event {
fn send_cmd<C: Into<Data>>(
cmd: C,
rpl: oneshot::Sender<PendingReply<Result<Data, String>>>,
) -> Self {
Self::SendCmd(cmd.into(), rpl)
}
fn send_rpl<C: Into<Data>>(id: Option<String>, rpl: C) -> Self {
Self::SendRpl(id, rpl.into())
}
}
#[derive(Debug, Clone, Default)]
pub struct Joining {
hello: Option<HelloEvent>,
snapshot: Option<SnapshotEvent>,
bounce: Option<BounceEvent>,
}
impl Joining {
fn on_data(&mut self, data: Data) {
match data {
Data::BounceEvent(p) => self.bounce = Some(p),
Data::HelloEvent(p) => self.hello = Some(p),
Data::SnapshotEvent(p) => self.snapshot = Some(p),
_ => {}
}
}
fn joined(&self) -> Option<Joined> {
if let (Some(hello), Some(snapshot)) = (&self.hello, &self.snapshot) {
let listing = snapshot
.listing
.iter()
.cloned()
.map(|s| (s.id.clone(), s))
.collect::<HashMap<_, _>>();
Some(Joined {
session: hello.session.clone(),
account: hello.account.clone(),
listing,
})
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub struct Joined {
session: SessionView,
account: Option<PersonalAccountView>,
listing: HashMap<UserId, SessionView>,
}
impl Joined {
fn on_data(&mut self, data: Data) {
match data {
Data::JoinEvent(p) => {
self.listing.insert(p.0.id.clone(), p.0);
}
Data::SendEvent(p) => {
self.listing.insert(p.0.sender.id.clone(), p.0.sender);
}
Data::PartEvent(p) => {
self.listing.remove(&p.0.id);
}
Data::NetworkEvent(p) => {
if p.r#type == "partition" {
self.listing.retain(|_, s| {
!(s.server_id == p.server_id && s.server_era == p.server_era)
});
}
}
Data::NickEvent(p) => {
if let Some(session) = self.listing.get_mut(&p.id) {
session.name = p.to;
}
}
Data::NickReply(p) => {
assert_eq!(self.session.id, p.id);
self.session.name = p.to;
}
// The who reply is broken and can't be trusted right now, so we'll
// not even look at it.
_ => {}
}
}
}
#[derive(Debug, Clone)]
pub enum Status {
Joining(Joining),
Joined(Joined),
}
struct State {
ws_tx: SplitSink<WsStream, tungstenite::Message>,
last_id: usize,
replies: Replies<String, Result<Data, String>>,
packet_tx: mpsc::UnboundedSender<Data>,
last_ws_ping: Option<Vec<u8>>,
last_ws_pong: Option<Vec<u8>>,
last_euph_ping: Option<Time>,
last_euph_pong: Option<Time>,
status: Status,
}
impl State {
async fn run(
ws: WsStream,
mut tx_canary: mpsc::UnboundedReceiver<Infallible>,
rx_canary: oneshot::Receiver<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
mut event_rx: mpsc::UnboundedReceiver<Event>,
packet_tx: mpsc::UnboundedSender<Data>,
) {
let (ws_tx, mut ws_rx) = ws.split();
let mut state = Self {
ws_tx,
last_id: 0,
replies: Replies::new(Duration::from_secs(10)), // TODO Make configurable
packet_tx,
last_ws_ping: None,
last_ws_pong: None,
last_euph_ping: None,
last_euph_pong: None,
status: Status::Joining(Joining::default()),
};
select! {
_ = tx_canary.recv() => (),
_ = rx_canary => (),
_ = Self::listen(&mut ws_rx, &event_tx) => (),
_ = Self::send_ping_events(&event_tx) => (),
_ = state.handle_events(&event_tx, &mut event_rx) => (),
}
}
async fn listen(
ws_rx: &mut SplitStream<WsStream>,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
while let Some(msg) = ws_rx.next().await {
event_tx.send(Event::Message(msg?))?;
}
Ok(())
}
async fn send_ping_events(event_tx: &mpsc::UnboundedSender<Event>) -> anyhow::Result<()> {
loop {
event_tx.send(Event::DoPings)?;
time::sleep(Duration::from_secs(10)).await; // TODO Make configurable
}
}
async fn handle_events(
&mut self,
event_tx: &mpsc::UnboundedSender<Event>,
event_rx: &mut mpsc::UnboundedReceiver<Event>,
) -> anyhow::Result<()> {
while let Some(ev) = event_rx.recv().await {
match ev {
Event::Message(msg) => self.on_msg(msg, event_tx)?,
Event::SendCmd(data, reply_tx) => self.on_send_cmd(data, reply_tx).await?,
Event::SendRpl(id, data) => self.on_send_rpl(id, data).await?,
Event::Status(reply_tx) => self.on_status(reply_tx),
Event::DoPings => self.do_pings(event_tx).await?,
}
}
Ok(())
}
fn on_msg(
&mut self,
msg: tungstenite::Message,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
match msg {
tungstenite::Message::Text(t) => self.on_packet(serde_json::from_str(&t)?, event_tx)?,
tungstenite::Message::Binary(_) => bail!("unexpected binary message"),
tungstenite::Message::Ping(_) => {}
tungstenite::Message::Pong(p) => self.last_ws_pong = Some(p),
tungstenite::Message::Close(_) => {}
tungstenite::Message::Frame(_) => {}
}
Ok(())
}
fn on_packet(
&mut self,
packet: Packet,
event_tx: &mpsc::UnboundedSender<Event>,
) -> anyhow::Result<()> {
let packet = ParsedPacket::from_packet(packet)?;
// Complete pending replies if the packet has an id
if let Some(id) = &packet.id {
self.replies.complete(id, packet.content.clone());
}
// Shovel events into self.packet_tx, assuming that no event ever
// errors. Events with errors are simply ignored.
if let Ok(data) = &packet.content {
if data.is_event() {
self.packet_tx.send(data.clone())?;
}
}
// Play a game of table tennis
match &packet.content {
Ok(Data::PingReply(p)) => self.last_euph_pong = p.time,
Ok(Data::PingEvent(p)) => {
let reply = PingReply { time: Some(p.time) };
event_tx.send(Event::send_rpl(packet.id.clone(), reply))?;
}
// TODO Handle disconnect event?
_ => {}
}
// Update internal state
if let Ok(data) = packet.content {
match &mut self.status {
Status::Joining(joining) => {
joining.on_data(data);
if let Some(joined) = joining.joined() {
self.status = Status::Joined(joined);
}
}
Status::Joined(joined) => joined.on_data(data),
}
}
Ok(())
}
async fn on_send_cmd(
&mut self,
data: Data,
reply_tx: oneshot::Sender<PendingReply<Result<Data, String>>>,
) -> anyhow::Result<()> {
// Overkill of universe-heat-death-like proportions
self.last_id = self.last_id.wrapping_add(1);
let id = format!("{}", self.last_id);
let packet = ParsedPacket {
id: Some(id.clone()),
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
.to_packet()?;
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?);
self.ws_tx.send(msg).await?;
let _ = reply_tx.send(self.replies.wait_for(id));
Ok(())
}
async fn on_send_rpl(&mut self, id: Option<String>, data: Data) -> anyhow::Result<()> {
let packet = ParsedPacket {
id,
r#type: data.packet_type(),
content: Ok(data),
throttled: None,
}
.to_packet()?;
let msg = tungstenite::Message::Text(serde_json::to_string(&packet)?);
self.ws_tx.send(msg).await?;
Ok(())
}
fn on_status(&mut self, reply_tx: oneshot::Sender<Status>) {
let _ = reply_tx.send(self.status.clone());
}
async fn do_pings(&mut self, event_tx: &mpsc::UnboundedSender<Event>) -> anyhow::Result<()> {
// Check old ws ping
if self.last_ws_ping.is_some() && self.last_ws_ping != self.last_ws_pong {
bail!("server missed ws ping")
}
// Send new ws ping
let mut ws_payload = [0_u8; 8];
rand::thread_rng().fill(&mut ws_payload);
self.ws_tx
.send(tungstenite::Message::Ping(ws_payload.to_vec()))
.await?;
// Check old euph ping
if self.last_euph_ping.is_some() && self.last_euph_ping != self.last_euph_pong {
bail!("server missed euph ping")
}
// Send new euph ping
let euph_payload = Time(Utc::now());
let (tx, _) = oneshot::channel();
event_tx.send(Event::send_cmd(Ping { time: euph_payload }, tx))?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct ConnTx {
canary: mpsc::UnboundedSender<Infallible>,
event_tx: mpsc::UnboundedSender<Event>,
}
impl ConnTx {
pub async fn send<C>(&self, cmd: C) -> Result<C::Reply, Error>
where
C: Command + Into<Data>,
C::Reply: TryFrom<Data>,
{
let (tx, rx) = oneshot::channel();
self.event_tx
.send(Event::SendCmd(cmd.into(), tx))
.map_err(|_| Error::ConnectionClosed)?;
let pending_reply = rx
.await
// This should only happen if something goes wrong during encoding
// of the packet or while sending it through the websocket. Assuming
// the first doesn't happen, the connection is probably closed.
.map_err(|_| Error::ConnectionClosed)?;
let data = pending_reply
.get()
.await
.map_err(|e| match e {
replies::Error::TimedOut => Error::TimedOut,
replies::Error::Canceled => Error::ConnectionClosed,
})?
.map_err(Error::Euph)?;
data.try_into().map_err(|_| Error::IncorrectReplyType)
}
pub async fn status(&self) -> Result<Status, Error> {
let (tx, rx) = oneshot::channel();
self.event_tx
.send(Event::Status(tx))
.map_err(|_| Error::ConnectionClosed)?;
rx.await.map_err(|_| Error::ConnectionClosed)
}
}
#[derive(Debug)]
pub struct ConnRx {
canary: oneshot::Sender<Infallible>,
packet_rx: mpsc::UnboundedReceiver<Data>,
}
impl ConnRx {
pub async fn recv(&mut self) -> Result<Data, Error> {
self.packet_rx.recv().await.ok_or(Error::ConnectionClosed)
}
}
// TODO Combine ConnTx and ConnRx and implement Stream + Sink?
pub fn wrap(ws: WsStream) -> (ConnTx, ConnRx) {
let (tx_canary_tx, tx_canary_rx) = mpsc::unbounded_channel();
let (rx_canary_tx, rx_canary_rx) = oneshot::channel();
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (packet_tx, packet_rx) = mpsc::unbounded_channel();
task::spawn(State::run(
ws,
tx_canary_rx,
rx_canary_rx,
event_tx.clone(),
event_rx,
packet_tx,
));
let tx = ConnTx {
canary: tx_canary_tx,
event_tx,
};
let rx = ConnRx {
canary: rx_canary_tx,
packet_rx,
};
(tx, rx)
}

110
src/euph/room.rs Normal file
View file

@ -0,0 +1,110 @@
use std::convert::Infallible;
use std::time::Duration;
use futures::stream::{SplitSink, SplitStream};
use futures::StreamExt;
use tokio::sync::{mpsc, oneshot};
use tokio::{select, task, time};
use tokio_tungstenite::tungstenite;
use super::conn::{State, Status, WsStream};
#[derive(Debug)]
enum Event {
Connected(SplitSink<WsStream, tungstenite::Message>),
Disconnected,
WsMessage(tungstenite::Message),
DoPings,
GetStatus(oneshot::Sender<Option<Status>>),
}
async fn run(
canary: oneshot::Receiver<Infallible>,
tx: mpsc::UnboundedSender<Event>,
rx: mpsc::UnboundedReceiver<Event>,
url: String,
) {
let state = State::default();
select! {
_ = canary => (),
_ = respond_to_events(state, rx) => (),
_ = maintain_connection(tx, url) => (),
}
}
async fn respond_to_events(
mut state: State,
mut rx: mpsc::UnboundedReceiver<Event>,
) -> anyhow::Result<()> {
while let Some(event) = rx.recv().await {
match event {
Event::Connected(tx) => state.on_connected(tx),
Event::Disconnected => state.on_disconnected(),
Event::WsMessage(msg) => state.on_ws_message(msg)?,
Event::DoPings => state.on_do_pings()?,
Event::GetStatus(tx) => {
let _ = tx.send(state.status());
}
}
}
Ok(())
}
async fn maintain_connection(tx: mpsc::UnboundedSender<Event>, url: String) -> anyhow::Result<()> {
loop {
// TODO Cookies
let (ws, _) = tokio_tungstenite::connect_async(&url).await?;
let (ws_tx, ws_rx) = ws.split();
tx.send(Event::Connected(ws_tx))?;
select! {
_ = receive_messages(&tx, ws_rx) => (),
_ = prompt_pings(&tx) => ()
}
tx.send(Event::Disconnected)?;
// TODO Make reconnect delay configurable
time::sleep(Duration::from_secs(5)).await;
}
}
async fn receive_messages(
tx: &mpsc::UnboundedSender<Event>,
mut rx: SplitStream<WsStream>,
) -> anyhow::Result<()> {
while let Some(msg) = rx.next().await {
tx.send(Event::WsMessage(msg?))?;
}
Ok(())
}
async fn prompt_pings(tx: &mpsc::UnboundedSender<Event>) -> anyhow::Result<()> {
loop {
// TODO Make ping delay configurable
time::sleep(Duration::from_secs(10)).await;
tx.send(Event::DoPings)?;
}
}
pub struct Room {
canary: oneshot::Sender<Infallible>,
tx: mpsc::UnboundedSender<Event>,
}
impl Room {
pub fn start(url: String) -> Self {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (canary_tx, canary_rx) = oneshot::channel();
task::spawn(run(canary_rx, event_tx.clone(), event_rx, url));
Self {
canary: canary_tx,
tx: event_tx,
}
}
pub async fn status(&self) -> anyhow::Result<Option<Status>> {
let (tx, rx) = oneshot::channel();
self.tx.send(Event::GetStatus(tx))?;
Ok(rx.await?)
}
}

99
src/log.rs Normal file
View file

@ -0,0 +1,99 @@
use std::sync::Arc;
use std::vec;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use parking_lot::Mutex;
use crate::store::{Msg, MsgStore, Path, Tree};
#[derive(Debug, Clone)]
pub struct LogMsg {
id: usize,
time: DateTime<Utc>,
topic: String,
content: String,
}
impl Msg for LogMsg {
type Id = usize;
fn id(&self) -> Self::Id {
self.id
}
fn parent(&self) -> Option<Self::Id> {
None
}
fn time(&self) -> DateTime<Utc> {
self.time
}
fn nick(&self) -> String {
self.topic.clone()
}
fn content(&self) -> String {
self.content.clone()
}
}
#[derive(Debug, Clone)]
pub struct Log {
entries: Arc<Mutex<Vec<LogMsg>>>,
}
#[async_trait]
impl MsgStore<LogMsg> for Log {
async fn path(&self, id: &usize) -> Path<usize> {
Path::new(vec![*id])
}
async fn tree(&self, root: &usize) -> Tree<LogMsg> {
let msgs = self
.entries
.lock()
.get(*root)
.map(|msg| vec![msg.clone()])
.unwrap_or_default();
Tree::new(*root, msgs)
}
async fn prev_tree(&self, tree: &usize) -> Option<usize> {
tree.checked_sub(1)
}
async fn next_tree(&self, tree: &usize) -> Option<usize> {
let len = self.entries.lock().len();
tree.checked_add(1).filter(|t| *t < len)
}
async fn first_tree(&self) -> Option<usize> {
let empty = self.entries.lock().is_empty();
Some(0).filter(|_| !empty)
}
async fn last_tree(&self) -> Option<usize> {
self.entries.lock().len().checked_sub(1)
}
}
impl Log {
pub fn new() -> Self {
Self {
entries: Arc::new(Mutex::new(Vec::new())),
}
}
pub fn log<S1: ToString, S2: ToString>(&self, topic: S1, content: S2) {
let mut guard = self.entries.lock();
let msg = LogMsg {
id: guard.len(),
time: Utc::now(),
topic: topic.to_string(),
content: content.to_string(),
};
guard.push(msg);
}
}

31
src/main.rs Normal file
View file

@ -0,0 +1,31 @@
#![warn(clippy::use_self)]
mod chat;
mod euph;
mod log;
mod replies;
mod store;
mod ui;
mod vault;
use directories::ProjectDirs;
use toss::terminal::Terminal;
use ui::Ui;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let dirs = ProjectDirs::from("de", "plugh", "cove").expect("unable to determine directories");
println!("Data dir: {}", dirs.data_dir().to_string_lossy());
let vault = vault::launch(&dirs.data_dir().join("vault.db"))?;
let mut terminal = Terminal::new()?;
// terminal.set_measuring(true);
Ui::run(&mut terminal).await?;
drop(terminal); // So the vault can print again
vault.close().await;
println!("Goodbye!");
Ok(())
}

72
src/replies.rs Normal file
View file

@ -0,0 +1,72 @@
use std::collections::HashMap;
use std::hash::Hash;
use std::result;
use std::time::Duration;
use tokio::sync::oneshot::{self, Receiver, Sender};
use tokio::time;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("timed out")]
TimedOut,
#[error("canceled")]
Canceled,
}
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug)]
pub struct PendingReply<R> {
timeout: Duration,
result: Receiver<R>,
}
impl<R> PendingReply<R> {
pub async fn get(self) -> Result<R> {
let result = time::timeout(self.timeout, self.result).await;
match result {
Err(_) => Err(Error::TimedOut),
Ok(Err(_)) => Err(Error::Canceled),
Ok(Ok(value)) => Ok(value),
}
}
}
#[derive(Debug)]
pub struct Replies<I, R> {
timeout: Duration,
pending: HashMap<I, Sender<R>>,
}
impl<I: Eq + Hash, R> Replies<I, R> {
pub fn new(timeout: Duration) -> Self {
Self {
timeout,
pending: HashMap::new(),
}
}
pub fn wait_for(&mut self, id: I) -> PendingReply<R> {
let (tx, rx) = oneshot::channel();
self.pending.insert(id, tx);
PendingReply {
timeout: self.timeout,
result: rx,
}
}
pub fn complete(&mut self, id: &I, result: R) {
if let Some(tx) = self.pending.remove(id) {
let _ = tx.send(result);
}
}
pub fn cancel(&mut self, id: &I) {
self.pending.remove(id);
}
pub fn purge(&mut self) {
self.pending.retain(|_, tx| !tx.is_closed());
}
}

130
src/store.rs Normal file
View file

@ -0,0 +1,130 @@
pub mod dummy;
use std::collections::HashMap;
use std::fmt::Debug;
use std::hash::Hash;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
pub trait Msg {
type Id: Clone + Debug + Hash + Eq + Ord;
fn id(&self) -> Self::Id;
fn parent(&self) -> Option<Self::Id>;
fn time(&self) -> DateTime<Utc>;
fn nick(&self) -> String;
fn content(&self) -> String;
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
pub struct Path<I>(Vec<I>);
impl<I> Path<I> {
pub fn new(segments: Vec<I>) -> Self {
assert!(!segments.is_empty(), "segments must not be empty");
Self(segments)
}
pub fn segments(&self) -> &[I] {
&self.0
}
pub fn first(&self) -> &I {
self.0.first().expect("path is not empty")
}
pub fn first_mut(&mut self) -> &mut I {
self.0.first_mut().expect("path is not empty")
}
pub fn last(&self) -> &I {
self.0.last().expect("path is not empty")
}
pub fn last_mut(&mut self) -> &mut I {
self.0.last_mut().expect("path is not empty")
}
}
pub struct Tree<M: Msg> {
root: M::Id,
msgs: HashMap<M::Id, M>,
children: HashMap<M::Id, Vec<M::Id>>,
}
impl<M: Msg> Tree<M> {
pub fn new(root: M::Id, msgs: Vec<M>) -> Self {
let msgs: HashMap<M::Id, M> = msgs.into_iter().map(|m| (m.id(), m)).collect();
let mut children: HashMap<M::Id, Vec<M::Id>> = HashMap::new();
for msg in msgs.values() {
children.entry(msg.id()).or_default();
if let Some(parent) = msg.parent() {
children.entry(parent).or_default().push(msg.id());
}
}
for list in children.values_mut() {
list.sort_unstable();
}
Self {
root,
msgs,
children,
}
}
pub fn root(&self) -> &M::Id {
&self.root
}
pub fn msg(&self, id: &M::Id) -> Option<&M> {
self.msgs.get(id)
}
pub fn parent(&self, id: &M::Id) -> Option<M::Id> {
self.msg(id).and_then(|m| m.parent())
}
pub fn children(&self, id: &M::Id) -> Option<&[M::Id]> {
self.children.get(id).map(|c| c as &[M::Id])
}
pub fn siblings(&self, id: &M::Id) -> Option<&[M::Id]> {
if let Some(parent) = self.parent(id) {
self.children(&parent)
} else {
None
}
}
pub fn prev_sibling(&self, id: &M::Id) -> Option<M::Id> {
let siblings = self.siblings(id)?;
siblings
.iter()
.zip(siblings.iter().skip(1))
.find(|(_, s)| *s == id)
.map(|(s, _)| s.clone())
}
pub fn next_sibling(&self, id: &M::Id) -> Option<M::Id> {
let siblings = self.siblings(id)?;
siblings
.iter()
.zip(siblings.iter().skip(1))
.find(|(s, _)| *s == id)
.map(|(_, s)| s.clone())
}
}
#[async_trait]
pub trait MsgStore<M: Msg> {
async fn path(&self, id: &M::Id) -> Path<M::Id>;
async fn tree(&self, root: &M::Id) -> Tree<M>;
async fn prev_tree(&self, tree: &M::Id) -> Option<M::Id>;
async fn next_tree(&self, tree: &M::Id) -> Option<M::Id>;
async fn first_tree(&self) -> Option<M::Id>;
async fn last_tree(&self) -> Option<M::Id>;
}

156
src/store/dummy.rs Normal file
View file

@ -0,0 +1,156 @@
use std::collections::{HashMap, HashSet};
use async_trait::async_trait;
use chrono::{DateTime, TimeZone, Utc};
use super::{Msg, MsgStore, Path, Tree};
#[derive(Clone)]
pub struct DummyMsg {
id: usize,
parent: Option<usize>,
time: DateTime<Utc>,
nick: String,
content: String,
}
impl DummyMsg {
pub fn new<S>(id: usize, nick: S, content: S) -> Self
where
S: Into<String>,
{
Self {
id,
parent: None,
time: Utc.timestamp(0, 0),
nick: nick.into(),
content: content.into(),
}
}
pub fn parent(mut self, parent: usize) -> Self {
self.parent = Some(parent);
self
}
}
impl Msg for DummyMsg {
type Id = usize;
fn id(&self) -> Self::Id {
self.id
}
fn parent(&self) -> Option<Self::Id> {
self.parent
}
fn time(&self) -> DateTime<Utc> {
self.time
}
fn nick(&self) -> String {
self.nick.clone()
}
fn content(&self) -> String {
self.content.clone()
}
}
pub struct DummyStore {
msgs: HashMap<usize, DummyMsg>,
children: HashMap<usize, Vec<usize>>,
}
impl DummyStore {
pub fn new() -> Self {
Self {
msgs: HashMap::new(),
children: HashMap::new(),
}
}
pub fn msg(mut self, msg: DummyMsg) -> Self {
if let Some(parent) = msg.parent {
self.children.entry(parent).or_default().push(msg.id());
}
self.msgs.insert(msg.id(), msg);
self
}
fn collect_tree(&self, id: usize, result: &mut Vec<DummyMsg>) {
if let Some(msg) = self.msgs.get(&id) {
result.push(msg.clone());
}
if let Some(children) = self.children.get(&id) {
for child in children {
self.collect_tree(*child, result);
}
}
}
fn trees(&self) -> Vec<usize> {
let mut trees = HashSet::new();
for m in self.msgs.values() {
match m.parent() {
Some(parent) if !self.msgs.contains_key(&parent) => {
trees.insert(parent);
}
Some(_) => {}
None => {
trees.insert(m.id());
}
}
}
let mut trees: Vec<usize> = trees.into_iter().collect();
trees.sort_unstable();
trees
}
}
#[async_trait]
impl MsgStore<DummyMsg> for DummyStore {
async fn path(&self, id: &usize) -> Path<usize> {
let mut id = *id;
let mut segments = vec![id];
while let Some(parent) = self.msgs.get(&id).and_then(|msg| msg.parent) {
segments.push(parent);
id = parent;
}
segments.reverse();
Path::new(segments)
}
async fn tree(&self, root: &usize) -> Tree<DummyMsg> {
let mut msgs = vec![];
self.collect_tree(*root, &mut msgs);
Tree::new(*root, msgs)
}
async fn prev_tree(&self, tree: &usize) -> Option<usize> {
let trees = self.trees();
trees
.iter()
.zip(trees.iter().skip(1))
.find(|(_, t)| *t == tree)
.map(|(t, _)| *t)
}
async fn next_tree(&self, tree: &usize) -> Option<usize> {
let trees = self.trees();
trees
.iter()
.zip(trees.iter().skip(1))
.find(|(t, _)| *t == tree)
.map(|(_, t)| *t)
}
async fn first_tree(&self) -> Option<usize> {
self.trees().first().cloned()
}
async fn last_tree(&self) -> Option<usize> {
self.trees().last().cloned()
}
}

214
src/ui.rs Normal file
View file

@ -0,0 +1,214 @@
use std::sync::{Arc, Weak};
use std::time::Duration;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use parking_lot::FairMutex;
use tokio::sync::mpsc::error::TryRecvError;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task;
use toss::frame::{Frame, Pos, Size};
use toss::terminal::Terminal;
use crate::chat::Chat;
use crate::log::{Log, LogMsg};
use crate::store::dummy::{DummyMsg, DummyStore};
#[derive(Debug)]
pub enum UiEvent {
Redraw,
Term(Event),
}
enum EventHandleResult {
Continue,
Stop,
}
enum Visible {
Main,
Log,
}
pub struct Ui {
event_tx: UnboundedSender<UiEvent>,
log: Log,
visible: Visible,
chat: Chat<DummyMsg, DummyStore>,
log_chat: Chat<LogMsg, Log>,
}
impl Ui {
const POLL_DURATION: Duration = Duration::from_millis(100);
pub async fn run(terminal: &mut Terminal) -> anyhow::Result<()> {
let log = Log::new();
log.log("Hello", "world!");
let (event_tx, event_rx) = mpsc::unbounded_channel();
let crossterm_lock = Arc::new(FairMutex::new(()));
// Prepare and start crossterm event polling task
let weak_crossterm_lock = Arc::downgrade(&crossterm_lock);
let event_tx_clone = event_tx.clone();
let crossterm_event_task = task::spawn_blocking(|| {
Self::poll_crossterm_events(event_tx_clone, weak_crossterm_lock)
});
log.log("main", "Started input polling task");
// Prepare dummy message store and chat for testing
let store = DummyStore::new()
.msg(DummyMsg::new(1, "nick", "content"))
.msg(DummyMsg::new(2, "Some1Else", "reply").parent(1))
.msg(DummyMsg::new(3, "Some1Else", "deeper reply").parent(2))
.msg(DummyMsg::new(4, "abc123", "even deeper reply").parent(3))
.msg(DummyMsg::new(5, "Some1Else", "another reply").parent(1))
.msg(DummyMsg::new(6, "Some1Else", "third reply").parent(1))
.msg(DummyMsg::new(8, "nick", "reply to nothing").parent(7))
.msg(DummyMsg::new(9, "nick", "another reply to nothing").parent(7))
.msg(DummyMsg::new(10, "abc123", "reply to reply to nothing").parent(8))
.msg(DummyMsg::new(11, "nick", "yet another reply to nothing").parent(7))
.msg(DummyMsg::new(12, "abc123", "beep\nboop").parent(11));
let chat = Chat::new(store);
// Run main UI.
//
// If the run_main method exits at any point or if this `run` method is
// not awaited any more, the crossterm_lock Arc should be deallocated,
// meaning the crossterm_event_task will also stop after at most
// `Self::POLL_DURATION`.
//
// On the other hand, if the crossterm_event_task stops for any reason,
// the rest of the UI is also shut down and the client stops.
let mut ui = Self {
event_tx,
log: log.clone(),
visible: Visible::Log,
chat,
log_chat: Chat::new(log),
};
let result = tokio::select! {
e = ui.run_main(terminal, event_rx, crossterm_lock) => e,
Ok(e) = crossterm_event_task => e,
};
result
}
fn poll_crossterm_events(
tx: UnboundedSender<UiEvent>,
lock: Weak<FairMutex<()>>,
) -> anyhow::Result<()> {
while let Some(lock) = lock.upgrade() {
let _guard = lock.lock();
if crossterm::event::poll(Self::POLL_DURATION)? {
let event = crossterm::event::read()?;
tx.send(UiEvent::Term(event))?;
}
}
Ok(())
}
async fn run_main(
&mut self,
terminal: &mut Terminal,
mut event_rx: UnboundedReceiver<UiEvent>,
crossterm_lock: Arc<FairMutex<()>>,
) -> anyhow::Result<()> {
loop {
// 1. Render current state
terminal.autoresize()?;
self.render(terminal.frame()).await?;
terminal.present()?;
// 2. Measure widths if required
if terminal.measuring_required() {
let _guard = crossterm_lock.lock();
terminal.measure_widths()?;
self.event_tx.send(UiEvent::Redraw)?;
}
// 3. Handle events (in batches)
let mut event = match event_rx.recv().await {
Some(event) => event,
None => return Ok(()),
};
terminal.autoresize()?;
loop {
let size = terminal.frame().size();
let result = match event {
UiEvent::Redraw => EventHandleResult::Continue,
UiEvent::Term(Event::Key(event)) => {
self.handle_key_event(event, terminal, size, &crossterm_lock)
.await
}
UiEvent::Term(Event::Mouse(event)) => self.handle_mouse_event(event).await?,
UiEvent::Term(Event::Resize(_, _)) => EventHandleResult::Continue,
};
match result {
EventHandleResult::Continue => {}
EventHandleResult::Stop => return Ok(()),
}
event = match event_rx.try_recv() {
Ok(event) => event,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => return Ok(()),
};
}
}
}
async fn render(&mut self, frame: &mut Frame) -> anyhow::Result<()> {
match self.visible {
Visible::Main => self.chat.render(frame, Pos::new(0, 0), frame.size()).await,
Visible::Log => {
self.log_chat
.render(frame, Pos::new(0, 0), frame.size())
.await
}
}
Ok(())
}
async fn handle_key_event(
&mut self,
event: KeyEvent,
terminal: &mut Terminal,
size: Size,
crossterm_lock: &Arc<FairMutex<()>>,
) -> EventHandleResult {
// Always exit when shift+q or ctrl+c are pressed
let shift_q = event.code == KeyCode::Char('Q');
let ctrl_c = event.modifiers == KeyModifiers::CONTROL && event.code == KeyCode::Char('c');
if shift_q || ctrl_c {
return EventHandleResult::Stop;
}
match event.code {
KeyCode::Char('e') => self.log.log("EE E", "E ee e!"),
KeyCode::F(1) => self.visible = Visible::Main,
KeyCode::F(2) => self.visible = Visible::Log,
_ => {}
}
match self.visible {
Visible::Main => {
self.chat.handle_navigation(terminal, size, event).await;
self.chat
.handle_messaging(terminal, crossterm_lock, event)
.await;
}
Visible::Log => {
self.log_chat.handle_navigation(terminal, size, event).await;
}
}
EventHandleResult::Continue
}
async fn handle_mouse_event(
&mut self,
_event: MouseEvent,
) -> anyhow::Result<EventHandleResult> {
Ok(EventHandleResult::Continue)
}
}

75
src/vault.rs Normal file
View file

@ -0,0 +1,75 @@
mod euph;
mod migrate;
use std::path::Path;
use std::{fs, thread};
use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot};
use self::euph::{EuphRequest, EuphVault};
enum Request {
Close(oneshot::Sender<()>),
Euph(EuphRequest),
}
pub struct Vault {
tx: mpsc::Sender<Request>,
}
impl Vault {
pub async fn close(&self) {
let (tx, rx) = oneshot::channel();
let _ = self.tx.send(Request::Close(tx)).await;
let _ = rx.await;
}
pub fn euph(&self, room: String) -> EuphVault {
EuphVault {
tx: self.tx.clone(),
room,
}
}
}
fn run(conn: Connection, mut rx: mpsc::Receiver<Request>) {
while let Some(request) = rx.blocking_recv() {
match request {
Request::Close(tx) => {
println!("Optimizing vault");
let _ = conn.execute_batch("PRAGMA optimize");
// Ensure `Vault::close` exits only after the sqlite connection
// has been closed properly.
drop(conn);
drop(tx);
break;
}
Request::Euph(r) => r.perform(&conn),
}
}
}
pub fn launch(path: &Path) -> rusqlite::Result<Vault> {
// If this fails, rusqlite will complain about not being able to open the db
// file, which saves me from adding a separate vault error type.
let _ = fs::create_dir_all(path.parent().expect("path to file"));
let mut conn = Connection::open(path)?;
// Setting locking mode before journal mode so no shared memory files
// (*-shm) need to be created by sqlite. Apparently, setting the journal
// mode is also enough to immediately acquire the exclusive lock even if the
// database was already using WAL.
// https://sqlite.org/pragma.html#pragma_locking_mode
conn.pragma_update(None, "locking_mode", "exclusive")?;
conn.pragma_update(None, "journal_mode", "wal")?;
conn.pragma_update(None, "foreign_keys", true)?;
conn.pragma_update(None, "trusted_schema", false)?;
migrate::migrate(&mut conn)?;
let (tx, rx) = mpsc::channel(8);
thread::spawn(move || run(conn, rx));
Ok(Vault { tx })
}

329
src/vault/euph.rs Normal file
View file

@ -0,0 +1,329 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use tokio::sync::{mpsc, oneshot};
use crate::euph::api::Snowflake;
use crate::store::{Msg, MsgStore, Path, Tree};
use super::Request;
#[derive(Debug, Clone)]
pub struct EuphMsg {
id: Snowflake,
parent: Option<Snowflake>,
time: DateTime<Utc>,
nick: String,
content: String,
}
impl Msg for EuphMsg {
type Id = Snowflake;
fn id(&self) -> Self::Id {
self.id
}
fn parent(&self) -> Option<Self::Id> {
self.parent
}
fn time(&self) -> DateTime<Utc> {
self.time
}
fn nick(&self) -> String {
self.nick.clone()
}
fn content(&self) -> String {
self.content.clone()
}
}
impl From<EuphRequest> for Request {
fn from(r: EuphRequest) -> Self {
Self::Euph(r)
}
}
pub struct EuphVault {
pub(super) tx: mpsc::Sender<Request>,
pub(super) room: String,
}
#[async_trait]
impl MsgStore<EuphMsg> for EuphVault {
async fn path(&self, id: &Snowflake) -> Path<Snowflake> {
// TODO vault::Error
let (tx, rx) = oneshot::channel();
let request = EuphRequest::Path {
room: self.room.clone(),
id: *id,
result: tx,
};
let _ = self.tx.send(request.into()).await;
rx.await.unwrap()
}
async fn tree(&self, root: &Snowflake) -> Tree<EuphMsg> {
// TODO vault::Error
let (tx, rx) = oneshot::channel();
let request = EuphRequest::Tree {
room: self.room.clone(),
root: *root,
result: tx,
};
let _ = self.tx.send(request.into()).await;
rx.await.unwrap()
}
async fn prev_tree(&self, root: &Snowflake) -> Option<Snowflake> {
// TODO vault::Error
let (tx, rx) = oneshot::channel();
let request = EuphRequest::PrevTree {
room: self.room.clone(),
root: *root,
result: tx,
};
let _ = self.tx.send(request.into()).await;
rx.await.unwrap()
}
async fn next_tree(&self, root: &Snowflake) -> Option<Snowflake> {
// TODO vault::Error
let (tx, rx) = oneshot::channel();
let request = EuphRequest::NextTree {
room: self.room.clone(),
root: *root,
result: tx,
};
let _ = self.tx.send(request.into()).await;
rx.await.unwrap()
}
async fn first_tree(&self) -> Option<Snowflake> {
// TODO vault::Error
let (tx, rx) = oneshot::channel();
let request = EuphRequest::FirstTree {
room: self.room.clone(),
result: tx,
};
let _ = self.tx.send(request.into()).await;
rx.await.unwrap()
}
async fn last_tree(&self) -> Option<Snowflake> {
// TODO vault::Error
let (tx, rx) = oneshot::channel();
let request = EuphRequest::LastTree {
room: self.room.clone(),
result: tx,
};
let _ = self.tx.send(request.into()).await;
rx.await.unwrap()
}
}
pub(super) enum EuphRequest {
Path {
room: String,
id: Snowflake,
result: oneshot::Sender<Path<Snowflake>>,
},
Tree {
room: String,
root: Snowflake,
result: oneshot::Sender<Tree<EuphMsg>>,
},
PrevTree {
room: String,
root: Snowflake,
result: oneshot::Sender<Option<Snowflake>>,
},
NextTree {
room: String,
root: Snowflake,
result: oneshot::Sender<Option<Snowflake>>,
},
FirstTree {
room: String,
result: oneshot::Sender<Option<Snowflake>>,
},
LastTree {
room: String,
result: oneshot::Sender<Option<Snowflake>>,
},
}
impl EuphRequest {
pub(super) fn perform(self, conn: &Connection) {
let _ = match self {
EuphRequest::Path { room, id, result } => Self::path(conn, room, id, result),
EuphRequest::Tree { room, root, result } => Self::tree(conn, room, root, result),
EuphRequest::PrevTree { room, root, result } => {
Self::prev_tree(conn, room, root, result)
}
EuphRequest::NextTree { room, root, result } => {
Self::next_tree(conn, room, root, result)
}
EuphRequest::FirstTree { room, result } => Self::first_tree(conn, room, result),
EuphRequest::LastTree { room, result } => Self::last_tree(conn, room, result),
};
}
fn path(
conn: &Connection,
room: String,
id: Snowflake,
result: oneshot::Sender<Path<Snowflake>>,
) -> rusqlite::Result<()> {
let path = conn
.prepare(
"
WITH RECURSIVE path (room, id) = (
VALUES (?, ?)
UNION
SELECT (room, parent)
FROM euph_msgs
JOIN path USING (room, id)
)
SELECT id
FROM path
ORDER BY id ASC
",
)?
.query_map(params![room, id.0], |row| row.get(0).map(Snowflake))?
.collect::<rusqlite::Result<_>>()?;
let path = Path::new(path);
let _ = result.send(path);
Ok(())
}
fn tree(
conn: &Connection,
room: String,
root: Snowflake,
result: oneshot::Sender<Tree<EuphMsg>>,
) -> rusqlite::Result<()> {
let msgs = conn
.prepare(
"
WITH RECURSIVE tree (room, id) = (
VALUES (?, ?)
UNION
SELECT (euph_msgs.room, euph_msgs.id)
FROM euph_msgs
JOIN tree
ON tree.room = euph_msgs.room
AND tree.id = euph_msgs.parent
)
SELECT (id, parent, time, name, content)
FROM euph_msg
JOIN tree USING (room, id)
ORDER BY id ASC
",
)?
.query_map(params![room, root.0], |row| {
Ok(EuphMsg {
id: Snowflake(row.get(0)?),
parent: row.get::<_, Option<u64>>(1)?.map(Snowflake),
time: row.get(2)?,
nick: row.get(3)?,
content: row.get(4)?,
})
})?
.collect::<rusqlite::Result<_>>()?;
let tree = Tree::new(root, msgs);
let _ = result.send(tree);
Ok(())
}
fn prev_tree(
conn: &Connection,
room: String,
root: Snowflake,
result: oneshot::Sender<Option<Snowflake>>,
) -> rusqlite::Result<()> {
let tree = conn
.prepare(
"
SELECT id
FROM euph_trees
WHERE room = ?
AND id < ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row(params![room, root.0], |row| row.get(0).map(Snowflake))
.optional()?;
let _ = result.send(tree);
Ok(())
}
fn next_tree(
conn: &Connection,
room: String,
root: Snowflake,
result: oneshot::Sender<Option<Snowflake>>,
) -> rusqlite::Result<()> {
let tree = conn
.prepare(
"
SELECT id
FROM euph_trees
WHERE room = ?
AND id > ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row(params![room, root.0], |row| row.get(0).map(Snowflake))
.optional()?;
let _ = result.send(tree);
Ok(())
}
fn first_tree(
conn: &Connection,
room: String,
result: oneshot::Sender<Option<Snowflake>>,
) -> rusqlite::Result<()> {
let tree = conn
.prepare(
"
SELECT id
FROM euph_trees
WHERE room = ?
ORDER BY id ASC
LIMIT 1
",
)?
.query_row([room], |row| row.get(0).map(Snowflake))
.optional()?;
let _ = result.send(tree);
Ok(())
}
fn last_tree(
conn: &Connection,
room: String,
result: oneshot::Sender<Option<Snowflake>>,
) -> rusqlite::Result<()> {
let tree = conn
.prepare(
"
SELECT id
FROM euph_trees
WHERE room = ?
ORDER BY id DESC
LIMIT 1
",
)?
.query_row([room], |row| row.get(0).map(Snowflake))
.optional()?;
let _ = result.send(tree);
Ok(())
}
}

76
src/vault/migrate.rs Normal file
View file

@ -0,0 +1,76 @@
use rusqlite::{Connection, Transaction};
pub fn migrate(conn: &mut Connection) -> rusqlite::Result<()> {
let mut tx = conn.transaction()?;
let user_version: usize =
tx.query_row("SELECT * FROM pragma_user_version", [], |r| r.get(0))?;
let total = MIGRATIONS.len();
for (i, migration) in MIGRATIONS.iter().enumerate().skip(user_version) {
println!("Migrating vault from {} to {} (out of {})", i, i + 1, total);
migration(&mut tx)?;
}
tx.pragma_update(None, "user_version", total)?;
tx.commit()
}
const MIGRATIONS: [fn(&mut Transaction) -> rusqlite::Result<()>; 1] = [m1];
fn m1(tx: &mut Transaction) -> rusqlite::Result<()> {
tx.execute_batch(
"
CREATE TABLE euph_msgs (
-- Message
room TEXT NOT NULL,
id INT NOT NULL,
parent INT,
previous_edit_id INT,
time INT NOT NULL,
content TEXT NOT NULL,
encryption_key_id TEXT,
edited INT,
deleted INT,
truncated INT NOT NULL,
-- SessionView
user_id TEXT NOT NULL,
name TEXT
server_id TEXT NOT NULL,
server_era TEXT NOT NULL,
session_id TEXT NOT NULL,
is_staff INT NOT NULL,
is_manager INT NOT NULL,
client_address TEXT,
real_client_address TEXT,
PRIMARY KEY (room, id)
) STRICT;
CREATE TABLE euph_spans (
room TEXT NOT NULL,
start INT,
end INT,
PRIMARY KEY (room, start, end),
FOREIGN KEY (room, start) REFERENCES euph_msgs (room, start),
FOREIGN KEY (room, end) REFERENCES euph_msgs (room, end)
) STRICT;
CREATE VIEW euph_trees (room, id) AS
SELECT room, id
FROM euph_msgs
WHERE parent IS NULL
UNION
(
SELECT room, parent
FROM euph_msgs
WHERE parent IS NOT NULL
EXCEPT
SELECT room, id
FROM euph_msgs
)
",
)
}