diff --git a/README.md b/README.md index d682cb0..3e20c38 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ -# reader.distributed.press -Read and follow federated microblogs. +# Social Reader +A P2P and offline-first ActivityPub client for reading and following microblogs on the Fediverse, avoiding dependency on always-online HTTP servers, allowing access to content anytime, anywhere. + +For more information, please visit [docs.distributed.press](https://docs.distributed.press/social-reader). \ No newline at end of file diff --git a/about.css b/about.css new file mode 100644 index 0000000..f9ec7f6 --- /dev/null +++ b/about.css @@ -0,0 +1,36 @@ +.about-container { + flex: 1; + max-width: 600px; + width: 100%; + margin: 0 20px; + margin-top: 10px; +} + +/* Apply general styles to all section elements within about-container */ +.about-container > section { + text-align: left; + color: var(--rdp-text-color); + width: 100%; + margin-bottom: 2rem; +} + +.about-info a, +.faq-section a { + color: var(--rdp-link-color); + text-decoration: underline; +} + +.about-info a:hover, +.faq-section a:hover { + text-decoration: none; +} + +.faq-section details { + margin-bottom: 1rem; + border-bottom: 1px solid var(--rdp-border-color); + padding-bottom: 1rem; +} + +.faq-section summary { + font-weight: bold; +} diff --git a/about.html b/about.html new file mode 100644 index 0000000..4b3f196 --- /dev/null +++ b/about.html @@ -0,0 +1,102 @@ + + + +About Reader + +
+ +
+
+

+ Social Reader is a P2P and offline ActivityPub client for reading and + following microblogs on the + Fediverse. +

+

+ Unlike traditional platforms, Social Reader does not index data on a + server. It empowers you to load public ActivityPub data directly, + turning your device into a personal indexer. This means + your content, your control. +

+

+ Social Reader natively supports content loading over P2P protocols such + as + ipfs:// and + hyper://. This innovation bypasses the need for always-online HTTP servers, + allowing you to access content anytime, anywhere—even offline. +

+

+ Social Reader is built on principles of low-tech; minimal dependencies, + vanilla JavaScript, unminified scripts, and IndexedDB for local data + storage. View and contribute to our open-source code on + GitHub. +

+
+ +
+

FAQs

+
+ How do I create an account on Social Reader? +

+ Social Reader is designed as a reading and following client, which + means you cannot create an account directly within the app. To + actively write and contribute to the Fediverse, you would need to + interact with the + Social Inbox + API. This can be done through platforms like + Sutty CMS or by forking and hosting + your own instance of + Staticpub + repository. +

+
+
+ + Why is Social Reader different from mainstream social platforms? + +

+ Social Reader eliminates the middleman, ensuring direct communication + with your audience without the interference of third-party algorithms. + This ad-free experience prioritizes user autonomy and engagement, + making it ideal for community leaders and organizations seeking + genuine reach and engagement. Unlike traditional social networks where + follower engagement often requires payment, Social Reader and the + broader Fediverse allow for genuine reach and engagement. +

+
+
+ I found a bug. Where do I report it? +

+ If you encounter any issues or have feedback, please file a report on + our + GitHub issues + page. We appreciate your input as it helps us improve Social Reader + for everyone. +

