Move JS source from packs to entrypoints (#30037)
				
					
				
			This commit is contained in:
		
							
								
								
									
										368
									
								
								app/javascript/entrypoints/admin.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										368
									
								
								app/javascript/entrypoints/admin.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,368 @@
 | 
			
		||||
import './public-path';
 | 
			
		||||
import { createRoot } from 'react-dom/client';
 | 
			
		||||
 | 
			
		||||
import Rails from '@rails/ujs';
 | 
			
		||||
 | 
			
		||||
import ready from '../mastodon/ready';
 | 
			
		||||
 | 
			
		||||
const setAnnouncementEndsAttributes = (target: HTMLInputElement) => {
 | 
			
		||||
  const valid = target.value && target.validity.valid;
 | 
			
		||||
  const element = document.querySelector<HTMLInputElement>(
 | 
			
		||||
    '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<HTMLInputElement>(
 | 
			
		||||
    '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<HTMLInputElement>(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<HTMLInputElement>(
 | 
			
		||||
    '#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<HTMLInputElement>(
 | 
			
		||||
    'input#batch_checkbox_all',
 | 
			
		||||
  );
 | 
			
		||||
  const selectAllMatchingElement = document.querySelector(
 | 
			
		||||
    '.batch-table__select-all',
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (checkAllElement) {
 | 
			
		||||
    const allCheckboxes = Array.from(
 | 
			
		||||
      document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
 | 
			
		||||
      '#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<HTMLElement>(
 | 
			
		||||
      '.form_admin_settings_registrations_mode .warning-hint',
 | 
			
		||||
    )
 | 
			
		||||
    .forEach((warning_hint) => {
 | 
			
		||||
      warning_hint.style.display = target.value === 'open' ? 'inline' : 'none';
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
  document
 | 
			
		||||
    .querySelectorAll<HTMLInputElement>(
 | 
			
		||||
      '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(
 | 
			
		||||
    '@/mastodon/containers/admin_component'
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const { default: Component } = (await import(
 | 
			
		||||
    `@/mastodon/components/admin/${componentName}`
 | 
			
		||||
  )) as { default: React.ComponentType };
 | 
			
		||||
 | 
			
		||||
  const root = createRoot(element);
 | 
			
		||||
 | 
			
		||||
  root.render(
 | 
			
		||||
    <AdminComponent>
 | 
			
		||||
      <Component {...componentProps} />
 | 
			
		||||
    </AdminComponent>,
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ready(() => {
 | 
			
		||||
  const domainBlockSeveritySelect = document.querySelector<HTMLSelectElement>(
 | 
			
		||||
    'select#domain_block_severity',
 | 
			
		||||
  );
 | 
			
		||||
  if (domainBlockSeveritySelect)
 | 
			
		||||
    onDomainBlockSeverityChange(domainBlockSeveritySelect);
 | 
			
		||||
 | 
			
		||||
  const enableBootstrapTimelineAccounts =
 | 
			
		||||
    document.querySelector<HTMLInputElement>(
 | 
			
		||||
      'input#form_admin_settings_enable_bootstrap_timeline_accounts',
 | 
			
		||||
    );
 | 
			
		||||
  if (enableBootstrapTimelineAccounts)
 | 
			
		||||
    onEnableBootstrapTimelineAccountsChange(enableBootstrapTimelineAccounts);
 | 
			
		||||
 | 
			
		||||
  const registrationMode = document.querySelector<HTMLSelectElement>(
 | 
			
		||||
    'select#form_admin_settings_registrations_mode',
 | 
			
		||||
  );
 | 
			
		||||
  if (registrationMode) onChangeRegistrationMode(registrationMode);
 | 
			
		||||
 | 
			
		||||
  const checkAllElement = document.querySelector<HTMLInputElement>(
 | 
			
		||||
    'input#batch_checkbox_all',
 | 
			
		||||
  );
 | 
			
		||||
  if (checkAllElement) {
 | 
			
		||||
    const allCheckboxes = Array.from(
 | 
			
		||||
      document.querySelectorAll<HTMLInputElement>(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<HTMLInputElement>(
 | 
			
		||||
        '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<HTMLInputElement>('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<HTMLInputElement>('input[type="datetime-local"]')
 | 
			
		||||
        .forEach((element) => {
 | 
			
		||||
          if (element.value && element.validity.valid) {
 | 
			
		||||
            element.value = convertLocalDatetimeToUTC(element.value);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const announcementStartsAt = document.querySelector<HTMLInputElement>(
 | 
			
		||||
    '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;
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										15
									
								
								app/javascript/entrypoints/application.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/javascript/entrypoints/application.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
import './public-path';
 | 
			
		||||
import main from "mastodon/main";
 | 
			
		||||
 | 
			
		||||
import { start } from '../mastodon/common';
 | 
			
		||||
import { loadLocale } from '../mastodon/locales';
 | 
			
		||||
import { loadPolyfills } from '../mastodon/polyfills';
 | 
			
		||||
 | 
			
		||||
start();
 | 
			
		||||
 | 
			
		||||
loadPolyfills()
 | 
			
		||||
  .then(loadLocale)
 | 
			
		||||
  .then(main)
 | 
			
		||||
  .catch(e => {
 | 
			
		||||
    console.error(e);
 | 
			
		||||
  });
 | 
			
		||||
							
								
								
									
										14
									
								
								app/javascript/entrypoints/error.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/javascript/entrypoints/error.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
import './public-path';
 | 
			
		||||
import ready from '../mastodon/ready';
 | 
			
		||||
 | 
			
		||||
ready(() => {
 | 
			
		||||
  const image = document.querySelector('img');
 | 
			
		||||
 | 
			
		||||
  image.addEventListener('mouseenter', () => {
 | 
			
		||||
    image.src = '/oops.gif';
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  image.addEventListener('mouseleave', () => {
 | 
			
		||||
    image.src = '/oops.png';
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										4
									
								
								app/javascript/entrypoints/inert.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								app/javascript/entrypoints/inert.js
									
									
									
									
									
										Normal file
									
								
							@@ -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';
 | 
			
		||||
							
								
								
									
										3
									
								
								app/javascript/entrypoints/mailer.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								app/javascript/entrypoints/mailer.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import '../styles/mailer.scss';
 | 
			
		||||
 | 
			
		||||
require.context('../icons');
 | 
			
		||||
							
								
								
									
										21
									
								
								app/javascript/entrypoints/public-path.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								app/javascript/entrypoints/public-path.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
// Dynamically set webpack's loading path depending on a meta header, in order
 | 
			
		||||
// to share the same assets regardless of instance configuration.
 | 
			
		||||
// See https://webpack.js.org/guides/public-path/#on-the-fly
 | 
			
		||||
 | 
			
		||||
function removeOuterSlashes(string) {
 | 
			
		||||
  return string.replace(/^\/*/, '').replace(/\/*$/, '');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function formatPublicPath(host = '', path = '') {
 | 
			
		||||
  let formattedHost = removeOuterSlashes(host);
 | 
			
		||||
  if (formattedHost && !/^http/i.test(formattedHost)) {
 | 
			
		||||
    formattedHost = `//${formattedHost}`;
 | 
			
		||||
  }
 | 
			
		||||
  const formattedPath = removeOuterSlashes(path);
 | 
			
		||||
  return `${formattedHost}/${formattedPath}/`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cdnHost = document.querySelector('meta[name=cdn-host]');
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line no-undef
 | 
			
		||||
__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH);
 | 
			
		||||
							
								
								
									
										462
									
								
								app/javascript/entrypoints/public.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										462
									
								
								app/javascript/entrypoints/public.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,462 @@
 | 
			
		||||
import { createRoot } from 'react-dom/client';
 | 
			
		||||
 | 
			
		||||
import './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 '../mastodon/common';
 | 
			
		||||
import { timeAgoString } from '../mastodon/components/relative_timestamp';
 | 
			
		||||
import emojify from '../mastodon/features/emoji/emoji';
 | 
			
		||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
 | 
			
		||||
import { loadLocale, getLocale } from '../mastodon/locales';
 | 
			
		||||
import { loadPolyfills } from '../mastodon/polyfills';
 | 
			
		||||
import ready from '../mastodon/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<string, PrimitiveType>,
 | 
			
		||||
  ) => {
 | 
			
		||||
    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<HTMLTimeElement>('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<HTMLTimeElement>('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<HTMLTimeElement>('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" */ '../mastodon/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(
 | 
			
		||||
          <MediaContainer locale={locale} components={reactComponents} />,
 | 
			
		||||
        );
 | 
			
		||||
        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<HTMLInputElement>(
 | 
			
		||||
        'input#user_password',
 | 
			
		||||
      );
 | 
			
		||||
      const confirmation = document.querySelector<HTMLInputElement>(
 | 
			
		||||
        '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<HTMLButtonElement>('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<HTMLImageElement>(
 | 
			
		||||
      `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<HTMLInputElement>(
 | 
			
		||||
    '.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<HTMLUListElement>('.sidebar ul');
 | 
			
		||||
  const toggleButton = document.querySelector<HTMLAnchorElement>(
 | 
			
		||||
    '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<HTMLInputElement>(`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);
 | 
			
		||||
  });
 | 
			
		||||
							
								
								
									
										174
									
								
								app/javascript/entrypoints/remote_interaction_helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								app/javascript/entrypoints/remote_interaction_helper.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
			
		||||
/*
 | 
			
		||||
 | 
			
		||||
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 './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: string) => {
 | 
			
		||||
  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}`;
 | 
			
		||||
 | 
			
		||||
  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<unknown>) => {
 | 
			
		||||
  // 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);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										32
									
								
								app/javascript/entrypoints/share.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/javascript/entrypoints/share.jsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
import './public-path';
 | 
			
		||||
import { createRoot } from 'react-dom/client';
 | 
			
		||||
 | 
			
		||||
import { start } from '../mastodon/common';
 | 
			
		||||
import ComposeContainer  from '../mastodon/containers/compose_container';
 | 
			
		||||
import { loadPolyfills } from '../mastodon/polyfills';
 | 
			
		||||
import ready from '../mastodon/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);
 | 
			
		||||
    const root = createRoot(mountNode);
 | 
			
		||||
 | 
			
		||||
    root.render(<ComposeContainer {...props} />);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function main() {
 | 
			
		||||
  ready(loaded);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
loadPolyfills().then(main).catch(error => {
 | 
			
		||||
  console.error(error);
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										42
									
								
								app/javascript/entrypoints/sign_up.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/javascript/entrypoints/sign_up.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
			
		||||
import './public-path';
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
 | 
			
		||||
import ready from '../mastodon/ready';
 | 
			
		||||
 | 
			
		||||
ready(() => {
 | 
			
		||||
  setInterval(() => {
 | 
			
		||||
    axios.get('/api/v1/emails/check_confirmation').then((response) => {
 | 
			
		||||
      if (response.data) {
 | 
			
		||||
        window.location = '/start';
 | 
			
		||||
      }
 | 
			
		||||
    }).catch(error => {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    });
 | 
			
		||||
  }, 5000);
 | 
			
		||||
 | 
			
		||||
  document.querySelectorAll('.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);
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										119
									
								
								app/javascript/entrypoints/two_factor_authentication.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								app/javascript/entrypoints/two_factor_authentication.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,119 @@
 | 
			
		||||
import * as WebAuthnJSON from '@github/webauthn-json';
 | 
			
		||||
import axios from 'axios';
 | 
			
		||||
 | 
			
		||||
import ready from '../mastodon/ready';
 | 
			
		||||
import 'regenerator-runtime/runtime';
 | 
			
		||||
 | 
			
		||||
function getCSRFToken() {
 | 
			
		||||
  var CSRFSelector = document.querySelector('meta[name="csrf-token"]');
 | 
			
		||||
  if (CSRFSelector) {
 | 
			
		||||
    return CSRFSelector.getAttribute('content');
 | 
			
		||||
  } else {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function hideFlashMessages() {
 | 
			
		||||
  Array.from(document.getElementsByClassName('flash-message')).forEach(function(flashMessage) {
 | 
			
		||||
    flashMessage.classList.add('hidden');
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function callback(url, body) {
 | 
			
		||||
  axios.post(url, JSON.stringify(body), {
 | 
			
		||||
    headers: {
 | 
			
		||||
      'Content-Type': 'application/json',
 | 
			
		||||
      'Accept': 'application/json',
 | 
			
		||||
      'X-CSRF-Token': getCSRFToken(),
 | 
			
		||||
    },
 | 
			
		||||
    credentials: 'same-origin',
 | 
			
		||||
  }).then(function(response) {
 | 
			
		||||
    window.location.replace(response.data.redirect_path);
 | 
			
		||||
  }).catch(function(error) {
 | 
			
		||||
    if (error.response.status === 422) {
 | 
			
		||||
      const errorMessage = document.getElementById('security-key-error-message');
 | 
			
		||||
      errorMessage.classList.remove('hidden');
 | 
			
		||||
      console.error(error.response.data.error);
 | 
			
		||||
    } else {
 | 
			
		||||
      console.error(error);
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ready(() => {
 | 
			
		||||
  if (!WebAuthnJSON.supported()) {
 | 
			
		||||
    const unsupported_browser_message = document.getElementById('unsupported-browser-message');
 | 
			
		||||
    if (unsupported_browser_message) {
 | 
			
		||||
      unsupported_browser_message.classList.remove('hidden');
 | 
			
		||||
      document.querySelector('.btn.js-webauthn').disabled = true;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  const webAuthnCredentialRegistrationForm = document.getElementById('new_webauthn_credential');
 | 
			
		||||
  if (webAuthnCredentialRegistrationForm) {
 | 
			
		||||
    webAuthnCredentialRegistrationForm.addEventListener('submit', (event) => {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
 | 
			
		||||
      var nickname = event.target.querySelector('input[name="new_webauthn_credential[nickname]"]');
 | 
			
		||||
      if (nickname.value) {
 | 
			
		||||
        axios.get('/settings/security_keys/options')
 | 
			
		||||
          .then((response) => {
 | 
			
		||||
            const credentialOptions = response.data;
 | 
			
		||||
 | 
			
		||||
            WebAuthnJSON.create({ 'publicKey': credentialOptions }).then((credential) => {
 | 
			
		||||
              var params = { 'credential': credential, 'nickname': nickname.value };
 | 
			
		||||
              callback('/settings/security_keys', params);
 | 
			
		||||
            }).catch((error) => {
 | 
			
		||||
              const errorMessage = document.getElementById('security-key-error-message');
 | 
			
		||||
              errorMessage.classList.remove('hidden');
 | 
			
		||||
              console.error(error);
 | 
			
		||||
            });
 | 
			
		||||
          }).catch((error) => {
 | 
			
		||||
            console.error(error.response.data.error);
 | 
			
		||||
          });
 | 
			
		||||
      } else {
 | 
			
		||||
        nickname.focus();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const webAuthnCredentialAuthenticationForm = document.getElementById('webauthn-form');
 | 
			
		||||
  if (webAuthnCredentialAuthenticationForm) {
 | 
			
		||||
    webAuthnCredentialAuthenticationForm.addEventListener('submit', (event) => {
 | 
			
		||||
      event.preventDefault();
 | 
			
		||||
 | 
			
		||||
      axios.get('sessions/security_key_options')
 | 
			
		||||
        .then((response) => {
 | 
			
		||||
          const credentialOptions = response.data;
 | 
			
		||||
 | 
			
		||||
          WebAuthnJSON.get({ 'publicKey': credentialOptions }).then((credential) => {
 | 
			
		||||
            var params = { 'user': { 'credential': credential } };
 | 
			
		||||
            callback('sign_in', params);
 | 
			
		||||
          }).catch((error) => {
 | 
			
		||||
            const errorMessage = document.getElementById('security-key-error-message');
 | 
			
		||||
            errorMessage.classList.remove('hidden');
 | 
			
		||||
            console.error(error);
 | 
			
		||||
          });
 | 
			
		||||
        }).catch((error) => {
 | 
			
		||||
          console.error(error.response.data.error);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
		Reference in New Issue
	
	Block a user