Merge pull request #3399 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 56ecdde152
This commit is contained in:
Claire
2026-02-14 21:16:37 +01:00
committed by GitHub
46 changed files with 1110 additions and 630 deletions

View File

@@ -1,5 +1,5 @@
# For details, see https://github.com/devcontainers/images/tree/main/src/ruby
FROM mcr.microsoft.com/devcontainers/ruby:1-3.3-bookworm
FROM mcr.microsoft.com/devcontainers/ruby:3.4-trixie
# Install node version from .nvmrc
WORKDIR /app

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
module Admin::ContentPoliciesHelper
def policy_list(domain_block)
domain_block
.policies
.map { |policy| I18n.t("admin.instances.content_policies.policies.#{policy}") }
.join(' · ')
end
end

View File

@@ -7,7 +7,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
}
// First, check if we already have them all.
const { searchCustomEmojisByShortcodes, clearEtag } =
const { searchCustomEmojisByShortcodes, clearCache } =
await import('@/flavours/glitch/features/emoji/database');
const existingEmojis = await searchCustomEmojisByShortcodes(
@@ -16,7 +16,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
// If there's a mismatch, re-import all custom emojis.
if (existingEmojis.length < emojis.length) {
await clearEtag('custom');
await clearCache('custom');
await loadCustomEmoji();
}
}

View File

@@ -92,8 +92,11 @@ export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
export const CustomEmojiProvider = ({
children,
emojis: rawEmojis,
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg | null }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis), [rawEmojis]);
if (!emojis) {
return children;
}
return (
<CustomEmojiContext.Provider value={emojis}>
{children}

View File

@@ -25,7 +25,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
extraEmojis,
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
className = '',
className,
onElement,
onAttribute,
...props

View File

@@ -0,0 +1,125 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { Column } from '@/flavours/glitch/components/column';
import { ColumnBackButton } from '@/flavours/glitch/components/column_back_button';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
import type { AccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import { AccountHeaderFields } from '../account_timeline/components/fields';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import classes from './styles.module.css';
const selectIsProfileEmpty = createAppSelector(
[(state) => state.accounts, (_, accountId: AccountId) => accountId],
(accounts, accountId) => {
// Null means still loading, otherwise it's a boolean.
if (!accountId) {
return null;
}
const account = accounts.get(accountId);
if (!account) {
return null;
}
return !account.note && !account.fields.size;
},
);
export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useAccountId();
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const isProfileEmpty = useAppSelector((state) =>
selectIsProfileEmpty(state, accountId),
);
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId || isProfileEmpty === null) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
const showEmptyMessage = forceEmptyState || isProfileEmpty;
return (
<Column bindToDocument={!multiColumn}>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
<div className={classes.wrapper}>
{!showEmptyMessage ? (
<>
<AccountBio
accountId={accountId}
className={`${classes.bio} account__header__content`}
/>
<AccountHeaderFields accountId={accountId} />
</>
) : (
<div className='empty-column-indicator'>
<EmptyMessage accountId={accountId} />
</div>
)}
</div>
</div>
</Column>
);
};
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const currentUserId = useAppSelector(
(state) => state.meta.get('me') as string | null,
);
const { acct } = useParams<{ acct?: string }>();
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
return <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (accountId === currentUserId) {
return (
<FormattedMessage
id='empty_column.account_about.me'
defaultMessage='You have not added any information about yourself yet.'
/>
);
}
return (
<FormattedMessage
id='empty_column.account_about.other'
defaultMessage='{acct} has not added any information about themselves yet.'
values={{ acct }}
/>
);
};

View File

@@ -0,0 +1,7 @@
.wrapper {
padding: 16px;
}
.bio {
color: var(--color-text-primary);
}

View File

@@ -210,12 +210,15 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} />
))}
<AccountBio
accountId={accountId}
className='account__header__content'
/>
<AccountHeaderFields accountId={accountId} />
{(!isRedesign || layout === 'single-column') && (
<>
<AccountBio
accountId={accountId}
className='account__header__content'
/>
<AccountHeaderFields accountId={accountId} />
</>
)}
</div>
</div>
)}

View File

