sforkowany z mirror/soapbox
Merge branch 'next-ts-conversions' into 'next'
Next: TypeScript conversions See merge request soapbox-pub/soapbox-fe!1254revert-5af0e40a
commit
52e21651a1
|
@ -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;
|
||||
|
|
@ -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 => {
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
import 'intersection-observer';
|
||||
import 'requestidlecallback';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
import objectFitImages from 'object-fit-images';
|
||||
|
||||
objectFitImages();
|
|
@ -37,7 +37,9 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
|
|||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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;
|
||||
|
|
|
@ -38,7 +38,9 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
|
|||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { showAlertForError } from '../actions/alerts';
|
||||
|
||||
const isFailType = type => type.endsWith('_FAIL');
|
||||
const isRememberFailType = type => type.endsWith('_REMEMBER_FAIL');
|
||||
|
||||
const hasResponse = error => Boolean(error && error.response);
|
||||
|
||||
const shouldShowError = ({ type, skipAlert, error }) => {
|
||||
return !skipAlert && hasResponse(error) && isFailType(type) && !isRememberFailType(type);
|
||||
};
|
||||
|
||||
export default function errorsMiddleware() {
|
||||
return ({ dispatch }) => next => action => {
|
||||
if (shouldShowError(action)) {
|
||||
dispatch(showAlertForError(action.error));
|
||||
}
|
||||
|
||||
return next(action);
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -1,6 +1,15 @@
|
|||
'use strict';
|
||||
|
||||
const createAudio = sources => {
|
||||
import type { ThunkMiddleware } from 'redux-thunk';
|
||||
|
||||
/** Soapbox audio clip. */
|
||||
type Sound = {
|
||||
src: string,
|
||||
type: string,
|
||||
}
|
||||
|
||||
/** Produce HTML5 audio from sound data. */
|
||||
const createAudio = (sources: Sound[]): HTMLAudioElement => {
|
||||
const audio = new Audio();
|
||||
sources.forEach(({ type, src }) => {
|
||||
const source = document.createElement('source');
|
||||
|
@ -11,7 +20,8 @@ const createAudio = sources => {
|
|||
return audio;
|
||||
};
|
||||
|
||||
const play = audio => {
|
||||
/** Play HTML5 sound. */
|
||||
const play = (audio: HTMLAudioElement): void => {
|
||||
if (!audio.paused) {
|
||||
audio.pause();
|
||||
if (typeof audio.fastSeek === 'function') {
|
||||
|
@ -24,8 +34,9 @@ const play = audio => {
|
|||
audio.play();
|
||||
};
|
||||
|
||||
export default function soundsMiddleware() {
|
||||
const soundCache = {
|
||||
/** Middleware to play sounds in response to certain Redux actions. */
|
||||
export default function soundsMiddleware(): ThunkMiddleware {
|
||||
const soundCache: Record<string, HTMLAudioElement> = {
|
||||
boop: createAudio([
|
||||
{
|
||||
src: require('../../sounds/boop.ogg'),
|
||||
|
@ -49,7 +60,7 @@ export default function soundsMiddleware() {
|
|||
};
|
||||
|
||||
return () => next => action => {
|
||||
if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
|
||||
if (action.meta?.sound && soundCache[action.meta.sound]) {
|
||||
play(soundCache[action.meta.sound]);
|
||||
}
|
||||
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
|
@ -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<typeof store.getState>;
|
||||
|
|
|
@ -16,10 +16,9 @@ export const getDomain = (account: Account): string => {
|
|||
return domain ? domain : getDomainFromURL(account);
|
||||
};
|
||||
|
||||
export const getBaseURL = (account: ImmutableMap<string, any>): 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 '';
|
||||
}
|
||||
|
|
|
@ -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<HTMLLinkElement> = 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 }) => {
|
|
@ -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) => {
|
|
@ -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(/<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');
|
||||
node.innerHTML = html;
|
||||
|
||||
const selectors = [
|
||||
// Quote posting
|
||||
'.quote-inline',
|
||||
// Explicit mentions
|
||||
'.recipients-inline',
|
||||
];
|
||||
|
|
@ -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);
|
|
@ -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);
|
|
@ -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';
|
|
@ -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';
|
|
@ -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<number>(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<void>((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<string>((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<HTMLImageElement>((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<number>(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<File>((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<File>((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<File>((resolve) => {
|
||||
if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
|
||||
resolve(inputFile);
|
||||
return;
|
|
@ -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;
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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 a="b"> => 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)) {
|
|
@ -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",
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Ładowanie…
Reference in New Issue