63
packages/pl-fe/src/normalizers/account.ts
Normal file
63
packages/pl-fe/src/normalizers/account.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { unescapeHTML } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { Account as BaseAccount } from 'pl-api';
|
||||
|
||||
const getDomainFromURL = (account: Pick<BaseAccount, 'url'>): string => {
|
||||
try {
|
||||
const url = account.url;
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const guessFqn = (account: Pick<BaseAccount, 'acct' | 'url'>): string => {
|
||||
const acct = account.acct;
|
||||
const [user, domain] = acct.split('@');
|
||||
|
||||
if (domain) {
|
||||
return acct;
|
||||
} else {
|
||||
return [user, getDomainFromURL(account)].join('@');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAccount = (account: BaseAccount) => {
|
||||
const missingAvatar = require('soapbox/assets/images/avatar-missing.png');
|
||||
const missingHeader = require('soapbox/assets/images/header-missing.png');
|
||||
|
||||
const fqn = account.fqn || guessFqn(account);
|
||||
const domain = fqn.split('@')[1] || '';
|
||||
const note = account.note === '<p></p>' ? '' : account.note;
|
||||
|
||||
const emojiMap = makeEmojiMap(account.emojis);
|
||||
|
||||
return {
|
||||
mute_expires_at: null,
|
||||
...account,
|
||||
avatar: account.avatar || account.avatar_static || missingAvatar,
|
||||
avatar_static: account.avatar_static || account.avatar || missingAvatar,
|
||||
header: account.header || account.header_static || missingHeader,
|
||||
header_static: account.header_static || account.header || missingHeader,
|
||||
fqn,
|
||||
domain,
|
||||
note,
|
||||
display_name_html: emojify(escapeTextContentForBrowser(account.display_name), emojiMap),
|
||||
note_emojified: emojify(account.note, emojiMap),
|
||||
note_plain: unescapeHTML(account.note),
|
||||
fields: account.fields.map(field => ({
|
||||
...field,
|
||||
name_emojified: emojify(escapeTextContentForBrowser(field.name), emojiMap),
|
||||
value_emojified: emojify(field.value, emojiMap),
|
||||
value_plain: unescapeHTML(field.value),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type Account = ReturnType<typeof normalizeAccount>;
|
||||
|
||||
export { normalizeAccount, type Account };
|
||||
14
packages/pl-fe/src/normalizers/admin-report.ts
Normal file
14
packages/pl-fe/src/normalizers/admin-report.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { AdminReport as BaseAdminReport } from 'pl-api';
|
||||
|
||||
const normalizeAdminReport = (report: BaseAdminReport) => ({
|
||||
...report,
|
||||
account_id: report.account?.id || null,
|
||||
target_account_id: report.target_account?.id || null,
|
||||
action_taken_by_account_id: report.action_taken_by_account?.id || null,
|
||||
assigned_account_id: report.assigned_account?.id || null,
|
||||
status_ids: report.statuses.map(status => status.id),
|
||||
});
|
||||
|
||||
type AdminReport = ReturnType<typeof normalizeAdminReport>;
|
||||
|
||||
export { normalizeAdminReport, type AdminReport };
|
||||
19
packages/pl-fe/src/normalizers/announcement.ts
Normal file
19
packages/pl-fe/src/normalizers/announcement.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { makeCustomEmojiMap } from 'soapbox/schemas/utils';
|
||||
|
||||
import type { Announcement as BaseAnnouncement } from 'pl-api';
|
||||
|
||||
const normalizeAnnouncement = (announcement: BaseAnnouncement) => {
|
||||
const emojiMap = makeCustomEmojiMap(announcement.emojis);
|
||||
|
||||
const contentHtml = emojify(announcement.content, emojiMap);
|
||||
|
||||
return {
|
||||
...announcement,
|
||||
contentHtml,
|
||||
};
|
||||
};
|
||||
|
||||
type Announcement = ReturnType<typeof normalizeAnnouncement>;
|
||||
|
||||
export { normalizeAnnouncement, type Announcement };
|
||||
12
packages/pl-fe/src/normalizers/chat-message.ts
Normal file
12
packages/pl-fe/src/normalizers/chat-message.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ChatMessage as BaseChatMessage } from 'pl-api';
|
||||
|
||||
const normalizeChatMessage = (chatMessage: BaseChatMessage & { pending?: boolean; deleting?: boolean }) => ({
|
||||
type: 'message' as const,
|
||||
pending: false,
|
||||
deleting: false,
|
||||
...chatMessage,
|
||||
});
|
||||
|
||||
type ChatMessage = ReturnType<typeof normalizeChatMessage>;
|
||||
|
||||
export { normalizeChatMessage, type ChatMessage };
|
||||
12
packages/pl-fe/src/normalizers/group-member.ts
Normal file
12
packages/pl-fe/src/normalizers/group-member.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { normalizeAccount } from './account';
|
||||
|
||||
import type { GroupMember as BaseGroupMember } from 'pl-api';
|
||||
|
||||
const normalizeGroupMember = (groupMember: BaseGroupMember) => ({
|
||||
...groupMember,
|
||||
account: normalizeAccount(groupMember.account),
|
||||
});
|
||||
|
||||
type GroupMember = ReturnType<typeof normalizeGroupMember>;
|
||||
|
||||
export { normalizeGroupMember, type GroupMember };
|
||||
43
packages/pl-fe/src/normalizers/group.ts
Normal file
43
packages/pl-fe/src/normalizers/group.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { unescapeHTML } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { Group as BaseGroup } from 'pl-api';
|
||||
|
||||
const getDomainFromURL = (group: Pick<BaseGroup, 'url'>): string => {
|
||||
try {
|
||||
const url = group.url;
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeGroup = (group: BaseGroup) => {
|
||||
const missingAvatar = require('soapbox/assets/images/avatar-missing.png');
|
||||
const missingHeader = require('soapbox/assets/images/header-missing.png');
|
||||
|
||||
const domain = getDomainFromURL(group);
|
||||
const note = group.note === '<p></p>' ? '' : group.note;
|
||||
|
||||
const emojiMap = makeEmojiMap(group.emojis);
|
||||
|
||||
return {
|
||||
...group,
|
||||
avatar: group.avatar || group.avatar_static || missingAvatar,
|
||||
avatar_static: group.avatar_static || group.avatar || missingAvatar,
|
||||
header: group.header || group.header_static || missingHeader,
|
||||
header_static: group.header_static || group.header || missingHeader,
|
||||
domain,
|
||||
note,
|
||||
display_name_html: emojify(escapeTextContentForBrowser(group.display_name), emojiMap),
|
||||
note_emojified: emojify(group.note, emojiMap),
|
||||
note_plain: unescapeHTML(group.note),
|
||||
};
|
||||
};
|
||||
|
||||
type Group = ReturnType<typeof normalizeGroup>;
|
||||
|
||||
export { normalizeGroup, type Group };
|
||||
13
packages/pl-fe/src/normalizers/index.ts
Normal file
13
packages/pl-fe/src/normalizers/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { normalizeAccount, type Account } from './account';
|
||||
export { normalizeAdminReport, type AdminReport } from './admin-report';
|
||||
export { normalizeAnnouncement, type Announcement } from './announcement';
|
||||
export { normalizeChatMessage, type ChatMessage } from './chat-message';
|
||||
export { normalizeGroup, type Group } from './group';
|
||||
export { normalizeGroupMember, type GroupMember } from './group-member';
|
||||
export { normalizeNotification, normalizeNotifications, type Notification } from './notification';
|
||||
export { normalizePoll, normalizePollEdit, type Poll, type PollEdit } from './poll';
|
||||
export { normalizeStatus, type Status } from './status';
|
||||
export { normalizeStatusEdit, type StatusEdit } from './status-edit';
|
||||
export { normalizeTranslation, type Translation } from './translation';
|
||||
|
||||
export { SoapboxConfigRecord, normalizeSoapboxConfig } from './soapbox/soapbox-config';
|
||||
53
packages/pl-fe/src/normalizers/notification.ts
Normal file
53
packages/pl-fe/src/normalizers/notification.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { getNotificationStatus } from 'soapbox/features/notifications/components/notification';
|
||||
|
||||
import { normalizeAccount } from './account';
|
||||
|
||||
import type { Notification as BaseNotification } from 'pl-api';
|
||||
|
||||
const STATUS_NOTIFICATION_TYPES = [
|
||||
'favourite',
|
||||
'reblog',
|
||||
'emoji_reaction',
|
||||
'event_reminder',
|
||||
'participation_accepted',
|
||||
'participation_request',
|
||||
];
|
||||
|
||||
const normalizeNotification = (notification: BaseNotification) => ({
|
||||
...notification,
|
||||
account: normalizeAccount(notification.account),
|
||||
account_id: notification.account.id,
|
||||
accounts: [normalizeAccount(notification.account)],
|
||||
account_ids: [notification.account.id],
|
||||
});
|
||||
|
||||
const normalizeNotifications = (notifications: Array<BaseNotification>) => {
|
||||
const deduplicatedNotifications: Notification[] = [];
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (STATUS_NOTIFICATION_TYPES.includes(notification.type)) {
|
||||
const existingNotification = deduplicatedNotifications
|
||||
.find(deduplicated =>
|
||||
deduplicated.type === notification.type
|
||||
&& ((notification.type === 'emoji_reaction' && deduplicated.type === 'emoji_reaction') ? notification.emoji === deduplicated.emoji : true)
|
||||
&& getNotificationStatus(deduplicated)?.id === getNotificationStatus(notification)?.id,
|
||||
);
|
||||
|
||||
if (existingNotification) {
|
||||
existingNotification.accounts.push(normalizeAccount(notification.account));
|
||||
existingNotification.account_ids.push(notification.account.id);
|
||||
existingNotification.id += '+' + notification.id;
|
||||
} else {
|
||||
deduplicatedNotifications.push(normalizeNotification(notification));
|
||||
}
|
||||
} else {
|
||||
deduplicatedNotifications.push(normalizeNotification(notification));
|
||||
}
|
||||
}
|
||||
|
||||
return deduplicatedNotifications;
|
||||
};
|
||||
|
||||
type Notification = ReturnType<typeof normalizeNotification>;
|
||||
|
||||
export { normalizeNotification, normalizeNotifications, type Notification };
|
||||
43
packages/pl-fe/src/normalizers/poll.ts
Normal file
43
packages/pl-fe/src/normalizers/poll.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { Status as BaseStatus, StatusEdit as BaseStatusEdit, CustomEmoji } from 'pl-api';
|
||||
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
const sanitizeTitle = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { ALLOWED_TAGS: [] });
|
||||
|
||||
const normalizePoll = (poll: Exclude<BaseStatus['poll'], null>) => {
|
||||
const emojiMap = makeEmojiMap(poll.emojis);
|
||||
return {
|
||||
...poll,
|
||||
options: poll.options.map(option => ({
|
||||
...option,
|
||||
title_emojified: sanitizeTitle(option.title, emojiMap),
|
||||
title_map_emojified: option.title_map
|
||||
? Object.fromEntries(Object.entries(option.title_map).map(([key, title]) => [key, sanitizeTitle(title, emojiMap)]))
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizePollEdit = (poll: Exclude<BaseStatusEdit['poll'], null>, emojis?: Array<CustomEmoji>) => {
|
||||
const emojiMap = makeEmojiMap(emojis);
|
||||
return {
|
||||
...poll,
|
||||
options: poll.options.map(option => ({
|
||||
...option,
|
||||
title_emojified: sanitizeTitle(option.title, emojiMap),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type Poll = ReturnType<typeof normalizePoll>;
|
||||
type PollEdit = ReturnType<typeof normalizePollEdit>;
|
||||
|
||||
export {
|
||||
normalizePoll,
|
||||
normalizePollEdit,
|
||||
type Poll,
|
||||
type PollEdit,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Record as ImmutableRecord } from 'immutable';
|
||||
|
||||
import { normalizeSoapboxConfig } from './soapbox-config';
|
||||
|
||||
describe('normalizeSoapboxConfig()', () => {
|
||||
it('adds base fields', () => {
|
||||
const result = normalizeSoapboxConfig({});
|
||||
expect(result.brandColor).toBe('');
|
||||
expect(ImmutableRecord.isRecord(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes cryptoAddresses', () => {
|
||||
const soapboxConfig = {
|
||||
cryptoAddresses: [
|
||||
{ ticker: '$BTC', address: 'bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n' },
|
||||
],
|
||||
};
|
||||
|
||||
const expected = {
|
||||
cryptoAddresses: [
|
||||
{ ticker: 'btc', address: 'bc1q9cx35adpm73aq2fw40ye6ts8hfxqzjr5unwg0n', note: '' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = normalizeSoapboxConfig(soapboxConfig);
|
||||
expect(result.cryptoAddresses.size).toBe(1);
|
||||
expect(ImmutableRecord.isRecord(result.cryptoAddresses.get(0))).toBe(true);
|
||||
expect(result.toJS()).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it('normalizes promoPanel', async () => {
|
||||
const soapboxConfig = await import('soapbox/__fixtures__/spinster-soapbox.json');
|
||||
const result = normalizeSoapboxConfig(soapboxConfig);
|
||||
expect(ImmutableRecord.isRecord(result.promoPanel)).toBe(true);
|
||||
expect(ImmutableRecord.isRecord(result.promoPanel.items.get(0))).toBe(true);
|
||||
expect(result.promoPanel.items.get(2)?.icon).toBe('question-circle');
|
||||
});
|
||||
|
||||
it('upgrades singleUserModeProfile to redirectRootNoLogin', () => {
|
||||
expect(normalizeSoapboxConfig({ singleUserMode: true, singleUserModeProfile: 'alex' }).redirectRootNoLogin).toBe('/@alex');
|
||||
expect(normalizeSoapboxConfig({ singleUserMode: true, singleUserModeProfile: '@alex' }).redirectRootNoLogin).toBe('/@alex');
|
||||
expect(normalizeSoapboxConfig({ singleUserMode: true, singleUserModeProfile: 'alex@gleasonator.com' }).redirectRootNoLogin).toBe('/@alex@gleasonator.com');
|
||||
expect(normalizeSoapboxConfig({ singleUserMode: false, singleUserModeProfile: 'alex' }).redirectRootNoLogin).toBe('');
|
||||
});
|
||||
|
||||
it('normalizes redirectRootNoLogin', () => {
|
||||
expect(normalizeSoapboxConfig({ redirectRootNoLogin: 'benis' }).redirectRootNoLogin).toBe('/benis');
|
||||
expect(normalizeSoapboxConfig({ redirectRootNoLogin: '/benis' }).redirectRootNoLogin).toBe('/benis');
|
||||
expect(normalizeSoapboxConfig({ redirectRootNoLogin: '/' }).redirectRootNoLogin).toBe('');
|
||||
});
|
||||
});
|
||||
237
packages/pl-fe/src/normalizers/soapbox/soapbox-config.ts
Normal file
237
packages/pl-fe/src/normalizers/soapbox/soapbox-config.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
Map as ImmutableMap,
|
||||
List as ImmutableList,
|
||||
Record as ImmutableRecord,
|
||||
fromJS,
|
||||
} from 'immutable';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
|
||||
import { normalizeUsername } from 'soapbox/utils/input';
|
||||
import { toTailwind } from 'soapbox/utils/tailwind';
|
||||
import { generateAccent } from 'soapbox/utils/theme';
|
||||
|
||||
import type {
|
||||
PromoPanelItem,
|
||||
FooterItem,
|
||||
CryptoAddress,
|
||||
} from 'soapbox/types/soapbox';
|
||||
|
||||
const DEFAULT_COLORS = ImmutableMap<string, any>({
|
||||
success: ImmutableMap({
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
}),
|
||||
danger: ImmutableMap({
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}),
|
||||
'greentext': '#789922',
|
||||
});
|
||||
|
||||
const PromoPanelItemRecord = ImmutableRecord({
|
||||
icon: '',
|
||||
text: '',
|
||||
url: '',
|
||||
textLocales: ImmutableMap<string, string>(),
|
||||
});
|
||||
|
||||
const PromoPanelRecord = ImmutableRecord({
|
||||
items: ImmutableList<PromoPanelItem>(),
|
||||
});
|
||||
|
||||
const FooterItemRecord = ImmutableRecord({
|
||||
title: '',
|
||||
url: '',
|
||||
});
|
||||
|
||||
const CryptoAddressRecord = ImmutableRecord({
|
||||
address: '',
|
||||
note: '',
|
||||
ticker: '',
|
||||
});
|
||||
|
||||
const SoapboxConfigRecord = ImmutableRecord({
|
||||
appleAppId: null,
|
||||
authProvider: '',
|
||||
logo: '',
|
||||
logoDarkMode: null,
|
||||
banner: '',
|
||||
brandColor: '', // Empty
|
||||
accentColor: '',
|
||||
colors: ImmutableMap(),
|
||||
copyright: `♥${new Date().getFullYear()}. Copying is an act of love. Please copy and share.`,
|
||||
customCss: ImmutableList<string>(),
|
||||
defaultSettings: ImmutableMap<string, any>(),
|
||||
extensions: ImmutableMap(),
|
||||
gdpr: false,
|
||||
gdprUrl: '',
|
||||
greentext: false,
|
||||
promoPanel: PromoPanelRecord(),
|
||||
navlinks: ImmutableMap({
|
||||
homeFooter: ImmutableList<FooterItem>(),
|
||||
}),
|
||||
allowedEmoji: ImmutableList<string>([
|
||||
'👍',
|
||||
'❤️',
|
||||
'😆',
|
||||
'😮',
|
||||
'😢',
|
||||
'😩',
|
||||
]),
|
||||
verifiedIcon: '',
|
||||
displayFqn: true,
|
||||
cryptoAddresses: ImmutableList<CryptoAddress>(),
|
||||
cryptoDonatePanel: ImmutableMap({
|
||||
limit: 1,
|
||||
}),
|
||||
aboutPages: ImmutableMap<string, ImmutableMap<string, unknown>>(),
|
||||
authenticatedProfile: false,
|
||||
linkFooterMessage: '',
|
||||
links: ImmutableMap<string, string>(),
|
||||
displayCta: false,
|
||||
/** Whether to inject suggested profiles into the Home feed. */
|
||||
feedInjection: true,
|
||||
tileServer: '',
|
||||
tileServerAttribution: '',
|
||||
redirectRootNoLogin: '',
|
||||
/**
|
||||
* Whether to use the preview URL for media thumbnails.
|
||||
* On some platforms this can be too blurry without additional configuration.
|
||||
*/
|
||||
mediaPreview: false,
|
||||
sentryDsn: undefined as string | undefined,
|
||||
}, 'SoapboxConfig');
|
||||
|
||||
type SoapboxConfigMap = ImmutableMap<string, any>;
|
||||
|
||||
const normalizeCryptoAddress = (address: unknown): CryptoAddress =>
|
||||
CryptoAddressRecord(ImmutableMap(fromJS(address))).update('ticker', ticker =>
|
||||
trimStart(ticker, '$').toLowerCase(),
|
||||
);
|
||||
|
||||
const normalizeCryptoAddresses = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const addresses = ImmutableList(soapboxConfig.get('cryptoAddresses'));
|
||||
return soapboxConfig.set('cryptoAddresses', addresses.map(normalizeCryptoAddress));
|
||||
};
|
||||
|
||||
const normalizeBrandColor = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const brandColor = soapboxConfig.get('brandColor') || soapboxConfig.getIn(['colors', 'primary', '500']) || '';
|
||||
return soapboxConfig.set('brandColor', brandColor);
|
||||
};
|
||||
|
||||
const normalizeAccentColor = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const brandColor = soapboxConfig.get('brandColor');
|
||||
|
||||
const accentColor = soapboxConfig.get('accentColor')
|
||||
|| soapboxConfig.getIn(['colors', 'accent', '500'])
|
||||
|| (brandColor ? generateAccent(brandColor) : '');
|
||||
|
||||
return soapboxConfig.set('accentColor', accentColor);
|
||||
};
|
||||
|
||||
const normalizeColors = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const colors = DEFAULT_COLORS.mergeDeep(soapboxConfig.get('colors'));
|
||||
return toTailwind(soapboxConfig.set('colors', colors));
|
||||
};
|
||||
|
||||
const maybeAddMissingColors = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const colors = soapboxConfig.get('colors');
|
||||
|
||||
const missing = ImmutableMap({
|
||||
'gradient-start': colors.getIn(['primary', '500']),
|
||||
'gradient-end': colors.getIn(['accent', '500']),
|
||||
'accent-blue': colors.getIn(['primary', '600']),
|
||||
});
|
||||
|
||||
return soapboxConfig.set('colors', missing.mergeDeep(colors));
|
||||
};
|
||||
|
||||
const normalizePromoPanel = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const promoPanel = PromoPanelRecord(soapboxConfig.get('promoPanel'));
|
||||
const items = promoPanel.items.map(PromoPanelItemRecord);
|
||||
return soapboxConfig.set('promoPanel', promoPanel.set('items', items));
|
||||
};
|
||||
|
||||
const normalizeFooterLinks = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const path = ['navlinks', 'homeFooter'];
|
||||
const items = (soapboxConfig.getIn(path, ImmutableList()) as ImmutableList<any>).map(FooterItemRecord);
|
||||
return soapboxConfig.setIn(path, items);
|
||||
};
|
||||
|
||||
/** Single user mode is now managed by `redirectRootNoLogin`. */
|
||||
const upgradeSingleUserMode = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const singleUserMode = soapboxConfig.get('singleUserMode') as boolean | undefined;
|
||||
const singleUserModeProfile = soapboxConfig.get('singleUserModeProfile') as string | undefined;
|
||||
const redirectRootNoLogin = soapboxConfig.get('redirectRootNoLogin') as string | undefined;
|
||||
|
||||
if (!redirectRootNoLogin && singleUserMode && singleUserModeProfile) {
|
||||
return soapboxConfig
|
||||
.set('redirectRootNoLogin', `/@${normalizeUsername(singleUserModeProfile)}`)
|
||||
.deleteAll(['singleUserMode', 'singleUserModeProfile']);
|
||||
} else {
|
||||
return soapboxConfig
|
||||
.deleteAll(['singleUserMode', 'singleUserModeProfile']);
|
||||
}
|
||||
};
|
||||
|
||||
/** Ensure a valid path is used. */
|
||||
const normalizeRedirectRootNoLogin = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => {
|
||||
const redirectRootNoLogin = soapboxConfig.get('redirectRootNoLogin');
|
||||
|
||||
if (!redirectRootNoLogin) return soapboxConfig;
|
||||
|
||||
try {
|
||||
// Basically just get the pathname with a leading slash.
|
||||
const normalized = new URL(redirectRootNoLogin, 'http://a').pathname;
|
||||
|
||||
if (normalized !== '/') {
|
||||
return soapboxConfig.set('redirectRootNoLogin', normalized);
|
||||
} else {
|
||||
// Prevent infinite redirect(?)
|
||||
return soapboxConfig.delete('redirectRootNoLogin');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('You have configured an invalid redirect in Soapbox Config.');
|
||||
console.error(e);
|
||||
return soapboxConfig.delete('redirectRootNoLogin');
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSoapboxConfig = (soapboxConfig: Record<string, any>) => SoapboxConfigRecord(
|
||||
ImmutableMap(fromJS(soapboxConfig)).withMutations(soapboxConfig => {
|
||||
normalizeBrandColor(soapboxConfig);
|
||||
normalizeAccentColor(soapboxConfig);
|
||||
normalizeColors(soapboxConfig);
|
||||
normalizePromoPanel(soapboxConfig);
|
||||
normalizeFooterLinks(soapboxConfig);
|
||||
maybeAddMissingColors(soapboxConfig);
|
||||
normalizeCryptoAddresses(soapboxConfig);
|
||||
upgradeSingleUserMode(soapboxConfig);
|
||||
normalizeRedirectRootNoLogin(soapboxConfig);
|
||||
}),
|
||||
);
|
||||
|
||||
export {
|
||||
PromoPanelItemRecord,
|
||||
PromoPanelRecord,
|
||||
FooterItemRecord,
|
||||
CryptoAddressRecord,
|
||||
SoapboxConfigRecord,
|
||||
normalizeSoapboxConfig,
|
||||
};
|
||||
30
packages/pl-fe/src/normalizers/status-edit.ts
Normal file
30
packages/pl-fe/src/normalizers/status-edit.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Status edit normalizer
|
||||
*/
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import { normalizePollEdit } from './poll';
|
||||
|
||||
import type { StatusEdit as BaseStatusEdit } from 'pl-api';
|
||||
|
||||
const normalizeStatusEdit = (statusEdit: BaseStatusEdit) => {
|
||||
const emojiMap = makeEmojiMap(statusEdit.emojis);
|
||||
|
||||
const poll = statusEdit.poll ? normalizePollEdit(statusEdit.poll) : null;
|
||||
|
||||
return {
|
||||
...statusEdit,
|
||||
poll,
|
||||
contentHtml: DOMPurify.sanitize(stripCompatibilityFeatures(emojify(statusEdit.content, emojiMap)), { ADD_ATTR: ['target'] }),
|
||||
spoilerHtml: DOMPurify.sanitize(emojify(escapeTextContentForBrowser(statusEdit.spoiler_text), emojiMap), { ADD_ATTR: ['target'] }),
|
||||
};
|
||||
};
|
||||
|
||||
type StatusEdit = ReturnType<typeof normalizeStatusEdit>
|
||||
|
||||
export { type StatusEdit, normalizeStatusEdit };
|
||||
185
packages/pl-fe/src/normalizers/status.ts
Normal file
185
packages/pl-fe/src/normalizers/status.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Status normalizer:
|
||||
* Converts API statuses into our internal format.
|
||||
* @see {@link https://docs.joinmastodon.org/entities/status/}
|
||||
*/
|
||||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { type Account as BaseAccount, type Status as BaseStatus, type MediaAttachment, mentionSchema, type Translation } from 'pl-api';
|
||||
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { stripCompatibilityFeatures, unescapeHTML } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import { normalizeAccount } from './account';
|
||||
import { normalizeGroup } from './group';
|
||||
import { normalizePoll } from './poll';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
type StatusApprovalStatus = Exclude<BaseStatus['approval_status'], null>;
|
||||
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' | 'group' | 'mutuals_only' | 'local';
|
||||
|
||||
type CalculatedValues = {
|
||||
search_index: string;
|
||||
contentHtml: string;
|
||||
spoilerHtml: string;
|
||||
contentMapHtml?: Record<string, string>;
|
||||
spoilerMapHtml?: Record<string, string>;
|
||||
expanded?: boolean | null;
|
||||
hidden?: boolean | null;
|
||||
translation?: Translation | null | false;
|
||||
currentLanguage?: string;
|
||||
};
|
||||
|
||||
type OldStatus = Pick<BaseStatus, 'content' | 'spoiler_text'> & CalculatedValues;
|
||||
|
||||
// Gets titles of poll options from status
|
||||
const getPollOptionTitles = ({ poll }: Pick<BaseStatus, 'poll'>): readonly string[] => {
|
||||
if (poll && typeof poll === 'object') {
|
||||
return poll.options.map(({ title }) => title);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Gets usernames of mentioned users from status
|
||||
const getMentionedUsernames = (status: Pick<BaseStatus, 'mentions'>): Array<string> =>
|
||||
status.mentions.map(({ acct }) => `@${acct}`);
|
||||
|
||||
// Creates search text from the status
|
||||
const buildSearchContent = (status: Pick<BaseStatus, 'poll' | 'mentions' | 'spoiler_text' | 'content'>): string => {
|
||||
const pollOptionTitles = getPollOptionTitles(status);
|
||||
const mentionedUsernames = getMentionedUsernames(status);
|
||||
|
||||
const fields = [
|
||||
status.spoiler_text,
|
||||
status.content,
|
||||
...pollOptionTitles,
|
||||
...mentionedUsernames,
|
||||
];
|
||||
|
||||
return unescapeHTML(fields.join('\n\n')) || '';
|
||||
};
|
||||
|
||||
const calculateContent = (text: string, emojiMap: any) => DOMPurify.sanitize(stripCompatibilityFeatures(emojify(text, emojiMap)), { USE_PROFILES: { html: true } });
|
||||
const calculateSpoiler = (text: string, emojiMap: any) => DOMPurify.sanitize(emojify(escapeTextContentForBrowser(text), emojiMap), { USE_PROFILES: { html: true } });
|
||||
|
||||
const calculateStatus = (status: BaseStatus, oldStatus?: OldStatus): CalculatedValues => {
|
||||
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
|
||||
const {
|
||||
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, expanded, translation, currentLanguage,
|
||||
} = oldStatus;
|
||||
|
||||
return {
|
||||
search_index, contentHtml, spoilerHtml, contentMapHtml, spoilerMapHtml, hidden, expanded, translation, currentLanguage,
|
||||
};
|
||||
} else {
|
||||
const searchContent = buildSearchContent(status);
|
||||
const emojiMap = makeEmojiMap(status.emojis);
|
||||
|
||||
return {
|
||||
search_index: domParser.parseFromString(searchContent, 'text/html').documentElement.textContent || '',
|
||||
contentHtml: calculateContent(status.content, emojiMap),
|
||||
spoilerHtml: calculateSpoiler(status.spoiler_text, emojiMap),
|
||||
contentMapHtml: status.content_map
|
||||
? Object.fromEntries(Object.entries(status.content_map)?.map(([key, value]) => [key, calculateContent(value, emojiMap)]))
|
||||
: undefined,
|
||||
spoilerMapHtml: status.spoiler_text_map
|
||||
? Object.fromEntries(Object.entries(status.spoiler_text_map).map(([key, value]) => [key, calculateSpoiler(value, emojiMap)]))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeStatus = (status: BaseStatus & {
|
||||
accounts?: Array<BaseAccount>;
|
||||
}, oldStatus?: OldStatus) => {
|
||||
const calculated = calculateStatus(status, oldStatus);
|
||||
|
||||
// Sort the replied-to mention to the top
|
||||
let mentions = status.mentions.toSorted((a, _b) => {
|
||||
if (a.id === status.in_reply_to_account_id) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Add self to mentions if it's a reply to self
|
||||
const isSelfReply = status.account.id === status.in_reply_to_account_id;
|
||||
const hasSelfMention = status.mentions.some(mention => status.account.id === mention.id);
|
||||
|
||||
if (isSelfReply && !hasSelfMention) {
|
||||
const selfMention = mentionSchema.parse(status.account);
|
||||
mentions = [selfMention, ...mentions];
|
||||
}
|
||||
|
||||
// Normalize event
|
||||
let event: BaseStatus['event'] & ({
|
||||
banner: MediaAttachment | null;
|
||||
links: Array<MediaAttachment>;
|
||||
} | null) = null;
|
||||
let media_attachments = status.media_attachments;
|
||||
|
||||
// Normalize poll
|
||||
const poll = status.poll ? normalizePoll(status.poll) : null;
|
||||
|
||||
if (status.event) {
|
||||
const firstAttachment = status.media_attachments[0];
|
||||
let banner: MediaAttachment | null = null;
|
||||
|
||||
if (firstAttachment?.description === 'Banner' && firstAttachment.type === 'image') {
|
||||
banner = firstAttachment;
|
||||
media_attachments = media_attachments.slice(1);
|
||||
}
|
||||
|
||||
const links = media_attachments.filter(attachment => attachment.mime_type === 'text/html');
|
||||
media_attachments = media_attachments.filter(attachment => attachment.mime_type !== 'text/html');
|
||||
|
||||
event = {
|
||||
...status.event,
|
||||
banner,
|
||||
links,
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize group
|
||||
const group = status.group ? normalizeGroup(status.group) : null;
|
||||
|
||||
return {
|
||||
account_id: status.account.id,
|
||||
reblog_id: status.reblog?.id || null,
|
||||
poll_id: status.poll?.id || null,
|
||||
quote_id: status.quote?.id || null,
|
||||
group_id: status.group?.id || null,
|
||||
translating: false,
|
||||
expectsCard: false,
|
||||
showFiltered: null as null | boolean,
|
||||
...status,
|
||||
account: normalizeAccount(status.account),
|
||||
accounts: status.accounts?.map(normalizeAccount),
|
||||
mentions,
|
||||
expanded: null,
|
||||
hidden: null,
|
||||
/** Rewrite `<p></p>` to empty string. */
|
||||
content: status.content === '<p></p>' ? '' : status.content,
|
||||
filtered: status.filtered?.map(result => result.filter.title),
|
||||
event,
|
||||
poll,
|
||||
group,
|
||||
media_attachments,
|
||||
...calculated,
|
||||
translation: (status.translation || calculated.translation || null) as Translation | null | false,
|
||||
// quote: status.quote ? normalizeStatus(status.quote as any) : null,
|
||||
};
|
||||
};
|
||||
|
||||
type Status = ReturnType<typeof normalizeStatus>;
|
||||
|
||||
export {
|
||||
type StatusApprovalStatus,
|
||||
type StatusVisibility,
|
||||
normalizeStatus,
|
||||
type Status,
|
||||
};
|
||||
19
packages/pl-fe/src/normalizers/translation.ts
Normal file
19
packages/pl-fe/src/normalizers/translation.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import emojify from 'soapbox/features/emoji';
|
||||
import { stripCompatibilityFeatures } from 'soapbox/utils/html';
|
||||
import { makeEmojiMap } from 'soapbox/utils/normalizers';
|
||||
|
||||
import type { Status, Translation as BaseTranslation } from 'pl-api';
|
||||
|
||||
const normalizeTranslation = (translation: BaseTranslation, status: Pick<Status, 'emojis'>) => {
|
||||
const emojiMap = makeEmojiMap(status.emojis);
|
||||
const content = stripCompatibilityFeatures(emojify(translation.content, emojiMap));
|
||||
|
||||
return {
|
||||
...translation,
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
type Translation = ReturnType<typeof normalizeTranslation>;
|
||||
|
||||
export { normalizeTranslation, type Translation };
|
||||
Reference in New Issue
Block a user