diff --git a/src/App.ts b/src/App.ts new file mode 100644 index 0000000..a127b4b --- /dev/null +++ b/src/App.ts @@ -0,0 +1,1466 @@ +import { generateID } from "IDUtils"; +import { PeerManager, PeerEventTypes } from "PeerManager"; +import { Sync } from "Sync"; +import { openDatabase, getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds } from "db"; +import { arrayBufferToBase64, compressString } from "dataUtils"; +import { log, logID, renderLog, setLogVisibility } from "log" + +declare let marked: any; +declare let QRCode: any; + +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; + } +} + +interface StoragePost { + data: Post; +} + +export class App { + username: string = ''; + peername: string = ''; + userID: string = ''; + peerID: string = ''; + following: Set = new Set(); + posts: StoragePost[] = []; + isHeadless: boolean = false; + isBootstrapPeer: boolean = false; + isArchivePeer: boolean = false; + showLog: boolean = false; + markedAvailable = false; + limitPosts = 50; + // websocket: wsConnection | null = null; + // vizGraph: any | null = null; + qrcode: any = null; + connectURL: string = ""; + firstRun = false; + peerManager: PeerManager | null = null; + sync: Sync = new Sync(); + renderTimer: number = 0; + postSyncQueue: any[] = []; + postSyncPromise: any = null; + + async syncPostsInQueue() { + + if (this.postSyncPromise) { + return; + } + + while (this.postSyncQueue.length !== 0) { + + let queueItem = this.postSyncQueue.pop(); + + let userID = queueItem.userID; + let peerID = queueItem.peerID; + let postIDs = queueItem.postIDs; + + new Promise(async (resolve, reject) => { + 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(sendingPeerID)}`)); + } + + }) + + } + + + + + + } + + addPostIDsToSyncQueue(userID: string, peerID: string, postIDs: string[]) { + this.postSyncQueue.push({ userID: userID, peerID: peerID, postIDs: postIDs }); + } + + // 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: string, userIDs: string[]) { + 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: any) => { + 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: any) => { + let peerID = event.peerID; + console.log.apply(null, log(`[app]: peer disconnected:${event.peerID}`)); + this.sync.deleteUserPeer(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: string, userIDs: string[]) => { + this.announceUser_rpc_response(sendingPeerID, userIDs); + }); + + this.peerManager.registerRPC('getPeersForUser', (userID: string) => { + return [1, 2, 3, 4, 5]; + }); + + + this.peerManager.registerRPC('getPostIDsForUser', async (userID: string) => { + let postIDs = await this.sync.getPostIdsForUser(userID); + if (postIDs) { + return postIDs; + } + }); + + this.peerManager.registerRPC('getPostsForUser', async (requestingPeerID: string, userID: string, postIDs: string[]) => { + 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)); + + this.peerManager?.rpc.sendPostForUser(requestingPeerID, userID, post); + } + // return posts; + + // return postIDs; + }); + + this.peerManager.registerRPC('sendPostForUser', async (userID: string, post: Post) => { + console.log.apply(null, log(`[app] sendPostForUser got post ${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() }, 200); + // } + }); + + + await this.peerManager.connect(); + console.log.apply(null, log("*************** after peerManager.connect"));; + + + if (this.isBootstrapPeer) { + return; + } + + // let usersToSync = await Sync.getFollowing(this.userID); + + // for (let userID of usersToSync) { + // console.log(userID); + // // this.peerManager.rpc.getPeersForUser(userID); + // } + + + // for (let userID in this.sync.usersToSync()) { + // let peers = await this.peerManager.rpc.getPeersForUser(userID); + + // for (let peer in peers) { + // let peer = await this.peerManager.connectToPeer(userID); + + // let postIDs = peer.getPostIDsForUser(userID); + + // let postIDsNeeded = this.sync.checkPostIds(userID, postIDs); + + // if (postIDs.length === 0) { + // continue; + // } + + // let posts = peer.rpc.getPostsForUser(userID, postIDs); + + // this.sync.writePostsForUser(userID, posts); + + // this.render(); + // } + + // } + + // let postIDs = await this.peerManager.rpc.getPostIDsForUser(this.peerManager.bootstrapPeerID, this.userID); + + + // console.log.apply(null, log("peerManager.rpc.getPostIDsForUser", postIDs)); + + } + + getPreferentialUserID() { + return this.router.userID.length !== 0 ? this.router.userID : this.userID; + } + + initMarkdown() { + if (typeof marked === "undefined") { + return; + } + + const renderer = new marked.Renderer(); + renderer.link = (href: 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; + } + + animals = ['shrew', 'jerboa', 'lemur', 'weasel', 'possum', 'possum', 'marmoset', 'planigale', 'mole', 'narwhal']; + adjectives = ['snazzy', 'whimsical', 'jazzy', 'bonkers', 'wobbly', 'spiffy', 'chirpy', 'zesty', 'bubbly', 'perky', 'sassy']; + snakes = ['mamba', 'cobra', 'python', 'viper', 'krait', 'sidewinder', 'constrictor', 'boa', 'asp', 'anaconda', 'krait'] + + hashIdToIndices(id: string) { + 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: string, listOne: string[], listTwo: string[]) { + 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: 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 { + return this.sync.getFollowing(userID); + } + + 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 = this.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: any) => { + 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: any) => { + 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.peerID = this.getPeerID(); + this.peername = this.getPeername(); + this.userID = this.getUserID(); + this.username = this.getUsername(); + + this.sync.setUserID(this.userID) + this.sync.setArchive(this.isArchivePeer); + + this.getRoute(); + if (this.router.route === App.Route.CONNECT) { + console.log.apply(null, log('connect', this.router.userID)); + localStorage.setItem("dandelion_id", this.router.userID); + localStorage.removeItem("dandelion_username"); + } + + this.connect(); + + await this.initDB(); + + this.connectURL = `${document.location.origin}/connect/${this.userID}`; + document.getElementById('connectURL')!.innerHTML = `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 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 = / + + } + +} + +export namespace App { + export enum Route { + USER, + POST, + MEDIA, + GROUP, + HOME, + CONNECT, + } +}; \ No newline at end of file diff --git a/src/dataUtils.ts b/src/dataUtils.ts new file mode 100644 index 0000000..5ed5d82 --- /dev/null +++ b/src/dataUtils.ts @@ -0,0 +1,84 @@ +export 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 })); + }); +} + +export 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; +// } + +export 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); +} + +// 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); +// } \ No newline at end of file