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 e4744fee37..53146d222b 100644 Binary files a/app/soapbox/base_polyfills.js and b/app/soapbox/base_polyfills.ts differ diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index bb8f47e4dd..132b439df2 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -1,4 +1,4 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; import React from 'react'; import { FormattedMessage } from 'react-intl'; @@ -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 c2da75f7cf..47a1b6e338 100644 Binary files a/app/soapbox/extra_polyfills.js and b/app/soapbox/extra_polyfills.ts differ diff --git a/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx b/app/soapbox/features/onboarding/steps/avatar-selection-step.tsx index 3844943946..ccc000816d 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 079fd04a1c..1652244692 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 08e7736953..f2c1eb35b2 100644 Binary files a/app/soapbox/globals.js and b/app/soapbox/globals.ts differ diff --git a/app/soapbox/is_mobile.js b/app/soapbox/is_mobile.js deleted file mode 100644 index 1259124217..0000000000 Binary files a/app/soapbox/is_mobile.js and /dev/null differ diff --git a/app/soapbox/is_mobile.ts b/app/soapbox/is_mobile.ts new file mode 100644 index 0000000000..fe90f9a974 --- /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/middleware/errors.js b/app/soapbox/middleware/errors.js deleted file mode 100644 index b5d9a36a3f..0000000000 Binary files a/app/soapbox/middleware/errors.js and /dev/null differ diff --git a/app/soapbox/middleware/errors.ts b/app/soapbox/middleware/errors.ts new file mode 100644 index 0000000000..b87c502498 --- /dev/null +++ b/app/soapbox/middleware/errors.ts @@ -0,0 +1,29 @@ +import { showAlertForError } from '../actions/alerts'; + +import type { AnyAction } from 'redux'; +import type { ThunkMiddleware } from 'redux-thunk'; + +/** Whether the action is considered a failure. */ +const isFailType = (type: string): boolean => type.endsWith('_FAIL'); + +/** Whether the action is a failure to fetch from browser storage. */ +const isRememberFailType = (type: string): boolean => type.endsWith('_REMEMBER_FAIL'); + +/** Whether the error contains an Axios response. */ +const hasResponse = (error: any): boolean => Boolean(error && error.response); + +/** Whether the error should be shown to the user. */ +const shouldShowError = ({ type, skipAlert, error }: AnyAction): boolean => { + return !skipAlert && hasResponse(error) && isFailType(type) && !isRememberFailType(type); +}; + +/** Middleware to display Redux errors to the user. */ +export default function errorsMiddleware(): ThunkMiddleware { + return ({ dispatch }) => next => action => { + if (shouldShowError(action)) { + dispatch(showAlertForError(action.error)); + } + + return next(action); + }; +} diff --git a/app/soapbox/middleware/sounds.js b/app/soapbox/middleware/sounds.ts similarity index 63% rename from app/soapbox/middleware/sounds.js rename to app/soapbox/middleware/sounds.ts index 6950e76188..94ba153131 100644 Binary files a/app/soapbox/middleware/sounds.js and b/app/soapbox/middleware/sounds.ts differ 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 9b03a3f76b..3be1bb1b9b 100644 Binary files a/app/soapbox/precheck.js and b/app/soapbox/precheck.ts differ 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 eb4f40486b..cdcdaf5803 100644 Binary files a/app/soapbox/reducers/meta.js and b/app/soapbox/reducers/meta.ts differ 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 c557e3fd99..4c3599cb6a 100644 Binary files a/app/soapbox/rtl.js and b/app/soapbox/rtl.ts differ 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 1acaeee912..70efc0e8e0 100644 Binary files a/app/soapbox/settings.js and b/app/soapbox/settings.ts differ diff --git a/app/soapbox/store.ts b/app/soapbox/store.ts index 5e557ac54b..170b316c99 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 2c942eeaae..2ea90bc2b2 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 d009bca601..ac8a1341be 100644 Binary files a/app/soapbox/utils/favicon_service.js and b/app/soapbox/utils/favicon_service.ts differ 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 f60ee7c3c6..52a25e2be6 100644 Binary files a/app/soapbox/utils/greentext.js and b/app/soapbox/utils/greentext.ts differ 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 9eddcf876a..0b19ef819c 100644 Binary files a/app/soapbox/utils/html.js and b/app/soapbox/utils/html.ts differ diff --git a/app/soapbox/utils/numbers.js b/app/soapbox/utils/numbers.js deleted file mode 100644 index 18f4d50194..0000000000 Binary files a/app/soapbox/utils/numbers.js and /dev/null differ diff --git a/app/soapbox/utils/numbers.tsx b/app/soapbox/utils/numbers.tsx new file mode 100644 index 0000000000..457b69f844 --- /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 c64631078a..0000000000 Binary files a/app/soapbox/utils/quirks.js and /dev/null differ diff --git a/app/soapbox/utils/quirks.ts b/app/soapbox/utils/quirks.ts new file mode 100644 index 0000000000..6abfec205d --- /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 4d0040078a..268954fe7f 100644 Binary files a/app/soapbox/utils/resize_image.js and b/app/soapbox/utils/resize_image.ts differ 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 c5de1ea1e9..1fe3f5c790 100644 Binary files a/app/soapbox/utils/rich_content.js and b/app/soapbox/utils/rich_content.ts differ 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 5ccb4284d2..0000000000 Binary files a/app/soapbox/utils/state.js and /dev/null differ diff --git a/app/soapbox/utils/state.ts b/app/soapbox/utils/state.ts new file mode 100644 index 0000000000..6ea55dc215 --- /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 fd5dee9d82..0000000000 Binary files a/app/soapbox/utils/static.js and /dev/null differ diff --git a/app/soapbox/utils/static.ts b/app/soapbox/utils/static.ts new file mode 100644 index 0000000000..79ba469316 --- /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 7f2bfe42f6..b735fb75d9 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 288d53c079..5c740ced32 100644 Binary files a/app/soapbox/utils/tiny_post_html_processor.js and b/app/soapbox/utils/tiny_post_html_processor.ts differ diff --git a/package.json b/package.json index 128e3cceae..abf5cba9fc 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 d7cd7c4db1..94f03c4afa 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"