@ -21,9 +21,6 @@ const getLinks = (response: Pick<Response, 'headers'>): LinkHeader =>
|
||||
const getNextLink = (response: Pick<Response, 'headers'>): string | undefined =>
|
||||
getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
||||
|
||||
const getPrevLink = (response: Pick<Response, 'headers'>): string | undefined =>
|
||||
getLinks(response).refs.find(link => link.rel === 'prev')?.uri;
|
||||
|
||||
/**
|
||||
* Dumb client for grabbing static files.
|
||||
* It uses FE_SUBDIRECTORY and parses JSON if possible.
|
||||
@ -58,7 +55,6 @@ export {
|
||||
type PlfeResponse,
|
||||
getLinks,
|
||||
getNextLink,
|
||||
getPrevLink,
|
||||
staticFetch,
|
||||
getClient,
|
||||
};
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
groupRelationshipSchema,
|
||||
groupSchema,
|
||||
instanceSchema,
|
||||
previewCardSchema,
|
||||
relationshipSchema,
|
||||
statusSchema,
|
||||
GroupRoles,
|
||||
@ -12,16 +13,12 @@ import {
|
||||
type GroupMember,
|
||||
type GroupRelationship,
|
||||
type Instance,
|
||||
type PreviewCard,
|
||||
type Relationship,
|
||||
type Status,
|
||||
} from 'pl-api';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
cardSchema,
|
||||
type Card,
|
||||
} from 'soapbox/schemas';
|
||||
|
||||
import type { PartialDeep } from 'type-fest';
|
||||
|
||||
// TODO: there's probably a better way to create these factory functions.
|
||||
@ -33,8 +30,8 @@ const buildAccount = (props: PartialDeep<Account> = {}): Account =>
|
||||
url: `https://soapbox.test/users/${uuidv4()}`,
|
||||
}, props));
|
||||
|
||||
const buildCard = (props: PartialDeep<Card> = {}): Card =>
|
||||
cardSchema.parse(Object.assign({
|
||||
const buildCard = (props: PartialDeep<PreviewCard> = {}): PreviewCard =>
|
||||
previewCardSchema.parse(Object.assign({
|
||||
url: 'https://soapbox.test',
|
||||
}, props));
|
||||
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { cardSchema } from './card';
|
||||
|
||||
describe('cardSchema', () => {
|
||||
it('adds base fields', () => {
|
||||
const card = { url: 'https://soapbox.test' };
|
||||
const result = cardSchema.parse(card);
|
||||
|
||||
expect(result.type).toEqual('link');
|
||||
expect(result.url).toEqual(card.url);
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
import punycode from 'punycode';
|
||||
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const IDNA_PREFIX = 'xn--';
|
||||
|
||||
/**
|
||||
* Card (aka link preview).
|
||||
* https://docs.joinmastodon.org/entities/card/
|
||||
*/
|
||||
const cardSchema = z.object({
|
||||
author_name: z.string().catch(''),
|
||||
author_url: z.string().url().catch(''),
|
||||
blurhash: z.string().nullable().catch(null),
|
||||
description: z.string().catch(''),
|
||||
embed_url: z.string().url().catch(''),
|
||||
height: z.number().catch(0),
|
||||
html: z.string().catch(''),
|
||||
image: z.string().nullable().catch(null),
|
||||
pleroma: z.object({
|
||||
opengraph: z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
html: z.string(),
|
||||
thumbnail_url: z.string().url(),
|
||||
}).optional().catch(undefined),
|
||||
}).optional().catch(undefined),
|
||||
provider_name: z.string().catch(''),
|
||||
provider_url: z.string().url().catch(''),
|
||||
title: z.string().catch(''),
|
||||
type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'),
|
||||
url: z.string().url(),
|
||||
width: z.number().catch(0),
|
||||
}).transform(({ pleroma, ...card }) => {
|
||||
if (!card.provider_name) {
|
||||
card.provider_name = decodeIDNA(new URL(card.url).hostname);
|
||||
}
|
||||
|
||||
if (pleroma?.opengraph) {
|
||||
if (!card.width && !card.height) {
|
||||
card.width = pleroma.opengraph.width;
|
||||
card.height = pleroma.opengraph.height;
|
||||
}
|
||||
|
||||
if (!card.html) {
|
||||
card.html = pleroma.opengraph.html;
|
||||
}
|
||||
|
||||
if (!card.image) {
|
||||
card.image = pleroma.opengraph.thumbnail_url;
|
||||
}
|
||||
}
|
||||
|
||||
const html = DOMPurify.sanitize(card.html, {
|
||||
ALLOWED_TAGS: ['iframe'],
|
||||
ALLOWED_ATTR: ['src', 'width', 'height', 'frameborder', 'allowfullscreen'],
|
||||
RETURN_DOM: true,
|
||||
});
|
||||
|
||||
html.querySelectorAll('iframe').forEach((frame) => {
|
||||
try {
|
||||
const src = new URL(frame.src);
|
||||
if (src.protocol !== 'https:') {
|
||||
throw new Error('iframe must be https');
|
||||
}
|
||||
if (src.origin === location.origin) {
|
||||
throw new Error('iframe must not be same origin');
|
||||
}
|
||||
frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-presentation');
|
||||
} catch (e) {
|
||||
frame.remove();
|
||||
}
|
||||
});
|
||||
|
||||
card.html = html.innerHTML;
|
||||
|
||||
if (!card.html) {
|
||||
card.type = 'link';
|
||||
}
|
||||
|
||||
return card;
|
||||
});
|
||||
|
||||
const decodeIDNA = (domain: string): string => domain
|
||||
.split('.')
|
||||
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
|
||||
.join('.');
|
||||
|
||||
type Card = z.infer<typeof cardSchema>;
|
||||
|
||||
export { cardSchema, type Card };
|
||||
@ -1,6 +1,5 @@
|
||||
export { adminAnnouncementSchema, type AdminAnnouncement } from './admin-announcement';
|
||||
export { cardSchema, type Card } from './card';
|
||||
export { domainSchema, type Domain } from './domain';
|
||||
export { moderationLogEntrySchema, type ModerationLogEntry } from './moderation-log-entry';
|
||||
export { relaySchema, type Relay } from './relay';
|
||||
export { ruleSchema, adminRuleSchema, type Rule, type AdminRule } from './rule';
|
||||
export { adminRuleSchema, type AdminRule } from './rule';
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
// import { pollSchema } from './poll';
|
||||
|
||||
describe('normalizePoll()', () => {
|
||||
it('adds base fields', () => {
|
||||
const poll = { id: '1', options: [{ title: 'Apples' }, { title: 'Oranges' }] };
|
||||
const result = pollSchema.parse(poll);
|
||||
|
||||
const expected = {
|
||||
options: [
|
||||
{ title: 'Apples', votes_count: 0 },
|
||||
{ title: 'Oranges', votes_count: 0 },
|
||||
],
|
||||
emojis: [],
|
||||
expired: false,
|
||||
multiple: false,
|
||||
voters_count: 0,
|
||||
votes_count: 0,
|
||||
own_votes: null,
|
||||
voted: false,
|
||||
};
|
||||
|
||||
expect(result).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it('normalizes a Pleroma logged-out poll', async () => {
|
||||
const { poll } = await import('soapbox/__fixtures__/pleroma-status-with-poll.json');
|
||||
const result = pollSchema.parse(poll);
|
||||
|
||||
// Adds logged-in fields
|
||||
expect(result.voted).toBe(false);
|
||||
expect(result.own_votes).toBe(null);
|
||||
});
|
||||
|
||||
it('normalizes poll with emojis', async () => {
|
||||
const { poll } = await import('soapbox/__fixtures__/pleroma-status-with-poll-with-emojis.json');
|
||||
const result = pollSchema.parse(poll);
|
||||
|
||||
// Emojifies poll options
|
||||
expect(result.options[1]?.title_emojified)
|
||||
.toContain('emojione');
|
||||
|
||||
expect(result.emojis[1]?.shortcode).toEqual('soapbox');
|
||||
});
|
||||
});
|
||||
@ -1,22 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseRuleSchema = z.object({
|
||||
const adminRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string().catch(''),
|
||||
hint: z.string().catch(''),
|
||||
});
|
||||
|
||||
const ruleSchema = z.preprocess((data: any) => ({
|
||||
...data,
|
||||
hint: data.hint || data.subtext,
|
||||
}), baseRuleSchema);
|
||||
|
||||
type Rule = z.infer<typeof ruleSchema>;
|
||||
|
||||
const adminRuleSchema = baseRuleSchema.extend({
|
||||
priority: z.number().nullable().catch(null),
|
||||
});
|
||||
|
||||
type AdminRule = z.infer<typeof adminRuleSchema>;
|
||||
|
||||
export { ruleSchema, adminRuleSchema, type Rule, type AdminRule };
|
||||
export { adminRuleSchema, type AdminRule };
|
||||
@ -8,13 +8,4 @@ const normalizeUsername = (username: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const slugify = (text: string): string => text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w]/g, '-') // replace non-word characters with a hyphen
|
||||
.replace(/-+/g, '-'); // replace multiple hyphens with a single hyphen
|
||||
|
||||
export {
|
||||
normalizeUsername,
|
||||
slugify,
|
||||
};
|
||||
export { normalizeUsername };
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
interface LegacyMap {
|
||||
get(key: any): unknown;
|
||||
getIn(keyPath: any[]): unknown;
|
||||
toJS(): any;
|
||||
}
|
||||
|
||||
export {
|
||||
type LegacyMap,
|
||||
};
|
||||
@ -1,8 +1,5 @@
|
||||
import z from 'zod';
|
||||
|
||||
/** Use new value only if old value is undefined */
|
||||
const mergeDefined = (oldVal: any, newVal: any) => oldVal === undefined ? newVal : oldVal;
|
||||
|
||||
const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any) => {
|
||||
obj[`:${emoji.shortcode}:`] = emoji;
|
||||
return obj;
|
||||
@ -11,33 +8,7 @@ const makeEmojiMap = (emojis: any) => emojis.reduce((obj: any, emoji: any) => {
|
||||
/** Normalize entity ID */
|
||||
const normalizeId = (id: any): string | null => z.string().nullable().catch(null).parse(id);
|
||||
|
||||
type Normalizer<V, R> = (value: V) => R;
|
||||
|
||||
/**
|
||||
* Allows using any legacy normalizer function as a zod schema.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const statusSchema = toSchema(normalizeStatus);
|
||||
* statusSchema.parse(status);
|
||||
* ```
|
||||
*/
|
||||
const toSchema = <V, R>(normalizer: Normalizer<V, R>) => z.custom<V>().transform<R>(normalizer);
|
||||
|
||||
/** Legacy normalizer transition helper function. */
|
||||
const maybeFromJS = (value: any): unknown => {
|
||||
if ('toJS' in value) {
|
||||
return value.toJS();
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
type Normalizer,
|
||||
mergeDefined,
|
||||
makeEmojiMap,
|
||||
normalizeId,
|
||||
toSchema,
|
||||
maybeFromJS,
|
||||
};
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { render, screen } from 'soapbox/jest/test-helpers';
|
||||
|
||||
import { isIntegerId, secondsToDays, shortNumberFormat } from './numbers';
|
||||
import { isIntegerId, shortNumberFormat } from './numbers';
|
||||
|
||||
test('isIntegerId()', () => {
|
||||
expect(isIntegerId('0')).toBe(true);
|
||||
@ -14,14 +14,6 @@ test('isIntegerId()', () => {
|
||||
expect(isIntegerId(null as any)).toBe(false);
|
||||
expect(isIntegerId(undefined as any)).toBe(false);
|
||||
});
|
||||
|
||||
test('secondsToDays', () => {
|
||||
expect(secondsToDays(604800)).toEqual(7);
|
||||
expect(secondsToDays(1209600)).toEqual(14);
|
||||
expect(secondsToDays(2592000)).toEqual(30);
|
||||
expect(secondsToDays(7776000)).toEqual(90);
|
||||
});
|
||||
|
||||
describe('shortNumberFormat', () => {
|
||||
test('handles non-numbers', () => {
|
||||
render(<div data-testid='num'>{shortNumberFormat('not-number')}</div>, undefined, null);
|
||||
|
||||
@ -8,8 +8,6 @@ const isNumber = (value: unknown): value is number => typeof value === 'number'
|
||||
/** The input is a number and is not NaN. */
|
||||
const realNumberSchema = z.coerce.number().refine(n => !isNaN(n));
|
||||
|
||||
const secondsToDays = (seconds: number) => Math.floor(seconds / (3600 * 24));
|
||||
|
||||
const roundDown = (num: number) => {
|
||||
if (num >= 100 && num < 1000) {
|
||||
num = Math.floor(num);
|
||||
@ -58,7 +56,6 @@ const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id);
|
||||
export {
|
||||
isNumber,
|
||||
realNumberSchema,
|
||||
secondsToDays,
|
||||
roundDown,
|
||||
shortNumberFormat,
|
||||
isIntegerId,
|
||||
|
||||
@ -1,15 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { Location } from 'soapbox/types/history';
|
||||
|
||||
const LOCAL_STORAGE_REDIRECT_KEY = 'plfe:redirect-uri';
|
||||
|
||||
const cacheCurrentUrl = (location: Location) => {
|
||||
const actualUrl = encodeURIComponent(`${location.pathname}${location.search}`);
|
||||
localStorage.setItem(LOCAL_STORAGE_REDIRECT_KEY, actualUrl);
|
||||
return actualUrl;
|
||||
};
|
||||
|
||||
const getRedirectUrl = () => {
|
||||
let redirectUri = localStorage.getItem(LOCAL_STORAGE_REDIRECT_KEY);
|
||||
if (redirectUri) {
|
||||
@ -34,4 +26,4 @@ const useCachedLocationHandler = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export { cacheCurrentUrl, getRedirectUrl, useCachedLocationHandler };
|
||||
export { getRedirectUrl, useCachedLocationHandler };
|
||||
|
||||
@ -56,15 +56,6 @@ const textForScreenReader = (
|
||||
return values.join(', ');
|
||||
};
|
||||
|
||||
/** Get reblogged status if any, otherwise return the original status. */
|
||||
const getActualStatus = <T extends { reblog: T | null }>(status: T): Omit<T, 'reblog'> => {
|
||||
if (status?.reblog && typeof status?.reblog === 'object') {
|
||||
return status.reblog;
|
||||
} else {
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIdsFromLinksInContent = (content: string): string[] => {
|
||||
const urls = content.match(RegExp(`${window.location.origin}/@([a-z\\d_-]+(?:@[^@\\s]+)?)/posts/[a-z0-9]+(?!\\S)`, 'gi'));
|
||||
|
||||
@ -81,6 +72,5 @@ export {
|
||||
shouldHaveCard,
|
||||
hasIntegerMediaIds,
|
||||
textForScreenReader,
|
||||
getActualStatus,
|
||||
getStatusIdsFromLinksInContent,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user