// TODO: virtual list, only rerender what's needed so things can keep playing. import { getData, addData, addDataArray, clearData, deleteData, mergeDataArray, getAllData, checkPostIds, getAllIds, getPostsByIds} from "./db.js" declare let WebTorrent: 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 uuidv4() { return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c: any) => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) ); } let logLines: string[] = []; let logLength = 10; function log(message: string) { console.log(message); logLines.push(`${new Date().toLocaleTimeString()}: ${message}`); if (logLines.length > 10) { logLines = logLines.slice(logLines.length - logLength); } let log = document.getElementById("log"); if (!log) { throw new Error(); } log.innerText = logLines.join("\n"); } function generateID() { if (self.crypto.hasOwnProperty("randomUUID")) { return self.crypto.randomUUID(); } return uuidv4(); } 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; } } window.addEventListener('scroll', () => { // Total height of the document const totalPageHeight = document.body.scrollHeight; // Current scroll position const scrollPoint = window.scrollY + window.innerHeight; // Check if scrolled to bottom if (scrollPoint >= totalPageHeight) { console.log('Scrolled to the bottom!'); console.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); // } // } function arrayBufferToBase64( buffer:ArrayBuffer ) { var binary = ''; var bytes = new Uint8Array( buffer ); var len = bytes.byteLength; for (var i = 0; i < len; i++) { binary += String.fromCharCode( bytes[ i ] ); } return window.btoa( binary ); } function base64ToArrayBuffer(base64:string) { var binaryString = atob(base64); var bytes = new Uint8Array(binaryString.length); for (var i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes.buffer; } class wsConnection { websocket: WebSocket | null = null; userID = ""; peerID = ""; websocketPingInterval: number = 0; retry = 10; state = 'disconnected'; peers: Map = new Map(); messageHandlers: Map void> = new Map(); peerMessageHandlers: Map void> = new Map(); send(message: any) { let json = "" try { json = JSON.stringify(message); } catch (e) { console.log(e, "wsConnection send: Couldn't serialize message", message); } log(`ws->${json.slice(0,240)}`) this.websocket!.send(json); } helloResponseHandler(data: any) { for (let [userID, peerIDs] of Object.entries(data.userPeers)) { this.peers.set(userID, [...Object.keys(peerIDs as any)]); for (let peerID of [...Object.keys(peerIDs as any)]) { if (peerID === this.peerID) { continue; } this.send({ type:"peer_message", from:this.peerID, to:peerID, message:{type:"get_post_ids_for_user", user_id:userID} }) } } } pongHandler(data: any) { } async getPostIdsForUserResponseHandler(data: any) { // log(`getPostsForUserResponse: ${data}`) let message = data.message; console.log(`getPostIdsForUserResponseHandler Got ${message.post_ids.length} from peer ${data.from}`); console.log(`Checking post IDs...`); let postIds = await checkPostIds(message.user_id, data.message.post_ids); if (postIds.length === 0) { log(`Don't need any posts from peer ${data.from}`); return; } let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, message: { type: "get_posts_for_user", post_ids: postIds, user_id: message.user_id } } this.send(responseMessage); } async getPostIdsForUserHandler(data: any) { let message = data.message; let postIds = await getAllIds(message.user_id) ?? []; let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, message: { type: "get_post_ids_for_user_response", post_ids: postIds, user_id: message.user_id } } 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) ?? []; let output = []; for (let post of posts) { let newPost = (post as any).data; if (newPost.image_data) { newPost.image_data = arrayBufferToBase64(newPost.image_data) } output.push(newPost); } // posts = posts.map((post:any)=>{let newPost = post.data; if (newPost.image_data){newPost.image_data = arraybufferto};return newPost}); // posts = posts.map((post:any)=>{}) let responseMessage = { type: "peer_message", from: app.peerID, to: data.from, message: { type: "get_posts_for_user_response", posts: output, user_id: message.user_id } } this.send(responseMessage) } // Got posts from peer async getPostsForUserReponseHandler(data: any) { let message = data.message; console.log(`getPostsForUserResponseHandler Got ${message.posts.length} from peer ${data.from}`); for (let post of message.posts) { post.post_timestamp = new Date(post.post_timestamp); if (post.image_data) { post.image_data = base64ToArrayBuffer(post.image_data); } } console.log(`Merging same user peer posts...`) await mergeDataArray(message.user_id, data.message.posts) if (message.user_id === this.userID) { app.posts = await app.loadPosts(this.userID) ?? []; app.render(app.posts); } } async peerMessageHandler(data: any) { log(`peerMessageHandler ${data}`) 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); } connect(): void { if (this.websocket?.readyState === WebSocket.OPEN) { return; } window.clearInterval(this.websocketPingInterval); if (this.websocket) { this.websocket.close() }; try { this.websocket = new WebSocket(`wss://${window.location.hostname}:${window.location.port}/ws`); } catch (error: any) { console.log(error.message); return; } this.websocket.onopen = (event) => { log("ws:connected"); this.send({type:"hello", user_id: this.userID, peer_id:this.peerID}); this.websocketPingInterval = window.setInterval(() => { if (!navigator.onLine) { return; } this.send({type:"ping", peer_id: this.peerID}); }, 10_000) }; this.websocket.onclose = (event) => { log("ws:disconnected"); // this.retry *= 2; log(`Retrying in ${this.retry} seconds`); window.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) => { log('ws:error: ' + event); }; } disconnect() { this.websocket?.close(); } constructor(userID: string, peerID: string) { this.userID = userID; this.peerID = peerID; this.messageHandlers.set('hello', this.helloResponseHandler.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.connect(); if (!this.websocket) { // set a timer and retry? } } } class App { username: string = ''; userID: string = ''; peerID: string = ''; posts: Post[] = []; initMarkdown() { const renderer = new marked.Renderer(); renderer.link = (href: any, title: string, text: string) => { return `${text}`; }; marked.setOptions({ renderer: renderer }); } // 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 } async importTweetArchive(userID: string, tweetArchive: any[]) { log("Importing tweet archive") let postsTestData: any[] = []; // let response = await fetch("./tweets.js"); // let tweetsText = await response.text(); // tweetsText = tweetsText.replace("window.YTD.tweets.part0", "window.tweetData"); // new Function(tweetsText)(); // let tweets = JSON.parse(tweetJSON); let count = 0; for (let entry of tweetArchive) { // if (entry.tweet.hasOwnProperty("in_reply_to_screen_name") || entry.tweet.retweeted || entry.tweet.full_text.startsWith("RT")) { // continue; // } let mediaURL: 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(imageData); // } catch (e) { // console.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) { 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("Service worker already registered."); return registrations[0]; } navigator.serviceWorker .register("/sw.js") .then((registration) => { console.log("Service Worker registered with scope:", registration.scope); return registration; }) .catch((error) => { console.error("Service Worker registration failed:", error); }); } addPost(userID: string, postText: string, imageData?: ArrayBuffer) { if ((typeof postText !== "string") || postText.length === 0) { log("Not posting an empty string...") return; } let post = new Post(this.username, userID, postText, new Date(), imageData); this.posts.push(post); // localStorage.setItem(key, JSON.stringify(posts)); addData(userID, post) this.render(this.posts); } getPeerID() { let id = localStorage.getItem("peer_id"); if (!id) { id = generateID(); localStorage.setItem("peer_id", id); } return id; } getUserID() { let id = localStorage.getItem("dandelion_id"); if (!id) { id = generateID(); localStorage.setItem("dandelion_id", id); } return id; } getUsername() { let username = localStorage.getItem("dandelion_username"); if (!username) { username = "not_set" localStorage.setItem("dandelion_username", username); } return username; } 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 window.addEventListener('offline', () => { log("offline") }); // Event listener for going online window.addEventListener('online', async () => { log("online") connection.connect(); this.posts = await this.loadPosts(this.userID) ?? []; this.render(this.posts); }); 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); }); } initButtons(userID: string, posts: Post[], 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 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 addP = document.getElementById('button_add_pic') as HTMLDivElement; 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(file); if (file == null) { return; } let tweetData = await this.readFile(file); tweetData = tweetData.replace('window.YTD.tweets.part0 = ', ''); const tweets = JSON.parse(tweetData); let imported_posts = await this.importTweetArchive(userID, tweets); clearData(userID); // posts = posts.reverse(); addDataArray(userID, imported_posts); posts = await this.loadPosts(userID) ?? []; this.render(posts); }); clearPostsButton.addEventListener('click', () => { clearData(userID); posts = []; this.render(posts) }); 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(); let type = this.addPost(this.userID, 'image...', buffer); }); postButton.addEventListener("click", () => { this.addPost(userID, postText.value); postText.value = ""; }); updateApp.addEventListener("click", () => { registration?.active?.postMessage({ type: "update_app" }); }); let infoElement = document.getElementById('info'); if (infoElement === null) { return; } ddlnLogoButton.addEventListener('click', () => { infoElement.style.display == 'none' ? infoElement.style.display = 'block' : infoElement.style.display = 'none'; }); } async loadPosts(userID: string) { this.timerStart(); let posts: any = await getData(userID, new Date(2022, 8), new Date()); if (posts.length > 0) { 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 main() { let urlParams = (new URL(window.location.href)).searchParams; let connection_userID = urlParams.get('connect'); let registration = undefined; // if (urlParams.get("sw") === "true") { registration = await this.registerServiceWorker(); // } if (connection_userID) { console.log('connect', connection_userID); localStorage.setItem("dandelion_id", connection_userID); } this.username = this.getUsername(); document.getElementById('username')!.innerText = this.username; let userID = this.getUserID(); let peerID = this.getPeerID(); this.userID = userID; this.peerID = peerID; this.initButtons(userID, this.posts, registration); let time = 0; let delta = 0; // let isPersisted = await navigator?.storage?.persisted(); // if (!isPersisted) { // debugger; // const isPersisted = await navigator.storage.persist(); // 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(code); // registration.active.postMessage({type:"updateMain", code:code}); this.posts = await this.loadPosts(userID) ?? []; // debugger; this.timerStart(); this.render(this.posts); // , (postID:string)=>{this.deletePost(userID, postID)} let renderTime = this.timerDelta(); log(`render took: ${renderTime.toFixed(2)}ms`); if ((performance as any)?.memory) { log(`memory used: ${((performance as any).memory.usedJSHeapSize / 1024 / 1024).toFixed(2)}Mb`) } let connectURL = `https://${document.location.hostname}?connect=${this.userID}`; document.getElementById('connectURL')!.innerHTML = `connect`; let qrcode = await new QRCode(document.getElementById('qrcode'), { text: connectURL, width: 256, height: 256, colorDark: "#000000", colorLight: "#ffffff", correctLevel: QRCode.CorrectLevel.H }); let qrcodeImage:HTMLImageElement = document.querySelector('#qrcode > img') as HTMLImageElement; qrcodeImage.classList.add('qrcode_image'); log(`user:${userID} peer:${peerID}`); let websocket = new wsConnection(userID, peerID); window.addEventListener('beforeunload', () => { websocket.disconnect() }) this.initOffline(websocket); // 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')); // }) } render(posts: Post[]) { const fragment = document.createDocumentFragment(); let contentDiv = document.getElementById("content"); if (!contentDiv) { throw new Error(); } contentDiv.innerHTML = ""; // let count = 0; for (let i = posts.length - 1; i >= 0; i--) { let postData = posts[i]; let post = this.renderPost(postData, posts); if (post) { fragment.appendChild(post); // count++; } // if (count > 100) { // break; // } } if (!contentDiv) { throw new Error("Couldn't get content div!"); } contentDiv.appendChild(fragment); } async deletePost(userID: string, postID: string) { deleteData(userID, postID) this.posts = await this.loadPosts(userID) ?? []; this.render(this.posts); } renderPost(post: Post, posts: Post[]) { 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'; let editButton = document.createElement('button'); editButton.innerText = 'edit'; deleteButton.onclick = () => { this.deletePost(this.userID, post.post_id) }; let postTemplate = `

@${post.author} - ${post.post_timestamp.toLocaleDateString()}
${marked.parse(post.text)}
` containerDiv.innerHTML = postTemplate; containerDiv.querySelector('#deleteButton')?.appendChild(deleteButton); containerDiv.querySelector('#editButton')?.appendChild(editButton); if (!("image_data" in post && post.image_data)) { // containerDiv.appendChild(timestampDiv); return containerDiv; // return null; } let image = document.createElement("img"); // 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"; containerDiv.appendChild(image); // containerDiv.appendChild(timestampDiv); return containerDiv; } } let app = new App(); window.addEventListener("load", app.main.bind(app));