@@ -1,24 +1,25 @@
import type { FC } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { FC, Key } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import htmlConfig from '@/config/html-tags.json';
import { AccountFields } from '@/flavours/glitch/components/account_fields';
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import type { EmojiHTMLProps } from '@/flavours/glitch/components/emoji/html';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import { MiniCard } from '@/flavours/glitch/components/mini_card';
import { Icon } from '@/flavours/glitch/components/icon';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useOverflowScroll } from '@/flavours/glitch/hooks/useOverflow';
import type { Account } from '@/flavours/glitch/models/account';
import { isValidUrl } from '@/flavours/glitch/utils/checks';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react';
import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react';
import IconLink from '@/material-icons/400-24px/link_2.svg?react';
import { cleanExtraEmojis } from '../../emoji/normalize';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
@@ -57,96 +58,164 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({
);
};
const verifyMessage = defineMessage({
id: 'account.link_verified_on',
defaultMessage: 'Ownership of this link was checked on {date}',
});
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink();
const emojis = useMemo(
() => cleanExtraEmojis(account.emojis),
[account.emojis],
);
const textHasCustomEmoji = useCallback(
(text: string) => {
if (!emojis) {
return false;
}
for (const emoji of Object.keys(emojis)) {
if (text.includes(`:${emoji}:`)) {
return true;
}
}
return false;
},
[emojis],
);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: account.id,
});
const intl = useIntl();
const {
bodyRef,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
handleScroll,
} = useOverflowScroll();
return (
<div
className={classNames(
classes.fieldWrapper,
canScrollLeft && classes.fieldWrapperLeft,
canScrollRight && classes.fieldWrapperRight,
)}
>
{canScrollLeft && (
<IconButton
icon='more'
iconComponent={IconLeftArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_prev',
defaultMessage: 'Show previous',
})}
className={classes.fieldArrowButton}
onClick={handleLeftNav}
/>
)}
<dl ref={bodyRef} className={classes.fieldList} onScroll={handleScroll}>
<CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList}>
{account.fields.map(
(
{ name, name_emojified, value_emojified, value_plain, verified_at },
key,
) => (
<MiniCard
<div
key={key}
label={
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
title={name}
{...htmlHandlers}
/>
}
value={
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
title={value_plain ?? undefined}
{...htmlHandlers}
/>
}
icon={fieldIcon(verified_at, value_plain)}
className={classNames(
classes.fieldCard,
verified_at && classes.fieldCardVerified,
classes.fieldRow,
verified_at && classes.fieldVerified,
)}
/>
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<FieldHTML
as='dd'
text={value_plain ?? ''}
textEmojified={value_emojified}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
titleLength={120}
{...htmlHandlers}
/>
{verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</div>
),
)}
</dl>
{canScrollRight && (
<IconButton
icon='more'
iconComponent={IconRightArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_next',
defaultMessage: 'Show next',
})}
className={classes.fieldArrowButton}
onClick={handleRightNav}
/>
)}
</div>
</CustomEmojiProvider>
);
};
function fieldIcon(verified_at: string | null, value_plain: string | null) {
if (verified_at) {
return IconVerified;
} else if (value_plain && isValidUrl(value_plain)) {
return IconLink;
const FieldHTML: FC<
{
as: 'dd' | 'dt';
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
titleLength: number;
} & Omit<EmojiHTMLProps, 'htmlString'>
> = ({
as,
className,
extraEmojis,
text,
textEmojified,
textHasCustomEmoji,
titleLength,
onElement,
...props
}) => {
const [showAll, setShowAll] = useState(false);
const handleClick = useCallback(() => {
setShowAll((prev) => !prev);
}, []);
const handleElement: OnElementHandler = useCallback(
(element, props, children, extra) => {
if (element instanceof HTMLAnchorElement) {
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
if (textHasCustomEmoji) {
return (
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
{children}
</span>
);
}
return onElement?.(element, props, children, extra);
}
return undefined;
},
[onElement, textHasCustomEmoji],
);
return (
<EmojiHTML
as={as}
htmlString={textEmojified}
title={showTitleOnLength(text, titleLength)}
className={classNames(
className,
text && isValidUrl(text) && classes.fieldLink,
showAll && classes.fieldShowAll,
)}
onClick={handleClick}
onElement={handleElement}
{...props}
/>
);
};
function filterAttributesForSpan(props: Record<string, unknown>) {
const validAttributes: Record<string, unknown> = {};
for (const key of Object.keys(props)) {
if (key in htmlConfig.tags.span.attributes) {
validAttributes[key] = props[key];
}
}
return validAttributes;
}
function showTitleOnLength(value: string | null, maxLength: number) {
if (value && value.length > maxLength) {
return value;
}
return undefined;
}

View File

@@ -192,115 +192,80 @@ svg.badgeIcon {
}
}
.fieldWrapper {
margin-top: 16px;
width: 100%;
position: relative;
}
.fieldWrapper::before,
.fieldWrapper::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 40px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 1;
}
.fieldWrapper::before {
left: 0;
background: linear-gradient(
to left,
transparent 0%,
var(--color-bg-primary) 100%
);
}
.fieldWrapper::after {
right: 0;
background: linear-gradient(
to right,
transparent 0%,
var(--color-bg-primary) 100%
);
}
.fieldWrapperLeft::before {
opacity: 1;
}
.fieldWrapperRight::after {
opacity: 1;
}
.fieldList {
display: flex;
flex-wrap: nowrap;
gap: 4px;
scroll-snap-type: x mandatory;
scroll-padding-left: 40px;
scroll-padding-right: 40px;
scroll-behavior: smooth;
overflow-x: scroll;
scrollbar-width: none;
overflow-y: visible;
}
display: grid;
grid-template-columns: 160px 1fr min-content;
column-gap: 12px;
margin: 4px 0 16px;
.fieldCard {
scroll-snap-align: start;
&:focus-visible,
&:focus-within {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
:is(dt, dd) {
max-width: 200px;
@container (width < 420px) {
grid-template-columns: 100px 1fr min-content;
}
}
.fieldCardVerified {
.fieldRow {
display: grid;
grid-column: 1 / -1;
align-items: start;
grid-template-columns: subgrid;
padding: 0 4px;
> :is(dt, dd) {
margin: 8px 0;
&:not(.fieldShowAll) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
}
> dt {
color: var(--color-text-secondary);
}
&:not(.fieldVerified) > dd {
grid-column: span 2;
}
a {
font-weight: 500;
color: var(--color-text-brand);
text-decoration: none;
transition: 0.2s ease-in-out;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
}
}
}
.fieldVerified {
background-color: var(--color-bg-brand-softer);
}
.fieldArrowButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: var(--color-bg-primary);
box-shadow: 0 1px 4px 0 var(--color-shadow-primary);
border-radius: 9999px;
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
outline-offset: 2px;
z-index: 2;
.fieldLink:is(dd, dt) {
margin: 0;
}
&:first-child {
left: 4px;
}
.fieldLink > a {
display: block;
padding: 8px 0;
}
&:last-child {
right: 4px;
}
&:hover,
&:focus,
&:focus-visible {
background-color: color-mix(
in oklab,
var(--color-bg-brand-base) var(--overlay-strength-brand),
var(--color-bg-primary)
);
}
.fieldVerifiedIcon {
width: 16px;
height: 16px;
margin-top: 8px;
}
.fieldNumbersWrapper {
padding: 0;
a {
font-weight: unset;
}
@@ -358,7 +323,11 @@ svg.badgeIcon {
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 12px;
padding: 0 24px;
padding: 0 12px;
@container (width >= 500px) {
padding: 0 24px;
}
a {
display: block;

View File

@@ -5,15 +5,23 @@ import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
const { layout } = useLayout();
if (isRedesignEnabled()) {
return (
<div className={classes.tabs}>
<NavLink isActive={isActive} to={`/@${acct}`}>
{layout !== 'single-column' && (
<NavLink exact to={`/@${acct}/about`}>
<FormattedMessage id='account.about' defaultMessage='About' />
</NavLink>
)}
<NavLink isActive={isActive} to={`/@${acct}/posts`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink>
<NavLink exact to={`/@${acct}/media`}>

View File

@@ -3,7 +3,6 @@ import { IDBFactory } from 'fake-indexeddb';
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import {
putEmojiData,
loadEmojiByHexcode,
@@ -12,8 +11,6 @@ import {
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
loadLatestEtag,
putLatestEtag,
} from './database';
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
@@ -120,36 +117,4 @@ describe('emoji database', () => {
).resolves.toEqual(data);
});
});
describe('loadLatestEtag', () => {
beforeEach(async () => {
await putLatestEtag('etag', 'en');
await putEmojiData([unicodeEmojiFactory()], 'en');
await putLatestEtag('fr-etag', 'fr');
});
test('retrieves the etag for loaded locale', async () => {
await putEmojiData(
[unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })],
'en',
);
const etag = await loadLatestEtag('en');
expect(etag).toBe('etag');
});
test('returns null if locale has no shortcodes', async () => {
const etag = await loadLatestEtag('en');
expect(etag).toBeNull();
});
test('returns null if locale not loaded', async () => {
const etag = await loadLatestEtag('de');
expect(etag).toBeNull();
});
test('returns null if locale has no data', async () => {
const etag = await loadLatestEtag('fr');
expect(etag).toBeNull();
});
});
});

View File

@@ -3,21 +3,16 @@ import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/flavours/glitch/api_types/custom_emoji';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { openEmojiDB } from './db-schema';
import type { Database } from './db-schema';
import {
localeToSegmenter,
toSupportedLocale,
toSupportedLocaleOrCustom,
} from './locale';
import { localeToSegmenter, toSupportedLocale } from './locale';
import {
extractTokens,
skinHexcodeToEmoji,
transformCustomEmojiData,
transformEmojiData,
} from './normalize';
import type { AnyEmojiData, EtagTypes } from './types';
import type { AnyEmojiData, CacheKey } from './types';
import { emojiLogger } from './utils';
const loadedLocales = new Set<Locale>();
@@ -214,16 +209,21 @@ export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
await trx.done;
}
export async function putLatestEtag(etag: string, name: EtagTypes) {
export async function loadCacheValue(key: CacheKey) {
const db = await loadDB();
await db.put('etags', etag, name);
const value = await db.get('etags', key);
return value;
}
export async function clearEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
export async function putCacheValue(key: CacheKey, value: string) {
const db = await loadDB();
await db.delete('etags', locale);
log('Cleared etag for %s', locale);
await db.put('etags', value, key);
}
export async function clearCache(key: CacheKey) {
const db = await loadDB();
await db.delete('etags', key);
log('Cleared cache for %s', key);
}
export async function loadEmojiByHexcode(
@@ -276,26 +276,6 @@ export async function loadLegacyShortcodesByShortcode(shortcode: string) {
);
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
const rowCount = await db.count(locale);
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
// Check if shortcodes exist for the given Unicode locale.
if (locale !== 'custom') {
const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST);
if (!result?.shortcodes) {
return null;
}
}
const etag = await db.get('etags', locale);
return etag ?? null;
}
// Private functions
async function syncLocales(db: Database) {

View File

@@ -10,7 +10,7 @@ import type {
StoreNames,
} from 'idb';
import type { CustomEmojiData, EtagTypes, UnicodeEmojiData } from './types';
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('database');
@@ -35,7 +35,7 @@ interface EmojiDB extends LocaleTables, DBSchema {
};
};
etags: {
key: EtagTypes;
key: CacheKey;
value: string;
};
}