+
+
+
+
+ +
+
+ + + diff --git a/actor-mini-profile.css b/actor-mini-profile.css new file mode 100644 index 0000000..5579e0c --- /dev/null +++ b/actor-mini-profile.css @@ -0,0 +1,32 @@ +.mini-profile { + display: flex; + align-items: center; + text-align: left; + cursor: pointer; + background: none; + border: none; + padding: 0; + margin-bottom: 4px; + color: inherit; + font: inherit; +} + +.profile-mini-icon { + width: 28px; + height: 28px; + border-radius: 50%; + background-color: #000000; + margin-right: 6px; +} + +.profile-mini-name { + color: var(--rdp-text-color); +} + +.profile-followed-date { + text-align: center; + font-size: 0.875rem; + color: var(--rdp-details-color); + margin-left: 34px; + margin-bottom: 6px; +} diff --git a/actor-mini-profile.js b/actor-mini-profile.js new file mode 100644 index 0000000..923cf66 --- /dev/null +++ b/actor-mini-profile.js @@ -0,0 +1,80 @@ +import { db } from './dbInstance.js' + +class ActorMiniProfile extends HTMLElement { + static get observedAttributes () { + return ['url'] + } + + constructor () { + super() + this.url = '' + } + + connectedCallback () { + this.url = this.getAttribute('url') + this.fetchAndRenderActorInfo(this.url) + } + + attributeChangedCallback (name, oldValue, newValue) { + if (name === 'url' && newValue !== oldValue) { + this.url = newValue + this.fetchAndRenderActorInfo(this.url) + } + } + + async fetchAndRenderActorInfo (url) { + try { + const actorInfo = await db.getActor(url) + if (actorInfo) { + this.renderActorInfo(actorInfo) + } + } catch (error) { + console.error('Error fetching actor info:', error) + } + } + + renderActorInfo (actorInfo) { + // Clear existing content + this.innerHTML = '' + + // Container for the icon and name, which should be a button for clickable actions + const clickableContainer = document.createElement('button') + clickableContainer.className = 'mini-profile' + clickableContainer.setAttribute('type', 'button') + + let iconUrl = './assets/profile.png' + if (actorInfo.icon) { + iconUrl = actorInfo.icon.url || (Array.isArray(actorInfo.icon) ? actorInfo.icon[0].url : iconUrl) + } + + // Actor icon + const img = document.createElement('img') + img.className = 'profile-mini-icon' + img.src = iconUrl + img.alt = actorInfo.name ? actorInfo.name : 'Actor icon' + clickableContainer.appendChild(img) + + // Actor name + if (actorInfo.name) { + const pName = document.createElement('div') + pName.classList.add('profile-mini-name') + pName.textContent = actorInfo.name + clickableContainer.appendChild(pName) + } + + // Append the clickable container + this.appendChild(clickableContainer) + + // Add click event to the clickable container for navigation + clickableContainer.addEventListener('click', () => { + window.location.href = `/profile.html?actor=${encodeURIComponent(this.url)}` + }) + + const pDate = document.createElement('span') + pDate.classList.add('profile-followed-date') + pDate.textContent = ` - Followed At: ${this.getAttribute('followed-at')}` + this.appendChild(pDate) + } +} + +customElements.define('actor-mini-profile', ActorMiniProfile) diff --git a/actor-profile.css b/actor-profile.css new file mode 100644 index 0000000..e50fbc9 --- /dev/null +++ b/actor-profile.css @@ -0,0 +1,101 @@ +.profile { + margin-top: 20px; +} + +.profile-container { + text-align: center; +} + +.distributed-post-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.profile-icon { + width: 50px; + height: 50px; + border-radius: 50%; + background-color: #000000; + margin-right: 8px; + margin-bottom: 8px; +} + +.profile-details { + display: flex; + flex-direction: column; +} + +.profile-name { + color: var(--rdp-text-color); + font-weight: bold; +} + +.profile-username { + color: var(--rdp-text-color); + margin-top: 1px; +} + +.profile-summary { + color: var(--rdp-details-color); + width: 500px; + margin-left: auto; + margin-right: auto; + margin-top: 8px; + margin-bottom: 10px; + overflow-wrap: break-word; +} + +follow-button { + appearance: none; + border: 1px solid var(--rdp-border-color); + border-radius: 4px; + box-shadow: rgba(27, 31, 35, 0.1) 0 1px 0; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: inherit; + font-weight: 600; + line-height: 20px; + padding: 4px 16px; + position: relative; + text-align: center; + text-decoration: none; + touch-action: manipulation; + vertical-align: middle; + white-space: nowrap; +} + +follow-button[state="follow"], +follow-button[state="unfollow"] { + color: #fff; +} + +follow-button[state="follow"] { + background-color: #3b82f6; +} +follow-button[state="follow"]:hover { + background-color: #2563eb; +} + +follow-button[state="unfollow"] { + background-color: #ef4444; +} +follow-button[state="unfollow"]:hover { + background-color: #dc2626; +} + +.actor-profile { + flex: 1; + max-width: 600px; + width: 100%; + margin: 0 20px; +} + +@media (max-width: 768px) { + .profile-summary { + width: 100%; + } +} diff --git a/actor-profile.js b/actor-profile.js new file mode 100644 index 0000000..d04a676 --- /dev/null +++ b/actor-profile.js @@ -0,0 +1,159 @@ +import { db } from './dbInstance.js' + +class ActorProfile extends HTMLElement { + static get observedAttributes () { + return ['url'] + } + + constructor () { + super() + this.url = '' + } + + connectedCallback () { + this.url = this.getAttribute('url') + this.fetchAndRenderActorProfile(this.url) + } + + async fetchAndRenderActorProfile (url) { + try { + const actorInfo = await db.getActor(url) + if (actorInfo) { + this.renderActorProfile(actorInfo) + } else { + this.renderError('Actor information not found') + } + } catch (error) { + console.error('Error fetching actor info:', error) + this.renderError('An error occurred while fetching actor information.') + } + } + + renderActorProfile (actorInfo) { + // Clear existing content + this.innerHTML = '' + + const profileContainer = document.createElement('div') + profileContainer.classList.add('profile') + + // Create a container for the actor icon and name, to center them + const actorContainer = document.createElement('div') + actorContainer.classList.add('profile-container') + + // Handle both single icon object and array of icons + let iconUrl = './assets/profile.png' // Default profile image path + if (actorInfo.icon) { + if (Array.isArray(actorInfo.icon) && actorInfo.icon.length > 0) { + iconUrl = actorInfo.icon[0].url + } else if (actorInfo.icon.url) { + iconUrl = actorInfo.icon.url + } + } + + const img = document.createElement('img') + img.classList.add('profile-icon') + img.src = iconUrl + img.alt = actorInfo.name ? actorInfo.name : 'Actor icon' + actorContainer.appendChild(img) // Append to the actor container + + if (actorInfo.name) { + const pName = document.createElement('div') + pName.classList.add('profile-name') + pName.textContent = actorInfo.name + actorContainer.appendChild(pName) // Append to the actor container + } + + if (actorInfo.preferredUsername) { + const pUserName = document.createElement('a') + pUserName.classList.add('profile-username') + pUserName.href = db.getObjectPage(actorInfo) + pUserName.textContent = `@${actorInfo.preferredUsername}` + actorContainer.appendChild(pUserName) // Append to the actor container + } + + if (actorInfo.summary) { + const pUserSummary = document.createElement('div') + pUserSummary.classList.add('profile-summary') + pUserSummary.textContent = `${actorInfo.summary}` + actorContainer.appendChild(pUserSummary) // Append to the actor container + } + + // Instead of creating a button, create a FollowButton component + const followButton = document.createElement('follow-button') + followButton.setAttribute('url', this.url) + actorContainer.appendChild(followButton) + + // Append the actor container to the profile container + profileContainer.appendChild(actorContainer) + + // Create the distributed-outbox component and append it to the profile container + const distributedOutbox = document.createElement('distributed-outbox') + profileContainer.appendChild(distributedOutbox) + + // Append the profile container to the main component + this.appendChild(profileContainer) + + // Update distributed-outbox URL based on fetched actorInfo + distributedOutbox.setAttribute( + 'url', + actorInfo.outbox + ) + this.dispatchEvent(new CustomEvent('outboxUpdated', { bubbles: true })) + } + + renderError (message) { + this.innerHTML = '' // Clear existing content + const errorComponent = document.createElement('error-message') + errorComponent.setAttribute('message', message) + this.appendChild(errorComponent) + } +} + +customElements.define('actor-profile', ActorProfile) + +class FollowButton extends HTMLElement { + static get observedAttributes () { + return ['url'] + } + + constructor () { + super() + this.url = this.getAttribute('url') || '' + this.state = 'unknown' + } + + connectedCallback () { + this.updateState() + this.render() + this.addEventListener('click', this.toggleFollowState.bind(this)) + } + + attributeChangedCallback (name, oldValue, newValue) { + if (name === 'url' && newValue !== oldValue) { + this.url = newValue + this.updateState() + } + } + + async updateState () { + const isFollowed = await db.isActorFollowed(this.url) + this.state = isFollowed ? 'unfollow' : 'follow' + this.render() + } + + async toggleFollowState () { + if (this.state === 'follow') { + await db.followActor(this.url) + } else if (this.state === 'unfollow') { + await db.unfollowActor(this.url) + } + this.updateState() + } + + render () { + this.textContent = this.state === 'follow' ? 'Follow' : 'Unfollow' + this.setAttribute('state', this.state) + } +} + +customElements.define('follow-button', FollowButton) diff --git a/assets/profile.png b/assets/profile.png new file mode 100644 index 0000000..6dfa7b6 Binary files /dev/null and b/assets/profile.png differ diff --git a/common.css b/common.css new file mode 100644 index 0000000..2dbeca8 --- /dev/null +++ b/common.css @@ -0,0 +1,83 @@ +html { + background: var(--bg-color); + font-family: var(--rdp-font); +} + +/* Main styles */ +img, +video { + max-width: 100%; +} + +.container { + display: flex; + justify-content: space-between; + max-width: 1200px; + width: 100%; + margin-top: 20px; + position: relative; +} + +.load-more-btn-container { + text-align: center; + margin-top: 15px; + margin-bottom: 15px; +} + +.load-more-btn { + color: #000; + background-color: #fff; + appearance: none; + border: 1px solid var(--rdp-border-color); + border-radius: 4px; + box-shadow: rgba(27, 31, 35, 0.1) 0 1px 0; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + font-family: inherit; + font-size: inherit; + font-weight: 400; + line-height: 20px; + padding: 4px 12px; + position: relative; + text-align: center; + text-decoration: none; + touch-action: manipulation; + vertical-align: middle; + white-space: nowrap; +} + +.load-more-btn:hover { + background-color: #f3f4f6; +} + +/* Empty right column for balance */ +.right-column { + flex: 0 0 200px; +} + +@media screen and (max-width: 1280px) { + .right-column { + flex: 0 0 100px; + } +} + +@media screen and (max-width: 768px) { + .container { + flex-direction: column; + align-items: center; + } + + .main-content { + width: 80%; + max-width: 100%; + margin-top: 175px; + } + .actor-profile{ + width: 100%; + } + + sidebar-nav { + width: 100%; + } +} diff --git a/db.js b/db.js index b1e1db2..f09b0c6 100644 --- a/db.js +++ b/db.js @@ -1,9 +1,12 @@ +/* globals DOMParser */ import { openDB } from './dependencies/idb/index.js' export const DEFAULT_DB = 'default' export const ACTORS_STORE = 'actors' export const NOTES_STORE = 'notes' export const ACTIVITIES_STORE = 'activities' +export const FOLLOWED_ACTORS_STORE = 'followedActors' +export const DEFAULT_LIMIT = 32 export const ID_FIELD = 'id' export const URL_FIELD = 'url' @@ -25,67 +28,118 @@ export const TYPE_UPDATE = 'Update' export const TYPE_NOTE = 'Note' export const TYPE_DELETE = 'Delete' +export const HYPER_PREFIX = 'hyper://' +export const IPNS_PREFIX = 'ipns://' + +const ACCEPT_HEADER = +'application/activity+json, application/ld+json, application/json, text/html' + // TODO: When ingesting notes and actors, wrap any dates in `new Date()` // TODO: When ingesting notes add a "tag_names" field which is just the names of the tag // TODO: When ingesting notes, also load their replies -export class ActivityPubDB { +export function isP2P (url) { + return url.startsWith(HYPER_PREFIX) || url.startsWith(IPNS_PREFIX) +} + +export class ActivityPubDB extends EventTarget { constructor (db, fetch = globalThis.fetch) { + super() this.db = db this.fetch = fetch } static async load (name = DEFAULT_DB, fetch = globalThis.fetch) { - const db = await openDB(name, 1, { + const db = await openDB(name, 2, { upgrade }) return new ActivityPubDB(db, fetch) } + resolveURL (url) { + // TODO: Check if mention + return this.#get(url) + } + + getObjectPage (data) { + if (typeof data === 'string') return data + const { url, id } = data + + if (!url) return id + if (typeof url === 'string') return url + if (Array.isArray(url)) { + const firstLink = url.find((item) => (typeof item === 'string') || item.href) + if (firstLink) return firstLink.href || firstLink + } else if (url.href) { + return url.href + } + return id + } + + #fetch (...args) { + const { fetch } = this + return fetch(...args) + } + + #gateWayFetch (url, options = {}) { + let gatewayUrl = url + // TODO: Don't hardcode the gateway + if (url.startsWith(HYPER_PREFIX)) { + gatewayUrl = url.replace(HYPER_PREFIX, 'https://hyper.hypha.coop/hyper/') + } else if (url.startsWith(IPNS_PREFIX)) { + gatewayUrl = url.replace(IPNS_PREFIX, 'https://ipfs.hypha.coop/ipns/') + } + + return this.#fetch(gatewayUrl, options) + } + + #proxiedFetch (url, options = {}) { + const proxiedURL = 'https://corsproxy.io/?' + encodeURIComponent(url) + return this.#fetch(proxiedURL, options) + } + async #get (url) { if (url && typeof url === 'object') { return url } - let response // Try fetching directly for all URLs (including P2P URLs) // TODO: Signed fetch try { - response = await this.fetch.call(globalThis, url, { + response = await this.#fetch(url, { headers: { - Accept: 'application/json' + Accept: ACCEPT_HEADER } }) } catch (error) { - console.error('P2P loading failed, trying HTTP gateway:', error) - } - - // If direct fetch was not successful, attempt fetching from a gateway for P2P protocols - if (!response || !response.ok) { - let gatewayUrl = url - - if (url.startsWith('hyper://')) { - gatewayUrl = url.replace('hyper://', 'https://hyper.hypha.coop/hyper/') - } else if (url.startsWith('ipns://')) { - gatewayUrl = url.replace('ipns://', 'https://ipfs.hypha.coop/ipns/') - } - - try { - response = await this.fetch.call(globalThis, gatewayUrl, { + if (isP2P(url)) { + // Maybe the browser can't load p2p URLs + response = await this.#gateWayFetch(url, { headers: { - Accept: 'application/json' + Accept: ACCEPT_HEADER + } + }) + } else { + // Try the proxy, maybe it's cors? + response = await this.#proxiedFetch(url, { + headers: { + Accept: ACCEPT_HEADER } }) - } catch (error) { - console.error('Fetching from gateway failed:', error) - throw new Error(`Failed to fetch ${url} from gateway`) } } if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`) + throw new Error(`HTTP error! Status: ${response.status}.\n${url}\n${await response.text()}`) + } + if (isResponseHTML(response)) { + const jsonLdUrl = await getResponseLink(response) + if (jsonLdUrl) return this.#get(jsonLdUrl) + // No JSON-LD link found in HTML + throw new Error('No JSON-LD link found in the response') } + return await response.json() } @@ -109,7 +163,9 @@ export class ActivityPubDB { async getNote (url) { try { - return this.db.get(NOTES_STORE, url) + const note = await this.db.get(NOTES_STORE, url) + if (!note) throw new Error('Not loaded') + return note } catch { const note = await this.#get(url) await this.ingestNote(note) @@ -117,32 +173,73 @@ export class ActivityPubDB { } } - async searchNotes (criteria) { + async getActivity (url) { + try { + return this.db.get(ACTIVITIES_STORE, url) + } catch { + const activity = await this.#get(url) + await this.ingestActivity(activity) + return activity + } + } + + async * searchActivities (actor, { limit = DEFAULT_LIMIT, skip = 0 } = {}) { + const indexName = ACTOR_FIELD + ', published' + const tx = this.db.transaction(ACTIVITIES_STORE, 'read') + const index = tx.store.index(indexName) + + let count = 0 + + for await (const cursor of index.iterate(actor)) { + if (count === 0) { + cursor.advance(skip) + } + yield cursor.value + count++ + if (count >= limit) break + } + + await tx.done() + } + + async * searchNotes ({ attributedTo } = {}, { skip = 0, limit = DEFAULT_LIMIT, sort = -1 } = {}) { const tx = this.db.transaction(NOTES_STORE, 'readonly') - const notes = [] - const index = criteria.attributedTo - ? tx.store.index('attributedTo') - : tx.store - - // Use async iteration to iterate over the store or index - for await (const cursor of index.iterate(criteria.attributedTo)) { - notes.push(cursor.value) + let count = 0 + const direction = sort > 0 ? 'next' : 'prev' // 'prev' for descending order + let cursor = null + + const indexName = attributedTo ? ATTRIBUTED_TO_FIELD + ', published' : PUBLISHED_FIELD + + const index = tx.store.index(indexName) + + if (attributedTo) { + cursor = await index.openCursor([attributedTo], direction) + } else { + cursor = await index.openCursor(null, direction) } - // Implement additional filtering logic if needed based on other criteria (like time ranges or tags) - // For example: - // notes.filter(note => note.published >= criteria.startDate && note.published <= criteria.endDate); - return notes.sort((a, b) => b.published - a.published) // Sort by published date in descending order + // Skip the required entries + if (skip) await cursor.advance(skip) + + // Collect the required limit of entries + while (cursor) { + if (count >= limit) break + count++ + yield cursor.value + cursor = await cursor.continue() + } + + await tx.done } - async ingestActor (url) { + async ingestActor (url, isInitial = false) { console.log(`Starting ingestion for actor from URL: ${url}`) const actor = await this.getActor(url) console.log('Actor received:', actor) // If actor has an 'outbox', ingest it as a collection if (actor.outbox) { - await this.ingestActivityCollection(actor.outbox, actor.id) + await this.ingestActivityCollection(actor.outbox, actor.id, isInitial) } else { console.error(`No outbox found for actor at URL ${url}`) } @@ -151,33 +248,85 @@ export class ActivityPubDB { // e.g., if (actor.followers) { ... } } - async ingestActivityCollection (collectionOrUrl, actorId) { + async ingestActivityCollection (collectionOrUrl, actorId, isInitial = false) { console.log( `Fetching collection for actor ID ${actorId}:`, collectionOrUrl ) + const sort = isInitial ? -1 : 1 - const collection = await this.#get(collectionOrUrl) + const cursor = this.iterateCollection(collectionOrUrl, { + limit: Infinity, + sort + }) - for await (const activity of this.iterateCollection(collection)) { - await this.ingestActivity(activity, actorId) + for await (const activity of cursor) { + // Assume newest items will be first + const wasNew = await this.ingestActivity(activity, actorId) + if (!wasNew) { + console.log('Caught up with', actorId || collectionOrUrl) + break + } } } - async * iterateCollection (collection) { - // TODO: handle pagination here, if collection contains a 'next' or 'first' link. - const items = collection.orderedItems || collection.items + async * iterateCollection (collectionOrUrl, { skip = 0, limit = DEFAULT_LIMIT, sort = 1 } = {}) { + const collection = await this.#get(collectionOrUrl) + + let items = collection.orderedItems || collection.items || [] + let next, prev - if (!items) { - console.error('No items found in collection:', collection) - return // Exit if no items to iterate over + if (sort === -1) { + items = items.reverse() + prev = collection.last // Start from the last page if sorting in descending order + } else { + next = collection.first // Start from the first page if sorting in ascending order } + let toSkip = skip + let count = 0 + + if (items) { + for await (const item of this.#getAll(items)) { + if (toSkip > 0) { + toSkip-- + } else { + yield item + count++ + if (count >= limit) return + } + } + } + + // Iterate through pages in the specified order + while (sort === -1 ? prev : next) { + const page = await this.#get(sort === -1 ? prev : next) + next = page.next + prev = page.prev + items = page.orderedItems || page.items + + if (sort === -1) { + items = items.reverse() + } + + for await (const item of this.#getAll(items)) { + if (toSkip > 0) { + toSkip-- + } else { + yield item + count++ + if (count >= limit) return + } + } + } + } + + async * #getAll (items) { for (const itemOrUrl of items) { - const activity = await this.#get(itemOrUrl) - - if (activity) { - yield activity + const item = await this.#get(itemOrUrl) + + if (item) { + yield item } } } @@ -197,6 +346,9 @@ export class ActivityPubDB { } } + const existing = await this.db.get(ACTIVITIES_STORE, activity.id) + if (existing) return false + // Convert the published date to a Date object activity.published = new Date(activity.published) @@ -204,48 +356,133 @@ export class ActivityPubDB { console.log('Ingesting activity:', activity) await this.db.put(ACTIVITIES_STORE, activity) - if (activity.type !== TYPE_CREATE || activity.type !== TYPE_UPDATE) { - let note - if (typeof activity.object === 'string') { - note = await this.#get(activity.object) - } else { - note = activity.object - } + if (activity.type === TYPE_CREATE || activity.type === TYPE_UPDATE) { + const note = await this.#get(activity.object) if (note.type === TYPE_NOTE) { - note.id = activity.id; // Use the Create activity's ID for the note ID - console.log("Ingesting note:", note); - await this.ingestNote(note); + console.log('Ingesting note:', note) + await this.ingestNote(note) } } else if (activity.type === TYPE_DELETE) { // Handle 'Delete' activity type await this.deleteNote(activity.object) } + + return true } - async ingestNote(note) { + async ingestNote (note) { + console.log('Ingesting note', note) // Convert needed fields to date - note.published = new Date(note.published); + note.published = new Date(note.published) // Add tag_names field - note.tag_names = (note.tags || []).map(({ name }) => name); + note.tag_names = (note.tags || []).map(({ name }) => name) // Try to retrieve an existing note from the database - const existingNote = await this.db.get(NOTES_STORE, note.id); + const existingNote = await this.db.get(NOTES_STORE, note.id) + console.log(existingNote) // If there's an existing note and the incoming note is newer, update it if (existingNote && new Date(note.published) > new Date(existingNote.published)) { - console.log(`Updating note with newer version: ${note.id}`); - await this.db.put(NOTES_STORE, note); + console.log(`Updating note with newer version: ${note.id}`) + await this.db.put(NOTES_STORE, note) } else if (!existingNote) { // If no existing note, just add the new note - console.log(`Adding new note: ${note.id}`); - await this.db.put(NOTES_STORE, note); + console.log(`Adding new note: ${note.id}`) + await this.db.put(NOTES_STORE, note) } // If the existing note is newer, do not replace it // TODO: Loop through replies - } + } async deleteNote (url) { // delete note using the url as the `id` from the notes store this.db.delete(NOTES_STORE, url) } + + // Method to follow an actor + async followActor (url) { + const followedAt = new Date() + await this.db.put(FOLLOWED_ACTORS_STORE, { url, followedAt }) + + await this.ingestActor(url, true) + console.log(`Followed actor: ${url} at ${followedAt}`) + this.dispatchEvent(new CustomEvent('actorFollowed', { detail: { url, followedAt } })) + } + + // Method to unfollow an actor + async unfollowActor (url) { + await this.db.delete(FOLLOWED_ACTORS_STORE, url) + await this.purgeActor(url) + console.log(`Unfollowed and purged actor: ${url}`) + this.dispatchEvent(new CustomEvent('actorUnfollowed', { detail: { url } })) + } + + async purgeActor (url) { + // First, remove the actor from the ACTORS_STORE + const actor = await this.getActor(url) + if (actor) { + await this.db.delete(ACTORS_STORE, actor.id) + console.log(`Removed actor: ${url}`) + } + + // Remove all activities related to this actor from the ACTIVITIES_STORE using async iteration + const activitiesTx = this.db.transaction(ACTIVITIES_STORE, 'readwrite') + const activitiesStore = activitiesTx.objectStore(ACTIVITIES_STORE) + const activitiesIndex = activitiesStore.index(ACTOR_FIELD) + + for await (const cursor of activitiesIndex.iterate(actor.id)) { + await activitiesStore.delete(cursor.primaryKey) + } + + await activitiesTx.done + console.log(`Removed all activities related to actor: ${url}`) + + // Additionally, remove notes associated with the actor's activities using async iteration + const notesTx = this.db.transaction(NOTES_STORE, 'readwrite') + const notesStore = notesTx.objectStore(NOTES_STORE) + const notesIndex = notesStore.index(ATTRIBUTED_TO_FIELD) + + for await (const cursor of notesIndex.iterate(actor.id)) { + await notesStore.delete(cursor.primaryKey) + } + + await notesTx.done + console.log(`Removed all notes related to actor: ${url}`) + } + + // Method to retrieve all followed actors + async getFollowedActors () { + const tx = this.db.transaction(FOLLOWED_ACTORS_STORE, 'readonly') + const store = tx.objectStore(FOLLOWED_ACTORS_STORE) + const followedActors = [] + for await (const cursor of store) { + followedActors.push(cursor.value) + } + return followedActors + } + + // Method to check if an actor is followed + async isActorFollowed (url) { + try { + const record = await this.db.get(FOLLOWED_ACTORS_STORE, url) + return !!record // Convert the record to a boolean indicating if the actor is followed + } catch (error) { + console.error(`Error checking if actor is followed: ${url}`, error) + return false // Assume not followed if there's an error + } + } + + async hasFollowedActors () { + const followedActors = await this.getFollowedActors() + return followedActors.length > 0 + } + + async setTheme (themeName) { + await this.db.put('settings', { key: 'theme', value: themeName }) + } + + async getTheme () { + const themeSetting = await this.db.get('settings', 'theme') + return themeSetting ? themeSetting.value : null + } } function upgrade (db) { @@ -258,11 +495,16 @@ function upgrade (db) { actors.createIndex(UPDATED_FIELD, UPDATED_FIELD) actors.createIndex(URL_FIELD, URL_FIELD) + db.createObjectStore(FOLLOWED_ACTORS_STORE, { + keyPath: 'url' + }) + const notes = db.createObjectStore(NOTES_STORE, { keyPath: 'id', autoIncrement: false }) - + notes.createIndex(ATTRIBUTED_TO_FIELD, ATTRIBUTED_TO_FIELD, { unique: false }) + notes.createIndex(PUBLISHED_FIELD, PUBLISHED_FIELD, { unique: false }) addRegularIndex(notes, TO_FIELD) addRegularIndex(notes, URL_FIELD) addRegularIndex(notes, TAG_NAMES_FIELD, { multiEntry: true }) @@ -275,8 +517,10 @@ function upgrade (db) { keyPath: 'id', autoIncrement: false }) + activities.createIndex(ACTOR_FIELD, ACTOR_FIELD) addSortedIndex(activities, ACTOR_FIELD) addSortedIndex(activities, TO_FIELD) + addRegularIndex(activities, PUBLISHED_FIELD) function addRegularIndex (store, field, options = {}) { store.createIndex(field, field, options) @@ -284,4 +528,44 @@ function upgrade (db) { function addSortedIndex (store, field, options = {}) { store.createIndex(field + ', published', [field, PUBLISHED_FIELD], options) } + + db.createObjectStore('settings', { keyPath: 'key' }) +} + +// TODO: prefer p2p alternate links when possible +async function getResponseLink (response) { +// For HTML responses, look for the link in the HTTP headers + const linkHeader = response.headers.get('Link') + if (linkHeader) { + const matches = linkHeader.match( + /<([^>]+)>;\s*rel="alternate";\s*type="application\/ld\+json"/ + ) + if (matches && matches[1]) { + // Found JSON-LD link in headers, fetch that URL + return matches[1] + } + } + // If no link header or alternate JSON-LD link is found, or response is HTML without JSON-LD link, process as HTML + const htmlContent = await response.text() + const jsonLdUrl = await parsePostHtml(htmlContent) + + return jsonLdUrl +} + +async function parsePostHtml (htmlContent) { + const parser = new DOMParser() + const doc = parser.parseFromString(htmlContent, 'text/html') + const alternateLinks = doc.querySelectorAll('link[rel="alternate"]') + console.log(...alternateLinks) + for (const link of alternateLinks) { + if (!link.type) continue + if (link.type.includes('application/ld+json') || link.type.includes('application/activity+json')) { + return link.href + } + } + return null +} + +function isResponseHTML (response) { + return response.headers.get('content-type').includes('text/html') } diff --git a/dbInstance.js b/dbInstance.js index 88ff977..25a7914 100644 --- a/dbInstance.js +++ b/dbInstance.js @@ -1,3 +1,3 @@ -import { ActivityPubDB } from "./db.js"; +import { ActivityPubDB } from './db.js' -export const db = await ActivityPubDB.load(); +export const db = await ActivityPubDB.load() diff --git a/dependencies/dompurify/purify.js b/dependencies/dompurify/purify.js index da0df8a..2319962 100644 --- a/dependencies/dompurify/purify.js +++ b/dependencies/dompurify/purify.js @@ -1,10 +1,13 @@ /*! @license DOMPurify 3.0.9 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.0.9/LICENSE */ (function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.DOMPurify = factory()); -})(this, (function () { 'use strict'; + typeof exports === 'object' && typeof module !== 'undefined' + ? module.exports = factory() + : typeof define === 'function' && define.amd + ? define(factory) + : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.DOMPurify = factory()) +})(this, function () { + 'use strict' const { entries, @@ -12,48 +15,48 @@ isFrozen, getPrototypeOf, getOwnPropertyDescriptor - } = Object; + } = Object let { freeze, seal, create - } = Object; // eslint-disable-line import/no-mutable-exports + } = Object // eslint-disable-line import/no-mutable-exports let { apply, construct - } = typeof Reflect !== 'undefined' && Reflect; + } = typeof Reflect !== 'undefined' && Reflect if (!freeze) { - freeze = function freeze(x) { - return x; - }; + freeze = function freeze (x) { + return x + } } if (!seal) { - seal = function seal(x) { - return x; - }; + seal = function seal (x) { + return x + } } if (!apply) { - apply = function apply(fun, thisValue, args) { - return fun.apply(thisValue, args); - }; + apply = function apply (fun, thisValue, args) { + return fun.apply(thisValue, args) + } } if (!construct) { - construct = function construct(Func, args) { - return new Func(...args); - }; + construct = function construct (Func, args) { + return new Func(...args) + } } - const arrayForEach = unapply(Array.prototype.forEach); - const arrayPop = unapply(Array.prototype.pop); - const arrayPush = unapply(Array.prototype.push); - const stringToLowerCase = unapply(String.prototype.toLowerCase); - const stringToString = unapply(String.prototype.toString); - const stringMatch = unapply(String.prototype.match); - const stringReplace = unapply(String.prototype.replace); - const stringIndexOf = unapply(String.prototype.indexOf); - const stringTrim = unapply(String.prototype.trim); - const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); - const regExpTest = unapply(RegExp.prototype.test); - const typeErrorCreate = unconstruct(TypeError); + const arrayForEach = unapply(Array.prototype.forEach) + const arrayPop = unapply(Array.prototype.pop) + const arrayPush = unapply(Array.prototype.push) + const stringToLowerCase = unapply(String.prototype.toLowerCase) + const stringToString = unapply(String.prototype.toString) + const stringMatch = unapply(String.prototype.match) + const stringReplace = unapply(String.prototype.replace) + const stringIndexOf = unapply(String.prototype.indexOf) + const stringTrim = unapply(String.prototype.trim) + const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty) + const regExpTest = unapply(RegExp.prototype.test) + const typeErrorCreate = unconstruct(TypeError) /** * Creates a new function that calls the given function with a specified thisArg and arguments. @@ -61,13 +64,13 @@ * @param {Function} func - The function to be wrapped and called. * @returns {Function} A new function that calls the given function with a specified thisArg and arguments. */ - function unapply(func) { + function unapply (func) { return function (thisArg) { for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - args[_key - 1] = arguments[_key]; + args[_key - 1] = arguments[_key] } - return apply(func, thisArg, args); - }; + return apply(func, thisArg, args) + } } /** @@ -76,13 +79,13 @@ * @param {Function} func - The constructor function to be wrapped and called. * @returns {Function} A new function that constructs an instance of the given constructor function with the provided arguments. */ - function unconstruct(func) { + function unconstruct (func) { return function () { for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { - args[_key2] = arguments[_key2]; + args[_key2] = arguments[_key2] } - return construct(func, args); - }; + return construct(func, args) + } } /** @@ -93,30 +96,30 @@ * @param {Function} transformCaseFunc - An optional function to transform the case of each element before adding to the set. * @returns {Object} The modified set with added elements. */ - function addToSet(set, array) { - let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; + function addToSet (set, array) { + const transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase if (setPrototypeOf) { // Make 'in' and truthy checks like Boolean(set.constructor) // independent of any properties defined on Object.prototype. // Prevent prototype setters from intercepting set as a this value. - setPrototypeOf(set, null); + setPrototypeOf(set, null) } - let l = array.length; + let l = array.length while (l--) { - let element = array[l]; + let element = array[l] if (typeof element === 'string') { - const lcElement = transformCaseFunc(element); + const lcElement = transformCaseFunc(element) if (lcElement !== element) { // Config presets (e.g. tags.js, attrs.js) are immutable. if (!isFrozen(array)) { - array[l] = lcElement; + array[l] = lcElement } - element = lcElement; + element = lcElement } } - set[element] = true; + set[element] = true } - return set; + return set } /** @@ -125,14 +128,14 @@ * @param {Array} array - The array to be cleaned. * @returns {Array} The cleaned version of the array */ - function cleanArray(array) { + function cleanArray (array) { for (let index = 0; index < array.length; index++) { - const isPropertyExist = objectHasOwnProperty(array, index); + const isPropertyExist = objectHasOwnProperty(array, index) if (!isPropertyExist) { - array[index] = null; + array[index] = null } } - return array; + return array } /** @@ -141,21 +144,21 @@ * @param {Object} object - The object to be cloned. * @returns {Object} A new object that copies the original. */ - function clone(object) { - const newObject = create(null); + function clone (object) { + const newObject = create(null) for (const [property, value] of entries(object)) { - const isPropertyExist = objectHasOwnProperty(object, property); + const isPropertyExist = objectHasOwnProperty(object, property) if (isPropertyExist) { if (Array.isArray(value)) { - newObject[property] = cleanArray(value); + newObject[property] = cleanArray(value) } else if (value && typeof value === 'object' && value.constructor === Object) { - newObject[property] = clone(value); + newObject[property] = clone(value) } else { - newObject[property] = value; + newObject[property] = value } } } - return newObject; + return newObject } /** @@ -165,79 +168,79 @@ * @param {String} prop - The property name for which to find the getter function. * @returns {Function} The getter function found in the prototype chain or a fallback function. */ - function lookupGetter(object, prop) { + function lookupGetter (object, prop) { while (object !== null) { - const desc = getOwnPropertyDescriptor(object, prop); + const desc = getOwnPropertyDescriptor(object, prop) if (desc) { if (desc.get) { - return unapply(desc.get); + return unapply(desc.get) } if (typeof desc.value === 'function') { - return unapply(desc.value); + return unapply(desc.value) } } - object = getPrototypeOf(object); + object = getPrototypeOf(object) } - function fallbackValue() { - return null; + function fallbackValue () { + return null } - return fallbackValue; + return fallbackValue } - const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); + const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']) // SVG - const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); - const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); + const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']) + const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']) // List of SVG elements that are disallowed by default. // We still need to know them so that we can do namespace // checks properly in case one wants to add them to // allow-list. - const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); - const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); + const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']) + const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']) // Similarly to SVG, we want to know all MathML elements, // even those that we disallow by default. - const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); - const text = freeze(['#text']); + const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']) + const text = freeze(['#text']) - const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'xmlns', 'slot']); - const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); - const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); - const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); + const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'xmlns', 'slot']) + const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']) + const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']) + const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']) // eslint-disable-next-line unicorn/better-regex - const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode - const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); - const TMPLIT_EXPR = seal(/\${[\w\W]*}/gm); - const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]/); // eslint-disable-line no-useless-escape - const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape + const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm) // Specify template detection regex for SAFE_FOR_TEMPLATES mode + const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm) + const TMPLIT_EXPR = seal(/\${[\w\W]*}/gm) + const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]/) // eslint-disable-line no-useless-escape + const ARIA_ATTR = seal(/^aria-[\-\w]+$/) // eslint-disable-line no-useless-escape const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape - ); + ) - const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); + const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i) const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex - ); + ) - const DOCTYPE_NAME = seal(/^html$/i); + const DOCTYPE_NAME = seal(/^html$/i) - var EXPRESSIONS = /*#__PURE__*/Object.freeze({ + const EXPRESSIONS = /* #__PURE__ */Object.freeze({ __proto__: null, - MUSTACHE_EXPR: MUSTACHE_EXPR, - ERB_EXPR: ERB_EXPR, - TMPLIT_EXPR: TMPLIT_EXPR, - DATA_ATTR: DATA_ATTR, - ARIA_ATTR: ARIA_ATTR, - IS_ALLOWED_URI: IS_ALLOWED_URI, - IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA, - ATTR_WHITESPACE: ATTR_WHITESPACE, - DOCTYPE_NAME: DOCTYPE_NAME - }); - - const getGlobal = function getGlobal() { - return typeof window === 'undefined' ? null : window; - }; + MUSTACHE_EXPR, + ERB_EXPR, + TMPLIT_EXPR, + DATA_ATTR, + ARIA_ATTR, + IS_ALLOWED_URI, + IS_SCRIPT_OR_DATA, + ATTR_WHITESPACE, + DOCTYPE_NAME + }) + + const getGlobal = function getGlobal () { + return typeof window === 'undefined' ? null : window + } /** * Creates a no-op policy for internal use only. @@ -247,63 +250,63 @@ * @return {TrustedTypePolicy} The policy created (or null, if Trusted Types * are not supported or creating the policy failed). */ - const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { + const _createTrustedTypesPolicy = function _createTrustedTypesPolicy (trustedTypes, purifyHostElement) { if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { - return null; + return null } // Allow the callers to control the unique policy name // by adding a data-tt-policy-suffix to the script element with the DOMPurify. // Policy creation with duplicate names throws in Trusted Types. - let suffix = null; - const ATTR_NAME = 'data-tt-policy-suffix'; + let suffix = null + const ATTR_NAME = 'data-tt-policy-suffix' if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { - suffix = purifyHostElement.getAttribute(ATTR_NAME); + suffix = purifyHostElement.getAttribute(ATTR_NAME) } - const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); + const policyName = 'dompurify' + (suffix ? '#' + suffix : '') try { return trustedTypes.createPolicy(policyName, { - createHTML(html) { - return html; + createHTML (html) { + return html }, - createScriptURL(scriptUrl) { - return scriptUrl; + createScriptURL (scriptUrl) { + return scriptUrl } - }); + }) } catch (_) { // Policy creation failed (most likely another DOMPurify script has // already run). Skip creating the policy, as this will only cause errors // if TT are enforced. - console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); - return null; + console.warn('TrustedTypes policy ' + policyName + ' could not be created.') + return null } - }; - function createDOMPurify() { - let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); - const DOMPurify = root => createDOMPurify(root); + } + function createDOMPurify () { + const window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal() + const DOMPurify = root => createDOMPurify(root) /** * Version label, exposed for easier checks * if DOMPurify is up to date or not */ - DOMPurify.version = '3.0.9'; + DOMPurify.version = '3.0.9' /** * Array of elements that DOMPurify removed during sanitation. * Empty if nothing was removed. */ - DOMPurify.removed = []; + DOMPurify.removed = [] if (!window || !window.document || window.document.nodeType !== 9) { // Not running in a browser, provide a factory function // so that you can pass your own Window - DOMPurify.isSupported = false; - return DOMPurify; + DOMPurify.isSupported = false + return DOMPurify } let { document - } = window; - const originalDocument = document; - const currentScript = originalDocument.currentScript; + } = window + const originalDocument = document + const currentScript = originalDocument.currentScript const { DocumentFragment, HTMLTemplateElement, @@ -314,12 +317,12 @@ HTMLFormElement, DOMParser, trustedTypes - } = window; - const ElementPrototype = Element.prototype; - const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); - const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); - const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); - const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); + } = window + const ElementPrototype = Element.prototype + const cloneNode = lookupGetter(ElementPrototype, 'cloneNode') + const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling') + const getChildNodes = lookupGetter(ElementPrototype, 'childNodes') + const getParentNode = lookupGetter(ElementPrototype, 'parentNode') // As per issue #47, the web-components registry is inherited by a // new document created via createHTMLDocument. As per the spec @@ -328,28 +331,28 @@ // document, so we use that as our parent document to ensure nothing // is inherited. if (typeof HTMLTemplateElement === 'function') { - const template = document.createElement('template'); + const template = document.createElement('template') if (template.content && template.content.ownerDocument) { - document = template.content.ownerDocument; + document = template.content.ownerDocument } } - let trustedTypesPolicy; - let emptyHTML = ''; + let trustedTypesPolicy + let emptyHTML = '' const { implementation, createNodeIterator, createDocumentFragment, getElementsByTagName - } = document; + } = document const { importNode - } = originalDocument; - let hooks = {}; + } = originalDocument + let hooks = {} /** * Expose whether this browser supports running the full DOMPurify. */ - DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; + DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined const { MUSTACHE_EXPR, ERB_EXPR, @@ -358,10 +361,10 @@ ARIA_ATTR, IS_SCRIPT_OR_DATA, ATTR_WHITESPACE - } = EXPRESSIONS; + } = EXPRESSIONS let { IS_ALLOWED_URI: IS_ALLOWED_URI$1 - } = EXPRESSIONS; + } = EXPRESSIONS /** * We consider the elements and attributes below to be safe. Ideally @@ -369,12 +372,12 @@ */ /* allowed element names */ - let ALLOWED_TAGS = null; - const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); + let ALLOWED_TAGS = null + const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]) /* Allowed attribute names */ - let ALLOWED_ATTR = null; - const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); + let ALLOWED_ATTR = null + const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]) /* * Configure how DOMPUrify should handle custom elements and their attributes as well as customized built-in elements. @@ -401,60 +404,60 @@ enumerable: true, value: false } - })); + })) /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ - let FORBID_TAGS = null; + let FORBID_TAGS = null /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ - let FORBID_ATTR = null; + let FORBID_ATTR = null /* Decide if ARIA attributes are okay */ - let ALLOW_ARIA_ATTR = true; + let ALLOW_ARIA_ATTR = true /* Decide if custom data attributes are okay */ - let ALLOW_DATA_ATTR = true; + let ALLOW_DATA_ATTR = true /* Decide if unknown protocols are okay */ - let ALLOW_UNKNOWN_PROTOCOLS = false; + let ALLOW_UNKNOWN_PROTOCOLS = false /* Decide if self-closing tags in attributes are allowed. * Usually removed due to a mXSS issue in jQuery 3.0 */ - let ALLOW_SELF_CLOSE_IN_ATTR = true; + let ALLOW_SELF_CLOSE_IN_ATTR = true /* Output should be safe for common template engines. * This means, DOMPurify removes data attributes, mustaches and ERB */ - let SAFE_FOR_TEMPLATES = false; + let SAFE_FOR_TEMPLATES = false /* Decide if document with ... should be returned */ - let WHOLE_DOCUMENT = false; + let WHOLE_DOCUMENT = false /* Track whether config is already set on this instance of DOMPurify. */ - let SET_CONFIG = false; + let SET_CONFIG = false /* Decide if all elements (e.g. style, script) must be children of * document.body. By default, browsers might move them to document.head */ - let FORCE_BODY = false; + let FORCE_BODY = false /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html * string (or a TrustedHTML object if Trusted Types are supported). * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead */ - let RETURN_DOM = false; + let RETURN_DOM = false /* Decide if a DOM `DocumentFragment` should be returned, instead of a html * string (or a TrustedHTML object if Trusted Types are supported) */ - let RETURN_DOM_FRAGMENT = false; + let RETURN_DOM_FRAGMENT = false /* Try to return a Trusted Type object instead of a string, return a string in * case Trusted Types are not supported */ - let RETURN_TRUSTED_TYPE = false; + let RETURN_TRUSTED_TYPE = false /* Output should be free from DOM clobbering attacks? * This sanitizes markups named with colliding, clobberable built-in DOM APIs. */ - let SANITIZE_DOM = true; + let SANITIZE_DOM = true /* Achieve full DOM Clobbering protection by isolating the namespace of named * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. @@ -469,57 +472,57 @@ * Namespace isolation is implemented by prefixing `id` and `name` attributes * with a constant string, i.e., `user-content-` */ - let SANITIZE_NAMED_PROPS = false; - const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; + let SANITIZE_NAMED_PROPS = false + const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-' /* Keep element content when removing element? */ - let KEEP_CONTENT = true; + let KEEP_CONTENT = true /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead * of importing it into a new Document and returning a sanitized copy */ - let IN_PLACE = false; + let IN_PLACE = false /* Allow usage of profiles like html, svg and mathMl */ - let USE_PROFILES = {}; + let USE_PROFILES = {} /* Tags to ignore content of when KEEP_CONTENT is true */ - let FORBID_CONTENTS = null; - const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); + let FORBID_CONTENTS = null + const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']) /* Tags that are safe for data: URIs */ - let DATA_URI_TAGS = null; - const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); + let DATA_URI_TAGS = null + const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']) /* Attributes safe for values like "javascript:" */ - let URI_SAFE_ATTRIBUTES = null; - const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']); - const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; - const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; - const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; + let URI_SAFE_ATTRIBUTES = null + const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']) + const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML' + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg' + const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml' /* Document namespace */ - let NAMESPACE = HTML_NAMESPACE; - let IS_EMPTY_INPUT = false; + let NAMESPACE = HTML_NAMESPACE + let IS_EMPTY_INPUT = false /* Allowed XHTML+XML namespaces */ - let ALLOWED_NAMESPACES = null; - const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString); + let ALLOWED_NAMESPACES = null + const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString) /* Parsing of strict XHTML documents */ - let PARSER_MEDIA_TYPE = null; - const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html']; - const DEFAULT_PARSER_MEDIA_TYPE = 'text/html'; - let transformCaseFunc = null; + let PARSER_MEDIA_TYPE = null + const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html'] + const DEFAULT_PARSER_MEDIA_TYPE = 'text/html' + let transformCaseFunc = null /* Keep a reference to config to pass to hooks */ - let CONFIG = null; + let CONFIG = null /* Ideally, do not touch anything below this line */ /* ______________________________________________ */ - const formElement = document.createElement('form'); - const isRegexOrFunction = function isRegexOrFunction(testValue) { - return testValue instanceof RegExp || testValue instanceof Function; - }; + const formElement = document.createElement('form') + const isRegexOrFunction = function isRegexOrFunction (testValue) { + return testValue instanceof RegExp || testValue instanceof Function + } /** * _parseConfig @@ -527,190 +530,190 @@ * @param {Object} cfg optional config literal */ // eslint-disable-next-line complexity - const _parseConfig = function _parseConfig() { - let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; + const _parseConfig = function _parseConfig () { + let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {} if (CONFIG && CONFIG === cfg) { - return; + return } /* Shield configuration object from tampering */ if (!cfg || typeof cfg !== 'object') { - cfg = {}; + cfg = {} } /* Shield configuration object from prototype pollution */ - cfg = clone(cfg); + cfg = clone(cfg) PARSER_MEDIA_TYPE = // eslint-disable-next-line unicorn/prefer-includes - SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; + SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is. - transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase; + transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase /* Set configuration parameters */ - ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS; - ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR; - ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES; + ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS + ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR + ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), // eslint-disable-line indent - cfg.ADD_URI_SAFE_ATTR, + cfg.ADD_URI_SAFE_ATTR, // eslint-disable-line indent transformCaseFunc // eslint-disable-line indent ) // eslint-disable-line indent - : DEFAULT_URI_SAFE_ATTRIBUTES; + : DEFAULT_URI_SAFE_ATTRIBUTES DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), // eslint-disable-line indent - cfg.ADD_DATA_URI_TAGS, + cfg.ADD_DATA_URI_TAGS, // eslint-disable-line indent transformCaseFunc // eslint-disable-line indent ) // eslint-disable-line indent - : DEFAULT_DATA_URI_TAGS; - FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS; - FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {}; - FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {}; - USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false; - ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true - ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true - ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false - ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true - SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false - WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false - RETURN_DOM = cfg.RETURN_DOM || false; // Default false - RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false - RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false - FORCE_BODY = cfg.FORCE_BODY || false; // Default false - SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true - SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false - KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true - IN_PLACE = cfg.IN_PLACE || false; // Default false - IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI; - NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE; - CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {}; + : DEFAULT_DATA_URI_TAGS + FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS + FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {} + FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {} + USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false + ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false // Default true + ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false // Default true + ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false // Default false + ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false // Default true + SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false // Default false + WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false // Default false + RETURN_DOM = cfg.RETURN_DOM || false // Default false + RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false // Default false + RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false // Default false + FORCE_BODY = cfg.FORCE_BODY || false // Default false + SANITIZE_DOM = cfg.SANITIZE_DOM !== false // Default true + SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false // Default false + KEEP_CONTENT = cfg.KEEP_CONTENT !== false // Default true + IN_PLACE = cfg.IN_PLACE || false // Default false + IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI + NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE + CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {} if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) { - CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck; + CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck } if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) { - CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck; + CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck } if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') { - CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements; + CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements } if (SAFE_FOR_TEMPLATES) { - ALLOW_DATA_ATTR = false; + ALLOW_DATA_ATTR = false } if (RETURN_DOM_FRAGMENT) { - RETURN_DOM = true; + RETURN_DOM = true } /* Parse profile info */ if (USE_PROFILES) { - ALLOWED_TAGS = addToSet({}, text); - ALLOWED_ATTR = []; + ALLOWED_TAGS = addToSet({}, text) + ALLOWED_ATTR = [] if (USE_PROFILES.html === true) { - addToSet(ALLOWED_TAGS, html$1); - addToSet(ALLOWED_ATTR, html); + addToSet(ALLOWED_TAGS, html$1) + addToSet(ALLOWED_ATTR, html) } if (USE_PROFILES.svg === true) { - addToSet(ALLOWED_TAGS, svg$1); - addToSet(ALLOWED_ATTR, svg); - addToSet(ALLOWED_ATTR, xml); + addToSet(ALLOWED_TAGS, svg$1) + addToSet(ALLOWED_ATTR, svg) + addToSet(ALLOWED_ATTR, xml) } if (USE_PROFILES.svgFilters === true) { - addToSet(ALLOWED_TAGS, svgFilters); - addToSet(ALLOWED_ATTR, svg); - addToSet(ALLOWED_ATTR, xml); + addToSet(ALLOWED_TAGS, svgFilters) + addToSet(ALLOWED_ATTR, svg) + addToSet(ALLOWED_ATTR, xml) } if (USE_PROFILES.mathMl === true) { - addToSet(ALLOWED_TAGS, mathMl$1); - addToSet(ALLOWED_ATTR, mathMl); - addToSet(ALLOWED_ATTR, xml); + addToSet(ALLOWED_TAGS, mathMl$1) + addToSet(ALLOWED_ATTR, mathMl) + addToSet(ALLOWED_ATTR, xml) } } /* Merge configuration parameters */ if (cfg.ADD_TAGS) { if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { - ALLOWED_TAGS = clone(ALLOWED_TAGS); + ALLOWED_TAGS = clone(ALLOWED_TAGS) } - addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); + addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc) } if (cfg.ADD_ATTR) { if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { - ALLOWED_ATTR = clone(ALLOWED_ATTR); + ALLOWED_ATTR = clone(ALLOWED_ATTR) } - addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); + addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc) } if (cfg.ADD_URI_SAFE_ATTR) { - addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc); + addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) } if (cfg.FORBID_CONTENTS) { if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) { - FORBID_CONTENTS = clone(FORBID_CONTENTS); + FORBID_CONTENTS = clone(FORBID_CONTENTS) } - addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc); + addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc) } /* Add #text in case KEEP_CONTENT is set to true */ if (KEEP_CONTENT) { - ALLOWED_TAGS['#text'] = true; + ALLOWED_TAGS['#text'] = true } /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ if (WHOLE_DOCUMENT) { - addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); + addToSet(ALLOWED_TAGS, ['html', 'head', 'body']) } /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ if (ALLOWED_TAGS.table) { - addToSet(ALLOWED_TAGS, ['tbody']); - delete FORBID_TAGS.tbody; + addToSet(ALLOWED_TAGS, ['tbody']) + delete FORBID_TAGS.tbody } if (cfg.TRUSTED_TYPES_POLICY) { if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') { - throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.'); + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.') } if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') { - throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.'); + throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.') } // Overwrite existing TrustedTypes policy. - trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; + trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY // Sign local variables required by `sanitize`. - emptyHTML = trustedTypesPolicy.createHTML(''); + emptyHTML = trustedTypesPolicy.createHTML('') } else { // Uninitialized policy, attempt to initialize the internal dompurify policy. if (trustedTypesPolicy === undefined) { - trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); + trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript) } // If creating the internal policy succeeded sign internal variables. if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') { - emptyHTML = trustedTypesPolicy.createHTML(''); + emptyHTML = trustedTypesPolicy.createHTML('') } } // Prevent further manipulation of configuration. // Not available in IE8, Safari 5, etc. if (freeze) { - freeze(cfg); + freeze(cfg) } - CONFIG = cfg; - }; - const MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); - const HTML_INTEGRATION_POINTS = addToSet({}, ['foreignobject', 'desc', 'title', 'annotation-xml']); + CONFIG = cfg + } + const MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']) + const HTML_INTEGRATION_POINTS = addToSet({}, ['foreignobject', 'desc', 'title', 'annotation-xml']) // Certain elements are allowed in both SVG and HTML // namespace. We need to specify them explicitly // so that they don't get erroneously deleted from // HTML namespace. - const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']); + const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']) /* Keep track of all possible SVG and MathML tags * so that we can perform the namespace checks * correctly. */ - const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]); - const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); + const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]) + const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]) /** * @param {Element} element a DOM element whose namespace is being checked @@ -718,8 +721,8 @@ * namespace that a spec-compliant parser would never * return. Return true otherwise. */ - const _checkValidNamespace = function _checkValidNamespace(element) { - let parent = getParentNode(element); + const _checkValidNamespace = function _checkValidNamespace (element) { + let parent = getParentNode(element) // In JSDOM, if we're inside shadow DOM, then parentNode // can be null. We just simulate parent in this case. @@ -727,94 +730,94 @@ parent = { namespaceURI: NAMESPACE, tagName: 'template' - }; + } } - const tagName = stringToLowerCase(element.tagName); - const parentTagName = stringToLowerCase(parent.tagName); + const tagName = stringToLowerCase(element.tagName) + const parentTagName = stringToLowerCase(parent.tagName) if (!ALLOWED_NAMESPACES[element.namespaceURI]) { - return false; + return false } if (element.namespaceURI === SVG_NAMESPACE) { // The only way to switch from HTML namespace to SVG // is via . If it happens via any other tag, then // it should be killed. if (parent.namespaceURI === HTML_NAMESPACE) { - return tagName === 'svg'; + return tagName === 'svg' } // The only way to switch from MathML to SVG is via` // svg if parent is either or MathML // text integration points. if (parent.namespaceURI === MATHML_NAMESPACE) { - return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); + return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) } // We only allow elements that are defined in SVG // spec. All others are disallowed in SVG namespace. - return Boolean(ALL_SVG_TAGS[tagName]); + return Boolean(ALL_SVG_TAGS[tagName]) } if (element.namespaceURI === MATHML_NAMESPACE) { // The only way to switch from HTML namespace to MathML // is via . If it happens via any other tag, then // it should be killed. if (parent.namespaceURI === HTML_NAMESPACE) { - return tagName === 'math'; + return tagName === 'math' } // The only way to switch from SVG to MathML is via // and HTML integration points if (parent.namespaceURI === SVG_NAMESPACE) { - return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; + return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName] } // We only allow elements that are defined in MathML // spec. All others are disallowed in MathML namespace. - return Boolean(ALL_MATHML_TAGS[tagName]); + return Boolean(ALL_MATHML_TAGS[tagName]) } if (element.namespaceURI === HTML_NAMESPACE) { // The only way to switch from SVG to HTML is via // HTML integration points, and from MathML to HTML // is via MathML text integration points if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { - return false; + return false } if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { - return false; + return false } // We disallow tags that are specific for MathML // or SVG and should never appear in HTML namespace - return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); + return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]) } // For XHTML and XML documents that support custom namespaces if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) { - return true; + return true } // The code should never reach this place (this means // that the element somehow got namespace that is not // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES). // Return false just in case. - return false; - }; + return false + } /** * _forceRemove * * @param {Node} node a DOM node */ - const _forceRemove = function _forceRemove(node) { + const _forceRemove = function _forceRemove (node) { arrayPush(DOMPurify.removed, { element: node - }); + }) try { // eslint-disable-next-line unicorn/prefer-dom-node-remove - node.parentNode.removeChild(node); + node.parentNode.removeChild(node) } catch (_) { - node.remove(); + node.remove() } - }; + } /** * _removeAttribute @@ -822,33 +825,33 @@ * @param {String} name an Attribute name * @param {Node} node a DOM node */ - const _removeAttribute = function _removeAttribute(name, node) { + const _removeAttribute = function _removeAttribute (name, node) { try { arrayPush(DOMPurify.removed, { attribute: node.getAttributeNode(name), from: node - }); + }) } catch (_) { arrayPush(DOMPurify.removed, { attribute: null, from: node - }); + }) } - node.removeAttribute(name); + node.removeAttribute(name) // We void attribute values for unremovable "is"" attributes if (name === 'is' && !ALLOWED_ATTR[name]) { if (RETURN_DOM || RETURN_DOM_FRAGMENT) { try { - _forceRemove(node); + _forceRemove(node) } catch (_) {} } else { try { - node.setAttribute(name, ''); + node.setAttribute(name, '') } catch (_) {} } } - }; + } /** * _initDocument @@ -856,52 +859,52 @@ * @param {String} dirty a string of dirty markup * @return {Document} a DOM, filled with the dirty markup */ - const _initDocument = function _initDocument(dirty) { + const _initDocument = function _initDocument (dirty) { /* Create a HTML document */ - let doc = null; - let leadingWhitespace = null; + let doc = null + let leadingWhitespace = null if (FORCE_BODY) { - dirty = '' + dirty; + dirty = '' + dirty } else { /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ - const matches = stringMatch(dirty, /^[\r\n\t ]+/); - leadingWhitespace = matches && matches[0]; + const matches = stringMatch(dirty, /^[\r\n\t ]+/) + leadingWhitespace = matches && matches[0] } if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) { // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict) - dirty = '' + dirty + ''; + dirty = '' + dirty + '' } - const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; + const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty /* * Use the DOMParser API by default, fallback later if needs be * DOMParser not work for svg when has multiple root element. */ if (NAMESPACE === HTML_NAMESPACE) { try { - doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE); + doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE) } catch (_) {} } /* Use createHTMLDocument in case DOMParser is not available */ if (!doc || !doc.documentElement) { - doc = implementation.createDocument(NAMESPACE, 'template', null); + doc = implementation.createDocument(NAMESPACE, 'template', null) try { - doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload; + doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload } catch (_) { // Syntax error if dirtyPayload is invalid xml } } - const body = doc.body || doc.documentElement; + const body = doc.body || doc.documentElement if (dirty && leadingWhitespace) { - body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null); + body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null) } /* Work on whole document or just its body */ if (NAMESPACE === HTML_NAMESPACE) { - return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; + return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0] } - return WHOLE_DOCUMENT ? doc.documentElement : body; - }; + return WHOLE_DOCUMENT ? doc.documentElement : body + } /** * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document. @@ -909,11 +912,11 @@ * @param {Node} root The root element or node to start traversing on. * @return {NodeIterator} The created NodeIterator */ - const _createNodeIterator = function _createNodeIterator(root) { + const _createNodeIterator = function _createNodeIterator (root) { return createNodeIterator.call(root.ownerDocument || root, root, // eslint-disable-next-line no-bitwise - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT, null); - }; + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT, null) + } /** * _isClobbered @@ -921,9 +924,9 @@ * @param {Node} elm element to check for clobbering attacks * @return {Boolean} true if clobbered, false if safe */ - const _isClobbered = function _isClobbered(elm) { - return elm instanceof HTMLFormElement && (typeof elm.nodeName !== 'string' || typeof elm.textContent !== 'string' || typeof elm.removeChild !== 'function' || !(elm.attributes instanceof NamedNodeMap) || typeof elm.removeAttribute !== 'function' || typeof elm.setAttribute !== 'function' || typeof elm.namespaceURI !== 'string' || typeof elm.insertBefore !== 'function' || typeof elm.hasChildNodes !== 'function'); - }; + const _isClobbered = function _isClobbered (elm) { + return elm instanceof HTMLFormElement && (typeof elm.nodeName !== 'string' || typeof elm.textContent !== 'string' || typeof elm.removeChild !== 'function' || !(elm.attributes instanceof NamedNodeMap) || typeof elm.removeAttribute !== 'function' || typeof elm.setAttribute !== 'function' || typeof elm.namespaceURI !== 'string' || typeof elm.insertBefore !== 'function' || typeof elm.hasChildNodes !== 'function') + } /** * Checks whether the given object is a DOM node. @@ -931,9 +934,9 @@ * @param {Node} object object to check whether it's a DOM node * @return {Boolean} true is object is a DOM node */ - const _isNode = function _isNode(object) { - return typeof Node === 'function' && object instanceof Node; - }; + const _isNode = function _isNode (object) { + return typeof Node === 'function' && object instanceof Node + } /** * _executeHook @@ -943,14 +946,14 @@ * @param {Node} currentNode node to work on with the hook * @param {Object} data additional hook parameters */ - const _executeHook = function _executeHook(entryPoint, currentNode, data) { + const _executeHook = function _executeHook (entryPoint, currentNode, data) { if (!hooks[entryPoint]) { - return; + return } arrayForEach(hooks[entryPoint], hook => { - hook.call(DOMPurify, currentNode, data, CONFIG); - }); - }; + hook.call(DOMPurify, currentNode, data, CONFIG) + }) + } /** * _sanitizeElements @@ -962,31 +965,31 @@ * @param {Node} currentNode to check for permission to exist * @return {Boolean} true if node was killed, false if left alive */ - const _sanitizeElements = function _sanitizeElements(currentNode) { - let content = null; + const _sanitizeElements = function _sanitizeElements (currentNode) { + let content = null /* Execute a hook if present */ - _executeHook('beforeSanitizeElements', currentNode, null); + _executeHook('beforeSanitizeElements', currentNode, null) /* Check if element is clobbered or can clobber */ if (_isClobbered(currentNode)) { - _forceRemove(currentNode); - return true; + _forceRemove(currentNode) + return true } /* Now let's check the element's type and name */ - const tagName = transformCaseFunc(currentNode.nodeName); + const tagName = transformCaseFunc(currentNode.nodeName) /* Execute a hook if present */ _executeHook('uponSanitizeElement', currentNode, { tagName, allowedTags: ALLOWED_TAGS - }); + }) /* Detect mXSS attempts abusing namespace confusion */ if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { - _forceRemove(currentNode); - return true; + _forceRemove(currentNode) + return true } /* Remove element if anything forbids its presence */ @@ -994,59 +997,59 @@ /* Check if we have a custom element to handle */ if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) { if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) { - return false; + return false } if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) { - return false; + return false } } /* Keep content except for bad-listed elements */ if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { - const parentNode = getParentNode(currentNode) || currentNode.parentNode; - const childNodes = getChildNodes(currentNode) || currentNode.childNodes; + const parentNode = getParentNode(currentNode) || currentNode.parentNode + const childNodes = getChildNodes(currentNode) || currentNode.childNodes if (childNodes && parentNode) { - const childCount = childNodes.length; + const childCount = childNodes.length for (let i = childCount - 1; i >= 0; --i) { - parentNode.insertBefore(cloneNode(childNodes[i], true), getNextSibling(currentNode)); + parentNode.insertBefore(cloneNode(childNodes[i], true), getNextSibling(currentNode)) } } } - _forceRemove(currentNode); - return true; + _forceRemove(currentNode) + return true } /* Check whether element has a valid namespace */ if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { - _forceRemove(currentNode); - return true; + _forceRemove(currentNode) + return true } /* Make sure that older browsers don't get fallback-tag mXSS */ if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) { - _forceRemove(currentNode); - return true; + _forceRemove(currentNode) + return true } /* Sanitize element content to be template-safe */ if (SAFE_FOR_TEMPLATES && currentNode.nodeType === 3) { /* Get the element's text content */ - content = currentNode.textContent; + content = currentNode.textContent arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - content = stringReplace(content, expr, ' '); - }); + content = stringReplace(content, expr, ' ') + }) if (currentNode.textContent !== content) { arrayPush(DOMPurify.removed, { element: currentNode.cloneNode() - }); - currentNode.textContent = content; + }) + currentNode.textContent = content } } /* Execute a hook if present */ - _executeHook('afterSanitizeElements', currentNode, null); - return false; - }; + _executeHook('afterSanitizeElements', currentNode, null) + return false + } /** * _isValidAttribute @@ -1057,10 +1060,10 @@ * @return {Boolean} Returns true if `value` is valid, otherwise false. */ // eslint-disable-next-line complexity - const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { + const _isValidAttribute = function _isValidAttribute (lcTag, lcName, value) { /* Make sure attribute cannot clobber */ if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { - return false; + return false } /* Allow valid data-* attributes: At least one character after "-" @@ -1072,18 +1075,18 @@ // First condition does a very basic check if a) it's basically a valid custom element tagname AND // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck - _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || + _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || // Alternative, second condition checks if it's an `is`-attribute, AND // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ; else { - return false; + return false } /* Check value is safe. First, is attr inert? If so, is safe */ } else if (URI_SAFE_ATTRIBUTES[lcName]) ; else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ; else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ; else if (value) { - return false; + return false } else ; - return true; - }; + return true + } /** * _isBasicCustomElement @@ -1093,9 +1096,9 @@ * @param {string} tagName name of the tag of the node to sanitize * @returns {boolean} Returns true if the tag name meets the basic criteria for a custom element, otherwise false. */ - const _isBasicCustomElement = function _isBasicCustomElement(tagName) { - return tagName !== 'annotation-xml' && tagName.indexOf('-') > 0; - }; + const _isBasicCustomElement = function _isBasicCustomElement (tagName) { + return tagName !== 'annotation-xml' && tagName.indexOf('-') > 0 + } /** * _sanitizeAttributes @@ -1107,73 +1110,73 @@ * * @param {Node} currentNode to sanitize */ - const _sanitizeAttributes = function _sanitizeAttributes(currentNode) { + const _sanitizeAttributes = function _sanitizeAttributes (currentNode) { /* Execute a hook if present */ - _executeHook('beforeSanitizeAttributes', currentNode, null); + _executeHook('beforeSanitizeAttributes', currentNode, null) const { attributes - } = currentNode; + } = currentNode /* Check if we have attributes; if not we might have a text node */ if (!attributes) { - return; + return } const hookEvent = { attrName: '', attrValue: '', keepAttr: true, allowedAttributes: ALLOWED_ATTR - }; - let l = attributes.length; + } + let l = attributes.length /* Go backwards over all attributes; safely remove bad ones */ while (l--) { - const attr = attributes[l]; + const attr = attributes[l] const { name, namespaceURI, value: attrValue - } = attr; - const lcName = transformCaseFunc(name); - let value = name === 'value' ? attrValue : stringTrim(attrValue); + } = attr + const lcName = transformCaseFunc(name) + let value = name === 'value' ? attrValue : stringTrim(attrValue) /* Execute a hook if present */ - hookEvent.attrName = lcName; - hookEvent.attrValue = value; - hookEvent.keepAttr = true; - hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set - _executeHook('uponSanitizeAttribute', currentNode, hookEvent); - value = hookEvent.attrValue; + hookEvent.attrName = lcName + hookEvent.attrValue = value + hookEvent.keepAttr = true + hookEvent.forceKeepAttr = undefined // Allows developers to see this is a property they can set + _executeHook('uponSanitizeAttribute', currentNode, hookEvent) + value = hookEvent.attrValue /* Did the hooks approve of the attribute? */ if (hookEvent.forceKeepAttr) { - continue; + continue } /* Remove attribute */ - _removeAttribute(name, currentNode); + _removeAttribute(name, currentNode) /* Did the hooks approve of the attribute? */ if (!hookEvent.keepAttr) { - continue; + continue } /* Work around a security issue in jQuery 3.0 */ if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) { - _removeAttribute(name, currentNode); - continue; + _removeAttribute(name, currentNode) + continue } /* Sanitize attribute content to be template-safe */ if (SAFE_FOR_TEMPLATES) { arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - value = stringReplace(value, expr, ' '); - }); + value = stringReplace(value, expr, ' ') + }) } /* Is `value` valid for this attribute? */ - const lcTag = transformCaseFunc(currentNode.nodeName); + const lcTag = transformCaseFunc(currentNode.nodeName) if (!_isValidAttribute(lcTag, lcName, value)) { - continue; + continue } /* Full DOM Clobbering protection via namespace isolation, @@ -1181,10 +1184,10 @@ */ if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) { // Remove the attribute with this value - _removeAttribute(name, currentNode); + _removeAttribute(name, currentNode) // Prefix the value and later re-create the attribute with the sanitized value - value = SANITIZE_NAMED_PROPS_PREFIX + value; + value = SANITIZE_NAMED_PROPS_PREFIX + value } /* Handle attributes that require Trusted Types */ @@ -1192,15 +1195,15 @@ if (namespaceURI) ; else { switch (trustedTypes.getAttributeType(lcTag, lcName)) { case 'TrustedHTML': - { - value = trustedTypesPolicy.createHTML(value); - break; - } + { + value = trustedTypesPolicy.createHTML(value) + break + } case 'TrustedScriptURL': - { - value = trustedTypesPolicy.createScriptURL(value); - break; - } + { + value = trustedTypesPolicy.createScriptURL(value) + break + } } } } @@ -1208,51 +1211,51 @@ /* Handle invalid data-* attribute set by try-catching it */ try { if (namespaceURI) { - currentNode.setAttributeNS(namespaceURI, name, value); + currentNode.setAttributeNS(namespaceURI, name, value) } else { /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ - currentNode.setAttribute(name, value); + currentNode.setAttribute(name, value) } - arrayPop(DOMPurify.removed); + arrayPop(DOMPurify.removed) } catch (_) {} } /* Execute a hook if present */ - _executeHook('afterSanitizeAttributes', currentNode, null); - }; + _executeHook('afterSanitizeAttributes', currentNode, null) + } /** * _sanitizeShadowDOM * * @param {DocumentFragment} fragment to iterate over recursively */ - const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { - let shadowNode = null; - const shadowIterator = _createNodeIterator(fragment); + const _sanitizeShadowDOM = function _sanitizeShadowDOM (fragment) { + let shadowNode = null + const shadowIterator = _createNodeIterator(fragment) /* Execute a hook if present */ - _executeHook('beforeSanitizeShadowDOM', fragment, null); + _executeHook('beforeSanitizeShadowDOM', fragment, null) while (shadowNode = shadowIterator.nextNode()) { /* Execute a hook if present */ - _executeHook('uponSanitizeShadowNode', shadowNode, null); + _executeHook('uponSanitizeShadowNode', shadowNode, null) /* Sanitize tags and elements */ if (_sanitizeElements(shadowNode)) { - continue; + continue } /* Deep shadow DOM detected */ if (shadowNode.content instanceof DocumentFragment) { - _sanitizeShadowDOM(shadowNode.content); + _sanitizeShadowDOM(shadowNode.content) } /* Check attributes, sanitize if necessary */ - _sanitizeAttributes(shadowNode); + _sanitizeAttributes(shadowNode) } /* Execute a hook if present */ - _executeHook('afterSanitizeShadowDOM', fragment, null); - }; + _executeHook('afterSanitizeShadowDOM', fragment, null) + } /** * Sanitize @@ -1263,126 +1266,126 @@ */ // eslint-disable-next-line complexity DOMPurify.sanitize = function (dirty) { - let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let body = null; - let importedNode = null; - let currentNode = null; - let returnNode = null; + const cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {} + let body = null + let importedNode = null + let currentNode = null + let returnNode = null /* Make sure we have a string to sanitize. DO NOT return early, as this will return the wrong type if the user has requested a DOM object rather than a string */ - IS_EMPTY_INPUT = !dirty; + IS_EMPTY_INPUT = !dirty if (IS_EMPTY_INPUT) { - dirty = ''; + dirty = '' } /* Stringify, in case dirty is an object */ if (typeof dirty !== 'string' && !_isNode(dirty)) { if (typeof dirty.toString === 'function') { - dirty = dirty.toString(); + dirty = dirty.toString() if (typeof dirty !== 'string') { - throw typeErrorCreate('dirty is not a string, aborting'); + throw typeErrorCreate('dirty is not a string, aborting') } } else { - throw typeErrorCreate('toString is not a function'); + throw typeErrorCreate('toString is not a function') } } /* Return dirty HTML if DOMPurify cannot run */ if (!DOMPurify.isSupported) { - return dirty; + return dirty } /* Assign config vars */ if (!SET_CONFIG) { - _parseConfig(cfg); + _parseConfig(cfg) } /* Clean up removed elements */ - DOMPurify.removed = []; + DOMPurify.removed = [] /* Check if dirty is correctly typed for IN_PLACE */ if (typeof dirty === 'string') { - IN_PLACE = false; + IN_PLACE = false } if (IN_PLACE) { /* Do some early pre-sanitization to avoid unsafe root nodes */ if (dirty.nodeName) { - const tagName = transformCaseFunc(dirty.nodeName); + const tagName = transformCaseFunc(dirty.nodeName) if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { - throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place'); + throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place') } } } else if (dirty instanceof Node) { /* If dirty is a DOM element, append to an empty document to avoid elements being stripped by the parser */ - body = _initDocument(''); - importedNode = body.ownerDocument.importNode(dirty, true); + body = _initDocument('') + importedNode = body.ownerDocument.importNode(dirty, true) if (importedNode.nodeType === 1 && importedNode.nodeName === 'BODY') { /* Node is already a body, use as is */ - body = importedNode; + body = importedNode } else if (importedNode.nodeName === 'HTML') { - body = importedNode; + body = importedNode } else { // eslint-disable-next-line unicorn/prefer-dom-node-append - body.appendChild(importedNode); + body.appendChild(importedNode) } } else { /* Exit directly if we have nothing to do */ if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && // eslint-disable-next-line unicorn/prefer-includes dirty.indexOf('<') === -1) { - return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty } /* Initialize the document to work on */ - body = _initDocument(dirty); + body = _initDocument(dirty) /* Check we have a DOM node from the data */ if (!body) { - return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : ''; + return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : '' } } /* Remove first element node (ours) if FORCE_BODY is set */ if (body && FORCE_BODY) { - _forceRemove(body.firstChild); + _forceRemove(body.firstChild) } /* Get node iterator */ - const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body); + const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body) /* Now start iterating over the created document */ while (currentNode = nodeIterator.nextNode()) { /* Sanitize tags and elements */ if (_sanitizeElements(currentNode)) { - continue; + continue } /* Shadow DOM detected, sanitize it */ if (currentNode.content instanceof DocumentFragment) { - _sanitizeShadowDOM(currentNode.content); + _sanitizeShadowDOM(currentNode.content) } /* Check attributes, sanitize if necessary */ - _sanitizeAttributes(currentNode); + _sanitizeAttributes(currentNode) } /* If we sanitized `dirty` in-place, return it. */ if (IN_PLACE) { - return dirty; + return dirty } /* Return sanitized string or DOM */ if (RETURN_DOM) { if (RETURN_DOM_FRAGMENT) { - returnNode = createDocumentFragment.call(body.ownerDocument); + returnNode = createDocumentFragment.call(body.ownerDocument) while (body.firstChild) { // eslint-disable-next-line unicorn/prefer-dom-node-append - returnNode.appendChild(body.firstChild); + returnNode.appendChild(body.firstChild) } } else { - returnNode = body; + returnNode = body } if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) { /* @@ -1392,25 +1395,25 @@ The state that is cloned by importNode() is explicitly defined by the specs. */ - returnNode = importNode.call(originalDocument, returnNode, true); + returnNode = importNode.call(originalDocument, returnNode, true) } - return returnNode; + return returnNode } - let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; + let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML /* Serialize doctype if allowed */ if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) { - serializedHTML = '\n' + serializedHTML; + serializedHTML = '\n' + serializedHTML } /* Sanitize final string template-safe */ if (SAFE_FOR_TEMPLATES) { arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - serializedHTML = stringReplace(serializedHTML, expr, ' '); - }); + serializedHTML = stringReplace(serializedHTML, expr, ' ') + }) } - return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; - }; + return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML + } /** * Public method to set the configuration once @@ -1419,10 +1422,10 @@ * @param {Object} cfg configuration object */ DOMPurify.setConfig = function () { - let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - _parseConfig(cfg); - SET_CONFIG = true; - }; + const cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {} + _parseConfig(cfg) + SET_CONFIG = true + } /** * Public method to remove the configuration @@ -1430,9 +1433,9 @@ * */ DOMPurify.clearConfig = function () { - CONFIG = null; - SET_CONFIG = false; - }; + CONFIG = null + SET_CONFIG = false + } /** * Public method to check if an attribute value is valid. @@ -1447,12 +1450,12 @@ DOMPurify.isValidAttribute = function (tag, attr, value) { /* Initialize shared config vars if necessary. */ if (!CONFIG) { - _parseConfig({}); + _parseConfig({}) } - const lcTag = transformCaseFunc(tag); - const lcName = transformCaseFunc(attr); - return _isValidAttribute(lcTag, lcName, value); - }; + const lcTag = transformCaseFunc(tag) + const lcName = transformCaseFunc(attr) + return _isValidAttribute(lcTag, lcName, value) + } /** * AddHook @@ -1463,11 +1466,11 @@ */ DOMPurify.addHook = function (entryPoint, hookFunction) { if (typeof hookFunction !== 'function') { - return; + return } - hooks[entryPoint] = hooks[entryPoint] || []; - arrayPush(hooks[entryPoint], hookFunction); - }; + hooks[entryPoint] = hooks[entryPoint] || [] + arrayPush(hooks[entryPoint], hookFunction) + } /** * RemoveHook @@ -1479,9 +1482,9 @@ */ DOMPurify.removeHook = function (entryPoint) { if (hooks[entryPoint]) { - return arrayPop(hooks[entryPoint]); + return arrayPop(hooks[entryPoint]) } - }; + } /** * RemoveHooks @@ -1491,24 +1494,23 @@ */ DOMPurify.removeHooks = function (entryPoint) { if (hooks[entryPoint]) { - hooks[entryPoint] = []; + hooks[entryPoint] = [] } - }; + } /** * RemoveAllHooks * Public method to remove all DOMPurify hooks */ DOMPurify.removeAllHooks = function () { - hooks = {}; - }; - return DOMPurify; + hooks = {} + } + return DOMPurify } - var purify = createDOMPurify(); - - return purify; + const purify = createDOMPurify() -})); -//# sourceMappingURL=purify.js.map + return purify +}) +// # sourceMappingURL=purify.js.map -export default DOMPurify; +export default DOMPurify diff --git a/error-message.js b/error-message.js new file mode 100644 index 0000000..b8d5ad5 --- /dev/null +++ b/error-message.js @@ -0,0 +1,36 @@ +class ErrorMessage extends HTMLElement { + constructor () { + super() + this.attachShadow({ mode: 'open' }) + + // Create the main element for the error message + const errorElement = document.createElement('p') + errorElement.classList.add('error') + errorElement.textContent = + this.getAttribute('message') || 'An error occurred' + + const style = document.createElement('style') + style.textContent = ` + .error { + color: var(--rdp-details-color); + text-align: center; + margin: 20px; + font-size: 1rem; + } + ` + + this.shadowRoot.append(style, errorElement) + } + + static get observedAttributes () { + return ['message'] + } + + attributeChangedCallback (name, oldValue, newValue) { + if (name === 'message' && oldValue !== newValue) { + this.shadowRoot.querySelector('.error').textContent = newValue + } + } +} + +customElements.define('error-message', ErrorMessage) diff --git a/example/outbox.html b/example/outbox.html new file mode 100644 index 0000000..474b62f --- /dev/null +++ b/example/outbox.html @@ -0,0 +1,17 @@ + +Reader Outbox + +
+ + + +
+ + + diff --git a/example/post.html b/example/post.html new file mode 100644 index 0000000..5b99b51 --- /dev/null +++ b/example/post.html @@ -0,0 +1,28 @@ + +Reader Post + +
+ + + + +
+ + + + diff --git a/followed-accounts.css b/followed-accounts.css new file mode 100644 index 0000000..6cd25e7 --- /dev/null +++ b/followed-accounts.css @@ -0,0 +1,34 @@ +.followed-container { + flex: 1; + max-width: 600px; + width: 100%; + margin: 0 20px; + color: var(--rdp-text-color); + display: flex; + flex-direction: column; + align-items: center; + margin-top: 10px; +} + +.imp-exp-btn { + margin-bottom: 20px; +} + +#exportFollowedList, +#importFollowedList { + cursor: pointer; +} + +.cache-warning-msg { + font-size: 0.775em; + margin-top: 10px; +} + +followed-actors-list { + text-align: left; + color: var(--rdp-text-color); + width: 80%; + max-width: fit-content; + margin: 0 auto; + overflow-wrap: break-word; +} diff --git a/followed-accounts.html b/followed-accounts.html new file mode 100644 index 0000000..1a328c9 --- /dev/null +++ b/followed-accounts.html @@ -0,0 +1,65 @@ + + + +Followed Accounts + +
+ +
+

+ You're following + accounts on the + fediverse.
+ To sync your list of followed accounts across multiple devices, be sure + to ⬆️ export your followed list from one device and 📥 import it on the + other. +

+
+ + + +
+ + ⚠️ Before clearing your browser's cache, ensure you export your followed + list. +
+
+ +
+
+ + + + + diff --git a/followed-accounts.js b/followed-accounts.js new file mode 100644 index 0000000..1b5de4c --- /dev/null +++ b/followed-accounts.js @@ -0,0 +1,128 @@ +import { db } from './dbInstance.js' + +export class FollowedActorsList extends HTMLElement { + constructor () { + super() + this.updateFollowedActors = this.updateFollowedActors.bind(this) + } + + connectedCallback () { + this.renderFollowedActors() + + db.addEventListener('actorFollowed', this.updateFollowedActors) + db.addEventListener('actorUnfollowed', this.updateFollowedActors) + + this.addEventListener('exportFollowed', FollowedActorsList.exportFollowedList) + this.addEventListener('importFollowed', (e) => { + FollowedActorsList.importFollowedList(e.detail.file) + }) + } + + disconnectedCallback () { + db.removeEventListener('actorFollowed', this.updateFollowedActors) + db.removeEventListener('actorUnfollowed', this.updateFollowedActors) + + this.removeEventListener('exportFollowed', FollowedActorsList.exportFollowedList) + this.removeEventListener('importFollowed', FollowedActorsList.importFollowedList) + } + + async updateFollowedActors () { + await this.renderFollowedActors() + const followCount = document.querySelector('followed-count') + if (followCount) { + followCount.updateCount() + } + } + + async renderFollowedActors () { + const followedActors = await db.getFollowedActors() + this.innerHTML = '' + followedActors.forEach((actor) => { + const actorElement = document.createElement('actor-mini-profile') + actorElement.setAttribute('url', actor.url) + actorElement.setAttribute('followed-at', this.formatDate(actor.followedAt)) + this.appendChild(actorElement) + }) + } + + formatDate (dateString) { + const options = { year: 'numeric', month: 'long', day: 'numeric' } + const date = new Date(dateString) + return date.toLocaleDateString('en-US', options) + } + + static async exportFollowedList () { + const followedActors = await db.getFollowedActors() + const blob = new Blob([JSON.stringify(followedActors, null, 2)], { + type: 'application/json' + }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'reader-followed-accounts.json' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } + + static async importFollowedList (file) { + const reader = new FileReader() + reader.onload = async (e) => { + const followedActors = JSON.parse(e.target.result) + for (const actor of followedActors) { + if (!(await db.isActorFollowed(actor.url))) { + await db.followActor(actor.url) + } + } + } + reader.readAsText(file) + } +} + +customElements.define('followed-actors-list', FollowedActorsList) + +class FollowedCount extends HTMLElement { + connectedCallback () { + this.updateCountOnLoad() + db.addEventListener('actorFollowed', () => this.updateCount()) + db.addEventListener('actorUnfollowed', () => this.updateCount()) + } + + disconnectedCallback () { + db.removeEventListener('actorFollowed', () => this.updateCount()) + db.removeEventListener('actorUnfollowed', () => this.updateCount()) + } + + async updateCountOnLoad () { + setTimeout(() => this.updateCount(), 100) + } + + async updateCount () { + const followedActors = await db.getFollowedActors() + this.textContent = followedActors.length + } +} + +customElements.define('followed-count', FollowedCount) + +// test following/unfollowing +// (async () => { +// const actorUrl1 = "https://example.com/actor/1"; +// const actorUrl2 = "https://example.com/actor/2"; + +// console.log("Following actors..."); +// await db.followActor(actorUrl1); +// await db.followActor(actorUrl2); + +// console.log("Retrieving followed actors..."); +// let followedActors = await db.getFollowedActors(); +// console.log("Followed Actors:", followedActors); + +// console.log("Unfollowing an actor..."); +// await db.unfollowActor(actorUrl2); + +// console.log("Retrieving followed actors after unfollowing..."); +// followedActors = await db.getFollowedActors(); +// console.log("Followed Actors after unfollowing:", followedActors); +// })(); diff --git a/index.css b/index.css index 02b5f3a..f268d71 100644 --- a/index.css +++ b/index.css @@ -7,3 +7,7 @@ html { background: var(--bg-color); } + +* { + box-sizing: border-box; +} \ No newline at end of file diff --git a/index.html b/index.html index bb7b9e8..54d695c 100644 --- a/index.html +++ b/index.html @@ -4,24 +4,24 @@ Social Reader
- + +
+ +
+ + + + diff --git a/outbox.css b/outbox.css index 10ea263..6957cd9 100644 --- a/outbox.css +++ b/outbox.css @@ -1,3 +1,5 @@ -.pagination-controls { +.repost-label { + color: var(--rdp-details-color); + font-size: 0.875rem; text-align: center; } diff --git a/outbox.html b/outbox.html deleted file mode 100644 index 62dbde5..0000000 --- a/outbox.html +++ /dev/null @@ -1,31 +0,0 @@ - -Reader Outbox - -
- - - -
-
- - -
- - - diff --git a/outbox.js b/outbox.js index c0e05f0..330b9d5 100644 --- a/outbox.js +++ b/outbox.js @@ -1,310 +1,256 @@ +import { db } from './dbInstance.js' + class DistributedOutbox extends HTMLElement { - constructor() { - super(); - this.renderedItems = new Map(); // Tracks rendered items by ID - this.numPosts = 32; // Default value - this.page = 1; // Default value - this.totalPages = 0; // Keep track of total pages + skip = 0 + limit = 32 + + constructor () { + super() + this.renderedItems = new Map() // Tracks rendered items by ID } - static get observedAttributes() { - return ["url", "num-posts", "page"]; + static get observedAttributes () { + return ['url'] } - connectedCallback() { - // Use attributes or default values - this.numPosts = - parseInt(this.getAttribute("num-posts"), 10) || this.numPosts; - this.page = parseInt(this.getAttribute("page"), 10) || this.page; - this.loadOutbox(this.getAttribute("url")); + connectedCallback () { + this.outboxUrl = this.getAttribute('url') + this.loadOutbox(this.outboxUrl) } - async loadOutbox(outboxUrl) { - this.clearContent(); - for await (const item of this.fetchOutboxItems(outboxUrl)) { - this.processItem(item); + async loadOutbox (outboxUrl) { + this.clearContent() + const items = await this.collectItems(outboxUrl, { skip: this.skip, limit: this.limit + 1 }) + items.slice(0, this.limit).forEach(item => this.processItem(item)) + + // Update skip for next potential load + this.skip += this.limit + + // Check if there are more items to load + if (items.length > this.limit) { + this.createLoadMoreButton() } } - processItem(item) { - const itemKey = item.id || item.object; - if (!itemKey) { - console.error("Item key is undefined, item:", item); - return; + async loadMore () { + this.removeLoadMoreButton() + const items = await this.collectItems(this.outboxUrl, { skip: this.skip, limit: this.limit + 1 }) + items.slice(0, this.limit).forEach(item => this.processItem(item)) + + this.skip += this.limit + + if (items.length > this.limit) { + this.createLoadMoreButton() } - if (!this.renderedItems.has(itemKey)) { - this.renderItem(item); - this.renderedItems.set(itemKey, true); + } + + async collectItems (outboxUrl, { skip, limit }) { + const items = [] + for await (const item of db.iterateCollection(outboxUrl, { skip, limit })) { + items.push(item) } + return items } - async *fetchOutboxItems(outboxUrl) { - if (!outboxUrl) { - console.error("No outbox URL provided"); - return; + processItem (item) { + const itemKey = item.id || item.object + if (!itemKey) { + console.error('Item key is undefined, item:', item) + return } + if (!this.renderedItems.has(itemKey)) { + this.renderItem(item) + this.renderedItems.set(itemKey, true) + } + } - try { - let response; - // Check the scheme and adjust the URL for unsupported schemes before fetching - if (outboxUrl.startsWith("hyper://")) { - const gatewayUrl = outboxUrl.replace( - "hyper://", - "https://hyper.hypha.coop/hyper/" - ); - response = await fetch(gatewayUrl); - } else if (outboxUrl.startsWith("ipns://")) { - const gatewayUrl = outboxUrl.replace( - "ipns://", - "https://ipfs.hypha.coop/ipns/" - ); - response = await fetch(gatewayUrl); - } else { - response = await fetch(outboxUrl); - } + renderItem (item) { + const activityElement = document.createElement('distributed-activity') + activityElement.type = item.type + activityElement.data = item + this.appendChild(activityElement) + } - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - const outbox = await response.json(); - - // Adjust for both direct items and items loaded via URLs - const items = []; - for (const itemOrUrl of outbox.orderedItems) { - if (typeof itemOrUrl === "string") { - // URL to an activity - const itemResponse = await fetch(itemOrUrl); - if (itemResponse.ok) { - const item = await itemResponse.json(); - items.push(item); - } - } else { - items.push(itemOrUrl); // Directly included activity - } - } + createLoadMoreButton () { + this.removeLoadMoreButton() - this.totalPages = Math.ceil(items.length / this.numPosts); - this.page = Math.min(this.page, this.totalPages); + const loadMoreBtn = document.createElement('button') + loadMoreBtn.textContent = 'Load More' + loadMoreBtn.className = 'load-more-btn' - // Calculate the range of items to be loaded based on the current page and numPosts - const startIndex = (this.page - 1) * this.numPosts; - const endIndex = startIndex + this.numPosts; - const itemsToLoad = items.slice(startIndex, endIndex); + const loadMoreBtnWrapper = document.createElement('div') + loadMoreBtnWrapper.className = 'load-more-btn-container' + loadMoreBtnWrapper.appendChild(loadMoreBtn) - for (const item of itemsToLoad) { - yield item; - } - } catch (error) { - console.error("Error fetching outbox:", error); - } + loadMoreBtn.addEventListener('click', () => this.loadMore()) + this.appendChild(loadMoreBtnWrapper) } - renderItem(item) { - const activityElement = document.createElement("distributed-activity"); - activityElement.type = item.type; - activityElement.data = item; - this.appendChild(activityElement); + clearContent () { + this.innerHTML = '' + this.renderedItems.clear() } - nextPage() { - const currentPage = this.page; - if (currentPage < this.totalPages) { - this.setAttribute("page", currentPage + 1); + removeLoadMoreButton () { + const loadMoreBtnWrapper = this.querySelector('.load-more-btn-container') + if (loadMoreBtnWrapper) { + loadMoreBtnWrapper.remove() } } - prevPage() { - const currentPage = this.page; - this.setAttribute("page", Math.max(1, currentPage - 1)); - } - - attributeChangedCallback(name, oldValue, newValue) { - if (name === "url") { - this.clearContent(); - this.loadOutbox(newValue); - } else if (name === "num-posts" || name === "page") { - // Convert attribute name from kebab-case to camelCase - const propName = name.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); - this[propName] = parseInt(newValue, 10); - this.clearContent(); - this.loadOutbox(this.getAttribute("url")); + attributeChangedCallback (name, oldValue, newValue) { + if (name === 'url' && newValue !== oldValue) { + this.outboxUrl = newValue + this.loadOutbox(this.outboxUrl) } } - - clearContent() { - // Clear existing content - this.innerHTML = ""; - this.renderedItems.clear(); - } } // Register the new element with the browser -customElements.define("distributed-outbox", DistributedOutbox); +customElements.define('distributed-outbox', DistributedOutbox) class DistributedActivity extends HTMLElement { - constructor() { - super(); - this.activityType = ""; - this.activityData = {}; - this.activityUrl = null; + constructor () { + super() + this.activityType = '' + this.activityData = {} + this.activityUrl = null } - static get observedAttributes() { - return ["type", "data", "url"]; + static get observedAttributes () { + return ['type', 'data', 'url'] } - async connectedCallback() { + async connectedCallback () { // Check if the component already has type and data set as properties if (this.type && this.data) { - this.activityType = this.type; - this.activityData = this.data; - this.renderActivity(); - } + this.activityType = this.type + this.activityData = this.data + this.renderActivity() + } else if (this.activityUrl) { // Load from URL if type and data are not set - else if (this.activityUrl) { - await this.loadDataFromUrl(this.activityUrl); + await this.loadDataFromUrl(this.activityUrl) } else { - console.error("Activity data is not provided and no URL is specified."); + console.error('Activity data is not provided and no URL is specified.') } } - async loadDataFromUrl(activityUrl) { + async loadDataFromUrl (activityUrl) { try { - const response = await fetch(activityUrl); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - const activityData = await response.json(); - this.type = activityData.type; - this.data = activityData; - this.connectedCallback(); + const activityData = await db.getActivity(activityUrl) + this.type = activityData.type + this.data = activityData + this.connectedCallback() } catch (error) { - console.error("Error loading activity data from URL:", error); + console.error('Error loading activity data from URL:', error) } } - async fetchAndDisplayPost() { - let postUrl; + async fetchAndDisplayPost () { + let postUrl // Determine the source of the post (direct activity or URL pointing to the activity) const isDirectPost = - typeof this.activityData.object === "string" || - this.activityData.object instanceof String; + typeof this.activityData.object === 'string' || + this.activityData.object instanceof String if (isDirectPost) { - postUrl = this.activityData.object; + postUrl = this.activityData.object } else if (this.activityData.object && this.activityData.object.id) { - postUrl = this.activityData.id; + postUrl = this.activityData.object.id } else { - postUrl = this.activityData.object; - } - - try { - const response = await fetch(postUrl); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - const postData = await response.json(); - // Determine how to extract content based on the post source - const content = isDirectPost ? postData.content : postData.object.content; - this.displayPostContent(content, postUrl); - } catch (error) { - console.error("Error fetching post content:", error); + postUrl = this.activityData.object } - } - - displayPostContent(content, url) { - // Clear existing content - this.innerHTML = ""; - // Create and append the distributed-post component - const distributedPostElement = document.createElement("distributed-post"); - distributedPostElement.setAttribute("url", url); - this.appendChild(distributedPostElement); + // Create and append the distributed-post component without clearing previous content + const distributedPostElement = document.createElement('distributed-post') + distributedPostElement.setAttribute('url', postUrl) + this.appendChild(distributedPostElement) } - fetchAndUpdatePost(activityData) { - let postUrl; - // Determine the source of the post (direct activity or URL pointing to the activity) - const isDirectUpdate = - typeof activityData.object === "string" || - activityData.object instanceof String; - - if (isDirectUpdate) { - // If it's a direct update, use the URL from the 'object' property - postUrl = activityData.object; - } else if (activityData.object && activityData.object.id) { - // If the 'object' property contains an 'id', use it as the URL - postUrl = activityData.object.id; - } else { - // Otherwise, use the 'id' property of the activityData itself - postUrl = activityData.id; - } - - this.fetchAndDisplayPost(postUrl); + displayUnimplemented () { + const message = `Activity type ${this.activityType} is not implemented yet.` + const messageElement = document.createElement('p') + messageElement.textContent = message + this.appendChild(messageElement) } - renderActivity() { + renderActivity () { // Clear existing content - this.innerHTML = ""; + this.innerHTML = '' switch (this.activityType) { - case "Create": - this.fetchAndDisplayPost(); - break; - case "Update": - this.fetchAndUpdatePost(this.activityData); - break; - case "Follow": - this.displayFollowActivity(); - break; - case "Like": - this.displayLikeActivity(); - break; + case 'Create': + this.fetchAndDisplayPost() + break + case 'Update': + this.fetchAndDisplayPost() + break + case 'Announce': + this.displayRepostedActivity() + break + case 'Follow': + this.displayFollowActivity() + break + case 'Like': + this.displayLikeActivity() + break default: - const message = `Activity type ${this.activityType} is not implemented yet.`; - const messageElement = document.createElement("p"); - messageElement.textContent = message; - this.appendChild(messageElement); - break; + this.displayUnimplemented() + break } } - displayFollowActivity() { - const from = this.activityData.actor; - const to = this.activityData.object; - const message = `New follow request from ${from} to ${to}`; - const messageElement = document.createElement("p"); - messageElement.textContent = message; - this.appendChild(messageElement); + displayRepostedActivity () { + const actorUrl = this.activityData.actor + db.getActor(actorUrl).then(actorData => { + const actorDisplayName = actorData.preferredUsername || actorData.name || actorUrl.split('/').pop().split('@').pop() // Fallback to URL parsing if name is unavailable + const repostLabel = document.createElement('p') + repostLabel.textContent = `Reposted by ${actorDisplayName} ⇄` + repostLabel.className = 'repost-label' + this.appendChild(repostLabel) + this.fetchAndDisplayPost() + }).catch(error => { + console.error('Error loading actor data:', error) + this.fetchAndDisplayPost() // Continue to display the post even if actor loading fails + }) } - displayLikeActivity() { - const message = `New like on ${this.activityData.object}`; - const messageElement = document.createElement("p"); - messageElement.textContent = message; - this.appendChild(messageElement); + displayFollowActivity () { + const from = this.activityData.actor + const to = this.activityData.object + const message = `New follow request from ${from} to ${to}` + const messageElement = document.createElement('p') + messageElement.textContent = message + this.appendChild(messageElement) } - attributeChangedCallback(name, oldValue, newValue) { + displayLikeActivity () { + const message = `New like on ${this.activityData.object}` + const messageElement = document.createElement('p') + messageElement.textContent = message + this.appendChild(messageElement) + } + + attributeChangedCallback (name, oldValue, newValue) { if (newValue !== oldValue) { - if (name === "type") { - this.activityType = newValue; - this.renderActivity(); - } else if (name === "data") { - this.activityData = JSON.parse(newValue); - this.renderActivity(); - } else if (name === "url") { + if (name === 'type') { + this.activityType = newValue + this.renderActivity() + } else if (name === 'data') { + this.activityData = JSON.parse(newValue) + this.renderActivity() + } else if (name === 'url') { this.loadDataFromUrl(newValue) .then(() => { - this.renderActivity(); + this.renderActivity() }) .catch((error) => { - console.error("Error loading activity data from URL:", error); - }); + console.error('Error loading activity data from URL:', error) + }) } } } } // Register the new element with the browser -customElements.define("distributed-activity", DistributedActivity); +customElements.define('distributed-activity', DistributedActivity) diff --git a/post.css b/post.css index e0d9114..f788527 100644 --- a/post.css +++ b/post.css @@ -3,20 +3,13 @@ --rdp-font: "Arial", sans-serif; --rdp-bg-color: #ebf3f5; --rdp-text-color: #000000; + --rdp-cw-color: #f87171; + --rdp-link-color: #0ea5e9; --rdp-details-color: #4d626a; --rdp-border-color: #cccccc; --rdp-border-radius: 6px; } -/* Main styles */ -html { - font-family: var(--rdp-font); -} - -img { - max-width: 100%; -} - /* Component styles */ .distributed-post { background: var(--rdp-bg-color); @@ -45,6 +38,7 @@ img { border-radius: 50%; background-color: #000000; margin-right: 8px; + cursor: pointer; } .actor-details { @@ -55,6 +49,7 @@ img { .actor-name { color: var(--rdp-text-color); font-weight: bold; + cursor: pointer; } .actor-username { @@ -66,20 +61,57 @@ img { font-size: 0.875rem; color: var(--rdp-details-color); } +.time-ago:hover{ + text-decoration: none; +} + +.full-date{ + color: var(--rdp-details-color); +} +.full-date:hover{ + text-decoration: none; +} + +.cw-summary { + color: var(--rdp-cw-color); +} + +.see-more-toggle { + color: var(--rdp-details-color); + font-size: 0.725em; + margin-left: 4px; + cursor: pointer; +} .post-content { font-size: 16px; color: var(--rdp-text-color); padding: 6px; - margin-bottom: 16px; + margin-bottom: 10px; overflow-wrap: break-word; } +.post-content a{ + color: var(--rdp-link-color); + text-decoration: underline; +} +.post-content a:hover{ + text-decoration: none; +} + .post-footer { + margin-top: 10px; font-size: 0.875rem; color: var(--rdp-details-color); } +.individual-post{ + flex: 1; + max-width: 600px; + width: 100%; + margin: 0 20px; +} + @media (max-width: 768px) { .distributed-post { max-width: 80%; @@ -97,4 +129,8 @@ img { width: 40px; height: 40px; } + + .individual-post{ + margin-top: 170px; + } } diff --git a/post.html b/post.html index ce044c7..fe4fa5d 100644 --- a/post.html +++ b/post.html @@ -1,20 +1,26 @@ + + Reader Post -
- - - - +
+ +
+ +
+
+ +
- - - - + + + + + + + diff --git a/post.js b/post.js index 7daa4ce..2b7bd09 100644 --- a/post.js +++ b/post.js @@ -1,454 +1,381 @@ -import DOMPurify from "./dependencies/dompurify/purify.js"; +/* global customElements, HTMLElement */ +import DOMPurify from './dependencies/dompurify/purify.js' +import { db } from './dbInstance.js' -const ACCEPT_HEADER = - "application/activity+json, application/ld+json, application/json, text/html"; - -async function loadPost(url) { - try { - const headers = new Headers({ Accept: ACCEPT_HEADER }); - - const response = await fetch(url, { headers }); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const contentType = response.headers.get("content-type"); - if ( - contentType.includes("application/ld+json") || - contentType.includes("application/activity+json") || - contentType.includes("application/json") - ) { - // Directly return JSON-LD if the response is JSON-LD or ActivityPub type - return await response.json(); - } else if (contentType.includes("text/html")) { - // For HTML responses, look for the link in the HTTP headers - const linkHeader = response.headers.get("Link"); - if (linkHeader) { - const matches = linkHeader.match( - /<([^>]+)>;\s*rel="alternate";\s*type="application\/ld\+json"/ - ); - if (matches && matches[1]) { - // Found JSON-LD link in headers, fetch that URL - return fetchJsonLd(matches[1]); - } - } - // If no link header or alternate JSON-LD link is found, or response is HTML without JSON-LD link, process as HTML - const htmlContent = await response.text(); - const jsonLdUrl = await parsePostHtml(htmlContent); - if (jsonLdUrl) { - // Found JSON-LD link in HTML, fetch that URL - return fetchJsonLd(jsonLdUrl); - } - // No JSON-LD link found in HTML - throw new Error("No JSON-LD link found in the response"); - } - } catch (error) { - console.error("Error fetching post:", error); - } -} - -async function parsePostHtml(htmlContent) { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlContent, "text/html"); - const alternateLink = doc.querySelector('link[rel="alternate"]'); - return alternateLink ? alternateLink.href : null; -} - -async function fetchJsonLd(jsonLdUrl) { - try { - const headers = new Headers({ - Accept: "application/ld+json", - }); - - const response = await fetch(jsonLdUrl, { headers }); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error("Error fetching JSON-LD:", error); - } -} - -// async function loadPostFromIpfs(ipfsUrl) { -// try { -// // Try loading content using native IPFS URLs -// const nativeResponse = await fetch(ipfsUrl); -// if (nativeResponse.ok) { -// return await nativeResponse.text(); -// } -// } catch (error) { -// console.log("Native IPFS loading failed, trying HTTP gateway:", error); -// } - -// // Fallback to loading content via an HTTP IPFS gateway -// const gatewayUrl = ipfsUrl.replace("ipfs://", "https://ipfs.hypha.coop/ipfs/"); -// try { -// const gatewayResponse = await fetch(gatewayUrl); -// if (!gatewayResponse.ok) { -// throw new Error(`HTTP error! Status: ${gatewayResponse.status}`); -// } -// return await gatewayResponse.text(); -// } catch (error) { -// console.error("Error fetching IPFS content via HTTP gateway:", error); -// } -// } - -// Function to load content from IPNS with fallback to the IPNS HTTP gateway -async function loadPostFromIpns(ipnsUrl) { - try { - const nativeResponse = await fetch(ipnsUrl); - if (nativeResponse.ok) { - return await nativeResponse.text(); - } - } catch (error) { - console.log("Native IPNS loading failed, trying HTTP gateway:", error); - } - - // Fallback to loading content via an HTTP IPNS gateway - const gatewayUrl = ipnsUrl.replace( - "ipns://", - "https://ipfs.hypha.coop/ipns/" - ); - try { - const gatewayResponse = await fetch(gatewayUrl); - if (!gatewayResponse.ok) { - throw new Error(`HTTP error! Status: ${gatewayResponse.status}`); - } - return await gatewayResponse.text(); - } catch (error) { - console.error("Error fetching IPNS content via HTTP gateway:", error); - } -} - -// Function to load content from Hyper with fallback to the Hyper HTTP gateway -async function loadPostFromHyper(hyperUrl) { - try { - const nativeResponse = await fetch(hyperUrl); - if (nativeResponse.ok) { - return await nativeResponse.text(); - } - } catch (error) { - console.log("Native Hyper loading failed, trying HTTP gateway:", error); - } - - // Fallback to loading content via an HTTP Hyper gateway - const gatewayUrl = hyperUrl.replace( - "hyper://", - "https://hyper.hypha.coop/hyper/" - ); - try { - const gatewayResponse = await fetch(gatewayUrl); - if (!gatewayResponse.ok) { - throw new Error(`HTTP error! Status: ${gatewayResponse.status}`); - } - return await gatewayResponse.text(); - } catch (error) { - console.error("Error fetching Hyper content via HTTP gateway:", error); - } -} - -async function fetchActorInfo(actorUrl) { - try { - const response = await fetch(actorUrl); - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - return await response.json(); - } catch (error) { - console.error("Error fetching actor info:", error); - } -} - -function formatDate(dateString) { - const options = { year: "numeric", month: "short", day: "numeric" }; - return new Date(dateString).toLocaleDateString(undefined, options); +function formatDate (dateString) { + const options = { year: 'numeric', month: 'short', day: 'numeric' } + return new Date(dateString).toLocaleDateString(undefined, options) } // Helper function to calculate elapsed time (e.g., 1h, 1d, 1w) -function timeSince(dateString) { - const date = new Date(dateString); - const seconds = Math.floor((new Date() - date) / 1000); +function timeSince (dateString) { + const date = new Date(dateString) + const seconds = Math.floor((new Date() - date) / 1000) - let interval = seconds / 31536000; // 365 * 24 * 60 * 60 + let interval = seconds / 31536000 // 365 * 24 * 60 * 60 if (interval > 1) { - return formatDate(dateString); // Return formatted date if more than a year + return formatDate(dateString) // Return formatted date if more than a year } - interval = seconds / 2592000; // 30 * 24 * 60 * 60 + interval = seconds / 2592000 // 30 * 24 * 60 * 60 if (interval > 1) { - return Math.floor(interval) + "mo"; + return Math.floor(interval) + 'mo' } - interval = seconds / 604800; // 7 * 24 * 60 * 60 + interval = seconds / 604800 // 7 * 24 * 60 * 60 if (interval > 1) { - return Math.floor(interval) + "w"; + return Math.floor(interval) + 'w' } - interval = seconds / 86400; // 24 * 60 * 60 + interval = seconds / 86400 // 24 * 60 * 60 if (interval > 1) { - return Math.floor(interval) + "d"; + return Math.floor(interval) + 'd' } - interval = seconds / 3600; // 60 * 60 + interval = seconds / 3600 // 60 * 60 if (interval > 1) { - return Math.floor(interval) + "h"; + return Math.floor(interval) + 'h' } - interval = seconds / 60; + interval = seconds / 60 if (interval > 1) { - return Math.floor(interval) + "m"; + return Math.floor(interval) + 'm' } - return Math.floor(seconds) + "s"; -} - -function renderError(message) { - const errorElement = document.createElement("p"); - errorElement.classList.add("error"); - errorElement.textContent = message; - return errorElement; + return Math.floor(seconds) + 's' } // Define a class for the web component class DistributedPost extends HTMLElement { - static get observedAttributes() { - return ["url"]; + static get observedAttributes () { + return ['url'] } - connectedCallback() { - this.loadAndRenderPost(this.getAttribute("url")); + connectedCallback () { + this.loadAndRenderPost(this.getAttribute('url')) } - async loadAndRenderPost(postUrl) { + async loadAndRenderPost (postUrl) { if (!postUrl) { - this.renderErrorContent("No post URL provided"); - return; + this.renderErrorContent('No post URL provided') + return } try { - // Check if the URL directly points to a JSON-LD document - if (postUrl.endsWith(".jsonld")) { - const jsonLdData = await fetchJsonLd(postUrl); - this.renderPostContent(jsonLdData); - return; - } - - // Handle different URL schemes and HTML content - let content; - // if (postUrl.startsWith("ipfs://")) { - // content = await loadPostFromIpfs(postUrl); - // } - // Attempt to load content using native URLs or HTTP gateways based on the scheme - if (postUrl.startsWith("ipns://")) { - content = await loadPostFromIpns(postUrl); - } else if (postUrl.startsWith("hyper://")) { - content = await loadPostFromHyper(postUrl); - } else if (postUrl.startsWith("https://")) { - content = await loadPost(postUrl); - } else { - this.renderErrorContent("Unsupported URL scheme"); - return; - } + const content = await db.getNote(postUrl) - // For HTML content, attempt to find and fetch JSON-LD link within the content - if (typeof content === "object" && !content.summary) { - // Assuming JSON-LD content has a "summary" field - this.renderPostContent(content); - } else if (typeof content === "string") { - const jsonLdUrl = await parsePostHtml(content); - if (jsonLdUrl) { - const jsonLdData = await fetchJsonLd(jsonLdUrl); - this.renderPostContent(jsonLdData); - } else { - this.renderErrorContent("JSON-LD URL not found in the post"); - } - } else { - this.renderErrorContent("Invalid content type"); - } + // Assuming JSON-LD content has a "summary" field + this.renderPostContent(content) } catch (error) { - this.renderErrorContent(error.message); + console.error(error) + this.renderErrorContent(error.message) } } - renderPostContent(jsonLdData) { + async renderPostContent (jsonLdData) { // Clear existing content - this.innerHTML = ""; + this.innerHTML = '' + + // Check if jsonLdData is an activity instead of a note + if ('object' in jsonLdData) { + this.renderErrorContent('Expected a Note but received an Activity') + return + } // Create the container for the post - const postContainer = document.createElement("div"); - postContainer.classList.add("distributed-post"); + const postContainer = document.createElement('div') + postContainer.classList.add('distributed-post') // Header for the post, which will contain actor info and published time - const postHeader = document.createElement("header"); - postHeader.classList.add("distributed-post-header"); + const postHeader = document.createElement('header') + postHeader.classList.add('distributed-post-header') // Determine the source of 'attributedTo' based on the structure of jsonLdData - let attributedToSource = jsonLdData.attributedTo; - if ("object" in jsonLdData && "attributedTo" in jsonLdData.object) { - attributedToSource = jsonLdData.object.attributedTo; + let attributedToSource = jsonLdData.attributedTo + if ('object' in jsonLdData && 'attributedTo' in jsonLdData.object) { + attributedToSource = jsonLdData.object.attributedTo } // Create elements for each field, using the determined source for 'attributedTo' if (attributedToSource) { - const actorInfo = document.createElement("actor-info"); - actorInfo.setAttribute("url", attributedToSource); - postHeader.appendChild(actorInfo); + const actorInfo = document.createElement('actor-info') + actorInfo.setAttribute('url', attributedToSource) + postHeader.appendChild(actorInfo) } // Published time element - const publishedTime = document.createElement("time"); - publishedTime.classList.add("time-ago"); - const elapsed = timeSince(jsonLdData.published); - publishedTime.textContent = elapsed; - postHeader.appendChild(publishedTime); + const publishedTime = document.createElement('a') + publishedTime.href = `/post.html?url=${encodeURIComponent(db.getObjectPage(jsonLdData))}` + publishedTime.classList.add('time-ago') + const elapsed = timeSince(jsonLdData.published) + publishedTime.textContent = elapsed + postHeader.appendChild(publishedTime) // Append the header to the post container - postContainer.appendChild(postHeader); + postContainer.appendChild(postHeader) // Main content of the post - const postContent = document.createElement("div"); - postContent.classList.add("post-content"); + const postContent = document.createElement('div') + postContent.classList.add('post-content') // Determine content source based on structure of jsonLdData - let contentSource = - jsonLdData.content || (jsonLdData.object && jsonLdData.object.content); + const contentSource = jsonLdData.content || (jsonLdData.object && jsonLdData.object.content) + + // Sanitize content and create a DOM from it + const sanitizedContent = DOMPurify.sanitize(contentSource) + const parser = new DOMParser() + const contentDOM = parser.parseFromString(sanitizedContent, 'text/html') + + // Process all anchor elements to handle actor and posts mentions + const anchors = contentDOM.querySelectorAll('a') + anchors.forEach(async (anchor) => { + const href = anchor.getAttribute('href') + if (href) { + const fediverseActorMatch = href.match(/^(https?|ipns|hyper):\/\/([^\/]+)\/@(\w+)$/) + const jsonldActorMatch = href.endsWith('about.jsonld') + const mastodonPostMatch = href.match(/^(https?|ipns|hyper):\/\/([^\/]+)\/@(\w+)\/(\d+)$/) + const jsonldPostMatch = href.endsWith('.jsonld') + + if (fediverseActorMatch || jsonldActorMatch) { + anchor.setAttribute('href', `/profile.html?actor=${encodeURIComponent(href)}`) + try { + const actorData = await db.getActor(href) + if (actorData) { + anchor.setAttribute('href', `/profile.html?actor=${encodeURIComponent(href)}`) + } else { + console.log('Actor not found in DB, default redirection applied.') + } + } catch (error) { + console.error('Error fetching actor data:', error) + } + } else if (mastodonPostMatch || jsonldPostMatch) { + anchor.setAttribute('href', `/post.html?url=${encodeURIComponent(href)}`) + try { + const noteData = await db.getNote(href) + if (noteData) { + anchor.setAttribute('href', `/post.html?url=${encodeURIComponent(href)}`) + } else { + console.log('Post not found in DB, default redirection applied.') + } + } catch (error) { + console.error('Error fetching note data:', error) + } + } else { + anchor.setAttribute('href', href) + } + } + }) // Determine if the content is marked as sensitive in either the direct jsonLdData or within jsonLdData.object - let isSensitive = + const isSensitive = jsonLdData.sensitive || - (jsonLdData.object && jsonLdData.object.sensitive); + (jsonLdData.object && jsonLdData.object.sensitive) + + const summary = + jsonLdData.summary || + (jsonLdData.object && jsonLdData.object.summary) // Handle sensitive content if (isSensitive) { - const details = document.createElement("details"); - const summary = document.createElement("summary"); - summary.textContent = "Sensitive Content (click to view)"; - details.appendChild(summary); - const content = document.createElement("p"); - content.innerHTML = DOMPurify.sanitize(contentSource); - details.appendChild(content); - postContent.appendChild(details); + const details = document.createElement('details') + const summary = document.createElement('summary') + summary.classList.add('cw-summary') + summary.textContent = 'Sensitive Content (click to view)' + details.appendChild(summary) + const content = document.createElement('p') + content.innerHTML = DOMPurify.sanitize(contentSource) + details.appendChild(content) + postContent.appendChild(details) + } else if (summary) { + // Non-sensitive content with a summary (post title) + const details = document.createElement('details') + const summaryElement = document.createElement('summary') + summaryElement.textContent = summary // Post title goes here + details.appendChild(summaryElement) + + // Adding the "Show more" and "Show less" toggle text + const toggleText = document.createElement('span') + toggleText.textContent = 'Show more' + toggleText.classList.add('see-more-toggle') + summaryElement.appendChild(toggleText) + + const contentElement = document.createElement('p') + contentElement.innerHTML = DOMPurify.sanitize(jsonLdData.content) + details.appendChild(contentElement) + postContent.appendChild(details) + + // Event listener to toggle the text of the Show more/Show less element + details.addEventListener('toggle', function () { + toggleText.textContent = details.open ? 'Show less' : 'Show more' + }) } else { - const content = document.createElement("p"); - content.innerHTML = DOMPurify.sanitize(contentSource); - postContent.appendChild(content); + const content = document.createElement('p') + content.innerHTML = contentDOM.body.innerHTML + postContent.appendChild(content) } // Append the content to the post container - postContainer.appendChild(postContent); - - // Footer of the post, which will contain the full published date and platform - const postFooter = document.createElement("footer"); - postFooter.classList.add("post-footer"); - const fullDate = document.createElement("div"); - fullDate.classList.add("full-date"); - fullDate.textContent = formatDate(jsonLdData.published) + " · reader web"; - postFooter.appendChild(fullDate); + postContainer.appendChild(postContent) + + // Footer of the post, which will contain the full published date and platform, but only the date is clickable + const postFooter = document.createElement('footer') + postFooter.classList.add('post-footer') + + // Create a container for the full date and additional text + const dateContainer = document.createElement('div') + + // Create the clickable link for the date + const fullDateLink = document.createElement('a') + fullDateLink.href = `/post.html?url=${encodeURIComponent(jsonLdData.id)}` + fullDateLink.classList.add('full-date') + fullDateLink.textContent = formatDate(jsonLdData.published) + dateContainer.appendChild(fullDateLink) + + // Add the ' · reader web' text outside of the link + const readerWebText = document.createElement('span') + readerWebText.textContent = ' · reader web' + dateContainer.appendChild(readerWebText) + + // Append the date container to the footer + postFooter.appendChild(dateContainer) + + // Handle attachments of other Fedi instances + if (!isSensitive && !jsonLdData.summary && jsonLdData.attachment && jsonLdData.attachment.length > 0) { + const attachmentsContainer = document.createElement('div') + attachmentsContainer.className = 'attachments-container' + + jsonLdData.attachment.forEach(attachment => { + if (attachment.mediaType.startsWith('image/')) { + // If it's an image + const img = document.createElement('img') + img.src = attachment.url + img.alt = attachment.name || 'Attached image' + img.className = 'attachment-image' + attachmentsContainer.appendChild(img) + } else if (attachment.mediaType.startsWith('video/')) { + // If it's a video + const video = document.createElement('video') + video.src = attachment.url + video.alt = attachment.name || 'Attached video' + video.className = 'attachment-video' + video.controls = true + attachmentsContainer.appendChild(video) + } + }) + postContainer.appendChild(attachmentsContainer) + } // Append the footer to the post container - postContainer.appendChild(postFooter); + postContainer.appendChild(postFooter) // Append the whole post container to the custom element - this.appendChild(postContainer); + this.appendChild(postContainer) } // appendField to optionally allow HTML content - appendField(label, value, isHTML = false) { + appendField (label, value, isHTML = false) { if (value) { - const p = document.createElement("p"); - const strong = document.createElement("strong"); - strong.textContent = `${label}:`; - p.appendChild(strong); + const p = document.createElement('p') + const strong = document.createElement('strong') + strong.textContent = `${label}:` + p.appendChild(strong) if (isHTML) { // If the content is HTML, set innerHTML directly - const span = document.createElement("span"); - span.innerHTML = value; - p.appendChild(span); + const span = document.createElement('span') + span.innerHTML = value + p.appendChild(span) } else { // If not, treat it as text - p.appendChild(document.createTextNode(` ${value}`)); + p.appendChild(document.createTextNode(` ${value}`)) } - this.appendChild(p); + this.appendChild(p) } } - renderErrorContent(errorMessage) { + renderErrorContent (errorMessage) { // Clear existing content - this.innerHTML = ""; + this.innerHTML = '' - const errorElement = document.createElement("p"); - errorElement.className = "error"; - errorElement.textContent = errorMessage; - errorElement.style.color = "red"; - this.appendChild(errorElement); + const errorComponent = document.createElement('error-message') + errorComponent.setAttribute('message', errorMessage) + this.appendChild(errorComponent) } } // Register the new element with the browser -customElements.define("distributed-post", DistributedPost); +customElements.define('distributed-post', DistributedPost) // Define a class for the web component class ActorInfo extends HTMLElement { - static get observedAttributes() { - return ["url"]; + static get observedAttributes () { + return ['url'] + } + + constructor () { + super() + this.actorUrl = '' } - attributeChangedCallback(name, oldValue, newValue) { - if (name === "url" && newValue) { - this.fetchAndRenderActorInfo(newValue); + attributeChangedCallback (name, oldValue, newValue) { + if (name === 'url' && newValue) { + this.actorUrl = newValue + this.fetchAndRenderActorInfo(newValue) } } - async fetchAndRenderActorInfo(url) { + navigateToActorProfile () { + window.location.href = `/profile.html?actor=${encodeURIComponent(this.actorUrl)}` + } + + async fetchAndRenderActorInfo (url) { try { - const actorInfo = await fetchActorInfo(url); + const actorInfo = await db.getActor(url) if (actorInfo) { // Clear existing content - this.innerHTML = ""; + this.innerHTML = '' - const author = document.createElement("div"); - author.classList.add("distributed-post-author"); + const author = document.createElement('div') + author.classList.add('distributed-post-author') - const authorDetails = document.createElement("div"); - authorDetails.classList.add("actor-details"); + const authorDetails = document.createElement('div') + authorDetails.classList.add('actor-details') // Handle both single icon object and array of icons - let iconUrl = null; + let iconUrl = './assets/profile.png' // Default profile image path if (actorInfo.icon) { if (Array.isArray(actorInfo.icon) && actorInfo.icon.length > 0) { - iconUrl = actorInfo.icon[0].url; + iconUrl = actorInfo.icon[0].url || actorInfo.id } else if (actorInfo.icon.url) { - iconUrl = actorInfo.icon.url; - } - - if (iconUrl) { - const img = document.createElement("img"); - img.classList.add("actor-icon"); - img.src = iconUrl; - img.alt = actorInfo.name ? actorInfo.name : "Actor icon"; - author.appendChild(img); + iconUrl = actorInfo.icon.url || actorInfo.id } } + const img = document.createElement('img') + img.classList.add('actor-icon') + img.src = iconUrl + img.alt = actorInfo.name ? actorInfo.name : 'Actor icon' + img.addEventListener('click', this.navigateToActorProfile.bind(this)) + author.appendChild(img) + if (actorInfo.name) { - const pName = document.createElement("div"); - pName.classList.add("actor-name"); - pName.textContent = actorInfo.name; - authorDetails.appendChild(pName); + const pName = document.createElement('div') + pName.classList.add('actor-name') + pName.textContent = actorInfo.name + pName.addEventListener('click', this.navigateToActorProfile.bind(this)) + authorDetails.appendChild(pName) } if (actorInfo.preferredUsername) { - const pUserName = document.createElement("div"); - pUserName.classList.add("actor-username"); - pUserName.textContent = `@${actorInfo.preferredUsername}`; - authorDetails.appendChild(pUserName); + const pUserName = document.createElement('div') + pUserName.classList.add('actor-username') + pUserName.textContent = `@${actorInfo.preferredUsername}` + authorDetails.appendChild(pUserName) } // Append the authorDetails to the author div - author.appendChild(authorDetails); + author.appendChild(authorDetails) // Append the author container to the actor-info component - this.appendChild(author); + this.appendChild(author) } } catch (error) { - const errorElement = renderError(error.message); - this.appendChild(errorElement); + const errorElement = renderError(error.message) + this.appendChild(errorElement) } } } // Register the new element with the browser -customElements.define("actor-info", ActorInfo); +customElements.define('actor-info', ActorInfo) diff --git a/profile.html b/profile.html new file mode 100644 index 0000000..83c9e8b --- /dev/null +++ b/profile.html @@ -0,0 +1,56 @@ + + + +User Profile + +
+ +
+ + +
+
+ +
+
+ + + + + + + + diff --git a/search-template.html b/search-template.html new file mode 100644 index 0000000..1bd4f92 --- /dev/null +++ b/search-template.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/search.css b/search.css new file mode 100644 index 0000000..5b190b6 --- /dev/null +++ b/search.css @@ -0,0 +1,18 @@ +distributed-search form { + display: flex; + margin-top: 8px; +} + +distributed-search input { + width: 10em; + padding: 4px; + border: 1px solid var(--rdp-border-color); + border-radius: 2px; +} + +distributed-search button { + margin-left: 4px; + border: 1px solid var(--rdp-border-color); + border-radius: 2px; + cursor: pointer; +} diff --git a/search.js b/search.js new file mode 100644 index 0000000..0d4f7f9 --- /dev/null +++ b/search.js @@ -0,0 +1,69 @@ +import { db } from './dbInstance.js' + +const response = await fetch('./search-template.html') +const text = await response.text() +const template = document.createElement('template') +template.innerHTML = text + +const style = document.createElement('style') +style.textContent = '@import url("./search.css");' +document.head.appendChild(style) + +class DistributedSearch extends HTMLElement { + constructor () { + super() + this.init() + } + + get form () { + return this.querySelector('form') + } + + get url () { + return this.querySelector('[name=url]').value + } + + init () { + const instance = template.content.cloneNode(true) + this.appendChild(instance) + + this.form.addEventListener('submit', (e) => { + e.preventDefault() + this.handleSubmit(e) + }) + } + + async handleSubmit () { + // TODO: Detect `@username@domain syntax + const url = this.url + try { + // TODO: Redirect to p2p version + const data = await db.resolveURL(url) + const { id, type } = data + + if (type === 'Person' || type === 'Service') { + const newURL = new URL('/profile.html', window.location.href) + newURL.searchParams.set('actor', id) + window.location.href = newURL.href + } else if (type === 'Note') { + const newURL = new URL('/post.html', window.location.href) + newURL.searchParams.set('url', id) + window.location.href = newURL.href + } else { + throw new Error(`Invalid JSON-LD type: ${type}`) + } + } catch (e) { + this.showError(e) + } + } + + showError (e) { + console.error(e) + const element = document.createElement('error-message') + element.setAttribute('message', e.message) + + this.appendChild(element) + } +} + +customElements.define('distributed-search', DistributedSearch) diff --git a/sidebar.css b/sidebar.css new file mode 100644 index 0000000..56a737b --- /dev/null +++ b/sidebar.css @@ -0,0 +1,86 @@ +sidebar-nav .header-branding { + color: var(--rdp-text-color); +} +sidebar-nav .home-page-link { + text-decoration: none; + cursor: pointer; +} + +sidebar-nav { + position: sticky; + top: 0; + flex: 0 0 250px; + display: flex; + flex-direction: column; + align-items: flex-start; + height: 100vh; + overflow-y: auto; +} + +sidebar-nav h1 { + font-family: "Times New Roman", Times, serif; + font-size: 1.8em; + font-weight: normal; + margin-bottom: 0.6em; +} + +sidebar-nav .controls a { + color: var(--rdp-text-color); + text-decoration: none; + font-size: 0.875rem; + font-weight: bold; + margin-bottom: 0.4em; +} +sidebar-nav .controls a:hover { + text-decoration: underline; +} + +sidebar-nav nav { + display: flex; + flex-direction: column; +} + +sidebar-nav nav a { + color: var(--rdp-details-color); + text-decoration: underline; + font-size: 0.775rem; +} + +sidebar-nav nav a:hover { + text-decoration: none; +} + +@media screen and (max-width: 1280px) { + sidebar-nav { + flex: 0 0 200px; + margin-left: 14px; + } +} + +@media screen and (max-width: 768px) { + sidebar-nav { + width: 100%; + position: fixed; + top: 0; + z-index: 100; + max-height: calc(100vh - 78vh); + overflow-y: auto; + align-items: center; + background-color: var(--bg-color); + } + + sidebar-nav h1 { + margin-bottom: 0.2em; + text-align: center; + } + + sidebar-nav nav { + text-align: center; + } +} + +@media screen and (max-width: 400px) { + sidebar-nav { + padding-bottom: 50px; + } +} diff --git a/sidebar.html b/sidebar.html new file mode 100644 index 0000000..96b0b87 --- /dev/null +++ b/sidebar.html @@ -0,0 +1,13 @@ + +

Social Reader

+
+ + + \ No newline at end of file diff --git a/sidebar.js b/sidebar.js new file mode 100644 index 0000000..ff74048 --- /dev/null +++ b/sidebar.js @@ -0,0 +1,24 @@ +import './search.js' + +const response = await fetch('./sidebar.html') +const text = await response.text() +const template = document.createElement('template') +template.innerHTML = text + +const style = document.createElement('style') +style.textContent = '@import url("./sidebar.css");' +document.head.appendChild(style) + +class SidebarNav extends HTMLElement { + constructor () { + super() + this.init() + } + + init () { + const instance = template.content.cloneNode(true) + this.appendChild(instance) + } +} + +customElements.define('sidebar-nav', SidebarNav) diff --git a/theme-selector.js b/theme-selector.js new file mode 100644 index 0000000..6eadb1e --- /dev/null +++ b/theme-selector.js @@ -0,0 +1,141 @@ +import { db } from './dbInstance.js' + +class ThemeSelector extends HTMLElement { + constructor () { + super() + this.attachShadow({ mode: 'open' }) + this.shadowRoot.appendChild(this.buildTemplate()) + this.shadowRoot.querySelector('#theme-select').addEventListener('change', this.changeTheme.bind(this)) + } + + async connectedCallback () { + // Append colorblind filters to the main document + document.body.appendChild(this.createColorBlindFilters()) + const currentTheme = await db.getTheme() + this.shadowRoot.querySelector('#theme-select').value = currentTheme || 'light' + this.applyTheme(currentTheme || 'light') + } + + appendColorBlindFiltersToBody () { + const existingSvg = document.querySelector('#colorblind-filters') + if (!existingSvg) { + document.body.appendChild(createColorBlindFilters()) + } + } + + changeTheme (event) { + const newTheme = event.target.value + db.setTheme(newTheme) + this.applyTheme(newTheme) + } + + applyTheme (themeName) { + document.documentElement.setAttribute('data-theme', themeName) + } + + buildTemplate () { + const template = document.createElement('template') + + const style = document.createElement('style') + style.textContent = ` + select { + padding: 4px; + margin: 6px 0; + border: 1px solid var(--rdp-border-color); + border-radius: 4px; + width: 60px; + } + ` + + // Create the select element + const select = document.createElement('select') + select.id = 'theme-select' + + // Create and append the options + const options = [ + { value: 'light', text: 'Light' }, + { value: 'dark', text: 'Dark' }, + { value: '', text: '👁️ Color Blind Themes 👁️', disabled: true }, + { value: 'deuteranomaly', text: 'Deuteranomaly (Green-Weak)' }, + { value: 'protanomaly', text: 'Protanomaly (Red-Weak)' }, + { value: 'deuteranopia', text: 'Deuteranopia (Green-Blind)' }, + { value: 'protanopia', text: 'Protanopia (Red-Blind)' }, + { value: 'tritanopia', text: 'Tritanopia (Blue-Blind)' }, + { value: 'tritanomaly', text: 'Tritanomaly (Blue-Weak)' }, + { value: 'achromatopsia', text: 'Achromatopsia (All-Color-Blind)' } + ] + + // Create and append the options + options.forEach(({ value, text }) => { + const option = document.createElement('option') + option.value = value + option.textContent = text + select.appendChild(option) + }) + + // Append the select & style to the template's content + template.content.appendChild(select) + template.content.appendChild(style) + + return template.content + } + + createColorBlindFilters () { + const svgNS = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(svgNS, 'svg') + svg.setAttribute('id', 'colorblind-filters') + svg.setAttribute('style', 'display: none') + + const defs = document.createElementNS(svgNS, 'defs') + + const filters = [ + { + id: 'deuteranopia', + values: '0.29031,0.70969,0.00000,0,0 0.29031,0.70969,0.00000,0,0 -0.02197,0.02197,1.00000,0,0 0,0,0,1,0' + }, + { + id: 'deuteranomaly', + values: '0.57418,0.42582,0.00000,0,0 0.17418,0.82582,0.00000,0,0 -0.01318,0.01318,1.00000,0,0 0,0,0,1,0' + }, + { + id: 'protanopia', + values: '0.10889,0.89111,0.00000,0,0 0.10889,0.89111,0.00000,0,0 0.00447,-0.00447,1.00000,0,0 0,0,0,1,0' + }, + { + id: 'protanomaly', + values: '0.46533,0.53467,0.00000,0,0 0.06533,0.93467,0.00000,0,0 0.00268,-0.00268,1.00000,0,0 0,0,0,1,0' + }, + { + id: 'tritanopia', + values: '1.00000,0.15236,-0.15236,0,0 0.00000,0.86717,0.13283,0,0 0.00000,0.86717,0.13283,0,0 0,0,0,1,0' + }, + { + id: 'tritanomaly', + values: '1.00000,0.09142,-0.09142,0,0 0.00000,0.92030,0.07970,0,0 0.00000,0.52030,0.47970,0,0 0,0,0,1,0' + }, + { + id: 'achromatopsia', + values: '0.299,0.587,0.114,0,0 0.299,0.587,0.114,0,0 0.299,0.587,0.114,0,0 0,0,0,1,0' + } + ] + + // Iterate through each filter and append to defs + filters.forEach((filter) => { + const filterElem = document.createElementNS(svgNS, 'filter') + filterElem.setAttribute('id', filter.id) + filterElem.setAttribute('color-interpolation-filters', 'linearRGB') + + const feColorMatrix = document.createElementNS(svgNS, 'feColorMatrix') + feColorMatrix.setAttribute('type', 'matrix') + feColorMatrix.setAttribute('values', filter.values) + + filterElem.appendChild(feColorMatrix) + defs.appendChild(filterElem) + }) + + svg.appendChild(defs) + return svg + } +} + +customElements.define('theme-selector', ThemeSelector) diff --git a/theme.css b/theme.css new file mode 100644 index 0000000..03f2e4f --- /dev/null +++ b/theme.css @@ -0,0 +1,124 @@ +:root[data-theme="light"] { + --bg-color: #ffffff; + --rdp-bg-color: #ebf3f5; + --rdp-text-color: #000000; + --rdp-cw-color: #f87171; + --rdp-link-color: #0ea5e9; + --rdp-details-color: #4d626a; + --rdp-border-color: #cccccc; +} + +:root[data-theme="dark"] { + --bg-color: #18181b; + --rdp-bg-color: #27272a; + --rdp-text-color: #d1d5db; + --rdp-cw-color: #f87171; + --rdp-link-color: #0ea5e9; + --rdp-details-color: #94a3b8; + --rdp-border-color: #71717a; +} + +:root[data-theme="deuteranomaly"] { + --bg-color: #ffffff; + --rdp-bg-color: #ebf3f5; + --rdp-text-color: #000000; + --rdp-cw-color: #d09b6b; + --rdp-link-color: #3faae9; + --rdp-details-color: #4d626a; + --rdp-border-color: #cccccc; +} + +:root[data-theme="protanomaly"] { + --bg-color: #ffffff; + --rdp-bg-color: #ebf3f5; + --rdp-text-color: #000000; + --rdp-cw-color: #c38473; + --rdp-link-color: #3ba5e9; + --rdp-details-color: #5b636c; + --rdp-border-color: #cccccc; +} + +:root[data-theme="deuteranopia"] { + --bg-color: #ffffff; + --rdp-bg-color: #f0f0f5; + --rdp-text-color: #000000; + --rdp-cw-color: #b0b067; + --rdp-link-color: #9190ea; + --rdp-details-color: #5c5b6a; + --rdp-border-color: #cccccc; +} + +:root[data-theme="protanopia"] { + --bg-color: #ffffff; + --rdp-bg-color: #f2f2f5; + --rdp-text-color: #000000; + --rdp-cw-color: #8e8e74; + --rdp-link-color: #9e9de8; + --rdp-details-color: #60606a; + --rdp-border-color: #cccccc; +} + +:root[data-theme="tritanopia"] { + --bg-color: #ffffff; + --rdp-bg-color: #ebf3f3; + --rdp-text-color: #000000; + --rdp-cw-color: #f5716e; + --rdp-link-color: #3cb0b0; + --rdp-details-color: #5b7070; + --rdp-border-color: #cccccc; +} + +:root[data-theme="tritanomaly"] { + --bg-color: #ffffff; + --rdp-bg-color: #ebf3f4; + --rdp-text-color: #000000; + --rdp-cw-color: #f5716f; + --rdp-link-color: #40b6d0; + --rdp-details-color: #4c6365; + --rdp-border-color: #cccccc; +} + +:root[data-theme="achromatopsia"] { + --bg-color: #ffffff; + --rdp-bg-color: #f0f0f0; + --rdp-text-color: #000000; + --rdp-cw-color: #9e9e9e; + --rdp-link-color: #8d8d8d; + --rdp-details-color: #5b5b5b; + --rdp-border-color: #cccccc; +} + +:root[data-theme="deuteranomaly"] img, +:root[data-theme="deuteranomaly"] video { + filter: url(#deuteranomaly); +} + +:root[data-theme="protanomaly"] img, +:root[data-theme="protanomaly"] video { + filter: url(#protanomaly); +} + +:root[data-theme="deuteranopia"] img, +:root[data-theme="deuteranopia"] video { + filter: url(#deuteranopia); +} + +:root[data-theme="protanopia"] img, +:root[data-theme="protanopia"] video { + filter: url(#protanopia); +} + +:root[data-theme="tritanopia"] img, +:root[data-theme="tritanopia"] video { + filter: url(#tritanopia); +} + +:root[data-theme="tritanomaly"] img, +:root[data-theme="tritanomaly"] video { + filter: url(#tritanomaly); +} + +:root[data-theme="achromatopsia"] img, +:root[data-theme="achromatopsia"] video { + filter: url(#achromatopsia); +} diff --git a/timeline.css b/timeline.css index 69938c4..c322362 100644 --- a/timeline.css +++ b/timeline.css @@ -6,43 +6,7 @@ body { align-items: flex-start; min-height: 100vh; font-family: var(--rdp-font); -} - -.container { - display: flex; - justify-content: center; - align-items: flex-start; - max-width: 1200px; - width: 100%; - margin-top: 20px; -} - -.sidebar { - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.sidebar h1 { - font-family: "Times New Roman", Times, serif; - font-size: 1.8em; - font-weight: normal; - margin-bottom: 0.6em; -} - -.sidebar nav { - display: flex; - flex-direction: column; -} - -.sidebar nav a { - color: var(--rdp-details-color); - text-decoration: underline; - font-size: 0.775rem; -} - -.sidebar nav a:hover { - text-decoration: underline; + background: var(--bg-color); } reader-timeline { @@ -53,35 +17,9 @@ reader-timeline { } @media screen and (max-width: 768px) { - .container { - flex-direction: column; - align-items: center; - } - - .sidebar { - width: 100%; - position: fixed; - top: 0; - z-index: 100; - max-height: 100vh; - overflow-y: auto; - align-items: center; - background-color: var(--bg-color); - padding: 10px; - } - - .sidebar h1 { - margin-bottom: 0.2em; - text-align: center; - } - - .sidebar nav { - text-align: center; - } - reader-timeline { width: 100%; max-width: 100%; - margin-top: 130px; + margin-top: 175px; } } diff --git a/timeline.js b/timeline.js index cb36235..b42477b 100644 --- a/timeline.js +++ b/timeline.js @@ -1,51 +1,85 @@ -import { db } from "./dbInstance.js"; +import { db } from './dbInstance.js' + +let hasLoaded = false class ReaderTimeline extends HTMLElement { - constructor() { - super(); - this.actorUrls = [ - "https://staticpub.mauve.moe/about.jsonld", - "https://hypha.coop/about.jsonld", - "https://prueba-cola-de-moderacion-2.sutty.nl/about.jsonld", - ]; - this.processedNotes = new Set(); // To keep track of notes already processed + skip = 0 + limit = 32 + hasMoreItems = true + loadMoreBtn = null + + constructor () { + super() + this.loadMoreBtn = document.createElement('button') + this.loadMoreBtn.textContent = 'Load More..' + this.loadMoreBtn.className = 'load-more-btn' + + this.loadMoreBtnWrapper = document.createElement('div') + this.loadMoreBtnWrapper.className = 'load-more-btn-container' + this.loadMoreBtnWrapper.appendChild(this.loadMoreBtn) + + this.loadMoreBtn.addEventListener('click', () => this.loadMore()) + } + + connectedCallback () { + this.initializeDefaultFollowedActors().then(() => this.initTimeline()) + } + + async initializeDefaultFollowedActors () { + const defaultActors = [ + 'https://social.distributed.press/v1/@announcements@social.distributed.press/', + 'ipns://distributed.press/about.ipns.jsonld', + 'hyper://hypha.coop/about.hyper.jsonld', + 'https://sutty.nl/about.jsonld' + // "https://akhilesh.sutty.nl/about.jsonld", + // "https://staticpub.mauve.moe/about.jsonld", + ] + + // Check if followed actors have already been initialized + const hasFollowedActors = await db.hasFollowedActors() + if (!hasFollowedActors) { + await Promise.all( + defaultActors.map(async (actorUrl) => { + await db.followActor(actorUrl) + }) + ) + } } - connectedCallback() { - this.initTimeline(); + async initTimeline () { + if (!hasLoaded) { + hasLoaded = true + const followedActors = await db.getFollowedActors() + await Promise.all(followedActors.map(({ url }) => db.ingestActor(url))) + } + this.loadMore() } - async initTimeline() { - this.innerHTML = ""; // Clear existing content + async loadMore () { + // Remove the button before loading more items + this.loadMoreBtnWrapper.remove() - for (const actorUrl of this.actorUrls) { - try { - console.log("Loading actor:", actorUrl); - await db.ingestActor(actorUrl); - } catch (error) { - console.error(`Error loading actor ${actorUrl}:`, error); - } + let count = 0 + for await (const note of db.searchNotes({}, { skip: this.skip, limit: this.limit })) { + count++ + this.appendNoteElement(note) } - // After ingesting all actors, search for all notes once - try { - const allNotes = await db.searchNotes({}); - // Sort all notes by published date in descending order - allNotes.sort((a, b) => new Date(b.published) - new Date(a.published)); - - // Create and append elements for each note - allNotes.forEach((note) => { - if (!this.processedNotes.has(note.id)) { - const activityElement = document.createElement("distributed-post"); - activityElement.setAttribute("url", note.id); - this.appendChild(activityElement); - this.processedNotes.add(note.id); // Mark this note as processed - } - }); - } catch (error) { - console.error(`Error retrieving notes:`, error); + // Update skip value and determine if there are more items + this.skip += this.limit + this.hasMoreItems = count === this.limit + + // Append the button at the end if there are more items + if (this.hasMoreItems) { + this.appendChild(this.loadMoreBtnWrapper) } } + + appendNoteElement (note) { + const activityElement = document.createElement('distributed-post') + activityElement.setAttribute('url', note.id) + this.appendChild(activityElement) + } } -customElements.define("reader-timeline", ReaderTimeline); +customElements.define('reader-timeline', ReaderTimeline)