public.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import { createRoot } from 'react-dom/client';
  2. import './public-path';
  3. import { IntlMessageFormat } from 'intl-messageformat';
  4. import type { MessageDescriptor, PrimitiveType } from 'react-intl';
  5. import { defineMessages } from 'react-intl';
  6. import Rails from '@rails/ujs';
  7. import axios from 'axios';
  8. import { throttle } from 'lodash';
  9. import { start } from '../mastodon/common';
  10. import { timeAgoString } from '../mastodon/components/relative_timestamp';
  11. import emojify from '../mastodon/features/emoji/emoji';
  12. import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
  13. import { loadLocale, getLocale } from '../mastodon/locales';
  14. import { loadPolyfills } from '../mastodon/polyfills';
  15. import ready from '../mastodon/ready';
  16. import 'cocoon-js-vanilla';
  17. start();
  18. const messages = defineMessages({
  19. usernameTaken: {
  20. id: 'username.taken',
  21. defaultMessage: 'That username is taken. Try another',
  22. },
  23. passwordExceedsLength: {
  24. id: 'password_confirmation.exceeds_maxlength',
  25. defaultMessage: 'Password confirmation exceeds the maximum password length',
  26. },
  27. passwordDoesNotMatch: {
  28. id: 'password_confirmation.mismatching',
  29. defaultMessage: 'Password confirmation does not match',
  30. },
  31. });
  32. function loaded() {
  33. const { messages: localeData } = getLocale();
  34. const locale = document.documentElement.lang;
  35. const dateTimeFormat = new Intl.DateTimeFormat(locale, {
  36. year: 'numeric',
  37. month: 'long',
  38. day: 'numeric',
  39. hour: 'numeric',
  40. minute: 'numeric',
  41. });
  42. const dateFormat = new Intl.DateTimeFormat(locale, {
  43. year: 'numeric',
  44. month: 'short',
  45. day: 'numeric',
  46. });
  47. const timeFormat = new Intl.DateTimeFormat(locale, {
  48. timeStyle: 'short',
  49. });
  50. const formatMessage = (
  51. { id, defaultMessage }: MessageDescriptor,
  52. values?: Record<string, PrimitiveType>,
  53. ) => {
  54. let message: string | undefined = undefined;
  55. if (id) message = localeData[id];
  56. if (!message) message = defaultMessage as string;
  57. const messageFormat = new IntlMessageFormat(message, locale);
  58. return messageFormat.format(values) as string;
  59. };
  60. document.querySelectorAll('.emojify').forEach((content) => {
  61. content.innerHTML = emojify(content.innerHTML);
  62. });
  63. document
  64. .querySelectorAll<HTMLTimeElement>('time.formatted')
  65. .forEach((content) => {
  66. const datetime = new Date(content.dateTime);
  67. const formattedDate = dateTimeFormat.format(datetime);
  68. content.title = formattedDate;
  69. content.textContent = formattedDate;
  70. });
  71. const isToday = (date: Date) => {
  72. const today = new Date();
  73. return (
  74. date.getDate() === today.getDate() &&
  75. date.getMonth() === today.getMonth() &&
  76. date.getFullYear() === today.getFullYear()
  77. );
  78. };
  79. const todayFormat = new IntlMessageFormat(
  80. localeData['relative_format.today'] ?? 'Today at {time}',
  81. locale,
  82. );
  83. document
  84. .querySelectorAll<HTMLTimeElement>('time.relative-formatted')
  85. .forEach((content) => {
  86. const datetime = new Date(content.dateTime);
  87. let formattedContent: string;
  88. if (isToday(datetime)) {
  89. const formattedTime = timeFormat.format(datetime);
  90. formattedContent = todayFormat.format({
  91. time: formattedTime,
  92. }) as string;
  93. } else {
  94. formattedContent = dateFormat.format(datetime);
  95. }
  96. content.title = formattedContent;
  97. content.textContent = formattedContent;
  98. });
  99. document
  100. .querySelectorAll<HTMLTimeElement>('time.time-ago')
  101. .forEach((content) => {
  102. const datetime = new Date(content.dateTime);
  103. const now = new Date();
  104. const timeGiven = content.dateTime.includes('T');
  105. content.title = timeGiven
  106. ? dateTimeFormat.format(datetime)
  107. : dateFormat.format(datetime);
  108. content.textContent = timeAgoString(
  109. {
  110. formatMessage,
  111. formatDate: (date: Date, options) =>
  112. new Intl.DateTimeFormat(locale, options).format(date),
  113. },
  114. datetime,
  115. now.getTime(),
  116. now.getFullYear(),
  117. timeGiven,
  118. );
  119. });
  120. const reactComponents = document.querySelectorAll('[data-component]');
  121. if (reactComponents.length > 0) {
  122. import(
  123. /* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container'
  124. )
  125. .then(({ default: MediaContainer }) => {
  126. reactComponents.forEach((component) => {
  127. Array.from(component.children).forEach((child) => {
  128. component.removeChild(child);
  129. });
  130. });
  131. const content = document.createElement('div');
  132. const root = createRoot(content);
  133. root.render(
  134. <MediaContainer locale={locale} components={reactComponents} />,
  135. );
  136. document.body.appendChild(content);
  137. return true;
  138. })
  139. .catch((error: unknown) => {
  140. console.error(error);
  141. });
  142. }
  143. Rails.delegate(
  144. document,
  145. 'input#user_account_attributes_username',
  146. 'input',
  147. throttle(
  148. ({ target }) => {
  149. if (!(target instanceof HTMLInputElement)) return;
  150. if (target.value && target.value.length > 0) {
  151. axios
  152. .get('/api/v1/accounts/lookup', { params: { acct: target.value } })
  153. .then(() => {
  154. target.setCustomValidity(formatMessage(messages.usernameTaken));
  155. return true;
  156. })
  157. .catch(() => {
  158. target.setCustomValidity('');
  159. });
  160. } else {
  161. target.setCustomValidity('');
  162. }
  163. },
  164. 500,
  165. { leading: false, trailing: true },
  166. ),
  167. );
  168. Rails.delegate(
  169. document,
  170. '#user_password,#user_password_confirmation',
  171. 'input',
  172. () => {
  173. const password = document.querySelector<HTMLInputElement>(
  174. 'input#user_password',
  175. );
  176. const confirmation = document.querySelector<HTMLInputElement>(
  177. 'input#user_password_confirmation',
  178. );
  179. if (!confirmation || !password) return;
  180. if (
  181. confirmation.value &&
  182. confirmation.value.length > password.maxLength
  183. ) {
  184. confirmation.setCustomValidity(
  185. formatMessage(messages.passwordExceedsLength),
  186. );
  187. } else if (password.value && password.value !== confirmation.value) {
  188. confirmation.setCustomValidity(
  189. formatMessage(messages.passwordDoesNotMatch),
  190. );
  191. } else {
  192. confirmation.setCustomValidity('');
  193. }
  194. },
  195. );
  196. Rails.delegate(
  197. document,
  198. 'button.status__content__spoiler-link',
  199. 'click',
  200. function () {
  201. if (!(this instanceof HTMLButtonElement)) return;
  202. const statusEl = this.parentNode?.parentNode;
  203. if (
  204. !(
  205. statusEl instanceof HTMLDivElement &&
  206. statusEl.classList.contains('.status__content')
  207. )
  208. )
  209. return;
  210. if (statusEl.dataset.spoiler === 'expanded') {
  211. statusEl.dataset.spoiler = 'folded';
  212. this.textContent = new IntlMessageFormat(
  213. localeData['status.show_more'] ?? 'Show more',
  214. locale,
  215. ).format() as string;
  216. } else {
  217. statusEl.dataset.spoiler = 'expanded';
  218. this.textContent = new IntlMessageFormat(
  219. localeData['status.show_less'] ?? 'Show less',
  220. locale,
  221. ).format() as string;
  222. }
  223. },
  224. );
  225. document
  226. .querySelectorAll<HTMLButtonElement>('button.status__content__spoiler-link')
  227. .forEach((spoilerLink) => {
  228. const statusEl = spoilerLink.parentNode?.parentNode;
  229. if (
  230. !(
  231. statusEl instanceof HTMLDivElement &&
  232. statusEl.classList.contains('.status__content')
  233. )
  234. )
  235. return;
  236. const message =
  237. statusEl.dataset.spoiler === 'expanded'
  238. ? (localeData['status.show_less'] ?? 'Show less')
  239. : (localeData['status.show_more'] ?? 'Show more');
  240. spoilerLink.textContent = new IntlMessageFormat(
  241. message,
  242. locale,
  243. ).format() as string;
  244. });
  245. }
  246. Rails.delegate(
  247. document,
  248. '#edit_profile input[type=file]',
  249. 'change',
  250. ({ target }) => {
  251. if (!(target instanceof HTMLInputElement)) return;
  252. const avatar = document.querySelector<HTMLImageElement>(
  253. `img#${target.id}-preview`,
  254. );
  255. if (!avatar) return;
  256. let file: File | undefined;
  257. if (target.files) file = target.files[0];
  258. const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
  259. if (url) avatar.src = url;
  260. },
  261. );
  262. Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
  263. if (!(target instanceof HTMLInputElement)) return;
  264. target.focus();
  265. target.select();
  266. target.setSelectionRange(0, target.value.length);
  267. });
  268. Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
  269. if (!(target instanceof HTMLButtonElement)) return;
  270. const input = target.parentNode?.querySelector<HTMLInputElement>(
  271. '.input-copy__wrapper input',
  272. );
  273. if (!input) return;
  274. navigator.clipboard
  275. .writeText(input.value)
  276. .then(() => {
  277. const parent = target.parentElement;
  278. if (parent) {
  279. parent.classList.add('copied');
  280. setTimeout(() => {
  281. parent.classList.remove('copied');
  282. }, 700);
  283. }
  284. return true;
  285. })
  286. .catch((error: unknown) => {
  287. console.error(error);
  288. });
  289. });
  290. const toggleSidebar = () => {
  291. const sidebar = document.querySelector<HTMLUListElement>('.sidebar ul');
  292. const toggleButton = document.querySelector<HTMLAnchorElement>(
  293. 'a.sidebar__toggle__icon',
  294. );
  295. if (!sidebar || !toggleButton) return;
  296. if (sidebar.classList.contains('visible')) {
  297. document.body.style.overflow = '';
  298. toggleButton.setAttribute('aria-expanded', 'false');
  299. } else {
  300. document.body.style.overflow = 'hidden';
  301. toggleButton.setAttribute('aria-expanded', 'true');
  302. }
  303. toggleButton.classList.toggle('active');
  304. sidebar.classList.toggle('visible');
  305. };
  306. Rails.delegate(document, '.sidebar__toggle__icon', 'click', () => {
  307. toggleSidebar();
  308. });
  309. Rails.delegate(document, '.sidebar__toggle__icon', 'keydown', (e) => {
  310. if (e.key === ' ' || e.key === 'Enter') {
  311. e.preventDefault();
  312. toggleSidebar();
  313. }
  314. });
  315. Rails.delegate(document, 'img.custom-emoji', 'mouseover', ({ target }) => {
  316. if (target instanceof HTMLImageElement && target.dataset.original)
  317. target.src = target.dataset.original;
  318. });
  319. Rails.delegate(document, 'img.custom-emoji', 'mouseout', ({ target }) => {
  320. if (target instanceof HTMLImageElement && target.dataset.static)
  321. target.src = target.dataset.static;
  322. });
  323. const setInputDisabled = (
  324. input: HTMLInputElement | HTMLSelectElement,
  325. disabled: boolean,
  326. ) => {
  327. input.disabled = disabled;
  328. const wrapper = input.closest('.with_label');
  329. if (wrapper) {
  330. wrapper.classList.toggle('disabled', input.disabled);
  331. const hidden =
  332. input.type === 'checkbox' &&
  333. wrapper.querySelector<HTMLInputElement>('input[type=hidden][value="0"]');
  334. if (hidden) {
  335. hidden.disabled = input.disabled;
  336. }
  337. }
  338. };
  339. Rails.delegate(
  340. document,
  341. '#account_statuses_cleanup_policy_enabled',
  342. 'change',
  343. ({ target }) => {
  344. if (!(target instanceof HTMLInputElement) || !target.form) return;
  345. target.form
  346. .querySelectorAll<
  347. HTMLInputElement | HTMLSelectElement
  348. >('input:not([type=hidden], #account_statuses_cleanup_policy_enabled), select')
  349. .forEach((input) => {
  350. setInputDisabled(input, !target.checked);
  351. });
  352. },
  353. );
  354. // Empty the honeypot fields in JS in case something like an extension
  355. // automatically filled them.
  356. Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
  357. [
  358. 'user_website',
  359. 'user_confirm_password',
  360. 'registration_user_website',
  361. 'registration_user_confirm_password',
  362. ].forEach((id) => {
  363. const field = document.querySelector<HTMLInputElement>(`input#${id}`);
  364. if (field) {
  365. field.value = '';
  366. }
  367. });
  368. });
  369. function main() {
  370. ready(loaded).catch((error: unknown) => {
  371. console.error(error);
  372. });
  373. }
  374. loadPolyfills()
  375. .then(loadLocale)
  376. .then(main)
  377. .then(loadKeyboardExtensions)
  378. .catch((error: unknown) => {
  379. console.error(error);
  380. });