fix x.redact relation and add file list

This commit is contained in:
tezlm 2023-07-14 09:05:15 -07:00
parent b959b9430b
commit 490834eb4d
Signed by: tezlm
GPG key ID: 649733FCD94AFBBA
25 changed files with 507 additions and 89 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

View file

@ -0,0 +1,2 @@
import Forum from "./Forum.svelte";
export default Forum;

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B