This commit is contained in:
2026-04-16 02:02:34 -07:00
21 changed files with 336 additions and 82 deletions

6
deno.json Normal file
View File

@@ -0,0 +1,6 @@
{
"tasks": {
"build": "deno run -A npm:typescript/bin/tsc",
"watch": "deno run -A npm:typescript/bin/tsc --watch"
}
}

68
deno.lock generated Normal file
View File

@@ -0,0 +1,68 @@
{
"version": "5",
"specifiers": {
"jsr:@deno-library/compress@*": "0.5.6",
"jsr:@deno-library/crc32@1.0.2": "1.0.2",
"jsr:@std/bytes@^1.0.2": "1.0.6",
"jsr:@std/fs@1.0.5": "1.0.5",
"jsr:@std/io@0.225.0": "0.225.0",
"jsr:@std/path@1.0.8": "1.0.8",
"jsr:@std/path@^1.0.7": "1.0.8",
"jsr:@std/streams@^1.0.7": "1.0.17",
"jsr:@std/tar@0.1.3": "0.1.3",
"jsr:@zip-js/zip-js@2.7.53": "2.7.53",
"npm:typescript@*": "6.0.2"
},
"jsr": {
"@deno-library/compress@0.5.6": {
"integrity": "9d76e37e7682fc8d3d99d5641a7af454ce4689b1df3fd3062141a1deb64453cd",
"dependencies": [
"jsr:@deno-library/crc32",
"jsr:@std/fs",
"jsr:@std/io",
"jsr:@std/path@1.0.8",
"jsr:@std/tar",
"jsr:@zip-js/zip-js"
]
},
"@deno-library/crc32@1.0.2": {
"integrity": "d2061bfee30c87c97f285dfca0fdc4458e632dc072a33ecfc73ca5177a5a39a0"
},
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/fs@1.0.5": {
"integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e",
"dependencies": [
"jsr:@std/path@^1.0.7"
]
},
"@std/io@0.225.0": {
"integrity": "c1db7c5e5a231629b32d64b9a53139445b2ca640d828c26bf23e1c55f8c079b3",
"dependencies": [
"jsr:@std/bytes"
]
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
},
"@std/streams@1.0.17": {
"integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140"
},
"@std/tar@0.1.3": {
"integrity": "531270fc707b37ab9b5f051aa4943e7b16b86905e0398a4ebe062983b0c93115",
"dependencies": [
"jsr:@std/streams"
]
},
"@zip-js/zip-js@2.7.53": {
"integrity": "acea5bd8e01feb3fe4c242cfbde7d33dd5e006549a4eb1d15283bc0c778ed672"
}
},
"npm": {
"typescript@6.0.2": {
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"bin": true
}
}
}

27
dev.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
set -e
SESSION="dandelion"
ROOT="$(cd "$(dirname "$0")" && pwd)"
# Kill any existing session
tmux kill-session -t "$SESSION" 2>/dev/null || true
# Create session with tsc in the first pane
tmux new-session -d -s "$SESSION" -c "$ROOT"
tmux send-keys -t "$SESSION" 'deno task watch' Enter
# Split right: deno server
tmux split-window -h -t "$SESSION" -c "$ROOT/deno"
tmux send-keys -t "$SESSION" 'bash dev.sh' Enter
# Split right: ddln_cli
tmux split-window -h -t "$SESSION" -c "$ROOT/ddln_cli"
tmux send-keys -t "$SESSION" 'bash dev.sh' Enter
tmux select-layout -t "$SESSION" even-horizontal
# Open Chrome silently after a short delay
(sleep 3 && open -a "Google Chrome" "https://localhost:8443" &>/dev/null) &
tmux attach-session -t "$SESSION"

View File

@@ -1,5 +0,0 @@
{
"devDependencies": {
"typescript": "5.8.3"
}
}

View File

@@ -1,6 +1,29 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Install deno if needed
if ! command -v deno &>/dev/null; then
echo "Installing deno..."
curl -fsSL https://deno.land/install.sh | sh
export DENO_INSTALL="$HOME/.deno"
export PATH="$DENO_INSTALL/bin:$PATH"
fi
# Install node if needed
if ! command -v node &>/dev/null; then
echo "Installing node..."
brew install node
fi
# Install TypeScript dependencies
npm install
# Install tmux if needed
if ! command -v tmux &>/dev/null; then
echo "Installing tmux..."
brew install tmux
fi
# Install mkcert if needed # Install mkcert if needed
if ! command -v mkcert &>/dev/null; then if ! command -v mkcert &>/dev/null; then
echo "Installing mkcert..." echo "Installing mkcert..."

