Compare commits
3 Commits
29ba02e3ce
...
bobbyd-exp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8cc08e5cc | ||
| 4ae581b1a2 | |||
| e27cf391ef |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"tasks": {
|
||||
"build": "deno run -A npm:typescript/bin/tsc",
|
||||
"watch": "deno run -A npm:typescript/bin/tsc --watch"
|
||||
"watch": "deno run -A npm:typescript/bin/tsc --watch",
|
||||
"test": "deno test --allow-net src/App.test.ts"
|
||||
}
|
||||
}
|
||||
|
||||
31
deno.lock
generated
31
deno.lock
generated
@@ -3,14 +3,17 @@
|
||||
"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": {
|
||||
@@ -28,6 +31,12 @@
|
||||
"@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"
|
||||
},
|
||||
@@ -37,6 +46,9 @@
|
||||
"jsr:@std/path@^1.0.7"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.12": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/io@0.225.0": {
|
||||
"integrity": "c1db7c5e5a231629b32d64b9a53139445b2ca640d828c26bf23e1c55f8c079b3",
|
||||
"dependencies": [
|
||||
@@ -60,6 +72,25 @@
|
||||
}
|
||||
},
|
||||
"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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
9
setup.sh
9
setup.sh
@@ -9,15 +9,6 @@ if ! command -v deno &>/dev/null; then
|
||||
export PATH="$DENO_INSTALL/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Install node if needed
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "Installing node..."
|
||||
brew install node
|
||||
fi
|
||||
|
||||
# Install TypeScript dependencies
|
||||
npm install
|
||||
|
||||
# Install tmux if needed
|
||||
if ! command -v tmux &>/dev/null; then
|
||||
echo "Installing tmux..."
|
||||
|
||||
203
src/App.ts
203
src/App.ts
@@ -1,7 +1,7 @@
|
||||
import { generateID } from "IDUtils";
|
||||
import { PeerManager, PeerEventTypes } from "PeerManager";
|
||||
import { Sync } from "Sync";
|
||||
import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap } from "db";
|
||||
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"
|
||||
|
||||
@@ -528,42 +528,105 @@ export class App {
|
||||
globalThis.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async importPostsForUser(userID: string, buffer: ArrayBuffer) {
|
||||
async importPostsForUser(buffer: ArrayBuffer) {
|
||||
const startTime = performance.now();
|
||||
console.log.apply(null, log("Importing posts"));
|
||||
const json = await decompressBuffer(buffer);
|
||||
const posts = JSON.parse(json);
|
||||
const data = JSON.parse(json);
|
||||
|
||||
for (let post of posts) {
|
||||
if (post.image_data && typeof post.image_data === 'string') {
|
||||
post.image_data = await base64ToArrayBuffer(post.image_data);
|
||||
}
|
||||
if (post.post_timestamp && typeof post.post_timestamp === 'string') {
|
||||
post.post_timestamp = new Date(post.post_timestamp);
|
||||
}
|
||||
let postsByUser: { [userID: string]: any[] };
|
||||
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 };
|
||||
} 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);
|
||||
}
|
||||
|
||||
await mergeDataArray(userID, posts);
|
||||
console.log.apply(null, log(`Imported ${posts.length} posts`));
|
||||
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(userID: string) {
|
||||
async exportPostsForUser() {
|
||||
console.log.apply(null, log("Exporting all posts for all users"));
|
||||
const exportStartTime = performance.now();
|
||||
|
||||
let posts = await getAllData(userID);
|
||||
const knownUsers = [...(await indexedDB.databases())]
|
||||
.map(db => db.name?.replace('user_', ''))
|
||||
.filter((userID): userID is string => userID !== undefined);
|
||||
|
||||
let output = [];
|
||||
const postsByUser: { [userID: string]: any[] } = {};
|
||||
const userTimings: { [userID: string]: number } = {};
|
||||
|
||||
console.log.apply(null, log("Serializing images"));
|
||||
for (let post of posts) {
|
||||
let newPost = (post as any).data;
|
||||
for (const userID of knownUsers) {
|
||||
const userStartTime = performance.now();
|
||||
const posts = await getAllData(userID);
|
||||
const output = [];
|
||||
|
||||
if (newPost.image_data) {
|
||||
newPost.image_data = await arrayBufferToBase64(newPost.image_data);
|
||||
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);
|
||||
}
|
||||
|
||||
output.push(newPost);
|
||||
if (output.length > 0) {
|
||||
postsByUser[userID] = output;
|
||||
}
|
||||
|
||||
userTimings[userID] = performance.now() - userStartTime;
|
||||
}
|
||||
|
||||
let compressedData = await compressString(JSON.stringify(output));
|
||||
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()
|
||||
@@ -573,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[]) {
|
||||
@@ -934,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');
|
||||
|
||||
@@ -980,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');
|
||||
@@ -995,15 +1075,16 @@ export class App {
|
||||
const file = importFilePicker.files?.[0];
|
||||
if (!file) return;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await this.importPostsForUser(this.userID, buffer);
|
||||
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');
|
||||
@@ -1316,7 +1397,7 @@ export class App {
|
||||
|
||||
await this.initDB();
|
||||
|
||||
window.addEventListener('popstate', () => { this.getRoute(); this.render(); });
|
||||
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>`;
|
||||
@@ -1462,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) ?? [];
|
||||
@@ -1472,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;
|
||||
@@ -1618,6 +1719,42 @@ export class App {
|
||||
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!");
|
||||
@@ -1743,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));
|
||||
@@ -1793,5 +1939,6 @@ export namespace App {
|
||||
GROUP = 8,
|
||||
HOME = 16,
|
||||
CONNECT = 32,
|
||||
NOTIFICATIONS = 64,
|
||||
}
|
||||
};
|
||||
27
src/db.ts
27
src/db.ts
@@ -542,6 +542,33 @@ export async function getRepliesForPost(postID: string): Promise<any[]> {
|
||||
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");
|
||||
|
||||
180
static/App.js
180
static/App.js
@@ -1,7 +1,7 @@
|
||||
import { generateID } from "IDUtils";
|
||||
import { PeerManager, PeerEventTypes } from "PeerManager";
|
||||
import { Sync } from "Sync";
|
||||
import { openDatabase, getData, addData, deleteData, mergeDataArray, getAllData, getPostForUser, getPostById, getRepliesForPost, buildReplyCountMap } from "db";
|
||||
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 {
|
||||
@@ -384,36 +384,88 @@ export class App {
|
||||
document.body.removeChild(link);
|
||||
globalThis.URL.revokeObjectURL(url);
|
||||
}
|
||||
async importPostsForUser(userID, buffer) {
|
||||
async importPostsForUser(buffer) {
|
||||
const startTime = performance.now();
|
||||
console.log.apply(null, log("Importing posts"));
|
||||
const json = await decompressBuffer(buffer);
|
||||
const posts = JSON.parse(json);
|
||||
for (let post of posts) {
|
||||
if (post.image_data && typeof post.image_data === 'string') {
|
||||
post.image_data = await base64ToArrayBuffer(post.image_data);
|
||||
}
|
||||
if (post.post_timestamp && typeof post.post_timestamp === 'string') {
|
||||
post.post_timestamp = new Date(post.post_timestamp);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
await mergeDataArray(userID, posts);
|
||||
console.log.apply(null, log(`Imported ${posts.length} posts`));
|
||||
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(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);
|
||||
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);
|
||||
}
|
||||
output.push(newPost);
|
||||
if (output.length > 0) {
|
||||
postsByUser[userID] = output;
|
||||
}
|
||||
userTimings[userID] = performance.now() - userStartTime;
|
||||
}
|
||||
let compressedData = await compressString(JSON.stringify(output));
|
||||
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"));
|
||||
@@ -694,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) {
|
||||
@@ -730,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');
|
||||
@@ -744,13 +812,15 @@ export class App {
|
||||
if (!file)
|
||||
return;
|
||||
const buffer = await file.arrayBuffer();
|
||||
await this.importPostsForUser(this.userID, buffer);
|
||||
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 => {
|
||||
@@ -975,7 +1045,7 @@ export class App {
|
||||
this.sync.setArchive(this.isArchivePeer);
|
||||
this.connect();
|
||||
await this.initDB();
|
||||
window.addEventListener('popstate', () => { this.getRoute(); this.render(); });
|
||||
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;
|
||||
@@ -1076,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) ?? [];
|
||||
@@ -1086,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;
|
||||
@@ -1203,6 +1292,36 @@ export class App {
|
||||
}
|
||||
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!");
|
||||
@@ -1296,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) {
|
||||
@@ -1342,6 +1469,7 @@ export class App {
|
||||
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
25
static/db.js
25
static/db.js
@@ -433,6 +433,31 @@ export async function getRepliesForPost(postID) {
|
||||
}
|
||||
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
@@ -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>
|
||||
|
||||
@@ -392,6 +392,7 @@ iframe {
|
||||
max-width: 96px;
|
||||
max-height: 96px;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -418,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user