fix x.redact relation and add file list
This commit is contained in:
parent
b959b9430b
commit
490834eb4d
25 changed files with 507 additions and 89 deletions
|
@ -58,7 +58,7 @@ pub enum Action {
|
|||
},
|
||||
/// get info about a ref
|
||||
Info {
|
||||
#[arg(short, long, help = "what relations to fetch")]
|
||||
#[arg(short, long, help = "what relations to fetch (in the form reltype/event.type")]
|
||||
rels: Vec<String>,
|
||||
#[arg(name = "ref")]
|
||||
item_ref: ItemRef,
|
||||
|
|
|
@ -9,6 +9,7 @@ use crate::derived::Derived;
|
|||
// TODO (future, maybe?): also come up with a better name than ufh
|
||||
|
||||
/// your average event
|
||||
// TODO: split out events with and without ids, its pretty annoying `.expect` or `.unwrap`ping everything
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct Event {
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
use std::collections::HashSet;
|
||||
use std::collections::{HashSet, HashMap};
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::item::ItemRef;
|
||||
use crate::actor::ActorId;
|
||||
use crate::event::Event;
|
||||
use crate::event::{Event, RelInfo};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
pub struct Query {
|
||||
|
@ -15,7 +15,7 @@ pub struct Query {
|
|||
|
||||
// after filtering, relations are fetched
|
||||
//
|
||||
// Relations fetch queries are defined in (rel_type, dest_event_type)
|
||||
// Relations fetch queries are defined in (rel_type, source_event_type)
|
||||
// pairs
|
||||
//
|
||||
// eg. if you have a `l.url` event relating to an `l.rss` feed via a
|
||||
|
@ -25,8 +25,17 @@ pub struct Query {
|
|||
pub relations: HashSet<(String, String)>,
|
||||
}
|
||||
|
||||
type Relations = HashMap<ItemRef, (Event, RelInfo)>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MatchType {
|
||||
Event,
|
||||
Relation,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
pub fn matches(&self, event: &Event) -> bool {
|
||||
pub fn matches_relationless(&self, event: &Event) -> bool {
|
||||
let event_id = &event.id.as_ref().expect("all events have ids");
|
||||
|
||||
let bad_ref = self.refs.as_ref().is_some_and(|s| !s.contains(event_id));
|
||||
|
@ -36,9 +45,34 @@ impl Query {
|
|||
|
||||
!(bad_ref || bad_sender || bad_type || bad_tags)
|
||||
}
|
||||
|
||||
pub fn matches_relation(&self, source: &Event, target: &Event, rel_info: &RelInfo) -> bool {
|
||||
for (rel_type, source_type) in &self.relations {
|
||||
if &rel_info.rel_type == rel_type && source.content.get_type() == source_type && self.matches_relationless(target) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub fn matches(&self, event: &Event, relations: &Relations) -> MatchType {
|
||||
if self.matches_relationless(event) {
|
||||
return MatchType::Event;
|
||||
}
|
||||
|
||||
for (target, rel_info) in relations.values() {
|
||||
if self.matches_relation(event, target, rel_info) {
|
||||
return MatchType::Relation;
|
||||
}
|
||||
}
|
||||
|
||||
MatchType::None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
// TODO: fix tests
|
||||
#[cfg(off)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ pub trait Database {
|
|||
|
||||
// thumbnails
|
||||
// async fn thumbnail_create(&self, item_ref: &ItemRef, size: &ThumbnailSize, bytes: &[u8]) -> Result<(), Self::Error>;
|
||||
// async fn thumbnail_get(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<(), Self::Error>;
|
||||
// async fn thumbnail_get(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<Option<()>, Self::Error>;
|
||||
// async fn thumbnail_delete(&self, item_ref: &ItemRef, size: &ThumbnailSize) -> Result<(), Self::Error>;
|
||||
|
||||
// shares
|
||||
|
|
|
@ -32,6 +32,28 @@ impl Sqlite {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
extern "pseudocode" fn is_event_visible(event: &Event, user: &ActorId) -> bool {
|
||||
if event.sender == user {
|
||||
return true;
|
||||
}
|
||||
|
||||
let acl = state.db.query(Query { refs: [event.id], rels: [("acl", "x.acl")] });
|
||||
if let Some(acl) = acl {
|
||||
return acl.users.contains(user) || acl.admins.contains(user);
|
||||
}
|
||||
|
||||
let acls_that_affected_event_sending = state.db.lookup_acls(&event).await?;
|
||||
for acl in acls_that_affected_event_sending {
|
||||
if acl.users.contains(user) || acl.admins.contains(user) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
*/
|
||||
|
||||
impl Database for Sqlite {
|
||||
type Error = Error;
|
||||
|
||||
|
@ -83,8 +105,7 @@ impl Database for Sqlite {
|
|||
let item_ref_str = item_ref.to_string();
|
||||
let row = sql!("SELECT * FROM events WHERE ref = ?", item_ref_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|_| Error::Validation("event item doesn't exist"))?;
|
||||
.await?;
|
||||
Ok(serde_json::from_str(&row.json)?)
|
||||
}
|
||||
|
||||
|
@ -126,10 +147,11 @@ impl Database for Sqlite {
|
|||
}
|
||||
builder.push(")");
|
||||
}
|
||||
builder.push(" ORDER BY rowid LIMIT ").push_bind(limit.unwrap_or(50).clamp(0, 100));
|
||||
let limit = limit.unwrap_or(50).clamp(0, 100);
|
||||
builder.push(" ORDER BY rowid LIMIT ").push_bind(limit);
|
||||
|
||||
let mut rows = builder.build().fetch(&self.pool);
|
||||
let mut events = Vec::new();
|
||||
let mut events = Vec::with_capacity(limit as usize);
|
||||
let mut last_after = None;
|
||||
while let Some(row) = rows.try_next().await? {
|
||||
let rowid: u32 = row.get("rowid");
|
||||
|
@ -192,7 +214,7 @@ impl Database for Sqlite {
|
|||
if records.len() != item_refs.len() {
|
||||
return Err(sqlx::Error::RowNotFound.into());
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
let mut items = Vec::with_capacity(records.len());
|
||||
for record in records {
|
||||
let item_ref_str = record.get("item_ref");
|
||||
let item_ref = ItemRef::from_str(item_ref_str)?;
|
||||
|
|
|
@ -12,6 +12,7 @@ pub enum Error {
|
|||
#[error("{1}")] Http(StatusCode, String),
|
||||
#[error("{0}")] Validation(&'static str),
|
||||
#[error("{0}")] ItemRefParse(ufh::item::ItemRefParseError),
|
||||
#[error("{0}")] RecvError(tokio::sync::broadcast::error::RecvError),
|
||||
// #[error("{0}")] Unknown(Box<dyn std::error::Error>),
|
||||
}
|
||||
|
||||
|
@ -40,8 +41,14 @@ impl From<image::ImageError> for Error {
|
|||
}
|
||||
|
||||
impl From<ufh::item::ItemRefParseError> for Error {
|
||||
fn from(value: ufh::item::ItemRefParseError) -> Self {
|
||||
Error::ItemRefParse(value)
|
||||
fn from(err: ufh::item::ItemRefParseError) -> Self {
|
||||
Error::ItemRefParse(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio::sync::broadcast::error::RecvError> for Error {
|
||||
fn from(err: tokio::sync::broadcast::error::RecvError) -> Self {
|
||||
Error::RecvError(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,6 +66,7 @@ impl IntoResponse for Error {
|
|||
Error::Image(_) => StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||
Error::Validation(_) => StatusCode::BAD_REQUEST,
|
||||
Error::ItemRefParse(_) => StatusCode::BAD_REQUEST,
|
||||
Error::RecvError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Error::Http(status, _) => status,
|
||||
};
|
||||
(status, Json(json!({ "error": self.to_string() }))).into_response()
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#![feature(const_option, async_fn_in_trait)] // ahh yes
|
||||
#![feature(const_option, async_fn_in_trait)] // ahh yes, experimental features
|
||||
|
||||
use axum::body::HttpBody;
|
||||
use axum::extract::Json;
|
||||
|
@ -8,6 +8,7 @@ use axum::response::IntoResponse;
|
|||
use axum::{Router, Server, routing, TypedHeader};
|
||||
use tower_http::{cors::CorsLayer, trace::TraceLayer};
|
||||
use serde::Serialize;
|
||||
use ufh::item::ItemRef;
|
||||
use ufh::query::Query;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Bound;
|
||||
|
@ -23,18 +24,20 @@ mod db;
|
|||
mod routes;
|
||||
|
||||
pub(crate) use error::Error;
|
||||
use ufh::event::Event;
|
||||
use ufh::event::{Event, RelInfo};
|
||||
|
||||
const MAX_SIZE: u64 = 1024 * 1024;
|
||||
|
||||
// TODO (future): maybe use a websocket instead of long polling?
|
||||
// for receiving new events, would potentially be a lot more complicated though
|
||||
|
||||
type Relations = HashMap<ItemRef, (Event, RelInfo)>;
|
||||
|
||||
pub struct ServerState {
|
||||
db: db::sqlite::Sqlite,
|
||||
sqlite: SqlitePool,
|
||||
queries: RwLock<HashMap<String, Query>>, // maybe move this to state?
|
||||
events: broadcast::Sender<Event>,
|
||||
events: broadcast::Sender<(Event, Relations)>,
|
||||
}
|
||||
|
||||
async fn csp_middleware<B>(request: Request<B>, next: Next<B>) -> axum::response::Response {
|
||||
|
|
|
@ -5,14 +5,14 @@ use crate::db::{Database, DbItem};
|
|||
use crate::derive::{derive_file, derive_media};
|
||||
use reqwest::StatusCode;
|
||||
use ufh::item::ItemRef;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use bytes::Bytes;
|
||||
use sqlx::query as sql;
|
||||
|
||||
pub(crate) use crate::error::Error;
|
||||
use ufh::event::{self, Event, EventContent};
|
||||
use ufh::event::{self, Event, EventContent, RelInfo};
|
||||
|
||||
use crate::ServerState;
|
||||
use crate::MAX_SIZE;
|
||||
|
@ -45,7 +45,6 @@ pub async fn route(
|
|||
}
|
||||
|
||||
let mut chunks: Vec<bytes::Bytes> = Vec::new();
|
||||
chunks.push(Bytes::new());
|
||||
while let Some(maybe_chunk) = body.data().await {
|
||||
let chunk = maybe_chunk.map_err(|_| Error::Validation("(connection dropped?)"))?;
|
||||
chunks.push(chunk);
|
||||
|
@ -79,6 +78,19 @@ pub async fn route(
|
|||
// upload event and get ref
|
||||
let item_ref = blobs::put(&blobs::Item::Event(event.clone())).await?;
|
||||
event.id = Some(item_ref.clone());
|
||||
|
||||
// get/validate relations
|
||||
let rel_ids: Vec<_> = event.relations.keys().cloned().collect();
|
||||
let rel_events = state.db.bulk_fetch(&rel_ids).await?;
|
||||
let relations: HashMap<ItemRef, (Event, RelInfo)> = rel_events.into_iter()
|
||||
.zip(event.relations.values())
|
||||
.map(|((item_ref, item), rel_info)| {
|
||||
match item {
|
||||
DbItem::Event(event) => Ok((item_ref, (*event, rel_info.clone()))),
|
||||
DbItem::Blob => Err(Error::Validation("some relations are to blobs instead of events")),
|
||||
}
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
|
||||
// handle special events
|
||||
match &event.content {
|
||||
|
@ -97,20 +109,21 @@ pub async fn route(
|
|||
if event.relations.is_empty() {
|
||||
return Err(Error::Validation("missing any relations"))?; // soft error
|
||||
}
|
||||
for (item_ref, info) in &event.relations {
|
||||
if info.key.is_some() {
|
||||
for (rel_ref, (rel_event, rel_info)) in &relations {
|
||||
if rel_info.key.is_some() {
|
||||
return Err(Error::Validation("redaction relation cannot have key"));
|
||||
}
|
||||
if info.rel_type != "redact" {
|
||||
|
||||
if rel_info.rel_type != "redact" {
|
||||
return Err(Error::Validation("redaction relation must be \"redact\""));
|
||||
}
|
||||
|
||||
let target_ev = state.db.get_event(item_ref).await?;
|
||||
if event.sender != target_ev.sender {
|
||||
if event.sender != rel_event.sender {
|
||||
return Err(Error::Validation("you currently cannot redact someone else's event"));
|
||||
}
|
||||
|
||||
state.db.redact_event(item_ref).await?;
|
||||
|
||||
state.db.redact_event(rel_ref).await?;
|
||||
|
||||
// TODO: garbage collect unreferenced blobs
|
||||
}
|
||||
},
|
||||
|
@ -120,7 +133,7 @@ pub async fn route(
|
|||
return Err(Error::Validation("missing relations")); // soft fail
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut items = Vec::with_capacity(event.relations.len());
|
||||
for (item_ref, rel_info) in &event.relations {
|
||||
if rel_info.rel_type != "tag" || rel_info.key.is_some() {
|
||||
return Err(Error::Validation("invalid relation"));
|
||||
|
@ -188,8 +201,8 @@ pub async fn route(
|
|||
|
||||
state.db.create_event(&event).await?;
|
||||
|
||||
let _ = state.events.send(event);
|
||||
|
||||
let _ = state.events.send((event.clone(), relations));
|
||||
|
||||
item_ref
|
||||
} else {
|
||||
let item_ref = blobs::put(&blobs::Item::Blob(blob)).await?;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use axum::extract::{State, Query};
|
||||
use ufh::event::Event;
|
||||
use ufh::{event::Event, query::MatchType};
|
||||
use crate::ServerState;
|
||||
use crate::db::Database;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
@ -58,17 +58,43 @@ pub async fn route(
|
|||
let mut recv = state.events.subscribe();
|
||||
loop {
|
||||
break match recv.recv().await {
|
||||
Err(err) => Err(err),
|
||||
Ok(event) if query.matches(&event) => Ok(event),
|
||||
Ok(_) => continue,
|
||||
Err(err) => Err(err.into()),
|
||||
Ok((event, relations)) => match query.matches(&event, &relations) {
|
||||
mt @ MatchType::Event => Ok((mt, event)),
|
||||
mt @ MatchType::Relation => Ok((mt, event)),
|
||||
MatchType::None => continue,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// NOTE: this might not be correct - last time i tried this, some things broke
|
||||
// however, this saves a db query and makes x.redact work
|
||||
// TODO: test!
|
||||
let timeout = Duration::from_millis(timeout);
|
||||
match tokio::time::timeout(timeout, next_event).await {
|
||||
Ok(_) => state.db.query_events(&query, params.limit, params.after.map(|p| p.to_string())).await?,
|
||||
_ => result,
|
||||
Ok(Ok(result)) => match result {
|
||||
(MatchType::Event, event) => {
|
||||
return Ok(QueryResult {
|
||||
events: vec![event],
|
||||
relations: None,
|
||||
next: params.after.map(|i| (i + 1).to_string()),
|
||||
});
|
||||
// state.db.query_events(&query, params.limit, params.after.map(|p| p.to_string())).await?
|
||||
},
|
||||
(MatchType::Relation, event) => {
|
||||
return Ok(QueryResult {
|
||||
events: Vec::new(),
|
||||
relations: Some(HashMap::from([
|
||||
(event.id.clone().unwrap(), event),
|
||||
])),
|
||||
next: params.after.map(|i| (i + 1).to_string()),
|
||||
});
|
||||
},
|
||||
(MatchType::None, _) => unreachable!("handled by next_event(...)"),
|
||||
},
|
||||
Ok(Err(err)) => return Err(err),
|
||||
Err(_) => result,
|
||||
}
|
||||
},
|
||||
_ => result,
|
||||
|
|
|
@ -97,7 +97,7 @@ pub async fn get_blob(file_event: &Event, via: Option<&str>) -> Result<Bytes, Er
|
|||
let EventContent::File(file) = &file_event.content else {
|
||||
return Err(Error::Validation("not a file event"));
|
||||
};
|
||||
let mut chunks = Vec::new();
|
||||
let mut chunks = Vec::with_capacity(file.chunks.len());
|
||||
for item_ref in &file.chunks {
|
||||
let blobs::Item::Blob(blob) = blobs::get(item_ref, via).await? else {
|
||||
// possibly unreachable!(), assuming everything validated properly on insert
|
||||
|
|
|
@ -99,3 +99,12 @@ do exist if you want to clear out stuff)
|
|||
|
||||
This also has a nice side effect of being able to time travel to any
|
||||
point in the past, if a record of all past events are kept.
|
||||
|
||||
## multihash
|
||||
|
||||
Currently item refs are defined by `hashtype-hash`,
|
||||
eg. `sha224-abcd1234`. Instead, I could use
|
||||
[multihash](https://github.com/multiformats/multihash), which might have
|
||||
more support.
|
||||
|
||||
See also: <https://github.com/jbenet/random-ideas/issues/1>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import Files from "./scenes/Files";
|
||||
import Links from "./scenes/Links.svelte";
|
||||
import Notes from "./scenes/Notes.svelte";
|
||||
import Forum from "./scenes/Forum";
|
||||
import Settings from "./scenes/Settings.svelte";
|
||||
|
||||
let selected;
|
||||
|
@ -20,6 +21,7 @@
|
|||
case "/links": return Links;
|
||||
case "/notes": return Notes;
|
||||
case "/settings": return Settings;
|
||||
case "/forum": return Forum;
|
||||
default: return Home;
|
||||
}
|
||||
}
|
||||
|
@ -44,6 +46,7 @@
|
|||
<a href="#/files">files</a>
|
||||
<a href="#/links">links</a>
|
||||
<a href="#/notes">notes</a>
|
||||
<a href="#/forum">forum</a>
|
||||
<a href="#/settings">settings</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { readable } from "svelte/store";
|
||||
import type { Event } from "./api";
|
||||
|
||||
const units = [
|
||||
const timeUnits = [
|
||||
[1000, "milisecond"],
|
||||
[60, "second"],
|
||||
[60, "minute"],
|
||||
|
@ -16,7 +16,7 @@ const units = [
|
|||
export function timeAgo(date: Date): string {
|
||||
const diff = Date.now() - date;
|
||||
let curMod = 1;
|
||||
for (const [mod, unit] of units) {
|
||||
for (const [mod, unit] of timeUnits) {
|
||||
if (diff < curMod * mod) {
|
||||
const trunc = Math.floor(diff / curMod);
|
||||
return `${trunc} ${unit}${trunc === 1 ? "" : "s"} ago`;
|
||||
|
@ -26,6 +26,17 @@ export function timeAgo(date: Date): string {
|
|||
return "long ago";
|
||||
}
|
||||
|
||||
export function formatSize(size: number): string {
|
||||
if (size < 0) return "??? kb";
|
||||
if (size === 0) return "0 kb";
|
||||
let max = 1024;
|
||||
for (let unit of ["bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]) {
|
||||
if (size < max) return `${Math.floor(size / (max / 102400)) / 100} ${unit}`;
|
||||
max *= 1024;
|
||||
}
|
||||
return "very big";
|
||||
}
|
||||
|
||||
export async function asyncIterArray(iter) {
|
||||
const arr = [];
|
||||
for await (const item of iter) arr.push(item);
|
||||
|
|
|
@ -14,6 +14,8 @@
|
|||
"dependencies": {
|
||||
"@noble/ed25519": "^2.0.0",
|
||||
"canonicalize": "^2.0.0",
|
||||
"events": "^3.3.0",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"uint8-to-base64": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -11,6 +11,12 @@ dependencies:
|
|||
canonicalize:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
events:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
typed-emitter:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
uint8-to-base64:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
|
@ -519,6 +525,11 @@ packages:
|
|||
'@types/estree': 1.0.1
|
||||
dev: true
|
||||
|
||||
/events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
dev: false
|
||||
|
||||
/fast-glob@3.3.0:
|
||||
resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
@ -794,6 +805,14 @@ packages:
|
|||
queue-microtask: 1.2.3
|
||||
dev: true
|
||||
|
||||
/rxjs@7.8.1:
|
||||
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
tslib: 2.6.0
|
||||
dev: false
|
||||
optional: true
|
||||
|
||||
/sade@1.8.1:
|
||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
@ -943,7 +962,12 @@ packages:
|
|||
|
||||
/tslib@2.6.0:
|
||||
resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==}
|
||||
dev: true
|
||||
|
||||
/typed-emitter@2.1.0:
|
||||
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
|
||||
optionalDependencies:
|
||||
rxjs: 7.8.1
|
||||
dev: false
|
||||
|
||||
/typescript@5.1.6:
|
||||
resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import { collectEvents } from "../../lib/util";
|
||||
import { onDestroy } from "svelte";
|
||||
import Gallery from "./Gallery.svelte";
|
||||
import List from "./List.svelte";
|
||||
import Popup from "./Popup.svelte";
|
||||
|
||||
export let options: URLSearchParams;
|
||||
|
@ -43,7 +44,11 @@
|
|||
|
||||
function loadImages(options: URLSearchParams) {
|
||||
const tags = options.getAll("tag");
|
||||
const query = api.query({ types: ["x.file", "x.redact"], ...(tags.length ? { tags } : null) }, true);
|
||||
const query = api.query({
|
||||
types: ["x.file"],
|
||||
tags: tags.length ? tags : null,
|
||||
relations: [["redact", "x.redact"]],
|
||||
}, true);
|
||||
return {
|
||||
events: collectEvents(query.events),
|
||||
stop: query.stop,
|
||||
|
@ -83,6 +88,8 @@
|
|||
}
|
||||
return event.content.name?.includes(search);
|
||||
});
|
||||
|
||||
let viewAs = "gallery";
|
||||
</script>
|
||||
<div>
|
||||
<!-- TODO: option to view as list instead of gallery -->
|
||||
|
@ -109,8 +116,14 @@
|
|||
<option>audio</option>
|
||||
<option>text</option>
|
||||
</select>
|
||||
- view as:
|
||||
<button on:click={() => viewAs = (viewAs === "gallery" ? "list" : "gallery")}>{viewAs}</button>
|
||||
</div>
|
||||
<Gallery items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
|
||||
{#if viewAs === "gallery"}
|
||||
<Gallery items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
|
||||
{:else if viewAs === "list"}
|
||||
<List items={filtered.map(i => i.event)} showPopup={(event) => popup = event} />
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup event={popup} close={() => popup = null}/>
|
||||
{/if}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { quadOut } from "svelte/easing";
|
||||
import Img from "../../atoms/Img.svelte";
|
||||
|
||||
export let items;
|
||||
|
@ -20,7 +19,6 @@
|
|||
}
|
||||
</script>
|
||||
<div>
|
||||
<!-- TODO (qol+future): syntax highlighting for source file -->
|
||||
<div
|
||||
class="gallery"
|
||||
on:mouseover={handleMouseover}
|
||||
|
|
94
web/scenes/Files/List.svelte
Normal file
94
web/scenes/Files/List.svelte
Normal file
|
@ -0,0 +1,94 @@
|
|||
<script lang="ts">
|
||||
import { api } from "../../lib/api";
|
||||
import { timeAgo, formatSize } from "../../lib/util";
|
||||
|
||||
export let items;
|
||||
export let showPopup;
|
||||
|
||||
let tooltip = { x: 0, y: 0, text: null };
|
||||
|
||||
function handleMouseover(e: MouseEvent) {
|
||||
tooltip = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: e.target.alt || e.target.dataset.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
function handleMouseout(e: MouseEvent) {
|
||||
tooltip.text = null;
|
||||
}
|
||||
</script>
|
||||
<div>
|
||||
<table class="items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="name">name</th>
|
||||
<th>size</th>
|
||||
<th>type</th>
|
||||
<th>date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each items as event (event.id)}
|
||||
<tr class="item">
|
||||
<td class="name"><a href="#" on:click|preventDefault={() => showPopup(event)}>{event.content.name}</a></td>
|
||||
<td class="size">{formatSize(event.derived.file.size)}</td>
|
||||
<td>{event.derived.file.mime}</td>
|
||||
<td>{timeAgo(event.origin_ts)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<ul
|
||||
on:mouseover={handleMouseover}
|
||||
on:mousemove={handleMouseover}
|
||||
on:mouseout={handleMouseout}
|
||||
>
|
||||
</ul>
|
||||
<div
|
||||
class="tooltip"
|
||||
style:display={tooltip.text ? "block" : "none"}
|
||||
style:translate="{tooltip.x}px {tooltip.y}px"
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.items {
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.item .name, th.name {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.item .size {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: var(--bg-primary);
|
||||
border: solid var(--borders) 1px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
pointer-events: none;
|
||||
animation: hover 1.5s ease-in-out infinite;
|
||||
transform-origin: top left;
|
||||
box-shadow: #00000055 4px 4px 4px;
|
||||
}
|
||||
</style>
|
|
@ -26,6 +26,7 @@
|
|||
};
|
||||
}
|
||||
</script>
|
||||
<!-- TODO (qol+future): syntax highlighting for source file -->
|
||||
<div class="popup" on:click={close} transition:opacity>
|
||||
<div on:click|stopPropagation transition:pop role="dialog">
|
||||
<div style="border-radius: 4px; overflow: hidden;">
|
||||
|
@ -46,49 +47,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
margin: 0 auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background-color: #2a2a33;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.item .name {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
background-color: #000000aa;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: var(--bg-primary);
|
||||
border: solid var(--borders) 1px;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
pointer-events: none;
|
||||
animation: hover 1.5s ease-in-out infinite;
|
||||
transform-origin: top left;
|
||||
box-shadow: #00000055 4px 4px 4px;
|
||||
}
|
||||
|
||||
.popup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
|
29
web/scenes/Forum/Comments.svelte
Normal file
29
web/scenes/Forum/Comments.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { events } from "./events";
|
||||
export let root;
|
||||
const context = getContext("comment");
|
||||
const { selected } = context;
|
||||
</script>
|
||||
<div
|
||||
on:click={() => events.emit("selectComment", ($selected === root.event.id) ? null : root.event)}
|
||||
class:selected={$selected === root.event.id}
|
||||
>
|
||||
{root.event.content.body}
|
||||
</div>
|
||||
{#if root.children.length}
|
||||
<ul>
|
||||
{#each root.children as child}
|
||||
<li><svelte:self root={child} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<style>
|
||||
.selected {
|
||||
background: #ffffff22;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-left: 1em;
|
||||
}
|
||||
</style>
|
154
web/scenes/Forum/Forum.svelte
Normal file
154
web/scenes/Forum/Forum.svelte
Normal file
|
@ -0,0 +1,154 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy, setContext } from "svelte";
|
||||
import { readable, writable } from "svelte/store";
|
||||
import { api } from "../../lib/api";
|
||||
import { timeAgo, collectEvents } from "../../lib/util";
|
||||
import { events } from "./events";
|
||||
import Comments from "./Comments.svelte";
|
||||
export let options: URLSearchParams;
|
||||
let { events: postEvents, stop: stopPosts } = loadPosts();
|
||||
let commentTree, stopComments;
|
||||
$: if (viewedPost) {
|
||||
let load = loadComments(viewedPost);
|
||||
commentTree = load.tree;
|
||||
stopComments = load.stop;
|
||||
}
|
||||
|
||||
// let searchBodyEl;
|
||||
// let searchBody = "";
|
||||
|
||||
// $: search = searchBody?.toLocaleLowerCase() ?? "";
|
||||
// $: filtered = $events.filter(ev => ev.content.body.toLocaleLowerCase().includes(search));
|
||||
|
||||
function loadPosts() {
|
||||
const query = api.query({ types: ["l.forum.post"] }, true);
|
||||
return {
|
||||
events: collectEvents(query.events),
|
||||
stop: query.stop,
|
||||
};
|
||||
}
|
||||
|
||||
function loadComments(event) {
|
||||
// TODO: stream relations?
|
||||
const query = api.query({ refs: [event.id], relations: [["comment", "l.forum.comment"]] });
|
||||
let update;
|
||||
const root = { event, children: [] };
|
||||
const treeIndex = new Map();
|
||||
const treeCollector = readable(root, (set) => { update = (val) => set(val) });
|
||||
treeIndex.set(event.id, root);
|
||||
|
||||
(async () => {
|
||||
for await (let _ of query.events); // load everything
|
||||
for (let event of query.relations.values()) {
|
||||
// TODO: circular comment check
|
||||
let item;
|
||||
if (treeIndex.has(event.id)) {
|
||||
item = treeIndex.get(event.id);
|
||||
item.event = event;
|
||||
} else {
|
||||
item = { event, children: [] };
|
||||
treeIndex.set(event.id, item);
|
||||
}
|
||||
const hasReply = !!Object.values(event.relations).find(i => i.type === "reply");
|
||||
for (let rel in event.relations) {
|
||||
if (event.relations[rel].type === "comment" && hasReply) continue;
|
||||
if (treeIndex.has(rel)) {
|
||||
treeIndex.get(rel).children.push(item);
|
||||
} else {
|
||||
treeIndex.set(rel, { event: null, children: [item] });
|
||||
}
|
||||
// console.log(treeIndex, root, rel, item);
|
||||
}
|
||||
}
|
||||
update(root);
|
||||
})();
|
||||
|
||||
return {
|
||||
tree: treeCollector,
|
||||
stop: query.stop,
|
||||
};
|
||||
}
|
||||
|
||||
let viewedPost;
|
||||
let submitTitle, submitBody;
|
||||
let commentBody, commentReply;
|
||||
|
||||
async function handlePost(e) {
|
||||
const event = await api.makeEvent("l.forum.post", {
|
||||
title: submitTitle || undefined,
|
||||
body: submitBody || undefined,
|
||||
});
|
||||
submitTitle = submitBody = "";
|
||||
await api.upload(event);
|
||||
}
|
||||
|
||||
async function handleComment(e) {
|
||||
const event = await api.makeEvent("l.forum.comment", {
|
||||
body: commentBody || undefined,
|
||||
}, {
|
||||
[viewedPost.id]: { type: "comment" },
|
||||
...($selected && { [$selected]: { type: "reply" } }),
|
||||
});
|
||||
commentBody = "";
|
||||
await api.upload(event);
|
||||
}
|
||||
|
||||
let selected = writable(null);
|
||||
|
||||
function handleSelect(event) {
|
||||
if (event) {
|
||||
$selected = event.id;
|
||||
} else {
|
||||
$selected = null;
|
||||
}
|
||||
}
|
||||
|
||||
setContext("comment", { selected });
|
||||
|
||||
events.on("selectComment", handleSelect);
|
||||
|
||||
onDestroy(() => stopPosts());
|
||||
onDestroy(() => stopComments?.());
|
||||
onDestroy(() => events.off("selectComment", handleSelect));
|
||||
</script>
|
||||
<div>
|
||||
{#if viewedPost}
|
||||
<details open>
|
||||
<summary>new comment</summary>
|
||||
<form on:submit|preventDefault={handleComment}>
|
||||
body: <textarea bind:value={commentBody} ></textarea><br />
|
||||
<input type="submit" value="post">
|
||||
</form>
|
||||
</details>
|
||||
<button on:click={() => viewedPost = ""}>back</button>
|
||||
<ul>
|
||||
<b style="font-weight: bold">{$commentTree.event.content.title || "no title"}</b><br />
|
||||
{$commentTree.event.content.body || "no body"}
|
||||
<hr />
|
||||
{#each $commentTree.children as item}
|
||||
<li><Comments root={item} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<details open>
|
||||
<summary>new post</summary>
|
||||
<form on:submit|preventDefault={handlePost}>
|
||||
title: <input bind:value={submitTitle} /><br />
|
||||
body: <textarea bind:value={submitBody} ></textarea><br />
|
||||
<input type="submit" value="post">
|
||||
</form>
|
||||
</details>
|
||||
<ul>
|
||||
{#each $postEvents as event}
|
||||
<li>
|
||||
<a on:click={() => viewedPost = event}>{event.content.title || "no title"}</a>
|
||||
{event.content.body || "no body"}
|
||||
<!--{event.sender}
|
||||
{event.origin_ts} -->
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
</style>
|
8
web/scenes/Forum/events.ts
Normal file
8
web/scenes/Forum/events.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import EventEmitter from "events";
|
||||
import TypedEmitter from "typed-emitter";
|
||||
|
||||
type ForumEvents = {
|
||||
selectComment: (event) => void,
|
||||
};
|
||||
|
||||
export const events = new EventEmitter() as TypedEmitter<ForumEvents>;
|
2
web/scenes/Forum/index.ts
Normal file
2
web/scenes/Forum/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
import Forum from "./Forum.svelte";
|
||||
export default Forum;
|
|
@ -25,6 +25,7 @@ h3 { font-size: 1.3em }
|
|||
a {
|
||||
text-decoration: none;
|
||||
color: var(--fg-link);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:hover, a:focus-visible {
|
||||
|
@ -39,17 +40,22 @@ input, textarea, button, select {
|
|||
padding: 0 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
button, input[type=submit] {
|
||||
color: var(--fg-link);
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover, button:focus-visible {
|
||||
button:hover, button:focus-visible,
|
||||
input[type=submit]:hover, input[type=submit]:focus-visible {
|
||||
text-decoration: underline;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#wrapper {
|
||||
display: grid;
|
||||
grid-template-areas: "header" "main";
|
||||
|
|
BIN
web/transparent.png
Normal file
BIN
web/transparent.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 593 B |
Loading…
Reference in a new issue