Dissolve workspace
This commit is contained in:
parent
1cc7dd8920
commit
e601476d02
29 changed files with 24 additions and 29 deletions
103
src/chat.rs
Normal file
103
src/chat.rs
Normal 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
84
src/chat/tree.rs
Normal 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
97
src/chat/tree/action.rs
Normal 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
180
src/chat/tree/blocks.rs
Normal 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
382
src/chat/tree/cursor.rs
Normal 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
168
src/chat/tree/layout.rs
Normal 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
93
src/chat/tree/render.rs
Normal 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
56
src/chat/tree/util.rs
Normal 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
5
src/euph.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod api;
|
||||
pub mod conn;
|
||||
// mod room;
|
||||
|
||||
// pub use room::Room;
|
||||
13
src/euph/api.rs
Normal file
13
src/euph/api.rs
Normal 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
170
src/euph/api/events.rs
Normal 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 room’s 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 server’s 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
222
src/euph/api/packet.rs
Normal 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
140
src/euph/api/room_cmds.rs
Normal 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>,
|
||||
}
|
||||
43
src/euph/api/session_cmds.rs
Normal file
43
src/euph/api/session_cmds.rs
Normal 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
340
src/euph/api/types.rs
Normal 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
440
src/euph/conn.rs
Normal 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
110
src/euph/room.rs
Normal 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
99
src/log.rs
Normal 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
31
src/main.rs
Normal 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
72
src/replies.rs
Normal 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
130
src/store.rs
Normal 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
156
src/store/dummy.rs
Normal 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
214
src/ui.rs
Normal 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
75
src/vault.rs
Normal 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
329
src/vault/euph.rs
Normal 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
76
src/vault/migrate.rs
Normal 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
|
||||
)
|
||||
",
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue