24 Commits

Author SHA1 Message Date
bobbydigitales
e8cc08e5cc Working export/import 2026-04-19 21:39:25 -07:00
4ae581b1a2 Merge pull request 'Add notification nav, show only replies in short format' (#4) from bobbyd-notifications into main
Reviewed-on: #4
2026-04-17 00:34:00 -07:00
e27cf391ef Add notification nav, show only replies in short format 2026-04-17 00:29:51 -07:00
29ba02e3ce Merge pull request 'bobbyd-replies' (#3) from bobbyd-replies into main
Reviewed-on: #3
2026-04-16 21:56:30 -07:00
1353acc4d1 Really revert the server file watchign change 2026-04-16 21:49:34 -07:00
4dda9af788 Revert the server file watchign changes as I think they were not needed 2026-04-16 21:35:46 -07:00
eb4dbb2448 All working nicely 2026-04-16 21:29:31 -07:00
3fef295b59 Working. 2026-04-16 02:02:34 -07:00
548ac39d19 Use deno typescript instead of node. Fix compilation errors for updated typescript. update setup script to install deno. use tmux when running localling for all processes. 2026-04-16 00:36:22 -07:00
bobbydigitales
f22d8b9ba6 replies working, need to add root post ID 2026-04-16 00:26:20 -07:00
bobbydigitales
9c15ed2cd2 Merge branch 'main' of https://nasbox.local:6789/bobbydigitales/dandelion 2026-04-14 23:29:15 -07:00
187d41e93f add setup script 2026-04-15 06:15:59 +00:00
c1de283fb6 working on replies 2026-04-15 06:13:50 +00:00
0d392c90cc Working single post rendering 2026-04-15 06:13:50 +00:00
3a056dffce working on showing single posts for users we dont know about 2026-04-15 06:13:50 +00:00
5a6691a214 More logging 2026-04-15 06:13:30 +00:00
9ff871b0e3 Webrtc superlog 2026-04-15 06:13:30 +00:00
dbf45dbf14 remove config file 2026-04-15 06:12:55 +00:00
7c25342f82 Add bootstrap files 2026-04-15 06:12:55 +00:00
bobbydigitales
f387d9ea48 fix politeness 2026-03-02 23:10:18 -08:00
96ea7d56f9 Add README.md 2026-02-22 23:14:24 -08:00
bf3eb0ae9f Merge pull request 'bobbyd-show-single-post' (#1) from bobbyd-show-single-post into main
Reviewed-on: #1
2026-02-22 22:49:24 -08:00
3c46dbb04c Working single post rendering 2026-02-17 07:43:51 +00:00
bac14446bb working on showing single posts for users we dont know about 2025-12-31 07:11:35 +00:00
38 changed files with 1806 additions and 558 deletions

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# Intro
Dandelion is an experimental social network designed to let you build online communites that will last for 100 years.

View File

@@ -6,7 +6,7 @@
user_data_dir="${1:-./profile}"
user_id="${2:-b38b623c-c3fa-4351-9cab-50233c99fa4e}"
chromium-browser \
chromium \
--disable-setuid-sandbox \
--disable-infobars \
--no-first-run \

28
ddln_cli/dev.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
user_data_dir="${1:-./profile}"
user_id="${2:-b38b623c-c3fa-4351-9cab-50233c99fa4e}"
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--disable-setuid-sandbox \
--disable-infobars \
--no-first-run \
--hide-scrollbars \
--disable-notifications \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
--disable-breakpad \
--disable-component-extensions-with-background-pages \
--disable-extensions \
--disable-features=TranslateUI,BlinkGenPropertyTrees \
--disable-ipc-flooding-protection \
--disable-renderer-backgrounding \
--enable-features=NetworkService,NetworkServiceInProcess \
--force-color-profile=srgb \
--metrics-recording-only \
--mute-audio \
--disable-gpu \
--repl --headless \
--user-data-dir="$user_data_dir" \
--remote-debugging-port=9332 \
--enable-logging=stderr \
"https://localhost:8443/connect/$user_id?bootstrap"

7
deno.json Normal file
View File

@@ -0,0 +1,7 @@
{
"tasks": {
"build": "deno run -A npm:typescript/bin/tsc",
"watch": "deno run -A npm:typescript/bin/tsc --watch",
"test": "deno test --allow-net src/App.test.ts"
}
}

99
deno.lock generated Normal file
View File

@@ -0,0 +1,99 @@
{
"version": "5",
"specifiers": {
"jsr:@deno-library/compress@*": "0.5.6",
"jsr:@deno-library/crc32@1.0.2": "1.0.2",
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/bytes@^1.0.2": "1.0.6",
"jsr:@std/fs@1.0.5": "1.0.5",
"jsr:@std/internal@^1.0.12": "1.0.12",
"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:playwright@*": "1.59.1",
"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/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/bytes@1.0.6": {
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
},
"@std/fs@1.0.5": {
"integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e",
"dependencies": [
"jsr:@std/path@^1.0.7"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@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": {
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"playwright-core@1.59.1": {
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"bin": true
},
"playwright@1.59.1": {
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dependencies": [
"playwright-core"
],
"optionalDependencies": [
"fsevents"
],
"bin": true
},
"typescript@6.0.2": {
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
"bin": true
}
}
}

View File

@@ -292,12 +292,14 @@ function connectWebsocket(request: Request) {
}
const projectRoot = new URL('..', import.meta.url).pathname;
async function devServerWatchFiles() {
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(projectRoot, '/');
filepathResponseCache.delete(cachedPath);
console.log('Purging updated file:', cachedPath)
}
@@ -360,10 +362,15 @@ async function main() {
messageDispatch.set('hello2', hello2Handler);
messageDispatch.set('peer_message', peerMessageHandler);
const port = parseInt(Deno.env.get("PORT") ?? "443");
const certFile = Deno.env.get("TLS_CERT") ?? "/etc/letsencrypt/live/ddln.app/fullchain.pem";
const keyFile = Deno.env.get("TLS_KEY") ?? "/etc/letsencrypt/live/ddln.app/privkey.pem";
Deno.serve({
port: 6789,
cert: Deno.readTextFileSync("/etc/letsencrypt/live/ddln.app/fullchain.pem"),
key: Deno.readTextFileSync("/etc/letsencrypt/live/ddln.app/privkey.pem"),
hostname: "[::]",
port,
cert: Deno.readTextFileSync(certFile),
key: Deno.readTextFileSync(keyFile),
}, handler);
await devServerWatchFiles();

5
deno/dev.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
TLS_CERT=../certs/localhost.pem \
TLS_KEY=../certs/localhost-key.pem \
PORT=8443 \
deno run --inspect --allow-all --unstable-temporal --watch ddln_server.ts

BIN
deno_bootstrap/ddln_bootstrap Executable file

Binary file not shown.

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"

26
package-lock.json generated
View File

@@ -1,26 +0,0 @@
{
"name": "dandelion",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"typescript": "^5.5.4"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

View File

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

31
setup.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
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 tmux if needed
if ! command -v tmux &>/dev/null; then
echo "Installing tmux..."
brew install tmux
fi
# Install mkcert if needed
if ! command -v mkcert &>/dev/null; then
echo "Installing mkcert..."
brew install mkcert
fi
# Install the local CA into the system/browser trust stores
mkcert -install
# Generate certs for localhost
mkdir -p certs
mkcert -cert-file certs/localhost.pem -key-file certs/localhost-key.pem localhost 127.0.0.1
echo "Setup complete. Run deno/dev.sh and ddln_cli/dev.sh to start."

View File

@@ -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 } from "db";
import { arrayBufferToBase64, compressString } from "dataUtils";
import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap, getNotificationsForUser } from "db";
import { arrayBufferToBase64, compressString, decompressBuffer, base64ToArrayBuffer } from "dataUtils";
import { log, logID, renderLog, setLogVisibility } from "log"
declare let marked: any;
@@ -13,12 +13,12 @@ type PeerID = string;
class Post {
post_timestamp: Date;
post_id: string;
reply_to_id: string|null;
root_id: string|null;
author: string;
author_id: string;
text: string;
image_data: ArrayBuffer | null;
importedFrom: "twitter" | null;
importSource: any;
@@ -29,7 +29,9 @@ class Post {
post_timestamp: Date,
imageData: ArrayBuffer | null = null,
importedFrom: "twitter" | null = null,
importSource: any = null) {
importSource: any = null,
reply_to_id: string|null = null,
root_id: string|null = null) {
this.post_timestamp = post_timestamp;
this.post_id = generateID();
@@ -41,6 +43,8 @@ class Post {
this.importedFrom = importedFrom;
this.importSource = importSource;
this.reply_to_id = reply_to_id;
this.root_id = root_id;
}
}
@@ -118,6 +122,8 @@ export class App {
peername: string = '';
userID: string = '';
peerID: string = '';
replyToID: string|null = null;
replyRootID: string|null = null;
following: Set<string> = new Set();
posts: StoragePost[] = [];
isHeadless: boolean = false;
@@ -189,7 +195,7 @@ export class App {
let neededPostCount = neededPostIDs.length;
this.statusBar.updatePeerStatus(peerID, `need(${logID(userID)} | ${neededPostCount})`, { havePostCount: 0, neededPostCount: neededPostCount });
let neededPosts = await this.peerManager?.rpc.getPostsForUser(peerID, this.peerID, userID, neededPostIDs);
await this.peerManager?.rpc.getPostsForUser(peerID, this.peerID, userID, neededPostIDs);
}
else {
console.log.apply(null, log(`[app] Don't need any posts for user ${logID(userID)} from peer ${logID(peerID)}`));
@@ -236,15 +242,34 @@ export class App {
for (let userID of userIDs) {
// console.log.apply(null, log(`[app] announceUsers, got user:${userID} from peer ${sendingPeerID}`));
this.sync.addUserPeer(userID, sendingPeerID);
if (!(this.sync.shouldSyncUserID(userID) || (this.router.route === App.Route.USER && userID === this.router.userID))) {
console.log.apply(null, log(`[app] announceUser_rpc_response skipping user[${logID(userID)}] from[${logID(sendingPeerID)}]`));
continue;
const isUserOrPostRoute = (this.router.route & (App.Route.USER | App.Route.POST)) !== 0;
if (isUserOrPostRoute) {
if (userID !== this.router.userID) {
continue;
}
} else {
if (!this.sync.shouldSyncUserID(userID)) {
console.log.apply(null, log(`[app] announceUser_rpc_response skipping user[${logID(userID)}] from[${logID(sendingPeerID)}]`));
continue;
}
}
console.log.apply(null, log(`[app] calling getPostIDsForUser for user [${logID(userID)}] on peer [${logID(sendingPeerID)}]`));
this.statusBar.updatePeerStatus(sendingPeerID, `getPostIDs(${logID(userID)})⬆️`);
let postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID);
let postIDs = null;
if (this.router.route === App.Route.POST && this.router.userID == userID) {
postIDs = [this.router.postID];
} else {
postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID);
}
if (!postIDs) {
continue;
}
this.statusBar.updatePeerStatus(sendingPeerID, `syncing(${logID(userID)} ${postIDs.length})`);
@@ -306,6 +331,8 @@ export class App {
if (postIDs) {
return postIDs;
}
return [];
});
this.peerManager.registerRPC('getPostsForUser', async (requestingPeerID: string, userID: string, postIDs: string[]) => {
@@ -321,23 +348,16 @@ export class App {
}
return true;
// return posts;
// return postIDs;
});
this.peerManager.registerRPC('sendPostForUser', async (sendingPeerID: string, userID: string, post: Post) => {
console.log.apply(null, log(`[app] sendPostForUser got post[${logID(post.post_id)}] from peer[${logID(sendingPeerID)}] for user[${logID(userID)}] author[${post.author}] text[${post.text}]`));
// if (post.text === "image...") {
// debugger;
// }
let peerData = this.statusBar.getPeerData(sendingPeerID);
if (peerData) {
this.statusBar.updatePeerMessage(sendingPeerID, `⬇️${logID(userID)} ${peerData.havePostCount}/${peerData.neededPostCount}}`);
}
await this.sync.writePostForUser(userID, post);
// if (userID === this.userID) {
if (peerData) {
peerData.havePostCount++
@@ -351,14 +371,13 @@ export class App {
this.renderTimer = setTimeout(() => { this.render() }, 1000);
return true;
// }
});
this.statusBar.setMessageHTML("Connecting to ddln network...")
this.statusBar.setMessageHTML("Connecting to ddln...")
await this.peerManager.connect();
console.log.apply(null, log("*************** after peerManager.connect"));;
this.statusBar.setMessageHTML("Connected to ddln network...")
this.statusBar.setMessageHTML("Connected to ddln.")
@@ -509,28 +528,105 @@ export class App {
globalThis.URL.revokeObjectURL(url);
}
async importPostsForUser(userID: string, posts: string) {
async importPostsForUser(buffer: ArrayBuffer) {
const startTime = performance.now();
console.log.apply(null, log("Importing posts"));
const json = await decompressBuffer(buffer);
const data = JSON.parse(json);
}
let postsByUser: { [userID: string]: any[] };
let username = this.username;
let userID = this.userID;
async exportPostsForUser(userID: string) {
let posts = await getAllData(userID);
let output = [];
console.log.apply(null, log("Serializing images"));
for (let post of posts) {
let newPost = (post as any).data;
if (newPost.image_data) {
newPost.image_data = await arrayBufferToBase64(newPost.image_data);
}
output.push(newPost);
if (Array.isArray(data)) {
console.log.apply(null, log("Detected old export format"));
postsByUser = { [this.userID]: data };
} else {
console.log.apply(null, log("Detected new export format"));
const { username: importedUsername, userID: importedUserID, posts } = data;
username = importedUsername;
userID = importedUserID;
postsByUser = posts;
localStorage.setItem("dandelion_username", username);
localStorage.setItem("dandelion_id", userID);
}
let compressedData = await compressString(JSON.stringify(output));
let totalPostsImported = 0;
const userTimings: { [userID: string]: number } = {};
for (const [sourceUserID, posts] of Object.entries(postsByUser)) {
const userStartTime = performance.now();
const postList = posts as any[];
for (let post of postList) {
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(sourceUserID, postList);
totalPostsImported += postList.length;
userTimings[sourceUserID] = performance.now() - userStartTime;
}
const totalTime = performance.now() - startTime;
const userTimingsLog = Object.entries(userTimings)
.map(([uid, ms]) => `${logID(uid)}: ${ms.toFixed(2)}ms`)
.join(', ');
console.log.apply(null, log(`Imported ${totalPostsImported} posts from ${Object.keys(postsByUser).length} users in ${totalTime.toFixed(2)}ms (${userTimingsLog})`));
}
async exportPostsForUser() {
console.log.apply(null, log("Exporting all posts for all users"));
const exportStartTime = performance.now();
const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', ''))
.filter((userID): userID is string => userID !== undefined);
const postsByUser: { [userID: string]: any[] } = {};
const userTimings: { [userID: string]: number } = {};
for (const userID of knownUsers) {
const userStartTime = performance.now();
const posts = await getAllData(userID);
const output = [];
for (let post of posts) {
let newPost = (post as any).data;
if (newPost.image_data) {
newPost.image_data = await arrayBufferToBase64(newPost.image_data);
}
output.push(newPost);
}
if (output.length > 0) {
postsByUser[userID] = output;
}
userTimings[userID] = performance.now() - userStartTime;
}
const totalTime = performance.now() - exportStartTime;
const userTimingsLog = Object.entries(userTimings)
.map(([uid, ms]) => `${logID(uid)}: ${ms.toFixed(2)}ms`)
.join(', ');
console.log.apply(null, log(`Exported ${Object.keys(postsByUser).length} users in ${totalTime.toFixed(2)}ms (${userTimingsLog})`));
const exportData = {
username: this.username,
userID: this.userID,
posts: postsByUser
};
let compressedData = await compressString(JSON.stringify(exportData));
const d = new Date();
const timestamp = `${d.getFullYear()
@@ -540,8 +636,7 @@ export class App {
}_${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, `ddln_export_${timestamp}.json.gz`);
}
async importTweetArchive(userID: string, tweetArchive: any[]) {
@@ -708,7 +803,7 @@ export class App {
}
}
async createNewPost(userID: string, postText: string, mediaData?: ArrayBuffer, mimeType?: "image/png" | "image/gif" | "image/jpg" | "image/jpeg" | "video/mp4") {
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) {
console.log.apply(null, log("Not posting an empty string..."));
return;
@@ -716,14 +811,14 @@ export class App {
if (mediaData &&
(mimeType === 'image/jpg' || mimeType === 'image/jpeg' || mimeType === 'image/png') &&
(mediaData as ArrayBuffer).byteLength > 500 * 1024) {
(mediaData as ArrayBuffer).byteLength > 256 * 1024) {
let compressedImage = await this.compressImage(mediaData as ArrayBuffer, mimeType, 0.9);
if (compressedImage) {
mediaData = compressedImage as ArrayBuffer;
}
}
let post = new Post(this.username, userID, postText, new Date(), mediaData);
let post = new Post(this.username, userID, postText, new Date(), mediaData, null, null, replyToID, replyRootID);
// this.posts.push(post);
// localStorage.setItem(key, JSON.stringify(posts));
addData(userID, post);
@@ -734,27 +829,23 @@ export class App {
this.render();
}
getPeerID() {
let id = localStorage.getItem("peer_id");
if (!id) {
console.log.apply(null, log(`Didn't find a peer ID, generating one`));;
id = generateID();
localStorage.setItem("peer_id", id);
}
getPeerID(): string {
const existing = localStorage.getItem("peer_id");
if (existing) return existing;
console.log.apply(null, log(`Didn't find a peer ID, generating one`));
const id = generateID();
localStorage.setItem("peer_id", id);
return id;
}
getUserID() {
let id = localStorage.getItem("dandelion_id");
if (!id) {
console.log.apply(null, log(`Didn't find a user ID, generating one`));;
id = generateID();
localStorage.setItem("dandelion_id", id);
}
getUserID(): string {
const existing = localStorage.getItem("dandelion_id");
if (existing) return existing;
console.log.apply(null, log(`Didn't find a user ID, generating one`));
const id = generateID();
localStorage.setItem("dandelion_id", id);
return id;
}
@@ -905,6 +996,15 @@ export class App {
});
}
hideInfo() {
const infoElement = document.getElementById('info');
if (infoElement && infoElement.style.display !== 'none') {
infoElement.style.display = 'none';
setLogVisibility(false);
this.showLog = false;
}
}
showInfo() {
let infoElement = document.getElementById('info');
@@ -951,6 +1051,15 @@ export class App {
let profileButton = this.div('profile-button');
profileButton.addEventListener('click', e => globalThis.location.href = `${globalThis.location.origin}/user/${this.userID}`)
let notificationsButton = this.div('notifications-button');
notificationsButton.addEventListener('click', () => {
this.hideInfo();
navContainer.classList.remove('active');
history.pushState({}, '', '/notifications');
this.getRoute();
this.render();
});
let monitorButton = this.div('monitor_button');
monitorButton.addEventListener('click', async () => {
navContainer.classList.toggle('active');
@@ -961,16 +1070,26 @@ 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(buffer);
importFilePicker.value = '';
this.userID = localStorage.getItem("dandelion_id") || this.userID;
this.username = localStorage.getItem("dandelion_username") || this.username;
this.render();
});
let exportButton = this.button("export-button");
exportButton.addEventListener('click', async e => {
await this.exportPostsForUser(this.userID)
await this.exportPostsForUser()
});
let composeButton = this.div('compose-button');
composeButton.addEventListener('click', e => {
document.getElementById('compose')!.style.display = 'block';
document.getElementById('textarea_post')?.focus();
this.enterCompose();
});
@@ -978,11 +1097,13 @@ export class App {
filePicker?.addEventListener('change', async (event: any) => {
for (let file of filePicker.files as any) {
let buffer = await file.arrayBuffer();
await this.createNewPost(this.userID, 'image...', buffer, file.type);
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.
filePicker.value = '';
this.exitCompose();
});
let filePickerLabel = document.getElementById('file-input-label');
@@ -1019,25 +1140,37 @@ export class App {
// clearPostsButton.addEventListener('click', () => { clearData(userID); posts = []; this.render() });
let postButton = document.getElementById("button_post") as HTMLButtonElement;
let cancelPostButton = document.getElementById("button_cancel_post") as HTMLElement;
let postText = document.getElementById("textarea_post") as HTMLTextAreaElement;
if (!(postButton && postText)) {
let postButton = document.getElementById("button_post") as HTMLButtonElement;
if (!(cancelPostButton && postButton && postText)) {
throw new Error();
}
cancelPostButton.addEventListener('click', (e) => {
this.exitCompose();
});
postText.addEventListener('paste', async (e) => {
const dataTransfer = e.clipboardData
const file = dataTransfer!.files[0];
let buffer = await file.arrayBuffer();
await this.createNewPost(this.userID, 'image...', buffer, file.type as any);
await this.createPost(this.userID, 'image...', buffer, file.type as any, this.replyToID, this.replyRootID);
});
postButton.addEventListener("click", () => {
this.createNewPost(userID, postText.value);
postText.value = "";
document.getElementById('compose')!.style.display = 'none';
const submitPost = () => {
this.createPost(userID, postText.value, undefined, undefined, this.replyToID, this.replyRootID);
this.exitCompose();
};
postButton.addEventListener("click", submitPost);
postText.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
submitPost();
}
});
// updateApp.addEventListener("click", () => {
@@ -1051,6 +1184,34 @@ export class App {
}
// Change this all to a template so we're not toggling state in this crazy way!
enterCompose(replyToID: string|null = null, replyRootID: string|null = null) {
this.replyToID = replyToID;
this.replyRootID = replyRootID;
if (replyToID) {
this.renderComposeReplyArea(replyToID);
document.getElementById("compose-reply-area")!.style.display = "block";
}
document.getElementById('compose')!.style.display = 'flex';
document.getElementById('textarea_post')?.focus();
document.getElementById('compose-dimmer')?.classList.add("compose-dimmer-dimmed");
document.body.classList.add("no-scroll");
}
exitCompose() {
this.replyToID = null;
this.replyRootID = null;
let postText = document.getElementById("textarea_post") as HTMLTextAreaElement;
postText.value = "";
document.getElementById('compose')!.style.display = 'none';
document.getElementById('compose-dimmer')?.classList.remove("compose-dimmer-dimmed");
document.getElementById("compose-reply-area")!.style.display = "none";
document.body.classList.remove("no-scroll");
}
async getPostsForFeed() {
// get N posts from each user and sort them by date.
@@ -1078,17 +1239,23 @@ export class App {
this.timerStart();
let posts: StoragePost[] = [];
// if (postID) {
// posts = await gePostForUser(userID, postID);
// }
if (postID) {
const post = await getPostForUser(userID, postID);
posts = await getData(userID, new Date(2022, 8), new Date());
posts = post ? [post] : [];
if (posts.length > 0) {
} else {
posts = await getData(userID, new Date(2022, 8), new Date());
}
if (posts?.length) {
console.log.apply(null, log(`Loaded ${posts.length} posts in ${this.timerDelta().toFixed(2)}ms`));;
return posts;
}
console.log.apply(null, log(`No posts found for userID:${userID}, postID:${postID}`));;
// posts = await createTestData2(userID);
// log("Adding test data...");
@@ -1230,6 +1397,8 @@ export class App {
await this.initDB();
window.addEventListener('popstate', () => { this.hideInfo(); this.getRoute(); this.render(); });
this.connectURL = `${document.location.origin}/connect/${this.userID}`;
document.getElementById('connectURL')!.innerHTML = `<a href="${this.connectURL}">connect</a>`;
@@ -1374,6 +1543,11 @@ export class App {
compose.style.display = "none";
break;
}
case App.Route.NOTIFICATIONS: {
this.posts = await getNotificationsForUser(this.userID);
document.getElementById('compose')!.style.display = "none";
break;
}
default: {
console.log.apply(null, log("Render: got a route I didn't understand. Rendering HOME:", this.router.route));
this.posts = await this.loadPostsFromStorage(this.userID) ?? [];
@@ -1384,6 +1558,21 @@ export class App {
if (!contentDiv) {
throw new Error();
}
if (this.router.route === App.Route.NOTIFICATIONS) {
contentDiv.innerHTML = '';
if (this.posts.length === 0) {
contentDiv.innerHTML = '<div style="padding:20px;color:gray">No notifications yet.</div>';
} else {
let first = true;
for (const postRecord of this.posts) {
contentDiv.appendChild(this.renderNotificationItem(postRecord.data, first));
first = false;
}
}
return;
}
if (this.posts.length === 0) {
this.renderWelcome(contentDiv as HTMLDivElement);
return;
@@ -1414,13 +1603,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) {
@@ -1439,6 +1634,39 @@ export class App {
contentDiv.appendChild(fragment);
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) => {
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, currentPost.root_id ? 1 : 0);
}
let renderTime = this.timerDelta();
console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`));;
@@ -1459,7 +1687,75 @@ export class App {
this.render();
}
renderPost(post: Post, first: boolean) {
async renderComposeReplyArea(replyToID: string) {
const composeReplyArea = document.getElementById('compose-reply-area') as HTMLElement;
composeReplyArea.innerHTML = '';
const record = await getPostById(replyToID);
if (!record) return;
const postData = record.data;
const authorDiv = document.createElement('div');
authorDiv.className = 'compose-reply-author';
authorDiv.textContent = `@${postData.author}`;
composeReplyArea.appendChild(authorDiv);
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);
}
renderNotificationItem(post: Post, first: boolean): HTMLElement {
const el = document.createElement('div');
el.style.cssText = 'padding: 8px 10px; cursor: pointer;';
if (!first) el.innerHTML = '<hr>';
const authorDiv = document.createElement('div');
authorDiv.className = 'compose-reply-author';
authorDiv.textContent = `@${post.author} · ${post.post_timestamp.toLocaleDateString()}`;
el.appendChild(authorDiv);
const previewDiv = document.createElement('div');
previewDiv.className = 'compose-reply-preview';
const textDiv = document.createElement('div');
textDiv.className = 'compose-reply-preview-text';
textDiv.style.maxHeight = 'none';
textDiv.innerHTML = this.markedAvailable ? marked.parse(post.text) : post.text;
previewDiv.appendChild(textDiv);
if (post.image_data) {
const img = document.createElement('img');
img.className = 'compose-reply-preview-image';
img.src = URL.createObjectURL(new Blob([post.image_data as ArrayBuffer]));
previewDiv.appendChild(img);
}
el.appendChild(previewDiv);
el.addEventListener('click', () => {
history.pushState({}, '', `/user/${post.author_id}/post/${post.post_id}`);
this.getRoute();
this.render();
});
return el;
}
renderPost(post: Post, first: boolean, replyCount: number = 0) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
}
@@ -1478,6 +1774,14 @@ export class App {
await navigator.clipboard.writeText(shareUrl)
};
let replyButton = document.createElement('button'); replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply';
replyButton.onclick = async () => {
console.log(`replying to post ${post.post_id}`);
const rootID = post.root_id ?? post.post_id;
this.enterCompose(post.post_id, rootID);
};
let ownPost = post.author_id === this.userID;
let markdown = post.text;
@@ -1498,53 +1802,66 @@ 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>
${ownPost ? `<span id="deleteButton"></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>`
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);
// containerDiv.querySelector('#editButton')?.appendChild(editButton);
}
containerDiv.querySelector('#shareButton')?.appendChild(shareButton);
containerDiv.querySelector('#replyButton')?.appendChild(replyButton);
if (!("image_data" in post && post.image_data)) {
// containerDiv.appendChild(timestampDiv);
return containerDiv;
// return null;
let hasImage = ("image_data" in post && post.image_data);
if (hasImage) {
let image = document.createElement("img");
image.title = `${(post.image_data!.byteLength / 1024 / 1024).toFixed(2)}MBytes`;
// const blob = new Blob([post.image_data as ArrayBuffer], { type: 'image/png' });
const blob = new Blob([post.image_data as ArrayBuffer]);
const url = URL.createObjectURL(blob);
image.onload = () => {
// URL.revokeObjectURL(url);
};
image.src = url;
// image.src = image.src = "data:image/png;base64," + post.image;
image.className = "postImage";
// image.onclick = () => { App.maximizeElement(image) };
containerDiv.querySelector('#image')?.appendChild(image);
}
let image = document.createElement("img");
image.title = `${(post.image_data.byteLength / 1024 / 1024).toFixed(2)}MBytes`;
// const blob = new Blob([post.image_data as ArrayBuffer], { type: 'image/png' });
const blob = new Blob([post.image_data as ArrayBuffer]);
const url = URL.createObjectURL(blob);
image.onload = () => {
// URL.revokeObjectURL(url);
};
image.src = url;
// image.src = image.src = "data:image/png;base64," + post.image;
image.className = "postImage";
// image.onclick = () => { App.maximizeElement(image) };
containerDiv.appendChild(image);
// containerDiv.appendChild(timestampDiv);
return containerDiv;
}
@@ -1563,6 +1880,15 @@ export class App {
let path = document.location.pathname;
console.log.apply(null, log("router: path ", path));
if (path === '/notifications') {
this.router.route = App.Route.NOTIFICATIONS;
this.router.userID = '';
this.router.postID = '';
this.router.mediaID = '';
console.log.apply(null, log("router: NOTIFICATIONS"));
return;
}
const regex = "(user/([a-zA-Z0-9\-]+)/?(post/([a-zA-Z0-9\-]+)?/?)?(media/([0-9]+)?)?)|(connect/([a-zA-Z0-9\-]+))";
const match = path.match(new RegExp(regex));
@@ -1585,6 +1911,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]));
@@ -1602,11 +1933,12 @@ export class App {
export namespace App {
export enum Route {
USER,
POST,
MEDIA,
GROUP,
HOME,
CONNECT,
USER = 1,
POST = 2,
MEDIA = 4,
GROUP = 8,
HOME = 16,
CONNECT = 32,
NOTIFICATIONS = 64,
}
};

View File

@@ -6,7 +6,6 @@
import { generateID } from "IDUtils";
import { log, logID } from "log";
// import { App } from "./App";
// Use a broadcast channel to only have one peer manager for multiple tabs,
// then we won't need to have a session ID as all queries for a peerID will be coming from the same peer manager
@@ -22,7 +21,6 @@ export class PeerManager {
routingTable: Map<string, string>;
peers: Map<string, PeerConnection>;
// private signaler: Signaler;
searchQueryFunctions: Map<string, Function> = new Map();
RPC_remote: Map<string, Function> = new Map();
rpc: { [key: string]: Function } = {};
@@ -46,14 +44,6 @@ export class PeerManager {
reconnectTimer: number | null = null;
peerStateSuperlog: boolean = true;
// async watchdog() {
// // Check that we're connected to at least N peers. If not, reconnect to the bootstrap server.
// if (this.peers.size === 0) {
// await this.sendHello2();
// }
// }
animals = ['shrew', 'jerboa', 'lemur', 'weasel', 'possum', 'possum', 'marmoset', 'planigale', 'mole', 'narwhal'];
adjectives = ['snazzy', 'whimsical', 'jazzy', 'bonkers', 'wobbly', 'spiffy', 'chirpy', 'zesty', 'bubbly', 'perky', 'sassy'];
snakes = ['mamba', 'cobra', 'python', 'viper', 'krait', 'sidewinder', 'constrictor', 'boa', 'asp', 'anaconda', 'krait']
@@ -108,6 +98,18 @@ export class PeerManager {
}
onPeerMessageFromPeer(remotePeerID: string, messageJSON: any) {
let targetPeer = this.peers.get(messageJSON.to);
if (!targetPeer) {
console.log.apply(null, log("[PeerManager] Coulnd't find peer for onPeerMessageFromPeer:", messageJSON.to));
return;
}
targetPeer.send(messageJSON);
}
onWebsocketMessage(event: MessageEvent) {
let messageJSON = event.data;
let message: any = null;
@@ -149,10 +151,11 @@ export class PeerManager {
if (!peerConnection) {
let remotePeerID = message.from;
let newPeer = new PeerConnection(this, remotePeerID, this.websocketSendPeerMessage.bind(this));
if (this._isBootstrapPeer) {
newPeer.setPolite(false);
}
newPeer.setPoliteFromID(remotePeerID, true);
peerConnection = newPeer;
this.peers.set(newPeer.remotePeerID, newPeer);
this.onConnectRequest(newPeer);
@@ -553,12 +556,7 @@ class PeerConnection {
static config = {
iceServers: [
{ urls: "stun:ddln.app" },
// { urls: "turn:ddln.app", username: "a", credential: "b" },
{ urls: "stun:stun.l.google.com" }, // keeping this for now as my STUN server is not returning ipv6
{ urls: "stun:stun1.l.google.com" },
{ urls: "stun:stun2.l.google.com" },
{ urls: "stun:stun3.l.google.com" },
{ urls: "stun:stun4.l.google.com" },
{ urls: "turn:ddln.app", username: "ddln1", credential: "ddln1" },
],
};
// longMessageQueue: string[] = [];
@@ -585,6 +583,12 @@ class PeerConnection {
this.polite = polite;
}
setPoliteFromID(peerID: PeerID, remote = false) {
let polite = (parseInt(peerID.charAt(peerID.length-1), 16) % 2 == 0) && !remote;
this.setPolite(polite);
}
setupDataChannel() {
if (!this.dataChannel) {
throw new Error();
@@ -718,6 +722,7 @@ class PeerConnection {
try {
this.makingOffer = true;
this.setPoliteFromID(this.peerManager.peerID);
await this.rtcPeer.setLocalDescription();
if (!this.rtcPeer.localDescription) {
@@ -806,12 +811,6 @@ class PeerConnection {
} catch (err) {
console.error(err);
}
// };
// */
}
disconnect() {
@@ -889,15 +888,12 @@ class PeerConnection {
}
}
call(functionName: string, args: any) {
let transactionID = generateID(); // make this faster as we will only ever have a small number of in-flight queries on a peer
// Think about a timeout here to auto reject it after a while.
let promise = new Promise((resolve, reject) => {
this.pendingRPCs.set(transactionID, { resolve, reject, functionName });
setTimeout(() => reject(`function:${functionName}[${transactionID}] failed to resolve after 10 seconds.`), 10_000);
let timeoutSeconds = 10;
setTimeout(() => reject(`function:${functionName}[${transactionID}] failed to resolve after ${timeoutSeconds} seconds.`), timeoutSeconds * 1000);
});
let message = {

View File

@@ -2,7 +2,7 @@ import { openDatabase, getData, addData, addDataArray, clearData, deleteData, me
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) => {
const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result),
@@ -122,7 +122,9 @@ export class Sync {
'e01eff89-5100-4b35-af4c-1c1bcb007dd0',
'194696a2-d850-4bb0-98f7-47416b3d1662',
'f6b21eb1-a0ff-435b-8efc-6a3dd70c0dca',
'dd1d92aa-aa24-4166-a925-94ba072a9048'
'dd1d92aa-aa24-4166-a925-94ba072a9048',
'290dbb4f-6ce1-491a-b90d-51d8efcd3d60',
'3b32d0eb-94d5-49c4-a43b-0959a9fbb015'
]);
getFollowing(userID: string): string[] {

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) => {
const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result),
@@ -36,7 +36,21 @@ export async function compressString(input: string) {
const compressedArray = await new Response(compressionStream.readable).arrayBuffer();
// Convert the compressed data to a Uint8Array
return new Uint8Array(compressedArray);
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

186
src/db.ts
View File

@@ -15,7 +15,7 @@ const followingStoreName: string = "following"
const profileStoreName: string = "profiles"
let keyBase = "dandelion_posts_v1_"
let key = "";
let version = 1;
let version = 3;
interface IDBRequestEvent<T = any> extends Event {
@@ -28,13 +28,22 @@ type DBError = Event & {
};
async function upgrade_0to1(db: IDBDatabase) {
async function upgrade_0to1(db: IDBDatabase, _transaction: IDBTransaction) {
let postsStore = db.createObjectStore(postStoreName, { keyPath: "id", autoIncrement: true });
postsStore.createIndex("datetimeIndex", "post_timestamp", { unique: false });
postsStore.createIndex("postIDIndex", "data.post_id", { unique: true });
}
async function upgrade_1to2(_db: IDBDatabase, _transaction: IDBTransaction) {
// Was broken for some clients — index creation moved to upgrade_2to3
}
async function upgrade_2to3(_db: IDBDatabase, transaction: IDBTransaction) {
const postsStore = transaction.objectStore(postStoreName);
if (!postsStore.indexNames.contains("postReplyIndex")) {
postsStore.createIndex("postReplyIndex", "data.reply_to_id", { unique: false });
}
}
// let following = ['b38b623c-c3fa-4351-9cab-50233c99fa4e'];
@@ -51,13 +60,13 @@ async function upgrade_0to1(db: IDBDatabase) {
// let tombstones = ['b38b623c-c3fa-4351-9cab-50233c99fa4e'];
async function upgrade_1to2(db: IDBDatabase) {
let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
let profileStore = db.createObjectStore(profileStoreName, { keyPath: "id", autoIncrement: true });
let tombstoneStore = db.createObjectStore(tombstoneStoreName, { keyPath: "id", autoIncrement: true });
tombstoneStore.createIndex("postIDIndex", "data.post_id", { unique: true });
// async function upgrade_1to2(db: IDBDatabase) {
// let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
// let profileStore = db.createObjectStore(profileStoreName, { keyPath: "id", autoIncrement: true });
// let tombstoneStore = db.createObjectStore(tombstoneStoreName, { keyPath: "id", autoIncrement: true });
// tombstoneStore.createIndex("postIDIndex", "data.post_id", { unique: true });
}
// }
// async function upgrade_1to2(db: IDBDatabase) {
// console.log("Upgrading database from 1 to 2");
@@ -86,15 +95,15 @@ async function upgrade_1to2(db: IDBDatabase) {
// }
// }
async function upgrade_2to3(db: IDBDatabase) {
let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
}
// async function upgrade_2to3(db: IDBDatabase) {
// let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
// }
let upgrades = new Map([
[0, upgrade_0to1],
[1, upgrade_1to2],
[2, upgrade_2to3]
[2, upgrade_2to3],
]);
export function openDatabase(userID: string): Promise<IDBDatabase> {
@@ -110,13 +119,15 @@ export function openDatabase(userID: string): Promise<IDBDatabase> {
request.onupgradeneeded = async (event: IDBVersionChangeEvent) => {
const db: IDBDatabase = (event.target as IDBOpenDBRequest).result;
const transaction: IDBTransaction = (event.target as IDBOpenDBRequest).transaction!;
let upgradeFunction = upgrades.get(event.oldVersion);
if (!upgradeFunction) {
throw new Error(`db: Don't have an upgrade function to go from version ${event.oldVersion} to version ${event.newVersion}`);
for (let v = event.oldVersion; v < (event.newVersion ?? version); v++) {
const upgradeFunction = upgrades.get(v);
if (!upgradeFunction) {
throw new Error(`db: Don't have an upgrade function to go from version ${v} to version ${v + 1}`);
}
await upgradeFunction(db, transaction);
}
// debugger;
await upgradeFunction(db);
};
request.onsuccess = (event: Event) => {
@@ -198,15 +209,28 @@ export async function clearData(userID: string) {
}
}
// TODO - this function can return before the data is stored!
export async function addDataArray(userID: string, array: any[]): Promise<void> {
try {
const { db, transaction, store } = await getDBTransactionStore(userID, "readwrite");
transaction.onerror = (event: Event) => {
console.error('Error in adding data:', event);
}
let completionPromise = new Promise<void>((resolve, reject) => {
transaction.oncomplete = (event: Event) => {
resolve();
};
transaction.onerror = (event: Event) => {
console.error('Error in adding data:', event);
reject();
}
});
// let count = 0;
array.reverse();
@@ -230,6 +254,8 @@ export async function addDataArray(userID: string, array: any[]): Promise<void>
// }
}
return completionPromise;
} catch (error) {
console.error('Error in opening database:', error);
@@ -342,7 +368,21 @@ export async function mergeDataArray(userID: string, array: any[]): Promise<void
}
export async function getPostForUser(userID: string, postID: string): Promise<any | undefined> {
const { store } = await getDBTransactionStore(userID);
const index = store.index("postIDIndex");
return new Promise((resolve, reject) => {
const getPostRequest = index.get(postID);
getPostRequest.onsuccess = () => {
resolve(getPostRequest.result);
};
getPostRequest.onerror = () => {
console.error('Transaction failed:', getPostRequest.error?.message);
reject(getPostRequest.error);
};
});
}
@@ -423,6 +463,112 @@ 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 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[]> {
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 getNotificationsForUser(userID: string): Promise<any[]> {
const userPostIds = new Set(await getAllIds(userID));
const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', '')).filter(Boolean) as string[];
let notifications: any[] = [];
for (const dbUserID of knownUsers) {
try {
const { store } = await getDBTransactionStore(dbUserID);
const index = store.index("postReplyIndex");
const results: any[] = await new Promise((resolve, reject) => {
const req = index.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const r of results) {
if (r.data.reply_to_id && userPostIds.has(r.data.reply_to_id)) {
notifications.push(r);
}
}
} catch (_) {}
}
notifications.sort((a, b) =>
new Date(b.data.post_timestamp).getTime() - new Date(a.data.post_timestamp).getTime()
);
return notifications;
}
export async function getPostsByIds(userID: string, postIDs: string[]) {
const { store } = await getDBTransactionStore(userID);
const index = store.index("postIDIndex");

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) => {
const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result),
@@ -918,7 +918,7 @@ class App {
}_${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[]) {

126
src/sw.ts
View File

@@ -38,102 +38,44 @@ self.addEventListener("install", (e: any) => {
});
async function staleWhileRevalidate(event: any) {
let cache = await caches.open(cacheName);
let response = await cache.match(event.request);
if (response) {
debugLog ? console.log('Service Worker: Cache hit', event.request.url) : null;
async function fetchAndUpdateCache(request: Request): Promise<Response> {
debugLog && console.log('Service Worker: Fetching', request.url);
let networkResponse: Response;
try {
networkResponse = await fetch(request);
} catch (e) {
debugLog && console.log('Service Worker: Failed to fetch', e);
return new Response('Network error occurred', {
status: 404,
statusText: 'Cache miss and fetch failed',
headers: { 'Content-Type': 'text/plain' }
});
}
const fetchPromise = (async () => {
debugLog ? console.log('Service Worker: Fetching', event.request.url) : null;
let networkResponse = null;
try {
networkResponse = await fetch(event.request);
} catch (e) {
debugLog ? console.log('Service Worker: Failed to fetch', e) : null;
return new Response('Network error occurred', {
status: 404,
statusText: 'Cache miss and fetch failed',
headers: { 'Content-Type': 'text/plain' }
});
}
debugLog ? console.log('Service Worker: Updating cache', event.request.url) : null;
try {
await cache.put(event.request, networkResponse.clone());
} catch (e) {
debugLog ? console.log('Service Worker: failed to update cache', event.request.url, e) : null;
}
debugLog ? console.log('Service Worker: Returning networkResponse', event.request.url) : null;
return networkResponse;
})();
debugLog ? console.log('Service Worker: Returning return response || fetchPromise', event.request.url) : null;
return response || fetchPromise;
// if (networkResponse) {
// cache.put(event.request, networkResponse.clone())
// return networkResponse;
// }
// caches.open(cacheName)
// .then(function (cache) {
// return cache.match(event.request)
// .then(function (response) {
// var fetchPromise = fetch(event.request)
// .then(function (networkResponse) {
// cache.put(event.request, networkResponse.clone());
// return networkResponse;
// });
// return response || fetchPromise;
// });
// })
debugLog && console.log('Service Worker: Updating cache', request.url);
const cache = await caches.open(cacheName);
try {
await cache.put(request, networkResponse.clone());
} catch (e) {
debugLog && console.log('Service Worker: failed to update cache', request.url, e);
}
return networkResponse;
}
// async function responder(event: any) {
// debugLog ? console.log('Fetching', event.request.url) : null;
// let response = await fetch(event.request);
// if (!response) {
// debugLog ? console.log('Fetch failed, falling back to cache', event.request.url) : null;
// let cacheMatch = await caches.match(event.request);
// if (!cacheMatch) {
// // DUnno what to return here!
// }
// return cacheMatch;
// }
// if (response.status === 206) {
// debugLog ? console.log('Not caching partial content') : null;
// return response;
// }
// debugLog ? console.log('Fetch successful, updating cache', event.request.url) : null;
// const cache = await caches.open(cacheName);
// try {
// cache.put(event.request, response.clone()).catch((error) => debugLog ? console.log('failed to cache', event.request, error)) : null;
// } catch (e) {
// console.log('failed to cache', event.request)
// }
// return response;
// }
self.addEventListener('fetch', function (event: any) {
event.respondWith(staleWhileRevalidate(event));
// event.respondWith(responder(event));
const networkFetch = fetchAndUpdateCache(event.request);
event.respondWith(async function () {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
if (cached) {
debugLog && console.log('Service Worker: Cache hit', event.request.url);
return cached;
}
return networkFetch;
}());
// Keep the SW alive until the background cache update finishes
event.waitUntil(networkFetch);
});
addEventListener("message", async (e) => {

View File

@@ -1,11 +1,11 @@
import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync";
import { openDatabase, getData, addData, deleteData, getAllData } from "db";
import { arrayBufferToBase64, compressString } from "dataUtils";
import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap, getNotificationsForUser } 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) {
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_id = generateID();
this.author = author;
@@ -14,6 +14,8 @@ class Post {
this.image_data = imageData;
this.importedFrom = importedFrom;
this.importSource = importSource;
this.reply_to_id = reply_to_id;
this.root_id = root_id;
}
}
class StatusBar {
@@ -68,6 +70,8 @@ export class App {
this.peername = '';
this.userID = '';
this.peerID = '';
this.replyToID = null;
this.replyRootID = null;
this.following = new Set();
this.posts = [];
this.isHeadless = false;
@@ -139,7 +143,7 @@ export class App {
console.log.apply(null, log(`[app] Need (${neededPostIDs.length}) posts for user ${logID(userID)} from peer ${logID(peerID)}`));
let neededPostCount = neededPostIDs.length;
this.statusBar.updatePeerStatus(peerID, `need(${logID(userID)} | ${neededPostCount})`, { havePostCount: 0, neededPostCount: neededPostCount });
let neededPosts = await this.peerManager?.rpc.getPostsForUser(peerID, this.peerID, userID, neededPostIDs);
await this.peerManager?.rpc.getPostsForUser(peerID, this.peerID, userID, neededPostIDs);
}
else {
console.log.apply(null, log(`[app] Don't need any posts for user ${logID(userID)} from peer ${logID(peerID)}`));
@@ -172,13 +176,30 @@ export class App {
for (let userID of userIDs) {
// console.log.apply(null, log(`[app] announceUsers, got user:${userID} from peer ${sendingPeerID}`));
this.sync.addUserPeer(userID, sendingPeerID);
if (!(this.sync.shouldSyncUserID(userID) || (this.router.route === App.Route.USER && userID === this.router.userID))) {
console.log.apply(null, log(`[app] announceUser_rpc_response skipping user[${logID(userID)}] from[${logID(sendingPeerID)}]`));
continue;
const isUserOrPostRoute = (this.router.route & (App.Route.USER | App.Route.POST)) !== 0;
if (isUserOrPostRoute) {
if (userID !== this.router.userID) {
continue;
}
}
else {
if (!this.sync.shouldSyncUserID(userID)) {
console.log.apply(null, log(`[app] announceUser_rpc_response skipping user[${logID(userID)}] from[${logID(sendingPeerID)}]`));
continue;
}
}
console.log.apply(null, log(`[app] calling getPostIDsForUser for user [${logID(userID)}] on peer [${logID(sendingPeerID)}]`));
this.statusBar.updatePeerStatus(sendingPeerID, `getPostIDs(${logID(userID)})⬆️`);
let postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID);
let postIDs = null;
if (this.router.route === App.Route.POST && this.router.userID == userID) {
postIDs = [this.router.postID];
}
else {
postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID);
}
if (!postIDs) {
continue;
}
this.statusBar.updatePeerStatus(sendingPeerID, `syncing(${logID(userID)} ${postIDs.length})`);
console.log.apply(null, log(`[app] Got (${postIDs.length}) post IDs for user [${logID(userID)}] from peer [${logID(sendingPeerID)}]`));
this.addPostIDsToSyncQueue(userID, sendingPeerID, postIDs);
@@ -223,6 +244,7 @@ export class App {
if (postIDs) {
return postIDs;
}
return [];
});
this.peerManager.registerRPC('getPostsForUser', async (requestingPeerID, userID, postIDs) => {
let posts = await this.sync.getPostsForUser(userID, postIDs);
@@ -234,20 +256,14 @@ export class App {
await this.peerManager?.rpc.sendPostForUser(requestingPeerID, this.peerID, userID, post);
}
return true;
// return posts;
// return postIDs;
});
this.peerManager.registerRPC('sendPostForUser', async (sendingPeerID, userID, post) => {
console.log.apply(null, log(`[app] sendPostForUser got post[${logID(post.post_id)}] from peer[${logID(sendingPeerID)}] for user[${logID(userID)}] author[${post.author}] text[${post.text}]`));
// if (post.text === "image...") {
// debugger;
// }
let peerData = this.statusBar.getPeerData(sendingPeerID);
if (peerData) {
this.statusBar.updatePeerMessage(sendingPeerID, `⬇️${logID(userID)} ${peerData.havePostCount}/${peerData.neededPostCount}}`);
}
await this.sync.writePostForUser(userID, post);
// if (userID === this.userID) {
if (peerData) {
peerData.havePostCount++;
this.statusBar.updatePeerMessage(sendingPeerID, `⬇️${logID(userID)} ${peerData.havePostCount}/${peerData.neededPostCount}}`);
@@ -257,13 +273,12 @@ export class App {
}
this.renderTimer = setTimeout(() => { this.render(); }, 1000);
return true;
// }
});
this.statusBar.setMessageHTML("Connecting to ddln network...");
this.statusBar.setMessageHTML("Connecting to ddln...");
await this.peerManager.connect();
console.log.apply(null, log("*************** after peerManager.connect"));
;
this.statusBar.setMessageHTML("Connected to ddln network...");
this.statusBar.setMessageHTML("Connected to ddln.");
if (this.isBootstrapPeer) {
return;
}
@@ -369,23 +384,88 @@ export class App {
document.body.removeChild(link);
globalThis.URL.revokeObjectURL(url);
}
async importPostsForUser(userID, posts) {
}
async exportPostsForUser(userID) {
let posts = await getAllData(userID);
let output = [];
console.log.apply(null, log("Serializing images"));
for (let post of posts) {
let newPost = post.data;
if (newPost.image_data) {
newPost.image_data = await arrayBufferToBase64(newPost.image_data);
}
output.push(newPost);
async importPostsForUser(buffer) {
const startTime = performance.now();
console.log.apply(null, log("Importing posts"));
const json = await decompressBuffer(buffer);
const data = JSON.parse(json);
let postsByUser;
let username = this.username;
let userID = this.userID;
if (Array.isArray(data)) {
console.log.apply(null, log("Detected old export format"));
postsByUser = { [this.userID]: data };
}
let compressedData = await compressString(JSON.stringify(output));
else {
console.log.apply(null, log("Detected new export format"));
const { username: importedUsername, userID: importedUserID, posts } = data;
username = importedUsername;
userID = importedUserID;
postsByUser = posts;
localStorage.setItem("dandelion_username", username);
localStorage.setItem("dandelion_id", userID);
}
let totalPostsImported = 0;
const userTimings = {};
for (const [sourceUserID, posts] of Object.entries(postsByUser)) {
const userStartTime = performance.now();
const postList = posts;
for (let post of postList) {
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(sourceUserID, postList);
totalPostsImported += postList.length;
userTimings[sourceUserID] = performance.now() - userStartTime;
}
const totalTime = performance.now() - startTime;
const userTimingsLog = Object.entries(userTimings)
.map(([uid, ms]) => `${logID(uid)}: ${ms.toFixed(2)}ms`)
.join(', ');
console.log.apply(null, log(`Imported ${totalPostsImported} posts from ${Object.keys(postsByUser).length} users in ${totalTime.toFixed(2)}ms (${userTimingsLog})`));
}
async exportPostsForUser() {
console.log.apply(null, log("Exporting all posts for all users"));
const exportStartTime = performance.now();
const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', ''))
.filter((userID) => userID !== undefined);
const postsByUser = {};
const userTimings = {};
for (const userID of knownUsers) {
const userStartTime = performance.now();
const posts = await getAllData(userID);
const output = [];
for (let post of posts) {
let newPost = post.data;
if (newPost.image_data) {
newPost.image_data = await arrayBufferToBase64(newPost.image_data);
}
output.push(newPost);
}
if (output.length > 0) {
postsByUser[userID] = output;
}
userTimings[userID] = performance.now() - userStartTime;
}
const totalTime = performance.now() - exportStartTime;
const userTimingsLog = Object.entries(userTimings)
.map(([uid, ms]) => `${logID(uid)}: ${ms.toFixed(2)}ms`)
.join(', ');
console.log.apply(null, log(`Exported ${Object.keys(postsByUser).length} users in ${totalTime.toFixed(2)}ms (${userTimingsLog})`));
const exportData = {
username: this.username,
userID: this.userID,
posts: postsByUser
};
let compressedData = await compressString(JSON.stringify(exportData));
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')}`;
this.downloadBinary(compressedData, `ddln_${this.username}_export_${timestamp}.json.gz`);
this.downloadBinary(compressedData, `ddln_export_${timestamp}.json.gz`);
}
async importTweetArchive(userID, tweetArchive) {
console.log.apply(null, log("Importing tweet archive"));
@@ -512,20 +592,20 @@ export class App {
return null;
}
}
async createNewPost(userID, postText, mediaData, mimeType) {
async createPost(userID, postText, mediaData, mimeType, replyToID = null, replyRootID = null) {
if ((typeof postText !== "string") || postText.length === 0) {
console.log.apply(null, log("Not posting an empty string..."));
return;
}
if (mediaData &&
(mimeType === 'image/jpg' || mimeType === 'image/jpeg' || mimeType === 'image/png') &&
mediaData.byteLength > 500 * 1024) {
mediaData.byteLength > 256 * 1024) {
let compressedImage = await this.compressImage(mediaData, mimeType, 0.9);
if (compressedImage) {
mediaData = compressedImage;
}
}
let post = new Post(this.username, userID, postText, new Date(), mediaData);
let post = new Post(this.username, userID, postText, new Date(), mediaData, null, null, replyToID, replyRootID);
// this.posts.push(post);
// localStorage.setItem(key, JSON.stringify(posts));
addData(userID, post);
@@ -533,23 +613,21 @@ export class App {
this.render();
}
getPeerID() {
let id = localStorage.getItem("peer_id");
if (!id) {
console.log.apply(null, log(`Didn't find a peer ID, generating one`));
;
id = generateID();
localStorage.setItem("peer_id", id);
}
const existing = localStorage.getItem("peer_id");
if (existing)
return existing;
console.log.apply(null, log(`Didn't find a peer ID, generating one`));
const id = generateID();
localStorage.setItem("peer_id", id);
return id;
}
getUserID() {
let id = localStorage.getItem("dandelion_id");
if (!id) {
console.log.apply(null, log(`Didn't find a user ID, generating one`));
;
id = generateID();
localStorage.setItem("dandelion_id", id);
}
const existing = localStorage.getItem("dandelion_id");
if (existing)
return existing;
console.log.apply(null, log(`Didn't find a user ID, generating one`));
const id = generateID();
localStorage.setItem("dandelion_id", id);
return id;
}
hashIdToIndices(id) {
@@ -668,6 +746,14 @@ export class App {
correctLevel: QRCode.CorrectLevel.H
});
}
hideInfo() {
const infoElement = document.getElementById('info');
if (infoElement && infoElement.style.display !== 'none') {
infoElement.style.display = 'none';
setLogVisibility(false);
this.showLog = false;
}
}
showInfo() {
let infoElement = document.getElementById('info');
if (infoElement === null) {
@@ -704,6 +790,14 @@ export class App {
homeButton.addEventListener('click', e => globalThis.location.href = `${globalThis.location.origin}/`);
let profileButton = this.div('profile-button');
profileButton.addEventListener('click', e => globalThis.location.href = `${globalThis.location.origin}/user/${this.userID}`);
let notificationsButton = this.div('notifications-button');
notificationsButton.addEventListener('click', () => {
this.hideInfo();
navContainer.classList.remove('active');
history.pushState({}, '', '/notifications');
this.getRoute();
this.render();
});
let monitorButton = this.div('monitor_button');
monitorButton.addEventListener('click', async () => {
navContainer.classList.toggle('active');
@@ -712,23 +806,35 @@ 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(buffer);
importFilePicker.value = '';
this.userID = localStorage.getItem("dandelion_id") || this.userID;
this.username = localStorage.getItem("dandelion_username") || this.username;
this.render();
});
let exportButton = this.button("export-button");
exportButton.addEventListener('click', async (e) => {
await this.exportPostsForUser(this.userID);
await this.exportPostsForUser();
});
let composeButton = this.div('compose-button');
composeButton.addEventListener('click', e => {
document.getElementById('compose').style.display = 'block';
document.getElementById('textarea_post')?.focus();
this.enterCompose();
});
let filePicker = document.getElementById('file-input');
filePicker?.addEventListener('change', async (event) => {
for (let file of filePicker.files) {
let buffer = await file.arrayBuffer();
await this.createNewPost(this.userID, 'image...', buffer, file.type);
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.
filePicker.value = '';
this.exitCompose();
});
let filePickerLabel = document.getElementById('file-input-label');
filePickerLabel?.addEventListener('click', () => {
@@ -755,21 +861,31 @@ export class App {
// this.render();
// });
// clearPostsButton.addEventListener('click', () => { clearData(userID); posts = []; this.render() });
let postButton = document.getElementById("button_post");
let cancelPostButton = document.getElementById("button_cancel_post");
let postText = document.getElementById("textarea_post");
if (!(postButton && postText)) {
let postButton = document.getElementById("button_post");
if (!(cancelPostButton && postButton && postText)) {
throw new Error();
}
cancelPostButton.addEventListener('click', (e) => {
this.exitCompose();
});
postText.addEventListener('paste', async (e) => {
const dataTransfer = e.clipboardData;
const file = dataTransfer.files[0];
let buffer = await file.arrayBuffer();
await this.createNewPost(this.userID, 'image...', buffer, file.type);
await this.createPost(this.userID, 'image...', buffer, file.type, this.replyToID, this.replyRootID);
});
postButton.addEventListener("click", () => {
this.createNewPost(userID, postText.value);
postText.value = "";
document.getElementById('compose').style.display = 'none';
const submitPost = () => {
this.createPost(userID, postText.value, undefined, undefined, this.replyToID, this.replyRootID);
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" });
@@ -778,6 +894,29 @@ export class App {
// this.showInfo()
// });
}
// Change this all to a template so we're not toggling state in this crazy way!
enterCompose(replyToID = null, replyRootID = null) {
this.replyToID = replyToID;
this.replyRootID = replyRootID;
if (replyToID) {
this.renderComposeReplyArea(replyToID);
document.getElementById("compose-reply-area").style.display = "block";
}
document.getElementById('compose').style.display = 'flex';
document.getElementById('textarea_post')?.focus();
document.getElementById('compose-dimmer')?.classList.add("compose-dimmer-dimmed");
document.body.classList.add("no-scroll");
}
exitCompose() {
this.replyToID = null;
this.replyRootID = null;
let postText = document.getElementById("textarea_post");
postText.value = "";
document.getElementById('compose').style.display = 'none';
document.getElementById('compose-dimmer')?.classList.remove("compose-dimmer-dimmed");
document.getElementById("compose-reply-area").style.display = "none";
document.body.classList.remove("no-scroll");
}
async getPostsForFeed() {
// get N posts from each user and sort them by date.
// This isn't really going to work very well.
@@ -798,15 +937,20 @@ export class App {
async loadPostsFromStorage(userID, postID) {
this.timerStart();
let posts = [];
// if (postID) {
// posts = await gePostForUser(userID, postID);
// }
posts = await getData(userID, new Date(2022, 8), new Date());
if (posts.length > 0) {
if (postID) {
const post = await getPostForUser(userID, postID);
posts = post ? [post] : [];
}
else {
posts = await getData(userID, new Date(2022, 8), new Date());
}
if (posts?.length) {
console.log.apply(null, log(`Loaded ${posts.length} posts in ${this.timerDelta().toFixed(2)}ms`));
;
return posts;
}
console.log.apply(null, log(`No posts found for userID:${userID}, postID:${postID}`));
;
// posts = await createTestData2(userID);
// log("Adding test data...");
// addDataArray(userID, posts);
@@ -901,6 +1045,7 @@ export class App {
this.sync.setArchive(this.isArchivePeer);
this.connect();
await this.initDB();
window.addEventListener('popstate', () => { this.hideInfo(); 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;
@@ -1001,6 +1146,11 @@ export class App {
compose.style.display = "none";
break;
}
case App.Route.NOTIFICATIONS: {
this.posts = await getNotificationsForUser(this.userID);
document.getElementById('compose').style.display = "none";
break;
}
default: {
console.log.apply(null, log("Render: got a route I didn't understand. Rendering HOME:", this.router.route));
this.posts = await this.loadPostsFromStorage(this.userID) ?? [];
@@ -1011,6 +1161,20 @@ export class App {
if (!contentDiv) {
throw new Error();
}
if (this.router.route === App.Route.NOTIFICATIONS) {
contentDiv.innerHTML = '';
if (this.posts.length === 0) {
contentDiv.innerHTML = '<div style="padding:20px;color:gray">No notifications yet.</div>';
}
else {
let first = true;
for (const postRecord of this.posts) {
contentDiv.appendChild(this.renderNotificationItem(postRecord.data, first));
first = false;
}
}
return;
}
if (this.posts.length === 0) {
this.renderWelcome(contentDiv);
return;
@@ -1033,13 +1197,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) {
@@ -1054,6 +1223,36 @@ export class App {
throw new Error("Couldn't get content div!");
}
contentDiv.appendChild(fragment);
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) => {
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, currentPost.root_id ? 1 : 0);
}
let renderTime = this.timerDelta();
console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`));
;
@@ -1067,7 +1266,63 @@ export class App {
deleteData(userID, postID);
this.render();
}
renderPost(post, first) {
async renderComposeReplyArea(replyToID) {
const composeReplyArea = document.getElementById('compose-reply-area');
composeReplyArea.innerHTML = '';
const record = await getPostById(replyToID);
if (!record)
return;
const postData = record.data;
const authorDiv = document.createElement('div');
authorDiv.className = 'compose-reply-author';
authorDiv.textContent = `@${postData.author}`;
composeReplyArea.appendChild(authorDiv);
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);
}
renderNotificationItem(post, first) {
const el = document.createElement('div');
el.style.cssText = 'padding: 8px 10px; cursor: pointer;';
if (!first)
el.innerHTML = '<hr>';
const authorDiv = document.createElement('div');
authorDiv.className = 'compose-reply-author';
authorDiv.textContent = `@${post.author} · ${post.post_timestamp.toLocaleDateString()}`;
el.appendChild(authorDiv);
const previewDiv = document.createElement('div');
previewDiv.className = 'compose-reply-preview';
const textDiv = document.createElement('div');
textDiv.className = 'compose-reply-preview-text';
textDiv.style.maxHeight = 'none';
textDiv.innerHTML = this.markedAvailable ? marked.parse(post.text) : post.text;
previewDiv.appendChild(textDiv);
if (post.image_data) {
const img = document.createElement('img');
img.className = 'compose-reply-preview-image';
img.src = URL.createObjectURL(new Blob([post.image_data]));
previewDiv.appendChild(img);
}
el.appendChild(previewDiv);
el.addEventListener('click', () => {
history.pushState({}, '', `/user/${post.author_id}/post/${post.post_id}`);
this.getRoute();
this.render();
});
return el;
}
renderPost(post, first, replyCount = 0) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
}
@@ -1083,6 +1338,13 @@ export class App {
let shareUrl = `${document.location.origin}/user/${post.author_id}/post/${post.post_id}`;
await navigator.clipboard.writeText(shareUrl);
};
let replyButton = document.createElement('button');
replyButton.innerText = replyCount > 0 ? `reply (${replyCount})` : 'reply';
replyButton.onclick = async () => {
console.log(`replying to post ${post.post_id}`);
const rootID = post.root_id ?? post.post_id;
this.enterCompose(post.post_id, rootID);
};
let ownPost = post.author_id === this.userID;
let markdown = post.text;
if (this.markedAvailable) {
@@ -1096,41 +1358,54 @@ 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>
${ownPost ? `<span id="deleteButton"></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>`;
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);
}
containerDiv.querySelector('#shareButton')?.appendChild(shareButton);
if (!("image_data" in post && post.image_data)) {
// containerDiv.appendChild(timestampDiv);
return containerDiv;
// return null;
containerDiv.querySelector('#replyButton')?.appendChild(replyButton);
let hasImage = ("image_data" in post && post.image_data);
if (hasImage) {
let image = document.createElement("img");
image.title = `${(post.image_data.byteLength / 1024 / 1024).toFixed(2)}MBytes`;
// const blob = new Blob([post.image_data as ArrayBuffer], { type: 'image/png' });
const blob = new Blob([post.image_data]);
const url = URL.createObjectURL(blob);
image.onload = () => {
// URL.revokeObjectURL(url);
};
image.src = url;
// image.src = image.src = "data:image/png;base64," + post.image;
image.className = "postImage";
// image.onclick = () => { App.maximizeElement(image) };
containerDiv.querySelector('#image')?.appendChild(image);
}
let image = document.createElement("img");
image.title = `${(post.image_data.byteLength / 1024 / 1024).toFixed(2)}MBytes`;
// const blob = new Blob([post.image_data as ArrayBuffer], { type: 'image/png' });
const blob = new Blob([post.image_data]);
const url = URL.createObjectURL(blob);
image.onload = () => {
// URL.revokeObjectURL(url);
};
image.src = url;
// image.src = image.src = "data:image/png;base64," + post.image;
image.className = "postImage";
// image.onclick = () => { App.maximizeElement(image) };
containerDiv.appendChild(image);
// containerDiv.appendChild(timestampDiv);
return containerDiv;
}
@@ -1140,6 +1415,14 @@ export class App {
getRoute() {
let path = document.location.pathname;
console.log.apply(null, log("router: path ", path));
if (path === '/notifications') {
this.router.route = App.Route.NOTIFICATIONS;
this.router.userID = '';
this.router.postID = '';
this.router.mediaID = '';
console.log.apply(null, log("router: NOTIFICATIONS"));
return;
}
const regex = "(user/([a-zA-Z0-9\-]+)/?(post/([a-zA-Z0-9\-]+)?/?)?(media/([0-9]+)?)?)|(connect/([a-zA-Z0-9\-]+))";
const match = path.match(new RegExp(regex));
if (match) {
@@ -1162,6 +1445,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>
@@ -1174,12 +1463,13 @@ export class App {
(function (App) {
let Route;
(function (Route) {
Route[Route["USER"] = 0] = "USER";
Route[Route["POST"] = 1] = "POST";
Route[Route["MEDIA"] = 2] = "MEDIA";
Route[Route["GROUP"] = 3] = "GROUP";
Route[Route["HOME"] = 4] = "HOME";
Route[Route["CONNECT"] = 5] = "CONNECT";
Route[Route["USER"] = 1] = "USER";
Route[Route["POST"] = 2] = "POST";
Route[Route["MEDIA"] = 4] = "MEDIA";
Route[Route["GROUP"] = 8] = "GROUP";
Route[Route["HOME"] = 16] = "HOME";
Route[Route["CONNECT"] = 32] = "CONNECT";
Route[Route["NOTIFICATIONS"] = 64] = "NOTIFICATIONS";
})(Route = App.Route || (App.Route = {}));
})(App || (App = {}));
;

File diff suppressed because one or more lines are too long

View File

@@ -51,6 +51,14 @@ export class PeerManager {
this.messageSuperlog && console.log.apply(null, log("<-signaler:", message));
this.websocket.send(messageJSON);
}
onPeerMessageFromPeer(remotePeerID, messageJSON) {
let targetPeer = this.peers.get(messageJSON.to);
if (!targetPeer) {
console.log.apply(null, log("[PeerManager] Coulnd't find peer for onPeerMessageFromPeer:", messageJSON.to));
return;
}
targetPeer.send(messageJSON);
}
onWebsocketMessage(event) {
let messageJSON = event.data;
let message = null;
@@ -82,9 +90,7 @@ export class PeerManager {
if (!peerConnection) {
let remotePeerID = message.from;
let newPeer = new PeerConnection(this, remotePeerID, this.websocketSendPeerMessage.bind(this));
if (this._isBootstrapPeer) {
newPeer.setPolite(false);
}
newPeer.setPoliteFromID(remotePeerID, true);
peerConnection = newPeer;
this.peers.set(newPeer.remotePeerID, newPeer);
this.onConnectRequest(newPeer);
@@ -163,7 +169,6 @@ export class PeerManager {
// message: { type: "get_posts_for_user", post_ids: postIds, user_id: message.user_id } }
}
constructor(userID, peerID, isBootstrapPeer) {
// private signaler: Signaler;
this.searchQueryFunctions = new Map();
this.RPC_remote = new Map();
this.rpc = {};
@@ -182,12 +187,6 @@ export class PeerManager {
this.watchdogInterval = null;
this.reconnectTimer = null;
this.peerStateSuperlog = true;
// async watchdog() {
// // Check that we're connected to at least N peers. If not, reconnect to the bootstrap server.
// if (this.peers.size === 0) {
// await this.sendHello2();
// }
// }
this.animals = ['shrew', 'jerboa', 'lemur', 'weasel', 'possum', 'possum', 'marmoset', 'planigale', 'mole', 'narwhal'];
this.adjectives = ['snazzy', 'whimsical', 'jazzy', 'bonkers', 'wobbly', 'spiffy', 'chirpy', 'zesty', 'bubbly', 'perky', 'sassy'];
this.snakes = ['mamba', 'cobra', 'python', 'viper', 'krait', 'sidewinder', 'constrictor', 'boa', 'asp', 'anaconda', 'krait'];
@@ -417,6 +416,10 @@ class PeerConnection {
setPolite(polite) {
this.polite = polite;
}
setPoliteFromID(peerID, remote = false) {
let polite = (parseInt(peerID.charAt(peerID.length - 1), 16) % 2 == 0) && !remote;
this.setPolite(polite);
}
setupDataChannel() {
if (!this.dataChannel) {
throw new Error();
@@ -513,6 +516,7 @@ class PeerConnection {
}
try {
this.makingOffer = true;
this.setPoliteFromID(this.peerManager.peerID);
await this.rtcPeer.setLocalDescription();
if (!this.rtcPeer.localDescription) {
return;
@@ -590,8 +594,6 @@ class PeerConnection {
catch (err) {
console.error(err);
}
// };
// */
}
disconnect() {
this.rtcPeer?.close();
@@ -651,10 +653,10 @@ class PeerConnection {
}
call(functionName, args) {
let transactionID = generateID(); // make this faster as we will only ever have a small number of in-flight queries on a peer
// Think about a timeout here to auto reject it after a while.
let promise = new Promise((resolve, reject) => {
this.pendingRPCs.set(transactionID, { resolve, reject, functionName });
setTimeout(() => reject(`function:${functionName}[${transactionID}] failed to resolve after 10 seconds.`), 10000);
let timeoutSeconds = 10;
setTimeout(() => reject(`function:${functionName}[${transactionID}] failed to resolve after ${timeoutSeconds} seconds.`), timeoutSeconds * 1000);
});
let message = {
type: "rpc_call",
@@ -727,12 +729,7 @@ class PeerConnection {
PeerConnection.config = {
iceServers: [
{ urls: "stun:ddln.app" },
// { urls: "turn:ddln.app", username: "a", credential: "b" },
{ urls: "stun:stun.l.google.com" }, // keeping this for now as my STUN server is not returning ipv6
{ urls: "stun:stun1.l.google.com" },
{ urls: "stun:stun2.l.google.com" },
{ urls: "stun:stun3.l.google.com" },
{ urls: "stun:stun4.l.google.com" },
{ urls: "turn:ddln.app", username: "ddln1", credential: "ddln1" },
],
};
//# sourceMappingURL=PeerManager.js.map

File diff suppressed because one or more lines are too long

View File

@@ -44,7 +44,9 @@ export class Sync {
'e01eff89-5100-4b35-af4c-1c1bcb007dd0',
'194696a2-d850-4bb0-98f7-47416b3d1662',
'f6b21eb1-a0ff-435b-8efc-6a3dd70c0dca',
'dd1d92aa-aa24-4166-a925-94ba072a9048'
'dd1d92aa-aa24-4166-a925-94ba072a9048',
'290dbb4f-6ce1-491a-b90d-51d8efcd3d60',
'3b32d0eb-94d5-49c4-a43b-0959a9fbb015'
]);
// async getPostIdsForUserHandler(data: any) {
// let message = data.message;

File diff suppressed because one or more lines are too long

View File

@@ -34,5 +34,28 @@
"protocol": "web+ddln",
"url": "/%s"
}
]
],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "name",
"text": "text",
"url": "link",
"files": [
{
"name": "photos",
"accept": [
"image/svg+xml",
".svg",
".jpg",
".jpeg",
".png",
".gif"
]
}
]
}
}
}

View File

@@ -29,7 +29,19 @@ export async function compressString(input) {
// Read the compressed data from the stream
const compressedArray = await new Response(compressionStream.readable).arrayBuffer();
// Convert the compressed data to a Uint8Array
return new Uint8Array(compressedArray);
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';

View File

@@ -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,IAAI,UAAU,CAAC,eAAe,CAAC,CAAC;AACzC,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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"}

View File

@@ -10,12 +10,21 @@ const followingStoreName = "following";
const profileStoreName = "profiles";
let keyBase = "dandelion_posts_v1_";
let key = "";
let version = 1;
async function upgrade_0to1(db) {
let version = 3;
async function upgrade_0to1(db, _transaction) {
let postsStore = db.createObjectStore(postStoreName, { keyPath: "id", autoIncrement: true });
postsStore.createIndex("datetimeIndex", "post_timestamp", { unique: false });
postsStore.createIndex("postIDIndex", "data.post_id", { unique: true });
}
async function upgrade_1to2(_db, _transaction) {
// Was broken for some clients — index creation moved to upgrade_2to3
}
async function upgrade_2to3(_db, transaction) {
const postsStore = transaction.objectStore(postStoreName);
if (!postsStore.indexNames.contains("postReplyIndex")) {
postsStore.createIndex("postReplyIndex", "data.reply_to_id", { unique: false });
}
}
// let following = ['b38b623c-c3fa-4351-9cab-50233c99fa4e'];
// let profiles = [
// {
@@ -27,12 +36,12 @@ async function upgrade_0to1(db) {
// description: "A very nice person who never does anything wrong.",
// }];
// let tombstones = ['b38b623c-c3fa-4351-9cab-50233c99fa4e'];
async function upgrade_1to2(db) {
let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
let profileStore = db.createObjectStore(profileStoreName, { keyPath: "id", autoIncrement: true });
let tombstoneStore = db.createObjectStore(tombstoneStoreName, { keyPath: "id", autoIncrement: true });
tombstoneStore.createIndex("postIDIndex", "data.post_id", { unique: true });
}
// async function upgrade_1to2(db: IDBDatabase) {
// let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
// let profileStore = db.createObjectStore(profileStoreName, { keyPath: "id", autoIncrement: true });
// let tombstoneStore = db.createObjectStore(tombstoneStoreName, { keyPath: "id", autoIncrement: true });
// tombstoneStore.createIndex("postIDIndex", "data.post_id", { unique: true });
// }
// async function upgrade_1to2(db: IDBDatabase) {
// console.log("Upgrading database from 1 to 2");
// console.log("Converting all image arraybuffers to Blobs")
@@ -55,13 +64,13 @@ async function upgrade_1to2(db) {
// }
// }
// }
async function upgrade_2to3(db) {
let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
}
// async function upgrade_2to3(db: IDBDatabase) {
// let followingStore = db.createObjectStore(followingStoreName, { keyPath: "id", autoIncrement: true });
// }
let upgrades = new Map([
[0, upgrade_0to1],
[1, upgrade_1to2],
[2, upgrade_2to3]
[2, upgrade_2to3],
]);
export function openDatabase(userID) {
const dbName = `user_${userID}`;
@@ -73,12 +82,14 @@ export function openDatabase(userID) {
};
request.onupgradeneeded = async (event) => {
const db = event.target.result;
let upgradeFunction = upgrades.get(event.oldVersion);
if (!upgradeFunction) {
throw new Error(`db: Don't have an upgrade function to go from version ${event.oldVersion} to version ${event.newVersion}`);
const transaction = event.target.transaction;
for (let v = event.oldVersion; v < (event.newVersion ?? version); v++) {
const upgradeFunction = upgrades.get(v);
if (!upgradeFunction) {
throw new Error(`db: Don't have an upgrade function to go from version ${v} to version ${v + 1}`);
}
await upgradeFunction(db, transaction);
}
// debugger;
await upgradeFunction(db);
};
request.onsuccess = (event) => {
const db = event.target.result;
@@ -147,12 +158,19 @@ export async function clearData(userID) {
console.error('Error in opening database:', error);
}
}
// TODO - this function can return before the data is stored!
export async function addDataArray(userID, array) {
try {
const { db, transaction, store } = await getDBTransactionStore(userID, "readwrite");
transaction.onerror = (event) => {
console.error('Error in adding data:', event);
};
let completionPromise = new Promise((resolve, reject) => {
transaction.oncomplete = (event) => {
resolve();
};
transaction.onerror = (event) => {
console.error('Error in adding data:', event);
reject();
};
});
// let count = 0;
array.reverse();
for (let data of array) {
@@ -170,6 +188,7 @@ export async function addDataArray(userID, array) {
// console.log(`Added ${count} posts...`);
// }
}
return completionPromise;
}
catch (error) {
console.error('Error in opening database:', error);
@@ -260,6 +279,18 @@ export async function mergeDataArray(userID, array) {
}
}
export async function getPostForUser(userID, postID) {
const { store } = await getDBTransactionStore(userID);
const index = store.index("postIDIndex");
return new Promise((resolve, reject) => {
const getPostRequest = index.get(postID);
getPostRequest.onsuccess = () => {
resolve(getPostRequest.result);
};
getPostRequest.onerror = () => {
console.error('Transaction failed:', getPostRequest.error?.message);
reject(getPostRequest.error);
};
});
}
export async function getData(userID, lowerID, upperID) {
const { store } = await getDBTransactionStore(userID);
@@ -325,6 +356,108 @@ 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 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) {
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 getNotificationsForUser(userID) {
const userPostIds = new Set(await getAllIds(userID));
const knownUsers = [...(await indexedDB.databases())]
.map(db => db.name?.replace('user_', '')).filter(Boolean);
let notifications = [];
for (const dbUserID of knownUsers) {
try {
const { store } = await getDBTransactionStore(dbUserID);
const index = store.index("postReplyIndex");
const results = await new Promise((resolve, reject) => {
const req = index.getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
for (const r of results) {
if (r.data.reply_to_id && userPostIds.has(r.data.reply_to_id)) {
notifications.push(r);
}
}
}
catch (_) { }
}
notifications.sort((a, b) => new Date(b.data.post_timestamp).getTime() - new Date(a.data.post_timestamp).getTime());
return notifications;
}
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

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, maximum-scale=1, interactive-widget=resizes-content">
<title>Dandelion</title>
@@ -58,6 +58,10 @@
<span class="nav-emoji emoji-fill" role="img" aria-label="Home">🏠</span>
<span class="nav-label">Home</span>
</a>
<a class="nav-item" id="notifications-button">
<span class="nav-emoji emoji-fill" role="img" aria-label="Notifications">🔔</span>
<span class="nav-label">Notifications</span>
</a>
<!-- <a class="nav-item">
<span class="nav-emoji" role="img" aria-label="Search">🔍</span>
<span class="nav-label">Search</span>
@@ -87,6 +91,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"
@@ -111,32 +117,39 @@
</div>
<div id="compose">
<div id="buttons">
<!-- <button id="button_font1" >font1</button>
<button id="button_font2" >font2 </button> -->
<!-- <button id="import_tweets">import</button> -->
<!-- <button id="clear_posts">clear </button> -->
<!-- <button id="update_app">check for updates</button> -->
<!-- <button id="toggle_dark">light/dark</button> -->
</div>
<textarea cols="60" rows="6" id="textarea_post"></textarea>
<div class="right">
<label for="file-input" id="file-input-label" class="button button-big">photo</label>
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
<!-- <button id="button_add_pic" >🏞️</button> -->
<button id="button_post" class="button button-big">post</button>
</div>
</div>
<!-- <div id="torrent-content"></div> -->
<div id="content"></div>
<div id="compose-button" class="compose-button emoji-fill">✏️</div>
</div>
</div>
<div id="compose-dimmer" class="compose-dimmer-normal"></div>
<div id="compose">
<div id="compose-header">
<div id="button_cancel_post" class="link">Cancel</div>
<button id="button_post" class="button button-big">post</button>
</div>
<div id="compose-reply-area"></div>
<textarea cols="60" rows="6" id="textarea_post"></textarea>
<div class="compose-footer">
<div class="compose-left-buttons">
<label for="file-input" id="file-input-label" class="button button-big">photo</label>
<input type="file" id="file-input" accept="image/*" multiple style="display:none">
</div>
</div>
</div>
</body>
</html>

View File

@@ -4,6 +4,8 @@
:root {
--main-bg-color: white;
--main-hover-color: rgb(64, 64, 64);
--post-hover-color: rgb(32,32,32);
--border-color: rgb(132, 136, 138);
--edge-color: rgb(60, 60, 60);
--main-fg-color: black;
@@ -14,6 +16,7 @@
@media (prefers-color-scheme: dark) {
:root {
--main-bg-color: black;
--main-hover-color: rgb(64, 64, 64);
--border-color: rgb(132, 136, 138);
--edge-color: rgb(60, 60, 60);
--main-fg-color: rgb(202, 208, 211);
@@ -22,6 +25,10 @@
}
}
/* html, body {
overscroll-behavior-y: none;
} */
body {
font-family: sans-serif;
color: var(--main-fg-color);
@@ -52,14 +59,21 @@ hr {
font-family: sans-serif;
background-color: var(--main-bg-color);
color: var(--main-fg-color);
width: 100%;
box-sizing: border-box;
padding-left: 30px;
padding-right: 30px;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
border: 1px solid var(--border-color);
resize: vertical;
border-radius: 40px;
width: 98%;
margin: 1%;
height: 270px;
resize: vertical;
max-height: 60vh;
}
.flex-container {
@@ -72,17 +86,35 @@ hr {
.content {
max-width: 600px;
/* Your preferred max width for the content */
flex: 1;
/* Shorthand for flex-grow, flex-shrink and flex-basis */
box-sizing: border-box;
min-width: 300px;
/* Minimum width the content can shrink to */
padding: 20px;
box-shadow: 0 0 5px var(--edge-color);
text-align: left;
overflow-x: hidden;
/* Hide horizontal overflow inside the flex container */
line-height: 1.3;
z-index: 0;
}
.compose-dimmer-normal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 30;
background-color: black;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.compose-dimmer-dimmed {
background-color: black;
opacity: 0.7;
visibility: visible;
}
.embed {
@@ -94,6 +126,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(--post-hover-color);
}
#log {
@@ -104,16 +154,43 @@ hr {
width: 100%;
}
.right {
text-align: right;
/* .compose-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-top: 5px;
padding-left: 10px;
padding-right: 10px;
box-sizing: border-box;
} */
.compose-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
flex-shrink: 0;
margin-top: 5px;
padding-left: 10px;
padding-right: 10px;
box-sizing: border-box;
}
#buttons {
margin-left: 40px;
.compose-left-buttons,
.compose-right-buttons {
display: flex;
gap: 10px; /* Puts exactly 10px of space between buttons in the same group */
align-items: center;
}
#button_post {
margin-right: 40px;
.link {
user-select: none;
display:inline-block;
}
.link:hover {
cursor: pointer;
color: var(--border-color);
}
a {
@@ -160,6 +237,7 @@ a {
button,
.button {
user-select: none;
font-size: 12px;
background-color: var(--main-bg-color);
border-radius: 10px;
@@ -175,6 +253,15 @@ button,
cursor: pointer;
}
button:hover,
.button:hover {
background-color: var(--main-hover-color);
}
#deleteButton {
float: right;
}
.button-big {
font-size: large;
}
@@ -202,8 +289,116 @@ iframe {
height: 300px;
}
/* #compose {
display: none;
position: fixed;
z-index: 100;
} */
#compose {
display: none;
flex-direction: column;
position: fixed;
top: 5%;
left: 50%;
width: 88%;
transform: translateX(-50%);
max-height: 85vh;
max-width: 600px;
overflow-y: auto;
background-color: var(--main-bg-color);
z-index: 100;
padding: 5px;
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
}
#compose-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
}
@media (pointer: coarse) {
#compose {
top: 0;
width: 100%;
height: 100dvh;
max-height: 100dvh;
box-sizing: border-box;
overflow-y: hidden;
border: none;
border-radius: 0;
box-shadow: none;
}
#textarea_post {
flex: 1;
min-height: 0;
height: auto;
max-height: none;
resize: none;
border: none;
}
#textarea_post:focus {
outline: none;
}
#compose-reply-area {
border-bottom: 1px solid var(--border-color);
margin-bottom: 0;
}
}
#compose-reply-area {
display: none;
padding: 8px 10px;
margin-bottom: 8px;
font-size: 0.85em;
}
.compose-reply-author {
font-weight: bold;
font-size: 0.85em;
margin-bottom: 2px;
}
.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 {
height: 68px;
pointer-events: none;
}
.compose-reply-preview-image {
width: 96px;
height: 96px;
max-width: 96px;
max-height: 96px;
object-fit: cover;
object-position: top;
border-radius: 4px;
flex-shrink: 0;
}
.show {
display:block;
}
.username {
@@ -224,7 +419,7 @@ iframe {
/* border-right: 1px solid rgb(60, 60, 60); */
/* transition: width 0.3s; */
overflow: hidden;
width: 130px;
width: 185px;
margin-right: 5px;
}
@@ -260,6 +455,7 @@ iframe {
}
.burger-menu {
user-select: none;
text-align: center;
width: 46px;
border-radius: 10px;
@@ -269,7 +465,7 @@ iframe {
top: 0px;
font-size: 24px;
cursor: pointer;
z-index: 1000;
z-index: 20;
background: none;
border: none;
color: var(--main-fg-color);
@@ -280,14 +476,14 @@ iframe {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 900;
}
@media (max-width: 700px) {
.nav-container {
display: none;
/* transform: translateX(-100%); */
/* z-index: 1000; */
z-index:10;
user-select: none;
}
.nav-container.active {
@@ -328,7 +524,7 @@ iframe {
.small {
width: 24px;
height: 24px;
height: 24px;
}
.profile-pic-container img {
@@ -343,6 +539,7 @@ iframe {
}
.compose-button {
user-select: none;
font-size: 42px;
position: fixed;
bottom: 24px;
@@ -366,11 +563,11 @@ iframe {
align-content: center;
padding-left: 55px;
font-size: 12px;
height:12px;
height: 12px;
}
.spinner {
display: inline-block;
display: inline-block;
animation-name: spin;
animation-duration: 2000ms;
animation-iteration-count: infinite;
@@ -378,6 +575,10 @@ iframe {
/* font-size: 64px; */
}
.no-scroll {
overflow: hidden;
}
@keyframes spin {
from {
transform: rotate(0deg);
@@ -391,6 +592,6 @@ iframe {
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
transform: none;
transform: none;
}
}

View File

@@ -689,7 +689,7 @@ class App {
let compressedData = await compressString(JSON.stringify(output));
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')}`;
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) {
log("Importing tweet archive");

File diff suppressed because one or more lines are too long

View File

@@ -30,82 +30,43 @@ self.addEventListener("install", (e) => {
}
})());
});
async function staleWhileRevalidate(event) {
let cache = await caches.open(cacheName);
let response = await cache.match(event.request);
if (response) {
debugLog ? console.log('Service Worker: Cache hit', event.request.url) : null;
async function fetchAndUpdateCache(request) {
debugLog && console.log('Service Worker: Fetching', request.url);
let networkResponse;
try {
networkResponse = await fetch(request);
}
const fetchPromise = (async () => {
debugLog ? console.log('Service Worker: Fetching', event.request.url) : null;
let networkResponse = null;
try {
networkResponse = await fetch(event.request);
}
catch (e) {
debugLog ? console.log('Service Worker: Failed to fetch', e) : null;
return new Response('Network error occurred', {
status: 404,
statusText: 'Cache miss and fetch failed',
headers: { 'Content-Type': 'text/plain' }
});
}
debugLog ? console.log('Service Worker: Updating cache', event.request.url) : null;
try {
await cache.put(event.request, networkResponse.clone());
}
catch (e) {
debugLog ? console.log('Service Worker: failed to update cache', event.request.url, e) : null;
}
debugLog ? console.log('Service Worker: Returning networkResponse', event.request.url) : null;
return networkResponse;
})();
debugLog ? console.log('Service Worker: Returning return response || fetchPromise', event.request.url) : null;
return response || fetchPromise;
// if (networkResponse) {
// cache.put(event.request, networkResponse.clone())
// return networkResponse;
// }
// caches.open(cacheName)
// .then(function (cache) {
// return cache.match(event.request)
// .then(function (response) {
// var fetchPromise = fetch(event.request)
// .then(function (networkResponse) {
// cache.put(event.request, networkResponse.clone());
// return networkResponse;
// });
// return response || fetchPromise;
// });
// })
catch (e) {
debugLog && console.log('Service Worker: Failed to fetch', e);
return new Response('Network error occurred', {
status: 404,
statusText: 'Cache miss and fetch failed',
headers: { 'Content-Type': 'text/plain' }
});
}
debugLog && console.log('Service Worker: Updating cache', request.url);
const cache = await caches.open(cacheName);
try {
await cache.put(request, networkResponse.clone());
}
catch (e) {
debugLog && console.log('Service Worker: failed to update cache', request.url, e);
}
return networkResponse;
}
// async function responder(event: any) {
// debugLog ? console.log('Fetching', event.request.url) : null;
// let response = await fetch(event.request);
// if (!response) {
// debugLog ? console.log('Fetch failed, falling back to cache', event.request.url) : null;
// let cacheMatch = await caches.match(event.request);
// if (!cacheMatch) {
// // DUnno what to return here!
// }
// return cacheMatch;
// }
// if (response.status === 206) {
// debugLog ? console.log('Not caching partial content') : null;
// return response;
// }
// debugLog ? console.log('Fetch successful, updating cache', event.request.url) : null;
// const cache = await caches.open(cacheName);
// try {
// cache.put(event.request, response.clone()).catch((error) => debugLog ? console.log('failed to cache', event.request, error)) : null;
// } catch (e) {
// console.log('failed to cache', event.request)
// }
// return response;
// }
self.addEventListener('fetch', function (event) {
event.respondWith(staleWhileRevalidate(event));
// event.respondWith(responder(event));
const networkFetch = fetchAndUpdateCache(event.request);
event.respondWith(async function () {
const cache = await caches.open(cacheName);
const cached = await cache.match(event.request);
if (cached) {
debugLog && console.log('Service Worker: Cache hit', event.request.url);
return cached;
}
return networkFetch;
}());
// Keep the SW alive until the background cache update finishes
event.waitUntil(networkFetch);
});
addEventListener("message", async (e) => {
debugLog ? console.log(`Message received:`, e.data) : null;

View File

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

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 */
// "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": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"paths": {
"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. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */