replies working, need to add root post ID
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
130
src/App.ts
130
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 = `<a href="${this.connectURL}">connect</a>`;
|
||||
|
||||
@@ -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 =
|
||||
`<div>${first ? '' : '<hr>'}
|
||||
<div>
|
||||
<span class='header' title='${timestamp}'><img class="logo" src="/static/favicon.ico"><a class="username" href="${userURL}">@${post.author}</a> -
|
||||
<span style="color:rgb(128,128,128)">${post.post_timestamp.toLocaleDateString()}</span>
|
||||
</span>
|
||||
`<div class="post-container">${first ? '' : '<hr>'}
|
||||
<div class="post-body">
|
||||
<div>
|
||||
<span class='header' title='${timestamp}'><img class="logo" src="/static/favicon.ico"><a class="username" href="${userURL}">@${post.author}</a> -
|
||||
<span style="color:rgb(128,128,128)">${post.post_timestamp.toLocaleDateString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>${markdown}</div>
|
||||
|
||||
<div id="image"></div>
|
||||
|
||||
<span id="replyButton"></span>
|
||||
${ownPost ? `<span id="editButton"></span>` : ''}
|
||||
<span id="shareButton"></span>
|
||||
${ownPost ? `<span id="deleteButton"></span>` : ''}
|
||||
</div>
|
||||
<div>${markdown}</div>
|
||||
|
||||
<div id="image"></div>
|
||||
|
||||
<span id="replyButton"></span>
|
||||
${ownPost ? `<span id="editButton"></span>` : ''}
|
||||
<span id="shareButton"></span>
|
||||
${ownPost ? `<span id="deleteButton"></span>` : ''}
|
||||
|
||||
|
||||
</div>`
|
||||
|
||||
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]));
|
||||
|
||||
@@ -39,6 +39,20 @@ export async function compressString(input: string) {
|
||||
return compressedArray;
|
||||
}
|
||||
|
||||
export async function decompressBuffer(input: ArrayBuffer): Promise<string> {
|
||||
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<ArrayBuffer> {
|
||||
const response = await fetch("data:application/octet-stream;base64," + base64);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
// Base58 character set
|
||||
// const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
// Base58 encoding
|
||||
|
||||
61
src/db.ts
61
src/db.ts
@@ -463,6 +463,67 @@ export async function getAllIds(userID: string): Promise<any | undefined> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildReplyCountMap(): Promise<{ direct: Map<string, number>, recursive: Map<string, number> }> {
|
||||
const children = new Map<string, string[]>();
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
for (const postID of children.keys()) {
|
||||
recursive.set(postID, countDescendants(postID));
|
||||
}
|
||||
|
||||
return { direct, recursive };
|
||||
}
|
||||
|
||||
export async function getRepliesForPost(postID: string): Promise<any[]> {
|
||||
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");
|
||||
|
||||
123
static/App.js
123
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 = `<a href="${this.connectURL}">connect</a>`;
|
||||
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("<iframe", `<iframe style="width:100%;height:50px;display:none" onblur="this.style.display = 'inline';"`);
|
||||
}
|
||||
let userURL = `${document.location.origin}/user/${post.author_id}/`;
|
||||
let postTemplate = `<div>${first ? '' : '<hr>'}
|
||||
<div>
|
||||
<span class='header' title='${timestamp}'><img class="logo" src="/static/favicon.ico"><a class="username" href="${userURL}">@${post.author}</a> -
|
||||
<span style="color:rgb(128,128,128)">${post.post_timestamp.toLocaleDateString()}</span>
|
||||
</span>
|
||||
let postTemplate = `<div class="post-container">${first ? '' : '<hr>'}
|
||||
<div class="post-body">
|
||||
<div>
|
||||
<span class='header' title='${timestamp}'><img class="logo" src="/static/favicon.ico"><a class="username" href="${userURL}">@${post.author}</a> -
|
||||
<span style="color:rgb(128,128,128)">${post.post_timestamp.toLocaleDateString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>${markdown}</div>
|
||||
|
||||
<div id="image"></div>
|
||||
|
||||
<span id="replyButton"></span>
|
||||
${ownPost ? `<span id="editButton"></span>` : ''}
|
||||
<span id="shareButton"></span>
|
||||
${ownPost ? `<span id="deleteButton"></span>` : ''}
|
||||
</div>
|
||||
<div>${markdown}</div>
|
||||
|
||||
<div id="image"></div>
|
||||
|
||||
<span id="replyButton"></span>
|
||||
${ownPost ? `<span id="editButton"></span>` : ''}
|
||||
<span id="shareButton"></span>
|
||||
${ownPost ? `<span id="deleteButton"></span>` : ''}
|
||||
|
||||
|
||||
</div>`;
|
||||
containerDiv.innerHTML = postTemplate;
|
||||
const postBody = containerDiv.querySelector('.post-body');
|
||||
postBody.addEventListener('click', (e) => {
|
||||
if (e.target.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);
|
||||
// containerDiv.querySelector('#editButton')?.appendChild(editButton);
|
||||
@@ -1221,6 +1288,12 @@ export class App {
|
||||
}
|
||||
}
|
||||
}
|
||||
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]));
|
||||
// user = /user/<ID>
|
||||
// post = /user/<ID>/post/<ID>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,6 +31,18 @@ export async function compressString(input) {
|
||||
// Convert the compressed data to a Uint8Array
|
||||
return compressedArray;
|
||||
}
|
||||
export async function decompressBuffer(input) {
|
||||
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) {
|
||||
const response = await fetch("data:application/octet-stream;base64," + base64);
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
// Base58 character set
|
||||
// const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||
// Base58 encoding
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"dataUtils.js","sourceRoot":"","sources":["../src/dataUtils.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,KAAiB,EAAE,IAAI,GAAG,0BAA0B;IAC7F,OAAO,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAC3C,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,UAAU,EAAE,EAAE;YAC7C,MAAM,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC;YACpC,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC;SACpC,CAAC,CAAC;QACH,MAAM,CAAC,aAAa,CAAC,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IACxD,CAAC,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,MAAmB;IAC3D,IAAI,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;IACnC,OAAO,CAAC,MAAM,oBAAoB,CAAC,KAAK,CAAY,CAAA,CAAC,OAAO,CAAC,uCAAuC,EAAE,EAAE,CAAC,CAAC;AAC5G,CAAC;AAED,6DAA6D;AAC7D,wFAAwF;AACxF,oDAAoD;AACpD,wBAAwB;AACxB,IAAI;AAEJ,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,KAAa;IAChD,qCAAqC;IACrC,MAAM,WAAW,GAAG,IAAI,WAAW,EAAE,CAAC;IACtC,MAAM,UAAU,GAAG,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAE7C,6BAA6B;IAC7B,MAAM,iBAAiB,GAAG,IAAI,iBAAiB,CAAC,MAAM,CAAC,CAAC;IACxD,MAAM,MAAM,GAAG,iBAAiB,CAAC,QAAQ,CAAC,SAAS,EAAE,CAAC;IAEtD,sCAAsC;IACtC,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACzB,MAAM,CAAC,KAAK,EAAE,CAAC;IAEf,2CAA2C;IAC3C,MAAM,eAAe,GAAG,MAAM,IAAI,QAAQ,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAC;IAErF,8CAA8C;IAC9C,OAAO,eAAe,CAAC;AACzB,CAAC;AAED,uBAAuB;AACvB,wFAAwF;AACxF,kBAAkB;AAClB,kBAAkB;AAClB,sDAAsD;AACtD,eAAe;AACf,wBAAwB;AAExB,iCAAiC;AACjC,oBAAoB;AACpB,gDAAgD;AAChD,iCAAiC;AACjC,gCAAgC;AAChC,wCAAwC;AACxC,QAAQ;AACR,0BAA0B;AAC1B,iCAAiC;AACjC,wCAAwC;AACxC,QAAQ;AACR,MAAM;AAEN,qBAAqB;AACrB,4CAA4C;AAC5C,wCAAwC;AACxC,MAAM;AAEN,iCAAiC;AACjC,iCAAiC;AACjC,2BAA2B;AAC3B,8CAA8C;AAC9C,eAAe;AACf,eAAe;AACf,QAAQ;AACR,MAAM;AAEN,mBAAmB;AACnB,IAAI;AAEJ,4BAA4B;AAC5B,gDAAgD;AAChD,qCAAqC;AACrC,gCAAgC;AAChC,IAAI"}
|
||||
{"version":3,"file":"dataUtils.js","sourceRoot":"","sources":["../src/dataUtils.ts"],"names":[],"mappings":"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"}
|
||||
58
static/db.js
58
static/db.js
@@ -356,6 +356,64 @@ export async function getAllIds(userID) {
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function buildReplyCountMap() {
|
||||
const children = new Map();
|
||||
const knownUsers = [...(await indexedDB.databases())]
|
||||
.map(db => db.name?.replace('user_', '')).filter(Boolean);
|
||||
for (const userID of knownUsers) {
|
||||
try {
|
||||
const { store } = await getDBTransactionStore(userID);
|
||||
const index = store.index("postReplyIndex");
|
||||
const replies = 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) => {
|
||||
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) {
|
||||
const knownUsers = [...(await indexedDB.databases())]
|
||||
.map(db => db.name?.replace('user_', '')).filter(Boolean);
|
||||
let replies = [];
|
||||
for (const userID of knownUsers) {
|
||||
try {
|
||||
const { store } = await getDBTransactionStore(userID);
|
||||
const index = store.index("postReplyIndex");
|
||||
const results = 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, postIDs) {
|
||||
const { store } = await getDBTransactionStore(userID);
|
||||
const index = store.index("postIDIndex");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -87,6 +87,8 @@
|
||||
|
||||
<div id="info" style="display:none">
|
||||
<button id="export-button">export</button>
|
||||
<label for="import-file-input" id="import-button" class="button">import</label>
|
||||
<input type="file" id="import-file-input" accept=".gz" style="display:none">
|
||||
|
||||
<div id="profile">
|
||||
<span class="form_label">username:</span><span class="form_field" id="username"
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
:root {
|
||||
--main-bg-color: white;
|
||||
--main-hover-color: rgb(64, 64, 64) --border-color: rgb(132, 136, 138);
|
||||
--main-hover-color: rgb(64, 64, 64);
|
||||
--border-color: rgb(132, 136, 138);
|
||||
--edge-color: rgb(60, 60, 60);
|
||||
--main-fg-color: black;
|
||||
--highlight-fg-color: rgb(255, 255, 255);
|
||||
@@ -124,6 +125,24 @@ hr {
|
||||
|
||||
.postImage {
|
||||
width: 100%;
|
||||
max-height: 500px;
|
||||
object-fit: contain;
|
||||
object-position: left;
|
||||
}
|
||||
|
||||
.post-view .postImage {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.post-body {
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
transition: background-color 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-body:hover {
|
||||
background-color: var(--main-hover-color);
|
||||
}
|
||||
|
||||
#log {
|
||||
|
||||
Reference in New Issue
Block a user