Convert a bunch of files to TypeScript

This commit is contained in:
Alex Gleason 2022-04-24 14:28:07 -05:00
parent 3b55a5a9c7
commit 7038d6a844
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
32 changed files with 312 additions and 186 deletions

View File

@ -3,9 +3,12 @@
import 'intl'; import 'intl';
import 'intl/locale-data/jsonp/en'; import 'intl/locale-data/jsonp/en';
import 'es6-symbol/implement'; import 'es6-symbol/implement';
// @ts-ignore: No types
import includes from 'array-includes'; import includes from 'array-includes';
// @ts-ignore: No types
import isNaN from 'is-nan'; import isNaN from 'is-nan';
import assign from 'object-assign'; import assign from 'object-assign';
// @ts-ignore: No types
import values from 'object.values'; import values from 'object.values';
import { decode as decodeBase64 } from './utils/base64'; import { decode as decodeBase64 } from './utils/base64';
@ -30,7 +33,7 @@ if (!HTMLCanvasElement.prototype.toBlob) {
const BASE64_MARKER = ';base64,'; const BASE64_MARKER = ';base64,';
Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', { 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); const dataURL = this.toDataURL(type, quality);
let data; let data;

View File

@ -22,7 +22,7 @@ const SidebarNavigation = () => {
const followRequestsCount = useAppSelector((state) => state.user_lists.getIn(['follow_requests', 'items'], ImmutableOrderedSet()).count()); 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 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 features = getFeatures(instance);
const makeMenu = (): Menu => { const makeMenu = (): Menu => {

View File

@ -2,6 +2,6 @@
import 'intersection-observer'; import 'intersection-observer';
import 'requestidlecallback'; import 'requestidlecallback';
import objectFitImages from 'object-fit-images'; import objectFitImages from 'object-fit-images';
objectFitImages(); objectFitImages();

View File

@ -37,7 +37,9 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 400 * 400; 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) => { resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.avatar as string; const url = file ? URL.createObjectURL(file) : account?.avatar as string;

View File

@ -38,7 +38,9 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const maxPixels = 1920 * 1080; 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) => { resizeImage(rawFile, maxPixels).then((file) => {
const url = file ? URL.createObjectURL(file) : account?.header as string; const url = file ? URL.createObjectURL(file) : account?.header as string;

View File

@ -4,17 +4,20 @@
*/ */
import { changeSettingImmediate } from 'soapbox/actions/settings'; 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 = { const Soapbox = {
// Become a developer with `Soapbox.isDeveloper()` /** Become a developer with `Soapbox.isDeveloper()` */
isDeveloper: (bool = true) => { isDeveloper: (bool = true): boolean => {
if (![true, false].includes(bool)) { if (![true, false].includes(bool)) {
throw `Invalid option ${bool}. Must be true or false.`; throw `Invalid option ${bool}. Must be true or false.`;
} }
store.dispatch(changeSettingImmediate(['isDeveloper'], bool)); store.dispatch(changeSettingImmediate(['isDeveloper'], bool) as any);
return bool; return bool;
}, },
}; };
window.Soapbox = Soapbox; (window as any).Soapbox = Soapbox;
}; };

View File

@ -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;
}

34
app/soapbox/is_mobile.ts Normal file
View File

@ -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;
}

View File

@ -3,9 +3,14 @@
* @module soapbox/precheck * @module soapbox/precheck
*/ */
/** Whether a page title was inserted with SSR. */
const hasTitle = Boolean(document.querySelector('title')); const hasTitle = Boolean(document.querySelector('title'));
/** Whether pre-rendered data exists in Mastodon's format. */
const hasPrerenderPleroma = Boolean(document.getElementById('initial-results')); const hasPrerenderPleroma = Boolean(document.getElementById('initial-results'));
/** Whether pre-rendered data exists in Pleroma's format. */
const hasPrerenderMastodon = Boolean(document.getElementById('initial-state')); 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; export const isPrerendered = hasTitle || hasPrerenderPleroma || hasPrerenderMastodon;

View File

@ -4,11 +4,13 @@ import { Record as ImmutableRecord } from 'immutable';
import { INSTANCE_FETCH_FAIL } from 'soapbox/actions/instance'; import { INSTANCE_FETCH_FAIL } from 'soapbox/actions/instance';
import type { AnyAction } from 'redux';
const ReducerRecord = ImmutableRecord({ const ReducerRecord = ImmutableRecord({
instance_fetch_failed: false, instance_fetch_failed: false,
}); });
export default function meta(state = ReducerRecord(), action) { export default function meta(state = ReducerRecord(), action: AnyAction) {
switch(action.type) { switch(action.type) {
case INSTANCE_FETCH_FAIL: case INSTANCE_FETCH_FAIL:
return state.set('instance_fetch_failed', true); return state.set('instance_fetch_failed', true);

View File

@ -14,7 +14,8 @@
const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg; 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) { if (text.length === 0) {
return false; return false;
} }

View File

@ -2,15 +2,17 @@
export default class Settings { export default class Settings {
constructor(keyBase = null) { keyBase: string | null = null;
constructor(keyBase: string | null = null) {
this.keyBase = keyBase; this.keyBase = keyBase;
} }
generateKey(id) { generateKey(id: string) {
return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id; return this.keyBase ? [this.keyBase, `id${id}`].join('.') : id;
} }
set(id, data) { set(id: string, data: any) {
const key = this.generateKey(id); const key = this.generateKey(id);
try { try {
const encodedData = JSON.stringify(data); const encodedData = JSON.stringify(data);
@ -21,17 +23,17 @@ export default class Settings {
} }
} }
get(id) { get(id: string) {
const key = this.generateKey(id); const key = this.generateKey(id);
try { try {
const rawData = localStorage.getItem(key); const rawData = localStorage.getItem(key);
return JSON.parse(rawData); return rawData ? JSON.parse(rawData) : null;
} catch (e) { } catch (e) {
return null; return null;
} }
} }
remove(id) { remove(id: string) {
const data = this.get(id); const data = this.get(id);
if (data) { if (data) {
const key = this.generateKey(id); 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'); export const pushNotificationsSetting = new Settings('soapbox_push_notification_data');
/** Remember hashtag usage. */
export const tagHistory = new Settings('soapbox_tag_history'); export const tagHistory = new Settings('soapbox_tag_history');

View File

@ -17,6 +17,8 @@ export const store = createStore(
), ),
); );
export type Store = typeof store;
// Infer the `RootState` and `AppDispatch` types from the store itself // Infer the `RootState` and `AppDispatch` types from the store itself
// https://redux.js.org/usage/usage-with-typescript // https://redux.js.org/usage/usage-with-typescript
export type RootState = ReturnType<typeof store.getState>; export type RootState = ReturnType<typeof store.getState>;

View File

@ -16,10 +16,9 @@ export const getDomain = (account: Account): string => {
return domain ? domain : getDomainFromURL(account); return domain ? domain : getDomainFromURL(account);
}; };
export const getBaseURL = (account: ImmutableMap<string, any>): string => { export const getBaseURL = (account: Account): string => {
try { try {
const url = account.get('url'); return new URL(account.url).origin;
return new URL(url).origin;
} catch { } catch {
return ''; return '';
} }

View File

@ -1,14 +1,23 @@
// Adapted from Pleroma FE // Adapted from Pleroma FE
// https://git.pleroma.social/pleroma/pleroma-fe/-/blob/ef5bbc4e5f84bb9e8da76a0440eea5d656d36977/src/services/favicon_service/favicon_service.js // 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 createFaviconService = () => {
const favicons = []; const favicons: Favicon[] = [];
const faviconWidth = 128; const faviconWidth = 128;
const faviconHeight = 128; const faviconHeight = 128;
const badgeRadius = 24; const badgeRadius = 24;
const initFaviconService = () => { /** Start the favicon service */
const nodes = document.querySelectorAll('link[rel="icon"]'); const initFaviconService = (): void => {
const nodes: NodeListOf<HTMLLinkElement> = document.querySelectorAll('link[rel="icon"]');
nodes.forEach(favicon => { nodes.forEach(favicon => {
if (favicon) { if (favicon) {
const favcanvas = document.createElement('canvas'); 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; if (favicons.length === 0) return;
favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => { favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => {
if (!favimg || !favcontext || !favicon) return; 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; if (favicons.length === 0) return;
clearFaviconBadge(); clearFaviconBadge();
favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => { favicons.forEach(({ favimg, favcanvas, favcontext, favicon }) => {

View File

@ -1,6 +1,6 @@
import { processHtml } from './tiny_post_html_processor'; import { processHtml } from './tiny_post_html_processor';
export const addGreentext = html => { export const addGreentext = (html: string): string => {
// Copied from Pleroma FE // Copied from Pleroma FE
// https://git.pleroma.social/pleroma/pleroma-fe/-/blob/19475ba356c3fd6c54ca0306d3ae392358c212d1/src/components/status_content/status_content.js#L132 // https://git.pleroma.social/pleroma/pleroma-fe/-/blob/19475ba356c3fd6c54ca0306d3ae392358c212d1/src/components/status_content/status_content.js#L132
return processHtml(html, (string) => { return processHtml(html, (string) => {

View File

@ -1,16 +1,20 @@
/** Convert HTML to a plaintext representation, preserving whitespace. */
// NB: This function can still return unsafe HTML // NB: This function can still return unsafe HTML
export const unescapeHTML = (html) => { export const unescapeHTML = (html: string): string => {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><[^>]*>/g, '\n\n').replace(/<[^>]*>/g, ''); wrapper.innerHTML = html.replace(/<br\s*\/?>/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'); const node = document.createElement('div');
node.innerHTML = html; node.innerHTML = html;
const selectors = [ const selectors = [
// Quote posting
'.quote-inline', '.quote-inline',
// Explicit mentions
'.recipients-inline', '.recipients-inline',
]; ];

View File

@ -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 <FormattedNumber value={number} />;
} else {
return <span><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</span>;
}
};
export const isIntegerId = id => new RegExp(/^-?[0-9]+$/g).test(id);

View File

@ -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 <FormattedNumber value={number} />;
} else {
return <span><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</span>;
}
};
/** 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);

View File

@ -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';

View File

@ -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';

View File

@ -1,14 +1,19 @@
/* eslint-disable no-case-declarations */ /* eslint-disable no-case-declarations */
const DEFAULT_MAX_PIXELS = 1920 * 1080; 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 // 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 // while others won't, and the safest way to detect that is to examine how it
// is done on a known image. // is done on a known image.
// See https://github.com/w3c/csswg-drafts/issues/4666 // See https://github.com/w3c/csswg-drafts/issues/4666
// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881 // and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881
const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { const dropOrientationIfNeeded = (orientation: number) => new Promise<number>(resolve => {
switch (_browser_quirks['image-orientation-automatic']) { switch (_browser_quirks['image-orientation-automatic']) {
case true: case true:
resolve(1); 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 // *Some browsers don't allow reading from a canvas and instead return all-white
// works. // * or randomized data. Use a pre-defined image to check if reading the canvas
// const checkCanvasReliability = () => new Promise((resolve, reject) => { // * works.
// */
// const checkCanvasReliability = () => new Promise<void>((resolve, reject) => {
// switch(_browser_quirks['canvas-read-unreliable']) { // switch(_browser_quirks['canvas-read-unreliable']) {
// case true: // case true:
// reject('Canvas reading unreliable'); // reject('Canvas reading unreliable');
@ -61,9 +68,9 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => {
// img.onload = () => { // img.onload = () => {
// const canvas = document.createElement('canvas'); // const canvas = document.createElement('canvas');
// const context = canvas.getContext('2d'); // const context = canvas.getContext('2d');
// context.drawImage(img, 0, 0, 2, 2); // context?.drawImage(img, 0, 0, 2, 2);
// const imageData = context.getImageData(0, 0, 2, 2); // const imageData = context?.getImageData(0, 0, 2, 2);
// if (imageData.data.every((x, i) => refData[i] === x)) { // if (imageData?.data.every((x, i) => refData[i] === x)) {
// _browser_quirks['canvas-read-unreliable'] = false; // _browser_quirks['canvas-read-unreliable'] = false;
// resolve(); // resolve();
// } else { // } 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<string>((resolve, reject) => {
// @ts-ignore: This is a browser capabilities check.
if (window.URL?.createObjectURL) { if (window.URL?.createObjectURL) {
try { try {
resolve(URL.createObjectURL(inputFile)); resolve(URL.createObjectURL(inputFile));
@ -91,29 +100,32 @@ const getImageUrl = inputFile => new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onerror = (...args) => reject(...args); reader.onerror = (...args) => reject(...args);
reader.onload = ({ target }) => resolve(target.result); reader.onload = ({ target }) => resolve((target?.result || '') as string);
reader.readAsDataURL(inputFile); reader.readAsDataURL(inputFile);
}); });
const loadImage = inputFile => new Promise((resolve, reject) => { /** Get an image element from a file. */
const loadImage = (inputFile: File) => new Promise<HTMLImageElement>((resolve, reject) => {
getImageUrl(inputFile).then(url => { getImageUrl(inputFile).then(url => {
const img = new Image(); const img = new Image();
img.onerror = (...args) => reject(...args); img.onerror = (...args) => reject([...args]);
img.onload = () => resolve(img); img.onload = () => resolve(img);
img.src = url; img.src = url;
}).catch(reject); }).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<number>(resolve => {
if (!['image/jpeg', 'image/webp'].includes(type)) { if (!['image/jpeg', 'image/webp'].includes(type)) {
resolve(1); resolve(1);
return; return;
} }
import(/* webpackChunkName: "features/compose" */'exif-js').then(({ default: EXIF }) => { import(/* webpackChunkName: "features/compose" */'exif-js').then(({ default: EXIF }) => {
// @ts-ignore: The TypeScript definition is wrong.
EXIF.getData(img, () => { EXIF.getData(img, () => {
const orientation = EXIF.getTag(img, 'Orientation'); const orientation = EXIF.getTag(img, 'Orientation');
if (orientation !== 1) { if (orientation !== 1) {
@ -125,7 +137,22 @@ const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
}).catch(() => {}); }).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<File>((resolve, reject) => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
if (4 < orientation && orientation < 9) { if (4 < orientation && orientation < 9) {
@ -138,6 +165,11 @@ const processImage = (img, { width, height, orientation, type = 'image/png', nam
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (!context) {
reject(context);
return;
}
switch (orientation) { switch (orientation) {
case 2: context.transform(-1, 0, 0, 1, width, 0); break; case 2: context.transform(-1, 0, 0, 1, width, 0); break;
case 3: context.transform(-1, 0, 0, -1, width, height); 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); context.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (!blob) {
reject(blob);
return;
}
resolve(new File([blob], name, { type, lastModified: new Date().getTime() })); resolve(new File([blob], name, { type, lastModified: new Date().getTime() }));
}, type); }, type);
}); });
const resizeImage = (img, inputFile, maxPixels) => new Promise((resolve, reject) => { const resizeImage = (
img: HTMLImageElement,
inputFile: File,
maxPixels: number,
) => new Promise<File>((resolve, reject) => {
const { width, height } = img; const { width, height } = img;
const type = inputFile.type || 'image/png'; const type = inputFile.type || 'image/png';
@ -177,7 +217,8 @@ const resizeImage = (img, inputFile, maxPixels) => new Promise((resolve, reject)
.catch(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<File>((resolve) => {
if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
resolve(inputFile); resolve(inputFile);
return; return;

View File

@ -1,15 +1,15 @@
// Returns `true` if the node contains only emojis, up to a limit /** Returns `true` if the node contains only emojis, up to a limit */
export const onlyEmoji = (node, limit = 1, ignoreMentions = true) => { export const onlyEmoji = (node: HTMLElement, limit = 1, ignoreMentions = true): boolean => {
if (!node) return false; if (!node) return false;
try { try {
// Remove mentions before checking content // Remove mentions before checking content
if (ignoreMentions) { if (ignoreMentions) {
node = node.cloneNode(true); node = node.cloneNode(true) as HTMLElement;
node.querySelectorAll('a.mention').forEach(m => m.parentNode.removeChild(m)); 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')); const emojis = Array.from(node.querySelectorAll('img.emojione'));
if (emojis.length === 0) return false; if (emojis.length === 0) return false;
if (emojis.length > limit) return false; if (emojis.length > limit) return false;

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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);
};

View File

@ -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);
};

View File

@ -2,7 +2,8 @@ import { isIntegerId } from 'soapbox/utils/numbers';
import type { Status as StatusEntity } from 'soapbox/types/entities'; 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 { try {
// Pulled from Pleroma's media parser // Pulled from Pleroma's media parser
const selector = 'a:not(.mention,.hashtag,.attachment,[rel~="tag"])'; 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)); 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 // 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)); return status.media_attachments.some(({ id }) => isIntegerId(id));
}; };

View File

@ -1,32 +1,30 @@
// Copied from Pleroma FE // 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 // 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 * 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 * 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
*/ */
export const processHtml = (html, processor) => { export const processHtml = (html: string, processor: Processor): string => {
const handledTags = new Set(['p', 'br', 'div']); const handledTags = new Set(['p', 'br', 'div']);
const openCloseTags = new Set(['p', 'div']); const openCloseTags = new Set(['p', 'div']);
let buffer = ''; // Current output buffer 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 textBuffer = ''; // Current line content
let tagBuffer = null; // Current tag buffer, if null = we are not currently reading a tag let tagBuffer = null; // Current tag buffer, if null = we are not currently reading a tag
// Extracts tag name from tag, i.e. <span a="b"> => span // Extracts tag name from tag, i.e. <span a="b"> => span
const getTagName = (tag) => { const getTagName = (tag: string): string | null => {
const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag); const result = /(?:<\/(\w+)>|<(\w+)\s?[^/]*?\/?>)/gi.exec(tag);
return result && (result[1] || result[2]); 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) { if (textBuffer.trim().length > 0) {
buffer += processor(textBuffer); buffer += processor(textBuffer);
} else { } else {
@ -35,18 +33,18 @@ export const processHtml = (html, processor) => {
textBuffer = ''; textBuffer = '';
}; };
const handleBr = (tag) => { // handles single newlines/linebreaks/selfclosing const handleBr = (tag: string): void => { // handles single newlines/linebreaks/selfclosing
flush(); flush();
buffer += tag; buffer += tag;
}; };
const handleOpen = (tag) => { // handles opening tags const handleOpen = (tag: string): void => { // handles opening tags
flush(); flush();
buffer += tag; buffer += tag;
level.push(tag); level.push(tag);
}; };
const handleClose = (tag) => { // handles closing tags const handleClose = (tag: string): void => { // handles closing tags
flush(); flush();
buffer += tag; buffer += tag;
if (level[level.length - 1] === tag) { if (level[level.length - 1] === tag) {
@ -65,7 +63,7 @@ export const processHtml = (html, processor) => {
const tagFull = tagBuffer; const tagFull = tagBuffer;
tagBuffer = null; tagBuffer = null;
const tagName = getTagName(tagFull); const tagName = getTagName(tagFull);
if (handledTags.has(tagName)) { if (tagName && handledTags.has(tagName)) {
if (tagName === 'br') { if (tagName === 'br') {
handleBr(tagFull); handleBr(tagFull);
} else if (openCloseTags.has(tagName)) { } else if (openCloseTags.has(tagName)) {

View File

@ -74,6 +74,8 @@
"@types/http-link-header": "^1.0.3", "@types/http-link-header": "^1.0.3",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
"@types/lodash": "^4.14.180", "@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/qrcode.react": "^1.0.2",
"@types/react-datepicker": "^4.4.0", "@types/react-datepicker": "^4.4.0",
"@types/react-helmet": "^6.1.5", "@types/react-helmet": "^6.1.5",

View File

@ -2144,6 +2144,16 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== 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": "@types/parse-json@^4.0.0":
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"