diff --git a/deno/ddln_server.ts b/deno/ddln_server.ts
index 2a7738b..25dc1e6 100644
--- a/deno/ddln_server.ts
+++ b/deno/ddln_server.ts
@@ -293,11 +293,12 @@ function connectWebsocket(request: Request) {
}
async function devServerWatchFiles() {
+ const parentDir = Deno.cwd().replace(/\/[^/]+$/, '');
const watcher = Deno.watchFs(["../static/", "../src/"]);
for await (const event of watcher) {
if (event.kind === "modify") {
for (const path of event.paths) {
- const cachedPath = path.replace(Deno.cwd() + '/..', '')
+ const cachedPath = path.replace(parentDir, '');
filepathResponseCache.delete(cachedPath);
console.log('Purging updated file:', cachedPath)
}
diff --git a/src/App.ts b/src/App.ts
index ac52c39..97d3f0c 100644
--- a/src/App.ts
+++ b/src/App.ts
@@ -1,8 +1,8 @@
import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync";
-import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser } from "db";
-import { arrayBufferToBase64, compressString } from "dataUtils";
+import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getRepliesForPost, buildReplyCountMap } from "db";
+import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log"
declare let marked: any;
@@ -524,8 +524,22 @@ export class App {
globalThis.URL.revokeObjectURL(url);
}
- async importPostsForUser(userID: string, posts: string) {
+ async importPostsForUser(userID: string, buffer: ArrayBuffer) {
+ console.log.apply(null, log("Importing posts"));
+ const json = await decompressBuffer(buffer);
+ const posts = JSON.parse(json);
+ for (let post of posts) {
+ if (post.image_data && typeof post.image_data === 'string') {
+ post.image_data = await base64ToArrayBuffer(post.image_data);
+ }
+ if (post.post_timestamp && typeof post.post_timestamp === 'string') {
+ post.post_timestamp = new Date(post.post_timestamp);
+ }
+ }
+
+ await mergeDataArray(userID, posts);
+ console.log.apply(null, log(`Imported ${posts.length} posts`));
}
async exportPostsForUser(userID: string) {
@@ -976,6 +990,16 @@ export class App {
let burgerMenuButton = this.div('burger-menu-button');
burgerMenuButton.addEventListener('click', e => navContainer.classList.toggle('active'));
+ let importFilePicker = document.getElementById('import-file-input') as HTMLInputElement;
+ importFilePicker?.addEventListener('change', async () => {
+ const file = importFilePicker.files?.[0];
+ if (!file) return;
+ const buffer = await file.arrayBuffer();
+ await this.importPostsForUser(this.userID, buffer);
+ importFilePicker.value = '';
+ this.render();
+ });
+
let exportButton = this.button("export-button");
exportButton.addEventListener('click', async e => {
@@ -992,7 +1016,7 @@ export class App {
filePicker?.addEventListener('change', async (event: any) => {
for (let file of filePicker.files as any) {
let buffer = await file.arrayBuffer();
- await this.createPost(this.userID, 'image...', buffer, file.type);
+ await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID);
}
// Reset so that if they pick the same image again, we still get the change event.
@@ -1051,12 +1075,21 @@ export class App {
const dataTransfer = e.clipboardData
const file = dataTransfer!.files[0];
let buffer = await file.arrayBuffer();
- await this.createPost(this.userID, 'image...', buffer, file.type as any);
+ await this.createPost(this.userID, 'image...', buffer, file.type as any, this.replyToID);
});
- postButton.addEventListener("click", () => {
+ const submitPost = () => {
this.createPost(userID, postText.value, undefined, undefined, this.replyToID);
this.exitCompose();
+ };
+
+ postButton.addEventListener("click", submitPost);
+
+ postText.addEventListener("keydown", (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ e.preventDefault();
+ submitPost();
+ }
});
// updateApp.addEventListener("click", () => {
@@ -1073,13 +1106,13 @@ export class App {
// Change this all to a template so we're not toggling state in this crazy way!
enterCompose(replyToID:string|null=null) {
+ this.replyToID = replyToID;
+
if (replyToID) {
this.renderComposeReplyArea(replyToID);
document.getElementById("compose-reply-area")!.style.display = "block";
}
- replyToID = replyToID;
-
document.getElementById('compose')!.style.display = 'block';
document.getElementById('textarea_post')?.focus();
document.getElementById('compose-dimmer')?.classList.add("compose-dimmer-dimmed");
@@ -1087,6 +1120,7 @@ export class App {
}
exitCompose() {
+ this.replyToID = null;
let postText = document.getElementById("textarea_post") as HTMLTextAreaElement;
postText.value = "";
document.getElementById('compose')!.style.display = 'none';
@@ -1280,6 +1314,8 @@ export class App {
await this.initDB();
+ window.addEventListener('popstate', () => { this.getRoute(); this.render(); });
+
this.connectURL = `${document.location.origin}/connect/${this.userID}`;
document.getElementById('connectURL')!.innerHTML = `connect`;
@@ -1464,13 +1500,19 @@ export class App {
contentDiv.innerHTML = "";
let count = 0;
+ const isPostView = this.router.route === App.Route.POST;
+ contentDiv.classList.toggle('post-view', isPostView);
+
+ const replyCountMap = await buildReplyCountMap();
+
this.renderedPosts.clear();
let first = true;
for (let i = this.posts.length - 1; i >= 0; i--) {
let postData = this.posts[i];
+ if (!isPostView && postData.data.reply_to_id) continue;
// this.postsSet.add(postData);
// TODO return promises for all image loads and await those.
- let post = this.renderPost(postData.data, first);
+ let post = this.renderPost(postData.data, first, replyCountMap.recursive.get(postData.data.post_id) ?? 0);
first = false;
// this.renderedPosts.set(postData.post_id, post);
if (post) {
@@ -1489,6 +1531,30 @@ export class App {
contentDiv.appendChild(fragment);
+ if (isPostView && this.posts.length > 0) {
+ const renderReplies = async (postID: string, depth: number) => {
+ if (depth > 2) return;
+ const replies = await getRepliesForPost(postID);
+ // Top-level replies: newest first. Nested replies: oldest first (readable order).
+ if (depth === 0) {
+ replies.sort((a, b) =>
+ new Date(b.data.post_timestamp).getTime() - new Date(a.data.post_timestamp).getTime()
+ );
+ } else {
+ replies.sort((a, b) =>
+ new Date(a.data.post_timestamp).getTime() - new Date(b.data.post_timestamp).getTime()
+ );
+ }
+ for (const reply of replies) {
+ const el = this.renderPost(reply.data, false, replyCountMap.direct.get(reply.data.post_id) ?? 0);
+ if (depth > 0) el.style.marginLeft = `${depth * 20}px`;
+ contentDiv.appendChild(el);
+ await renderReplies(reply.data.post_id, depth + 1);
+ }
+ };
+ await renderReplies(this.posts[0].data.post_id, 0);
+ }
+
let renderTime = this.timerDelta();
console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`));;
@@ -1515,7 +1581,7 @@ export class App {
composeReplyArea.classList.add("show");
}
- renderPost(post: Post, first: boolean) {
+ renderPost(post: Post, first: boolean, replyCount: number = 0) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
}
@@ -1534,7 +1600,7 @@ export class App {
await navigator.clipboard.writeText(shareUrl)
};
- let replyButton = document.createElement('button'); replyButton.innerText = 'reply';
+ let replyButton = document.createElement('button'); replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply';
replyButton.onclick = async () => {
console.log(`replying to post ${post.post_id}`);
this.enterCompose(post.post_id);
@@ -1564,26 +1630,33 @@ export class App {
let userURL = `${document.location.origin}/user/${post.author_id}/`
let postTemplate =
- `
${first ? '' : '
'}
-
-
+ `
${first ? '' : '
'}
+
+
+
+
+
${markdown}
+
+
+
+
+ ${ownPost ? `
` : ''}
+
+ ${ownPost ? `
` : ''}
-
${markdown}
-
-
-
-
- ${ownPost ? `
` : ''}
-
- ${ownPost ? `
` : ''}
-
-
`
containerDiv.innerHTML = postTemplate;
+ const postBody = containerDiv.querySelector('.post-body') as HTMLElement;
+ postBody.addEventListener('click', (e) => {
+ if ((e.target as HTMLElement).closest('button, a')) return;
+ history.pushState({}, '', `/user/${post.author_id}/post/${post.post_id}`);
+ this.getRoute();
+ this.render();
+ });
if (ownPost) {
containerDiv.querySelector('#deleteButton')?.appendChild(deleteButton);
@@ -1657,6 +1730,11 @@ export class App {
this.router.route = App.Route.USER;
}
}
+ } else {
+ this.router.route = App.Route.HOME;
+ this.router.userID = '';
+ this.router.postID = '';
+ this.router.mediaID = '';
}
console.log.apply(null, log("router: ", this.router.userID, this.router.postID, this.router.mediaID, App.Route[this.router.route]));
diff --git a/src/dataUtils.ts b/src/dataUtils.ts
index abb2f27..0ecffba 100644
--- a/src/dataUtils.ts
+++ b/src/dataUtils.ts
@@ -39,6 +39,20 @@ export async function compressString(input: string) {
return compressedArray;
}
+export async function decompressBuffer(input: ArrayBuffer): Promise
{
+ const decompressionStream = new DecompressionStream('gzip');
+ const writer = decompressionStream.writable.getWriter();
+ writer.write(new Uint8Array(input));
+ writer.close();
+ const decompressedBuffer = await new Response(decompressionStream.readable).arrayBuffer();
+ return new TextDecoder().decode(decompressedBuffer);
+}
+
+export async function base64ToArrayBuffer(base64: string): Promise {
+ const response = await fetch("data:application/octet-stream;base64," + base64);
+ return response.arrayBuffer();
+}
+
// Base58 character set
// const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
// Base58 encoding
diff --git a/src/db.ts b/src/db.ts
index d8285c1..cee95e7 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -463,6 +463,67 @@ export async function getAllIds(userID: string): Promise {
});
}
+export async function buildReplyCountMap(): Promise<{ direct: Map, recursive: Map }> {
+ const children = new Map();
+ const knownUsers = [...(await indexedDB.databases())]
+ .map(db => db.name?.replace('user_', '')).filter(Boolean) as string[];
+ for (const userID of knownUsers) {
+ try {
+ const { store } = await getDBTransactionStore(userID);
+ const index = store.index("postReplyIndex");
+ const replies: any[] = await new Promise((resolve, reject) => {
+ const req = index.getAll();
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+ for (const r of replies) {
+ const parentID = r.data.reply_to_id;
+ const replyID = r.data.post_id;
+ if (parentID && replyID) {
+ if (!children.has(parentID)) children.set(parentID, []);
+ children.get(parentID)!.push(replyID);
+ }
+ }
+ } catch (_) {}
+ }
+
+ const direct = new Map();
+ for (const [parentID, kids] of children) {
+ direct.set(parentID, kids.length);
+ }
+
+ const countDescendants = (postID: string): number => {
+ const kids = children.get(postID) ?? [];
+ return kids.reduce((sum, kid) => sum + 1 + countDescendants(kid), 0);
+ };
+
+ const recursive = new Map();
+ for (const postID of children.keys()) {
+ recursive.set(postID, countDescendants(postID));
+ }
+
+ return { direct, recursive };
+}
+
+export async function getRepliesForPost(postID: string): Promise {
+ const knownUsers = [...(await indexedDB.databases())]
+ .map(db => db.name?.replace('user_', '')).filter(Boolean) as string[];
+ let replies: any[] = [];
+ for (const userID of knownUsers) {
+ try {
+ const { store } = await getDBTransactionStore(userID);
+ const index = store.index("postReplyIndex");
+ const results: any[] = await new Promise((resolve, reject) => {
+ const req = index.getAll(postID);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+ replies = replies.concat(results);
+ } catch (_) {}
+ }
+ return replies;
+}
+
export async function getPostsByIds(userID: string, postIDs: string[]) {
const { store } = await getDBTransactionStore(userID);
const index = store.index("postIDIndex");
diff --git a/static/App.js b/static/App.js
index 266ac06..ebb1329 100644
--- a/static/App.js
+++ b/static/App.js
@@ -1,8 +1,8 @@
import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync";
-import { openDatabase, getData, addData, deleteData, getAllData, getPostForUser } from "db";
-import { arrayBufferToBase64, compressString } from "dataUtils";
+import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getRepliesForPost, buildReplyCountMap } from "db";
+import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log";
class Post {
constructor(author, author_id, text, post_timestamp, imageData = null, importedFrom = null, importSource = null, reply_to_id = null) {
@@ -382,7 +382,20 @@ export class App {
document.body.removeChild(link);
globalThis.URL.revokeObjectURL(url);
}
- async importPostsForUser(userID, posts) {
+ async importPostsForUser(userID, buffer) {
+ console.log.apply(null, log("Importing posts"));
+ const json = await decompressBuffer(buffer);
+ const posts = JSON.parse(json);
+ for (let post of posts) {
+ if (post.image_data && typeof post.image_data === 'string') {
+ post.image_data = await base64ToArrayBuffer(post.image_data);
+ }
+ if (post.post_timestamp && typeof post.post_timestamp === 'string') {
+ post.post_timestamp = new Date(post.post_timestamp);
+ }
+ }
+ await mergeDataArray(userID, posts);
+ console.log.apply(null, log(`Imported ${posts.length} posts`));
}
async exportPostsForUser(userID) {
let posts = await getAllData(userID);
@@ -725,6 +738,16 @@ export class App {
let navContainer = this.div('nav-container');
let burgerMenuButton = this.div('burger-menu-button');
burgerMenuButton.addEventListener('click', e => navContainer.classList.toggle('active'));
+ let importFilePicker = document.getElementById('import-file-input');
+ importFilePicker?.addEventListener('change', async () => {
+ const file = importFilePicker.files?.[0];
+ if (!file)
+ return;
+ const buffer = await file.arrayBuffer();
+ await this.importPostsForUser(this.userID, buffer);
+ importFilePicker.value = '';
+ this.render();
+ });
let exportButton = this.button("export-button");
exportButton.addEventListener('click', async (e) => {
await this.exportPostsForUser(this.userID);
@@ -737,7 +760,7 @@ export class App {
filePicker?.addEventListener('change', async (event) => {
for (let file of filePicker.files) {
let buffer = await file.arrayBuffer();
- await this.createPost(this.userID, 'image...', buffer, file.type);
+ await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID);
}
// Reset so that if they pick the same image again, we still get the change event.
filePicker.value = '';
@@ -781,11 +804,18 @@ export class App {
const dataTransfer = e.clipboardData;
const file = dataTransfer.files[0];
let buffer = await file.arrayBuffer();
- await this.createPost(this.userID, 'image...', buffer, file.type);
+ await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID);
});
- postButton.addEventListener("click", () => {
+ const submitPost = () => {
this.createPost(userID, postText.value, undefined, undefined, this.replyToID);
this.exitCompose();
+ };
+ postButton.addEventListener("click", submitPost);
+ postText.addEventListener("keydown", (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
+ e.preventDefault();
+ submitPost();
+ }
});
// updateApp.addEventListener("click", () => {
// registration?.active?.postMessage({ type: "update_app" });
@@ -796,17 +826,18 @@ export class App {
}
// Change this all to a template so we're not toggling state in this crazy way!
enterCompose(replyToID = null) {
+ this.replyToID = replyToID;
if (replyToID) {
this.renderComposeReplyArea(replyToID);
document.getElementById("compose-reply-area").style.display = "block";
}
- replyToID = replyToID;
document.getElementById('compose').style.display = 'block';
document.getElementById('textarea_post')?.focus();
document.getElementById('compose-dimmer')?.classList.add("compose-dimmer-dimmed");
document.body.classList.add("no-scroll");
}
exitCompose() {
+ this.replyToID = null;
let postText = document.getElementById("textarea_post");
postText.value = "";
document.getElementById('compose').style.display = 'none';
@@ -942,6 +973,7 @@ export class App {
this.sync.setArchive(this.isArchivePeer);
this.connect();
await this.initDB();
+ window.addEventListener('popstate', () => { this.getRoute(); this.render(); });
this.connectURL = `${document.location.origin}/connect/${this.userID}`;
document.getElementById('connectURL').innerHTML = `connect`;
let time = 0;
@@ -1074,13 +1106,18 @@ export class App {
const fragment = document.createDocumentFragment();
contentDiv.innerHTML = "";
let count = 0;
+ const isPostView = this.router.route === App.Route.POST;
+ contentDiv.classList.toggle('post-view', isPostView);
+ const replyCountMap = await buildReplyCountMap();
this.renderedPosts.clear();
let first = true;
for (let i = this.posts.length - 1; i >= 0; i--) {
let postData = this.posts[i];
+ if (!isPostView && postData.data.reply_to_id)
+ continue;
// this.postsSet.add(postData);
// TODO return promises for all image loads and await those.
- let post = this.renderPost(postData.data, first);
+ let post = this.renderPost(postData.data, first, replyCountMap.recursive.get(postData.data.post_id) ?? 0);
first = false;
// this.renderedPosts.set(postData.post_id, post);
if (post) {
@@ -1095,6 +1132,28 @@ export class App {
throw new Error("Couldn't get content div!");
}
contentDiv.appendChild(fragment);
+ if (isPostView && this.posts.length > 0) {
+ const renderReplies = async (postID, depth) => {
+ if (depth > 2)
+ return;
+ const replies = await getRepliesForPost(postID);
+ // Top-level replies: newest first. Nested replies: oldest first (readable order).
+ if (depth === 0) {
+ replies.sort((a, b) => new Date(b.data.post_timestamp).getTime() - new Date(a.data.post_timestamp).getTime());
+ }
+ else {
+ replies.sort((a, b) => new Date(a.data.post_timestamp).getTime() - new Date(b.data.post_timestamp).getTime());
+ }
+ for (const reply of replies) {
+ const el = this.renderPost(reply.data, false, replyCountMap.direct.get(reply.data.post_id) ?? 0);
+ if (depth > 0)
+ el.style.marginLeft = `${depth * 20}px`;
+ contentDiv.appendChild(el);
+ await renderReplies(reply.data.post_id, depth + 1);
+ }
+ };
+ await renderReplies(this.posts[0].data.post_id, 0);
+ }
let renderTime = this.timerDelta();
console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`));
;
@@ -1113,7 +1172,7 @@ export class App {
composeReplyArea.innerText = replyToID;
composeReplyArea.classList.add("show");
}
- renderPost(post, first) {
+ renderPost(post, first, replyCount = 0) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
}
@@ -1130,7 +1189,7 @@ export class App {
await navigator.clipboard.writeText(shareUrl);
};
let replyButton = document.createElement('button');
- replyButton.innerText = 'reply';
+ replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply';
replyButton.onclick = async () => {
console.log(`replying to post ${post.post_id}`);
this.enterCompose(post.post_id);
@@ -1150,24 +1209,32 @@ export class App {
markdown = markdown.replace("