x.user, ui changes

This commit is contained in:
tezlm 2023-08-12 09:46:52 -07:00
parent 9afa466887
commit ad7d812590
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
39 changed files with 792 additions and 1124 deletions

158
Cargo.lock generated
View file

@ -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"

View file

@ -1,3 +1,3 @@
[workspace]
resolver = "2"
members = ["store-fs", "server", "lib", "cli", "peer"]
members = ["store-*", "server", "lib", "cli"]

View file

@ -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 };

View file

@ -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

View file

@ -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 {

View file

@ -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"] }

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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?;
}

View file

@ -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
}

View 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!()
}

View file

@ -3,4 +3,5 @@ pub mod p2p;
pub mod search;
pub mod shares;
pub mod things;
pub mod actors;
pub mod util;

View file

@ -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(

View file

@ -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

View file

@ -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)),

View file

@ -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>;
}

View file

@ -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 {

View file

@ -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;

View file

@ -102,7 +102,7 @@
& > .time {
flex: 1;
padding: 2px;
font-family: monospace;
font-family: var(--font-mono);
white-space: nowrap;
font-size: 0.8rem;

View file

@ -307,7 +307,7 @@
& > .time {
flex: 1;
padding: 2px;
font-family: monospace;
font-family: var(--font-mono);
white-space: nowrap;
font-size: 0.8rem;

View file

@ -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() }
}

View file

@ -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 });

View file

@ -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>;

View file

@ -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}`;
}

View file

@ -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";
}

View file

@ -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);

View file

@ -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>

View file

@ -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} />

View file

@ -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 });
}

View file

@ -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());

View file

@ -75,7 +75,7 @@
}
.item .size {
font-family: monospace;
font-family: var(--font-mono);
}
.tooltip {

View file

@ -111,7 +111,7 @@
max-width: 80vw;
max-height: 80vh;
overflow: auto;
font-family: monospace;
font-family: var(--font-mono);
padding: 8px;
}

View file

@ -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>

View file

@ -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 {

View file

@ -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 {

View file

@ -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());

View file

@ -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">

View file

@ -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;
}
}

View file

@ -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);