From 7038d6a844b18e10b81297bcfd8d95cacaf1a613 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 24 Apr 2022 14:28:07 -0500 Subject: [PATCH] Convert a bunch of files to TypeScript --- .../{base_polyfills.js => base_polyfills.ts} | 5 +- app/soapbox/components/sidebar-navigation.tsx | 2 +- ...{extra_polyfills.js => extra_polyfills.ts} | 2 +- .../steps/avatar-selection-step.tsx | 4 +- .../steps/cover-photo-selection-step.tsx | 4 +- app/soapbox/{globals.js => globals.ts} | 13 ++-- app/soapbox/is_mobile.js | 29 ------- app/soapbox/is_mobile.ts | 34 +++++++++ app/soapbox/{precheck.js => precheck.ts} | 5 ++ app/soapbox/reducers/{meta.js => meta.ts} | 4 +- app/soapbox/{rtl.js => rtl.ts} | 3 +- app/soapbox/{settings.js => settings.ts} | 17 +++-- app/soapbox/store.ts | 2 + app/soapbox/utils/accounts.ts | 5 +- ...{favicon_service.js => favicon_service.ts} | 24 ++++-- .../utils/{greentext.js => greentext.ts} | 2 +- app/soapbox/utils/{html.js => html.ts} | 10 ++- app/soapbox/utils/numbers.js | 16 ---- app/soapbox/utils/numbers.tsx | 19 +++++ app/soapbox/utils/quirks.js | 17 ----- app/soapbox/utils/quirks.ts | 38 ++++++++++ .../{resize_image.js => resize_image.ts} | 75 ++++++++++++++----- .../{rich_content.js => rich_content.ts} | 10 +-- ...ox_prop_types.js => soapbox_prop_types.ts} | 0 app/soapbox/utils/state.js | 42 ----------- app/soapbox/utils/state.ts | 44 +++++++++++ app/soapbox/utils/static.js | 12 --- app/soapbox/utils/static.ts | 13 ++++ app/soapbox/utils/status.ts | 9 ++- ...ocessor.js => tiny_post_html_processor.ts} | 26 +++---- package.json | 2 + yarn.lock | 10 +++ 32 files changed, 312 insertions(+), 186 deletions(-) rename app/soapbox/{base_polyfills.js => base_polyfills.ts} (87%) rename app/soapbox/{extra_polyfills.js => extra_polyfills.ts} (65%) rename app/soapbox/{globals.js => globals.ts} (57%) delete mode 100644 app/soapbox/is_mobile.js create mode 100644 app/soapbox/is_mobile.ts rename app/soapbox/{precheck.js => precheck.ts} (61%) rename app/soapbox/reducers/{meta.js => meta.ts} (75%) rename app/soapbox/{rtl.js => rtl.ts} (89%) rename app/soapbox/{settings.js => settings.ts} (73%) rename app/soapbox/utils/{favicon_service.js => favicon_service.ts} (73%) rename app/soapbox/utils/{greentext.js => greentext.ts} (92%) rename app/soapbox/utils/{html.js => html.ts} (61%) delete mode 100644 app/soapbox/utils/numbers.js create mode 100644 app/soapbox/utils/numbers.tsx delete mode 100644 app/soapbox/utils/quirks.js create mode 100644 app/soapbox/utils/quirks.ts rename app/soapbox/utils/{resize_image.js => resize_image.ts} (72%) rename app/soapbox/utils/{rich_content.js => rich_content.ts} (65%) rename app/soapbox/utils/{soapbox_prop_types.js => soapbox_prop_types.ts} (100%) delete mode 100644 app/soapbox/utils/state.js create mode 100644 app/soapbox/utils/state.ts delete mode 100644 app/soapbox/utils/static.js create mode 100644 app/soapbox/utils/static.ts rename app/soapbox/utils/{tiny_post_html_processor.js => tiny_post_html_processor.ts} (74%) diff --git a/app/soapbox/base_polyfills.js b/app/soapbox/base_polyfills.ts similarity index 87% rename from app/soapbox/base_polyfills.js rename to app/soapbox/base_polyfills.ts index e4744fee3..53146d222 100644 --- a/app/soapbox/base_polyfills.js +++ b/app/soapbox/base_polyfills.ts @@ -3,9 +3,12 @@ import 'intl'; import 'intl/locale-data/jsonp/en'; import 'es6-symbol/implement'; +// @ts-ignore: No types import includes from 'array-includes'; +// @ts-ignore: No types import isNaN from 'is-nan'; import assign from 'object-assign'; +// @ts-ignore: No types import values from 'object.values'; import { decode as decodeBase64 } from './utils/base64'; @@ -30,7 +33,7 @@ if (!HTMLCanvasElement.prototype.toBlob) { const BASE64_MARKER = ';base64,'; Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { - value(callback, type = 'image/png', quality) { + value(callback: any, type = 'image/png', quality: any) { const dataURL = this.toDataURL(type, quality); let data; diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index bb8f47e4d..00215c8e3 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -22,7 +22,7 @@ const SidebarNavigation = () => { const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count()); // const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count()); - const baseURL = account ? getBaseURL(ImmutableMap(account)) : ''; + const baseURL = account ? getBaseURL(account) : ''; const features = getFeatures(instance); const makeMenu = (): Menu => { diff --git a/app/soapbox/extra_polyfills.js b/app/soapbox/extra_polyfills.ts similarity index 65% rename from app/soapbox/extra_polyfills.js rename to app/soapbox/extra_polyfills.ts index c2da75f7c..47a1b6e33 100644 --- a/app/soapbox/extra_polyfills.js +++ b/app/soapbox/extra_polyfills.ts @@ -2,6 +2,6 @@ import 'intersection-observer'; import 'requestidlecallback'; -import objectFitImages from 'object-fit-images'; +import objectFitImages from 'object-fit-images'; objectFitImages(); diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx index 384494394..ccc000816 100644 --- a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx +++ b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx @@ -37,7 +37,9 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => { const handleFileChange = (event: React.ChangeEvent) => { const maxPixels = 400 * 400; - const [rawFile] = event.target.files || [] as any; + const rawFile = event.target.files?.item(0); + + if (!rawFile) return; resizeImage(rawFile, maxPixels).then((file) => { const url = file ? URL.createObjectURL(file) : account?.avatar as string; diff --git a/app/soapbox/features/onboarding/steps/cover-photo-selection-step.tsx b/app/soapbox/features/onboarding/steps/cover-photo-selection-step.tsx index 079fd04a1..165224469 100644 --- a/app/soapbox/features/onboarding/steps/cover-photo-selection-step.tsx +++ b/app/soapbox/features/onboarding/steps/cover-photo-selection-step.tsx @@ -38,7 +38,9 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => { const handleFileChange = (event: React.ChangeEvent) => { const maxPixels = 1920 * 1080; - const [rawFile] = event.target.files || [] as any; + const rawFile = event.target.files?.item(0); + + if (!rawFile) return; resizeImage(rawFile, maxPixels).then((file) => { const url = file ? URL.createObjectURL(file) : account?.header as string; diff --git a/app/soapbox/globals.js b/app/soapbox/globals.ts similarity index 57% rename from app/soapbox/globals.js rename to app/soapbox/globals.ts index 08e773695..f2c1eb35b 100644 --- a/app/soapbox/globals.js +++ b/app/soapbox/globals.ts @@ -4,17 +4,20 @@ */ import { changeSettingImmediate } from 'soapbox/actions/settings'; -export const createGlobals = store => { +import type { Store } from 'soapbox/store'; + +/** Add Soapbox globals to the window. */ +export const createGlobals = (store: Store) => { const Soapbox = { - // Become a developer with `Soapbox.isDeveloper()` - isDeveloper: (bool = true) => { + /** Become a developer with `Soapbox.isDeveloper()` */ + isDeveloper: (bool = true): boolean => { if (![true, false].includes(bool)) { throw `Invalid option ${bool}. Must be true or false.`; } - store.dispatch(changeSettingImmediate(['isDeveloper'], bool)); + store.dispatch(changeSettingImmediate(['isDeveloper'], bool) as any); return bool; }, }; - window.Soapbox = Soapbox; + (window as any).Soapbox = Soapbox; }; diff --git a/app/soapbox/is_mobile.js b/app/soapbox/is_mobile.js deleted file mode 100644 index 125912421..000000000 --- a/app/soapbox/is_mobile.js +++ /dev/null @@ -1,29 +0,0 @@ -'use strict'; - -import { supportsPassiveEvents } from 'detect-passive-events'; - -const LAYOUT_BREAKPOINT = 630; - -export function isMobile(width) { - return width <= LAYOUT_BREAKPOINT; -} - -const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; - -let userTouching = false; -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; - -function touchListener() { - userTouching = true; - window.removeEventListener('touchstart', touchListener, listenerOptions); -} - -window.addEventListener('touchstart', touchListener, listenerOptions); - -export function isUserTouching() { - return userTouching; -} - -export function isIOS() { - return iOS; -} diff --git a/app/soapbox/is_mobile.ts b/app/soapbox/is_mobile.ts new file mode 100644 index 000000000..fe90f9a97 --- /dev/null +++ b/app/soapbox/is_mobile.ts @@ -0,0 +1,34 @@ +'use strict'; + +import { supportsPassiveEvents } from 'detect-passive-events'; + +/** Breakpoint at which the application is considered "mobile". */ +const LAYOUT_BREAKPOINT = 630; + +/** Check if the width is small enough to be considered "mobile". */ +export function isMobile(width: number) { + return width <= LAYOUT_BREAKPOINT; +} + +/** Whether the device is iOS (best guess). */ +const iOS: boolean = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream; + +let userTouching = false; +const listenerOptions = supportsPassiveEvents ? { passive: true } as EventListenerOptions : false; + +function touchListener(): void { + userTouching = true; + window.removeEventListener('touchstart', touchListener, listenerOptions); +} + +window.addEventListener('touchstart', touchListener, listenerOptions); + +/** Whether the user has touched the screen since the page loaded. */ +export function isUserTouching(): boolean { + return userTouching; +} + +/** Whether the device is iOS (best guess). */ +export function isIOS(): boolean { + return iOS; +} diff --git a/app/soapbox/precheck.js b/app/soapbox/precheck.ts similarity index 61% rename from app/soapbox/precheck.js rename to app/soapbox/precheck.ts index 9b03a3f76..3be1bb1b9 100644 --- a/app/soapbox/precheck.js +++ b/app/soapbox/precheck.ts @@ -3,9 +3,14 @@ * @module soapbox/precheck */ +/** Whether a page title was inserted with SSR. */ const hasTitle = Boolean(document.querySelector('title')); +/** Whether pre-rendered data exists in Mastodon's format. */ const hasPrerenderPleroma = Boolean(document.getElementById('initial-results')); + +/** Whether pre-rendered data exists in Pleroma's format. */ const hasPrerenderMastodon = Boolean(document.getElementById('initial-state')); +/** Whether initial data was loaded into the page by server-side-rendering (SSR). */ export const isPrerendered = hasTitle || hasPrerenderPleroma || hasPrerenderMastodon; diff --git a/app/soapbox/reducers/meta.js b/app/soapbox/reducers/meta.ts similarity index 75% rename from app/soapbox/reducers/meta.js rename to app/soapbox/reducers/meta.ts index eb4f40486..cdcdaf580 100644 --- a/app/soapbox/reducers/meta.js +++ b/app/soapbox/reducers/meta.ts @@ -4,11 +4,13 @@ import { Record as ImmutableRecord } from 'immutable'; import { INSTANCE_FETCH_FAIL } from 'soapbox/actions/instance'; +import type { AnyAction } from 'redux'; + const ReducerRecord = ImmutableRecord({ instance_fetch_failed: false, }); -export default function meta(state = ReducerRecord(), action) { +export default function meta(state = ReducerRecord(), action: AnyAction) { switch(action.type) { case INSTANCE_FETCH_FAIL: return state.set('instance_fetch_failed', true); diff --git a/app/soapbox/rtl.js b/app/soapbox/rtl.ts similarity index 89% rename from app/soapbox/rtl.js rename to app/soapbox/rtl.ts index c557e3fd9..4c3599cb6 100644 --- a/app/soapbox/rtl.js +++ b/app/soapbox/rtl.ts @@ -14,7 +14,8 @@ const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; -export function isRtl(text) { +/** Check if text is right-to-left (eg Arabic). */ +export function isRtl(text: string): boolean { if (text.length === 0) { return false; } diff --git a/app/soapbox/settings.js b/app/soapbox/settings.ts similarity index 73% rename from app/soapbox/settings.js rename to app/soapbox/settings.ts index 1acaeee91..70efc0e8e 100644 --- a/app/soapbox/settings.js +++ b/app/soapbox/settings.ts @@ -2,15 +2,17 @@ export default class Settings { - constructor(keyBase = null) { + keyBase: string | null = null; + + constructor(keyBase: string | null = null) { this.keyBase = keyBase; } - generateKey(id) { + generateKey(id: string) { return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id; } - set(id, data) { + set(id: string, data: any) { const key = this.generateKey(id); try { const encodedData = JSON.stringify(data); @@ -21,17 +23,17 @@ export default class Settings { } } - get(id) { + get(id: string) { const key = this.generateKey(id); try { const rawData = localStorage.getItem(key); - return JSON.parse(rawData); + return rawData ? JSON.parse(rawData) : null; } catch (e) { return null; } } - remove(id) { + remove(id: string) { const data = this.get(id); if (data) { const key = this.generateKey(id); @@ -46,5 +48,8 @@ export default class Settings { } +/** Remember push notification settings. */ export const pushNotificationsSetting = new Settings('soapbox_push_notification_data'); + +/** Remember hashtag usage. */ export const tagHistory = new Settings('soapbox_tag_history'); diff --git a/app/soapbox/store.ts b/app/soapbox/store.ts index 5e557ac54..170b316c9 100644 --- a/app/soapbox/store.ts +++ b/app/soapbox/store.ts @@ -17,6 +17,8 @@ export const store = createStore( ), ); +export type Store = typeof store; + // Infer the `RootState` and `AppDispatch` types from the store itself // https://redux.js.org/usage/usage-with-typescript export type RootState = ReturnType; diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index 2c942eeaa..2ea90bc2b 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -16,10 +16,9 @@ export const getDomain = (account: Account): string => { return domain ? domain : getDomainFromURL(account); }; -export const getBaseURL = (account: ImmutableMap): string => { +export const getBaseURL = (account: Account): string => { try { - const url = account.get('url'); - return new URL(url).origin; + return new URL(account.url).origin; } catch { return ''; } diff --git a/app/soapbox/utils/favicon_service.js b/app/soapbox/utils/favicon_service.ts similarity index 73% rename from app/soapbox/utils/favicon_service.js rename to app/soapbox/utils/favicon_service.ts index d009bca60..ac8a1341b 100644 --- a/app/soapbox/utils/favicon_service.js +++ b/app/soapbox/utils/favicon_service.ts @@ -1,14 +1,23 @@ // Adapted from Pleroma FE // https://git.pleroma.social/pleroma/pleroma-fe/-/blob/ef5bbc4e5f84bb9e8da76a0440eea5d656d36977/src/services/favicon_service/favicon_service.js +type Favicon = { + favcanvas: HTMLCanvasElement, + favimg: HTMLImageElement, + favcontext: CanvasRenderingContext2D | null, + favicon: HTMLLinkElement, +}; + +/** Service to draw and update a notifications dot on the favicon */ const createFaviconService = () => { - const favicons = []; + const favicons: Favicon[] = []; const faviconWidth = 128; const faviconHeight = 128; const badgeRadius = 24; - const initFaviconService = () => { - const nodes = document.querySelectorAll('link[rel="icon"]'); + /** Start the favicon service */ + const initFaviconService = (): void => { + const nodes: NodeListOf = document.querySelectorAll('link[rel="icon"]'); nodes.forEach(favicon => { if (favicon) { const favcanvas = document.createElement('canvas'); @@ -23,9 +32,11 @@ const createFaviconService = () => { }); }; - const isImageLoaded = (img) => img.complete && img.naturalHeight !== 0; + /** Check if the image is loaded */ + const isImageLoaded = (img: HTMLImageElement): boolean => img.complete && img.naturalHeight !== 0; - const clearFaviconBadge = () => { + /** Reset the favicon image to its initial state */ + const clearFaviconBadge = (): void => { if (favicons.length === 0) return; favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => { if (!favimg || !favcontext || !favicon) return; @@ -37,7 +48,8 @@ const createFaviconService = () => { }); }; - const drawFaviconBadge = () => { + /** Replace the favicon image with one that has a notification dot */ + const drawFaviconBadge = (): void => { if (favicons.length === 0) return; clearFaviconBadge(); favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => { diff --git a/app/soapbox/utils/greentext.js b/app/soapbox/utils/greentext.ts similarity index 92% rename from app/soapbox/utils/greentext.js rename to app/soapbox/utils/greentext.ts index f60ee7c3c..52a25e2be 100644 --- a/app/soapbox/utils/greentext.js +++ b/app/soapbox/utils/greentext.ts @@ -1,6 +1,6 @@ import { processHtml } from './tiny_post_html_processor'; -export const addGreentext = html => { +export const addGreentext = (html: string): string => { // Copied from Pleroma FE // https://git.pleroma.social/pleroma/pleroma-fe/-/blob/19475ba356c3fd6c54ca0306d3ae392358c212d1/src/components/status_content/status_content.js#L132 return processHtml(html, (string) => { diff --git a/app/soapbox/utils/html.js b/app/soapbox/utils/html.ts similarity index 61% rename from app/soapbox/utils/html.js rename to app/soapbox/utils/html.ts index 9eddcf876..0b19ef819 100644 --- a/app/soapbox/utils/html.js +++ b/app/soapbox/utils/html.ts @@ -1,16 +1,20 @@ +/** Convert HTML to a plaintext representation, preserving whitespace. */ // NB: This function can still return unsafe HTML -export const unescapeHTML = (html) => { +export const unescapeHTML = (html: string): string => { const wrapper = document.createElement('div'); wrapper.innerHTML = html.replace(//g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, ''); - return wrapper.textContent; + return wrapper.textContent || ''; }; -export const stripCompatibilityFeatures = html => { +/** Remove compatibility markup for features Soapbox supports. */ +export const stripCompatibilityFeatures = (html: string): string => { const node = document.createElement('div'); node.innerHTML = html; const selectors = [ + // Quote posting '.quote-inline', + // Explicit mentions '.recipients-inline', ]; diff --git a/app/soapbox/utils/numbers.js b/app/soapbox/utils/numbers.js deleted file mode 100644 index 18f4d5019..000000000 --- a/app/soapbox/utils/numbers.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { FormattedNumber } from 'react-intl'; - -export const isNumber = number => typeof number === 'number' && !isNaN(number); - -export const shortNumberFormat = number => { - if (!isNumber(number)) return '•'; - - if (number < 1000) { - return ; - } else { - return K; - } -}; - -export const isIntegerId = id => new RegExp(/^-?[0-9]+$/g).test(id); diff --git a/app/soapbox/utils/numbers.tsx b/app/soapbox/utils/numbers.tsx new file mode 100644 index 000000000..457b69f84 --- /dev/null +++ b/app/soapbox/utils/numbers.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { FormattedNumber } from 'react-intl'; + +/** Check if a value is REALLY a number. */ +export const isNumber = (number: unknown): boolean => typeof number === 'number' && !isNaN(number); + +/** Display a number nicely for the UI, eg 1000 becomes 1K. */ +export const shortNumberFormat = (number: any): React.ReactNode => { + if (!isNumber(number)) return '•'; + + if (number < 1000) { + return ; + } else { + return K; + } +}; + +/** Check if an entity ID is an integer (eg not a FlakeId). */ +export const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id); diff --git a/app/soapbox/utils/quirks.js b/app/soapbox/utils/quirks.js deleted file mode 100644 index c64631078..000000000 --- a/app/soapbox/utils/quirks.js +++ /dev/null @@ -1,17 +0,0 @@ -import { createSelector } from 'reselect'; - -import { parseVersion, PLEROMA, MITRA } from './features'; - -// For solving bugs between API implementations -export const getQuirks = createSelector([ - instance => parseVersion(instance.get('version')), -], (v) => { - return { - invertedPagination: v.software === PLEROMA, - noApps: v.software === MITRA, - noOAuthForm: v.software === MITRA, - }; -}); - -export const getNextLinkName = getState => - getQuirks(getState().get('instance')).invertedPagination ? 'prev' : 'next'; diff --git a/app/soapbox/utils/quirks.ts b/app/soapbox/utils/quirks.ts new file mode 100644 index 000000000..6abfec205 --- /dev/null +++ b/app/soapbox/utils/quirks.ts @@ -0,0 +1,38 @@ +/* eslint sort-keys: "error" */ +import { createSelector } from 'reselect'; + +import { parseVersion, PLEROMA, MITRA } from './features'; + +import type { RootState } from 'soapbox/store'; +import type { Instance } from 'soapbox/types/entities'; + +/** For solving bugs between API implementations. */ +export const getQuirks = createSelector([ + (instance: Instance) => parseVersion(instance.version), +], (v) => { + return { + /** + * The `next` and `prev` Link headers are backwards for blocks and mutes. + * @see GET /api/v1/blocks + * @see GET /api/v1/mutes + */ + invertedPagination: v.software === PLEROMA, + + /** + * Apps are not supported by the API, and should not be created during login or registration. + * @see POST /api/v1/apps + * @see POST /oauth/token + */ + noApps: v.software === MITRA, + + /** + * There is no OAuth form available for login. + * @see GET /oauth/authorize + */ + noOAuthForm: v.software === MITRA, + }; +}); + +/** Shortcut for inverted pagination quirk. */ +export const getNextLinkName = (getState: () => RootState) => + getQuirks(getState().instance).invertedPagination ? 'prev' : 'next'; diff --git a/app/soapbox/utils/resize_image.js b/app/soapbox/utils/resize_image.ts similarity index 72% rename from app/soapbox/utils/resize_image.js rename to app/soapbox/utils/resize_image.ts index 4d0040078..268954fe7 100644 --- a/app/soapbox/utils/resize_image.js +++ b/app/soapbox/utils/resize_image.ts @@ -1,14 +1,19 @@ /* eslint-disable no-case-declarations */ const DEFAULT_MAX_PIXELS = 1920 * 1080; -const _browser_quirks = {}; +interface BrowserCanvasQuirks { + 'image-orientation-automatic'?: boolean, + 'canvas-read-unreliable'?: boolean, +} + +const _browser_quirks: BrowserCanvasQuirks = {}; // Some browsers will automatically draw images respecting their EXIF orientation // while others won't, and the safest way to detect that is to examine how it // is done on a known image. // See https://github.com/w3c/csswg-drafts/issues/4666 // and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881 -const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { +const dropOrientationIfNeeded = (orientation: number) => new Promise(resolve => { switch (_browser_quirks['image-orientation-automatic']) { case true: resolve(1); @@ -40,10 +45,12 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { } }); -// Some browsers don't allow reading from a canvas and instead return all-white -// or randomized data. Use a pre-defined image to check if reading the canvas -// works. -// const checkCanvasReliability = () => new Promise((resolve, reject) => { +// /** +// *Some browsers don't allow reading from a canvas and instead return all-white +// * or randomized data. Use a pre-defined image to check if reading the canvas +// * works. +// */ +// const checkCanvasReliability = () => new Promise((resolve, reject) => { // switch(_browser_quirks['canvas-read-unreliable']) { // case true: // reject('Canvas reading unreliable'); @@ -61,9 +68,9 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { // img.onload = () => { // const canvas = document.createElement('canvas'); // const context = canvas.getContext('2d'); -// context.drawImage(img, 0, 0, 2, 2); -// const imageData = context.getImageData(0, 0, 2, 2); -// if (imageData.data.every((x, i) => refData[i] === x)) { +// context?.drawImage(img, 0, 0, 2, 2); +// const imageData = context?.getImageData(0, 0, 2, 2); +// if (imageData?.data.every((x, i) => refData[i] === x)) { // _browser_quirks['canvas-read-unreliable'] = false; // resolve(); // } else { @@ -79,7 +86,9 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { // } // }); -const getImageUrl = inputFile => new Promise((resolve, reject) => { +/** Convert the file into a local blob URL. */ +const getImageUrl = (inputFile: File) => new Promise((resolve, reject) => { + // @ts-ignore: This is a browser capabilities check. if (window.URL?.createObjectURL) { try { resolve(URL.createObjectURL(inputFile)); @@ -91,29 +100,32 @@ const getImageUrl = inputFile => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = (...args) => reject(...args); - reader.onload = ({ target }) => resolve(target.result); + reader.onload = ({ target }) => resolve((target?.result || '') as string); reader.readAsDataURL(inputFile); }); -const loadImage = inputFile => new Promise((resolve, reject) => { +/** Get an image element from a file. */ +const loadImage = (inputFile: File) => new Promise((resolve, reject) => { getImageUrl(inputFile).then(url => { const img = new Image(); - img.onerror = (...args) => reject(...args); + img.onerror = (...args) => reject([...args]); img.onload = () => resolve(img); img.src = url; }).catch(reject); }); -const getOrientation = (img, type = 'image/png') => new Promise(resolve => { +/** Get the exif orientation for the image. */ +const getOrientation = (img: HTMLImageElement, type = 'image/png') => new Promise(resolve => { if (!['image/jpeg', 'image/webp'].includes(type)) { resolve(1); return; } import(/* webpackChunkName: "features/compose" */'exif-js').then(({ default: EXIF }) => { + // @ts-ignore: The TypeScript definition is wrong. EXIF.getData(img, () => { const orientation = EXIF.getTag(img, 'Orientation'); if (orientation !== 1) { @@ -125,7 +137,22 @@ const getOrientation = (img, type = 'image/png') => new Promise(resolve => { }).catch(() => {}); }); -const processImage = (img, { width, height, orientation, type = 'image/png', name = 'resized.png' }) => new Promise(resolve => { +const processImage = ( + img: HTMLImageElement, + { + width, + height, + orientation, + type = 'image/png', + name = 'resized.png', + } : { + width: number, + height: number, + orientation: number, + type?: string, + name?: string, + }, +) => new Promise((resolve, reject) => { const canvas = document.createElement('canvas'); if (4 < orientation && orientation < 9) { @@ -138,6 +165,11 @@ const processImage = (img, { width, height, orientation, type = 'image/png', nam const context = canvas.getContext('2d'); + if (!context) { + reject(context); + return; + } + switch (orientation) { case 2: context.transform(-1, 0, 0, 1, width, 0); break; case 3: context.transform(-1, 0, 0, -1, width, height); break; @@ -151,11 +183,19 @@ const processImage = (img, { width, height, orientation, type = 'image/png', nam context.drawImage(img, 0, 0, width, height); canvas.toBlob((blob) => { + if (!blob) { + reject(blob); + return; + } resolve(new File([blob], name, { type, lastModified: new Date().getTime() })); }, type); }); -const resizeImage = (img, inputFile, maxPixels) => new Promise((resolve, reject) => { +const resizeImage = ( + img: HTMLImageElement, + inputFile: File, + maxPixels: number, +) => new Promise((resolve, reject) => { const { width, height } = img; const type = inputFile.type || 'image/png'; @@ -177,7 +217,8 @@ const resizeImage = (img, inputFile, maxPixels) => new Promise((resolve, reject) .catch(reject); }); -export default (inputFile, maxPixels = DEFAULT_MAX_PIXELS) => new Promise((resolve) => { +/** Resize an image to the maximum number of pixels. */ +export default (inputFile: File, maxPixels = DEFAULT_MAX_PIXELS) => new Promise((resolve) => { if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { resolve(inputFile); return; diff --git a/app/soapbox/utils/rich_content.js b/app/soapbox/utils/rich_content.ts similarity index 65% rename from app/soapbox/utils/rich_content.js rename to app/soapbox/utils/rich_content.ts index c5de1ea1e..1fe3f5c79 100644 --- a/app/soapbox/utils/rich_content.js +++ b/app/soapbox/utils/rich_content.ts @@ -1,15 +1,15 @@ -// Returns `true` if the node contains only emojis, up to a limit -export const onlyEmoji = (node, limit = 1, ignoreMentions = true) => { +/** Returns `true` if the node contains only emojis, up to a limit */ +export const onlyEmoji = (node: HTMLElement, limit = 1, ignoreMentions = true): boolean => { if (!node) return false; try { // Remove mentions before checking content if (ignoreMentions) { - node = node.cloneNode(true); - node.querySelectorAll('a.mention').forEach(m => m.parentNode.removeChild(m)); + node = node.cloneNode(true) as HTMLElement; + node.querySelectorAll('a.mention').forEach(m => m.parentNode?.removeChild(m)); } - if (node.textContent.replace(new RegExp(' ', 'g'), '') !== '') return false; + if (node.textContent?.replace(new RegExp(' ', 'g'), '') !== '') return false; const emojis = Array.from(node.querySelectorAll('img.emojione')); if (emojis.length === 0) return false; if (emojis.length > limit) return false; diff --git a/app/soapbox/utils/soapbox_prop_types.js b/app/soapbox/utils/soapbox_prop_types.ts similarity index 100% rename from app/soapbox/utils/soapbox_prop_types.js rename to app/soapbox/utils/soapbox_prop_types.ts diff --git a/app/soapbox/utils/state.js b/app/soapbox/utils/state.js deleted file mode 100644 index 5ccb4284d..000000000 --- a/app/soapbox/utils/state.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * State: general Redux state utility functions. - * @module soapbox/utils/state - */ - -import { getSoapboxConfig } from'soapbox/actions/soapbox'; -import { BACKEND_URL } from 'soapbox/build_config'; -import { isPrerendered } from 'soapbox/precheck'; -import { getBaseURL as getAccountBaseURL } from 'soapbox/utils/accounts'; -import { isURL } from 'soapbox/utils/auth'; - -export const displayFqn = state => { - const soapbox = getSoapboxConfig(state); - return soapbox.get('displayFqn'); -}; - -export const federationRestrictionsDisclosed = state => { - return state.hasIn(['instance', 'pleroma', 'metadata', 'federation', 'mrf_policies']); -}; - -/** - * Determine whether Soapbox FE is running in standalone mode. - * Standalone mode runs separately from any backend and can login anywhere. - * @param {object} state - * @returns {boolean} - */ -export const isStandalone = state => { - const instanceFetchFailed = state.getIn(['meta', 'instance_fetch_failed'], false); - return isURL(BACKEND_URL) ? false : (!isPrerendered && instanceFetchFailed); -}; - -/** - * Get the baseURL of the instance. - * @param {object} state - * @returns {string} url - */ -export const getBaseURL = state => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); - - return isURL(BACKEND_URL) ? BACKEND_URL : getAccountBaseURL(account); -}; diff --git a/app/soapbox/utils/state.ts b/app/soapbox/utils/state.ts new file mode 100644 index 000000000..6ea55dc21 --- /dev/null +++ b/app/soapbox/utils/state.ts @@ -0,0 +1,44 @@ +/** + * State: general Redux state utility functions. + * @module soapbox/utils/state + */ + +import { getSoapboxConfig } from'soapbox/actions/soapbox'; +import * as BuildConfig from 'soapbox/build_config'; +import { isPrerendered } from 'soapbox/precheck'; +import { isURL } from 'soapbox/utils/auth'; + +import type { RootState } from 'soapbox/store'; + +/** Whether to display the fqn instead of the acct. */ +export const displayFqn = (state: RootState): boolean => { + return getSoapboxConfig(state).displayFqn; +}; + +/** Whether the instance exposes instance blocks through the API. */ +export const federationRestrictionsDisclosed = (state: RootState): boolean => { + return state.instance.pleroma.hasIn(['metadata', 'federation', 'mrf_policies']); +}; + +/** + * Determine whether Soapbox FE is running in standalone mode. + * Standalone mode runs separately from any backend and can login anywhere. + */ +export const isStandalone = (state: RootState): boolean => { + const instanceFetchFailed = state.meta.instance_fetch_failed; + return isURL(BuildConfig.BACKEND_URL) ? false : (!isPrerendered && instanceFetchFailed); +}; + +const getHost = (url: any): string => { + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + +/** Get the baseURL of the instance. */ +export const getBaseURL = (state: RootState): string => { + const account = state.accounts.get(state.me); + return isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : getHost(account?.url); +}; diff --git a/app/soapbox/utils/static.js b/app/soapbox/utils/static.js deleted file mode 100644 index fd5dee9d8..000000000 --- a/app/soapbox/utils/static.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Static: functions related to static files. - * @module soapbox/utils/static - */ - -import { join } from 'path'; - -import { FE_SUBDIRECTORY } from 'soapbox/build_config'; - -export const joinPublicPath = (...paths) => { - return join(FE_SUBDIRECTORY, ...paths); -}; diff --git a/app/soapbox/utils/static.ts b/app/soapbox/utils/static.ts new file mode 100644 index 000000000..79ba46931 --- /dev/null +++ b/app/soapbox/utils/static.ts @@ -0,0 +1,13 @@ +/** + * Static: functions related to static files. + * @module soapbox/utils/static + */ + +import { join } from 'path'; + +import * as BuildConfig from 'soapbox/build_config'; + +/** Gets the path to a file with build configuration being considered. */ +export const joinPublicPath = (...paths: string[]): string => { + return join(BuildConfig.FE_SUBDIRECTORY, ...paths); +}; diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index 7f2bfe42f..b735fb75d 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -2,7 +2,8 @@ import { isIntegerId } from 'soapbox/utils/numbers'; import type { Status as StatusEntity } from 'soapbox/types/entities'; -export const getFirstExternalLink = (status: StatusEntity) => { +/** Grab the first external link from a status. */ +export const getFirstExternalLink = (status: StatusEntity): HTMLAnchorElement | null => { try { // Pulled from Pleroma's media parser const selector = 'a:not(.mention,.hashtag,.attachment,[rel~="tag"])'; @@ -14,11 +15,13 @@ export const getFirstExternalLink = (status: StatusEntity) => { } }; -export const shouldHaveCard = (status: StatusEntity) => { +/** Whether the status is expected to have a Card after it loads. */ +export const shouldHaveCard = (status: StatusEntity): boolean => { return Boolean(getFirstExternalLink(status)); }; +/** Whether the media IDs on this status have integer IDs (opposed to FlakeIds). */ // https://gitlab.com/soapbox-pub/soapbox-fe/-/merge_requests/1087 -export const hasIntegerMediaIds = (status: StatusEntity) => { +export const hasIntegerMediaIds = (status: StatusEntity): boolean => { return status.media_attachments.some(({ id }) => isIntegerId(id)); }; diff --git a/app/soapbox/utils/tiny_post_html_processor.js b/app/soapbox/utils/tiny_post_html_processor.ts similarity index 74% rename from app/soapbox/utils/tiny_post_html_processor.js rename to app/soapbox/utils/tiny_post_html_processor.ts index 288d53c07..5c740ced3 100644 --- a/app/soapbox/utils/tiny_post_html_processor.js +++ b/app/soapbox/utils/tiny_post_html_processor.ts @@ -1,32 +1,30 @@ // Copied from Pleroma FE // https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/src/services/tiny_post_html_processor/tiny_post_html_processor.service.js +type Processor = (html: string) => string; + /** * This is a tiny purpose-built HTML parser/processor. This basically detects any type of visual newline and - * allows it to be processed, useful for greentexting, mostly + * allows it to be processed, useful for greentexting, mostly. * - * known issue: doesn't handle CDATA so nested CDATA might not work well - * - * @param {Object} input - input data - * @param {(string) => string} processor - function that will be called on every line - * @return {string} processed html + * known issue: doesn't handle CDATA so nested CDATA might not work well. */ -export const processHtml = (html, processor) => { +export const processHtml = (html: string, processor: Processor): string => { const handledTags = new Set(['p', 'br', 'div']); const openCloseTags = new Set(['p', 'div']); let buffer = ''; // Current output buffer - const level = []; // How deep we are in tags and which tags were there + const level: string[] = []; // How deep we are in tags and which tags were there let textBuffer = ''; // Current line content let tagBuffer = null; // Current tag buffer, if null = we are not currently reading a tag // Extracts tag name from tag, i.e. => span - const getTagName = (tag) => { + const getTagName = (tag: string): string | null => { const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag); return result && (result[1] || result[2]); }; - const flush = () => { // Processes current line buffer, adds it to output buffer and clears line buffer + const flush = (): void => { // Processes current line buffer, adds it to output buffer and clears line buffer if (textBuffer.trim().length > 0) { buffer += processor(textBuffer); } else { @@ -35,18 +33,18 @@ export const processHtml = (html, processor) => { textBuffer = ''; }; - const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing + const handleBr = (tag: string): void => { // handles single newlines/linebreaks/selfclosing flush(); buffer += tag; }; - const handleOpen = (tag) => { // handles opening tags + const handleOpen = (tag: string): void => { // handles opening tags flush(); buffer += tag; level.push(tag); }; - const handleClose = (tag) => { // handles closing tags + const handleClose = (tag: string): void => { // handles closing tags flush(); buffer += tag; if (level[level.length - 1] === tag) { @@ -65,7 +63,7 @@ export const processHtml = (html, processor) => { const tagFull = tagBuffer; tagBuffer = null; const tagName = getTagName(tagFull); - if (handledTags.has(tagName)) { + if (tagName && handledTags.has(tagName)) { if (tagName === 'br') { handleBr(tagFull); } else if (openCloseTags.has(tagName)) { diff --git a/package.json b/package.json index 128e3ccea..abf5cba9f 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,8 @@ "@types/http-link-header": "^1.0.3", "@types/jest": "^27.4.1", "@types/lodash": "^4.14.180", + "@types/object-assign": "^4.0.30", + "@types/object-fit-images": "^3.2.3", "@types/qrcode.react": "^1.0.2", "@types/react-datepicker": "^4.4.0", "@types/react-helmet": "^6.1.5", diff --git a/yarn.lock b/yarn.lock index d7cd7c4db..94f03c4af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2144,6 +2144,16 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/object-assign@^4.0.30": + version "4.0.30" + resolved "https://registry.yarnpkg.com/@types/object-assign/-/object-assign-4.0.30.tgz#8949371d5a99f4381ee0f1df0a9b7a187e07e652" + integrity sha1-iUk3HVqZ9Dge4PHfCpt6GH4H5lI= + +"@types/object-fit-images@^3.2.3": + version "3.2.3" + resolved "https://registry.yarnpkg.com/@types/object-fit-images/-/object-fit-images-3.2.3.tgz#aa17a1cb4ac113ba81ce62f901177c9ccd5194f5" + integrity sha512-kpBPy4HIzbM1o3v+DJrK4V5NgUpcUg/ayzjixOVHQNukpdEUYDIaeDrnYJUSemQXWX5mKeEnxDRU1nACAWYnvg== + "@types/parse-json@^4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"