kopia lustrzana https://gitlab.com/soapbox-pub/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