x.user, ui changes
This commit is contained in:
parent
9afa466887
commit
ad7d812590
39 changed files with 792 additions and 1124 deletions
158
Cargo.lock
generated
158
Cargo.lock
generated
|
@ -525,16 +525,6 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.4"
|
||||
|
@ -883,21 +873,6 @@ version = "1.0.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.0"
|
||||
|
@ -1316,19 +1291,6 @@ dependencies = [
|
|||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.57"
|
||||
|
@ -1753,24 +1715,6 @@ dependencies = [
|
|||
"getrandom 0.2.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.4"
|
||||
|
@ -1879,50 +1823,6 @@ version = "0.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "345df152bc43501c5eb9e4654ff05f794effb78d4efe3d53abc158baddc0703d"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.90"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "374533b0e45f3a7ced10fcaeccca020e66656bc03dac384f852e4e5a7a8104a6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
@ -2031,19 +1931,6 @@ dependencies = [
|
|||
"sha2 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"rand 0.8.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.7",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.0"
|
||||
|
@ -2360,12 +2247,10 @@ dependencies = [
|
|||
"http-body",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-tls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
|
@ -2375,7 +2260,6 @@ dependencies = [
|
|||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tower-service",
|
||||
"url",
|
||||
|
@ -2497,15 +2381,6 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
|
@ -2528,29 +2403,6 @@ dependencies = [
|
|||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.177"
|
||||
|
@ -3268,16 +3120,6 @@ dependencies = [
|
|||
"syn 2.0.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.23.4"
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["store-fs", "server", "lib", "cli", "peer"]
|
||||
members = ["store-*", "server", "lib", "cli"]
|
||||
|
|
|
@ -139,18 +139,17 @@ async fn main() -> Result<(), Error> {
|
|||
};
|
||||
}
|
||||
FileAction::List { tags, long, stream } => {
|
||||
let query = Query::builder()
|
||||
.with_sender(&config.key.get_id())
|
||||
.with_type("x.file")
|
||||
.build();
|
||||
let query = Query {
|
||||
refs: None,
|
||||
senders: Some(HashSet::from([config.key.get_id()])),
|
||||
types: Some(HashSet::from(["x.file".into()])),
|
||||
tags: if tags.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(into_hashset(tags))
|
||||
},
|
||||
relations: HashSet::new(),
|
||||
ephemeral: HashSet::new(),
|
||||
with_redacts: false,
|
||||
..query
|
||||
};
|
||||
let query = client.query(&query).await?;
|
||||
let timeout = if stream { Some(30000) } else { None };
|
||||
|
|
|
@ -104,11 +104,15 @@ pub struct RelInfo {
|
|||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
// empty events; they have no content, or store content in relations
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct UserEvent {} // TODO: currently unused
|
||||
pub struct UserEvent {
|
||||
name: String,
|
||||
}
|
||||
|
||||
// TODO: currently unused
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct AnnotateEvent {} // TODO: currently unused
|
||||
pub struct AnnotateEvent {}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct RedactEvent {} // uses`redact` relationship
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ use crate::event::{Event, EventContent, RelInfo};
|
|||
use crate::item::ItemRef;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct Query {
|
||||
// each filter is logically ANDed together, and each item in each vec is logically ORed
|
||||
pub refs: Option<HashSet<ItemRef>>,
|
||||
|
@ -24,6 +24,11 @@ pub struct Query {
|
|||
pub ephemeral: HashSet<QueryRelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct QueryBuilder {
|
||||
query: Query,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
#[serde(untagged)]
|
||||
pub enum QueryRelation {
|
||||
|
@ -53,7 +58,11 @@ pub enum MatchType {
|
|||
}
|
||||
|
||||
impl Query {
|
||||
pub fn matches_relationless(&self, event: &Event) -> bool {
|
||||
pub fn builder() -> QueryBuilder {
|
||||
QueryBuilder::new()
|
||||
}
|
||||
|
||||
pub fn matches_relationless(&self, event: &Event, allow_redaction: bool) -> bool {
|
||||
let bad_ref = self.refs.as_ref().is_some_and(|s| !s.contains(&event.id));
|
||||
let bad_sender = self
|
||||
.senders
|
||||
|
@ -67,7 +76,7 @@ impl Query {
|
|||
.tags
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_disjoint(&event.derived.tags));
|
||||
let bad_redact = !self.with_redacts && matches!(event.content, EventContent::Redacted(_));
|
||||
let bad_redact = !allow_redaction && !self.with_redacts && matches!(event.content, EventContent::Redacted(_));
|
||||
|
||||
!(bad_ref || bad_sender || bad_type || bad_tags || bad_redact)
|
||||
}
|
||||
|
@ -78,7 +87,7 @@ impl Query {
|
|||
QueryRelation::FromRel(QueryFromRel(source_type, rel_type)) => {
|
||||
if &rel_info.rel_type == rel_type
|
||||
&& source.content.get_type() == source_type
|
||||
&& self.matches_relationless(target)
|
||||
&& self.matches_relationless(target, true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
@ -87,7 +96,7 @@ impl Query {
|
|||
if &rel_info.rel_type == rel_type
|
||||
&& source.content.get_type() == source_type
|
||||
&& target.content.get_type() == target_type
|
||||
&& self.matches_relationless(target)
|
||||
&& self.matches_relationless(target, true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
@ -99,7 +108,7 @@ impl Query {
|
|||
}
|
||||
|
||||
pub fn matches(&self, event: &Event, relations: &Relations) -> MatchType {
|
||||
if self.matches_relationless(event) {
|
||||
if self.matches_relationless(event, false) {
|
||||
return MatchType::Event;
|
||||
}
|
||||
|
||||
|
@ -123,6 +132,43 @@ impl QueryRelation {
|
|||
}
|
||||
}
|
||||
|
||||
impl QueryBuilder {
|
||||
pub fn new() -> QueryBuilder {
|
||||
QueryBuilder::default()
|
||||
}
|
||||
|
||||
pub fn with_ref(mut self, item_ref: &ItemRef) -> Self {
|
||||
if let Some(types) = &mut self.query.refs {
|
||||
types.insert(item_ref.clone());
|
||||
} else {
|
||||
self.query.refs = Some(HashSet::from([item_ref.clone()]));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_type(mut self, event_type: impl Into<String>) -> Self {
|
||||
if let Some(types) = &mut self.query.types {
|
||||
types.insert(event_type.into());
|
||||
} else {
|
||||
self.query.types = Some(HashSet::from([event_type.into()]));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sender(mut self, sender: &ActorId) -> Self {
|
||||
if let Some(senders) = &mut self.query.senders {
|
||||
senders.insert(sender.clone());
|
||||
} else {
|
||||
self.query.senders = Some(HashSet::from([sender.clone()]));
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Query {
|
||||
self.query
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: fix tests
|
||||
#[cfg(off)]
|
||||
mod tests {
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
[package]
|
||||
name = "peer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
axum = "0.6.19"
|
||||
rand = "0.8.5"
|
||||
reqwest = { version = "0.11.18", features = ["json"] }
|
||||
serde = { version = "1.0.175", features = ["derive"] }
|
||||
serde_json = "1.0.103"
|
||||
sha2 = "0.10.7"
|
||||
tokio = { version = "1.29.1", features = ["rt-multi-thread", "macros"] }
|
|
@ -1,72 +0,0 @@
|
|||
mod peer;
|
||||
|
||||
use std::{
|
||||
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use axum::{extract::State, Json, Router, Server};
|
||||
use peer::{Contact, Node, NodeId, RPCRequest, RPCResponse};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
struct NodeState {
|
||||
node: Mutex<Node>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Request {
|
||||
info: RPCRequest,
|
||||
contact: Contact,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let port: u16 = std::env::args()
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap();
|
||||
let node = Node::new(NodeId::new(), port);
|
||||
let state = Arc::new(NodeState {
|
||||
node: Mutex::new(node),
|
||||
});
|
||||
let router = Router::new()
|
||||
.route("/send", axum::routing::post(handle))
|
||||
.with_state(state.clone());
|
||||
let addr = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), port));
|
||||
tokio::spawn(Server::bind(&addr).serve(router.into_make_service()));
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
std::io::stdin().read_line(&mut line).unwrap();
|
||||
let parts: Vec<_> = line.trim().split(' ').collect();
|
||||
let mut node = state.node.lock().await;
|
||||
match parts[0] {
|
||||
"ping" => println!("pong"),
|
||||
"info" => println!("{}", serde_json::to_string(&node.contact).unwrap()),
|
||||
"bootstrap" => {
|
||||
node.bootstrap(serde_json::from_str(parts[1]).unwrap())
|
||||
.await;
|
||||
println!("added bootstrap node");
|
||||
}
|
||||
"set" => {
|
||||
node.set(&NodeId::new_from_str(parts[1]), parts[2]).await;
|
||||
println!("set");
|
||||
}
|
||||
"get" => {
|
||||
let result = node.get(&NodeId::new_from_str(parts[1])).await;
|
||||
println!("get: {:?}", result);
|
||||
}
|
||||
_ => println!("not a command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
State(state): State<Arc<NodeState>>,
|
||||
Json(request): Json<Request>,
|
||||
) -> Json<RPCResponse> {
|
||||
// println!("handle request");
|
||||
let mut node = state.node.lock().await;
|
||||
let response = node.receive(&request.contact, request.info);
|
||||
Json(response)
|
||||
}
|
272
peer/src/peer.rs
272
peer/src/peer.rs
|
@ -1,272 +0,0 @@
|
|||
// this code will have no networking, pretty much only pure logic/state machines
|
||||
|
||||
// #![allow(unused)] // TODO (commit): remove this before comitting
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// the length of each key
|
||||
const KEY_LEN: usize = 20;
|
||||
|
||||
/// each bit has its own bucket
|
||||
const N_BUCKETS: usize = KEY_LEN * 8;
|
||||
|
||||
/// 8 entries per bucket
|
||||
const K_PARAM: usize = 8;
|
||||
|
||||
/// 3 concurrent requests maximum
|
||||
// const A_PARAM: usize = 3;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize, Deserialize)]
|
||||
pub struct NodeId([u8; KEY_LEN]);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Distance([u8; KEY_LEN]);
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Router {
|
||||
for_id: NodeId,
|
||||
buckets: Vec<Vec<Contact>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Node {
|
||||
pub contact: Contact,
|
||||
router: Router,
|
||||
store: HashMap<NodeId, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Contact {
|
||||
id: NodeId,
|
||||
host: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum RPCRequest {
|
||||
Ping,
|
||||
Store(NodeId, String),
|
||||
FindNode(NodeId),
|
||||
FindValue(NodeId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum RPCResponse {
|
||||
Ok,
|
||||
FindNode(Vec<Contact>),
|
||||
FindValue(String),
|
||||
}
|
||||
|
||||
impl NodeId {
|
||||
pub fn new() -> NodeId {
|
||||
NodeId(rand::random())
|
||||
}
|
||||
|
||||
pub fn new_from_str(s: &str) -> Self {
|
||||
let hash = {
|
||||
let mut hasher = sha2::Sha224::default();
|
||||
hasher.update(s);
|
||||
hasher.finalize()
|
||||
};
|
||||
let trimmed: [u8; KEY_LEN] = hash[0..KEY_LEN].try_into().unwrap();
|
||||
NodeId(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Distance {
|
||||
fn between(a: &NodeId, b: &NodeId) -> Distance {
|
||||
let mut bytes = [0; KEY_LEN];
|
||||
for i in 0..KEY_LEN {
|
||||
bytes[1] = a.0[i] ^ b.0[i];
|
||||
}
|
||||
Distance(bytes)
|
||||
}
|
||||
|
||||
/// count leading zeroes
|
||||
fn clz(&self) -> usize {
|
||||
for byte_idx in 0..KEY_LEN {
|
||||
let byte = self.0[byte_idx];
|
||||
for bit_idx in 0..8 {
|
||||
let bit = (byte >> (7 - bit_idx)) & 0x01;
|
||||
if bit != 0 {
|
||||
return byte_idx * 8 + bit_idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
KEY_LEN * 8 - 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
// TODO (future): i really should split apart network logic
|
||||
async fn send(
|
||||
&self,
|
||||
sender: &Self,
|
||||
message: RPCRequest,
|
||||
) -> Result<RPCResponse, reqwest::Error> {
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Request<'a> {
|
||||
info: RPCRequest,
|
||||
contact: &'a Contact,
|
||||
}
|
||||
|
||||
let request = Request {
|
||||
info: message,
|
||||
contact: sender,
|
||||
};
|
||||
|
||||
dbg!(format!("http://{}/send", self.host));
|
||||
|
||||
reqwest::Client::new()
|
||||
.post(format!("http://{}/send", self.host))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Router {
|
||||
fn new(for_id: NodeId) -> Router {
|
||||
Router {
|
||||
buckets: vec![0; N_BUCKETS].iter().map(|_| Vec::new()).collect(),
|
||||
for_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&mut self, contact: Contact) {
|
||||
let id = contact.id;
|
||||
let prefix_length = Distance::between(&id, &self.for_id).clz();
|
||||
let bucket = &mut self.buckets[prefix_length];
|
||||
let element_idx = bucket.iter().position(|i| i.id == id);
|
||||
if let Some(element_idx) = element_idx {
|
||||
bucket.remove(element_idx);
|
||||
bucket.insert(0, contact);
|
||||
} else if bucket.len() < K_PARAM {
|
||||
// println!("link {:?} -> {:?}", self.for_id, contact.id);
|
||||
bucket.insert(0, contact);
|
||||
} else {
|
||||
// TODO: evict old contacts
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, id: &NodeId) {
|
||||
let prefix_length = Distance::between(id, &self.for_id).clz();
|
||||
let bucket = &mut self.buckets[prefix_length];
|
||||
let element_idx = bucket.iter().position(|i| &i.id == id);
|
||||
if let Some(element_idx) = element_idx {
|
||||
bucket.remove(element_idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn find_closest(&self, target: &NodeId, count: usize) -> Vec<Contact> {
|
||||
let mut ret = Vec::new();
|
||||
for bucket in &self.buckets {
|
||||
for contact in bucket {
|
||||
let distance = Distance::between(&contact.id, target).clz();
|
||||
ret.push((contact, distance));
|
||||
}
|
||||
}
|
||||
|
||||
ret.sort_by_key(|i| i.1);
|
||||
ret.into_iter().take(count).map(|i| i.0.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn new(id: NodeId, port: u16) -> Self {
|
||||
Node {
|
||||
// id,
|
||||
router: Router::new(id),
|
||||
store: HashMap::new(),
|
||||
contact: Contact {
|
||||
id,
|
||||
host: format!("127.0.0.1:{}", port),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bootstrap(&mut self, contact: Contact) {
|
||||
self.send(&contact, RPCRequest::Ping).await;
|
||||
self.router.update(contact);
|
||||
}
|
||||
|
||||
async fn send(&mut self, contact: &Contact, message: RPCRequest) -> Option<RPCResponse> {
|
||||
if let Ok(res) = contact.send(&self.contact, message).await {
|
||||
return Some(res);
|
||||
}
|
||||
|
||||
// node is dead
|
||||
self.router.remove(&contact.id);
|
||||
None
|
||||
}
|
||||
|
||||
pub fn receive(&mut self, sender: &Contact, message: RPCRequest) -> RPCResponse {
|
||||
self.router.update(sender.clone());
|
||||
match message {
|
||||
RPCRequest::Ping => RPCResponse::Ok,
|
||||
RPCRequest::Store(key, value) => {
|
||||
self.store.insert(key, value);
|
||||
RPCResponse::Ok
|
||||
}
|
||||
RPCRequest::FindNode(id) => {
|
||||
let contacts = self.router.find_closest(&id, 20);
|
||||
RPCResponse::FindNode(contacts)
|
||||
}
|
||||
RPCRequest::FindValue(key) => {
|
||||
if let Some(value) = self.store.get(&key) {
|
||||
RPCResponse::FindValue(value.clone())
|
||||
} else {
|
||||
let contacts = self.router.find_closest(&key, 20);
|
||||
RPCResponse::FindNode(contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set(&mut self, key: &NodeId, value: &str) {
|
||||
let contacts = self.router.find_closest(key, 1);
|
||||
for contact in contacts {
|
||||
self.send(&contact, RPCRequest::Store(*key, value.to_owned()))
|
||||
.await;
|
||||
}
|
||||
self.store.insert(*key, value.to_owned());
|
||||
}
|
||||
|
||||
pub async fn get(&mut self, key: &NodeId) -> Option<String> {
|
||||
if let Some(value) = self.store.get(key) {
|
||||
Some(value.clone())
|
||||
} else {
|
||||
let mut queried = HashSet::new();
|
||||
let mut nodes = self.router.find_closest(key, 1);
|
||||
while !nodes.is_empty() {
|
||||
let contact = nodes.remove(0);
|
||||
if self.contact == contact {
|
||||
continue;
|
||||
}
|
||||
let Some(response) = self.send(&contact, RPCRequest::FindValue(*key)).await else {
|
||||
continue;
|
||||
};
|
||||
match response {
|
||||
RPCResponse::FindNode(received) => {
|
||||
queried.insert(contact.id);
|
||||
for contact in received {
|
||||
if !queried.contains(&contact.id) {
|
||||
nodes.push(contact);
|
||||
}
|
||||
}
|
||||
}
|
||||
RPCResponse::FindValue(value) => {
|
||||
return Some(value);
|
||||
}
|
||||
RPCResponse::Ok => (),
|
||||
}
|
||||
dbg!("loop");
|
||||
}
|
||||
dbg!("not found");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ use crate::{
|
|||
derive::Deriver,
|
||||
routes::{things::Error, util::get_blob},
|
||||
state::{
|
||||
db::{sqlite::Sqlite, Database, DbItem},
|
||||
db::{sqlite::Sqlite, Database, DbItem, Location},
|
||||
search::{Document, Search},
|
||||
},
|
||||
};
|
||||
|
@ -51,6 +51,16 @@ pub async fn prepare_special(
|
|||
relations: &Relations,
|
||||
) -> Result<DelayedAction, Error> {
|
||||
match &event.content {
|
||||
EventContent::User(_) => {
|
||||
let query = ufh::query::Query::builder()
|
||||
.with_type("x.user")
|
||||
.with_sender(&event.sender)
|
||||
.build();
|
||||
let result = me.db.query_events(&query, Location::Beginning, 1).await?;
|
||||
if !result.events.is_empty() {
|
||||
return Err(Error::Validation("user already exists"));
|
||||
}
|
||||
},
|
||||
EventContent::File(f) => {
|
||||
match me.db.bulk_fetch(&f.chunks, false).await {
|
||||
Ok(items) if items.iter().all(|i| matches!(i, (_, DbItem::Blob(_)))) => Ok(()),
|
||||
|
@ -60,7 +70,6 @@ pub async fn prepare_special(
|
|||
}
|
||||
Err(err) => Err(err),
|
||||
}?;
|
||||
debug!("validated file");
|
||||
}
|
||||
EventContent::Redact(_) => {
|
||||
if event.relations.is_empty() {
|
||||
|
@ -86,8 +95,6 @@ pub async fn prepare_special(
|
|||
));
|
||||
}
|
||||
|
||||
debug!("validated redaction");
|
||||
|
||||
refs.push(rel_ref.clone());
|
||||
}
|
||||
|
||||
|
@ -157,6 +164,8 @@ pub async fn prepare_special(
|
|||
),
|
||||
};
|
||||
|
||||
debug!("validated {} event", event.get_type());
|
||||
|
||||
Ok(DelayedAction::None)
|
||||
}
|
||||
|
||||
|
@ -185,11 +194,10 @@ pub async fn commit_special(me: &Items, action: &DelayedAction) -> Result<(), Er
|
|||
.await?;
|
||||
|
||||
// FIXME (investigate): potential race condition with update + redact
|
||||
|
||||
me.finish_event_create(crate::items::WipCreate {
|
||||
me.finish_event_create(crate::items::DerivelessCreate {
|
||||
item_ref: item_ref.clone(),
|
||||
event,
|
||||
action: DelayedAction::None,
|
||||
rowid: 0,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
|
|
@ -39,6 +39,13 @@ pub struct WipCreate {
|
|||
action: DelayedAction,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DerivelessCreate {
|
||||
pub item_ref: ItemRef,
|
||||
pub rowid: u32,
|
||||
event: Event,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Create {
|
||||
pub event: Event,
|
||||
|
@ -105,11 +112,9 @@ impl Items {
|
|||
})
|
||||
}
|
||||
|
||||
/// finish extra processing, eg. deriving metadata or indexing for fts
|
||||
/// split out to continue in background
|
||||
// FIXME: this code is ugly and may still have race conditions
|
||||
/// take an event and put it into the database
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn finish_event_create(&self, wip: WipCreate) -> Result<Create, Error> {
|
||||
pub async fn commit_event_create(&self, wip: WipCreate) -> Result<DerivelessCreate, Error> {
|
||||
let event = wip.event;
|
||||
|
||||
let rowid = self.db.event_create(&event).await?;
|
||||
|
@ -118,8 +123,15 @@ impl Items {
|
|||
events::commit_special(self, &wip.action).await?;
|
||||
debug!("commit special cases");
|
||||
|
||||
Ok(DerivelessCreate { item_ref: wip.item_ref, rowid, event })
|
||||
}
|
||||
|
||||
/// finish extra processing, eg. deriving metadata or indexing for fts
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn finish_event_create(&self, wip: DerivelessCreate) -> Result<Create, Error> {
|
||||
let event = wip.event;
|
||||
|
||||
let derived = if let EventContent::File(file) = &event.content {
|
||||
debug!("begin derive");
|
||||
let (file, media, thumb) = events::derive(self, &event, file).await?;
|
||||
self.db
|
||||
.derived_put(&event.id, "file", serde_json::to_value(&file)?)
|
||||
|
@ -149,12 +161,12 @@ impl Items {
|
|||
|
||||
update_search_index(self, &event, &relations).await?;
|
||||
|
||||
Ok(Create { event, relations, rowid })
|
||||
Ok(Create { event, relations, rowid: wip.rowid })
|
||||
}
|
||||
|
||||
#[async_recursion::async_recursion]
|
||||
pub async fn create_event(&self, wip: WipEvent) -> Result<Create, Error> {
|
||||
let wip = self.begin_event_create(wip).await?;
|
||||
let wip = self.commit_event_create(wip).await?;
|
||||
self.finish_event_create(wip).await
|
||||
}
|
||||
|
||||
|
|
26
server/src/routes/actors.rs
Normal file
26
server/src/routes/actors.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
#![allow(unused)]
|
||||
|
||||
use axum::{routing, Router, extract::{State, Path}};
|
||||
use std::sync::Arc;
|
||||
use ufh::actor::ActorId;
|
||||
use ufh::event::Event;
|
||||
|
||||
pub(crate) use crate::error::Error;
|
||||
|
||||
use super::util::{perms, Authenticate};
|
||||
use crate::ServerState;
|
||||
|
||||
type Response<T> = Result<T, Error>;
|
||||
|
||||
pub fn routes() -> Router<Arc<ServerState>> {
|
||||
Router::new()
|
||||
.route("/:actor_id", routing::get(get_actor))
|
||||
}
|
||||
|
||||
async fn get_actor(
|
||||
State(state): State<Arc<ServerState>>,
|
||||
auth: Authenticate<perms::None>,
|
||||
Path(item_ref): Path<ActorId>,
|
||||
) -> Response<()> {
|
||||
todo!()
|
||||
}
|
|
@ -3,4 +3,5 @@ pub mod p2p;
|
|||
pub mod search;
|
||||
pub mod shares;
|
||||
pub mod things;
|
||||
pub mod actors;
|
||||
pub mod util;
|
||||
|
|
|
@ -144,7 +144,7 @@ fn get_overlap((start1, end1): (usize, usize), (start2, end2): (usize, usize)) -
|
|||
let closest_end = cmp::min(end1, end2) as isize;
|
||||
let furthest_start = cmp::max(start1, start2) as isize;
|
||||
let diff = closest_end - furthest_start;
|
||||
diff.abs() as usize
|
||||
diff.unsigned_abs()
|
||||
}
|
||||
|
||||
fn slice(
|
||||
|
|
|
@ -87,6 +87,10 @@ pub async fn route(
|
|||
let item_ref = wip.item_ref.clone();
|
||||
tokio::spawn(async move {
|
||||
let _lock = ROWID_LOCK.lock().await;
|
||||
let wip = match state.items.commit_event_create(wip).await {
|
||||
Ok(wip) => wip,
|
||||
Err(err) => return error!("failed to finish committing event: {}", err),
|
||||
};
|
||||
let create = match state.items.finish_event_create(wip).await {
|
||||
Ok(create) => create,
|
||||
Err(err) => return error!("failed to finish creating event: {}", err),
|
||||
|
@ -99,6 +103,7 @@ pub async fn route(
|
|||
}
|
||||
|
||||
let _lock = ROWID_LOCK.lock().await;
|
||||
let wip = state.items.commit_event_create(wip).await?;
|
||||
let create = state.items.finish_event_create(wip).await?;
|
||||
let item_ref = create.event.id.clone();
|
||||
let _ = state
|
||||
|
|
|
@ -178,6 +178,7 @@ pub async fn route(
|
|||
Ok((event, relations, rowid))
|
||||
if can_view_event(&state.db, &event, &user).await =>
|
||||
{
|
||||
dbg!("check event: ", &query, &event, &relations);
|
||||
match query.matches(&event, &relations) {
|
||||
mt @ MatchType::Event => Ok((mt, event, rowid)),
|
||||
mt @ MatchType::Relation => Ok((mt, event, rowid)),
|
||||
|
|
|
@ -58,28 +58,27 @@ pub trait Database {
|
|||
|
||||
// events
|
||||
async fn event_create(&self, event: &Event) -> Result<u32, Self::Error>;
|
||||
// async fn event_update(&self, item_ref: &ItemRef, content: EventContent) -> Result<Option<Event>, Self::Error>;
|
||||
async fn event_redact(&self, item_ref: &ItemRef) -> Result<(), Self::Error>;
|
||||
async fn event_fetch(&self, item_ref: &ItemRef) -> Result<Option<Event>, Self::Error>;
|
||||
async fn derived_put(&self, item_ref: &ItemRef, key: &str, derived: Value) -> Result<(), Self::Error>;
|
||||
async fn derived_del(&self, item_ref: &ItemRef, key: &str) -> Result<(), Self::Error>;
|
||||
|
||||
// query
|
||||
async fn event_fetch(&self, item_ref: &ItemRef) -> Result<Option<Event>, Self::Error>;
|
||||
async fn query_events(&self, query: &Query, after: Location, limit: u32) -> Result<QueryResult, Self::Error>;
|
||||
// return type is currently a bit of a kludge
|
||||
// return type is currently a bit of a kludge for now
|
||||
async fn query_relations(&self, relations: &[QueryRelation], for_events: &[ItemRef], after: Location, limit: u32) -> Result<(HashMap<ItemRef, Event>, Option<u32>), Self::Error>;
|
||||
async fn bulk_fetch(&self, item_refs: &[ItemRef], partial: bool) -> Result<Vec<(ItemRef, DbItem)>, Self::Error>;
|
||||
|
||||
// routes::things::create has a lot of file-specific things
|
||||
// misc
|
||||
async fn tags_set(&self, item_refs: &[ItemRef], tags: &[String]) -> Result<(), Self::Error>;
|
||||
async fn bulk_fetch(&self, item_refs: &[ItemRef], partial: bool) -> Result<Vec<(ItemRef, DbItem)>, Self::Error>;
|
||||
|
||||
// thumbnails
|
||||
async fn thumbnail_put(&self, item_ref: &ItemRef, size: &ThumbnailSize, bytes: &[u8]) -> Result<(), Self::Error>;
|
||||
async fn thumbnail_get(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<Option<Thumbnail>, Self::Error>;
|
||||
async fn thumbnail_del(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<(), Self::Error>;
|
||||
|
||||
// shares
|
||||
// async fn share_create(&self, item_ref: &ItemRef, share_id: Option<&str>, expires_at: Option<u64>) -> Result<(), Self::Error>;
|
||||
// async fn share_get(&self, share_id: Option<&str>) -> Result<(), Self::Error>;
|
||||
// async fn share_delete(&self, share_id: Option<&str>) -> Result<(), Self::Error>;
|
||||
// aliases
|
||||
// async fn alias_create(&self, alias_id: &str, item_ref: &ItemRef) -> Result<(), Self::Error>;
|
||||
// async fn alias_get(&self, alias_id: &str) -> Result<(), Self::Error>;
|
||||
// async fn alias_delete(&self, alias_id: &str) -> Result<(), Self::Error>;
|
||||
}
|
||||
|
|
122
web/App.svelte
122
web/App.svelte
|
@ -9,23 +9,16 @@
|
|||
import Audio from "./status/Audio.svelte";
|
||||
import Status from "./status/Status.svelte";
|
||||
import { EventDocScene, ListDocScene } from "./scenes/Document";
|
||||
import { api } from "./lib/api";
|
||||
import { collectEvents } from "./lib/util";
|
||||
import { state, events } from "./state";
|
||||
import { api, Event } from "./lib/api";
|
||||
import { watch } from "./lib/util";
|
||||
import { state } from "./state";
|
||||
import { onDestroy, onMount, SvelteComponent } from "svelte";
|
||||
import Popup from "./atoms/Popup.svelte";
|
||||
import SettingsIc from "carbon-icons-svelte/lib/Settings.svelte";
|
||||
import HomeIc from "carbon-icons-svelte/lib/Home.svelte";
|
||||
import ForumIc from "carbon-icons-svelte/lib/Forum.svelte";
|
||||
import FolderIc from "carbon-icons-svelte/lib/Folder.svelte";
|
||||
import BookmarkIc from "carbon-icons-svelte/lib/Bookmark.svelte";
|
||||
import DocumentIc from "carbon-icons-svelte/lib/Document.svelte";
|
||||
import Edit from "./popups/Edit.svelte";
|
||||
import Create from "./popups/Create.svelte";
|
||||
import { Wrapper as Menu } from "./atoms/context";
|
||||
import Nav from "./Nav.svelte";
|
||||
import log from "./lib/log";
|
||||
import type { Event } from "./lib/api";
|
||||
import Clock from "./Clock.svelte";
|
||||
|
||||
$: log.web("set state", $state);
|
||||
|
@ -35,7 +28,11 @@
|
|||
let selected: Promise<Event | null> = Promise.resolve(null);
|
||||
$: items = [
|
||||
{ type: "home", id: "home", getContent: () => ({ name: "home" }) },
|
||||
{ type: "settings", id: "settings", getContent: () => ({ name: "settings" }) },
|
||||
{
|
||||
type: "settings",
|
||||
id: "settings",
|
||||
getContent: () => ({ name: "settings" }),
|
||||
},
|
||||
...$itemsEvents,
|
||||
];
|
||||
|
||||
|
@ -44,11 +41,8 @@
|
|||
types: ["l.files", "l.notes", "l.forum", "l.docs"],
|
||||
relations: [["x.redact", "redact"]],
|
||||
ephemeral: [["x.update", "update"]],
|
||||
}, true);
|
||||
return {
|
||||
events: collectEvents(query),
|
||||
stop: query.stop,
|
||||
};
|
||||
});
|
||||
return watch(query);
|
||||
}
|
||||
|
||||
let options = new URL(location.hash.slice(1), location.origin).searchParams;
|
||||
|
@ -58,27 +52,13 @@
|
|||
options = url.searchParams;
|
||||
const id = url.pathname.slice(1);
|
||||
$: log.web("goto ref", id);
|
||||
if (!id) return location.hash = "/home";
|
||||
const idx = items.findIndex(i => i.id === id);
|
||||
if (!id) return (location.hash = "/home");
|
||||
const idx = items.findIndex((i) => i.id === id);
|
||||
selected = Promise.resolve(items[idx] ?? null);
|
||||
if (idx === -1) selected = api.fetch(id);
|
||||
if (idx === -1) selected = api.events.fetch(id);
|
||||
}
|
||||
|
||||
function getIcon(type: string) {
|
||||
switch (type) {
|
||||
case "home": return HomeIc;
|
||||
case "settings": return SettingsIc;
|
||||
case "l.files": return FolderIc;
|
||||
case "l.notes": return BookmarkIc;
|
||||
case "l.forum": return ForumIc;
|
||||
case "l.docs": return DocumentIc;
|
||||
default: return null;
|
||||
// no Result<T, E> in js so handling errors in svelte would be very annying...
|
||||
// default: throw new Error("invalid/unsupported type");
|
||||
}
|
||||
}
|
||||
|
||||
function getComponent(type: string): Option<SvelteComponent> {
|
||||
function getComponent(type: string): SvelteComponent | null {
|
||||
switch (type) {
|
||||
case "home": return Home;
|
||||
case "settings": return Settings;
|
||||
|
@ -90,11 +70,16 @@
|
|||
case "l.docs": return ListDocScene;
|
||||
case "l.doc": return EventDocScene;
|
||||
default: return null;
|
||||
// no Result<T, E> in js so handling errors in svelte would be very annying...
|
||||
// no Result<T, E> in js so handling errors in svelte would be very annoying...
|
||||
// default: throw new Error("invalid/unsupported type");
|
||||
}
|
||||
}
|
||||
|
||||
navigator.mediaSession?.setActionHandler(
|
||||
"stop",
|
||||
state.curry("statusMusic/close")
|
||||
);
|
||||
|
||||
// this always bothered me, but there was never a good way to sync animations... until now
|
||||
const syncedAnims = new Set(["hover"]);
|
||||
function syncAnimations(e: AnimationEvent) {
|
||||
|
@ -105,12 +90,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
navigator.mediaSession?.setActionHandler("stop", events.curry("statusMusic/close"));
|
||||
let devicePixelRatio: number;
|
||||
|
||||
onMount(updateScene);
|
||||
onDestroy(() => stop());
|
||||
</script>
|
||||
<div id="wrapper">
|
||||
<div id="wrapper" style:--pixel-ratio={devicePixelRatio}>
|
||||
<header id="header">
|
||||
header goes here - maybe search bar later?
|
||||
{#await selected then selected}
|
||||
|
@ -120,12 +105,16 @@
|
|||
{/await}
|
||||
</header>
|
||||
{#if $state.pins}
|
||||
<nav id="pins"></nav>
|
||||
<nav id="pins" />
|
||||
{/if}
|
||||
<Nav {items} {selected} />
|
||||
<main id="main">
|
||||
{#await selected}
|
||||
<div class="empty"><Clock /><div style="display: inline-block; width: 1em"></div>fetching event...</div>
|
||||
<div class="empty">
|
||||
<Clock />
|
||||
<div style="display: inline-block; width: 1em" />
|
||||
fetching event...
|
||||
</div>
|
||||
{:then selected}
|
||||
{#if selected instanceof ArrayBuffer}
|
||||
<div class="empty">you can't view a raw blob here!</div>
|
||||
|
@ -134,33 +123,44 @@
|
|||
{:else if getComponent(selected.type) === null}
|
||||
<div class="empty">unsupported event type!</div>
|
||||
{:else}
|
||||
<svelte:component this={getComponent(selected.type)} bucket={selected} />
|
||||
<svelte:component
|
||||
this={getComponent(selected.type)}
|
||||
bucket={selected}
|
||||
/>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<div class="empty">failed to load: {err}</div>
|
||||
{/await}
|
||||
</main>
|
||||
{#if $state.sidebar}
|
||||
<aside id="side"></aside>
|
||||
<aside id="side" />
|
||||
{/if}
|
||||
<footer id="status">
|
||||
{#if $state.statusMusic}
|
||||
<div>
|
||||
<Audio event={$state.statusMusic.event} small={!$state.pins} time={$state.statusMusic.time} />
|
||||
<Audio
|
||||
event={$state.statusMusic.event}
|
||||
small={!$state.pins}
|
||||
time={$state.statusMusic.time}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div><Status /></div>
|
||||
</footer>
|
||||
<header id="nav-header"></header>
|
||||
<header id="nav-header" />
|
||||
</div>
|
||||
{#each $state.popups as popup}
|
||||
{#if popup.type === "event"}
|
||||
<EventPopup event={popup.event} paginate={popup.paginate} close={events.curry("popup/close")}/>
|
||||
<EventPopup
|
||||
event={popup.event}
|
||||
paginate={popup.paginate}
|
||||
close={state.curry("popup/close")}
|
||||
/>
|
||||
{:else if popup.type === "text"}
|
||||
<Popup>
|
||||
{popup.text}
|
||||
<div style="margin-top: 1em; text-align: right">
|
||||
<button on:click={events.curry("popup/close")}>alright</button>
|
||||
<button on:click={state.curry("popup/close")}>alright</button>
|
||||
</div>
|
||||
</Popup>
|
||||
{:else if popup.type === "edit"}
|
||||
|
@ -171,8 +171,18 @@
|
|||
<Popup on:close={() => popup.resolve(false)}>
|
||||
<div>{popup.question}</div>
|
||||
<div style="margin-top: 1em; text-align: right">
|
||||
<button on:click={() => { events.do("popup/close"); popup.resolve(false); }}>cancel</button>
|
||||
<button on:click={() => { events.do("popup/close"); popup.resolve(true); }}>confirm</button>
|
||||
<button
|
||||
on:click={() => {
|
||||
state.do("popup/close");
|
||||
popup.resolve(false);
|
||||
}}>cancel</button
|
||||
>
|
||||
<button
|
||||
on:click={() => {
|
||||
state.do("popup/close");
|
||||
popup.resolve(true);
|
||||
}}>confirm</button
|
||||
>
|
||||
</div>
|
||||
</Popup>
|
||||
{:else}
|
||||
|
@ -182,12 +192,18 @@
|
|||
{#if $state.menu}
|
||||
<Menu menu={$state.menu} />
|
||||
{/if}
|
||||
<svelte:window on:animationstart={syncAnimations} on:hashchange={updateScene} />
|
||||
<svelte:window on:animationstart={syncAnimations} on:hashchange={updateScene} bind:devicePixelRatio />
|
||||
<style lang="scss">
|
||||
#wrapper {
|
||||
--zoom-ratio: .8; // ratio of ui:text size when user is zooming in (1:1 is way too much!)
|
||||
--resize: calc(var(--pixel-ratio, 1) * var(--zoom-ratio) + (1 - var(--zoom-ratio)));
|
||||
--pin-size: calc(64px / var(--resize));
|
||||
--nav-size: calc(240px / var(--resize));
|
||||
--side-size: calc(240px / var(--resize));
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 48px 1fr 1fr auto;
|
||||
grid-template-columns: fit-content(64px) 240px 1fr auto;
|
||||
grid-template-columns: fit-content(64px) var(--nav-size) 1fr auto;
|
||||
grid-template-areas:
|
||||
"pin notice notice notice"
|
||||
"pin nav-header header header"
|
||||
|
@ -203,19 +219,19 @@
|
|||
#pins {
|
||||
grid-area: pin;
|
||||
background: var(--bg-quartiary);
|
||||
width: 64px;
|
||||
width: var(--pin-size);
|
||||
}
|
||||
|
||||
#side {
|
||||
grid-area: side;
|
||||
background: var(--bg-secondary);
|
||||
width: 240px;
|
||||
width: var(--side-size);
|
||||
}
|
||||
|
||||
#status {
|
||||
grid-area: status;
|
||||
background: var(--bg-tertiary);
|
||||
width: 240px;
|
||||
width: var(--side-size);
|
||||
|
||||
& > div + div {
|
||||
border-top: solid var(--borders) 1px;
|
||||
|
@ -223,7 +239,7 @@
|
|||
}
|
||||
|
||||
#pins ~ #status {
|
||||
width: calc(64px + 240px);
|
||||
width: calc(var(--pin-size) + var(--side-size) + 1px);
|
||||
}
|
||||
|
||||
#nav-header {
|
||||
|
|
|
@ -42,6 +42,7 @@
|
|||
|
||||
function getName(event: Event) {
|
||||
const cont = event.getContent();
|
||||
if (!cont) return "---";
|
||||
switch (event.type) {
|
||||
case "home": return "home";
|
||||
case "settings": return "settings";
|
||||
|
@ -79,9 +80,9 @@
|
|||
on:contextmenu|preventDefault|stopPropagation={openContext(selectedRes)}
|
||||
>
|
||||
<div class="highlight">
|
||||
<svelte:component this={getIcon(selectedRes)} />
|
||||
<div class="icon"><svelte:component this={getIcon(selectedRes)} /></div>
|
||||
<span class="name" title={name}>{name}</span>
|
||||
<button on:click={state.curry("popup/open", { type: "edit", event: selectedRes })}>+</button>
|
||||
<button on:click|preventDefault={state.curry("popup/open", { type: "edit", event: selectedRes, panel: "general" })}>+</button>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
@ -94,10 +95,10 @@
|
|||
on:contextmenu|preventDefault|stopPropagation={openContext(item)}
|
||||
>
|
||||
<div class="highlight">
|
||||
<svelte:component this={getIcon(item)} />
|
||||
<div class="icon"><svelte:component this={getIcon(item)} /></div>
|
||||
<span class="name" title={name}>{name}</span>
|
||||
{#if !["home", "settings"].includes(item.id)}
|
||||
<button on:click={state.curry("popup/open", { type: "edit", event: item })}>+</button>
|
||||
<button on:click|preventDefault={state.curry("popup/open", { type: "edit", event: item, panel: "general" })}>+</button>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
@ -131,21 +132,25 @@
|
|||
& > .highlight {
|
||||
display: flex;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
& > .icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
& > .name {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& > button {
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
|
|
|
@ -102,7 +102,7 @@
|
|||
& > .time {
|
||||
flex: 1;
|
||||
padding: 2px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
|
||||
|
|
|
@ -307,7 +307,7 @@
|
|||
& > .time {
|
||||
flex: 1;
|
||||
padding: 2px;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
|
||||
|
|
|
@ -1,96 +0,0 @@
|
|||
import * as ed25519 from "@noble/ed25519";
|
||||
import * as base64Normal from "uint8-to-base64";
|
||||
import canonicalize from "canonicalize";
|
||||
export * as ed25519 from "@noble/ed25519";
|
||||
import EventEmitter from "events";
|
||||
import TypedEmitter from "typed-emitter";
|
||||
import log from "./log";
|
||||
|
||||
export const base64 = {
|
||||
encode(data: Uint8Array): string {
|
||||
return base64Normal
|
||||
.encode(data)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "")
|
||||
},
|
||||
decode(data: string): Uint8Array {
|
||||
const cleaned = data
|
||||
.replace(/\-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
return base64Normal.decode(cleaned);
|
||||
},
|
||||
}
|
||||
|
||||
type ref = string;
|
||||
|
||||
interface QueryOptions {
|
||||
refs?: Array<ref>,
|
||||
senders?: Array<string>,
|
||||
types?: Array<string>,
|
||||
tags?: Array<string>,
|
||||
relations?: Array<[string, string] | [string, string, string]>,
|
||||
ephemeral?: Array<[string, string] | [string, string, string]>,
|
||||
}
|
||||
|
||||
type Relations = Record<ref, { type: string, key?: string }>;
|
||||
|
||||
type TypedEvents = {
|
||||
"x.file": { chunks: Array<string>, name?: string },
|
||||
"x.redact": { reason?: string },
|
||||
"x.acl": {
|
||||
roles?: Record<string, Array<[string, string, string]>>,
|
||||
users?: Record<string, Array<string>>,
|
||||
admins?: Array<string>,
|
||||
},
|
||||
// "x.user": { name?: string },
|
||||
};
|
||||
|
||||
function todo(): never {
|
||||
throw new Error("not implemented!");
|
||||
}
|
||||
|
||||
// export class Event<
|
||||
// EventType extends keyof TypedEvents | string,
|
||||
// EventContent extends (EventType extends keyof TypedEvents ? TypedEvents[EventType] : Record<string, any>),
|
||||
// > {
|
||||
// id: string;
|
||||
// type: EventType;
|
||||
// content: EventContent | null;
|
||||
export class Event {
|
||||
id: string;
|
||||
type: string;
|
||||
content: Record<string, any> | null;
|
||||
sender: string;
|
||||
signature: string;
|
||||
origin_ts: number;
|
||||
derived?: Record<string, any>;
|
||||
relations?: Relations;
|
||||
|
||||
constructor() {
|
||||
throw new Error("you cannot use the constructor");
|
||||
}
|
||||
|
||||
static from(json: Record<string, any>) {
|
||||
return Object.assign(new Event(), json);
|
||||
}
|
||||
|
||||
getContent() { return this.derived?.update ?? this.content }
|
||||
isRedacted () { return this.content === null }
|
||||
|
||||
async redact(reason?: string) { todo() }
|
||||
async edit(content: Record<string, any>): Promise<ref> { todo() }
|
||||
}
|
||||
|
||||
export class Events extends Map<string, Event> {
|
||||
async fetch(ref: string): Promise<Event> { todo() }
|
||||
}
|
||||
|
||||
export class Query {
|
||||
|
||||
}
|
||||
|
||||
export class Client {
|
||||
async createEvent(type: string, content: Record<string, any>, relations: Relations = {}): Promise<ref> { todo() }
|
||||
async createBlob(blob: ArrayBuffer): Promise<ref> { todo() }
|
||||
}
|
421
web/lib/api.ts
421
web/lib/api.ts
|
@ -2,8 +2,6 @@ import * as ed25519 from "@noble/ed25519";
|
|||
import * as base64Normal from "uint8-to-base64";
|
||||
import canonicalize from "canonicalize";
|
||||
export * as ed25519 from "@noble/ed25519";
|
||||
import EventEmitter from "events";
|
||||
import TypedEmitter from "typed-emitter";
|
||||
import { Option } from "./monad";
|
||||
import log from "./log";
|
||||
|
||||
|
@ -23,8 +21,20 @@ export const base64 = {
|
|||
},
|
||||
}
|
||||
|
||||
interface QueryOptions {
|
||||
refs?: Array<string>,
|
||||
function createAsync<T, E>(): [Promise<T>, (value: T) => void, (error: E) => void] {
|
||||
let resolve: (value: T) => void;
|
||||
let reject: (error: E) => void;
|
||||
const prom: Promise<T> = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return [prom, resolve!, reject!];
|
||||
}
|
||||
|
||||
type ref = string;
|
||||
|
||||
export interface QueryOptions {
|
||||
refs?: Array<ref>,
|
||||
senders?: Array<string>,
|
||||
types?: Array<string>,
|
||||
tags?: Array<string>,
|
||||
|
@ -32,103 +42,217 @@ interface QueryOptions {
|
|||
ephemeral?: Array<[string, string] | [string, string, string]>,
|
||||
}
|
||||
|
||||
type Relations = Record<string, { type: string, key?: string }>;
|
||||
type Relations = Record<ref, { type: string, key?: string }>;
|
||||
|
||||
type TypedEvents = {
|
||||
"x.user": { name: string },
|
||||
"x.file": { chunks: Array<string>, name?: string },
|
||||
"x.redact": { reason?: string },
|
||||
"x.acl": {
|
||||
roles?: Record<string, Array<[string, string, string]>>,
|
||||
users?: Record<string, Array<string>>,
|
||||
admins?: Array<string>,
|
||||
},
|
||||
"x.update": Record<string, any>,
|
||||
// "x.annotate": {},
|
||||
// "x.annotate.local": {},
|
||||
"x.tag": { tags: Array<string> },
|
||||
"x.tag.local": { tags: Array<string> },
|
||||
};
|
||||
|
||||
// export class Event<
|
||||
// EventType extends keyof TypedEvents | string,
|
||||
// EventContent extends (EventType extends keyof TypedEvents ? TypedEvents[EventType] : Record<string, any>),
|
||||
// > {
|
||||
// id: string;
|
||||
// type: EventType;
|
||||
// content: EventContent | null;
|
||||
export class Event {
|
||||
id: string;
|
||||
type: string;
|
||||
content: any;
|
||||
sender: string;
|
||||
signature: string;
|
||||
origin_ts: number;
|
||||
id!: string;
|
||||
type!: string;
|
||||
content!: Record<string, any> | null;
|
||||
sender!: string;
|
||||
signature!: string;
|
||||
origin_ts!: number;
|
||||
derived?: Record<string, any>;
|
||||
relations?: Relations;
|
||||
|
||||
static from(json: Record<string, any>) {
|
||||
return Object.assign(new Event(), json);
|
||||
constructor(public client: Client) { }
|
||||
|
||||
static from(client: Client, json: Record<string, any>) {
|
||||
return Object.assign(new Event(client), json);
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return this.derived?.update ?? this.content;
|
||||
getContent() { return this.derived?.update ?? this.content }
|
||||
isRedacted() { return this.content === null }
|
||||
|
||||
async redact(reason?: string): Promise<ref> {
|
||||
const ref = await this.client.createEvent("x.redact", { reason }, { [this.id]: { type: "redact" } });
|
||||
this.content = null;
|
||||
delete this.derived;
|
||||
return ref;
|
||||
}
|
||||
|
||||
async update(content: Record<string, any>): Promise<ref> {
|
||||
const ref = await this.client.createEvent("x.update", content, { [this.id]: { type: "update" } });
|
||||
if (this.derived) {
|
||||
this.derived.update = content;
|
||||
} else {
|
||||
this.derived = { update: content };
|
||||
}
|
||||
return ref;
|
||||
}
|
||||
}
|
||||
|
||||
export class Events extends Map<string, Event> {
|
||||
private requests = new Map();
|
||||
|
||||
constructor(public client: Client) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetch(ref: string, force = false): Promise<Event> {
|
||||
if (!force && this.has(ref)) return this.get(ref)!;
|
||||
if (this.requests.has(ref)) return this.requests.get(ref)!;
|
||||
const [promise, done, fail] = createAsync();
|
||||
this.requests.set(ref, promise);
|
||||
try {
|
||||
const event = await this._fetch(ref);
|
||||
this.set(ref, event);
|
||||
done(event);
|
||||
return event;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.requests.delete(ref);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetch(ref: ref): Promise<Event> {
|
||||
const req = await fetch(`${this.client.baseUrl}/things/${ref}`, {
|
||||
headers: { "authorization": `Bearer ${this.client.token}` },
|
||||
});
|
||||
const type = req.headers.get("content-type");
|
||||
if (req.status === 404) throw new Error("doesnt exist");
|
||||
if (!req.ok) throw new Error("failed to fetch");
|
||||
if (type === "application/octet-stream") throw new Error("got blob instead of event");
|
||||
if (type !== "application/json") throw new Error("invalid response type");
|
||||
const event = Event.from(this.client, await req.json());
|
||||
if (ref !== event.id) throw new Error("server sent wrong ref");
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
export class Actors extends Map<string, Event> {
|
||||
private requests = new Map();
|
||||
|
||||
constructor(public client: Client) {
|
||||
super();
|
||||
}
|
||||
|
||||
// TODO: expose bulk fetch
|
||||
// also: maybe have a ~~syntax~~ api sugar endpoint for getting a user instead of query?
|
||||
// the whole api is designed to be flexible enough for this, but qol endpoints might be nice
|
||||
// also will save a request (maybe *another* create-then-immediately-fetch query endpoint would also be good)
|
||||
// i'll wait for a while and see if there's a strong use case for it
|
||||
async fetch(actor: string, force = false): Promise<Event> {
|
||||
if (!force && this.has(actor)) return this.get(actor)!;
|
||||
if (this.requests.has(actor)) return this.requests.get(actor)!;
|
||||
const [promise, done, fail] = createAsync();
|
||||
this.requests.set(actor, promise);
|
||||
try {
|
||||
const events = await this._fetch([actor]);
|
||||
if (!events.length) throw new Error("no such actor");
|
||||
const [event] = events;
|
||||
this.set(actor, event);
|
||||
done(event);
|
||||
return event;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.requests.delete(actor);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetch(actors: Array<string>): Promise<Array<Event>> {
|
||||
const query = await this.client.query({ senders: actors, types: ["x.user"] });
|
||||
const chunk = await query.next();
|
||||
if (!chunk) throw new Error("failed to fetch");
|
||||
return chunk.events;
|
||||
}
|
||||
}
|
||||
|
||||
type QueryEvents = {
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
class Query extends EventEmitter implements TypedEmitter<QueryEvents> {
|
||||
private signal = new AbortSignal();
|
||||
type QueryChunk = {
|
||||
events: Array<Event>,
|
||||
relations: Map<ref, Event>,
|
||||
next: string,
|
||||
};
|
||||
|
||||
// export class Query extends EventEmitter implements TypedEmitter<QueryEvents> {
|
||||
export class Query {
|
||||
public after: string | null = null;
|
||||
public aborter = new AbortController();
|
||||
|
||||
constructor(
|
||||
public client: Client,
|
||||
public id: string,
|
||||
) {
|
||||
super();
|
||||
// super();
|
||||
}
|
||||
|
||||
async watch(stream: null | number = null) {
|
||||
let after = "";
|
||||
while (true) {
|
||||
const url = `${client.baseUrl}/things?query=${this.id}&after=${after}&timeout=${stream ?? ""}`;
|
||||
async next(timeout?: number): Promise<QueryChunk | null> {
|
||||
let url = `${this.client.baseUrl}/things?query=${this.id}`;
|
||||
if (this.after) url += "&after=" + this.after;
|
||||
if (timeout) url += "&timeout=" + timeout;
|
||||
const req = await fetch(url, {
|
||||
signal: this.abort.signal,
|
||||
headers: { "authorization": `Bearer ${self.token}` },
|
||||
signal: this.aborter.signal,
|
||||
headers: { "authorization": `Bearer ${this.client.token}` },
|
||||
}).catch((err) => {
|
||||
if (this.signal.aborted) return null;
|
||||
if (this.aborter.signal.aborted) {
|
||||
log.api(`stop poll(${this.id})`);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (!req) break;
|
||||
if (!req) return null;
|
||||
if (!req.ok) throw new Error("failed to query: " + await req.text());
|
||||
const json = await req.json();
|
||||
log.api(`long poll(${await getPagination()})`, json);
|
||||
for (let relId in json.relations) relations.set(relId, Event.from(json.relations[relId]));
|
||||
for (let event of json.events) this.emit("event", Event.from(event));
|
||||
for (let rel of json.relations) this.emit("relation", Event.from(event));
|
||||
if (json.next) {
|
||||
after = json.next;
|
||||
} else if (stopped || !stream) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.signal.aborted) return;
|
||||
this.signal.abort();
|
||||
log.api(`long poll(${this.id})`, json);
|
||||
if (json.next) this.after = json.next;
|
||||
const events = (json.events ?? []).map((raw: any) => Event.from(this.client, raw));
|
||||
const relations = new Map(Object.entries(json.relations ?? {}).map(([ref, raw]: [string, any]) => [ref, Event.from(this.client, raw)]));
|
||||
for (const event of [...events, ...relations.values()]) this.client.events.set(event.id, event);
|
||||
return {
|
||||
events,
|
||||
relations,
|
||||
next: json.next,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class Client {
|
||||
public events = new Events(this);
|
||||
public actors = new Actors(this);
|
||||
|
||||
constructor(
|
||||
public baseUrl: string,
|
||||
public key: Uint8Array,
|
||||
public token: string,
|
||||
) {}
|
||||
public secretKey: Uint8Array,
|
||||
public publicKey: Uint8Array,
|
||||
) { }
|
||||
|
||||
async createBlob(blob: ArrayBuffer | Event, wait = true): Promise<string> {
|
||||
const req = await fetch(`${this.baseUrl}/things?wait=${wait}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"authorization": `Bearer ${this.token}`,
|
||||
},
|
||||
body: blob,
|
||||
});
|
||||
if (!req.ok) {
|
||||
throw new Error("failed to upload: " + await req.text());
|
||||
}
|
||||
const { ref } = await req.json();
|
||||
return ref;
|
||||
}
|
||||
|
||||
async createEvent(type: string, content: Record<string, any>, relations?: Relations, wait = true): Promise<string> {
|
||||
// TODO: cache public key
|
||||
const sender = "%" + base64.encode(await ed25519.getPublicKeyAsync(this.key));
|
||||
async createEvent(type: string, content: Record<string, any>, relations: Relations = {}, wait = true): Promise<ref> {
|
||||
const sender = `%${base64.encode(this.publicKey)}`;
|
||||
const event = { type, content, sender, origin_ts: Date.now() } as any;
|
||||
if (relations) event.relations = relations;
|
||||
const encoder = new TextEncoder();
|
||||
event.signature = base64.encode(await ed25519.signAsync(encoder.encode(canonicalize(event)), this.key));
|
||||
event.signature = base64.encode(await ed25519.signAsync(encoder.encode(canonicalize(event)), this.secretKey));
|
||||
|
||||
const req = await fetch(`${this.baseUrl}/things?wait=${wait}`, {
|
||||
method: "POST",
|
||||
|
@ -142,23 +266,37 @@ export class Client {
|
|||
throw new Error("failed to upload: " + await req.text());
|
||||
}
|
||||
const { ref } = await req.json();
|
||||
log.api(`create event(type=${type}, ${ref})`);
|
||||
return ref;
|
||||
}
|
||||
|
||||
// async redact(ref: string, reason?: string): Promise<string> {
|
||||
// return this.createEvent("x.redact", { reason }, { [ref]: { type: "redact" } });
|
||||
// }
|
||||
|
||||
async fetch(ref: string): Promise<ArrayBuffer | Event> {
|
||||
const req = await fetch(`${this.baseUrl}/things/${ref}`, {
|
||||
headers: { "authorization": `Bearer ${this.token}` },
|
||||
async createBlob(blob: ArrayBuffer, wait = true): Promise<ref> {
|
||||
const req = await fetch(`${this.baseUrl}/things?wait=${wait}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"authorization": `Bearer ${this.token}`,
|
||||
},
|
||||
body: blob,
|
||||
});
|
||||
const type = req.headers.get("content-type");
|
||||
if (req.status === 404) throw new Error("doesnt exist");
|
||||
if (!req.ok) throw new Error("failed to fetch");
|
||||
if (type === "application/octet-stream") return req.arrayBuffer();
|
||||
if (type === "application/json") return Event.from(await req.json());
|
||||
throw new Error("invalid response type");
|
||||
if (!req.ok) {
|
||||
throw new Error("failed to upload: " + await req.text());
|
||||
}
|
||||
const { ref } = await req.json();
|
||||
log.api(`create blob(size=${blob.byteLength})`);
|
||||
return ref;
|
||||
}
|
||||
|
||||
async query(options: QueryOptions): Promise<Query> {
|
||||
const queryId = await fetch(`${this.baseUrl}/things/query`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"authorization": `Bearer ${this.token}`,
|
||||
},
|
||||
body: JSON.stringify(options),
|
||||
}).then(res => res.json()).then(res => res.query);
|
||||
return new Query(this, queryId);
|
||||
}
|
||||
|
||||
getBlobUrl(ref: string): string {
|
||||
|
@ -168,131 +306,20 @@ export class Client {
|
|||
getThumbUrl(ref: string, width: number, height: number): string {
|
||||
return `${this.baseUrl}/things/${ref}/thumbnail?width=${width}&height=${height}`;
|
||||
}
|
||||
|
||||
async query(options: QueryOptions, stream = false): Query {
|
||||
const { query: id } = await fetch(`${this.baseUrl}/things/query`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"authorization": `Bearer ${this.token}`,
|
||||
},
|
||||
body: JSON.stringify(options),
|
||||
}).then(res => res.json());
|
||||
const query = new Query(this, id);
|
||||
}
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
let key = new Option(localStorage.getItem("key"))
|
||||
async function getClient() {
|
||||
let secretKey = new Option(localStorage.getItem("key"))
|
||||
.map(s => ed25519.etc.hexToBytes(s)!)
|
||||
.extract(() => ed25519.utils.randomPrivateKey());
|
||||
localStorage.setItem("key", ed25519.etc.bytesToHex(key));
|
||||
localStorage.setItem("key", ed25519.etc.bytesToHex(secretKey));
|
||||
|
||||
let server = localStorage.getItem("server") || "http://localhost:3210/";
|
||||
let token = localStorage.getItem("token") || "";
|
||||
const server = localStorage.getItem("server") || "http://localhost:3210/";
|
||||
const token = localStorage.getItem("token") || "";
|
||||
const publicKey = await ed25519.getPublicKeyAsync(secretKey);
|
||||
|
||||
return new Client(server, key, token);
|
||||
return new Client(server, token, secretKey, publicKey);
|
||||
}
|
||||
|
||||
export const client = getClient();
|
||||
export const api = Object.assign(getClient(), {
|
||||
async upload(blob: ArrayBuffer | Event, wait = true): Promise<string> {
|
||||
const isRaw = blob instanceof ArrayBuffer;
|
||||
const req = await fetch(this.baseUrl + "/things?wait=" + wait, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": isRaw ? "application/octet-stream" : "application/json",
|
||||
"authorization": `Bearer ${this.token}`,
|
||||
},
|
||||
body: isRaw ? blob : JSON.stringify(blob),
|
||||
});
|
||||
if (!req.ok) {
|
||||
log.err(await req.text());
|
||||
throw new Error("failed to upload: " + req.statusText);
|
||||
}
|
||||
const { ref } = await req.json();
|
||||
return ref;
|
||||
},
|
||||
query(query: QueryOptions, stream = false): {
|
||||
events: AsyncGenerator<{ type: "event" | "relation", event: Event }>,
|
||||
relations: Map<string, Event>,
|
||||
stop: () => void,
|
||||
} {
|
||||
const self = this;
|
||||
let after: string;
|
||||
let pagination: string;
|
||||
let stopped = false;
|
||||
let stopSig = new AbortController();
|
||||
const relations = new Map();
|
||||
|
||||
async function getPagination(): Promise<string> {
|
||||
if (pagination) return pagination;
|
||||
// TODO: make query.senders default to pubkey
|
||||
// const queryReal = {
|
||||
// ...query,
|
||||
// senders: [`%${base64.encode(await ed25519.getPublicKeyAsync(self.key))}`],
|
||||
// };
|
||||
// FIXME: this lets anyone make any file appear to a person, which isn't good
|
||||
const queryReal = query;
|
||||
|
||||
const res = await fetch(self.baseUrl + "/things/query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"authorization": `Bearer ${self.token}`,
|
||||
},
|
||||
body: JSON.stringify(queryReal),
|
||||
}).then(res => res.json());
|
||||
pagination = res.query;
|
||||
return pagination;
|
||||
}
|
||||
|
||||
async function *paginateEvents(): AsyncGenerator<{ type: "event" | "relation", event: Event }> {
|
||||
while (true) {
|
||||
const url = self.baseUrl + "/things?query=" + (await getPagination()) + (after ? "&after=" + after : "") + (stream ? "&timeout=30000" : "");
|
||||
const req = await fetch(url, {
|
||||
signal: stopSig.signal,
|
||||
headers: {
|
||||
"authorization": `Bearer ${self.token}`,
|
||||
},
|
||||
}).catch((err) => {
|
||||
if (stopSig.signal.aborted) return null;
|
||||
throw err;
|
||||
});
|
||||
if (!req) break;
|
||||
if (!req.ok) throw new Error("failed to query: " + await req.text());
|
||||
const json = await req.json();
|
||||
log.api(`long poll(${await getPagination()})`, json);
|
||||
for (let relId in json.relations) relations.set(relId, json.relations[relId]);
|
||||
for (let event of json.events) if (event.content) yield { type: "event", event: Event.from(event) };
|
||||
for (let relId in json.relations) if (json.relations[relId]?.content) yield { type: "relation", event: Event.from(json.relations[relId]) };
|
||||
if (json.next) {
|
||||
after = json.next;
|
||||
} else if (stopped || !stream) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: paginateEvents(),
|
||||
relations,
|
||||
stop() {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
stopSig.abort();
|
||||
}
|
||||
}
|
||||
},
|
||||
async makeEvent(type: string, content: any, relations?: Relations): Promise<Event> {
|
||||
// TODO: cache public key
|
||||
const sender = "%" + base64.encode(await ed25519.getPublicKeyAsync(this.key));
|
||||
const event = { type, content, sender, origin_ts: Date.now() } as any;
|
||||
if (relations) event.relations = relations;
|
||||
const encoder = new TextEncoder();
|
||||
event.signature = base64.encode(await ed25519.signAsync(encoder.encode(canonicalize(event)), this.key));
|
||||
return event;
|
||||
}
|
||||
});
|
||||
|
||||
Object.assign(globalThis, { client, api, base64, ed25519 });
|
||||
export const api = await getClient();
|
||||
Object.assign(globalThis, { api, base64, ed25519 });
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { readable } from "svelte/store";
|
||||
import type { Event } from "./api";
|
||||
import { Readable, readable } from "svelte/store";
|
||||
import { Event, Query, QueryOptions } from "./api";
|
||||
import log from "./log";
|
||||
|
||||
const timeUnits = [
|
||||
[1000, "milisecond"],
|
||||
|
@ -39,7 +40,7 @@ export function formatSize(size: number): string {
|
|||
|
||||
|
||||
export function formatTime(time?: number): string {
|
||||
if (isNaN(time)) return "-:--";
|
||||
if (time === undefined || isNaN(time)) return "-:--";
|
||||
const seconds = Math.floor(time) % 60;
|
||||
const minutes = Math.floor((time / 60) % 60);
|
||||
const hours = Math.floor(time / (60 * 60));
|
||||
|
@ -52,76 +53,48 @@ export function formatTime(time?: number): string {
|
|||
}
|
||||
}
|
||||
|
||||
export async function asyncIterArray(iter) {
|
||||
const arr = [];
|
||||
for await (const item of iter) arr.push(item);
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function collect<T>(asyncIter: AsyncIterable<T>) {
|
||||
const items: Array<T> = [];
|
||||
let update = (_: Array<T>) => {};
|
||||
export function watch(queryPromise: Promise<Query>, filter?: (event: Event) => boolean): {
|
||||
events: Readable<Array<Event>>,
|
||||
stop: () => void,
|
||||
} {
|
||||
let items: Array<Event> = [];
|
||||
let query: Query | undefined;
|
||||
let stop = () => query?.aborter.abort();
|
||||
let update = () => {};
|
||||
|
||||
(async () => {
|
||||
for await (let item of asyncIter) {
|
||||
items.push(item);
|
||||
update(items);
|
||||
}
|
||||
})();
|
||||
|
||||
return readable(items, (set) => {
|
||||
update = (val) => set(val);
|
||||
});
|
||||
}
|
||||
|
||||
export function collectEvents(query, filter?: (event: Event) => boolean) {
|
||||
const events: Array<Event> = [];
|
||||
let update = (_) => {};
|
||||
|
||||
(async () => {
|
||||
for await (let { type: eventType, event } of query.events) {
|
||||
query = await queryPromise;
|
||||
while (true) {
|
||||
const chunk = await query.next(30000);
|
||||
if (!chunk) return;
|
||||
const events = [...chunk.events, ...chunk.relations.values()];
|
||||
for (const event of events) {
|
||||
if (event.type === "x.redact") {
|
||||
for (let target in event.relations) {
|
||||
const idx = events.findIndex(ev => ev.id === target);
|
||||
if (idx !== -1) events.splice(idx, 1);
|
||||
}
|
||||
const rels = event.relations ?? {};
|
||||
log.dbg("remove", rels);
|
||||
items = items.filter(i => !(i.id in rels));
|
||||
} else if (event.type === "x.update") {
|
||||
for (let target in event.relations) {
|
||||
const idx = events.findIndex(ev => ev.id === target);
|
||||
if (idx !== -1) {
|
||||
events[idx].content = event.content;
|
||||
events[idx].derived = event.derived;
|
||||
const rels = event.relations ?? {};
|
||||
log.dbg("update", rels);
|
||||
for (const target of items.filter(i => i.id in rels)) {
|
||||
if (target.derived) {
|
||||
target.derived.update = event.content;
|
||||
} else {
|
||||
target.derived = { update: event.content };
|
||||
}
|
||||
target.derived
|
||||
}
|
||||
} else if (!filter || filter(event)) {
|
||||
events.push(event);
|
||||
items.push(event);
|
||||
}
|
||||
update(events);
|
||||
}
|
||||
update();
|
||||
}
|
||||
})();
|
||||
|
||||
return readable(events, (set) => {
|
||||
update = (val) => set(val);
|
||||
});
|
||||
return { events: readable(items, set => update = () => set(items)), stop };
|
||||
}
|
||||
|
||||
export function filterRels(event: Event, reltype: string): Array<string> {
|
||||
return Object.entries(event.relations ?? {}).filter(i => i[1].type === reltype).map(i => i[0]);
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator
|
||||
const AsyncIteratorPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(Object.getPrototypeOf((async function* () {})())),
|
||||
);
|
||||
|
||||
AsyncIteratorPrototype.map = async function*(func) {
|
||||
for await (let item of this) {
|
||||
yield func(item);
|
||||
}
|
||||
} as <T, U>(func: (_: T) => U) => AsyncIterator<U>;
|
||||
|
||||
AsyncIteratorPrototype.filter = async function*(func) {
|
||||
for await (let item of this) {
|
||||
if (func(item)) yield item;
|
||||
}
|
||||
} as <T, U>(func: (_: T) => U) => AsyncIterator<U>;
|
||||
|
|
|
@ -6,8 +6,7 @@
|
|||
let description: string;
|
||||
|
||||
async function create() {
|
||||
const ev = await api.makeEvent(eventType, { name, description });
|
||||
const ref = await api.upload(ev);
|
||||
const ref = await api.createEvent(eventType, { name, description });
|
||||
state.do("popup/close");
|
||||
location.hash = `/${ref}`;
|
||||
}
|
||||
|
|
|
@ -1,21 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { api } from "../lib/api";
|
||||
import { asyncIterArray } from "../lib/util";
|
||||
import { api, Event } from "../lib/api";
|
||||
import { state } from "../state";
|
||||
import type { Event } from "../lib/api";
|
||||
import type { EventPanel } from "../types";
|
||||
import type { EditPanels } from "../types";
|
||||
export let event: Event;
|
||||
export let panel: EventPanel;
|
||||
let name = event.content.name;
|
||||
let description = event.content.description;
|
||||
export let panel: EditPanels;
|
||||
let content = event.getContent() ?? {};
|
||||
let name = content.name;
|
||||
let description = content.description;
|
||||
$: acl = fetchAcl(event);
|
||||
|
||||
async function fetchAcl(event: Event): Promise<Event | null> {
|
||||
const query = await asyncIterArray(api.query({ refs: [event.id], relations: [["x.acl", "acl"]] }, false).events);
|
||||
return query.filter(i => i.type === "relation" && i.event.type === "x.acl").map(i => i.event)[0] ?? null;
|
||||
const query = await api.query({ refs: [event.id], relations: [["x.acl", "acl"]] });
|
||||
const chunk = await query.next();
|
||||
if (!chunk) return null;
|
||||
return [...chunk.relations.values()].find(event => event.type === "x.acl") ?? null;
|
||||
}
|
||||
|
||||
function select(panel: EventPanel) {
|
||||
function select(panel: EditPanels) {
|
||||
state.do("popup/replace", { type: "edit", event, panel });
|
||||
}
|
||||
|
||||
|
@ -30,16 +31,14 @@
|
|||
}
|
||||
|
||||
async function update() {
|
||||
if (name === event.content.name && description === event.content.description) return events.do("close", "popup");
|
||||
const ev = await api.makeEvent("x.update", { name, description }, { [event.id]: { type: "update" } });
|
||||
await api.upload(ev);
|
||||
if (name === event.getContent().name && description === event.getContent().description) return state.do("close", "popup");
|
||||
event.update({ name, description });
|
||||
state.do("popup/close");
|
||||
}
|
||||
|
||||
async function deleteNexus() {
|
||||
if (!await confirm("are you sure you want to delete this?")) return;
|
||||
const ev = await api.makeEvent("x.redact", {}, { [event.id]: { type: "redact" } });
|
||||
await api.upload(ev);
|
||||
await event.redact();
|
||||
state.do("popup/close");
|
||||
location.hash = "/home";
|
||||
}
|
||||
|
|
|
@ -203,7 +203,7 @@
|
|||
top: calc(1em + 8px);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
font-family: sans-serif;
|
||||
font-family: var(--font-normal);
|
||||
font-size: .9rem;
|
||||
color: var(--fg-dimmed);
|
||||
|
||||
|
|
|
@ -25,12 +25,11 @@
|
|||
</body>
|
||||
</html>`;
|
||||
const buffer = await new Blob([html]).arrayBuffer();
|
||||
const blobRef = await api.upload(buffer);
|
||||
const event = await api.makeEvent("x.file", {
|
||||
const blobRef = await api.createBlob(buffer);
|
||||
const eventRef = await api.createEvent("x.file", {
|
||||
chunks: [blobRef],
|
||||
name: `${name.toLowerCase().replace(/[^a-z0-9-_\s]/g, "").replace(/\s/g, "-")}.html`,
|
||||
});
|
||||
const eventRef = await api.upload(event)
|
||||
window.open(api.getBlobUrl(eventRef));
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,28 +1,28 @@
|
|||
<script lang="ts">
|
||||
import Document from "./Document.svelte";
|
||||
import { api } from "../../lib/api";
|
||||
import { asyncIterArray } from "../../lib/util";
|
||||
import { api, Event } from "../../lib/api";
|
||||
import { renderDocument } from "./render";
|
||||
import type { Event } from "../../lib/api";
|
||||
import { onDestroy } from "svelte";
|
||||
import { watch } from "../../lib/util";
|
||||
import { derived, get, readable } from "svelte/store";
|
||||
import type { Readable } from "svelte/store";
|
||||
export let event: Event;
|
||||
export let hideNotes: boolean;
|
||||
$: embeds = fetchEmbeds(event);
|
||||
$: ({ events: embeds, stop } = fetchEmbeds(event));
|
||||
|
||||
export async function snapshot() {
|
||||
return renderDocument(event.getContent().doc, await embeds);
|
||||
return renderDocument(event.getContent().doc, get(embeds));
|
||||
}
|
||||
|
||||
async function fetchEmbeds(event: Event): Promise<Map<string, Event>> {
|
||||
if (!event.relations) return new Map();
|
||||
function fetchEmbeds(event: Event): { events: Readable<Map<string, Event>>, stop: () => void } {
|
||||
if (!event.relations) return { events: readable(new Map()), stop: () => {} };
|
||||
const relations = Object.entries(event.relations)
|
||||
.filter(([_, rel]) => rel.type === "embed")
|
||||
.map(([id, _]) => id);
|
||||
const query = await asyncIterArray(api.query({ refs: relations }, false).events);
|
||||
return new Map(query.map(i => [i.event.id, i.event]));
|
||||
const { events, stop } = watch(api.query({ refs: relations }));
|
||||
return { events: derived(events, (events) => new Map(events.map(ev => [ev.id, ev]))), stop };
|
||||
}
|
||||
|
||||
onDestroy(() => stop());
|
||||
</script>
|
||||
{#await embeds}
|
||||
<Document doc={event.getContent().doc} {hideNotes} />
|
||||
{:then embeds}
|
||||
<Document doc={event.getContent().doc} {embeds} {hideNotes} />
|
||||
{/await}
|
||||
<Document doc={event.getContent().doc} embeds={$embeds} {hideNotes} />
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { api, Event } from "../../lib/api";
|
||||
import { timeAgo, collectEvents } from "../../lib/util";
|
||||
import { timeAgo, watch } from "../../lib/util";
|
||||
import { events } from "../../events";
|
||||
export let options = new URLSearchParams();
|
||||
export let bucket: Event;
|
||||
let { events: docs, stop } = loadDocs(options);
|
||||
|
||||
async function removeDoc(id: string) {
|
||||
const event = await api.makeEvent("x.redact", {}, { [id]: { type: "redact" }});
|
||||
await api.upload(event);
|
||||
await api.createEvent("x.redact", {}, { [id]: { type: "redact" }});
|
||||
}
|
||||
|
||||
function loadDocs(options: URLSearchParams) {
|
||||
|
@ -18,11 +17,8 @@
|
|||
refs: [bucket.id],
|
||||
tags: tags.length ? tags : undefined,
|
||||
relations: [["x.redact", "redact"], ["l.doc", "in"]],
|
||||
}, true);
|
||||
return {
|
||||
events: collectEvents(query, ev => ev.type === "l.doc"),
|
||||
stop: query.stop,
|
||||
};
|
||||
});
|
||||
return watch(query, ev => ev.type === "l.doc");
|
||||
}
|
||||
|
||||
interface UploadStatus {
|
||||
|
@ -44,8 +40,7 @@
|
|||
const content = JSON.parse(await file.text());
|
||||
const relations = Object.fromEntries(content.doc.filter(i => i.type === "embed").map(i => [i.ref, { type: "embed" }]));
|
||||
relations[bucket.id] = { type: "in" };
|
||||
const event = await api.makeEvent("l.doc", content, relations);
|
||||
await api.upload(event, false);
|
||||
await api.createEvent("l.doc", content, relations);
|
||||
} catch (err) {
|
||||
events.do("popup/open", { type: "text", text: "failed to upload: " + err });
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { api } from "../../lib/api";
|
||||
import { collectEvents } from "../../lib/util";
|
||||
import { watch } from "../../lib/util";
|
||||
import { onDestroy } from "svelte";
|
||||
import { derived } from "svelte/store";
|
||||
import Gallery from "./Gallery.svelte";
|
||||
|
@ -40,8 +40,7 @@
|
|||
const ref = await api.upload(await slice.arrayBuffer());
|
||||
refs.push(ref);
|
||||
}
|
||||
const event = await api.makeEvent("x.file", { chunks: refs, name: file.name }, { [bucket.id]: { type: "in" } });
|
||||
await api.upload(event, false);
|
||||
await api.createEvent("x.file", { chunks: refs, name: file.name }, { [bucket.id]: { type: "in" } });
|
||||
uploadStatus.count++;
|
||||
}
|
||||
if (uploadStatus.count === uploadStatus.total) {
|
||||
|
@ -55,11 +54,8 @@
|
|||
refs: [bucket.id],
|
||||
tags: tags.length ? tags : null,
|
||||
relations: [["x.redact", "redact"], ["x.file", "in"]],
|
||||
}, true);
|
||||
return {
|
||||
events: collectEvents(query, event => event.type === "x.file"),
|
||||
stop: query.stop,
|
||||
};
|
||||
});
|
||||
return watch(query, event => event.type === "x.file");
|
||||
}
|
||||
|
||||
onDestroy(() => stop());
|
||||
|
|
|
@ -75,7 +75,7 @@
|
|||
}
|
||||
|
||||
.item .size {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
|
|
|
@ -111,7 +111,7 @@
|
|||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,162 @@
|
|||
<script lang="ts">
|
||||
import type { Event } from "../../lib/api";
|
||||
import { filterRels } from "../../lib/util";
|
||||
import { api, Event } from "../../lib/api";
|
||||
import { filterRels, timeAgo } from "../../lib/util";
|
||||
export let state: any;
|
||||
export let event: Event;
|
||||
export let comments: Array<Event>;
|
||||
$: children = comments
|
||||
.filter(comment => filterRels(comment, "comment").indexOf(event.id) !== -1)
|
||||
.sort((a, b) => a.origin_ts - b.origin_ts);
|
||||
$: author = api.actors.fetch(event.sender);
|
||||
$: isFromOp = $state.opId === event.sender;
|
||||
$: replied = $state.replyId === event.id;
|
||||
let collapsed = false;
|
||||
</script>
|
||||
<div
|
||||
on:click={state.curry("reply", ($state.replyId === event.id) ? null : event.id)}
|
||||
class:selected={$state.replyId === event.id}
|
||||
class="comment"
|
||||
class:replied
|
||||
class:fromop={isFromOp}
|
||||
class:collapsed
|
||||
>
|
||||
<header>
|
||||
<button class="collapse" on:click={() => collapsed = !collapsed}>
|
||||
{collapsed ? "+" : "-"}
|
||||
</button>
|
||||
<div class="author">
|
||||
{#await author}
|
||||
<i>loading...</i>
|
||||
{:then author}
|
||||
{@const name = author.content?.name}
|
||||
{#if name && isFromOp}
|
||||
<b>{name}</b> (op)
|
||||
{:else if name && author.origin_ts < (Date.now() + 1000 * 60 * 60 * 24 * 7)}
|
||||
<span class="green">{name}</span>
|
||||
{:else if name}
|
||||
{name}
|
||||
{:else}
|
||||
<i>anonymous</i>
|
||||
{/if}
|
||||
{:catch}
|
||||
<i>anonymous</i>
|
||||
{/await}
|
||||
</div>
|
||||
<time datetime="{new Date(event.origin_ts).toISOString()}">{timeAgo(event.origin_ts)}</time>
|
||||
<!-- <div>123 points</div> -->
|
||||
{#if collapsed}
|
||||
<div class="summary">
|
||||
{event.content.body}
|
||||
</div>
|
||||
{#if children.length}
|
||||
<ul>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{#if !collapsed}
|
||||
<div class="content">
|
||||
{event.content.body}
|
||||
</div>
|
||||
<menu>
|
||||
<!--
|
||||
<button>vote</button>
|
||||
<button>flag</button>
|
||||
-->
|
||||
<button on:click={state.curry("reply", replied ? null : event.id)}>
|
||||
{replied ? "deselect" : "reply"}
|
||||
</button>
|
||||
<button on:click={() => navigator.clipboard.writeText(event.id)}>share</button>
|
||||
<!--
|
||||
<button>remove</button>
|
||||
<button>crosspost</button>
|
||||
-->
|
||||
</menu>
|
||||
{#if children.length}
|
||||
<ul class="children">
|
||||
{#each children as child}
|
||||
<li><svelte:self {state} event={child} {comments} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<style>
|
||||
.selected {
|
||||
background: #ffffff22;
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<style lang="scss">
|
||||
.comment {
|
||||
border-left: solid var(--borders) 1px;
|
||||
margin-left: -1px;
|
||||
|
||||
// &.fromop {
|
||||
// // TODO: find a good color
|
||||
// // making the border thicker doesn't look right
|
||||
// border-left: solid var(--borders) 1px;
|
||||
// }
|
||||
|
||||
&.replied {
|
||||
border-left: solid var(--color-accent) 1px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: 1em;
|
||||
& > header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: var(--bg-secondary);
|
||||
white-space: nowrap;
|
||||
|
||||
& > .collapse {
|
||||
min-width: 24px;
|
||||
border: solid var(--borders) 1px;
|
||||
border-left: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-light);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > .author > .green {
|
||||
color: #77c57d;
|
||||
}
|
||||
|
||||
& > time {
|
||||
color: var(--fg-dimmed);
|
||||
}
|
||||
|
||||
& > .summary {
|
||||
color: var(--fg-dimmed);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&::before {
|
||||
content: "-";
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.collapsed > header {
|
||||
background: none;
|
||||
}
|
||||
|
||||
& > .content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
& > menu {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
& > button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: .9rem;
|
||||
color: var(--fg-dimmed);
|
||||
}
|
||||
}
|
||||
|
||||
& > .children {
|
||||
list-style: none;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,34 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { api, Event } from "../../lib/api";
|
||||
import { timeAgo, collectEvents } from "../../lib/util";
|
||||
import { timeAgo, watch } from "../../lib/util";
|
||||
export let options = new URLSearchParams();
|
||||
export let bucket: Event;
|
||||
|
||||
let { events: posts, stop } = loadPosts();
|
||||
|
||||
function loadPosts() {
|
||||
const query = api.query({ refs: [bucket.id], relations: [["l.forum.post", "in"]] }, true);
|
||||
return {
|
||||
events: collectEvents(query, ev => ev.type === "l.forum.post"),
|
||||
stop: query.stop,
|
||||
};
|
||||
const query = api.query({ refs: [bucket.id], relations: [["l.forum.post", "in"]] });
|
||||
return watch(query, ev => ev.type === "l.forum.post");
|
||||
}
|
||||
|
||||
let submitTitle: string;
|
||||
let submitBody: string;
|
||||
|
||||
async function handlePost(e) {
|
||||
const event = await api.makeEvent("l.forum.post", {
|
||||
const event = await api.createEvent("l.forum.post", {
|
||||
title: submitTitle || undefined,
|
||||
body: submitBody || undefined,
|
||||
}, {
|
||||
[bucket.id]: { type: "in" }
|
||||
});
|
||||
submitTitle = submitBody = "";
|
||||
await api.upload(event);
|
||||
}
|
||||
|
||||
$: sorted = $posts.sort((a, b) => a.origin_ts - b.origin_ts);
|
||||
|
||||
onDestroy(stop);
|
||||
</script>
|
||||
<div class="wrapper">
|
||||
|
@ -42,12 +40,34 @@
|
|||
</form>
|
||||
<hr />
|
||||
<ul class="posts">
|
||||
{#each $posts as event (event.id)}
|
||||
{#each sorted as event (event.id)}
|
||||
{@const content = event.getContent()}
|
||||
<li>
|
||||
<a href="/#/{event.id}">{event.content.title || "no title"}</a> -
|
||||
{event.content.body || "no body"}
|
||||
<!--{event.sender}
|
||||
{event.origin_ts} -->
|
||||
<article class="post">
|
||||
<header>
|
||||
<a href="/#/{event.id}">{content.title || "no title"}</a> -
|
||||
{#await api.actors.fetch(event.sender)}
|
||||
<i>loading...</i>
|
||||
{:then author}
|
||||
{@const name = author.content?.name}
|
||||
{#if name}
|
||||
{name}
|
||||
{:else}
|
||||
<i>anonymous</i>
|
||||
{/if}
|
||||
{:catch}
|
||||
<i>anonymous</i>
|
||||
{/await}
|
||||
<time datetime="{new Date(event.origin_ts).toISOString()}">{timeAgo(event.origin_ts)}</time>
|
||||
</header>
|
||||
<div>
|
||||
{content.body || "no body"}
|
||||
</div>
|
||||
<menu>
|
||||
<a href="/#/{event.id}">comments</a>
|
||||
<button on:click={(e) => navigator.clipboard.writeText(event.id)}>share</button>
|
||||
</menu>
|
||||
</article>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
@ -58,7 +78,30 @@
|
|||
}
|
||||
|
||||
.posts {
|
||||
margin-left: 1em;
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
|
||||
& .post {
|
||||
& > header {
|
||||
& > time {
|
||||
color: var(--fg-dimmed);
|
||||
}
|
||||
}
|
||||
|
||||
& > menu {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
& > button, & > a {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: .9rem;
|
||||
color: var(--fg-dimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
|
|
|
@ -2,17 +2,22 @@
|
|||
import { api } from "../../lib/api";
|
||||
import type { Event } from "../../lib/api";
|
||||
import Comments from "./Comments.svelte";
|
||||
import { collectEvents, filterRels } from "../../lib/util";
|
||||
import { filterRels, watch } from "../../lib/util";
|
||||
import { Reduxer } from "../../lib/reduxer";
|
||||
import { onDestroy } from "svelte";
|
||||
import { onDestroy, tick } from "svelte";
|
||||
export let options = new URLSearchParams();
|
||||
export let bucket: Event;
|
||||
$: forumId = filterRels(bucket, "in")[0];
|
||||
let commentBox: HTMLFormElement;
|
||||
|
||||
const state = new Reduxer({
|
||||
replyId: null as null | string,
|
||||
opId: bucket.sender,
|
||||
}, {
|
||||
reply(_state, replyId: string | null) {
|
||||
if (replyId) {
|
||||
tick().then(() => commentBox?.scrollIntoView({ behavior: "smooth", block: "center" }));
|
||||
}
|
||||
return { replyId };
|
||||
},
|
||||
});
|
||||
|
@ -20,8 +25,8 @@
|
|||
const query = api.query({
|
||||
refs: [bucket.id],
|
||||
relations: [["l.forum.comment", "comment"]],
|
||||
}, true);
|
||||
const comments = collectEvents(query, event => event.type === "l.forum.comment");
|
||||
});
|
||||
const { events: comments, stop } = watch(query, event => event.type === "l.forum.comment");
|
||||
$: topLevelComments = $comments
|
||||
.filter(comment => filterRels(comment, "comment").indexOf(bucket.id) !== -1)
|
||||
.sort((a, b) => a.origin_ts - b.origin_ts);
|
||||
|
@ -29,42 +34,41 @@
|
|||
let commentBody: string;
|
||||
|
||||
async function handleComment(e) {
|
||||
const event = await api.makeEvent("l.forum.comment", {
|
||||
await api.createEvent("l.forum.comment", {
|
||||
body: commentBody || undefined,
|
||||
}, {
|
||||
[$state.replyId ?? bucket.id]: { type: "comment" },
|
||||
});
|
||||
commentBody = "";
|
||||
state.do("reply", null);
|
||||
await api.upload(event);
|
||||
}
|
||||
|
||||
onDestroy(query.stop);
|
||||
onDestroy(stop);
|
||||
</script>
|
||||
<div class="wrapper">
|
||||
<div>
|
||||
<a href="/#/{forumId}">back</a>
|
||||
</div>
|
||||
<div class="post">
|
||||
<article class="post">
|
||||
<h1 style="font-weight: bold">{bucket.content.title || "no title"}</h1>
|
||||
<p>{bucket.content.body || "no body"}</p>
|
||||
</div>
|
||||
</article>
|
||||
<hr />
|
||||
<ul class="comments">
|
||||
{#each topLevelComments as event (event.id)}
|
||||
<li><Comments {state} comments={$comments} {event} /></li>
|
||||
<li class="toplevel"><Comments {state} comments={$comments} {event} /></li>
|
||||
{:else}
|
||||
<em>no comments</em>
|
||||
{/each}
|
||||
</ul>
|
||||
<hr />
|
||||
<form on:submit|preventDefault={handleComment}>
|
||||
<form on:submit|preventDefault={handleComment} bind:this={commentBox}>
|
||||
<table>
|
||||
<tr><td><em>new comment</em></td></tr>
|
||||
{#if $state.replyId}
|
||||
<tr><td>reply:</td><td><button on:click={state.curry("reply", null)}>deselect</button></td></tr>
|
||||
{/if}
|
||||
<tr><td>comment:</td><td><textarea bind:value={commentBody}></textarea></td></tr>
|
||||
<tr><td>comment:</td><td><textarea bind:value={commentBody} placeholder="say something nice"></textarea></td></tr>
|
||||
<tr><td></td><td><input type="submit" value="post"></td></tr>
|
||||
</table>
|
||||
</form>
|
||||
|
@ -79,7 +83,12 @@
|
|||
}
|
||||
|
||||
.comments {
|
||||
margin-left: 1em;
|
||||
list-style: none;
|
||||
margin-left: 0;
|
||||
|
||||
& > .toplevel {
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { api } from "../lib/api";
|
||||
import { timeAgo, collectEvents } from "../lib/util";
|
||||
import { timeAgo, watch } from "../lib/util";
|
||||
import type { Event } from "../lib/api";
|
||||
export let options = new URLSearchParams();
|
||||
export let bucket: Event;
|
||||
|
@ -25,8 +25,7 @@
|
|||
e.preventDefault();
|
||||
const body = searchBody;
|
||||
searchBody = "";
|
||||
const event = await api.makeEvent("l.note", { body }, { [bucket.id]: { type: "in" } });
|
||||
await api.upload(event);
|
||||
await api.createEvent("l.note", { body }, { [bucket.id]: { type: "in" } });
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
|
@ -35,8 +34,7 @@
|
|||
}
|
||||
|
||||
async function removeNote(id: string) {
|
||||
const event = await api.makeEvent("x.redact", {}, { [id]: { type: "redact" }});
|
||||
await api.upload(event);
|
||||
await api.createEvent("x.redact", {}, { [id]: { type: "redact" }});
|
||||
}
|
||||
|
||||
function loadNotes(options: URLSearchParams) {
|
||||
|
@ -45,11 +43,8 @@
|
|||
refs: [bucket.id],
|
||||
tags: tags.length ? tags : undefined,
|
||||
relations: [["x.redact", "redact"], ["l.note", "in"]],
|
||||
}, true);
|
||||
return {
|
||||
events: collectEvents(query, ev => ev.type === "l.note"),
|
||||
stop: query.stop,
|
||||
};
|
||||
});
|
||||
return watch(query, ev => ev.type === "l.note");
|
||||
}
|
||||
|
||||
onDestroy(() => stop());
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { api, base64, ed25519 } from "../lib/api";
|
||||
import { api, base64 } from "../lib/api";
|
||||
</script>
|
||||
<table>
|
||||
<tr>
|
||||
|
@ -27,11 +27,11 @@
|
|||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={ed25519.etc.bytesToHex(api.key)}
|
||||
value={ed25519.etc.bytesToHex(api.secretKey)}
|
||||
on:input={(e) => {
|
||||
const newKey = e.target.value;
|
||||
if (newKey.length !== 64) return;
|
||||
api.key = ed25519.etc.hexToBytes(newKey);
|
||||
api.secretKey = ed25519.etc.hexToBytes(newKey);
|
||||
localStorage.setItem("key", newKey);
|
||||
}}
|
||||
/>
|
||||
|
@ -39,13 +39,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td>User id:</td>
|
||||
<td>
|
||||
{#await ed25519.getPublicKeyAsync(api.key)}
|
||||
<input type="text" readonly value="loading..." />
|
||||
{:then pubkey}
|
||||
<input type="text" readonly value={"%" + base64.encode(pubkey)} />
|
||||
{/await}
|
||||
</td>
|
||||
<td><input type="text" readonly value={"%" + base64.encode(api.publicKey)} /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<style lang="scss">
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { api, ed25519, base64 } from "../lib/api";
|
||||
import { api, base64 } from "../lib/api";
|
||||
import { state } from "../state";
|
||||
</script>
|
||||
<div class="wrapper">
|
||||
stuff here soon...
|
||||
{#await ed25519.getPublicKeyAsync(api.key) then pubkey}
|
||||
<div class="userid">user id: <code>{"%" + base64.encode(pubkey)}</code></div>
|
||||
{/await}
|
||||
<div class="userid">user id: <code>{"%" + base64.encode(api.publicKey)}</code></div>
|
||||
<div>
|
||||
<button on:click={state.curry($state.sidebar ? "sidebar/close" : "sidebar/open")}>sidebar</button>
|
||||
<button on:click={state.curry($state.pins ? "pins/close" : "pins/open")}>pins</button>
|
||||
|
@ -27,7 +25,7 @@
|
|||
text-overflow: ellipsis;
|
||||
|
||||
& > code {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
user-select: all;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,13 +17,15 @@
|
|||
--fg-dimmed: #7f879b;
|
||||
--color-accent: #b18cf3;
|
||||
--borders: #222a30;
|
||||
--font-normal: sans-serif;
|
||||
--font-mono: monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
color: var(--fg-text);
|
||||
background: var(--bg-tertiary);
|
||||
font: 16px/1.3 sans-serif;
|
||||
font: 16px/1.3 var(--font-normal);
|
||||
}
|
||||
|
||||
h1 { font-size: 2em }
|
||||
|
@ -60,7 +62,7 @@ ol, ul {
|
|||
}
|
||||
|
||||
pre, code {
|
||||
font-family: monospace;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
&:not(.lang-mono) {
|
||||
background: var(--bg-secondary);
|
||||
|
|
Loading…
Reference in a new issue