View File

@@ -1,7 +1,7 @@
import { generateID } from "IDUtils"; import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager"; import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync"; import { Sync } from "Sync";
import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getRepliesForPost, buildReplyCountMap } from "db"; import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap } from "db";
import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils"; import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log" import { log, logID, renderLog, setLogVisibility } from "log"
@@ -14,6 +14,7 @@ class Post {
post_timestamp: Date; post_timestamp: Date;
post_id: string; post_id: string;
reply_to_id: string|null; reply_to_id: string|null;
root_id: string|null;
author: string; author: string;
author_id: string; author_id: string;
text: string; text: string;
@@ -29,7 +30,8 @@ class Post {
imageData: ArrayBuffer | null = null, imageData: ArrayBuffer | null = null,
importedFrom: "twitter" | null = null, importedFrom: "twitter" | null = null,
importSource: any = null, importSource: any = null,
reply_to_id:string|null = null) { reply_to_id: string|null = null,
root_id: string|null = null) {
this.post_timestamp = post_timestamp; this.post_timestamp = post_timestamp;
this.post_id = generateID(); this.post_id = generateID();
@@ -42,6 +44,7 @@ class Post {
this.importedFrom = importedFrom; this.importedFrom = importedFrom;
this.importSource = importSource; this.importSource = importSource;
this.reply_to_id = reply_to_id; this.reply_to_id = reply_to_id;
this.root_id = root_id;
} }
} }
@@ -120,6 +123,7 @@ export class App {
userID: string = ''; userID: string = '';
peerID: string = ''; peerID: string = '';
replyToID: string|null = null; replyToID: string|null = null;
replyRootID: string|null = null;
following: Set<string> = new Set(); following: Set<string> = new Set();
posts: StoragePost[] = []; posts: StoragePost[] = [];
isHeadless: boolean = false; isHeadless: boolean = false;
@@ -737,7 +741,7 @@ export class App {
} }
} }
async createPost(userID: string, postText: string, mediaData?: ArrayBuffer, mimeType?: "image/svg+xml" | "image/png" | "image/gif" | "image/jpg" | "image/jpeg" | "video/mp4", replyToID:string|null = null) { async createPost(userID: string, postText: string, mediaData?: ArrayBuffer, mimeType?: "image/svg+xml" | "image/png" | "image/gif" | "image/jpg" | "image/jpeg" | "video/mp4", replyToID: string|null = null, replyRootID: string|null = null) {
if ((typeof postText !== "string") || postText.length === 0) { if ((typeof postText !== "string") || postText.length === 0) {
console.log.apply(null, log("Not posting an empty string...")); console.log.apply(null, log("Not posting an empty string..."));
return; return;
@@ -752,7 +756,7 @@ export class App {
} }
} }
let post = new Post(this.username, userID, postText, new Date(), mediaData, null, null, replyToID); let post = new Post(this.username, userID, postText, new Date(), mediaData, null, null, replyToID, replyRootID);
// this.posts.push(post); // this.posts.push(post);
// localStorage.setItem(key, JSON.stringify(posts)); // localStorage.setItem(key, JSON.stringify(posts));
addData(userID, post); addData(userID, post);
@@ -763,27 +767,23 @@ export class App {
this.render(); this.render();
} }
getPeerID() { getPeerID(): string {
let id = localStorage.getItem("peer_id"); const existing = localStorage.getItem("peer_id");
if (existing) return existing;
if (!id) {
console.log.apply(null, log(`Didn't find a peer ID, generating one`));;
id = generateID();
localStorage.setItem("peer_id", id);
}
console.log.apply(null, log(`Didn't find a peer ID, generating one`));
const id = generateID();
localStorage.setItem("peer_id", id);
return id; return id;
} }
getUserID() { getUserID(): string {
let id = localStorage.getItem("dandelion_id"); const existing = localStorage.getItem("dandelion_id");
if (existing) return existing;
if (!id) {
console.log.apply(null, log(`Didn't find a user ID, generating one`));;
id = generateID();
localStorage.setItem("dandelion_id", id);
}
console.log.apply(null, log(`Didn't find a user ID, generating one`));
const id = generateID();
localStorage.setItem("dandelion_id", id);
return id; return id;
} }
@@ -1016,7 +1016,7 @@ export class App {
filePicker?.addEventListener('change', async (event: any) => { filePicker?.addEventListener('change', async (event: any) => {
for (let file of filePicker.files as any) { for (let file of filePicker.files as any) {
let buffer = await file.arrayBuffer(); let buffer = await file.arrayBuffer();
await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID); await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID, this.replyRootID);
} }
// Reset so that if they pick the same image again, we still get the change event. // Reset so that if they pick the same image again, we still get the change event.
@@ -1075,11 +1075,11 @@ export class App {
const dataTransfer = e.clipboardData const dataTransfer = e.clipboardData
const file = dataTransfer!.files[0]; const file = dataTransfer!.files[0];
let buffer = await file.arrayBuffer(); let buffer = await file.arrayBuffer();
await this.createPost(this.userID, 'image...', buffer, file.type as any, this.replyToID); await this.createPost(this.userID, 'image...', buffer, file.type as any, this.replyToID, this.replyRootID);
}); });
const submitPost = () => { const submitPost = () => {
this.createPost(userID, postText.value, undefined, undefined, this.replyToID); this.createPost(userID, postText.value, undefined, undefined, this.replyToID, this.replyRootID);
this.exitCompose(); this.exitCompose();
}; };
@@ -1105,8 +1105,9 @@ export class App {
// Change this all to a template so we're not toggling state in this crazy way! // Change this all to a template so we're not toggling state in this crazy way!
enterCompose(replyToID:string|null=null) { enterCompose(replyToID: string|null = null, replyRootID: string|null = null) {
this.replyToID = replyToID; this.replyToID = replyToID;
this.replyRootID = replyRootID;
if (replyToID) { if (replyToID) {
this.renderComposeReplyArea(replyToID); this.renderComposeReplyArea(replyToID);
@@ -1121,6 +1122,7 @@ export class App {
exitCompose() { exitCompose() {
this.replyToID = null; this.replyToID = null;
this.replyRootID = null;
let postText = document.getElementById("textarea_post") as HTMLTextAreaElement; let postText = document.getElementById("textarea_post") as HTMLTextAreaElement;
postText.value = ""; postText.value = "";
document.getElementById('compose')!.style.display = 'none'; document.getElementById('compose')!.style.display = 'none';
@@ -1532,6 +1534,15 @@ export class App {
contentDiv.appendChild(fragment); contentDiv.appendChild(fragment);
if (isPostView && this.posts.length > 0) { if (isPostView && this.posts.length > 0) {
const currentPost = this.posts[0].data;
if (currentPost.root_id) {
const rootRecord = await getPostById(currentPost.root_id);
if (rootRecord) {
const rootEl = this.renderPost(rootRecord.data, true, replyCountMap.recursive.get(rootRecord.data.post_id) ?? 0);
contentDiv.insertBefore(rootEl, contentDiv.firstChild);
}
}
const renderReplies = async (postID: string, depth: number) => { const renderReplies = async (postID: string, depth: number) => {
if (depth > 2) return; if (depth > 2) return;
const replies = await getRepliesForPost(postID); const replies = await getRepliesForPost(postID);
@@ -1552,7 +1563,7 @@ export class App {
await renderReplies(reply.data.post_id, depth + 1); await renderReplies(reply.data.post_id, depth + 1);
} }
}; };
await renderReplies(this.posts[0].data.post_id, 0); await renderReplies(this.posts[0].data.post_id, currentPost.root_id ? 1 : 0);
} }
let renderTime = this.timerDelta(); let renderTime = this.timerDelta();
@@ -1575,10 +1586,31 @@ export class App {
this.render(); this.render();
} }
renderComposeReplyArea(replyToID:string) { async renderComposeReplyArea(replyToID: string) {
let composeReplyArea = document.getElementById('compose-reply-area') as HTMLElement; const composeReplyArea = document.getElementById('compose-reply-area') as HTMLElement;
composeReplyArea.innerText = replyToID; composeReplyArea.innerHTML = '';
composeReplyArea.classList.add("show");
const record = await getPostById(replyToID);
if (!record) return;
const postData = record.data;
const previewDiv = document.createElement('div');
previewDiv.className = 'compose-reply-preview';
const textDiv = document.createElement('div');
textDiv.className = 'compose-reply-preview-text';
textDiv.innerHTML = this.markedAvailable ? marked.parse(postData.text) : postData.text;
previewDiv.appendChild(textDiv);
if (postData.image_data) {
const img = document.createElement('img');
img.className = 'compose-reply-preview-image';
const blob = new Blob([postData.image_data as ArrayBuffer]);
img.src = URL.createObjectURL(blob);
previewDiv.appendChild(img);
}
composeReplyArea.appendChild(previewDiv);
} }
renderPost(post: Post, first: boolean, replyCount: number = 0) { renderPost(post: Post, first: boolean, replyCount: number = 0) {
@@ -1603,10 +1635,8 @@ export class App {
let replyButton = document.createElement('button'); replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply'; let replyButton = document.createElement('button'); replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply';
replyButton.onclick = async () => { replyButton.onclick = async () => {
console.log(`replying to post ${post.post_id}`); console.log(`replying to post ${post.post_id}`);
this.enterCompose(post.post_id); const rootID = post.root_id ?? post.post_id;
// let shareUrl = `${document.location.origin}/user/${post.author_id}/post/${post.post_id}`; this.enterCompose(post.post_id, rootID);
// await navigator.clipboard.writeText(shareUrl)
}; };

View File

@@ -2,7 +2,7 @@ import { openDatabase, getData, addData, addDataArray, clearData, deleteData, me
import { log, logID } from "log"; import { log, logID } from "log";
async function bytesToBase64DataUrl(bytes: Uint8Array, type = "application/octet-stream") { async function bytesToBase64DataUrl(bytes: Uint8Array<ArrayBuffer>, type = "application/octet-stream") {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const reader = Object.assign(new FileReader(), { const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result), onload: () => resolve(reader.result),

View File

@@ -1,4 +1,4 @@
export async function bytesToBase64DataUrl(bytes: Uint8Array, type = "application/octet-stream") { export async function bytesToBase64DataUrl(bytes: Uint8Array<ArrayBuffer>, type = "application/octet-stream") {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const reader = Object.assign(new FileReader(), { const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result), onload: () => resolve(reader.result),

View File

@@ -505,6 +505,24 @@ export async function buildReplyCountMap(): Promise<{ direct: Map<string, number
return { direct, recursive }; return { direct, recursive };
} }
export async function getPostById(postID: string): Promise<any | undefined> {
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("postIDIndex");
const result: any = await new Promise((resolve, reject) => {
const req = index.get(postID);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (result) return result;
} catch (_) {}
}
return undefined;
}
export async function getRepliesForPost(postID: string): Promise<any[]> { export async function getRepliesForPost(postID: string): Promise<any[]> {
const knownUsers = [...(await indexedDB.databases())] const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', '')).filter(Boolean) as string[]; .map(db => db.name?.replace('user_', '')).filter(Boolean) as string[];

View File

@@ -230,7 +230,7 @@ window.addEventListener('scroll', () => {
// } // }
// } // }
async function bytesToBase64DataUrl(bytes: Uint8Array, type = "application/octet-stream") { async function bytesToBase64DataUrl(bytes: Uint8Array<ArrayBuffer>, type = "application/octet-stream") {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const reader = Object.assign(new FileReader(), { const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result), onload: () => resolve(reader.result),
@@ -918,7 +918,7 @@ class App {
}_${String(d.getSeconds()).padStart(2, '0')}`; }_${String(d.getSeconds()).padStart(2, '0')}`;
this.downloadBinary(compressedData, `ddln_${this.username}_export_${timestamp}.json.gz`); this.downloadBinary(compressedData.buffer, `ddln_${this.username}_export_${timestamp}.json.gz`);
} }
async importTweetArchive(userID: string, tweetArchive: any[]) { async importTweetArchive(userID: string, tweetArchive: any[]) {

View File

@@ -1,11 +1,11 @@
import { generateID } from "IDUtils"; import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager"; import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync"; import { Sync } from "Sync";
import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getRepliesForPost, buildReplyCountMap } from "db"; import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap } from "db";
import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils"; import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log"; import { log, logID, renderLog, setLogVisibility } from "log";
class Post { class Post {
constructor(author, author_id, text, post_timestamp, imageData = null, importedFrom = null, importSource = null, reply_to_id = null) { constructor(author, author_id, text, post_timestamp, imageData = null, importedFrom = null, importSource = null, reply_to_id = null, root_id = null) {
this.post_timestamp = post_timestamp; this.post_timestamp = post_timestamp;
this.post_id = generateID(); this.post_id = generateID();
this.author = author; this.author = author;
@@ -15,6 +15,7 @@ class Post {
this.importedFrom = importedFrom; this.importedFrom = importedFrom;
this.importSource = importSource; this.importSource = importSource;
this.reply_to_id = reply_to_id; this.reply_to_id = reply_to_id;
this.root_id = root_id;
} }
} }
class StatusBar { class StatusBar {
@@ -70,6 +71,7 @@ export class App {
this.userID = ''; this.userID = '';
this.peerID = ''; this.peerID = '';
this.replyToID = null; this.replyToID = null;
this.replyRootID = null;
this.following = new Set(); this.following = new Set();
this.posts = []; this.posts = [];
this.isHeadless = false; this.isHeadless = false;
@@ -538,7 +540,7 @@ export class App {
return null; return null;
} }
} }
async createPost(userID, postText, mediaData, mimeType, replyToID = null) { async createPost(userID, postText, mediaData, mimeType, replyToID = null, replyRootID = null) {
if ((typeof postText !== "string") || postText.length === 0) { if ((typeof postText !== "string") || postText.length === 0) {
console.log.apply(null, log("Not posting an empty string...")); console.log.apply(null, log("Not posting an empty string..."));
return; return;
@@ -551,7 +553,7 @@ export class App {
mediaData = compressedImage; mediaData = compressedImage;
} }
} }
let post = new Post(this.username, userID, postText, new Date(), mediaData, null, null, replyToID); let post = new Post(this.username, userID, postText, new Date(), mediaData, null, null, replyToID, replyRootID);
// this.posts.push(post); // this.posts.push(post);
// localStorage.setItem(key, JSON.stringify(posts)); // localStorage.setItem(key, JSON.stringify(posts));
addData(userID, post); addData(userID, post);
@@ -559,23 +561,21 @@ export class App {
this.render(); this.render();
} }
getPeerID() { getPeerID() {
let id = localStorage.getItem("peer_id"); const existing = localStorage.getItem("peer_id");
if (!id) { if (existing)
console.log.apply(null, log(`Didn't find a peer ID, generating one`)); return existing;
; console.log.apply(null, log(`Didn't find a peer ID, generating one`));
id = generateID(); const id = generateID();
localStorage.setItem("peer_id", id); localStorage.setItem("peer_id", id);
}
return id; return id;
} }
getUserID() { getUserID() {
let id = localStorage.getItem("dandelion_id"); const existing = localStorage.getItem("dandelion_id");
if (!id) { if (existing)
console.log.apply(null, log(`Didn't find a user ID, generating one`)); return existing;
; console.log.apply(null, log(`Didn't find a user ID, generating one`));
id = generateID(); const id = generateID();
localStorage.setItem("dandelion_id", id); localStorage.setItem("dandelion_id", id);
}
return id; return id;
} }
hashIdToIndices(id) { hashIdToIndices(id) {
@@ -760,7 +760,7 @@ export class App {
filePicker?.addEventListener('change', async (event) => { filePicker?.addEventListener('change', async (event) => {
for (let file of filePicker.files) { for (let file of filePicker.files) {
let buffer = await file.arrayBuffer(); let buffer = await file.arrayBuffer();
await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID); await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID, this.replyRootID);
} }
// Reset so that if they pick the same image again, we still get the change event. // Reset so that if they pick the same image again, we still get the change event.
filePicker.value = ''; filePicker.value = '';
@@ -804,10 +804,10 @@ export class App {
const dataTransfer = e.clipboardData; const dataTransfer = e.clipboardData;
const file = dataTransfer.files[0]; const file = dataTransfer.files[0];
let buffer = await file.arrayBuffer(); let buffer = await file.arrayBuffer();
await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID); await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID, this.replyRootID);
}); });
const submitPost = () => { const submitPost = () => {
this.createPost(userID, postText.value, undefined, undefined, this.replyToID); this.createPost(userID, postText.value, undefined, undefined, this.replyToID, this.replyRootID);
this.exitCompose(); this.exitCompose();
}; };
postButton.addEventListener("click", submitPost); postButton.addEventListener("click", submitPost);
@@ -825,8 +825,9 @@ export class App {
// }); // });
} }
// Change this all to a template so we're not toggling state in this crazy way! // Change this all to a template so we're not toggling state in this crazy way!
enterCompose(replyToID = null) { enterCompose(replyToID = null, replyRootID = null) {
this.replyToID = replyToID; this.replyToID = replyToID;
this.replyRootID = replyRootID;
if (replyToID) { if (replyToID) {
this.renderComposeReplyArea(replyToID); this.renderComposeReplyArea(replyToID);
document.getElementById("compose-reply-area").style.display = "block"; document.getElementById("compose-reply-area").style.display = "block";
@@ -838,6 +839,7 @@ export class App {
} }
exitCompose() { exitCompose() {
this.replyToID = null; this.replyToID = null;
this.replyRootID = null;
let postText = document.getElementById("textarea_post"); let postText = document.getElementById("textarea_post");
postText.value = ""; postText.value = "";
document.getElementById('compose').style.display = 'none'; document.getElementById('compose').style.display = 'none';
@@ -1133,6 +1135,14 @@ export class App {
} }
contentDiv.appendChild(fragment); contentDiv.appendChild(fragment);
if (isPostView && this.posts.length > 0) { if (isPostView && this.posts.length > 0) {
const currentPost = this.posts[0].data;
if (currentPost.root_id) {
const rootRecord = await getPostById(currentPost.root_id);
if (rootRecord) {
const rootEl = this.renderPost(rootRecord.data, true, replyCountMap.recursive.get(rootRecord.data.post_id) ?? 0);
contentDiv.insertBefore(rootEl, contentDiv.firstChild);
}
}
const renderReplies = async (postID, depth) => { const renderReplies = async (postID, depth) => {
if (depth > 2) if (depth > 2)
return; return;
@@ -1152,7 +1162,7 @@ export class App {
await renderReplies(reply.data.post_id, depth + 1); await renderReplies(reply.data.post_id, depth + 1);
} }
}; };
await renderReplies(this.posts[0].data.post_id, 0); await renderReplies(this.posts[0].data.post_id, currentPost.root_id ? 1 : 0);
} }
let renderTime = this.timerDelta(); let renderTime = this.timerDelta();
console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`)); console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`));
@@ -1167,10 +1177,27 @@ export class App {
deleteData(userID, postID); deleteData(userID, postID);
this.render(); this.render();
} }
renderComposeReplyArea(replyToID) { async renderComposeReplyArea(replyToID) {
let composeReplyArea = document.getElementById('compose-reply-area'); const composeReplyArea = document.getElementById('compose-reply-area');
composeReplyArea.innerText = replyToID; composeReplyArea.innerHTML = '';
composeReplyArea.classList.add("show"); const record = await getPostById(replyToID);
if (!record)
return;
const postData = record.data;
const previewDiv = document.createElement('div');
previewDiv.className = 'compose-reply-preview';
const textDiv = document.createElement('div');
textDiv.className = 'compose-reply-preview-text';
textDiv.innerHTML = this.markedAvailable ? marked.parse(postData.text) : postData.text;
previewDiv.appendChild(textDiv);
if (postData.image_data) {
const img = document.createElement('img');
img.className = 'compose-reply-preview-image';
const blob = new Blob([postData.image_data]);
img.src = URL.createObjectURL(blob);
previewDiv.appendChild(img);
}
composeReplyArea.appendChild(previewDiv);
} }
renderPost(post, first, replyCount = 0) { renderPost(post, first, replyCount = 0) {
if (!(post.hasOwnProperty("text"))) { if (!(post.hasOwnProperty("text"))) {
@@ -1192,9 +1219,8 @@ export class App {
replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply'; replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply';
replyButton.onclick = async () => { replyButton.onclick = async () => {
console.log(`replying to post ${post.post_id}`); console.log(`replying to post ${post.post_id}`);
this.enterCompose(post.post_id); const rootID = post.root_id ?? post.post_id;
// let shareUrl = `${document.location.origin}/user/${post.author_id}/post/${post.post_id}`; this.enterCompose(post.post_id, rootID);
// await navigator.clipboard.writeText(shareUrl)
}; };
let ownPost = post.author_id === this.userID; let ownPost = post.author_id === this.userID;
let markdown = post.text; let markdown = post.text;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"version":3,"file":"dataUtils.js","sourceRoot":"","sources":["../src/dataUtils.ts"],"names":[],"mappings":"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"} {"version":3,"file":"dataUtils.js","sourceRoot":"","sources":["../src/dataUtils.ts"],"names":[],"mappings":"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"}

View File

@@ -395,6 +395,25 @@ export async function buildReplyCountMap() {
} }
return { direct, recursive }; return { direct, recursive };
} }
export async function getPostById(postID) {
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("postIDIndex");
const result = await new Promise((resolve, reject) => {
const req = index.get(postID);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
if (result)
return result;
}
catch (_) { }
}
return undefined;
}
export async function getRepliesForPost(postID) { export async function getRepliesForPost(postID) {
const knownUsers = [...(await indexedDB.databases())] const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', '')).filter(Boolean); .map(db => db.name?.replace('user_', '')).filter(Boolean);

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,7 @@
:root { :root {
--main-bg-color: white; --main-bg-color: white;
--main-hover-color: rgb(64, 64, 64); --main-hover-color: rgb(64, 64, 64);
--post-hover-color: rgb(32,32,32);
--border-color: rgb(132, 136, 138); --border-color: rgb(132, 136, 138);
--edge-color: rgb(60, 60, 60); --edge-color: rgb(60, 60, 60);
--main-fg-color: black; --main-fg-color: black;
@@ -142,7 +143,7 @@ hr {
} }
.post-body:hover { .post-body:hover {
background-color: var(--main-hover-color); background-color: var(--post-hover-color);
} }
#log { #log {
@@ -316,7 +317,39 @@ iframe {
} }
#compose-reply-area { #compose-reply-area {
display:none; display: none;
padding: 8px 10px;
margin-bottom: 8px;
border-left: 3px solid var(--highlight-fg-color);
font-size: 0.85em;
}
.compose-reply-preview {
display: flex;
gap: 10px;
align-items: flex-start;
}
.compose-reply-preview-text {
flex: 1;
overflow: hidden;
max-height: 80px;
}
.compose-reply-preview-text iframe {
width: 120px;
height: 68px;
pointer-events: none;
}
.compose-reply-preview-image {
width: 48px;
height: 48px;
max-width: 48px;
max-height: 48px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
} }
.show { .show {

View File

@@ -689,7 +689,7 @@ class App {
let compressedData = await compressString(JSON.stringify(output)); let compressedData = await compressString(JSON.stringify(output));
const d = new Date(); const d = new Date();
const timestamp = `${d.getFullYear()}_${String(d.getMonth() + 1).padStart(2, '0')}_${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}_${String(d.getMinutes()).padStart(2, '0')}_${String(d.getSeconds()).padStart(2, '0')}`; const timestamp = `${d.getFullYear()}_${String(d.getMonth() + 1).padStart(2, '0')}_${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}_${String(d.getMinutes()).padStart(2, '0')}_${String(d.getSeconds()).padStart(2, '0')}`;
this.downloadBinary(compressedData, `ddln_${this.username}_export_${timestamp}.json.gz`); this.downloadBinary(compressedData.buffer, `ddln_${this.username}_export_${timestamp}.json.gz`);
} }
async importTweetArchive(userID, tweetArchive) { async importTweetArchive(userID, tweetArchive) {
log("Importing tweet archive"); log("Importing tweet archive");

File diff suppressed because one or more lines are too long

2
stop.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/bash
tmux kill-session -t dandelion 2>/dev/null && echo "Stopped." || echo "Not running."

View File

@@ -45,8 +45,15 @@
/* Module Resolution Options */ /* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ "paths": {
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ "App": ["./src/App.ts"],
"IDUtils": ["./src/IDUtils.ts"],
"PeerManager": ["./src/PeerManager.ts"],
"Sync": ["./src/Sync.ts"],
"db": ["./src/db.ts"],
"dataUtils": ["./src/dataUtils.ts"],
"log": ["./src/log.ts"]
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */ // "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */ // "types": [], /* Type declaration files to be included in compilation. */