import { generateID } from "IDUtils"; import { PeerManager, PeerEventTypes } from "PeerManager"; import { Sync } from "Sync"; import { openDatabase, getData, addData, deleteData, getAllData, getPostForUser } 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; } } class StatusBar { constructor() { this.peerStatus = new Map(); this.headless = false; } setMessageHTML(html) { let statusBarElement = document.getElementById('status_bar'); if (!statusBarElement) { return; } statusBarElement.innerHTML = html; } setHeadless(headless) { this.headless = headless; } updatePeerMessage(peerID, message) { this.peerStatus.set(peerID, { message, data: this.peerStatus.get(peerID)?.data }); this.render(); } updatePeerData(peerID, data) { this.peerStatus.set(peerID, { message: this.peerStatus.get(peerID)?.message, data: data }); } updatePeerStatus(peerID, message = "", data = {}) { this.peerStatus.set(peerID, { message, data }); this.render(); } getPeerData(peerID) { let status = this.peerStatus.get(peerID); if (status) { return status.data; } return null; } render() { if (this.headless) { // TODO:Make a nice htop-like display for headless at some point return; } let newStatus = ""; for (let [peerID, status] of this.peerStatus.entries()) { let statusBarItem = `(${logID(peerID)} | ${status.message}) `; newStatus += statusBarItem; } this.setMessageHTML(newStatus); } } 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 = null; this.syncQueues = new Map(); this.syncing = new Set(); this.statusBar = new StatusBar(); 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: '' }; } // updateStatusBar() { // let statusBarElement = document.getElementById('status_bar'); // if (!statusBarElement) { // return; // } // let newStatusBar = ""; // for (let [userID, syncItems] of this.syncQueues.entries()) { // for (let item of syncItems) { // let {peerID, postIDs} = item; // // let statusBarItem = ` 🤔(${postIDs.length})✉️ [${this.getUserFunkyName(userID)}]${logID(userID)} from [${this.getPeerFunkyName(peerID)}]${logID(peerID)} `; // // let statusBarItem = ` 🤔(${postIDs.length})✉️ [${this.getUserFunkyName(userID)}] from [${this.getPeerFunkyName(peerID)}] `; // let statusBarItem = ` (${this.getUserFunkyName(userID)}+${this.getPeerFunkyName(peerID)})🧐 `; // newStatusBar+= statusBarItem; // } // } // this.statusBar += newStatusBar; // statusBarElement.innerHTML = this.statusBar; // } 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 neededPostCount = neededPostIDs.length; this.statusBar.updatePeerStatus(peerID, `need(${logID(userID)} | ${neededPostCount})`, { havePostCount: 0, neededPostCount: neededPostCount }); await this.peerManager?.rpc.getPostsForUser(peerID, this.peerID, userID, neededPostIDs); } else { console.log.apply(null, log(`[app] Don't need any posts for user ${logID(userID)} from peer ${logID(peerID)}`)); this.statusBar.updatePeerStatus(peerID, `synced(${logID(userID)})`); } } // this.updateStatusBar(); 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)); this.statusBar.updatePeerStatus(sendingPeerID, `announcePeers(${userIDs.length})⬇️`); 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 | App.Route.POST) && 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)}]`)); this.statusBar.updatePeerStatus(sendingPeerID, `getPostIDs(${logID(userID)})⬆️`); let postIDs = null; if (this.router.route === App.Route.POST && this.router.userID == userID) { postIDs = [this.router.postID]; } else { postIDs = await this.peerManager?.rpc.getPostIDsForUser(sendingPeerID, userID); } if (!postIDs) { continue; } this.statusBar.updatePeerStatus(sendingPeerID, `syncing(${logID(userID)} ${postIDs.length})`); console.log.apply(null, log(`[app] Got (${postIDs.length}) post IDs for user [${logID(userID)}] from peer [${logID(sendingPeerID)}]`)); this.addPostIDsToSyncQueue(userID, sendingPeerID, postIDs); } } 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(); // rpc saying what peers we have this.peerManager.rpc.announceUsers(event.peerID, this.peerID, knownUsers); }); 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; } return []; }); this.peerManager.registerRPC('getPostsForUser', async (requestingPeerID, userID, postIDs) => { let posts = await this.sync.getPostsForUser(userID, postIDs); let i = 0; 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)); i++; this.statusBar.updatePeerStatus(this.peerID, `⬆️${logID(requestingPeerID)} ${i}/${posts.length}`); await this.peerManager?.rpc.sendPostForUser(requestingPeerID, this.peerID, userID, post); } return true; // return posts; // return postIDs; }); this.peerManager.registerRPC('sendPostForUser', async (sendingPeerID, userID, post) => { console.log.apply(null, log(`[app] sendPostForUser got post[${logID(post.post_id)}] from peer[${logID(sendingPeerID)}] for user[${logID(userID)}] author[${post.author}] text[${post.text}]`)); // if (post.text === "image...") { // debugger; // } let peerData = this.statusBar.getPeerData(sendingPeerID); if (peerData) { this.statusBar.updatePeerMessage(sendingPeerID, `⬇️${logID(userID)} ${peerData.havePostCount}/${peerData.neededPostCount}}`); } await this.sync.writePostForUser(userID, post); // if (userID === this.userID) { if (peerData) { peerData.havePostCount++; this.statusBar.updatePeerMessage(sendingPeerID, `⬇️${logID(userID)} ${peerData.havePostCount}/${peerData.neededPostCount}}`); } if (this.renderTimer) { clearTimeout(this.renderTimer); } this.renderTimer = setTimeout(() => { this.render(); }, 1000); return true; // } }); this.statusBar.setMessageHTML("Connecting to ddln network..."); await this.peerManager.connect(); console.log.apply(null, log("*************** after peerManager.connect")); ; this.statusBar.setMessageHTML("Connected to ddln network..."); 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 }; } getUserFunkyName(userID) { let { first: adjective, second: animal } = this.funkyName(userID, this.adjectives, this.animals); return `${adjective}_${animal}`; } getPeerFunkyName(peerID) { let { first: adjective, second: snake } = this.funkyName(peerID, this.adjectives, this.snakes); return `${adjective}_${snake}`; } getUsername() { let username = localStorage.getItem("dandelion_username"); if (username && username !== "not_set") { return username; } username = this.getUserFunkyName(this.userID); localStorage.setItem("dandelion_username", username); return username; } getPeername() { let peername = this.getPeerFunkyName(this.peerID); 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 getPostForUser(userID, postID); } else { posts = await getData(userID, new Date(2022, 8), new Date()); } if (posts?.length) { console.log.apply(null, log(`Loaded ${posts.length} posts in ${this.timerDelta().toFixed(2)}ms`)); ; return posts; } console.log.apply(null, log(`No posts found for userID:${userID}, postID:${postID}`)); ; // posts = await createTestData2(userID); // log("Adding test data..."); // addDataArray(userID, posts); // 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}`); this.statusBar.setHeadless(this.isHeadless); 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 = `