Merge branch 'next-ts-conversions' into 'next'
Next: TypeScript conversions See merge request soapbox-pub/soapbox-fe!1254
This commit is contained in:
commit
52e21651a1
|
@ -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;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
|
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
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 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 => {
|
||||||
|
|
|
@ -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();
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
|
@ -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';
|
'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();
|
const audio = new Audio();
|
||||||
sources.forEach(({ type, src }) => {
|
sources.forEach(({ type, src }) => {
|
||||||
const source = document.createElement('source');
|
const source = document.createElement('source');
|
||||||
|
@ -11,7 +20,8 @@ const createAudio = sources => {
|
||||||
return audio;
|
return audio;
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = audio => {
|
/** Play HTML5 sound. */
|
||||||
|
const play = (audio: HTMLAudioElement): void => {
|
||||||
if (!audio.paused) {
|
if (!audio.paused) {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
if (typeof audio.fastSeek === 'function') {
|
if (typeof audio.fastSeek === 'function') {
|
||||||
|
@ -24,8 +34,9 @@ const play = audio => {
|
||||||
audio.play();
|
audio.play();
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function soundsMiddleware() {
|
/** Middleware to play sounds in response to certain Redux actions. */
|
||||||
const soundCache = {
|
export default function soundsMiddleware(): ThunkMiddleware {
|
||||||
|
const soundCache: Record<string, HTMLAudioElement> = {
|
||||||
boop: createAudio([
|
boop: createAudio([
|
||||||
{
|
{
|
||||||
src: require('../../sounds/boop.ogg'),
|
src: require('../../sounds/boop.ogg'),
|
||||||
|
@ -49,7 +60,7 @@ export default function soundsMiddleware() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => next => action => {
|
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]);
|
play(soundCache[action.meta.sound]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -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');
|
|
@ -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>;
|
||||||
|
|
|
@ -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 '';
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }) => {
|
|
@ -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) => {
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
@ -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 */
|
/* 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;
|
|
@ -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;
|
|
@ -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';
|
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));
|
||||||
};
|
};
|
||||||
|
|
|
@ -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)) {
|
|
@ -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",
|
||||||
|
|
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"
|
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"
|
||||||
|
|
Loading…
Reference in New Issue