diff --git a/.gitignore.orig b/.gitignore.orig new file mode 100644 index 0000000000..c8927af538 --- /dev/null +++ b/.gitignore.orig @@ -0,0 +1,82 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config and downloaded libraries. +/.bundle +/vendor/bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-journal + +# Ignore all logfiles and tempfiles. +.eslintcache +/log/* +!/log/.keep +/tmp +/coverage +/public/system +/public/assets +/public/packs +/public/packs-test +.env +.env.production +/node_modules/ +/build/ + +# Ignore Vagrant files +.vagrant/ + +# Ignore IDE files +.vscode/ +.idea/ + +# Ignore postgres + redis + elasticsearch volume optionally created by docker-compose +/postgres +/postgres14 +/redis +/elasticsearch + +# Ignore Apple files +.DS_Store + +# Ignore vim files +*~ +*.swp + +# Ignore npm debug log +npm-debug.log + +# Ignore yarn log files +yarn-error.log +yarn-debug.log + +# From https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Ignore vagrant log files +*-cloudimg-console.log + +# Ignore Docker option files +docker-compose.override.yml + +<<<<<<< HEAD +# Ignore dotenv .local files +.env*.local +======= +/public/packs +/public/packs-test +/node_modules +/yarn-error.log +yarn-debug.log* +.yarn-integrity +>>>>>>> e94c6b5887 (Wobbl customizations on Glitch-SOC) diff --git a/app/javascript/flavours/glitch/entrypoints/common-modern.js b/app/javascript/flavours/glitch/entrypoints/common-modern.js index 9bd42e6109..837e079b5e 100644 --- a/app/javascript/flavours/glitch/entrypoints/common-modern.js +++ b/app/javascript/flavours/glitch/entrypoints/common-modern.js @@ -1,4 +1,4 @@ -import 'packs/public-path'; +import '@/entrypoints/public-path'; import Rails from '@rails/ujs'; import 'flavours/glitch/styles/modern.scss'; diff --git a/app/javascript/flavours/glitch/entrypoints/common-modern.js.orig b/app/javascript/flavours/glitch/entrypoints/common-modern.js.orig new file mode 100644 index 0000000000..59332e1712 --- /dev/null +++ b/app/javascript/flavours/glitch/entrypoints/common-modern.js.orig @@ -0,0 +1,8 @@ +import 'packs/public-path'; +import { start } from '@rails/ujs'; +import 'flavours/glitch/styles/modern.scss'; + +start(); + +// This ensures that webpack compiles our images. +require.context('../images', true); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/admin.tsx b/app/javascript/flavours/glitch/modern-entrypoints/admin.tsx new file mode 100644 index 0000000000..209799ca26 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/admin.tsx @@ -0,0 +1,368 @@ +import '@/entrypoints/public-path'; +import { createRoot } from 'react-dom/client'; + +import Rails from '@rails/ujs'; + +import ready from 'flavours/glitch/ready'; + +const setAnnouncementEndsAttributes = (target: HTMLInputElement) => { + const valid = target.value && target.validity.valid; + const element = document.querySelector( + 'input[type="datetime-local"]#announcement_ends_at', + ); + + if (!element) return; + + if (valid) { + element.classList.remove('optional'); + element.required = true; + element.min = target.value; + } else { + element.classList.add('optional'); + element.removeAttribute('required'); + element.removeAttribute('min'); + } +}; + +Rails.delegate( + document, + 'input[type="datetime-local"]#announcement_starts_at', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + setAnnouncementEndsAttributes(target); + }, +); + +const batchCheckboxClassName = '.batch-checkbox input[type="checkbox"]'; + +const showSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + selectAllMatchingElement?.classList.add('active'); +}; + +const hideSelectAll = () => { + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + const hiddenField = document.querySelector( + 'input#select_all_matching', + ); + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + selectAllMatchingElement?.classList.remove('active'); + selectedMsg?.classList.remove('active'); + notSelectedMsg?.classList.add('active'); + if (hiddenField) hiddenField.value = '0'; +}; + +Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + document + .querySelectorAll(batchCheckboxClassName) + .forEach((content) => { + content.checked = target.checked; + }); + + if (selectAllMatchingElement) { + if (target.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } +}); + +Rails.delegate(document, '.batch-table__select-all button', 'click', () => { + const hiddenField = document.querySelector( + '#select_all_matching', + ); + + if (!hiddenField) return; + + const active = hiddenField.value === '1'; + const selectedMsg = document.querySelector( + '.batch-table__select-all .selected', + ); + const notSelectedMsg = document.querySelector( + '.batch-table__select-all .not-selected', + ); + + if (!selectedMsg || !notSelectedMsg) return; + + if (active) { + hiddenField.value = '0'; + selectedMsg.classList.remove('active'); + notSelectedMsg.classList.add('active'); + } else { + hiddenField.value = '1'; + notSelectedMsg.classList.remove('active'); + selectedMsg.classList.add('active'); + } +}); + +Rails.delegate(document, batchCheckboxClassName, 'change', () => { + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + const selectAllMatchingElement = document.querySelector( + '.batch-table__select-all', + ); + + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + + if (selectAllMatchingElement) { + if (checkAllElement.checked) { + showSelectAll(); + } else { + hideSelectAll(); + } + } + } +}); + +Rails.delegate( + document, + '.filter-subset--with-select select', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) target.form?.submit(); + }, +); + +const onDomainBlockSeverityChange = (target: HTMLSelectElement) => { + const rejectMediaDiv = document.querySelector( + '.input.with_label.domain_block_reject_media', + ); + const rejectReportsDiv = document.querySelector( + '.input.with_label.domain_block_reject_reports', + ); + + if (rejectMediaDiv && rejectMediaDiv instanceof HTMLElement) { + rejectMediaDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } + + if (rejectReportsDiv && rejectReportsDiv instanceof HTMLElement) { + rejectReportsDiv.style.display = + target.value === 'suspend' ? 'none' : 'block'; + } +}; + +Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => { + if (target instanceof HTMLSelectElement) onDomainBlockSeverityChange(target); +}); + +const onEnableBootstrapTimelineAccountsChange = (target: HTMLInputElement) => { + const bootstrapTimelineAccountsField = + document.querySelector( + '#form_admin_settings_bootstrap_timeline_accounts', + ); + + if (bootstrapTimelineAccountsField) { + bootstrapTimelineAccountsField.disabled = !target.checked; + if (target.checked) { + bootstrapTimelineAccountsField.parentElement?.classList.remove( + 'disabled', + ); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.remove( + 'disabled', + ); + } else { + bootstrapTimelineAccountsField.parentElement?.classList.add('disabled'); + bootstrapTimelineAccountsField.parentElement?.parentElement?.classList.add( + 'disabled', + ); + } + } +}; + +Rails.delegate( + document, + '#form_admin_settings_enable_bootstrap_timeline_accounts', + 'change', + ({ target }) => { + if (target instanceof HTMLInputElement) + onEnableBootstrapTimelineAccountsChange(target); + }, +); + +const onChangeRegistrationMode = (target: HTMLSelectElement) => { + const enabled = target.value === 'approved'; + + document + .querySelectorAll( + '.form_admin_settings_registrations_mode .warning-hint', + ) + .forEach((warning_hint) => { + warning_hint.style.display = target.value === 'open' ? 'inline' : 'none'; + }); + + document + .querySelectorAll( + 'input#form_admin_settings_require_invite_text', + ) + .forEach((input) => { + input.disabled = !enabled; + if (enabled) { + let element: HTMLElement | null = input; + do { + element.classList.remove('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } else { + let element: HTMLElement | null = input; + do { + element.classList.add('disabled'); + element = element.parentElement; + } while (element && !element.classList.contains('fields-group')); + } + }); +}; + +const convertUTCDateTimeToLocal = (value: string) => { + const date = new Date(value + 'Z'); + const twoChars = (x: number) => x.toString().padStart(2, '0'); + return `${date.getFullYear()}-${twoChars(date.getMonth() + 1)}-${twoChars(date.getDate())}T${twoChars(date.getHours())}:${twoChars(date.getMinutes())}`; +}; + +function convertLocalDatetimeToUTC(value: string) { + const date = new Date(value); + const fullISO8601 = date.toISOString(); + return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); +} + +Rails.delegate( + document, + '#form_admin_settings_registrations_mode', + 'change', + ({ target }) => { + if (target instanceof HTMLSelectElement) onChangeRegistrationMode(target); + }, +); + +async function mountReactComponent(element: Element) { + const componentName = element.getAttribute('data-admin-component'); + const stringProps = element.getAttribute('data-props'); + + if (!stringProps) return; + + const componentProps = JSON.parse(stringProps) as object; + + const { default: AdminComponent } = await import( + '@/flavours/glitch/containers/admin_component' + ); + + const { default: Component } = (await import( + `@/flavours/glitch/components/admin/${componentName}` + )) as { default: React.ComponentType }; + + const root = createRoot(element); + + root.render( + + + , + ); +} + +ready(() => { + const domainBlockSeveritySelect = document.querySelector( + 'select#domain_block_severity', + ); + if (domainBlockSeveritySelect) + onDomainBlockSeverityChange(domainBlockSeveritySelect); + + const enableBootstrapTimelineAccounts = + document.querySelector( + 'input#form_admin_settings_enable_bootstrap_timeline_accounts', + ); + if (enableBootstrapTimelineAccounts) + onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts); + + const registrationMode = document.querySelector( + 'select#form_admin_settings_registrations_mode', + ); + if (registrationMode) onChangeRegistrationMode(registrationMode); + + const checkAllElement = document.querySelector( + 'input#batch_checkbox_all', + ); + if (checkAllElement) { + const allCheckboxes = Array.from( + document.querySelectorAll(batchCheckboxClassName), + ); + checkAllElement.checked = allCheckboxes.every((content) => content.checked); + checkAllElement.indeterminate = + !checkAllElement.checked && + allCheckboxes.some((content) => content.checked); + } + + document + .querySelector('a#add-instance-button') + ?.addEventListener('click', (e) => { + const domain = document.querySelector( + 'input[type="text"]#by_domain', + )?.value; + + if (domain && e.target instanceof HTMLAnchorElement) { + const url = new URL(e.target.href); + url.searchParams.set('_domain', domain); + e.target.href = url.toString(); + } + }); + + document + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value) { + element.value = convertUTCDateTimeToLocal(element.value); + } + if (element.placeholder) { + element.placeholder = convertUTCDateTimeToLocal(element.placeholder); + } + }); + + Rails.delegate(document, 'form', 'submit', ({ target }) => { + if (target instanceof HTMLFormElement) + target + .querySelectorAll('input[type="datetime-local"]') + .forEach((element) => { + if (element.value && element.validity.valid) { + element.value = convertLocalDatetimeToUTC(element.value); + } + }); + }); + + const announcementStartsAt = document.querySelector( + 'input[type="datetime-local"]#announcement_starts_at', + ); + if (announcementStartsAt) { + setAnnouncementEndsAttributes(announcementStartsAt); + } + + document.querySelectorAll('[data-admin-component]').forEach((element) => { + void mountReactComponent(element); + }); +}).catch((reason: unknown) => { + throw reason; +}); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/application.ts b/app/javascript/flavours/glitch/modern-entrypoints/application.ts new file mode 100644 index 0000000000..3659d8212b --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/application.ts @@ -0,0 +1,15 @@ +import '@/entrypoints/public-path'; + +import { start } from 'flavours/glitch/common'; +import { loadLocale } from 'flavours/glitch/locales'; +import main from 'flavours/glitch/main'; +import { loadPolyfills } from 'flavours/glitch/polyfills'; + +start(); + +loadPolyfills() + .then(loadLocale) + .then(main) + .catch((e: unknown) => { + console.error(e); + }); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/common-modern.js.orig b/app/javascript/flavours/glitch/modern-entrypoints/common-modern.js.orig new file mode 100644 index 0000000000..59332e1712 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/common-modern.js.orig @@ -0,0 +1,8 @@ +import 'packs/public-path'; +import { start } from '@rails/ujs'; +import 'flavours/glitch/styles/modern.scss'; + +start(); + +// This ensures that webpack compiles our images. +require.context('../images', true); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/common.js b/app/javascript/flavours/glitch/modern-entrypoints/common.js new file mode 100644 index 0000000000..6363f6d5ec --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/common.js @@ -0,0 +1,8 @@ +import '@/modern-entrypoints/public-path'; +import Rails from '@rails/ujs'; +import 'flavours/glitch/styles/modern.scss'; + +start(); + +// This ensures that webpack compiles our images. +require.context('../images', true); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/error.ts b/app/javascript/flavours/glitch/modern-entrypoints/error.ts new file mode 100644 index 0000000000..9e067d4caa --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/error.ts @@ -0,0 +1,18 @@ +import '@/entrypoints/public-path'; +import ready from 'flavours/glitch/ready'; + +ready(() => { + const image = document.querySelector('img'); + + if (!image) return; + + image.addEventListener('mouseenter', () => { + image.src = '/oops.gif'; + }); + + image.addEventListener('mouseleave', () => { + image.src = '/oops.png'; + }); +}).catch((e: unknown) => { + console.error(e); +}); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/inert.ts b/app/javascript/flavours/glitch/modern-entrypoints/inert.ts new file mode 100644 index 0000000000..a5d7e548be --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/inert.ts @@ -0,0 +1,4 @@ +/* Placeholder file to have `inert.scss` compiled by Webpack + This is used by the `wicg-inert` polyfill */ + +import '@/styles/inert.scss'; diff --git a/app/javascript/flavours/glitch/modern-entrypoints/mailer.ts b/app/javascript/flavours/glitch/modern-entrypoints/mailer.ts new file mode 100644 index 0000000000..28cbb906f5 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/mailer.ts @@ -0,0 +1,3 @@ +import '@/styles/mailer.scss'; + +require.context('@/icons'); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/public.tsx b/app/javascript/flavours/glitch/modern-entrypoints/public.tsx new file mode 100644 index 0000000000..5a8d5e08cb --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/public.tsx @@ -0,0 +1,462 @@ +import { createRoot } from 'react-dom/client'; + +import '@/entrypoints/public-path'; + +import { IntlMessageFormat } from 'intl-messageformat'; +import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import { defineMessages } from 'react-intl'; + +import Rails from '@rails/ujs'; +import axios from 'axios'; +import { throttle } from 'lodash'; + +import { start } from 'flavours/glitch/common'; +import { timeAgoString } from 'flavours/glitch/components/relative_timestamp'; +import emojify from 'flavours/glitch/features/emoji/emoji'; +import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions'; +import { loadLocale, getLocale } from 'flavours/glitch/locales'; +import { loadPolyfills } from 'flavours/glitch/polyfills'; +import ready from 'flavours/glitch/ready'; + +import 'cocoon-js-vanilla'; + +start(); + +const messages = defineMessages({ + usernameTaken: { + id: 'username.taken', + defaultMessage: 'That username is taken. Try another', + }, + passwordExceedsLength: { + id: 'password_confirmation.exceeds_maxlength', + defaultMessage: 'Password confirmation exceeds the maximum password length', + }, + passwordDoesNotMatch: { + id: 'password_confirmation.mismatching', + defaultMessage: 'Password confirmation does not match', + }, +}); + +interface SetHeightMessage { + type: 'setHeight'; + id: string; + height: number; +} + +function isSetHeightMessage(data: unknown): data is SetHeightMessage { + if ( + data && + typeof data === 'object' && + 'type' in data && + data.type === 'setHeight' + ) + return true; + else return false; +} + +window.addEventListener('message', (e) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typings are not correct, it can be null in very rare cases + if (!e.data || !isSetHeightMessage(e.data) || !window.parent) return; + + const data = e.data; + + ready(() => { + window.parent.postMessage( + { + type: 'setHeight', + id: data.id, + height: document.getElementsByTagName('html')[0]?.scrollHeight, + }, + '*', + ); + }).catch((e: unknown) => { + console.error('Error in setHeightMessage postMessage', e); + }); +}); + +function loaded() { + const { messages: localeData } = getLocale(); + + const locale = document.documentElement.lang; + + const dateTimeFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + }); + + const dateFormat = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + + const timeFormat = new Intl.DateTimeFormat(locale, { + timeStyle: 'short', + }); + + const formatMessage = ( + { id, defaultMessage }: MessageDescriptor, + values?: Record, + ) => { + let message: string | undefined = undefined; + + if (id) message = localeData[id]; + + if (!message) message = defaultMessage as string; + + const messageFormat = new IntlMessageFormat(message, locale); + return messageFormat.format(values) as string; + }; + + document.querySelectorAll('.emojify').forEach((content) => { + content.innerHTML = emojify(content.innerHTML); + }); + + document + .querySelectorAll('time.formatted') + .forEach((content) => { + const datetime = new Date(content.dateTime); + const formattedDate = dateTimeFormat.format(datetime); + + content.title = formattedDate; + content.textContent = formattedDate; + }); + + const isToday = (date: Date) => { + const today = new Date(); + + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); + }; + const todayFormat = new IntlMessageFormat( + localeData['relative_format.today'] ?? 'Today at {time}', + locale, + ); + + document + .querySelectorAll('time.relative-formatted') + .forEach((content) => { + const datetime = new Date(content.dateTime); + + let formattedContent: string; + + if (isToday(datetime)) { + const formattedTime = timeFormat.format(datetime); + + formattedContent = todayFormat.format({ + time: formattedTime, + }) as string; + } else { + formattedContent = dateFormat.format(datetime); + } + + content.title = formattedContent; + content.textContent = formattedContent; + }); + + document + .querySelectorAll('time.time-ago') + .forEach((content) => { + const datetime = new Date(content.dateTime); + const now = new Date(); + + const timeGiven = content.dateTime.includes('T'); + content.title = timeGiven + ? dateTimeFormat.format(datetime) + : dateFormat.format(datetime); + content.textContent = timeAgoString( + { + formatMessage, + formatDate: (date: Date, options) => + new Intl.DateTimeFormat(locale, options).format(date), + }, + datetime, + now.getTime(), + now.getFullYear(), + timeGiven, + ); + }); + + const reactComponents = document.querySelectorAll('[data-component]'); + + if (reactComponents.length > 0) { + import( + /* webpackChunkName: "containers/media_container" */ 'flavours/glitch/containers/media_container' + ) + .then(({ default: MediaContainer }) => { + reactComponents.forEach((component) => { + Array.from(component.children).forEach((child) => { + component.removeChild(child); + }); + }); + + const content = document.createElement('div'); + + const root = createRoot(content); + root.render( + , + ); + document.body.appendChild(content); + + return true; + }) + .catch((error: unknown) => { + console.error(error); + }); + } + + Rails.delegate( + document, + 'input#user_account_attributes_username', + 'input', + throttle( + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + if (target.value && target.value.length > 0) { + axios + .get('/api/v1/accounts/lookup', { params: { acct: target.value } }) + .then(() => { + target.setCustomValidity(formatMessage(messages.usernameTaken)); + return true; + }) + .catch(() => { + target.setCustomValidity(''); + }); + } else { + target.setCustomValidity(''); + } + }, + 500, + { leading: false, trailing: true }, + ), + ); + + Rails.delegate( + document, + '#user_password,#user_password_confirmation', + 'input', + () => { + const password = document.querySelector( + 'input#user_password', + ); + const confirmation = document.querySelector( + 'input#user_password_confirmation', + ); + if (!confirmation || !password) return; + + if ( + confirmation.value && + confirmation.value.length > password.maxLength + ) { + confirmation.setCustomValidity( + formatMessage(messages.passwordExceedsLength), + ); + } else if (password.value && password.value !== confirmation.value) { + confirmation.setCustomValidity( + formatMessage(messages.passwordDoesNotMatch), + ); + } else { + confirmation.setCustomValidity(''); + } + }, + ); + + Rails.delegate( + document, + 'button.status__content__spoiler-link', + 'click', + function () { + if (!(this instanceof HTMLButtonElement)) return; + + const statusEl = this.parentNode?.parentNode; + + if ( + !( + statusEl instanceof HTMLDivElement && + statusEl.classList.contains('.status__content') + ) + ) + return; + + if (statusEl.dataset.spoiler === 'expanded') { + statusEl.dataset.spoiler = 'folded'; + this.textContent = new IntlMessageFormat( + localeData['status.show_more'] ?? 'Show more', + locale, + ).format() as string; + } else { + statusEl.dataset.spoiler = 'expanded'; + this.textContent = new IntlMessageFormat( + localeData['status.show_less'] ?? 'Show less', + locale, + ).format() as string; + } + }, + ); + + document + .querySelectorAll('button.status__content__spoiler-link') + .forEach((spoilerLink) => { + const statusEl = spoilerLink.parentNode?.parentNode; + + if ( + !( + statusEl instanceof HTMLDivElement && + statusEl.classList.contains('.status__content') + ) + ) + return; + + const message = + statusEl.dataset.spoiler === 'expanded' + ? localeData['status.show_less'] ?? 'Show less' + : localeData['status.show_more'] ?? 'Show more'; + spoilerLink.textContent = new IntlMessageFormat( + message, + locale, + ).format() as string; + }); +} + +Rails.delegate( + document, + '#edit_profile input[type=file]', + 'change', + ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + const avatar = document.querySelector( + `img#${target.id}-preview`, + ); + + if (!avatar) return; + + let file: File | undefined; + if (target.files) file = target.files[0]; + + const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; + + if (url) avatar.src = url; + }, +); + +Rails.delegate(document, '.input-copy input', 'click', ({ target }) => { + if (!(target instanceof HTMLInputElement)) return; + + target.focus(); + target.select(); + target.setSelectionRange(0, target.value.length); +}); + +Rails.delegate(document, '.input-copy button', 'click', ({ target }) => { + if (!(target instanceof HTMLButtonElement)) return; + + const input = target.parentNode?.querySelector( + '.input-copy__wrapper input', + ); + + if (!input) return; + + const oldReadOnly = input.readOnly; + + input.readOnly = false; + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (document.execCommand('copy')) { + input.blur(); + + const parent = target.parentElement; + + if (!parent) return; + parent.classList.add('copied'); + + setTimeout(() => { + parent.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + + input.readOnly = oldReadOnly; +}); + +const toggleSidebar = () => { + const sidebar = document.querySelector('.sidebar ul'); + const toggleButton = document.querySelector( + 'a.sidebar__toggle__icon', + ); + + if (!sidebar || !toggleButton) return; + + if (sidebar.classList.contains('visible')) { + document.body.style.overflow = ''; + toggleButton.setAttribute('aria-expanded', 'false'); + } else { + document.body.style.overflow = 'hidden'; + toggleButton.setAttribute('aria-expanded', 'true'); + } + + toggleButton.classList.toggle('active'); + sidebar.classList.toggle('visible'); +}; + +Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => { + toggleSidebar(); +}); + +Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', (e) => { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + toggleSidebar(); + } +}); + +Rails.delegate(document, 'img.custom-emoji', 'mouseover', ({ target }) => { + if (target instanceof HTMLImageElement && target.dataset.original) + target.src = target.dataset.original; +}); +Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => { + if (target instanceof HTMLImageElement && target.dataset.static) + target.src = target.dataset.static; +}); + +// Empty the honeypot fields in JS in case something like an extension +// automatically filled them. +Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { + [ + 'user_website', + 'user_confirm_password', + 'registration_user_website', + 'registration_user_confirm_password', + ].forEach((id) => { + const field = document.querySelector(`input#${id}`); + if (field) { + field.value = ''; + } + }); +}); + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(loadLocale) + .then(main) + .then(loadKeyboardExtensions) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/remote_interaction_helper.ts b/app/javascript/flavours/glitch/modern-entrypoints/remote_interaction_helper.ts new file mode 100644 index 0000000000..3bfc1fc139 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/remote_interaction_helper.ts @@ -0,0 +1,181 @@ +/* + +This script is meant to to be used in an `iframe` with the sole purpose of doing webfinger queries +client-side without being restricted by a strict `connect-src` Content-Security-Policy directive. + +It communicates with the parent window through message events that are authenticated by origin, +and performs no other task. + +*/ + +import '@/entrypoints/public-path'; + +import axios from 'axios'; + +interface JRDLink { + rel: string; + template?: string; + href?: string; +} + +const isJRDLink = (link: unknown): link is JRDLink => + typeof link === 'object' && + link !== null && + 'rel' in link && + typeof link.rel === 'string' && + (!('template' in link) || typeof link.template === 'string') && + (!('href' in link) || typeof link.href === 'string'); + +const findLink = (rel: string, data: unknown): JRDLink | undefined => { + if ( + typeof data === 'object' && + data !== null && + 'links' in data && + data.links instanceof Array + ) { + return data.links.find( + (link): link is JRDLink => isJRDLink(link) && link.rel === rel, + ); + } else { + return undefined; + } +}; + +const findTemplateLink = (data: unknown) => + findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template; + +const fetchInteractionURLSuccess = ( + uri_or_domain: string, + template: string, +) => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-success', + uri_or_domain, + template, + }, + window.origin, + ); +}; + +const fetchInteractionURLFailure = () => { + window.parent.postMessage( + { + type: 'fetchInteractionURL-failure', + }, + window.origin, + ); +}; + +const isValidDomain = (value: unknown) => { + if (typeof value !== 'string') return false; + + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +// Attempt to find a remote interaction URL from a domain +const fromDomain = (domain: string) => { + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `https://${domain}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(domain, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fetchInteractionURLSuccess(domain, fallbackTemplate); + }); +}; + +// Attempt to find a remote interaction URL from an arbitrary URL +const fromURL = (url: string) => { + const domain = new URL(url).host; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: url }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(url, template ?? fallbackTemplate); + return; + }) + .catch(() => { + fromDomain(domain); + }); +}; + +// Attempt to find a remote interaction URL from a `user@domain` string +const fromAcct = (acct: string) => { + acct = acct.replace(/^@/, ''); + + const segments = acct.split('@'); + + if (segments.length !== 2 || !segments[0] || !isValidDomain(segments[1])) { + fetchInteractionURLFailure(); + return; + } + + const domain = segments[1]; + const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; + + if (!domain) { + fetchInteractionURLFailure(); + return; + } + + axios + .get(`https://${domain}/.well-known/webfinger`, { + params: { resource: `acct:${acct}` }, + }) + .then(({ data }) => { + const template = findTemplateLink(data); + fetchInteractionURLSuccess(acct, template ?? fallbackTemplate); + return; + }) + .catch(() => { + // TODO: handle host-meta? + fromDomain(domain); + }); +}; + +const fetchInteractionURL = (uri_or_domain: string) => { + if (uri_or_domain === '') { + fetchInteractionURLFailure(); + } else if (/^https?:\/\//.test(uri_or_domain)) { + fromURL(uri_or_domain); + } else if (uri_or_domain.includes('@')) { + fromAcct(uri_or_domain); + } else { + fromDomain(uri_or_domain); + } +}; + +window.addEventListener('message', (event: MessageEvent) => { + // Check message origin + if ( + !window.origin || + window.parent !== event.source || + event.origin !== window.origin + ) { + return; + } + + if ( + event.data && + typeof event.data === 'object' && + 'type' in event.data && + event.data.type === 'fetchInteractionURL' && + 'uri_or_domain' in event.data && + typeof event.data.uri_or_domain === 'string' + ) { + fetchInteractionURL(event.data.uri_or_domain); + } +}); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/share.tsx b/app/javascript/flavours/glitch/modern-entrypoints/share.tsx new file mode 100644 index 0000000000..0eda442506 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/share.tsx @@ -0,0 +1,36 @@ +import '@/entrypoints/public-path'; +import { createRoot } from 'react-dom/client'; + +import { start } from 'flavours/glitch/common'; +import ComposeContainer from 'flavours/glitch/containers/compose_container'; +import { loadPolyfills } from 'flavours/glitch/polyfills'; +import ready from 'flavours/glitch/ready'; + +start(); + +function loaded() { + const mountNode = document.getElementById('mastodon-compose'); + + if (mountNode) { + const attr = mountNode.getAttribute('data-props'); + + if (!attr) return; + + const props = JSON.parse(attr) as object; + const root = createRoot(mountNode); + + root.render(); + } +} + +function main() { + ready(loaded).catch((error: unknown) => { + console.error(error); + }); +} + +loadPolyfills() + .then(main) + .catch((error: unknown) => { + console.error(error); + }); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/sign_up.ts b/app/javascript/flavours/glitch/modern-entrypoints/sign_up.ts new file mode 100644 index 0000000000..18e2931546 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/sign_up.ts @@ -0,0 +1,48 @@ +import '@/entrypoints/public-path'; +import axios from 'axios'; + +import ready from 'flavours/glitch/ready'; + +async function checkConfirmation() { + const response = await axios.get('/api/v1/emails/check_confirmation'); + + if (response.data) { + window.location.href = '/start'; + } +} + +ready(() => { + setInterval(() => { + void checkConfirmation(); + }, 5000); + + document + .querySelectorAll('button.timer-button') + .forEach((button) => { + let counter = 30; + + const container = document.createElement('span'); + + const updateCounter = () => { + container.innerText = ` (${counter})`; + }; + + updateCounter(); + + const countdown = setInterval(() => { + counter--; + + if (counter === 0) { + button.disabled = false; + button.removeChild(container); + clearInterval(countdown); + } else { + updateCounter(); + } + }, 1000); + + button.appendChild(container); + }); +}).catch((e: unknown) => { + throw e; +}); diff --git a/app/javascript/flavours/glitch/modern-entrypoints/two_factor_authentication.ts b/app/javascript/flavours/glitch/modern-entrypoints/two_factor_authentication.ts new file mode 100644 index 0000000000..3106167117 --- /dev/null +++ b/app/javascript/flavours/glitch/modern-entrypoints/two_factor_authentication.ts @@ -0,0 +1,197 @@ +import * as WebAuthnJSON from '@github/webauthn-json'; +import axios, { AxiosError } from 'axios'; + +import ready from 'flavours/glitch/ready'; + +import 'regenerator-runtime/runtime'; + +type PublicKeyCredentialCreationOptionsJSON = + WebAuthnJSON.CredentialCreationOptionsJSON['publicKey']; + +function exceptionHasAxiosError( + error: unknown, +): error is AxiosError<{ error: unknown }> { + return ( + error instanceof AxiosError && + typeof error.response?.data === 'object' && + 'error' in error.response.data + ); +} + +function logAxiosResponseError(error: unknown) { + if (exceptionHasAxiosError(error)) console.error(error); +} + +function getCSRFToken() { + return document + .querySelector('meta[name="csrf-token"]') + ?.getAttribute('content'); +} + +function hideFlashMessages() { + document.querySelectorAll('.flash-message').forEach((flashMessage) => { + flashMessage.classList.add('hidden'); + }); +} + +async function callback( + url: string, + body: + | { + credential: WebAuthnJSON.PublicKeyCredentialWithAttestationJSON; + nickname: string; + } + | { + user: { credential: WebAuthnJSON.PublicKeyCredentialWithAssertionJSON }; + }, +) { + try { + const response = await axios.post<{ redirect_path: string }>( + url, + JSON.stringify(body), + { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-Token': getCSRFToken(), + }, + }, + ); + + window.location.replace(response.data.redirect_path); + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 422) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + + logAxiosResponseError(error); + } else { + console.error(error); + } + } +} + +async function handleWebauthnCredentialRegistration(nickname: string) { + try { + const response = await axios.get( + '/settings/security_keys/options', + ); + + const credentialOptions = response.data; + + try { + const credential = await WebAuthnJSON.create({ + publicKey: credentialOptions, + }); + + const params = { + credential: credential, + nickname: nickname, + }; + + await callback('/settings/security_keys', params); + } catch (error) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + console.error(error); + } + } catch (error) { + logAxiosResponseError(error); + } +} + +async function handleWebauthnCredentialAuthentication() { + try { + const response = await axios.get( + 'sessions/security_key_options', + ); + + const credentialOptions = response.data; + + try { + const credential = await WebAuthnJSON.get({ + publicKey: credentialOptions, + }); + + const params = { user: { credential: credential } }; + void callback('sign_in', params); + } catch (error) { + const errorMessage = document.getElementById( + 'security-key-error-message', + ); + errorMessage?.classList.remove('hidden'); + console.error(error); + } + } catch (error) { + logAxiosResponseError(error); + } +} + +ready(() => { + if (!WebAuthnJSON.supported()) { + const unsupported_browser_message = document.getElementById( + 'unsupported-browser-message', + ); + if (unsupported_browser_message) { + unsupported_browser_message.classList.remove('hidden'); + const button = document.querySelector( + 'button.btn.js-webauthn', + ); + if (button) button.disabled = true; + } + } + + const webAuthnCredentialRegistrationForm = + document.querySelector('form#new_webauthn_credential'); + if (webAuthnCredentialRegistrationForm) { + webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => { + event.preventDefault(); + + if (!(event.target instanceof HTMLFormElement)) return; + + const nickname = event.target.querySelector( + 'input[name="new_webauthn_credential[nickname]"]', + ); + + if (nickname?.value) { + void handleWebauthnCredentialRegistration(nickname.value); + } else { + nickname?.focus(); + } + }); + } + + const webAuthnCredentialAuthenticationForm = + document.getElementById('webauthn-form'); + if (webAuthnCredentialAuthenticationForm) { + webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => { + event.preventDefault(); + void handleWebauthnCredentialAuthentication(); + }); + + const otpAuthenticationForm = document.getElementById( + 'otp-authentication-form', + ); + + const linkToOtp = document.getElementById('link-to-otp'); + + linkToOtp?.addEventListener('click', () => { + webAuthnCredentialAuthenticationForm.classList.add('hidden'); + otpAuthenticationForm?.classList.remove('hidden'); + hideFlashMessages(); + }); + + const linkToWebAuthn = document.getElementById('link-to-webauthn'); + linkToWebAuthn?.addEventListener('click', () => { + otpAuthenticationForm?.classList.add('hidden'); + webAuthnCredentialAuthenticationForm.classList.remove('hidden'); + hideFlashMessages(); + }); + } +}).catch((e: unknown) => { + throw e; +}); diff --git a/app/javascript/flavours/modern-glitch/theme.yml b/app/javascript/flavours/modern-glitch/theme.yml index a389cd1629..b593fcea4f 100644 --- a/app/javascript/flavours/modern-glitch/theme.yml +++ b/app/javascript/flavours/modern-glitch/theme.yml @@ -1,27 +1,5 @@ # (REQUIRED) The location of the pack files. -pack: - admin: - - packs/admin.jsx - - packs/public.jsx - auth: packs/public.jsx - common: - filename: packs/common-modern.js - stylesheet: true - embed: packs/public.jsx - error: packs/error.js - home: - filename: packs/home.js - preload: - - flavours/glitch/async/compose - - flavours/glitch/async/getting_started - - flavours/glitch/async/home_timeline - - flavours/glitch/async/notifications - mailer: - modal: - public: packs/public.jsx - settings: packs/settings.js - sign_up: packs/sign_up.js - share: packs/share.jsx +pack_directory: app/javascript/flavours/glitch/modern-entrypoints # (OPTIONAL) The directory which contains localization files for # the flavour, relative to this directory. The contents of this @@ -36,12 +14,6 @@ inherit_locales: vanilla # or an array thereof. These are the full path from `app/javascript/`. screenshot: flavours/glitch/images/modern-preview.jpg -# (OPTIONAL) The directory which contains the pack files. -# Defaults to the theme directory (`app/javascript/themes/[theme]`), -# which should be sufficient for like 99% of use-cases lol. - -pack_directory: app/javascript/flavours/glitch - # (OPTIONAL) By default the theme will fallback to the default theme # if a particular pack is not provided. You can specify different # fallbacks here, or disable fallback behaviours altogether by