diff --git a/CSS/cards.css b/CSS/cards.css index a901945..7a7802f 100644 --- a/CSS/cards.css +++ b/CSS/cards.css @@ -239,6 +239,7 @@ align-items: center; justify-content: center; transition: background-color 0.3s ease; + z-index: 9999; } .compose-button:hover { @@ -317,4 +318,59 @@ padding: 8px; text-align: left; font-size: 0.9em; -} \ No newline at end of file +} + +/* container for the external embed */ +.external-embed-container { + border: 2px solid #4e004e; + border-radius: 8px; + padding: 16px; + max-width: 400px; + margin: 16px auto; + background-color: #2e003e; + color: #e6e6ff; + font-family: Arial, sans-serif; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + text-align: center; +} + +/* thumbnail styling */ +.external-embed-thumb { + width: 100%; + max-width: 400px; + height: auto; + border-radius: 8px; + margin-bottom: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* title styling */ +.external-embed-title { + font-size: 1.5em; + margin: 0 0 8px 0; + color: #ffcc00; +} + +/* description styling */ +.external-embed-description { + font-size: 1em; + margin: 0 0 16px 0; + color: #e6e6ff; +} + +/* link styling */ +.external-embed-link { + display: inline-block; + padding: 8px 16px; + background-color: #4e004e; + color: #ffcc00; + text-decoration: none; + border-radius: 5px; + font-weight: bold; + transition: background-color 0.3s ease; +} + +.external-embed-link:hover { + background-color: #ffcc00; + color: #4e004e; +} diff --git a/CSS/global.css b/CSS/global.css index 0d04689..557ad69 100644 --- a/CSS/global.css +++ b/CSS/global.css @@ -38,7 +38,7 @@ img { } -#eof { +.eof { text-align: center; } diff --git a/HTML/index.html b/HTML/index.html index 9b0fb28..38bb144 100644 --- a/HTML/index.html +++ b/HTML/index.html @@ -63,14 +63,7 @@

nothing here...

- + diff --git a/JS/likes.cjs b/JS/likes.cjs deleted file mode 100644 index 73b974f..0000000 --- a/JS/likes.cjs +++ /dev/null @@ -1,42 +0,0 @@ -async function renderLikes(data, ipcRenderer) { - let container; - /** @type {Map} */ - let a; - if (document.querySelector('#likescontainer')) { - container = document.querySelector('#likescontainer'); - a = new Map(Array.from(container.querySelectorAll('.post-card')).map(o => ([o.dataset.bskyid, o]))); - } - else { - container = document.createElement('div'); - container.classList.add('cards-container'); - container.id = 'likescontainer'; - a = new Map(); - } - - // setupWorker(); - // renderLikesFeed(posts.map(p => (p?.reply?.root?.uri || p.post.uri)?.trim()), likes, ipcRenderer); - - for (const post of data) renderPostSingle(post, a, likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), ipcRenderer, container); - - const postids = Array.from(document.querySelectorAll('[data-like-bskyid]')).map(o => o.dataset.bskyid); - console.log(postids); - - // "force" garbage collection - // delete a; - a.clear(); - a = null; - - // append container to the document body or a specific section - const postdiv = document.querySelector('#posts'); - postdiv.querySelector('.placeholder')?.remove(); - if (!document.querySelector('#likescontainer')) { - postdiv.appendChild(container); - document.addEventListener('click', (e) => { - if (e.target.tagName === "VIDEO") zoomvid(e); - else if (e.target.tagName === "IMG" || e.target.classList.contains('overlay')) zoomimg(e); - }); - } -} - - -module.exports = renderLikes; \ No newline at end of file diff --git a/JS/posts.cjs b/JS/posts.cjs index 8e8cebe..1aef757 100644 --- a/JS/posts.cjs +++ b/JS/posts.cjs @@ -559,6 +559,47 @@ function renderPostSingle(post, a, likes, ipcRenderer, container, idkey = 'bskyi } } + if (post.post.embed && post.post.embed.$type === 'app.bsky.embed.external#view') { + const embedContainer = document.createElement('div'), + embed = post.post.embed.external; + + embedContainer.classList.add('external-embed-container'); + + // thumbnail (if exists) + if (embed.thumb) { + const thumbnail = document.createElement('img'); + thumbnail.classList.add('external-embed-thumb'); + thumbnail.src = embed.thumb; + thumbnail.alt = `${embed.title} thumbnail`; + embedContainer.appendChild(thumbnail); + } + + // title + const titleElement = document.createElement('h3'); + titleElement.classList.add('external-embed-title'); + titleElement.textContent = embed.title; + + // description + const descriptionElement = document.createElement('p'); + descriptionElement.classList.add('external-embed-description'); + descriptionElement.textContent = embed.description; + + // link + const linkElement = document.createElement('a'); + linkElement.classList.add('external-embed-link'); + linkElement.href = embed.uri; + linkElement.target = '_blank'; // opens in a new tab + linkElement.rel = 'noopener noreferrer'; // improves security + linkElement.textContent = 'Visit'; + + // append elements to the container + embedContainer.appendChild(titleElement); + embedContainer.appendChild(descriptionElement); + embedContainer.appendChild(linkElement); + + card.appendChild(embedContainer); + } + // interaction counts if (!card.querySelector('.interaction-counts')) { const counts = document.createElement('div'); @@ -585,7 +626,6 @@ function renderPostSingle(post, a, likes, ipcRenderer, container, idkey = 'bskyi * @param {*} ipcRenderer */ module.exports = function renderPosts(posts, likes, pinnedPost, ipcRenderer, idkey = 'bskyid', containerid = 'posts') { - console.log(`${idkey}, ${containerid}container, ${posts.length}`); let container; /** @type {Map} */ let a; @@ -596,14 +636,26 @@ module.exports = function renderPosts(posts, likes, pinnedPost, ipcRenderer, idk else { container = document.createElement('div'); container.classList.add('cards-container'); - container.id = containerid; + container.id = `${containerid}container`; a = new Map(); } - // setupWorker(); - // renderLikesFeed(posts.map(p => (p?.reply?.root?.uri || p.post.uri)?.trim()), likes, ipcRenderer); - - for (const post of posts) renderPostSingle(post, a, likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), ipcRenderer, container, idkey, containerid); + for (const post of posts) { + try { + renderPostSingle( + post, + a, + likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), + ipcRenderer, + container, + idkey, + containerid + ); + } + catch (err) { + console.error(err); + } + } const postids = Array.from(document.querySelectorAll(`[data-${idkey}]`)).map(o => o.dataset[`${idkey} `]); console.log(postids); @@ -614,6 +666,8 @@ module.exports = function renderPosts(posts, likes, pinnedPost, ipcRenderer, idk a = null; if (pinnedPost) { + console.log("PINNED", posts.find(p => p.post.uri === pinnedPost.uri)); + const pinnedcard = container.querySelector(`[data-${idkey}="${pinnedPost.uri}"]`); if (pinnedcard) { const pinnedLabel = document.createElement('div'); diff --git a/JS/replies.cjs b/JS/replies.cjs index 354f179..76136ac 100644 --- a/JS/replies.cjs +++ b/JS/replies.cjs @@ -86,12 +86,10 @@ function createPostPreview(post, isParent = false) { } -module.exports = function renderReplies(replyObj) { +module.exports = function renderReplies({ cursor, replies }) { const repliesContainer = document.querySelector('#replies'); repliesContainer.querySelector('.placeholder')?.remove(); - const { cursor, replies } = replyObj; - if (cursor) sessionStorage.setItem('repliescursor', cursor); else sessionStorage.removeItem('repliescursor'); diff --git a/JS/script.js b/JS/script.js index aae15fd..827eab7 100644 --- a/JS/script.js +++ b/JS/script.js @@ -1,12 +1,27 @@ -document.addEventListener('scroll', () => bottomscrolled(document.querySelector('#posts'))); -async function bottomscrolled(targetDiv) { +document.addEventListener('scroll', bottomscrolled); +async function bottomscrolled(e) { const scrollPosition = window.scrollY + window.innerHeight, pageHeight = document.documentElement.scrollHeight, atbottom = (scrollPosition >= pageHeight); if (!atbottom) return; - window.electronAPI.getnewposts(); + // get the current div + const targetDiv = document.querySelector('.content.active') + + switch (targetDiv.id) { + case 'posts': window.electronAPI.getnewposts(); + break; + case 'replies': window.electronAPI.getReplies() + break; + case 'likes': window.electronAPI.getnewlikes(); + break; + case 'media': + window.electronAPI.getnewmedia(); + break; + + default: console.log(`unknown scroll div ID ${targetDiv.id}`); + } } // function to toggle the active section @@ -24,16 +39,55 @@ function showSection(sectionId) { // show the selected section and activate the button document.getElementById(sectionId).classList.add('active'); document.getElementById(sectionId + 'Btn').classList.add('active'); + + createDropdown(); } + +function createDropdown() { + document.querySelector('.dropdown')?.remove(); + + const activeDiv = document.querySelector('.content.active'); + if (activeDiv.id === 'replies') return; + + const dropdown = document.createElement('div'); + dropdown.classList.add('dropdown'); + + const button = document.createElement('button'); + button.classList.add('dropdown-button'); + button.textContent = 'Layout: Compact'; + + const content = document.createElement('div'); + content.classList.add('dropdown-content'); + content.id = 'layoutDropdown'; + + const options = [ + { text: 'Large', value: 'large' }, + { text: 'Relaxed', value: 'relaxed' }, + { text: 'Compact', value: 'compact', selected: true } + ]; + + options.forEach(({ text, value, selected }) => { + const link = document.createElement('a'); + link.href = '#'; + link.dataset.value = value; + link.textContent = text; + if (selected) link.classList.add('selected'); + link.addEventListener('click', togglerelaxed); + content.appendChild(link); + }) + + dropdown.append(button, content); + activeDiv.prepend(dropdown); +} + + // function to handle layout selection function togglerelaxed(e) { e.preventDefault(); // prevent the default anchor behavior - const value = e.target.getAttribute('data-value'); - const container = document.querySelector(`#${sessionStorage.getItem('currenttab') || 'cardscontainer'}`); - - console.log(value) + const container = document.querySelector('.content.active [class*="cards-container"]'), + value = e.target.getAttribute('data-value'); if (!container) return console.warn('container not found!'); @@ -50,9 +104,4 @@ function togglerelaxed(e) { document.querySelector('.dropdown-button').textContent = `Layout: ${value.charAt(0).toUpperCase() + value.slice(1)}`; } -document.addEventListener('DOMContentLoaded', () => { - // event listener for dropdown items - document.querySelectorAll('#layoutDropdown a').forEach(item => { - item.addEventListener('click', togglerelaxed); - }); -}); \ No newline at end of file +document.addEventListener('DOMContentLoaded', createDropdown); \ No newline at end of file diff --git a/bluesky/main.js b/bluesky/main.js index e70c88a..2ebd130 100644 --- a/bluesky/main.js +++ b/bluesky/main.js @@ -133,6 +133,7 @@ async function getUserData(utag, allData = false) { output.likesCursor = likesCursor; output.posts = feed; output.postcursor = cursor; + output.media = await getMedia(utag); output.replies = await getReplies(utag); } @@ -161,8 +162,27 @@ export const getPosts = async (utag, cursor = undefined, likesCursor = undefined } +export const getLikes = async (utag, cursor = undefined) => { + try { + const did = await getDID(utag); + if (!did) return { err: 'DID not found!' }; + + const data = await agent.getActorLikes({ actor: did, cursor }); + + // sloppy fix, opened an issue at https://github.com/bluesky-social/atproto/issues/3087 + if (!data.data.feed.length) delete data.data.cursor; + return data.data; + } + catch (err) { + console.error(err); + return null; + } +} + + export const getReplies = async (utag, cursorInit, limit = 20) => { const did = await getDID(utag); + let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit }); let cursor = posts.data.cursor; const replies = posts.data.feed.filter(o => o.reply); @@ -181,15 +201,16 @@ export const getMedia = async (utag, cursorInit, limit = 20) => { const did = await getDID(utag); let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit }); let cursor = posts.data.cursor; - const replies = posts.data.feed.filter(o => o.reply); + const f = o => (o.post.embed?.images?.length || (o.post?.post?.embed?.$type === 'app.bsky.embed.video#view')), + data = posts.data.feed.filter(f); - while (cursor && replies.length < limit) { + while (cursor && data.length < limit) { posts = await agent.getAuthorFeed({ actor: did, limit, includePins: true, cursor }); - replies.push(...posts.data.feed.filter(o => o.reply)); + data.push(...posts.data.feed.filter(f)); cursor = posts.data.cursor; } - return { replies, cursor }; + return { data, cursor }; } @@ -225,10 +246,20 @@ export async function setupIPC() { clearCache(); }); + ipcMain.handle('getlikes', async (e, utag, cursor) => { + if (!cursor) return e.sender.send(404); + e.sender.send('likes', await getLikes(utag, cursor)); + }); + + ipcMain.handle('getmedia', async (e, utag, cursor) => { + if (!cursor) return e.sender.send(404); + e.sender.send('media', await getMedia(utag, cursor)); + }); + ipcMain.handle('get-connections', getConnections); ipcMain.handle('gethistory', (e, limit, offset) => e.sender.send('history', JSON.stringify(getHistory(limit, offset)))); - ipcMain.handle('getreplies', async (e, limit, offset) => e.sender.send('replies', JSON.stringify(await getReplies(limit, offset)))); + ipcMain.handle('getreplies', async (e, cursor, utag, limit = 20) => e.sender.send('replies', JSON.stringify(await getReplies(utag, cursor, limit)))); ipcMain.handle('getvideo', async (e, oldurl) => { const newURL = await convertAndServe(`${crypto.randomUUID()}.mp4`, oldurl); e.sender.send('video', oldurl, newURL); diff --git a/src/user_page_preload.cjs b/src/user_page_preload.cjs index c64071d..41b1067 100644 --- a/src/user_page_preload.cjs +++ b/src/user_page_preload.cjs @@ -2,7 +2,6 @@ const { contextBridge, ipcRenderer } = require('electron'); const { populateProfile, Profile } = require('../JS/Profile.cjs'); const renderPosts = require('../JS/posts.cjs'); const renderReplies = require('../JS/replies.cjs'); -const renderLikes = require('../JS/likes.cjs'); const { displayUploadStatus, renderCompose } = require('../JS/compose.cjs'); async function handleFileDialogue(e) { @@ -35,6 +34,15 @@ async function handleFileDialogue(e) { } } +const appendEOF = (divid) => { + if (document.getElementById(divid)?.querySelector('.eof')) return; + else { + const d = document.createElement('div'); + d.className = 'eof'; + d.innerHTML = '

Reached end of feed!

'; + document.getElementById(divid)?.appendChild(d); + } +} window.addEventListener('DOMContentLoaded', () => { @@ -52,7 +60,11 @@ window.addEventListener('DOMContentLoaded', () => { console.log(data); if (data.postcursor) sessionStorage.setItem('postcursor', data.postcursor); - if (data.likesCursor) sessionStorage.setItem('likescursor', data.likesCursor); + if (data.likesCursor) { + sessionStorage.setItem('likescursor', data.likesCursor); + sessionStorage.setItem('likesfeedcursor', data.likesCursor); + } + if (data.media.cursor) sessionStorage.setItem('mediacursor', data.media.cursor); document.querySelector('#loading')?.remove(); populateProfile(pObj); @@ -60,6 +72,7 @@ window.addEventListener('DOMContentLoaded', () => { if (data.posts) renderPosts(data.posts, data.likes || [], pObj?.pinnedPost, ipcRenderer); if (data.replies) renderReplies(data.replies); if (data.likes) renderPosts(data.likes, data.likes, null, ipcRenderer, 'bskylikeid', 'likes'); + if (data.media) renderPosts(data.media.data, data.likes, null, ipcRenderer, 'bskymediaid', 'media'); }); contextBridge.exposeInMainWorld('electronAPI', { @@ -68,29 +81,25 @@ window.addEventListener('DOMContentLoaded', () => { likescursor = sessionStorage.getItem('likescursor'); if (cursor) ipcRenderer.invoke('getposts', utag, cursor, likescursor); - else if (document.querySelector('#eof')) return; - else { - const d = document.createElement('div'); - d.id = 'eof'; - d.innerHTML = '

Reached end of feed!

'; - document.querySelector('#posts')?.appendChild(d); - } + else appendEOF('posts'); }, getnewlikes: () => { - return alert("TODO (check TODO.txt)"); - const cursor = sessionStorage.getItem('postcursor'); + const cursor = sessionStorage.getItem('likesfeedcursor'); - if (cursor) ipcRenderer.invoke('getposts', utag, cursor, likescursor); - else if (document.querySelector('#eof')) return; - else { - const d = document.createElement('div'); - d.id = 'eof'; - d.innerHTML = '

Reached end of feed!

'; - document.querySelector('#posts')?.appendChild(d); - } + if (cursor) ipcRenderer.invoke('getlikes', utag, cursor); + else appendEOF('likes'); }, getHistory: (cursor = undefined) => ipcRenderer.invoke('gethistory', cursor), - getReplies: (cursor = undefined) => ipcRenderer.invoke('getreplies', cursor), + getReplies: (utag) => { + const cursor = sessionStorage.getItem('repliescursor'); + if (cursor) ipcRenderer.invoke('getreplies', cursor, utag); + else appendEOF('replies'); + }, + getnewmedia: (utag) => { + const cursor = sessionStorage.getItem('mediacursor'); + if (cursor) ipcRenderer.invoke('getmedia', cursor, utag); + else appendEOF('media'); + }, getVideo: (src) => ipcRenderer.invoke('getvideo', src), }); @@ -112,6 +121,23 @@ window.addEventListener('DOMContentLoaded', () => { else sessionStorage.removeItem('postcursor'); }); + ipcRenderer.on('likes', (e, data) => { + // reset all videos because the cache was cleared + document.querySelectorAll('.post-card video').forEach(video => { + video.src = '../assets/video-loading.mp4'; + video.pause(); + video.currentTime = 0; + }); + + if (data.err) return alert(data.err); + + console.log(data); + renderPosts(data.feed, data.feed, null, ipcRenderer, 'bskylikeid', 'likes'); + + if (data.cursor) sessionStorage.setItem('likesfeedcursor', data.cursor); + else sessionStorage.removeItem('likesfeedcursor'); + }); + ipcRenderer.on('history', (e, data) => { const hist = JSON.parse(data); console.log(hist);