Files
dandelion/static/main2.js

1145 lines
49 KiB
JavaScript

// TODO: virtual list, only rerender what's needed so things can keep playing.
/*
Problems
1. Can't delete, very annoying
Tombstones. Send all IDs and all Tombstones. Ask only for posts that we don't get a tombstone for. Don't send posts we have a tombstone for?
Posts don't propagate, you need to refresh to see new posts.
Broadcast when we post to all peers we know about.
3. Posting is slow because too long to render
2. Can't follow people
4. Can't like or reply to posts
user
posts
media
tombstones
following
profile
name
description
profile pic
Restruucture the app around the data. App/WS split is messy. Clean it up.
*/
// import * as ForceGraph3D from "3d-force-graph";
import { openDatabase, getData, addData, deleteData, getAllData } from "db";
import { generateID } from "IDUtils";
import { PeerManager, PeerEventTypes } from "PeerManager";
import { Sync } from "Sync";
import { log, logID, renderLog, setLogVisibility } from "log";
class Post {
constructor(author, author_id, text, post_timestamp, imageData = null, importedFrom = null, importSource = null) {
this.post_timestamp = post_timestamp;
this.post_id = generateID();
this.author = author;
this.author_id = author_id;
this.text = text;
this.image_data = imageData;
this.importedFrom = importedFrom;
this.importSource = importSource;
}
}
window.addEventListener('scroll', () => {
// Total height of the document
const totalPageHeight = document.body.scrollHeight;
// Current scroll position
const scrollPoint = window.scrollY + window.innerHeight;
// Check if scrolled to bottom
if (scrollPoint >= totalPageHeight) {
// console.log.apply(null, log('Scrolled to the bottom!'));
// console.log.apply(null, log(scrollPoint, totalPageHeight));
}
});
// let peer = await new PeerConnection(peer_id);
// let connectionReply = await wsConnection.send('hello');
// for (let peer of connectionReply) {
// let peerConnection = await wsConnection.send('connect', peer.id);
// if (peerConnection) {
// this.peers.push(peerConnection);
// let postIDs = await peerConnection.getPostIDs();
// let postsWeDontHave = this.diffPostIDs(postIDs);
// let newPosts = await peerConnection.getPosts(postsWeDontHave);
// this.addPosts(newPosts);
// }
// }
async function bytesToBase64DataUrl(bytes, type = "application/octet-stream") {
return await new Promise((resolve, reject) => {
const reader = Object.assign(new FileReader(), {
onload: () => resolve(reader.result),
onerror: () => reject(reader.error),
});
reader.readAsDataURL(new File([bytes], "", { type }));
});
}
async function arrayBufferToBase64(buffer) {
var bytes = new Uint8Array(buffer);
return (await bytesToBase64DataUrl(bytes)).replace("data:application/octet-stream;base64,", "");
}
// async function base64ToArrayBuffer(base64String: string) {
// let response = await fetch("data:application/octet-stream;base64," + base64String);
// let arrayBuffer = await response.arrayBuffer();
// return arrayBuffer;
// }
async function compressString(input) {
// Convert the string to a Uint8Array
const textEncoder = new TextEncoder();
const inputArray = textEncoder.encode(input);
// Create a CompressionStream
const compressionStream = new CompressionStream('gzip');
const writer = compressionStream.writable.getWriter();
// Write the data and close the stream
writer.write(inputArray);
writer.close();
// 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);
}
class App {
constructor() {
this.username = '';
this.peername = '';
this.userID = '';
this.peerID = '';
this.following = new Set();
this.posts = [];
this.isHeadless = false;
this.isBootstrapPeer = false;
this.isArchivePeer = false;
this.showLog = false;
this.markedAvailable = false;
this.limitPosts = 50;
// websocket: wsConnection | null = null;
// vizGraph: any | null = null;
this.qrcode = null;
this.connectURL = "";
this.firstRun = false;
this.peerManager = null;
this.sync = new Sync();
this.renderTimer = 0;
this.time = 0;
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'];
// keep a map of posts to dom nodes.
// on re-render
// posts that are not in our list that we need at add
// posts that are in our list that we need to remove
this.renderedPosts = new Map();
this.router = {
route: App.Route.HOME,
userID: '',
postID: '',
mediaID: ''
};
}
async announceUser_rpc_response(sendingPeerID, userIDs) {
if (this.isBootstrapPeer) {
return;
}
console.log.apply(null, log(`announceUsers from ${sendingPeerID}`, userIDs));
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)) {
let postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID);
// console.log.apply(null, log(`[app] announceUsers response, gotPostIDs`, postIDs));
let neededPostIDs = await this.sync.checkPostIds(userID, sendingPeerID, postIDs);
// console.log.apply(null, log(`[app] announceUsers needed posts`, neededPostIDs));
if (neededPostIDs.length > 0) {
let neededPosts = await this.peerManager?.rpc.getPostsForUser(sendingPeerID, this.peerID, userID, neededPostIDs);
console.log(neededPosts);
}
}
}
;
}
async connect() {
this.peerManager = new PeerManager(this.userID, this.peerID, this.isBootstrapPeer);
if (this.peerManager === null) {
throw new Error();
}
// this.registerRPCs();
this.peerManager.addEventListener(PeerEventTypes.PEER_CONNECTED, async (event) => {
if (!this.peerManager) {
throw new Error();
}
console.log.apply(null, log(`[app]: peer connected:${event.peerID}`));
if (this.isBootstrapPeer) {
return;
}
let knownUsers = await this.sync.getKnownUsers();
this.peerManager.rpc.announceUsers(event.peerID, this.peerID, knownUsers);
// rpc saying what peers we have
});
this.peerManager.addEventListener(PeerEventTypes.PEER_DISCONNECTED, async (event) => {
console.log.apply(null, log(`[app]: peer disconnected:${event.peerID}`));
});
console.log.apply(null, log("*************** before peerManager.connect"));
// We use promises here to only return from this call once we're connected to the boostrap peer
// and the datachannel is open.
// Might want to take this a step further and only return once we're connected to an initial set of peers?
// we could return progress information as we connect and have the app subscribe to that?
// Would be lovely to show a little display of peers connecting, whether you're connected directly to a friend's peer etc.
// Basically that live "dandelion" display.
this.peerManager.registerRPC('announceUsers', (sendingPeerID, userIDs) => {
this.announceUser_rpc_response(sendingPeerID, userIDs);
});
this.peerManager.registerRPC('getPeersForUser', (userID) => {
return [1, 2, 3, 4, 5];
});
this.peerManager.registerRPC('getPostIDsForUser', async (userID) => {
let postIDs = await this.sync.getPostIdsForUser(userID);
if (postIDs) {
return postIDs;
}
});
this.peerManager.registerRPC('getPostsForUser', async (requestingPeerID, userID, postIDs) => {
let posts = await this.sync.getPostsForUser(userID, postIDs);
for (let post of posts) {
console.log.apply(null, log(`[app] sendPostForUser sending post [${logID(post.post_id)}] to [${logID(requestingPeerID)}]`, userID, post));
this.peerManager?.rpc.sendPostForUser(requestingPeerID, userID, post);
}
// return posts;
// return postIDs;
});
this.peerManager.registerRPC('sendPostForUser', async (userID, post) => {
console.log.apply(null, log(`[app] sendPostForUser got post`, userID, post));
// if (post.text === "image...") {
// debugger;
// }
await this.sync.writePostForUser(userID, post);
// if (userID === this.userID) {
if (this.renderTimer) {
clearTimeout(this.renderTimer);
}
this.renderTimer = setTimeout(() => { this.render(); }, 200);
// }
});
await this.peerManager.connect();
console.log.apply(null, log("*************** after peerManager.connect"));
;
if (this.isBootstrapPeer) {
return;
}
// let usersToSync = await Sync.getFollowing(this.userID);
// for (let userID of usersToSync) {
// console.log(userID);
// // this.peerManager.rpc.getPeersForUser(userID);
// }
// for (let userID in this.sync.usersToSync()) {
// let peers = await this.peerManager.rpc.getPeersForUser(userID);
// for (let peer in peers) {
// let peer = await this.peerManager.connectToPeer(userID);
// let postIDs = peer.getPostIDsForUser(userID);
// let postIDsNeeded = this.sync.checkPostIds(userID, postIDs);
// if (postIDs.length === 0) {
// continue;
// }
// let posts = peer.rpc.getPostsForUser(userID, postIDs);
// this.sync.writePostsForUser(userID, posts);
// this.render();
// }
// }
// let postIDs = await this.peerManager.rpc.getPostIDsForUser(this.peerManager.bootstrapPeerID, this.userID);
// console.log.apply(null, log("peerManager.rpc.getPostIDsForUser", postIDs));
}
getPreferentialUserID() {
return this.router.userID.length !== 0 ? this.router.userID : this.userID;
}
initMarkdown() {
if (typeof marked === "undefined") {
return;
}
const renderer = new marked.Renderer();
renderer.link = (href, title, text) => {
return `<a href="${href}" target="_blank"${title ? ` title="${title}"` : ''}>${text}</a>`;
};
marked.setOptions({ renderer: renderer });
this.markedAvailable = true;
}
// arrayBufferToBase64(buffer: ArrayBuffer) {
// return new Promise((resolve, reject) => {
// const blob = new Blob([buffer], { type: 'application/octet-stream' });
// const reader = new FileReader();
// reader.onloadend = () => {
// const dataUrl = reader.result as string;
// if (!dataUrl) {
// resolve(null);
// return;
// }
// const base64 = dataUrl.split(',')[1];
// resolve(base64);
// };
// reader.onerror = (error) => {
// reject(error);
// };
// reader.readAsDataURL(blob);
// });
// }
async createTestData() {
let postsTestData = await (await fetch("./postsTestData.json")).json();
return postsTestData;
}
timerStart() {
this.time = performance.now();
}
timerDelta() {
return performance.now() - this.time;
}
getFixedTweetText(entry) {
let fullText = entry.tweet.full_text;
let linkMarkdown = "";
for (const url of entry.tweet.entities.urls) {
linkMarkdown = `[${url.display_url}](${url.expanded_url})`;
fullText = fullText.replace(url.url, linkMarkdown);
}
return fullText;
}
downloadBinary(data, filename, mimeType = 'application/octet-stream') {
// Create a blob from the ArrayBuffer with the specified MIME type
const blob = new Blob([data], { type: mimeType });
// Create object URL from the blob
const url = window.URL.createObjectURL(blob);
// Create temporary link element
const link = document.createElement('a');
link.href = url;
link.download = filename;
// Append link to body, click it, and remove it
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the object URL
window.URL.revokeObjectURL(url);
}
downloadJson(data, filename = 'data.json') {
const jsonString = JSON.stringify(data);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.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);
}
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`);
}
async importTweetArchive(userID, tweetArchive) {
console.log.apply(null, log("Importing tweet archive"));
let postsTestData = [];
// let response = await fetch("./tweets.js");
// let tweetsText = await response.text();
// tweetsText = tweetsText.replace("window.YTD.tweets.part0", "window.tweetData");
// new Function(tweetsText)();
// let tweets = JSON.parse(tweetJSON);
let count = 0;
for (let entry of tweetArchive) {
// if (entry.tweet.hasOwnProperty("in_reply_to_screen_name") || entry.tweet.retweeted || entry.tweet.full_text.startsWith("RT")) {
// continue;
// }
let mediaURL = entry.tweet?.entities?.media?.[0]?.media_url_https;
let isImage = false;
if (mediaURL) {
isImage = mediaURL.includes('jpg');
}
let imageData = null;
// if (isImage) {
// try {
// let response = await fetch(mediaURL);
// await waitMs(100);
// if (response.status === 200) {
// imageData = await response.arrayBuffer();
// }
// console.log.apply(null, log(imageData);
// } catch (e) {
// console.log.apply(null, log(e);
// }
// }
let timeStamp = new Date(entry.tweet.created_at);
let tweetText = this.getFixedTweetText(entry);
let newPost = new Post('bobbydigitales', userID, tweetText, timeStamp, imageData, 'twitter', entry);
postsTestData.push(newPost);
count++;
if (count % 100 === 0) {
console.log.apply(null, log(`Imported ${count} posts...`));
;
// render(postsTestData);
}
// if (count == 100-1) {
// break;
// }
}
return postsTestData;
}
async createTestData3(userID) {
let posts = await (await (fetch('./posts.json'))).json();
return posts;
}
async registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
return;
}
let registrations = await navigator.serviceWorker.getRegistrations();
if (registrations.length > 0) {
console.log.apply(null, log("Service worker already registered."));
return registrations[0];
}
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
console.log.apply(null, log("Service Worker registered with scope:", registration.scope));
return registration;
})
.catch((error) => {
console.error("Service Worker registration failed:", error);
});
}
async compressImage(imageData, mimeType, quality = 0.5) {
let uncompressedByteLength = imageData.byteLength;
console.log.apply(null, log(`compressImage input:${mimeType} size:${(uncompressedByteLength / 1024).toFixed(2)}KBi quality:${quality}`));
;
try {
// Convert ArrayBuffer to Blob
const blob = new Blob([imageData], { type: mimeType });
const bitmap = await createImageBitmap(blob, {
imageOrientation: 'none',
// resizeWidth: desiredWidth,
// resizeHeight: desiredHeight,
// resizeQuality: 'high',
});
// const bitmap = await createImageBitmap(bitmapTemp, {
// imageOrientation: 'none',
// resizeWidth: 600,
// resizeHeight: 800,
// // resizeHeight: (bitmapTemp.height / bitmapTemp.width) * 600,
// resizeQuality: 'high',
// })
//drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
// Create a canvas and draw the image onto it
// let scale = 1/32;
// let scaledWidth = bitmap.width*scale;
// let scaledHeight = bitmap.height*scale;
// let scale = 1/32;
let scaledWidth = bitmap.width;
let scaledHeight = bitmap.height;
let resizeThreshold = 600;
if (scaledWidth > resizeThreshold) {
scaledWidth = resizeThreshold;
scaledHeight = (bitmap.height / bitmap.width) * resizeThreshold;
}
const canvas = document.createElement('canvas');
canvas.width = scaledWidth;
canvas.height = scaledHeight;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
canvas.getContext('2d').drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, scaledWidth, scaledHeight);
// Compress the image and get the result as an ArrayBuffer
const compressedBlob = await new Promise((resolve, reject) => canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('Compression failed.'))), 'image/jpeg', quality));
// TODO: Don't need to do this as we'll be storing blobs directly.
let compressedArrayBuffer = await compressedBlob.arrayBuffer();
let compressedByteLength = compressedArrayBuffer.byteLength;
let percent = (uncompressedByteLength / compressedByteLength);
console.log.apply(null, log(`compressImage: compressedSize:${(compressedArrayBuffer.byteLength / 1024).toFixed(2)}KBi ${percent.toFixed(2)}:1 compression`));
;
return compressedArrayBuffer;
}
catch (e) {
console.error(e);
return null;
}
}
async createNewPost(userID, postText, mediaData, mimeType) {
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) {
let compressedImage = await this.compressImage(mediaData, mimeType, 0.9);
if (compressedImage) {
mediaData = compressedImage;
}
}
let post = new Post(this.username, userID, postText, new Date(), mediaData);
// this.posts.push(post);
// localStorage.setItem(key, JSON.stringify(posts));
addData(userID, post);
// this.websocket?.broadcastNewPost(userID, post);
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);
}
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);
}
return id;
}
hashIdToIndices(id) {
let indices = [];
for (let char of id) {
if (char !== '0' && char !== '-') {
indices.push(parseInt(char, 16));
if (indices.length == 2) {
break;
}
}
}
return [indices[0], indices[1]];
}
funkyName(id, listOne, listTwo) {
let [one, two] = this.hashIdToIndices(id);
let first = listOne[one % this.adjectives.length];
let second = listTwo[two % this.animals.length];
return { first, second };
}
getUsername() {
let username = localStorage.getItem("dandelion_username");
if (username && username !== "not_set") {
return username;
}
let { first: adjective, second: animal } = this.funkyName(this.userID, this.adjectives, this.animals);
username = `${adjective}_${animal}`;
localStorage.setItem("dandelion_username", username);
return username;
}
getPeername() {
let { first: adjective, second: snake } = this.funkyName(this.peerID, this.adjectives, this.snakes);
let peername = `${adjective}_${snake}`;
return peername;
}
setFont(fontName, fontSize) {
let content = document.getElementById('content');
if (!content) {
return;
}
content.style.fontFamily = fontName;
content.style.fontSize = fontSize;
let textArea = document.getElementById('textarea_post');
if (!textArea) {
return;
}
textArea.style.fontFamily = fontName;
textArea.style.fontSize = fontSize;
}
// initOffline(connection: wsConnection) {
// // Event listener for going offline
// window.addEventListener('offline', () => {
// console.log.apply(null, log("offline"));
// });
// // Event listener for going online
// window.addEventListener('online', async () => {
// console.log.apply(null, log("online"));
// // connection.connect();
// this.render();
// });
// console.log.apply(null, log(`Online status: ${navigator.onLine ? "online" : "offline"}`));
// }
selectFile(contentType) {
return new Promise(resolve => {
let input = document.createElement('input');
input.type = 'file';
// input.multiple = multiple;
input.accept = contentType;
input.onchange = () => {
if (input.files == null) {
resolve(null);
return;
}
let files = Array.from(input.files);
// if (multiple)
// resolve(files);
// else
resolve(files[0]);
};
input.click();
});
}
readFile(file) {
// Always return a Promise
return new Promise((resolve, reject) => {
let content = '';
const reader = new FileReader();
// Wait till complete
reader.onloadend = function (e) {
content = e.target.result;
resolve(content);
};
// Make sure to handle error states
reader.onerror = function (e) {
reject(e);
};
reader.readAsText(file);
});
}
async lazyCreateQRCode() {
if (this.qrcode != null) {
return;
}
this.qrcode = await new QRCode(document.getElementById('qrcode'), {
text: this.connectURL,
width: 150,
height: 150,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H
});
}
showInfo() {
let infoElement = document.getElementById('info');
if (infoElement === null) {
return;
}
infoElement.style.display == 'none' ? infoElement.style.display = 'block' : infoElement.style.display = 'none';
setLogVisibility(infoElement.style.display == 'block');
renderLog();
this.lazyCreateQRCode();
document.querySelector('#qrcode > img').classList.add('qrcode_image');
document.querySelector('#qrcode > canvas').classList.add('qrcode_image');
this.showLog = true;
}
button(elementName) {
return document.getElementById(elementName);
}
div(elementName) {
return document.getElementById(elementName);
}
initButtons(userID, posts, registration) {
// let font1Button = document.getElementById("button_font1") as HTMLButtonElement;
// let font2Button = document.getElementById("button_font2") as HTMLButtonElement;
// let importTweetsButton = document.getElementById("import_tweets") as HTMLButtonElement;
// let toggleDark = document.getElementById('toggle_dark') as HTMLButtonElement;
// let clearPostsButton = document.getElementById("clear_posts") as HTMLButtonElement;
// let updateApp = document.getElementById("update_app") as HTMLButtonElement;
// let ddlnLogoButton = document.getElementById('ddln_logo_button') as HTMLDivElement;
// let addPic = document.getElementById('button_add_pic') as HTMLDivElement;
// toggleDark.addEventListener('click', () => {
// document.documentElement.style.setProperty('--main-bg-color', 'white');
// document.documentElement.style.setProperty('--main-fg-color', 'black');
// })
let homeButton = this.div('home-button');
homeButton.addEventListener('click', e => window.location.href = `${window.location.origin}/`);
let profileButton = this.div('profile-button');
profileButton.addEventListener('click', e => window.location.href = `${window.location.origin}/user/${this.userID}`);
let monitorButton = this.div('monitor_button');
monitorButton.addEventListener('click', async () => {
navContainer.classList.toggle('active');
this.showInfo();
});
let navContainer = this.div('nav-container');
let burgerMenuButton = this.div('burger-menu-button');
burgerMenuButton.addEventListener('click', e => navContainer.classList.toggle('active'));
let exportButton = this.button("export-button");
exportButton.addEventListener('click', async (e) => {
await this.exportPostsForUser(this.userID);
});
let composeButton = this.div('compose-button');
composeButton.addEventListener('click', e => {
document.getElementById('compose').style.display = 'block';
document.getElementById('textarea_post')?.focus();
});
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);
}
// Reset so that if they pick the same image again, we still get the change event.
filePicker.value = '';
});
let filePickerLabel = document.getElementById('file-input-label');
filePickerLabel?.addEventListener('click', () => {
console.log.apply(null, log("Add pic..."));
});
let usernameField = document.getElementById('username');
usernameField?.addEventListener('input', (event) => {
this.username = event.target.innerText;
localStorage.setItem("dandelion_username", this.username);
});
// importTweetsButton.addEventListener('click', async () => {
// let file = await this.selectFile('text/*');
// console.log.apply(null, log(file);
// if (file == null) {
// return;
// }
// let tweetData = await this.readFile(file);
// tweetData = tweetData.replace('window.YTD.tweets.part0 = ', '');
// const tweets = JSON.parse(tweetData);
// let imported_posts = await this.importTweetArchive(userID, tweets);
// clearData(userID);
// // posts = posts.reverse();
// addDataArray(userID, imported_posts);
// this.render();
// });
// clearPostsButton.addEventListener('click', () => { clearData(userID); posts = []; this.render() });
let postButton = document.getElementById("button_post");
let postText = document.getElementById("textarea_post");
if (!(postButton && postText)) {
throw new Error();
}
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);
});
postButton.addEventListener("click", () => {
this.createNewPost(userID, postText.value);
postText.value = "";
document.getElementById('compose').style.display = 'none';
});
// updateApp.addEventListener("click", () => {
// registration?.active?.postMessage({ type: "update_app" });
// });
// ddlnLogoButton.addEventListener('click', async () => {
// this.showInfo()
// });
}
async getPostsForFeed() {
// get N posts from each user and sort them by date.
// This isn't really going to work very well.
// Eventually we'll need a db that only has followed user posts so we can get them chronologically
//
let posts = [];
for (let followedID of this.following.keys()) {
posts = posts.concat(await getData(followedID, new Date(2022, 8), new Date()));
// console.log.apply(null, log(followedID);
}
// @ts-ignore
posts = posts.sort((a, b) => a.post_timestamp - b.post_timestamp);
return posts;
}
async loadFollowersFromStorage(userID) {
return this.sync.getFollowing(userID);
}
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) {
console.log.apply(null, log(`Loaded ${posts.length} posts in ${this.timerDelta().toFixed(2)}ms`));
;
return posts;
}
// posts = await createTestData2(userID);
// log("Adding test data...");
// addDataArray(userID, posts);
// return await getData(userID, new Date(2022, 8), new Date());
}
async listUsers() {
let knownUsers = [...(await indexedDB.databases())].map((db) => db.name?.replace('user_', ''));
if (knownUsers.length === 0) {
return;
}
let preferredId = app.getPreferentialUserID();
for (let userID of knownUsers) {
// if (userID === preferredId) {
// continue;
// }
// let ids = await getAllIds(userID);
// if (ids.length === 0) {
// console.log.apply(null, log(`Purging user ${userID}`);
// indexedDB.deleteDatabase(`user_${userID}`);
// continue;
// }
console.log.apply(null, log(`${document.location.origin}/user/${userID}`));
// console.log.apply(null, log(`https://ddln.app/${this.username}/${uuidToBase58(userID)}`, userID);
}
}
async initDB() {
let db = await openDatabase(this.userID);
}
query_findPeersForUser(message) {
let havePostsForUser = true;
if (havePostsForUser) {
return this.peerID;
}
return false;
}
async registerRPCs() {
if (!this.peerManager) {
throw new Error();
}
this.peerManager.registerRPC('ping', (args) => {
return { id: this.peerID, user: this.userID, user_name: this.username, peer_name: this.peername };
});
// if (!this.isBootstrapPeer) {
// let pong = await this.peerManager.rpc.ping(this.peerManager.bootstrapPeerID);
// console.log.apply(null, log('pong from: ', pong));
// }
// this.peerManager.registerRPC('getPostIDsForUser', (args: any) => {
// this.sync.getPostsForUser
// });
}
async testPeerManager() {
if (!this.peerManager) {
throw new Error();
}
this.peerManager.registerRPC('getPeersForUser', (userID) => {
return [1, 2, 3, 4, 5];
});
// this.peerManager.registerRPC('getPostIDsForUser', (args: any) => {
// return [1, 2, 3, 4, 5];
// });
// let postIDs = await this.peerManager.rpc.getPostIDsForUser("dummy_peer", "bloop");
// console.log.apply(null, log("peerManager.rpc.getPostIDsForUser", postIDs));
// this.peerManager.registerSearchQuery('find_peers_for_user', this.query_findPeersForUser);
// let peers = await this.peerManager.search('find_peers_for_user', { 'user_id': 'bloop' });
}
async main() {
// Do capability detection here and report in a simple way if things we need don't exist with guidance on how to resolve it.
let urlParams = (new URL(window.location.href)).searchParams;
if (urlParams.has('log')) {
this.showInfo();
}
this.isHeadless = /\bHeadlessChrome\//.test(navigator.userAgent) || urlParams.has('headless');
this.isArchivePeer = urlParams.has('archive');
this.isBootstrapPeer = urlParams.has("bootstrap");
console.log(`[headless]${this.isHeadless} [archive] ${this.isArchivePeer} [bootstrap] ${this.isBootstrapPeer}`);
let limitPostsParam = urlParams.get('limitPosts');
if (limitPostsParam) {
this.limitPosts = parseInt(limitPostsParam);
}
this.peerID = this.getPeerID();
this.peername = this.getPeername();
this.userID = this.getUserID();
this.username = this.getUsername();
this.sync.setUserID(this.userID);
this.sync.setArchive(this.isArchivePeer);
this.getRoute();
if (this.router.route === App.Route.CONNECT) {
console.log.apply(null, log('connect', this.router.userID));
localStorage.setItem("dandelion_id", this.router.userID);
localStorage.removeItem("dandelion_username");
}
this.connect();
await this.initDB();
this.connectURL = `${document.location.origin}/connect/${this.userID}`;
document.getElementById('connectURL').innerHTML = `<a href="${this.connectURL}">connect</a>`;
let time = 0;
let delta = 0;
// let isPersisted = await navigator?.storage?.persisted();
// if (!isPersisted) {
// debugger;
// const isPersisted = await navigator.storage.persist();
// console.log.apply(null, log(`Persisted storage granted: ${isPersisted}`));;
// }
// log(`Persisted: ${(await navigator?.storage?.persisted())?.toString()}`);
this.initMarkdown();
// let main = await fetch("/main.js");
// let code = await main.text();
// console.log.apply(null, log(code);
// registration.active.postMessage({type:"updateMain", code:code});
// this.posts = await this.loadPosts(userID) ?? [];
// debugger;
await this.render(); // , (postID:string)=>{this.deletePost(userID, postID)}
if (performance?.memory) {
console.log.apply(null, log(`memory used: ${(performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2)}Mb`));
}
// if (navigator?.storage) {
// let storageUsed = (await navigator?.storage?.estimate())?.usage/1024/1024
// }
// if (urlParams.get("sw") === "true") {
let registration;
registration = await this.registerServiceWorker();
// }
document.getElementById('username').innerText = `${this.username}`;
document.getElementById('peername').innerText = `peername:${this.peername}`;
document.getElementById('user_id').innerText = `user_id:${this.userID}`;
document.getElementById('peer_id').innerText = `peer_id:${this.peerID}`;
this.initButtons(this.userID, this.posts, registration);
console.log.apply(null, log(`username:${this.username} user:${this.userID} peername:${this.peername} peer:${this.peerID}`));
;
// await this.purgeEmptyUsers();
// this.listUsers()
// this.createNetworkViz();
// const client = new WebTorrent()
// // Sintel, a free, Creative Commons movie
// const torrentId = 'magnet:?xt=urn:btih:6091e199a8d9272a40dd9a25a621a5c355d6b0be&dn=WING+IT!+-+Blender+Open+Movie+1080p.mp4&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337';
// client.add(torrentId, function (torrent: any) {
// // Torrents can contain many files. Let's use the .mp4 file
// const file = torrent.files.find(function (file: any) {
// return file.name.endsWith('.mp4')
// })
// // Display the file by adding it to the DOM.
// // Supports video, audio, image files, and more!
// file.appendTo(document.getElementById('torrent-content'));
// })
}
renderWelcome(contentDiv) {
contentDiv.innerHTML = `<div style="font-size:24px">
Welcome to Dandelion v0.1!<br>
Loading posts for the default feed...
</div>
`;
}
async render() {
if (this.isHeadless) {
console.log.apply(null, log('Headless so skipping render...'));
return;
}
performance.mark("render-start");
this.timerStart();
let existingPosts = this.posts;
this.posts = [];
switch (this.router.route) {
case App.Route.HOME:
case App.Route.CONNECT: {
this.following = new Set(await this.loadFollowersFromStorage(this.userID) ?? []);
this.posts = await this.getPostsForFeed();
// this.posts = await this.loadPostsFromStorage(this.userID) ?? [];
// let compose = document.getElementById('compose');
// if (!compose) {
// break;
// }
// compose.style.display = "block";
break;
}
case App.Route.USER: {
this.posts = await this.loadPostsFromStorage(this.router.userID) ?? [];
let compose = document.getElementById('compose');
if (!compose) {
break;
}
compose.style.display = "none";
break;
}
case App.Route.POST: {
this.posts = await this.loadPostsFromStorage(this.router.userID, this.router.postID) ?? [];
let compose = document.getElementById('compose');
if (!compose) {
break;
}
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) ?? [];
break;
}
}
let contentDiv = document.getElementById("content");
if (!contentDiv) {
throw new Error();
}
if (this.posts.length === 0) {
this.renderWelcome(contentDiv);
return;
}
// let existingPostSet = new Set(existingPosts.map(post => post.post_id));
// let incomingPostSet = new Set(this.posts.map(post => post.post_id));
// let addedPosts = [];
// for (let post of this.posts) {
// if (!existingPostSet.has(post.post_id)) {
// addedPosts.push(post);
// }
// }
// let deletedPosts = [];
// for (let post of existingPosts) {
// if (!incomingPostSet.has(post.post_id)) {
// deletedPosts.push(post);
// }
// }
// console.log.apply(null, log("added:", addedPosts, "removed:", deletedPosts);
const fragment = document.createDocumentFragment();
contentDiv.innerHTML = "";
let count = 0;
this.renderedPosts.clear();
let first = true;
for (let i = this.posts.length - 1; i >= 0; i--) {
let postData = this.posts[i];
// this.postsSet.add(postData);
// TODO return promises for all image loads and await those.
let post = this.renderPost(postData.data, first);
first = false;
// this.renderedPosts.set(postData.post_id, post);
if (post) {
fragment.appendChild(post);
count++;
}
if (count > this.limitPosts) {
break;
}
}
if (!contentDiv) {
throw new Error("Couldn't get content div!");
}
contentDiv.appendChild(fragment);
let renderTime = this.timerDelta();
console.log.apply(null, log(`render took: ${renderTime.toFixed(2)}ms`));
;
performance.mark("render-end");
performance.measure('render-time', 'render-start', 'render-end');
// if ((performance as any)?.memory) {
// console.log.apply(null, log(`memory used: ${((performance as any).memory.usedJSHeapSize / 1024 / 1024).toFixed(2)}Mb`));
// }
}
async deletePost(userID, postID) {
deleteData(userID, postID);
this.render();
}
renderPost(post, first) {
if (!(post.hasOwnProperty("text"))) {
throw new Error("Post is malformed!");
}
let containerDiv = document.createElement("div");
let timestamp = `${post.post_timestamp.toLocaleTimeString()} · ${post.post_timestamp.toLocaleDateString()}`;
let deleteButton = document.createElement('button');
deleteButton.innerText = 'delete';
deleteButton.onclick = () => { this.deletePost(post.author_id, post.post_id); };
// let editButton = document.createElement('button'); editButton.innerText = 'edit';
let shareButton = document.createElement('button');
shareButton.innerText = 'share';
shareButton.onclick = async () => {
let shareUrl = `${document.location.origin}/user/${post.author_id}/post/${post.post_id}`;
await navigator.clipboard.writeText(shareUrl);
};
let ownPost = post.author_id === this.userID;
let markdown = post.text;
if (this.markedAvailable) {
markdown = marked.parse(post.text);
}
// if (markdown.includes("<iframe")) {
// debugger;
// }
// markdown = this.replaceIframeWithDiv(markdown);
if (markdown.includes("<iframe") && markdown.includes(`src="https://dotbigbang`)) {
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>` : ''}
${ownPost ? `<span id="editButton"></span>` : ''}
<span id="shareButton"></span>
</div>
<div>${markdown}</div>
</div>`;
containerDiv.innerHTML = postTemplate;
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;
}
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;
}
static maximizeElement(element) {
element.style.transform = "scale(2.0)";
}
getRoute() {
let path = document.location.pathname;
console.log.apply(null, log("router: path ", path));
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) {
if (match[8]) { // Check for the connect route
this.router.userID = match[8];
this.router.route = App.Route.CONNECT;
}
else {
this.router.userID = match[2];
this.router.postID = match[4];
this.router.mediaID = match[6];
if (this.router.mediaID) {
this.router.route = App.Route.MEDIA;
}
else if (this.router.postID) {
this.router.route = App.Route.POST;
}
else {
this.router.route = App.Route.USER;
}
}
}
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>
// media = /user/<ID>/post/<ID>/media/<index>
// group = /group/ID/post/<ID>
// hashtag = /hashtag/ -- maybe only hastags in groups
// home = /
}
}
(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 = App.Route || (App.Route = {}));
;
// export function connect() {
// throw new Error("Function not implemented.");
// }
// export function connect() {
// throw new Error("Function not implemented.");
// }
})(App || (App = {}));
let app = new App();
window.addEventListener("load", app.main.bind(app));