View File

@@ -4,11 +4,11 @@ import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import {
putEmojiData,
putCustomEmojiData,
loadLatestEtag,
putLatestEtag,
putCacheValue,
putLegacyShortcodes,
loadCacheValue,
} from './database';
import { toSupportedLocale, toValidEtagName } from './locale';
import { toSupportedLocale, toValidCacheKey } from './locale';
import type { CustomEmojiData } from './types';
import { emojiLogger } from './utils';
@@ -23,8 +23,8 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
shortcodes ? ' and shortcodes' : '',
);
let emojis = await fetchAndCheckEtag<CompactEmoji[]>({
etagString: locale,
let emojis = await fetchIfNotLoaded<CompactEmoji[]>({
key: locale,
path: localeToEmojiPath(locale),
});
if (!emojis) {
@@ -33,8 +33,8 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
const shortcodesData: ShortcodesDataset[] = [];
if (shortcodes) {
const shortcodesResponse = await fetchAndCheckEtag<ShortcodesDataset>({
etagString: `${locale}-shortcodes`,
const shortcodesResponse = await fetchIfNotLoaded<ShortcodesDataset>({
key: `${locale}-shortcodes`,
path: localeToShortcodesPath(locale),
});
if (shortcodesResponse) {
@@ -51,13 +51,24 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>({
etagString: 'custom',
const response = await fetchAndCheckEtag({
oldEtag: await loadCacheValue('custom'),
path: '/api/v1/custom_emojis',
});
if (!emojis) {
if (!response) {
return;
}
const etag = response.headers.get('ETag');
if (etag) {
log('Custom emoji data fetched successfully, storing etag %s', etag);
await putCacheValue('custom', etag);
} else {
log('No etag found in response for custom emoji data');
}
const emojis = (await response.json()) as CustomEmojiData[];
await putCustomEmojiData({ emojis, clear: true });
return emojis;
}
@@ -72,9 +83,8 @@ export async function importLegacyShortcodes() {
if (!path) {
throw new Error('IAMCAL shortcodes path not found');
}
const shortcodesData = await fetchAndCheckEtag<ShortcodesDataset>({
checkEtag: true,
etagString: 'shortcodes',
const shortcodesData = await fetchIfNotLoaded<ShortcodesDataset>({
key: 'shortcodes',
path,
});
if (!shortcodesData) {
@@ -118,48 +128,64 @@ function localeToShortcodesPath(locale: Locale) {
return path;
}
async function fetchAndCheckEtag<ResultType extends object[] | object>({
etagString,
async function fetchIfNotLoaded<ResultType extends object[] | object>({
key: rawKey,
path,
checkEtag = false,
}: {
etagString: string;
key: string;
path: string;
checkEtag?: boolean;
}): Promise<ResultType | null> {
const etagName = toValidEtagName(etagString);
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
const key = toValidCacheKey(rawKey);
const value = await loadCacheValue(key);
if (value === path) {
log('data for %s already loaded, skipping fetch', key);
return null;
}
const response = await fetchAndCheckEtag({ path });
if (!response) {
return null;
}
log('data for %s fetched successfully, storing etag', key);
await putCacheValue(key, path);
return (await response.json()) as ResultType;
}
async function fetchAndCheckEtag({
oldEtag,
path,
}: {
oldEtag?: string;
path: string;
}) {
const headers = new Headers({
'Content-Type': 'application/json',
});
if (oldEtag) {
headers.set('If-None-Match', oldEtag);
}
// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
},
headers,
});
// If not modified, return null
if (response.status === 304) {
log('etag not modified for %s', etagName);
log('etag not modified for %s', path);
return null;
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${etagName}: ${response.statusText}`,
`Failed to fetch emoji data for ${path}: ${response.statusText}`,
);
}
const data = (await response.json()) as ResultType;
// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag && checkEtag) {
log(`storing new etag for ${etagName}: ${etag}`);
await putLatestEtag(etag, etagName);
} else if (!etag) {
log(`no etag found in response for ${etagName}`);
}
return data;
return response;
}

View File

@@ -2,7 +2,7 @@ import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase';
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import type { EtagTypes, LocaleOrCustom, LocaleWithShortcodes } from './types';
import type { CacheKey, LocaleOrCustom, LocaleWithShortcodes } from './types';
export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase();
@@ -19,7 +19,7 @@ export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
return toSupportedLocale(locale);
}
export function toValidEtagName(input: string): EtagTypes {
export function toValidCacheKey(input: string): CacheKey {
const lower = input.toLowerCase();
if (lower === EMOJI_TYPE_CUSTOM || lower === EMOJI_DB_NAME_SHORTCODES) {
return lower;

View File

@@ -181,7 +181,7 @@ export function emojiToInversionClassName(emoji: string): string | null {
return null;
}
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg | null) {
if (!extraEmojis) {
return null;
}

View File

@@ -22,7 +22,7 @@ export type EmojiMode =
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
export type LocaleWithShortcodes = `${Locale}-shortcodes`;
export type EtagTypes =
export type CacheKey =
| LocaleOrCustom
| typeof EMOJI_DB_NAME_SHORTCODES
| LocaleWithShortcodes;

View File

@@ -81,6 +81,7 @@ import {
PrivacyPolicy,
TermsOfService,
AccountFeatured,
AccountAbout,
Quotes,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
@@ -91,6 +92,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
import { areCollectionsEnabled } from '../collections/utils';
import { isClientFeatureEnabled } from '@/flavours/glitch/utils/environment';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@@ -117,6 +119,7 @@ class SwitchingColumnsArea extends PureComponent {
children: PropTypes.node,
location: PropTypes.object,
singleColumn: PropTypes.bool,
layout: PropTypes.string.isRequired,
forceOnboarding: PropTypes.bool,
};
@@ -167,6 +170,37 @@ class SwitchingColumnsArea extends PureComponent {
redirect = <Redirect from='/' to='/about' exact />;
}
const profileRedesignEnabled = isClientFeatureEnabled('profile_redesign');
const profileRedesignRoutes = [];
if (profileRedesignEnabled) {
profileRedesignRoutes.push(
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
);
// Check if we're in single-column mode. Confusingly, the singleColumn prop includes mobile.
if (this.props.layout === 'single-column') {
// When in single column mode (desktop w/o advanced view), redirect both the root and about to the posts tab.
profileRedesignRoutes.push(
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/posts' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/posts' exact />,
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct/posts' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id/posts' exact />,
);
} else {
// Otherwise, provide and redirect to the /about page.
profileRedesignRoutes.push(
<WrappedRoute key="about" path={['/@:acct/about', '/accounts/:id/about']} component={AccountAbout} content={children} />,
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/about' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/about' exact />
);
}
} else {
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
profileRedesignRoutes.push(
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' exact />
);
}
return (
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
@@ -213,7 +247,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
{!profileRedesignEnabled && <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />}
{...profileRedesignRoutes}
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
@@ -243,7 +278,7 @@ class SwitchingColumnsArea extends PureComponent {
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
}
<Route component={BundleColumnError} />
</WrappedSwitch>
@@ -656,7 +691,13 @@ class UI extends PureComponent {
/>
</div>)}
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
<SwitchingColumnsArea
identity={this.props.identity}
location={location}
singleColumn={layout === 'mobile' || layout === 'single-column'}
layout={layout}
forceOnboarding={firstLaunch && newAccount}
>
{children}
</SwitchingColumnsArea>

View File

@@ -87,6 +87,11 @@ export function AccountFeatured() {
return import('../../account_featured');
}
export function AccountAbout() {
return import('../../account_about')
.then((module) => ({ default: module.AccountAbout }));
}
export function Followers () {
return import('../../followers');
}

View File

@@ -4,19 +4,41 @@ import { useParams } from 'react-router';
import { fetchAccount, lookupAccount } from 'flavours/glitch/actions/accounts';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'flavours/glitch/store';
interface Params {
acct?: string;
id?: string;
}
export const useAccountId = () => {
const selectNormalizedId = createAppSelector(
[
(state) => state.accounts_map,
(_, acct?: string) => acct,
(_, _acct, id?: string) => id,
],
(accountsMap, acct, id) => {
if (id) {
return id;
}
if (acct) {
return accountsMap[normalizeForLookup(acct)];
}
return undefined;
},
);
export type AccountId = string | null | undefined;
export function useAccountId() {
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch();
const accountId = useAppSelector(
(state) =>
id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined),
const accountId = useAppSelector((state) =>
selectNormalizedId(state, acct, id),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
@@ -31,5 +53,5 @@ export const useAccountId = () => {
}
}, [dispatch, accountId, acct, accountInStore]);
return accountId;
};
return accountId satisfies AccountId;
}

View File

@@ -7,7 +7,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
}
// First, check if we already have them all.
const { searchCustomEmojisByShortcodes, clearEtag } =
const { searchCustomEmojisByShortcodes, clearCache } =
await import('@/mastodon/features/emoji/database');
const existingEmojis = await searchCustomEmojisByShortcodes(
@@ -16,7 +16,7 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
// If there's a mismatch, re-import all custom emojis.
if (existingEmojis.length < emojis.length) {
await clearEtag('custom');
await clearCache('custom');
await loadCustomEmoji();
}
}

View File

@@ -92,8 +92,11 @@ export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
export const CustomEmojiProvider = ({
children,
emojis: rawEmojis,
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg | null }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis), [rawEmojis]);
if (!emojis) {
return children;
}
return (
<CustomEmojiContext.Provider value={emojis}>
{children}

View File

@@ -25,7 +25,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
extraEmojis,
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
className = '',
className,
onElement,
onAttribute,
...props

View File

@@ -0,0 +1,125 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Column } from '@/mastodon/components/column';
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import type { AccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import { AccountHeaderFields } from '../account_timeline/components/fields';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import classes from './styles.module.css';
const selectIsProfileEmpty = createAppSelector(
[(state) => state.accounts, (_, accountId: AccountId) => accountId],
(accounts, accountId) => {
// Null means still loading, otherwise it's a boolean.
if (!accountId) {
return null;
}
const account = accounts.get(accountId);
if (!account) {
return null;
}
return !account.note && !account.fields.size;
},
);
export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useAccountId();
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const forceEmptyState = blockedBy || hidden || suspended;
const isProfileEmpty = useAppSelector((state) =>
selectIsProfileEmpty(state, accountId),
);
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!accountId || isProfileEmpty === null) {
return (
<Column bindToDocument={!multiColumn}>
<LoadingIndicator />
</Column>
);
}
const showEmptyMessage = forceEmptyState || isProfileEmpty;
return (
<Column bindToDocument={!multiColumn}>
<ColumnBackButton />
<div className='scrollable scrollable--flex'>
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
<div className={classes.wrapper}>
{!showEmptyMessage ? (
<>
<AccountBio
accountId={accountId}
className={`${classes.bio} account__header__content`}
/>
<AccountHeaderFields accountId={accountId} />
</>
) : (
<div className='empty-column-indicator'>
<EmptyMessage accountId={accountId} />
</div>
)}
</div>
</div>
</Column>
);
};
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
const currentUserId = useAppSelector(
(state) => state.meta.get('me') as string | null,
);
const { acct } = useParams<{ acct?: string }>();
if (suspended) {
return (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
return <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
return (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (accountId === currentUserId) {
return (
<FormattedMessage
id='empty_column.account_about.me'
defaultMessage='You have not added any information about yourself yet.'
/>
);
}
return (
<FormattedMessage
id='empty_column.account_about.other'
defaultMessage='{acct} has not added any information about themselves yet.'
values={{ acct }}
/>
);
};

View File

@@ -0,0 +1,7 @@
.wrapper {
padding: 16px;
}
.bio {
color: var(--color-text-primary);
}

View File

@@ -210,12 +210,15 @@ export const AccountHeader: React.FC<{
<AccountNote accountId={accountId} />
))}
<AccountBio
accountId={accountId}
className='account__header__content'
/>
<AccountHeaderFields accountId={accountId} />
{(!isRedesign || layout === 'single-column') && (
<>
<AccountBio
accountId={accountId}
className='account__header__content'
/>
<AccountHeaderFields accountId={accountId} />
</>
)}
</div>
<AccountNumberFields accountId={accountId} />

View File

@@ -1,24 +1,25 @@
import type { FC } from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { FC, Key } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import htmlConfig from '@/config/html-tags.json';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { AccountFields } from '@/mastodon/components/account_fields';
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import { IconButton } from '@/mastodon/components/icon_button';
import { MiniCard } from '@/mastodon/components/mini_card';
import { Icon } from '@/mastodon/components/icon';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useOverflowScroll } from '@/mastodon/hooks/useOverflow';
import type { Account } from '@/mastodon/models/account';
import { isValidUrl } from '@/mastodon/utils/checks';
import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react';
import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react';
import IconLink from '@/material-icons/400-24px/link_2.svg?react';
import type { OnElementHandler } from '@/mastodon/utils/html';
import { cleanExtraEmojis } from '../../emoji/normalize';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
@@ -57,96 +58,164 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({
);
};
const verifyMessage = defineMessage({
id: 'account.link_verified_on',
defaultMessage: 'Ownership of this link was checked on {date}',
});
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink();
const emojis = useMemo(
() => cleanExtraEmojis(account.emojis),
[account.emojis],
);
const textHasCustomEmoji = useCallback(
(text: string) => {
if (!emojis) {
return false;
}
for (const emoji of Object.keys(emojis)) {
if (text.includes(`:${emoji}:`)) {
return true;
}
}
return false;
},
[emojis],
);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: account.id,
});
const intl = useIntl();
const {
bodyRef,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
handleScroll,
} = useOverflowScroll();
return (
<div
className={classNames(
classes.fieldWrapper,
canScrollLeft && classes.fieldWrapperLeft,
canScrollRight && classes.fieldWrapperRight,
)}
>
{canScrollLeft && (
<IconButton
icon='more'
iconComponent={IconLeftArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_prev',
defaultMessage: 'Show previous',
})}
className={classes.fieldArrowButton}
onClick={handleLeftNav}
/>
)}
<dl ref={bodyRef} className={classes.fieldList} onScroll={handleScroll}>
<CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList}>
{account.fields.map(
(
{ name, name_emojified, value_emojified, value_plain, verified_at },
key,
) => (
<MiniCard
<div
key={key}
label={
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
title={name}
{...htmlHandlers}
/>
}
value={
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
title={value_plain ?? undefined}
{...htmlHandlers}
/>
}
icon={fieldIcon(verified_at, value_plain)}
className={classNames(
classes.fieldCard,
verified_at && classes.fieldCardVerified,
classes.fieldRow,
verified_at && classes.fieldVerified,
)}
/>
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<FieldHTML
as='dd'
text={value_plain ?? ''}
textEmojified={value_emojified}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
titleLength={120}
{...htmlHandlers}
/>
{verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</div>
),
)}
</dl>
{canScrollRight && (
<IconButton
icon='more'
iconComponent={IconRightArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_next',
defaultMessage: 'Show next',
})}
className={classes.fieldArrowButton}
onClick={handleRightNav}
/>
)}
</div>
</CustomEmojiProvider>
);
};
function fieldIcon(verified_at: string | null, value_plain: string | null) {
if (verified_at) {
return IconVerified;
} else if (value_plain && isValidUrl(value_plain)) {
return IconLink;
const FieldHTML: FC<
{
as: 'dd' | 'dt';
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
titleLength: number;
} & Omit<EmojiHTMLProps, 'htmlString'>
> = ({
as,
className,
extraEmojis,
text,
textEmojified,
textHasCustomEmoji,
titleLength,
onElement,
...props
}) => {
const [showAll, setShowAll] = useState(false);
const handleClick = useCallback(() => {
setShowAll((prev) => !prev);
}, []);
const handleElement: OnElementHandler = useCallback(
(element, props, children, extra) => {
if (element instanceof HTMLAnchorElement) {
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
if (textHasCustomEmoji) {
return (
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
{children}
</span>
);
}
return onElement?.(element, props, children, extra);
}
return undefined;
},
[onElement, textHasCustomEmoji],
);
return (
<EmojiHTML
as={as}
htmlString={textEmojified}
title={showTitleOnLength(text, titleLength)}
className={classNames(
className,
text && isValidUrl(text) && classes.fieldLink,
showAll && classes.fieldShowAll,
)}
onClick={handleClick}
onElement={handleElement}
{...props}
/>
);
};
function filterAttributesForSpan(props: Record<string, unknown>) {
const validAttributes: Record<string, unknown> = {};
for (const key of Object.keys(props)) {
if (key in htmlConfig.tags.span.attributes) {
validAttributes[key] = props[key];
}
}
return validAttributes;
}
function showTitleOnLength(value: string | null, maxLength: number) {
if (value && value.length > maxLength) {
return value;
}
return undefined;
}

View File

@@ -192,115 +192,80 @@ svg.badgeIcon {
}
}
.fieldWrapper {
margin-top: 16px;
width: 100%;
position: relative;
}
.fieldWrapper::before,
.fieldWrapper::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 40px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
z-index: 1;
}
.fieldWrapper::before {
left: 0;
background: linear-gradient(
to left,
transparent 0%,
var(--color-bg-primary) 100%
);
}
.fieldWrapper::after {
right: 0;
background: linear-gradient(
to right,
transparent 0%,
var(--color-bg-primary) 100%
);
}
.fieldWrapperLeft::before {
opacity: 1;
}
.fieldWrapperRight::after {
opacity: 1;
}
.fieldList {
display: flex;
flex-wrap: nowrap;
gap: 4px;
scroll-snap-type: x mandatory;
scroll-padding-left: 40px;
scroll-padding-right: 40px;
scroll-behavior: smooth;
overflow-x: scroll;
scrollbar-width: none;
overflow-y: visible;
}
display: grid;
grid-template-columns: 160px 1fr min-content;
column-gap: 12px;
margin: 4px 0 16px;
.fieldCard {
scroll-snap-align: start;
&:focus-visible,
&:focus-within {
outline: var(--outline-focus-default);
outline-offset: -2px;
}
:is(dt, dd) {
max-width: 200px;
@container (width < 420px) {
grid-template-columns: 100px 1fr min-content;
}
}
.fieldCardVerified {
.fieldRow {
display: grid;
grid-column: 1 / -1;
align-items: start;
grid-template-columns: subgrid;
padding: 0 4px;
> :is(dt, dd) {
margin: 8px 0;
&:not(.fieldShowAll) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
}
> dt {
color: var(--color-text-secondary);
}
&:not(.fieldVerified) > dd {
grid-column: span 2;
}
a {
font-weight: 500;
color: var(--color-text-brand);
text-decoration: none;
transition: 0.2s ease-in-out;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
}
}
}
.fieldVerified {
background-color: var(--color-bg-brand-softer);
}
.fieldArrowButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background-color: var(--color-bg-primary);
box-shadow: 0 1px 4px 0 var(--color-shadow-primary);
border-radius: 9999px;
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
outline-offset: 2px;
z-index: 2;
.fieldLink:is(dd, dt) {
margin: 0;
}
&:first-child {
left: 4px;
}
.fieldLink > a {
display: block;
padding: 8px 0;
}
&:last-child {
right: 4px;
}
&:hover,
&:focus,
&:focus-visible {
background-color: color-mix(
in oklab,
var(--color-bg-brand-base) var(--overlay-strength-brand),
var(--color-bg-primary)
);
}
.fieldVerifiedIcon {
width: 16px;
height: 16px;
margin-top: 8px;
}
.fieldNumbersWrapper {
padding: 0;
a {
font-weight: unset;
}
@@ -358,7 +323,11 @@ svg.badgeIcon {
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 12px;
padding: 0 24px;
padding: 0 12px;
@container (width >= 500px) {
padding: 0 24px;
}
a {
display: block;

View File

@@ -5,15 +5,23 @@ import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
const { layout } = useLayout();
if (isRedesignEnabled()) {
return (
<div className={classes.tabs}>
<NavLink isActive={isActive} to={`/@${acct}`}>
{layout !== 'single-column' && (
<NavLink exact to={`/@${acct}/about`}>
<FormattedMessage id='account.about' defaultMessage='About' />
</NavLink>
)}
<NavLink isActive={isActive} to={`/@${acct}/posts`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink>
<NavLink exact to={`/@${acct}/media`}>

View File

@@ -3,7 +3,6 @@ import { IDBFactory } from 'fake-indexeddb';
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import {
putEmojiData,
loadEmojiByHexcode,
@@ -12,8 +11,6 @@ import {
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
loadLatestEtag,
putLatestEtag,
} from './database';
function rawEmojiFactory(data: Partial<CompactEmoji> = {}): CompactEmoji {
@@ -120,36 +117,4 @@ describe('emoji database', () => {
).resolves.toEqual(data);
});
});
describe('loadLatestEtag', () => {
beforeEach(async () => {
await putLatestEtag('etag', 'en');
await putEmojiData([unicodeEmojiFactory()], 'en');
await putLatestEtag('fr-etag', 'fr');
});
test('retrieves the etag for loaded locale', async () => {
await putEmojiData(
[unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })],
'en',
);
const etag = await loadLatestEtag('en');
expect(etag).toBe('etag');
});
test('returns null if locale has no shortcodes', async () => {
const etag = await loadLatestEtag('en');
expect(etag).toBeNull();
});
test('returns null if locale not loaded', async () => {
const etag = await loadLatestEtag('de');
expect(etag).toBeNull();
});
test('returns null if locale has no data', async () => {
const etag = await loadLatestEtag('fr');
expect(etag).toBeNull();
});
});
});

View File

@@ -3,21 +3,16 @@ import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { openEmojiDB } from './db-schema';
import type { Database } from './db-schema';
import {
localeToSegmenter,
toSupportedLocale,
toSupportedLocaleOrCustom,
} from './locale';
import { localeToSegmenter, toSupportedLocale } from './locale';
import {
extractTokens,
skinHexcodeToEmoji,
transformCustomEmojiData,
transformEmojiData,
} from './normalize';
import type { AnyEmojiData, EtagTypes } from './types';
import type { AnyEmojiData, CacheKey } from './types';
import { emojiLogger } from './utils';
const loadedLocales = new Set<Locale>();
@@ -214,16 +209,21 @@ export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
await trx.done;
}
export async function putLatestEtag(etag: string, name: EtagTypes) {
export async function loadCacheValue(key: CacheKey) {
const db = await loadDB();
await db.put('etags', etag, name);
const value = await db.get('etags', key);
return value;
}
export async function clearEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
export async function putCacheValue(key: CacheKey, value: string) {
const db = await loadDB();
await db.delete('etags', locale);
log('Cleared etag for %s', locale);
await db.put('etags', value, key);
}
export async function clearCache(key: CacheKey) {
const db = await loadDB();
await db.delete('etags', key);
log('Cleared cache for %s', key);
}
export async function loadEmojiByHexcode(
@@ -276,26 +276,6 @@ export async function loadLegacyShortcodesByShortcode(shortcode: string) {
);
}
export async function loadLatestEtag(localeString: string) {
const locale = toSupportedLocaleOrCustom(localeString);
const db = await loadDB();
const rowCount = await db.count(locale);
if (!rowCount) {
return null; // No data for this locale, return null even if there is an etag.
}
// Check if shortcodes exist for the given Unicode locale.
if (locale !== 'custom') {
const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST);
if (!result?.shortcodes) {
return null;
}
}
const etag = await db.get('etags', locale);
return etag ?? null;
}
// Private functions
async function syncLocales(db: Database) {

View File

@@ -10,7 +10,7 @@ import type {
StoreNames,
} from 'idb';
import type { CustomEmojiData, EtagTypes, UnicodeEmojiData } from './types';
import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('database');
@@ -35,7 +35,7 @@ interface EmojiDB extends LocaleTables, DBSchema {
};
};
etags: {
key: EtagTypes;
key: CacheKey;
value: string;
};
}

View File

@@ -4,11 +4,11 @@ import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import {
putEmojiData,
putCustomEmojiData,
loadLatestEtag,
putLatestEtag,
putCacheValue,
putLegacyShortcodes,
loadCacheValue,
} from './database';
import { toSupportedLocale, toValidEtagName } from './locale';
import { toSupportedLocale, toValidCacheKey } from './locale';
import type { CustomEmojiData } from './types';
import { emojiLogger } from './utils';
@@ -23,8 +23,8 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
shortcodes ? ' and shortcodes' : '',
);
let emojis = await fetchAndCheckEtag<CompactEmoji[]>({
etagString: locale,
let emojis = await fetchIfNotLoaded<CompactEmoji[]>({
key: locale,
path: localeToEmojiPath(locale),
});
if (!emojis) {
@@ -33,8 +33,8 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
const shortcodesData: ShortcodesDataset[] = [];
if (shortcodes) {
const shortcodesResponse = await fetchAndCheckEtag<ShortcodesDataset>({
etagString: `${locale}-shortcodes`,
const shortcodesResponse = await fetchIfNotLoaded<ShortcodesDataset>({
key: `${locale}-shortcodes`,
path: localeToShortcodesPath(locale),
});
if (shortcodesResponse) {
@@ -51,13 +51,24 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>({
etagString: 'custom',
const response = await fetchAndCheckEtag({
oldEtag: await loadCacheValue('custom'),
path: '/api/v1/custom_emojis',
});
if (!emojis) {
if (!response) {
return;
}
const etag = response.headers.get('ETag');
if (etag) {
log('Custom emoji data fetched successfully, storing etag %s', etag);
await putCacheValue('custom', etag);
} else {
log('No etag found in response for custom emoji data');
}
const emojis = (await response.json()) as CustomEmojiData[];
await putCustomEmojiData({ emojis, clear: true });
return emojis;
}
@@ -72,9 +83,8 @@ export async function importLegacyShortcodes() {
if (!path) {
throw new Error('IAMCAL shortcodes path not found');
}
const shortcodesData = await fetchAndCheckEtag<ShortcodesDataset>({
checkEtag: true,
etagString: 'shortcodes',
const shortcodesData = await fetchIfNotLoaded<ShortcodesDataset>({
key: 'shortcodes',
path,
});
if (!shortcodesData) {
@@ -118,48 +128,64 @@ function localeToShortcodesPath(locale: Locale) {
return path;
}
async function fetchAndCheckEtag<ResultType extends object[] | object>({
etagString,
async function fetchIfNotLoaded<ResultType extends object[] | object>({
key: rawKey,
path,
checkEtag = false,
}: {
etagString: string;
key: string;
path: string;
checkEtag?: boolean;
}): Promise<ResultType | null> {
const etagName = toValidEtagName(etagString);
const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
const key = toValidCacheKey(rawKey);
const value = await loadCacheValue(key);
if (value === path) {
log('data for %s already loaded, skipping fetch', key);
return null;
}
const response = await fetchAndCheckEtag({ path });
if (!response) {
return null;
}
log('data for %s fetched successfully, storing etag', key);
await putCacheValue(key, path);
return (await response.json()) as ResultType;
}
async function fetchAndCheckEtag({
oldEtag,
path,
}: {
oldEtag?: string;
path: string;
}) {
const headers = new Headers({
'Content-Type': 'application/json',
});
if (oldEtag) {
headers.set('If-None-Match', oldEtag);
}
// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin);
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
},
headers,
});
// If not modified, return null
if (response.status === 304) {
log('etag not modified for %s', etagName);
log('etag not modified for %s', path);
return null;
}
if (!response.ok) {
throw new Error(
`Failed to fetch emoji data for ${etagName}: ${response.statusText}`,
`Failed to fetch emoji data for ${path}: ${response.statusText}`,
);
}
const data = (await response.json()) as ResultType;
// Store the ETag for future requests
const etag = response.headers.get('ETag');
if (etag && checkEtag) {
log(`storing new etag for ${etagName}: ${etag}`);
await putLatestEtag(etag, etagName);
} else if (!etag) {
log(`no etag found in response for ${etagName}`);
}
return data;
return response;
}

View File

@@ -2,7 +2,7 @@ import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase';
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
import type { EtagTypes, LocaleOrCustom, LocaleWithShortcodes } from './types';
import type { CacheKey, LocaleOrCustom, LocaleWithShortcodes } from './types';
export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase();
@@ -19,7 +19,7 @@ export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
return toSupportedLocale(locale);
}
export function toValidEtagName(input: string): EtagTypes {
export function toValidCacheKey(input: string): CacheKey {
const lower = input.toLowerCase();
if (lower === EMOJI_TYPE_CUSTOM || lower === EMOJI_DB_NAME_SHORTCODES) {
return lower;

View File

@@ -181,7 +181,7 @@ export function emojiToInversionClassName(emoji: string): string | null {
return null;
}
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg | null) {
if (!extraEmojis) {
return null;
}

View File

@@ -22,7 +22,7 @@ export type EmojiMode =
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
export type LocaleWithShortcodes = `${Locale}-shortcodes`;
export type EtagTypes =
export type CacheKey =
| LocaleOrCustom
| typeof EMOJI_DB_NAME_SHORTCODES
| LocaleWithShortcodes;

View File

@@ -78,6 +78,7 @@ import {
PrivacyPolicy,
TermsOfService,
AccountFeatured,
AccountAbout,
Quotes,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
@@ -88,6 +89,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
import { areCollectionsEnabled } from '../collections/utils';
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@@ -109,6 +111,7 @@ class SwitchingColumnsArea extends PureComponent {
children: PropTypes.node,
location: PropTypes.object,
singleColumn: PropTypes.bool,
layout: PropTypes.string.isRequired,
forceOnboarding: PropTypes.bool,
};
@@ -159,6 +162,37 @@ class SwitchingColumnsArea extends PureComponent {
redirect = <Redirect from='/' to='/about' exact />;
}
const profileRedesignEnabled = isClientFeatureEnabled('profile_redesign');
const profileRedesignRoutes = [];
if (profileRedesignEnabled) {
profileRedesignRoutes.push(
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
);
// Check if we're in single-column mode. Confusingly, the singleColumn prop includes mobile.
if (this.props.layout === 'single-column') {
// When in single column mode (desktop w/o advanced view), redirect both the root and about to the posts tab.
profileRedesignRoutes.push(
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/posts' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/posts' exact />,
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct/posts' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id/posts' exact />,
);
} else {
// Otherwise, provide and redirect to the /about page.
profileRedesignRoutes.push(
<WrappedRoute key="about" path={['/@:acct/about', '/accounts/:id/about']} component={AccountAbout} content={children} />,
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/about' exact />,
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/about' exact />
);
}
} else {
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
profileRedesignRoutes.push(
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' exact />
);
}
return (
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
@@ -205,7 +239,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/search' component={Search} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
{!profileRedesignEnabled && <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />}
{...profileRedesignRoutes}
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
@@ -235,7 +270,7 @@ class SwitchingColumnsArea extends PureComponent {
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
}
<Route component={BundleColumnError} />
</WrappedSwitch>
@@ -591,7 +626,13 @@ class UI extends PureComponent {
return (
<Hotkeys global handlers={handlers}>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
<SwitchingColumnsArea
identity={this.props.identity}
location={location}
singleColumn={layout === 'mobile' || layout === 'single-column'}
layout={layout}
forceOnboarding={firstLaunch && newAccount}
>
{children}
</SwitchingColumnsArea>

View File

@@ -87,6 +87,11 @@ export function AccountFeatured() {
return import('../../account_featured');
}
export function AccountAbout() {
return import('../../account_about')
.then((module) => ({ default: module.AccountAbout }));
}
export function Followers () {
return import('../../followers');
}

View File

@@ -4,19 +4,41 @@ import { useParams } from 'react-router';
import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from 'mastodon/store';
interface Params {
acct?: string;
id?: string;
}
export const useAccountId = () => {
const selectNormalizedId = createAppSelector(
[
(state) => state.accounts_map,
(_, acct?: string) => acct,
(_, _acct, id?: string) => id,
],
(accountsMap, acct, id) => {
if (id) {
return id;
}
if (acct) {
return accountsMap[normalizeForLookup(acct)];
}
return undefined;
},
);
export type AccountId = string | null | undefined;
export function useAccountId() {
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch();
const accountId = useAppSelector(
(state) =>
id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined),
const accountId = useAppSelector((state) =>
selectNormalizedId(state, acct, id),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
@@ -31,5 +53,5 @@ export const useAccountId = () => {
}
}, [dispatch, accountId, acct, accountInStore]);
return accountId;
};
return accountId satisfies AccountId;
}

View File

@@ -13,6 +13,7 @@
"about.not_available": "This information has not been made available on this server.",
"about.powered_by": "Decentralized social media powered by {mastodon}",
"about.rules": "Server rules",
"account.about": "About",
"account.account_note_header": "Personal note",
"account.activity": "Activity",
"account.add_note": "Add a personal note",
@@ -47,8 +48,6 @@
"account.featured.hashtags": "Hashtags",
"account.featured_tags.last_status_at": "Last post on {date}",
"account.featured_tags.last_status_never": "No posts",
"account.fields.scroll_next": "Show next",
"account.fields.scroll_prev": "Show previous",
"account.filters.all": "All activity",
"account.filters.boosts_toggle": "Show boosts",
"account.filters.posts_boosts": "Posts and boosts",
@@ -454,6 +453,8 @@
"emoji_button.search_results": "Search results",
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"empty_column.account_about.me": "You have not added any information about yourself yet.",
"empty_column.account_about.other": "{acct} has not added any information about themselves yet.",
"empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?",
"empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?",
"empty_column.account_featured_other.unknown": "This account has not featured anything yet.",

View File

@@ -17,7 +17,7 @@
%br/
= f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
= policy_list(f.object)
- if f.object.public_comment.present?
·
= f.object.public_comment

View File

@@ -6,7 +6,7 @@
%small
- if instance.domain_block
= instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
= policy_list(instance.domain_block)
- if instance.domain_block.public_comment.present?
%span.comment.public-comment #{t('admin.domain_blocks.public_comment')}: #{instance.domain_block.public_comment}
- if instance.domain_block.private_comment.present?

View File

@@ -37,7 +37,7 @@
%td= @instance.domain_block.public_comment
%tr
%th= t('admin.instances.content_policies.policy')
%td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
%td= policy_list(@instance.domain_block)
= link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button'
= link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }

View File

@@ -155,7 +155,9 @@ Rails.application.routes.draw do
constraints(username: %r{[^@/.]+}) do
with_options to: 'accounts#show' do
get '/@:username', as: :short_account
get '/@:username/posts'
get '/@:username/featured'
get '/@:username/about'
get '/@:username/with_replies', as: :short_account_with_replies
get '/@:username/media', as: :short_account_media
get '/@:username/tagged/:tag', as: :short_account_tag

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Admin::ContentPoliciesHelper do
describe '#policy_list' do
subject { helper.policy_list(domain_block) }
context 'when severity is suspend' do
let(:domain_block) { Fabricate.build :domain_block, severity: :suspend }
it { is_expected.to eq('Suspend') }
end
context 'when severity is silence' do
let(:domain_block) { Fabricate.build :domain_block, severity: :silence, reject_reports: true }
it { is_expected.to eq('Limit · Reject reports') }
end
end
end