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 = `
+ Welcome to Dandelion v0.1!
+ Loading posts for the default feed... +
+ `; + } + 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("${first ? '' : '
'} +
+ @${post.author} - + ${post.post_timestamp.toLocaleDateString()} + + ${ownPost ? `` : ''} + ${ownPost ? `` : ''} + +
+
${markdown}
+ `; + 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/ + // post = /user//post/ + // media = /user//post//media/ + // group = /group/ID/post/ + // 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 = {})); +})(App || (App = {})); +; diff --git a/static/IDUtils.js b/static/IDUtils.js new file mode 100644 index 0000000..efc9a6a --- /dev/null +++ b/static/IDUtils.js @@ -0,0 +1,9 @@ +function uuidv4() { + return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); +} +export function generateID() { + if (self.crypto.hasOwnProperty("randomUUID")) { + return self.crypto.randomUUID(); + } + return uuidv4(); +} diff --git a/static/PeerManager.js b/static/PeerManager.js new file mode 100644 index 0000000..cb73bbb --- /dev/null +++ b/static/PeerManager.js @@ -0,0 +1,692 @@ +// connect to WS server, send info, connecto to bootstrap peer +// once connected to bootstrap peer, +// Goal, connect to bootstrap peer, ask bootstrap peer for peers that have posts from users that we care about. get peers, connect to those peers, sync. +// how? do "perfect negotiation" with bootstrap peer. All logic here moves to BP. +import { generateID } from "IDUtils"; +import { log, logID } from "log"; +export var PeerEventTypes; +(function (PeerEventTypes) { + PeerEventTypes[PeerEventTypes["PEER_CONNECTED"] = 0] = "PEER_CONNECTED"; + PeerEventTypes[PeerEventTypes["PEER_DISCONNECTED"] = 1] = "PEER_DISCONNECTED"; +})(PeerEventTypes || (PeerEventTypes = {})); +export class PeerManager { + 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 }; + } + getPeername(peerID) { + let { first: adjective, second: snake } = this.funkyName(peerID, this.adjectives, this.snakes); + let peername = `${adjective}_${snake}`; + return peername; + } + websocketSend(message) { + if (!this.websocket) { + throw new Error(); + } + let messageJSON = ""; + try { + messageJSON = JSON.stringify(message); + } + catch (e) { + log(e); + return; + } + this.messageSuperlog && console.log.apply(null, log("<-signaler:", message)); + this.websocket.send(messageJSON); + } + onWebsocketMessage(event) { + let messageJSON = event.data; + let message = null; + try { + message = JSON.parse(messageJSON); + } + catch (e) { + log(e); + throw new Error(); + } + this.messageSuperlog && console.log.apply(null, log("->signaler:", message)); + if (message.type === "hello2") { + if (!this.isBootstrapPeer) { + this.bootstrapPeerID = message.bootstrapPeers[0]; + } + this.onHello2Received(this.bootstrapPeerID); + } + if (message.type === "peer_message") { + let peerConnection = this.peers.get(message.from); + if (message.message.type === "rtc_description") { + // let existingConnection = this.peers.get(message.from); + // // We're already connected, so delete the existing connection and make a new one. + if (peerConnection?.rtcPeer?.connectionState === "connected") { + console.log.apply(null, log("Connecting peer is already connected. Deleting existing peer connection and reconnecting.")); + peerConnection.disconnect(); + this.peers.delete(message.from); + peerConnection = undefined; + } + if (!peerConnection) { + let remotePeerID = message.from; + let newPeer = new PeerConnection(this, remotePeerID, this.websocketSendPeerMessage.bind(this)); + if (this.isBootstrapPeer) { + newPeer.setPolite(false); + } + peerConnection = newPeer; + this.peers.set(newPeer.remotePeerID, newPeer); + this.onConnectRequest(newPeer); + } + } + if (!peerConnection) { + console.log.apply(null, log("Can't find peer for peer message:", message)); + return; + } + peerConnection.onWebsocketMessage(message.message); + } + } + async onConnectRequest(newPeer) { + // let remotePeerID = message.from; + // let newPeer = new PeerConnection(this, remotePeerID, this.websocketSendPeerMessage.bind(this)); + // if (this.isBootstrapPeer) { + // newPeer.setPolite(false); + // } + await newPeer.connect(); + this.onPeerConnected(newPeer.remotePeerID); + return newPeer; + } + async onHello2Received(bootstrapPeerID) { + if (this.isBootstrapPeer) { + this.connectPromiseCallbacks?.resolve(); + return; + } + if (!bootstrapPeerID) { + console.log.apply(null, log("Didn't get bootstrap peer, waiting 10 seconds...")); + // let callSendHello2OnTimeout = () => { console.log(this, "jajajajaj");this.sendHello2() }; + // setTimeout(callSendHello2OnTimeout, 5_000); + return; + } + this.bootstrapPeerConnection = await this.connectToPeer(bootstrapPeerID); + this.connectPromiseCallbacks?.resolve(); + } + sendHello2() { + this.websocketSend({ + type: "hello2", + user_id: this.userID, + // user_name: app.username, + peer_id: this.peerID, + session_id: this.sessionID, + // peer_name: app.peername, + is_bootstrap_peer: this.isBootstrapPeer, + // peer_description: this.rtcPeerDescription + }); + } + websocketSendPeerMessage(remotePeerID, peerMessage) { + this.websocketSend({ + type: "peer_message", + from: this.peerID, + to: remotePeerID, + from_username: "blah user", + from_peername: "blah peer", + message: peerMessage + }); + // let responseMessage = { type: "peer_message", + // from: app.peerID, + // to: data.from, + // from_username: app.username, + // from_peername: app.peername, + // message: { type: "get_posts_for_user", post_ids: postIds, user_id: message.user_id } } + } + constructor(userID, peerID, isBootstrapPeer) { + // private signaler: Signaler; + this.searchQueryFunctions = new Map(); + this.RPC_remote = new Map(); + this.rpc = {}; + this.isBootstrapPeer = false; + this.bootstrapPeerConnection = null; + this.sessionID = generateID(); + this.websocket = null; + this.bootstrapPeerID = null; + this.connectPromiseCallbacks = null; + this.connectPromise = null; + this.pingPeers = []; + this.watchdogPeriodSeconds = 10; + this.eventListeners = new Map(); + this.reconnectPeriod = 10; + this.messageSuperlog = false; + this.watchdogInterval = 0; + this.reconnectTimer = null; + this.peerStateSuperlog = true; + // async watchdog() { + // // Check that we're connected to at least N peers. If not, reconnect to the bootstrap server. + // if (this.peers.size === 0) { + // await this.sendHello2(); + // } + // } + this.animals = ['shrew', 'jerboa', 'lemur', 'weasel', 'possum', 'possum', 'marmoset', 'planigale', 'mole', 'narwhal']; + this.adjectives = ['snazzy', 'whimsical', 'jazzy', 'bonkers', 'wobbly', 'spiffy', 'chirpy', 'zesty', 'bubbly', 'perky', 'sassy']; + this.snakes = ['mamba', 'cobra', 'python', 'viper', 'krait', 'sidewinder', 'constrictor', 'boa', 'asp', 'anaconda', 'krait']; + this.isBootstrapPeer = isBootstrapPeer; + this.peers = new Map(); + this.routingTable = new Map(); + this.userID = userID; + this.peerID = peerID; + } + disconnect() { + this.websocket?.close(); + for (let peer of this.peers.values()) { + peer.disconnect(); + } + } + connectWebSocket() { + try { + let hostname = globalThis?.location?.hostname ?? 'ddln.app'; + let port = globalThis?.location?.port ?? '443'; + let wsURL = `wss://${hostname}:${port}/ws`; + console.log.apply(null, log(`Attempting to connect websocket to URL: ${wsURL}`)); + this.websocket = new WebSocket(wsURL); + // this.websocket.onclose = (e: CloseEvent) => { + // let closedUnexpectedly = !e.wasClean; + // if (closedUnexpectedly) { + // console.log.apply(null, log(`Websocket closed unexpectedly. Will try to reconnect in ${this.reconnectPeriod} seconds`)); + // // let alreadyReconnecting = this.reconnectTimer !== null; + // // if (!alreadyReconnecting) { + // this.reconnectTimer = globalThis.setTimeout(() => { + // console.log.apply(null, log(`Reconnecting web socket`)); + // this.reconnectTimer = null; + // this.connectWebSocket(); + // }, this.reconnectPeriod * 1000) + // }; + // } + // } + } + catch (error) { + throw new Error(error.message); + } + this.websocket.onopen = async (event) => { + console.log.apply(null, log("peermanager:ws:onopen")); + this.sendHello2(); + }; + this.websocket.onmessage = this.onWebsocketMessage.bind(this); + } + connect() { + // setInterval(this.watchdog.bind(this), this.watchdogPeriodSeconds * 1000); + // Side effects :( + if (!this.watchdogInterval) { + this.watchdogInterval = setInterval(() => { + let numActive = 0; + for (let [id, peer] of this.peers) { + if ( /*id === this.bootstrapPeerID ||*/peer.rtcPeer?.connectionState === "new" || + peer.rtcPeer?.connectionState === "connecting") { + continue; + } + numActive++; + } + if (!this.isBootstrapPeer && numActive === 0) { + console.log.apply(null, log(`No peers connected, will attempt to reconnect in ${this.reconnectPeriod} seconds...`)); + // Websocket reconnect + if (this.websocket?.readyState === WebSocket.OPEN) { + this.sendHello2(); + } + if (this.websocket?.readyState === WebSocket.CLOSED) { + this.connectWebSocket(); + } + } + let output = `Current status:` + "\n" + `[${logID(this.peerID)}]${this.getPeername(this.peerID)}[local]` + "\n"; + for (let [peerID, peer] of this.peers) { + output += `[${logID(peerID)}]${peer.rtcPeer?.connectionState}:${this.getPeername(peerID)}${(peerID === this.bootstrapPeerID) ? "[Bootstrap]" : ""}` + "\n"; + } + output += `numActivePeers: ${numActive}` + "\n"; + console.log.apply(null, log(output)); + }, this.reconnectPeriod * 1000); + } + let connectPromise = this.connectPromise; + if (!connectPromise) { + connectPromise = new Promise((resolve, reject) => { + this.connectPromiseCallbacks = { resolve, reject }; + }); + this.connectPromise = connectPromise; + } + this.connectWebSocket(); + return connectPromise; + // this.signaler = new Signaler(userID, peerID, isBootstrapPeer, this.onConnected.bind(this)); + // Testing + // let dummyPeer = new PeerConnection(this, "dummy_peer", this.websocketSendPeerMessage.bind(this)); + // this.peers.set("dummy_peer", dummyPeer); + } + async connectToPeer(remotePeerID) { + // Connect to the peer that has the peer id remotePeerID. + // TODO how do multiple windows / tabs from the same peer and user work? + // Need to decide if they should all get a unique connection. A peer should only be requesting and writing + // Data once though, so it probably need to be solved on the client side as the data is shared obv + // Maybe use BroadcastChannel to proxy all calls to peermanager? That will probably really complicate things. + // What if we just user session+peerID for the connections? Then we might have two windows making requests + // For IDs etc, it would probably be best to proxy everything. + // Maybe once we put this logic in a web worker, we'll need an interface to it that works over postMessage + // anyway, and at that point, we could just use that same interface over a broadcastChannel + // let's keep it simple for now and ignore the problem :) + let peerConnection = new PeerConnection(this, remotePeerID, this.websocketSendPeerMessage.bind(this)); + this.peers.set(remotePeerID, peerConnection); + await peerConnection.connect(); + this.onPeerConnected(remotePeerID); + return peerConnection; + } + onPeerConnected(peerID) { + this.peerStateSuperlog && console.log.apply(null, log(`PeerManager: Successfully connected to peer ${peerID}`)); + this.dispatchEvent(PeerEventTypes.PEER_CONNECTED, { peerID: peerID }); + } + dispatchEvent(event, parameters) { + let listeners = this.eventListeners.get(event); + if (!listeners) { + return; + } + for (let listener of listeners) { + listener(parameters); + } + } + addEventListener(eventName, func) { + let listeners = this.eventListeners.get(eventName); + if (!listeners) { + this.eventListeners.set(eventName, [func]); + } + } + onPeerDisconnected(peerID) { + let deleted = this.peers.delete(peerID); + if (!deleted) { + throw new Error(`Can't find peer that disconnected ${peerID}`); + } + // TODO: What do we do if we lose connection to the bootstrap peer? + // If we have other connections, it probably doesn't matter. + // Eventually we want the bootstrap peer to be no different than any other peer anyway. + // We should disconnect from the websocket once we connect to our intial peers. + // If we have no peer connections, try to connect. If connection fails, start a timer to reconnect. + if (peerID === this.bootstrapPeerID) { + this.bootstrapPeerID = null; + this.bootstrapPeerConnection = null; + } + this.peerStateSuperlog && console.log.apply(null, log(`PeerManager: disconnected from peer ${peerID}`)); + this.dispatchEvent(PeerEventTypes.PEER_DISCONNECTED, { peerID: peerID }); + } + async disconnectFromPeer(remotePeerID) { + let peer = this.peers.get(remotePeerID); + if (!peer) { + console.log.apply(null, log(`PeerManager.disconnect: couldn't find peer ${remotePeerID}`)); + return; + } + console.log.apply(null, log(`PeerManager.disconnect: disconnecting peer ${remotePeerID}`)); + await peer.disconnect(); + this.onPeerDisconnected(remotePeerID); + } + async call(peerID, functionName, args) { + let peer = this.peers.get(peerID); + if (!peer) { + console.log.apply(null, log(`Can't find peer ${peerID}`)); + return; + } + let returnValues = await peer.call(functionName, args); + return returnValues; + } + async callFromRemote(functionName, args) { + let func = this.RPC_remote.get(functionName); + if (!func) { + throw new Error(`callFromRemote: got RPC we don't know about: ${functionName}, ${args}`); + } + let returnValues = await func.apply(null, args); + return returnValues; + } + registerRPC(functionName, func) { + this.rpc[functionName] = (peerID, ...args) => { + return this.call(peerID, functionName, args); + }; + this.RPC_remote.set(functionName, func); + } + registerSearchQuery(searchType, queryFunction) { + this.searchQueryFunctions.set(searchType, queryFunction); + } + async search(type, message) { + let promises = []; + for (let peer of this.peers.values()) { + promises.push(peer.call(type, message)); + } + return await Promise.allSettled(promises); + } + onMessage(remotePeerID, message) { + console.log.apply(null, log(remotePeerID, message)); + } +} +class PeerConnection { + async RPCHandler(message) { + } + constructor(peerManager, remotePeerID, sendPeerMessage) { + this.dataChannel = null; + this.messageHandlers = new Map(); + this.makingOffer = false; + this.ignoreOffer = false; + this.isSettingRemoteAnswerPending = false; + this.polite = true; + this.webRTCSuperlog = false; + this.dataChannelSuperlog = false; + this.chunkSize = (16 * 1024) - 100; + this.messageSuperlog = false; + this.rpcSuperlog = false; + this.pendingRPCs = new Map(); + this.connectionPromise = null; + // private makingOffer:boolean = false; + // private ignoreOffer:boolean = false; + this.rtcPeer = null; + // longMessageQueue: string[] = []; + this.longMessages = new Map(); + this.chunkSuperlog = false; + this.sendPeerMessage = sendPeerMessage; + this.peerManager = peerManager; + this.remotePeerID = remotePeerID; + // this.signaler = signaler; + // this.signaler.route(remotePeerID, this); + } + setPolite(polite) { + this.polite = polite; + } + setupDataChannel() { + if (!this.dataChannel) { + throw new Error(); + } + this.dataChannel.onopen = (e) => { + if (!this.dataChannel) { + throw new Error(); + } + this.dataChannelSuperlog && console.log.apply(null, log("data channel is open to: ", this.remotePeerID, " from: ", this.peerManager.peerID)); + this.send({ type: "hello datachannel", from: this.peerManager.peerID, to: this.remotePeerID }); + // this.dataChannel?.send(`{typeHello datachannel from: ${this.peerManager.peerID}`); + console.log.apply(null, log([...this.peerManager.peers.keys()])); + if (this.peerManager.isBootstrapPeer) { + this.send({ type: 'initial_peers', from: this.peerManager.peerID, peers: [...this.peerManager.peers.keys()].filter(entry => entry !== this.remotePeerID) }); + // this.dataChannel.send(JSON.stringify()); + } + this.connectionPromise?.resolve(this.remotePeerID); + //globalThis.setTimeout(()=>this.connectionPromise?.resolve(this.remotePeerID), 5000); + }; + this.dataChannel.onmessage = (e) => { + this.messageSuperlog && console.log.apply(null, log(`[${logID(this.remotePeerID)}]->datachannel[${logID(this.peerManager.peerID)}]: `, e.data)); + this.onMessage(e.data); + }; + this.dataChannel.onclose = (e) => { + this.dataChannelSuperlog && console.log.apply(null, log(`datachannel from peer ${this.remotePeerID} closed, disconnecting peer.`)); + this.peerManager.disconnectFromPeer(this.remotePeerID); + }; + this.dataChannel.onerror = (e) => { + this.dataChannelSuperlog && console.log.apply(null, log(`datachannel from peer ${this.remotePeerID} error:`, e.error)); + }; + } + async connect() { + let connectionPromise = new Promise((resolve, reject) => { this.connectionPromise = { resolve, reject }; }); + this.rtcPeer = new RTCPeerConnection(PeerConnection.config); + this.rtcPeer.onconnectionstatechange = async (e) => { + this.webRTCSuperlog && console.log.apply(null, log(`rtcPeer: onconnectionstatechange: ${this.rtcPeer?.connectionState}: ${this.remotePeerID}`)); + if (!this.rtcPeer) { + throw new Error("onconnectionstatechange"); + } + // When the connection is closed, tell the peer manager that this connection has gone away + if (this.rtcPeer.connectionState === "failed") { + this.peerManager.onPeerDisconnected(this.remotePeerID); + // globalThis.setTimeout(async () => { await this.peerManager.connectToPeer(this.remotePeerID) }, 10_000); + } + if (this.rtcPeer.connectionState === "connected") { + // Check the selected candidates + const stats = await this.rtcPeer.getStats(); + let localIP = ''; + let remoteIP = ''; + for (const report of stats.values()) { + if (report.type === 'transport') { + let candidatePair = stats.get(report.selectedCandidatePairId); + let localCandidate = stats.get(candidatePair.localCandidateId); + let remoteCandidate = stats.get(candidatePair.remoteCandidateId); + this.webRTCSuperlog && console.log.apply(null, log("Connected candidates\n", localCandidate, remoteCandidate)); + } + } + } + }; + this.rtcPeer.ondatachannel = (e) => { + let dataChannel = e.channel; + this.dataChannel = dataChannel; + this.setupDataChannel(); + }; + if (this.polite) { + this.dataChannel = this.rtcPeer.createDataChannel("ddln_main"); + this.setupDataChannel(); + } + if (this.rtcPeer === null) { + return; + } + // this.rtcPeer.onicecandidate = ({ candidate }) => this.signaler.send(JSON.stringify({ candidate })); + // this.rtcPeer.onicecandidate = ({ candidate }) => console.log.apply(null, log(candidate); + this.rtcPeer.onicecandidate = ({ candidate }) => { + this.webRTCSuperlog && console.log.apply(null, log(candidate)); + this.sendPeerMessage(this.remotePeerID, { type: "rtc_candidate", candidate: candidate }); + }; + this.rtcPeer.onnegotiationneeded = async (event) => { + this.webRTCSuperlog && console.log.apply(null, log("on negotiation needed fired")); + if (!this.rtcPeer) { + throw new Error(); + } + try { + this.makingOffer = true; + await this.rtcPeer.setLocalDescription(); + if (!this.rtcPeer.localDescription) { + return; + } + this.sendPeerMessage(this.remotePeerID, { type: "rtc_description", description: this.rtcPeer.localDescription }); + } + catch (err) { + console.error(err); + } + finally { + this.makingOffer = false; + } + }; + return connectionPromise; + } + async onWebsocketMessage(message) { + if (message.type == "rtc_connect") { + this.rtcPeer?.setRemoteDescription(message.description); + } + // /* + // let ignoreOffer = false; + // let isSettingRemoteAnswerPending = false; + // signaler.onmessage = async ({ data: { description, candidate } }) => { + if (!this.rtcPeer) { + throw new Error(); + } + let description = null; + if (message.type == "rtc_description") { + description = message.description; + } + let candidate = null; + if (message.type == "rtc_candidate") { + candidate = message.candidate; + } + try { + if (description) { + const readyForOffer = !this.makingOffer && + (this.rtcPeer.signalingState === "stable" || this.isSettingRemoteAnswerPending); + const offerCollision = description.type === "offer" && !readyForOffer; + this.ignoreOffer = !this.polite && offerCollision; + if (this.ignoreOffer) { + console.warn(">>>>>>>>>>>>>>>>>IGNORING OFFER"); + return; + } + this.isSettingRemoteAnswerPending = description.type == "answer"; + await this.rtcPeer.setRemoteDescription(description); + this.isSettingRemoteAnswerPending = false; + if (description.type === "offer") { + await this.rtcPeer.setLocalDescription(); + this.sendPeerMessage(this.remotePeerID, { type: "rtc_description", description: this.rtcPeer.localDescription }); + } + } + else if (candidate) { + try { + await this.rtcPeer.addIceCandidate(candidate); + } + catch (err) { + if (!this.ignoreOffer) { + throw err; + } + } + } + } + catch (err) { + console.error(err); + } + // }; + // */ + } + disconnect() { + this.rtcPeer?.close(); + this.rtcPeer = null; + } + async send(message) { + if (!this.dataChannel) { + throw new Error("Send called but datachannel is null"); + } + while (this.dataChannel.bufferedAmount >= 8 * 1024 * 1024) { + await new Promise((resolve, reject) => { + setTimeout(() => resolve(), 1000); + }); + } + let messageJSON = JSON.stringify(message); + this.messageSuperlog && console.log.apply(null, log(`[${logID(this.remotePeerID)}]<-datachannel[${logID(this.peerManager.peerID)}]:`, message.type, message, `message size:${messageJSON.length}`)); + if (messageJSON.length > this.chunkSize) { + this.messageSuperlog && console.log.apply(null, log(`[datachannel] sending long message: `, messageJSON.length)); + this.sendLongMessage(messageJSON); + return; + } + try { + this.dataChannel?.send(messageJSON); + } + catch (e) { + console.log.apply(null, log(e)); + } + // this.onMessage(messageJSON); + } + // Get a polyfill for browsers that don't have this API + async hashMessage(message) { + let msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(''); + return hashHex; + } + async sendLongMessage(message) { + // message = JSON.parse(message); + let chunkSize = this.chunkSize / 2; + // let chunkSize = 1024; + let chunks = Math.ceil(message.length / chunkSize); + let messageID = generateID(); + let hash = await this.hashMessage(message); + for (let i = 0; i < chunks; i++) { + let offset = i * chunkSize; + let chunk = message?.substring(offset, offset + chunkSize); + // this.send(message?.substring(offset, offset + chunkSize-1)); + // console.log("[chunk]", chunk); + let chunkHash = await this.hashMessage(chunk); + this.chunkSuperlog && console.log.apply(null, log(`[chunk] chunkHash:${logID(chunkHash)} from:${logID(this.peerManager.peerID)} to:${logID(this.remotePeerID)} messageID:${logID(messageID)} hash:${logID(hash)} ${i + 1}/${chunks}`)); + let netMessage = { type: 'chunk', message_id: messageID, hash: hash, chunk_index: i, total_chunks: chunks, chunk: chunk, chunk_hash: chunkHash }; + await this.send(netMessage); + } + } + call(functionName, args) { + let transactionID = generateID(); // make this faster as we will only ever have a small number of in-flight queries on a peer + // Think about a timeout here to auto reject it after a while. + let promise = new Promise((resolve, reject) => { + this.pendingRPCs.set(transactionID, { resolve, reject, functionName }); + // setTimeout(() => reject("bad"), 1000); + }); + let message = { + type: "rpc_call", + transaction_id: transactionID, + function_name: functionName, + args: args, + }; + this.rpcSuperlog && console.log.apply(null, log(`[${logID(this.remotePeerID)}]<-[rpc][${logID(this.peerManager.peerID)}]`, message.function_name, message.transaction_id, JSON.stringify(message.args, null, 2))); + this.send(message); + return promise; + } + async onMessage(messageJSON) { + let message = {}; + try { + message = JSON.parse(messageJSON); + } + catch (e) { + console.log.apply(null, log("PeerConnection.onMessage:", e)); + } + this.messageSuperlog && console.log.apply(null, log(`[${logID(this.remotePeerID)}]->datachannel[${logID(this.peerManager.peerID)}]`, message.type, message)); + let type = message.type; + if (type === "rpc_response") { + this.rpcSuperlog && console.log.apply(null, log(`[${logID(this.remotePeerID)}]<-[rpc][${logID(this.peerManager.peerID)}] response: `, message.function_name, message.transaction_id, JSON.stringify(message.args, null, 2))); + let pendingRPC = this.pendingRPCs.get(message.transaction_id); + if (!pendingRPC) { + throw new Error(); + } + pendingRPC.resolve(message.response); + } + if (type === "rpc_call") { + this.rpcSuperlog && console.log.apply(null, log(`[${logID(this.remotePeerID)}]->[rpc][${logID(this.peerManager.peerID)}] call: `, message.function_name, message.transaction_id, JSON.stringify(message.args, null, 2))); + let response = await this.peerManager.callFromRemote(message.function_name, message.args); + this.rpcSuperlog && console.log.apply(null, log(`[rpc] call: response:`, response)); + if (response === undefined) { + return; + } + let responseMessage = { type: 'rpc_response', function_name: message.function_name, transaction_id: message.transaction_id, response: response }; + this.send(responseMessage); + } + if (type === "initial_peers") { + for (let peerID of message.peers) { + console.log.apply(null, log("Connecting to initial peer ", peerID)); + this.peerManager.connectToPeer(peerID); + } + } + if (type === "chunk") { + let messageID = message.message_id; + if (!this.longMessages.has(messageID)) { + this.longMessages.set(messageID, { messageChunks: [], totalChunks: message.total_chunks, hash: message.hash }); + } + let longMessage = this.longMessages.get(messageID); + if (!longMessage) { + return; + } + let chunkHash = await this.hashMessage(message.chunk_hash); + longMessage.messageChunks.push(message.chunk); + this.chunkSuperlog && console.log.apply(null, log(`[chunk] chunked message sent chunkHash:${logID(message.chunk_hash)} computed hash: ${logID(chunkHash)} messageId:${logID(messageID)} chunk ${message.chunk_index + 1}/${longMessage.totalChunks}`)); + if (message.chunk_index === longMessage.totalChunks - 1) { + let completeMessage = longMessage.messageChunks.join(''); + let hash = await this.hashMessage(completeMessage); + this.chunkSuperlog && console.log.apply(null, log(`[chunk] hashes match: ${hash === longMessage.hash} sent hash: ${logID(longMessage.hash)} computed hash: ${logID(hash)}`)); + if (hash !== longMessage.hash) { + throw new Error("[chunk] long message hashes don't match."); + } + this.onMessage(completeMessage); + this.longMessages.delete(messageID); + } + } + // this.peerManger.onMessage(this.remotePeerID, message); + } +} +PeerConnection.config = { + iceServers: [ + { urls: "stun:ddln.app" }, + // { urls: "turn:ddln.app", username: "a", credential: "b" }, + // { urls: "stun:stun.l.google.com" }, // keeping this for now as my STUN server is not returning ipv6 + // { urls: "stun:stun1.l.google.com" }, + // { urls: "stun:stun2.l.google.com" }, + // { urls: "stun:stun3.l.google.com" }, + // { urls: "stun:stun4.l.google.com" }, + ], +}; diff --git a/static/Sync.js b/static/Sync.js new file mode 100644 index 0000000..b0f1bb9 --- /dev/null +++ b/static/Sync.js @@ -0,0 +1,226 @@ +import { mergeDataArray, checkPostIds, getAllIds, getPostsByIds } from "db"; +import { log, logID } from "log"; +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) { + let response; + try { + response = await fetch("data:application/octet-stream;base64," + base64String); + } + catch (e) { + console.log("error", e, base64String); + return null; + } + let arrayBuffer = await response.arrayBuffer(); + return arrayBuffer; +} +export class Sync { + constructor() { + this.isArchivePeer = false; + this.userID = ""; + this.userPeers = new Map(); + this.userIDsToSync = new Set(); + this.syncSuperlog = false; + this.userBlockList = new Set([ + '5d63f0b2-a842-41bf-bf06-e0e4f6369271', + '5f1b85c4-b14c-454c-8df1-2cacc93f8a77', + // 'bba3ad24-9181-4e22-90c8-c265c80873ea' + ]); + this.postBlockList = new Set([ + '1c71f53c-c467-48e4-bc8c-39005b37c0d5', + '64203497-f77b-40d6-9e76-34d17372e72a', + '243130d8-4a41-471e-8898-5075f1bd7aec', + 'e01eff89-5100-4b35-af4c-1c1bcb007dd0', + '194696a2-d850-4bb0-98f7-47416b3d1662', + 'f6b21eb1-a0ff-435b-8efc-6a3dd70c0dca', + 'dd1d92aa-aa24-4166-a925-94ba072a9048' + ]); + // async getPostIdsForUserHandler(data: any) { + // let message = data.message; + // let postIds = await getAllIds(message.user_id) ?? []; + // postIds = postIds.filter((postID: string) => !this.postBlockList.has(postID)); + // if (postIds.length === 0) { + // console.log.apply(null, log(`Net: I know about user ${logID(message.user_id)} but I have 0 posts, so I'm not sending any to to peer ${logID(data.from)}`));; + // return; + // } + // console.log.apply(null, log(`Net: Sending ${postIds.length} post Ids for user ${logID(message.user_id)} to peer ${logID(data.from)}`)); + // let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, from_username: app.username, from_peername: app.peername, message: { type: "get_post_ids_for_user_response", post_ids: postIds, user_id: message.user_id } } + // this.send(responseMessage); + // } + } + setArchive(isArchive) { + this.isArchivePeer = isArchive; + } + setUserID(userID) { + this.userID = userID; + this.userIDsToSync = new Set(this.getFollowing(userID)); + } + shouldSyncUserID(userID) { + let shouldSyncAllUsers = this.isArchivePeer; + if (shouldSyncAllUsers) { + return true; + } + return this.userIDsToSync.has(userID); + } + getPeersForUser(userID) { + let peers = this.userPeers.get(userID); + if (!peers) { + return []; + } + return [...peers.keys()]; + } + addUserPeer(userID, peerID) { + this.syncSuperlog && console.log.apply(null, log(`[sync] addUserPeer user:${logID(userID)} peer:${logID(peerID)}`)); + ; + if (!this.userPeers.has(userID)) { + this.userPeers.set(userID, new Set()); + } + let peers = this.userPeers.get(userID); + peers.add(peerID); + // this.syncSuperlog && console.log.apply(null, log(this.userPeers)); + } + deleteUserPeer(peerIDToDelete) { + for (const peers of this.userPeers.values()) { + for (const peerID of peers) { + if (peerID === peerIDToDelete) { + peers.delete(peerIDToDelete); + } + } + } + } + // shouldSyncUserID(userID: string) { + // if (app.isHeadless) { + // return true; + // } + // return this.UserIDsTothis.has(userID); + // } + async getKnownUsers() { + let knownUsers = [...(await indexedDB.databases())].map(db => db.name?.replace('user_', '')).filter(userID => userID !== undefined); + knownUsers = knownUsers + .filter(userID => this.shouldSyncUserID(userID)) + .filter(userID => !this.userBlockList.has(userID)) + .filter(async (userID) => (await getAllIds(userID)).length > 0); // TODO:EASYOPT getting all the IDs is unecessary, replace it with a test to get a single ID. + return knownUsers; + } + getFollowing(userID) { + let following = ['a0e42390-08b5-4b07-bc2b-787f8e5f1297']; // Follow BMO by default :) + following.push(this.userID); + // Hazel + if (userID == '622ecc28-2eff-44b9-b89d-fdea7c8dd2d5') { + following.push(...[ + '8f6802be-c3b6-46c1-969c-5f90cbe01479', // Fiona + '622ecc28-2eff-44b9-b89d-fdea7c8dd2d5', // Hazel + ]); + } + // Rob + if (userID === 'b38b623c-c3fa-4351-9cab-50233c99fa4e') { + following.push(...[ + '6d774268-16cd-4e86-8bbe-847a0328893d', // Sean + '05a495a0-0dd8-4186-94c3-b8309ba6fc4c', // Martin + 'bba3ad24-9181-4e22-90c8-c265c80873ea', // Harry + '8f6802be-c3b6-46c1-969c-5f90cbe01479', // Fiona + '622ecc28-2eff-44b9-b89d-fdea7c8dd2d5', // Hazel + ]); + } + // Martin + if (userID === '05a495a0-0dd8-4186-94c3-b8309ba6fc4c') { + following.push(...[ + 'b38b623c-c3fa-4351-9cab-50233c99fa4e', // Rob + ]); + } + // Fiona + if (userID === '8f6802be-c3b6-46c1-969c-5f90cbe01479') { + following.push(...[ + 'b38b623c-c3fa-4351-9cab-50233c99fa4e', // Rob + '05a495a0-0dd8-4186-94c3-b8309ba6fc4c', // Martin + ]); + } + return following; + } + async getPostIdsForUser(userID) { + let postIds = await getAllIds(userID) ?? []; + postIds = postIds.filter((postID) => !this.postBlockList.has(postID)); + if (postIds.length === 0) { + console.log.apply(null, log(`Net: I know about user ${logID(userID)} but I have 0 posts`)); + return null; + } + return postIds; + } + async checkPostIds(userID, peerID, postIDs) { + let startTime = performance.now(); + let neededPostIds = await checkPostIds(userID, postIDs); + this.syncSuperlog && console.log.apply(null, log(`[sync] ID Check for user ${logID(userID)} with IDs from peer[${logID(peerID)}] took ${(performance.now() - startTime).toFixed(2)}ms`)); + if (neededPostIds.length > 0) { + this.syncSuperlog && console.log.apply(null, log(`[sync] Need posts (${neededPostIds.length}) for user[${logID(userID)}] from peer[${logID(peerID)}]`)); + ; + } + else { + this.syncSuperlog && console.log.apply(null, log(`[sync] Don't need any posts for user[${logID(userID)}] from peer[${logID(peerID)}]`)); + ; + } + // if (postIds.length === 0) { + // return []; + // } + return neededPostIds; + } + async getPostsForUser(userID, postIDs) { + let posts = await getPostsByIds(userID, postIDs) ?? []; + console.log.apply(null, log(`[sync] got ${posts.length} posts for user ${logID(userID)}`)); + ; + // app.timerStart(); + let output = []; + console.log.apply(null, log("Serializing images")); + for (let post of posts) { + let newPost = post.data; + if (newPost.image_data) { + // let compressedData = await wsConnection.compressArrayBuffer(newPost.image_data); + // console.log.apply(null, log((newPost.image_data.byteLength - compressedData.byteLength) / 1024 / 1024); + // TODO don't do this, use Blobs direclty! + // https://developer.chrome.com/blog/blob-support-for-Indexeddb-landed-on-chrome-dev + newPost.image_data = await arrayBufferToBase64(newPost.image_data); + } + // let megs = JSON.stringify(newPost).length/1024/1024; + // console.log.apply(null, log(`getPostsForUserHandler id:${newPost.post_id} post length:${megs}`); + output.push(newPost); + } + return output; + // console.log.apply(null, log(`getPostsForUser`,output)); + } + async writePostForUser(userID, post) { + // HACK: Some posts have insanely large images, so I'm gonna skip them. + // Once we support delete then we we could delete these posts in a sensible way. + if (this.postBlockList.has(post.post_id)) { + console.log.apply(null, log(`Skipping blocked post: ${post.post_id}`)); + ; + return; + } + // HACK - some posts had the wrong author ID + if (userID === this.userID) { + post.author_id = this.userID; + } + post.post_timestamp = new Date(post.post_timestamp); + if (post.image_data) { + let imageDataArrayBuffer = await base64ToArrayBuffer(post.image_data); + if (imageDataArrayBuffer === null) { + this.syncSuperlog && console.log(`[sync] Failed to create arraybuffer for image for post userID:${userID} postID:${post.post_id} `); + return; + } + post.image_data = imageDataArrayBuffer; + // skip posts with images for now. + // return; + } + console.log.apply(null, log(`Merging same user peer posts...`)); + await mergeDataArray(userID, [post]); + } +} diff --git a/static/bootstrap_main.js b/static/bootstrap_main.js new file mode 100644 index 0000000..1b94270 --- /dev/null +++ b/static/bootstrap_main.js @@ -0,0 +1,1643 @@ +// Attempt to get bootstrap peer to run under Deno +// Check if RTCPeerConnection is supported. +// 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, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds } from "db"; +import { generateID } from "./IDUtils.js"; +import { PeerManager } from "./PeerManager.js"; +import { log } from "./log.js"; +// import {PeerConnection} from "webRTC"; +// declare let WebTorrent: any; +// declare let ForceGraph3D: any; +// declare let marked: any; +// declare let QRCode: any; +// let posts:any; +// let keyBase = "dandelion_posts_v1_" +// let key:string = ""; +// interface PostTimestamp { +// year: number, +// month: number, +// day: number, +// hour: number, +// minute: number, +// second: number, +// } +// function waitMs(durationMs: number) { +// return new Promise(resolve => setTimeout(resolve, durationMs)); +// } +// function uuidToBytes(uuid: string): Uint8Array { +// return new Uint8Array(uuid.match(/[a-fA-F0-9]{2}/g)!.map((hex) => parseInt(hex, 16))); +// } +// // Base58 character set +// const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +// // Base58 encoding +// // Base58 encoding +// function encodeBase58(buffer: Uint8Array): string { +// let carry; +// const digits = [0]; +// for (const byte of buffer) { +// carry = byte; +// for (let i = 0; i < digits.length; i++) { +// carry += digits[i] << 8; +// digits[i] = carry % 58; +// carry = Math.floor(carry / 58); +// } +// while (carry > 0) { +// digits.push(carry % 58); +// carry = Math.floor(carry / 58); +// } +// } +// let result = ''; +// for (const digit of digits.reverse()) { +// result += BASE58_ALPHABET[digit]; +// } +// // Handle leading zero bytes +// for (const byte of buffer) { +// if (byte === 0x00) { +// result = BASE58_ALPHABET[0] + result; +// } else { +// break; +// } +// } +// return result; +// } +// // Convert UUID v4 to Base58 +// function uuidToBase58(uuid: string): string { +// const bytes = uuidToBytes(uuid); +// return encodeBase58(bytes); +// } +// // function log(message:string) { +// // console.log.apply(null, log(message); +// // let log = document.getElementById("log"); +// // let newlog = document.createElement('span'); +// // newlog.innerHTML = `
${message}
`; +// // log?.appendChild(newlog); +// // } +// interface StoragePost { +// data: Post; +// } +// class Post { +// post_timestamp: Date; +// post_id: string; +// author: string; +// author_id: string; +// text: string; +// image_data: ArrayBuffer | null; +// importedFrom: "twitter" | null; +// importSource: any; +// constructor( +// author: string, +// author_id: string, +// text: string, +// post_timestamp: Date, +// imageData: ArrayBuffer | null = null, +// importedFrom: "twitter" | null = null, +// importSource: any = 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; +// } +// } +// globalThis.addEventListener('scroll', () => { +// // Total height of the document +// const totalPageHeight = document.body.scrollHeight; +// // Current scroll position +// const scrollPoint = globalThis.scrollY + globalThis.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: Uint8Array, 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: ArrayBuffer) { +// var bytes = new Uint8Array(buffer); +// return (await bytesToBase64DataUrl(bytes) as string).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: string) { +// // 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); +// } +// interface PeerMessage { +// type: string; +// from: string; +// to: string; +// from_peername: string; +// from_username: string; +// message: any; +// } +// // class Signaler { +// // websocket: WebSocket | null = null; +// // websocketPingInterval: number = 0; +// // connect() { +// // if (this.websocket?.readyState === WebSocket.OPEN) { +// // return; +// // } +// // globalThis.clearInterval(this.websocketPingInterval); +// // if (this.websocket) { this.websocket.close() }; +// // try { +// // this.websocket = new WebSocket(`wss://${globalThis.location.hostname}:${globalThis.location.port}/ws`); +// // } catch (error: any) { +// // console.log.apply(null, log(error.message); +// // return; +// // } +// // this.websocket.onopen = async (event) => { +// // console.log.apply(null, log("ws:connected"));; +// // await this.sendHello(); +// // // If we're running as a headless peer, send a hello message every N seconds to refresh the posts we have. +// // let helloRefreshIntervalPeriod = 120; +// // if (app.isHeadless) { +// // console.log.apply(null, log("wsConnection: Setting hello refresh interval to ", helloRefreshIntervalPeriod) +// // this.helloRefreshInterval = globalThis.setInterval(() => { +// // console.log.apply(null, log("wsConnection: Hello refresh.") +// // if (!navigator.onLine) { +// // return; +// // } +// // this.sendHello(); +// // }, helloRefreshIntervalPeriod * 1000); +// // } +// // this.websocketPingInterval = globalThis.setInterval(() => { +// // if (!navigator.onLine) { +// // return; +// // } +// // this.send({ type: "ping", peer_id: this.peerID, peer_name: app.peername, user_id: app.userID, user_name: app.username }); +// // }, 10_000) +// // }; +// // this.websocket.onclose = (event) => { +// // console.log.apply(null, log("ws:disconnected"));; +// // // this.retry *= 2; +// // console.log.apply(null, log(`Retrying in ${this.retry} seconds`));; +// // globalThis.setTimeout(() => { this.connect(); }, this.retry * 1000); +// // }; +// // this.websocket.onmessage = (event) => { +// // // log('ws:<-' + event.data.slice(0, 240)); +// // let data = JSON.parse(event.data); +// // let { type } = data; +// // let handler = this.messageHandlers.get(type); +// // if (!handler) { +// // console.warn(`Got a message we can't handle:`, type); +// // return; +// // } +// // handler(data); +// // }; +// // this.websocket.onerror = (event) => { +// // console.log.apply(null, log('ws:error: ' + event));; +// // }; +// // } +// // } +// // disconnect() { +// // this.websocket?.close(); +// // } +// // } +// // Connect websocket +// // send hello +// // get bootstrap peer ID +// // WebRTC connect to bootstrap peer +// // ask Bootstrap peer for peers that have users we care about. +// // for now, bootstrap peer will connect to all peers and will tell us about them, moving all logic from the server to the BSP +// // WebRTC Connect to peers that might have posts we need +// // query those peers and do existing logic. +// class wsConnection { +// websocket: WebSocket | null = null; +// sessionID = ""; +// userID = ""; +// peerID = ""; +// rtcPeerDescription: RTCSessionDescription | null = null; +// UserIDsToSync: Set; +// websocketPingInterval: number = 0; +// helloRefreshInterval: number = 0; +// retry = 10; +// state = 'disconnected'; +// // peers: Map = new Map(); +// messageHandlers: Map void> = new Map(); +// peerMessageHandlers: Map void> = new Map(); +// seenPeers: Map = new Map(); +// constructor(userID: string, peerID: string, IDsToSync: Set, rtcPeerDescription: RTCSessionDescription) { +// this.rtcPeerDescription = rtcPeerDescription; +// this.sessionID = generateID(); +// this.userID = userID; +// this.peerID = peerID; +// this.UserIDsToSync = new Set(IDsToSync); +// this.messageHandlers.set('hello', this.helloResponseHandler.bind(this)); +// this.messageHandlers.set('hello2', this.hello2ResponseHandler.bind(this)); +// this.messageHandlers.set('pong', this.pongHandler); +// this.messageHandlers.set('peer_message', this.peerMessageHandler.bind(this)); +// // +// this.peerMessageHandlers.set('get_post_ids_for_user', this.getPostIdsForUserHandler.bind(this)); +// this.peerMessageHandlers.set('get_post_ids_for_user_response', this.getPostIdsForUserResponseHandler.bind(this)); +// this.peerMessageHandlers.set('get_posts_for_user', this.getPostsForUserHandler.bind(this)); +// this.peerMessageHandlers.set('get_posts_for_user_response', this.getPostsForUserReponseHandler.bind(this)); +// // this.peerMessageHandlers.set('send_webrtc_offer', this.sendWebRTCOfferHandler.bind(this)); +// // this.peerMessageHandlers.set('send_webrtc_offer_response', this.getPostIdsForUserResponseHandler.bind(this)); +// globalThis.addEventListener('beforeunload', () => this.disconnect()); +// this.connect(); +// } +// // So we don't need custom logic everywhere we use this, I just wrapped it. +// shouldSyncUserID(userID: string) { +// if (app.isHeadless) { +// return true; +// } +// return this.UserIDsToSync.has(userID); +// } +// async send(message: any) { +// let json = "" +// try { +// json = JSON.stringify(message); +// // console.log.apply(null, log("*******", (await compressString(json)).byteLength, json.length); +// } catch (e) { +// console.log.apply(null, log(e, "wsConnection send: Couldn't serialize message", message)); +// } +// // log(`ws->${json.slice(0, 240)}`) +// this.websocket!.send(json); +// } +// pongHandler(data: any) { +// } +// async sendWebRTCDescription(description: RTCSessionDescription | null) { +// console.log.apply(null, log("description:", description)); +// this.send({ type: "rtc_session_description", description: description }); +// } +// async getPostIdsForUserResponseHandler(data: any) { +// // log(`getPostsForUserResponse: ${data}`) +// let message = data.message; +// console.log.apply(null, log(`Net: got ${message.post_ids.length} post IDs for user ${logID(message.user_id)} from peer ${logID(data.from)}`));; +// let startTime = app.timerStart(); +// let postIds = await checkPostIds(message.user_id, message.post_ids); +// console.log.apply(null, log(`ID Check for user ${logID(message.user_id)} took ${app.timerDelta().toFixed(2)}ms`));; +// console.log.apply(null, log(`Need ${postIds.length} posts for user ${logID(message.user_id)} from peer ${logID(data.from)}`));; +// if (postIds.length === 0) { +// return; +// } +// console.log.apply(null, log(`Net: Req ${postIds.length} posts for user ${logID(message.user_id)} from peer ${logID(data.from)}`)); +// let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, from_username: app.username, from_peername: app.peername, message: { type: "get_posts_for_user", post_ids: postIds, user_id: message.user_id } } +// this.send(responseMessage); +// } +// // static async compressArrayBuffer(data: ArrayBuffer): Promise { +// // const compressionStream = new CompressionStream('gzip'); // You can also use 'deflate', 'deflate-raw', etc. +// // const compressedStream = new Response( +// // new Blob([data]).stream().pipeThrough(compressionStream) +// // ); +// // const compressedArrayBuffer = await compressedStream.arrayBuffer(); +// // return compressedArrayBuffer; +// // } +// postBlockList = new Set([ +// '1c71f53c-c467-48e4-bc8c-39005b37c0d5', +// '64203497-f77b-40d6-9e76-34d17372e72a', +// '243130d8-4a41-471e-8898-5075f1bd7aec', +// 'e01eff89-5100-4b35-af4c-1c1bcb007dd0', +// '194696a2-d850-4bb0-98f7-47416b3d1662', +// 'f6b21eb1-a0ff-435b-8efc-6a3dd70c0dca', +// 'dd1d92aa-aa24-4166-a925-94ba072a9048' +// ]); +// async getPostIdsForUserHandler(data: any) { +// let message = data.message; +// let postIds = await getAllIds(message.user_id) ?? []; +// postIds = postIds.filter((postID: string) => !this.postBlockList.has(postID)); +// if (postIds.length === 0) { +// console.log.apply(null, log(`Net: I know about user ${logID(message.user_id)} but I have 0 posts, so I'm not sending any to to peer ${logID(data.from)}`));; +// return; +// } +// console.log.apply(null, log(`Net: Sending ${postIds.length} post Ids for user ${logID(message.user_id)} to peer ${logID(data.from)}`)); +// let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, from_username: app.username, from_peername: app.peername, message: { type: "get_post_ids_for_user_response", post_ids: postIds, user_id: message.user_id } } +// this.send(responseMessage); +// } +// async broadcastNewPost(userID: string, post: any) { +// let newPost = { ...post } +// if (post.image_data) { +// newPost.image_data = await arrayBufferToBase64(post.image_data); +// } +// for (let [peerID, peerInfo] of this.seenPeers.entries()) { +// console.log.apply(null, log(`broadcastNewPost: sending new post to ${logID(peerID)}:${peerInfo.peerName}:${peerInfo.userName}`));; +// this.sendPostsForUser(peerID, app.userID, [newPost]) +// } +// } +// async sendPostsForUser(toPeerID: string, userID: string, posts: any) { +// let responseMessage = { +// type: "peer_message", +// from: app.peerID, +// to: toPeerID, +// from_username: app.username, +// from_peername: app.peername, +// message: { +// type: "get_posts_for_user_response", +// posts: posts, +// user_id: userID +// } +// } +// return this.send(responseMessage); +// } +// // Send posts to peer +// async getPostsForUserHandler(data: any) { +// let message = data.message; +// let posts = await getPostsByIds(message.user_id, message.post_ids) ?? []; +// console.log.apply(null, log(`Net: Sending ${posts.length} posts for user ${logID(message.user_id)} to peer ${logID(data.from)}`));; +// app.timerStart(); +// let output = []; +// console.log.apply(null, log("Serializing images")); +// for (let post of posts) { +// let newPost = (post as any).data; +// if (newPost.image_data) { +// // let compressedData = await wsConnection.compressArrayBuffer(newPost.image_data); +// // console.log.apply(null, log((newPost.image_data.byteLength - compressedData.byteLength) / 1024 / 1024); +// // TODO don't do this, use Blobs direclty! +// // https://developer.chrome.com/blog/blob-support-for-Indexeddb-landed-on-chrome-dev +// newPost.image_data = await arrayBufferToBase64(newPost.image_data); +// } +// // let megs = JSON.stringify(newPost).length/1024/1024; +// // console.log.apply(null, log(`getPostsForUserHandler id:${newPost.post_id} post length:${megs}`); +// output.push(newPost); +// } +// let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, from_username: app.username, from_peername: app.peername, message: { type: "get_posts_for_user_response", posts: output, user_id: message.user_id } } +// console.log.apply(null, log("Sending posts")); +// await this.sendPostsForUser(data.from, message.user_id, output); +// let sendTime = app.timerDelta(); +// console.log.apply(null, log(`getPostsForUserHandler send took: ${sendTime.toFixed(2)}ms`));; +// } +// // Got posts from peer +// async getPostsForUserReponseHandler(data: any) { +// app.timerStart(); +// let message = data.message; +// console.log.apply(null, log(`Net: got ${message.posts.length} posts for user ${logID(message.user_id)} from peer ${logID(data.from)}`)); +// for (let post of message.posts) { +// // HACK: Some posts have insanely large images, so I'm gonna skip them. +// // Once we support delete then we we could delete these posts in a sensible way. +// if (this.postBlockList.has(post.post_id)) { +// console.log.apply(null, log(`Skipping blocked post: ${post.post_id}`));; +// continue; +// } +// // HACK - some posts had the wrong author ID +// if (message.user_id === app.userID) { +// post.author_id = app.userID; +// } +// post.post_timestamp = new Date(post.post_timestamp); +// if (post.image_data) { +// post.image_data = await base64ToArrayBuffer(post.image_data); +// } +// } +// console.log.apply(null, log(`Merging same user peer posts...`)); +// await mergeDataArray(message.user_id, data.message.posts); +// let receiveTime = app.timerDelta(); +// console.log.apply(null, log(`getPostsForUserReponseHandler receive took: ${receiveTime.toFixed(2)}ms`));; +// if (message.user_id === app.getPreferentialUserID() || app.following.has(message.user_id)) { +// app.render(); +// } +// } +// async peerMessageHandler(data: PeerMessage) { +// // log(`peerMessageHandler ${JSON.stringify(data)}`) +// this.seenPeers.set(data.from, { peerName: data.from_peername, userName: data.from_username }); +// let peerMessageType = data.message.type; +// let handler = this.peerMessageHandlers.get(peerMessageType); +// if (!handler) { +// console.error(`got peer message type we don't have a handler for: ${peerMessageType}`); +// return; +// } +// handler(data); +// } +// userBlockList = new Set([ +// '5d63f0b2-a842-41bf-bf06-e0e4f6369271', +// '5f1b85c4-b14c-454c-8df1-2cacc93f8a77', +// // 'bba3ad24-9181-4e22-90c8-c265c80873ea' +// ]) +// // Hello2 +// // Goal, connect to bootstrap peer, ask bootstrap peer for peers that have posts from users that we care about. get peers, connect to those peers, sync. +// // how? do "perfect negotiation" with bootstrap peer. All logic here moves to BP. +// async sendHello2() { +// this.send({ +// type: "hello2", +// user_id: this.userID, +// user_name: app.username, +// peer_id: this.peerID, +// session_id: this.sessionID, +// peer_name: app.peername, +// is_bootstrap_peer: app.isBootstrapPeer, +// peer_description: this.rtcPeerDescription +// }); +// } +// async sendHello() { +// // TODO only get users you're following here. ✅ +// let knownUsers = [...(await indexedDB.databases())].map(db => db.name?.replace('user_', '')).filter(userID => userID !== undefined); +// knownUsers = knownUsers +// .filter(userID => this.shouldSyncUserID(userID)) +// .filter(userID => !this.userBlockList.has(userID)) +// .filter(async userID => (await getAllIds(userID)).length > 0); // TODO:EASYOPT getting all the IDs is unecessary, replace it with a test to get a single ID. +// console.log.apply(null, log('Net: Sending known users', knownUsers.map(userID => logID(userID ?? "")))); +// return await this.send({ type: "hello", user_id: this.userID, user_name: app.username, peer_id: this.peerID, peer_name: app.peername, known_users: knownUsers }); +// } +// hello2ResponseHandler(data: any) { +// } +// helloResponseHandler(data: any) { +// let users = []; +// let receivedUsers = Object.entries(data.userPeers); +// console.log.apply(null, log(`Net: got ${receivedUsers.length} users from bootstrap peer.`)); +// try { +// let preferentialUserID = app.getPreferentialUserID(); +// let currentUserPeers = data.userPeers[preferentialUserID]; +// users.push([preferentialUserID, currentUserPeers]); +// delete data.userPeers[preferentialUserID]; +// } catch (e) { +// console.log.apply(null, log('helloResponseHandler', e)); +// } +// let getAllUsers = app.router.route !== App.Route.USER +// if (getAllUsers) { +// users = [...users, ...Object.entries(data.userPeers).filter(userID => this.shouldSyncUserID(userID[0]))]; +// } +// // log(`Net: got ${users.length} users from bootstrap peer. \n${users.map((user)=>user[0]).join('\n')}`) +// for (let [userID, peerIDs] of users) { +// if (this.userBlockList.has(userID)) { +// console.log.apply(null, log("Skipping user on blocklist:", userID)); +// continue; +// } +// // this.peers.set(userID, [...peerIDs]); +// for (let peerID of [...peerIDs]) { +// if (peerID === this.peerID) { +// continue; +// } +// console.log.apply(null, log(`Net: Req post IDs for user ${logID(userID)} from peer ${logID(peerID)}`));; +// this.send({ +// type: "peer_message", +// from: this.peerID, +// from_username: app.username, +// from_peername: app.peername, +// to: peerID, +// message: { type: "get_post_ids_for_user", user_id: userID } +// }) +// } +// } +// } +// connect(): void { +// if (this.websocket?.readyState === WebSocket.OPEN) { +// return; +// } +// globalThis.clearInterval(this.websocketPingInterval); +// if (this.websocket) { this.websocket.close() }; +// try { +// this.websocket = new WebSocket(`wss://${globalThis.location.hostname}:${globalThis.location.port}/ws`); +// } catch (error: any) { +// console.log.apply(null, log(error.message)); +// return; +// } +// this.websocket.onopen = async (event) => { +// console.log.apply(null, log("ws:connected"));; +// await this.sendHello2(); +// // If we're running as a headless peer, send a hello message every N seconds to refresh the posts we have. +// // let helloRefreshIntervalPeriod = 120; +// // if (app.isHeadless) { +// // console.log.apply(null, log("wsConnection: Setting hello refresh interval to ", helloRefreshIntervalPeriod) +// // this.helloRefreshInterval = globalThis.setInterval(() => { +// // console.log.apply(null, log("wsConnection: Hello refresh.") +// // if (!navigator.onLine) { +// // return; +// // } +// // this.sendHello(); +// // }, helloRefreshIntervalPeriod * 1000); +// // } +// this.websocketPingInterval = globalThis.setInterval(() => { +// if (!navigator.onLine) { +// return; +// } +// this.send({ type: "ping", peer_id: this.peerID, peer_name: app.peername, user_id: app.userID, user_name: app.username }); +// }, 10_000) +// }; +// // this.websocket.onopen = async (event) => { +// // console.log.apply(null, log("ws:connected"));; +// // await this.sendHello(); +// // // If we're running as a headless peer, send a hello message every N seconds to refresh the posts we have. +// // let helloRefreshIntervalPeriod = 120; +// // if (app.isHeadless) { +// // console.log.apply(null, log("wsConnection: Setting hello refresh interval to ", helloRefreshIntervalPeriod) +// // this.helloRefreshInterval = globalThis.setInterval(() => { +// // console.log.apply(null, log("wsConnection: Hello refresh.") +// // if (!navigator.onLine) { +// // return; +// // } +// // this.sendHello(); +// // }, helloRefreshIntervalPeriod * 1000); +// // } +// // this.websocketPingInterval = globalThis.setInterval(() => { +// // if (!navigator.onLine) { +// // return; +// // } +// // this.send({ type: "ping", peer_id: this.peerID, peer_name: app.peername, user_id: app.userID, user_name: app.username }); +// // }, 10_000) +// // }; +// this.websocket.onclose = (event) => { +// console.log.apply(null, log("ws:disconnected"));; +// // this.retry *= 2; +// console.log.apply(null, log(`Retrying in ${this.retry} seconds`));; +// globalThis.setTimeout(() => { this.connect(); }, this.retry * 1000); +// }; +// this.websocket.onmessage = (event) => { +// // log('ws:<-' + event.data.slice(0, 240)); +// let data = JSON.parse(event.data); +// let { type } = data; +// let handler = this.messageHandlers.get(type); +// if (!handler) { +// console.warn(`Got a message we can't handle:`, type); +// return; +// } +// handler(data); +// }; +// this.websocket.onerror = (event) => { +// console.log.apply(null, log('ws:error: ' + event));; +// }; +// } +// disconnect() { +// this.websocket?.close(); +// } +// } +class App { + constructor() { + this.username = ''; + this.peername = ''; + this.userID = ''; + this.peerID = ''; + this.following = new Set(); + // posts: StoragePost[] = []; + this.isHeadless = false; + this.isBootstrapPeer = 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.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']; + // this.registerRPCs(); + // this.testPeerManager(); + // // let peer: RTCPeerConnection | null = null; + // // // if (globalThis.RTCPeerConnection) { + // // peer = new RTCPeerConnection({ + // // iceServers: [ + // // { urls: "stun:ddln.app" }, + // // // { urls: "turn:ddln.app", username: "a", credential: "b" }, + // // { urls: "stun:stun.l.google.com" }, // keeping this for now as my STUN server is not return ipv6 + // // // { urls: "stun:stun1.l.google.com" }, + // // // { urls: "stun:stun2.l.google.com" }, + // // // { urls: "stun:stun3.l.google.com" }, + // // // { urls: "stun:stun4.l.google.com" }, + // // ] + // // }); + // // peer.createDataChannel('boop'); + // // peer.onicecandidate = ({ candidate }) => { log(`WRTC:${candidate?.address} ${candidate?.protocol} ${candidate?.type} ${(candidate as any)?.url}`) }; + // // peer.onnegotiationneeded = async (event) => { + // // console.log.apply(null, log("on negotiation needed fired"));; + // // let makingOffer = false; + // // try { + // // makingOffer = true; + // // await peer.setLocalDescription(); + // // let IDsToSync = this.following; + // // if (this.router.route === App.Route.USER) { + // // IDsToSync = new Set([this.router.userID]); + // // } + // // if (!peer.localDescription) { + // // return; + // // } + // // // this.websocket = new wsConnection(this.userID, this.peerID, IDsToSync, peer.localDescription); + // // // log(peer.localDescription.type + ":" + peer.localDescription.sdp); + // // // this.initOffline(this.websocket); + // // // this.websocket?.sendWebRTCDescription(peer.localDescription); + // // } catch (err) { + // // console.error(err); + // // } finally { + // // makingOffer = false; + // // } + // // } + // // peer.createOffer().then((description)=>{ + // // peer.setLocalDescription(description) + // // console.log.apply(null, log("RTC: " + description.sdp + description.type));; + // // }); + // // } + // // await this.exportPostsForUser('b38b623c-c3fa-4351-9cab-50233c99fa4e'); + // // Get initial state and route from URL and user agent etc + // // Set local state (userid etc) based on that. + // // Init libraries + // // Render + // // Load all images async + // // Start the process of figuring out what posts we need + // // Download posts once all current images are loaded + // // globalThis.resizeTo(645, 900); + // // this.initLogo() + // 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"); + // } + // await this.initDB(); + // this.connectURL = `${document.location.origin}/connect/${this.userID}`; + // document.getElementById('connectURL')!.innerHTML = `connect`; + // this.isHeadless = urlParams.has('headless'); + // let limitPostsParam = urlParams.get('limitPosts'); + // if (limitPostsParam) { + // this.limitPosts = parseInt(limitPostsParam); + // } + // 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 as any)?.memory) { + // console.log.apply(null, log(`memory used: ${((performance as any).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: HTMLDivElement) { + // contentDiv.innerHTML = `
+ // Welcome to Dandelion v0.1!
+ // Loading posts for the default feed... + //
+ // `; + // } + // // 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 + // private renderedPosts = new Map(); + // 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 as HTMLDivElement); + // 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: string, postID: string) { + // deleteData(userID, postID) + // this.render(); + // } + // renderPost(post: Post, first: boolean) { + // 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("${first ? '' : '
'} + //
+ // @${post.author} - + // ${post.post_timestamp.toLocaleDateString()} + // + // ${ownPost ? `` : ''} + // ${ownPost ? `` : ''} + // + //
+ //
${markdown}
+ // ` + // 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 as ArrayBuffer]); + // const url = URL.createObjectURL(blob); + // image.onload = () => { + // URL.revokeObjectURL(url); + // }; + // image.src = url; + // // image.src = image.src = "data:image/png;base64," + post.image; + // image.className = "postImage"; + // // image.onclick = () => { App.maximizeElement(image) }; + // containerDiv.appendChild(image); + // // containerDiv.appendChild(timestampDiv); + // return containerDiv; + // } + // static maximizeElement(element: HTMLImageElement) { + // element.style.transform = "scale(2.0)" + // } + // router = { + // route: App.Route.HOME, + // userID: '', + // postID: '', + // mediaID: '' + // } + // 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/ + // // post = /user//post/ + // // media = /user//post//media/ + // // group = /group/ID/post/ + // // hashtag = /hashtag/ -- maybe only hastags in groups + // // home = / + // } + // } + // namespace App { + // export enum Route { + // USER, + // POST, + // MEDIA, + // GROUP, + // HOME, + // CONNECT, + // }; + // // export function connect() { + // // throw new Error("Function not implemented."); + // // } + // // export function connect() { + // // throw new Error("Function not implemented."); + // // } + } + async connect() { + this.peerManager = new PeerManager(this.userID, this.peerID, this.isBootstrapPeer); + this.registerRPCs(); + 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('getPostIDsForUser', (userID) => { + return [1, 2, 3, 4, 5]; + }); + await this.peerManager.connect(); + console.log.apply(null, log("*************** after peerManager.connect")); + ; + if (!this.isBootstrapPeer) { + 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: any, title: string, text: string) => { + // 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; + // } + // time = 0; + // timerStart() { + // this.time = performance.now(); + // } + // timerDelta() { + // return performance.now() - this.time; + // } + // getFixedTweetText(entry: any) { + // 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: ArrayBuffer, filename: string, mimeType: string = '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: any, 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: string, posts: string) { + // } + // async exportPostsForUser(userID: string) { + // let posts = await getAllData(userID); + // let output = []; + // console.log.apply(null, log("Serializing images")); + // for (let post of posts) { + // let newPost = (post as any).data; + // if (newPost.image_data) { + // newPost.image_data = await arrayBufferToBase64(newPost.image_data); + // } + // output.push(newPost); + // } + // 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: string, tweetArchive: any[]) { + // console.log.apply(null, log("Importing tweet archive")); + // let postsTestData: any[] = []; + // // 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: string = 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: string) { + // 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: ArrayBuffer, mimeType: string, quality = 0.5): Promise { + // 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 as 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 as Blob).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: string, postText: string, mediaData?: ArrayBuffer, mimeType?: "image/png" | "image/gif" | "image/jpg" | "image/jpeg" | "video/mp4") { + // 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 as ArrayBuffer).byteLength > 500 * 1024) { + // let compressedImage = await this.compressImage(mediaData as ArrayBuffer, mimeType, 0.9); + // if (compressedImage) { + // mediaData = compressedImage as ArrayBuffer; + // } + // } + // let post = new Post(this.username, userID, postText, new Date(), mediaData); + // // 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: string, fontSize: string) { + // 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: string): Promise { + // 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: File): Promise { + // // Always return a Promise + // return new Promise((resolve, reject) => { + // let content = ''; + // const reader = new FileReader(); + // // Wait till complete + // reader.onloadend = function (e: any) { + // content = e.target.result; + // resolve(content); + // }; + // // Make sure to handle error states + // reader.onerror = function (e: any) { + // 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') as HTMLImageElement).classList.add('qrcode_image'); + // (document.querySelector('#qrcode > canvas') as HTMLImageElement).classList.add('qrcode_image'); + // this.showLog = true; + // } + // button(elementName: string) { + // return document.getElementById(elementName) as HTMLButtonElement; + // } + // div(elementName: string) { + // return document.getElementById(elementName) as HTMLDivElement; + // } + // initButtons(userID: string, posts: StoragePost[], registration: ServiceWorkerRegistration | undefined) { + // // 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') as HTMLInputElement; + // filePicker?.addEventListener('change', async (event: any) => { + // for (let file of filePicker.files as any) { + // let buffer = await file.arrayBuffer(); + // await this.createNewPost(this.userID, 'image...', buffer, file.type); + // } + // // 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: any) => { + // 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") as HTMLButtonElement; + // let postText = document.getElementById("textarea_post") as HTMLTextAreaElement; + // 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 as any); + // }); + // 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: StoragePost[] = []; + // 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: string): Promise { + // // Rob + // if (userID === 'b38b623c-c3fa-4351-9cab-50233c99fa4e') { + // return [ + // 'b38b623c-c3fa-4351-9cab-50233c99fa4e', + // '6d774268-16cd-4e86-8bbe-847a0328893d', // Sean + // '05a495a0-0dd8-4186-94c3-b8309ba6fc4c', // Martin + // 'a0e42390-08b5-4b07-bc2b-787f8e5f1297', // BMO + // 'bba3ad24-9181-4e22-90c8-c265c80873ea', // Harry + // '8f6802be-c3b6-46c1-969c-5f90cbe01479', // Fiona + // ] + // } + // // Martin + // if (userID === '05a495a0-0dd8-4186-94c3-b8309ba6fc4c') { + // return [ + // 'b38b623c-c3fa-4351-9cab-50233c99fa4e', + // 'a0e42390-08b5-4b07-bc2b-787f8e5f1297', // BMO + // ] + // } + // // Fiona + // if (userID === '8f6802be-c3b6-46c1-969c-5f90cbe01479') { + // return [ + // 'b38b623c-c3fa-4351-9cab-50233c99fa4e', // Rob + // 'a0e42390-08b5-4b07-bc2b-787f8e5f1297', // BMO + // '05a495a0-0dd8-4186-94c3-b8309ba6fc4c', // Martin + // ] + // } + // return ['a0e42390-08b5-4b07-bc2b-787f8e5f1297']; // Follow BMO by default :) + // } + // async loadPostsFromStorage(userID: string, postID?: string) { + // this.timerStart(); + // let posts: StoragePost[] = []; + // // 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 as string[]) { + // // 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: any) { + // 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('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 = true; ///\bHeadlessChrome\//.test(navigator.userAgent); + this.isBootstrapPeer = true; //urlParams.has("bootstrap"); + if (this.isBootstrapPeer) { + console.log.apply(null, log(`This is a bootstrap peer`)); + ; + } + this.peerID = this.getPeerID(); + this.peername = this.getPeername(); + this.userID = this.getUserID(); + this.username = this.getUsername(); + this.connect(); + } +} +let app = new App(); +app.main(); +// globalThis.addEventListener("load", app.main.bind(app)); diff --git a/static/dataUtils.js b/static/dataUtils.js new file mode 100644 index 0000000..9b91f0b --- /dev/null +++ b/static/dataUtils.js @@ -0,0 +1,71 @@ +export 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 })); + }); +} +export 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; +// } +export 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); +} +// Base58 character set +// const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +// Base58 encoding +// Base58 encoding +// function encodeBase58(buffer: Uint8Array): string { +// let carry; +// const digits = [0]; +// for (const byte of buffer) { +// carry = byte; +// for (let i = 0; i < digits.length; i++) { +// carry += digits[i] << 8; +// digits[i] = carry % 58; +// carry = Math.floor(carry / 58); +// } +// while (carry > 0) { +// digits.push(carry % 58); +// carry = Math.floor(carry / 58); +// } +// } +// let result = ''; +// for (const digit of digits.reverse()) { +// result += BASE58_ALPHABET[digit]; +// } +// // Handle leading zero bytes +// for (const byte of buffer) { +// if (byte === 0x00) { +// result = BASE58_ALPHABET[0] + result; +// } else { +// break; +// } +// } +// return result; +// } +// Convert UUID v4 to Base58 +// function uuidToBase58(uuid: string): string { +// const bytes = uuidToBytes(uuid); +// return encodeBase58(bytes); +// } diff --git a/static/log.js b/static/log.js new file mode 100644 index 0000000..f06993d --- /dev/null +++ b/static/log.js @@ -0,0 +1,39 @@ +let logLines = []; +let logLength = 100; +let logVisible = false; +export function logID(ID) { + if (!ID) { + return "badID"; + } + return ID.substring(0, 5); +} +export function setLogVisibility(visible) { + logVisible = visible; +} +export function renderLog() { + if (!logVisible) { + return; + } + let log = document.getElementById("log"); + if (!log) { + throw new Error(); + } + log.innerText = logLines.join("\n"); +} +export function log(...args) { + // console.log(...args); + let logLine = `[${new Date().toLocaleTimeString()}]: `; + for (let arg of args) { + let completeLine = (typeof arg === "string" || arg instanceof String) ? arg : JSON.stringify(arg, null, 4); + if (completeLine === undefined) { + completeLine = "undefined"; + } + logLine += completeLine.substring(0, 500); + } + logLines.push(logLine + "\n"); + if (logLines.length > logLength) { + logLines = logLines.slice(logLines.length - logLength); + } + renderLog(); + return [logLine]; // [...args]; +}