diff --git a/static/App.js b/static/App.js new file mode 100644 index 0000000..146bc69 --- /dev/null +++ b/static/App.js @@ -0,0 +1,1094 @@ +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 { 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; + } +} +export 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.syncQueues = new Map(); + this.syncing = new Set(); + 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 processSyncQueue(userID) { + if (this.syncing.has(userID)) { + return; + } + let syncQueue = this.syncQueues.get(userID); + while (syncQueue.length !== 0) { + this.syncing.add(userID); + let syncItem = syncQueue.pop(); + if (!syncItem) { + throw new Error(); + } + let peerID = syncItem?.peerID; + let postIDs = syncItem?.postIDs; + let neededPostIDs = await this.sync.checkPostIds(userID, peerID, postIDs); + if (neededPostIDs.length > 0) { + console.log.apply(null, log(`[app] Need (${neededPostIDs.length}) posts for user ${logID(userID)} from peer ${logID(peerID)}`)); + let neededPosts = await this.peerManager?.rpc.getPostsForUser(peerID, this.peerID, userID, neededPostIDs); + // console.log(neededPosts); + } + else { + console.log.apply(null, log(`[app] Don't need any posts for user ${logID(userID)} from peer ${logID(peerID)}`)); + } + } + this.syncing.delete(userID); + } + addPostIDsToSyncQueue(userID, peerID, postIDs) { + let syncQueue = this.syncQueues.get(userID); + if (!syncQueue) { + let newArray = []; + this.syncQueues.set(userID, newArray); + syncQueue = newArray; + } + syncQueue.push({ peerID: peerID, postIDs: postIDs }); + this.processSyncQueue(userID); + } + // To avoid reuesting the same posts from multiple peers: + // 1. Add incoming IDs to queue + // 2. Call a function that tests IDs and then gets posts. + // 3. Once the posts are retrieved and written, process the next entry in the list based on current state. + async announceUser_rpc_response(sendingPeerID, userIDs) { + if (this.isBootstrapPeer) { + return; + } + console.log.apply(null, log(`[app] got announceUsers from ${logID(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))) { + 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)}]`)); + let postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID); + console.log.apply(null, log(`[app] Got (${postIDs.length}) post IDs for user [${logID(userID)}] from peer [${logID(sendingPeerID)}]`)); + this.addPostIDsToSyncQueue(userID, sendingPeerID, postIDs); + } + } + 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.author, post.text)); + 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; + // } + await this.sync.writePostForUser(userID, post); + // if (userID === this.userID) { + if (this.renderTimer) { + clearTimeout(this.renderTimer); + } + this.renderTimer = setTimeout(() => { this.render(); }, 1000); + return true; + // } + }); + 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 `${text}`; + }; + 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 = globalThis.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 + globalThis.URL.revokeObjectURL(url); + } + downloadJson(data, filename = 'data.json') { + const jsonString = JSON.stringify(data); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = globalThis.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + 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); + } + 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("globalThis.YTD.tweets.part0", "globalThis.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 % listOne.length]; + let second = listTwo[two % listTwo.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 + // globalThis.addEventListener('offline', () => { + // console.log.apply(null, log("offline")); + // }); + // // Event listener for going online + // globalThis.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) { + // 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 => 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 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('globalThis.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 = this.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(globalThis.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.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.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.connect(); + await this.initDB(); + this.connectURL = `${document.location.origin}/connect/${this.userID}`; + document.getElementById('connectURL').innerHTML = `connect`; + 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 + // } + let registration; + let shouldRegisterServiceWorker = !(this.isBootstrapPeer || this.isArchivePeer || this.isHeadless); + if (shouldRegisterServiceWorker) { + 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); + 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 = `