mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge commit 'a44a3f6d4047568921469ff9fbd212f553b1e7f4' into glitch-soc/merge-upstream
This commit is contained in:
61
app/javascript/config/html-tags.json
Normal file
61
app/javascript/config/html-tags.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"class": "className",
|
||||||
|
"id": true,
|
||||||
|
"title": true,
|
||||||
|
"dir": true,
|
||||||
|
"lang": true
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"p": {},
|
||||||
|
"br": {
|
||||||
|
"children": false
|
||||||
|
},
|
||||||
|
"span": {
|
||||||
|
"attributes": {
|
||||||
|
"translate": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"attributes": {
|
||||||
|
"href": true,
|
||||||
|
"rel": true,
|
||||||
|
"translate": true,
|
||||||
|
"target": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"del": {},
|
||||||
|
"s": {},
|
||||||
|
"pre": {},
|
||||||
|
"blockquote": {},
|
||||||
|
"code": {},
|
||||||
|
"b": {},
|
||||||
|
"strong": {},
|
||||||
|
"u": {},
|
||||||
|
"i": {},
|
||||||
|
"img": {
|
||||||
|
"children": false,
|
||||||
|
"attributes": {
|
||||||
|
"src": true,
|
||||||
|
"alt": true,
|
||||||
|
"title": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"em": {},
|
||||||
|
"ul": {},
|
||||||
|
"ol": {
|
||||||
|
"attributes": {
|
||||||
|
"start": true,
|
||||||
|
"reversed": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"li": {
|
||||||
|
"attributes": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ruby": {},
|
||||||
|
"rt": {},
|
||||||
|
"rp": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,24 +5,61 @@ import { useIntl, defineMessages } from 'react-intl';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useIdentity } from '@/mastodon/identity_context';
|
import { useIdentity } from '@/mastodon/identity_context';
|
||||||
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
|
import {
|
||||||
|
fetchRelationships,
|
||||||
|
followAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'mastodon/actions/accounts';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
import { useBreakpoint } from '../features/ui/hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const longMessages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||||
|
followRequest: {
|
||||||
|
id: 'account.follow_request',
|
||||||
|
defaultMessage: 'Request to follow',
|
||||||
|
},
|
||||||
|
followRequestCancel: {
|
||||||
|
id: 'account.follow_request_cancel',
|
||||||
|
defaultMessage: 'Cancel request',
|
||||||
|
},
|
||||||
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shortMessages = {
|
||||||
|
...longMessages, // Align type signature of shortMessages and longMessages
|
||||||
|
...defineMessages({
|
||||||
|
followBack: {
|
||||||
|
id: 'account.follow_back_short',
|
||||||
|
defaultMessage: 'Follow back',
|
||||||
|
},
|
||||||
|
followRequest: {
|
||||||
|
id: 'account.follow_request_short',
|
||||||
|
defaultMessage: 'Request',
|
||||||
|
},
|
||||||
|
followRequestCancel: {
|
||||||
|
id: 'account.follow_request_cancel_short',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
},
|
||||||
|
editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const FollowButton: React.FC<{
|
export const FollowButton: React.FC<{
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}> = ({ accountId, compact }) => {
|
labelLength?: 'auto' | 'short' | 'long';
|
||||||
|
}> = ({ accountId, compact, labelLength = 'auto' }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { signedIn } = useIdentity();
|
const { signedIn } = useIdentity();
|
||||||
@@ -57,29 +94,48 @@ export const FollowButton: React.FC<{
|
|||||||
|
|
||||||
if (accountId === me) {
|
if (accountId === me) {
|
||||||
return;
|
return;
|
||||||
|
} else if (relationship.muting) {
|
||||||
|
dispatch(unmuteAccount(accountId));
|
||||||
} else if (account && (relationship.following || relationship.requested)) {
|
} else if (account && (relationship.following || relationship.requested)) {
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||||
);
|
);
|
||||||
|
} else if (relationship.blocking) {
|
||||||
|
dispatch(unblockAccount(accountId));
|
||||||
} else {
|
} else {
|
||||||
dispatch(followAccount(accountId));
|
dispatch(followAccount(accountId));
|
||||||
}
|
}
|
||||||
}, [dispatch, accountId, relationship, account, signedIn]);
|
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||||
|
|
||||||
|
const isNarrow = useBreakpoint('narrow');
|
||||||
|
const useShortLabel =
|
||||||
|
labelLength === 'short' || (labelLength === 'auto' && isNarrow);
|
||||||
|
const messages = useShortLabel ? shortMessages : longMessages;
|
||||||
|
|
||||||
|
const followMessage = account?.locked
|
||||||
|
? messages.followRequest
|
||||||
|
: messages.follow;
|
||||||
|
|
||||||
let label;
|
let label;
|
||||||
|
|
||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
label = intl.formatMessage(messages.follow);
|
label = intl.formatMessage(followMessage);
|
||||||
} else if (accountId === me) {
|
} else if (accountId === me) {
|
||||||
label = intl.formatMessage(messages.editProfile);
|
label = intl.formatMessage(messages.editProfile);
|
||||||
} else if (!relationship) {
|
} else if (!relationship) {
|
||||||
label = <LoadingIndicator />;
|
label = <LoadingIndicator />;
|
||||||
} else if (relationship.following || relationship.requested) {
|
} else if (relationship.muting) {
|
||||||
|
label = intl.formatMessage(messages.unmute);
|
||||||
|
} else if (relationship.following) {
|
||||||
label = intl.formatMessage(messages.unfollow);
|
label = intl.formatMessage(messages.unfollow);
|
||||||
} else if (relationship.followed_by) {
|
} else if (relationship.blocking) {
|
||||||
|
label = intl.formatMessage(messages.unblock);
|
||||||
|
} else if (relationship.requested) {
|
||||||
|
label = intl.formatMessage(messages.followRequestCancel);
|
||||||
|
} else if (relationship.followed_by && !account?.locked) {
|
||||||
label = intl.formatMessage(messages.followBack);
|
label = intl.formatMessage(messages.followBack);
|
||||||
} else {
|
} else {
|
||||||
label = intl.formatMessage(messages.follow);
|
label = intl.formatMessage(followMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountId === me) {
|
if (accountId === me) {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect } from 'storybook/test';
|
||||||
|
|
||||||
|
import { HTMLBlock } from './index';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/HTMLBlock',
|
||||||
|
component: HTMLBlock,
|
||||||
|
args: {
|
||||||
|
contents:
|
||||||
|
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||||
|
},
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
// Just for visual clarity in Storybook.
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid black',
|
||||||
|
padding: '1rem',
|
||||||
|
minWidth: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HTMLBlock {...args} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof HTMLBlock>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
async play({ canvas }) {
|
||||||
|
const link = canvas.queryByRole('link');
|
||||||
|
await expect(link).toBeInTheDocument();
|
||||||
|
const button = canvas.queryByRole('button');
|
||||||
|
await expect(button).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
50
app/javascript/mastodon/components/html_block/index.tsx
Normal file
50
app/javascript/mastodon/components/html_block/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||||
|
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||||
|
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||||
|
|
||||||
|
import { htmlStringToComponents } from '../../utils/html';
|
||||||
|
|
||||||
|
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||||
|
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||||
|
|
||||||
|
interface HTMLBlockProps {
|
||||||
|
contents: string;
|
||||||
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||||
|
contents: raw,
|
||||||
|
extraEmojis,
|
||||||
|
}) => {
|
||||||
|
const customEmojis = useMemo(
|
||||||
|
() => cleanExtraEmojis(extraEmojis),
|
||||||
|
[extraEmojis],
|
||||||
|
);
|
||||||
|
const contents = useMemo(() => {
|
||||||
|
const key = JSON.stringify({ raw, customEmojis });
|
||||||
|
if (cache.has(key)) {
|
||||||
|
return cache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = htmlStringToComponents(raw, {
|
||||||
|
onText,
|
||||||
|
extraArgs: { customEmojis },
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.set(key, rendered);
|
||||||
|
return rendered;
|
||||||
|
}, [raw, customEmojis]);
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onText(
|
||||||
|
text: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||||
|
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
@@ -1,134 +1,23 @@
|
|||||||
import { useCallback } from 'react';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import {
|
|
||||||
followAccount,
|
|
||||||
unblockAccount,
|
|
||||||
unmuteAccount,
|
|
||||||
} from 'mastodon/actions/accounts';
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { autoPlayGif, me } from 'mastodon/initial_state';
|
import { autoPlayGif } from 'mastodon/initial_state';
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
import { makeGetAccount } from 'mastodon/selectors';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
cancel_follow_request: {
|
|
||||||
id: 'account.cancel_follow_request',
|
|
||||||
defaultMessage: 'Withdraw follow request',
|
|
||||||
},
|
|
||||||
requested: {
|
|
||||||
id: 'account.requested',
|
|
||||||
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
|
||||||
},
|
|
||||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const account = useAppSelector((s) => getAccount(s, accountId));
|
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (!account) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
account.getIn(['relationship', 'following']) ||
|
|
||||||
account.getIn(['relationship', 'requested'])
|
|
||||||
) {
|
|
||||||
dispatch(
|
|
||||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
}, [account, dispatch]);
|
|
||||||
|
|
||||||
const handleBlock = useCallback(() => {
|
|
||||||
if (account?.relationship?.blocking) {
|
|
||||||
dispatch(unblockAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
}, [account, dispatch]);
|
|
||||||
|
|
||||||
const handleMute = useCallback(() => {
|
|
||||||
if (account?.relationship?.muting) {
|
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
}, [account, dispatch]);
|
|
||||||
|
|
||||||
const handleEditProfile = useCallback(() => {
|
|
||||||
window.open('/settings/profile', '_blank');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
let actionBtn;
|
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
|
||||||
if (!account.get('relationship')) {
|
|
||||||
// Wait until the relationship is loaded
|
|
||||||
actionBtn = '';
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.cancel_follow_request)}
|
|
||||||
title={intl.formatMessage(messages.requested)}
|
|
||||||
onClick={handleFollow}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.unmute)}
|
|
||||||
onClick={handleMute}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
disabled={account.relationship?.blocked_by}
|
|
||||||
className={classNames({
|
|
||||||
'button--destructive': account.getIn(['relationship', 'following']),
|
|
||||||
})}
|
|
||||||
text={intl.formatMessage(
|
|
||||||
account.getIn(['relationship', 'following'])
|
|
||||||
? messages.unfollow
|
|
||||||
: messages.follow,
|
|
||||||
)}
|
|
||||||
onClick={handleFollow}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.unblock)}
|
|
||||||
onClick={handleBlock}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.edit_profile)}
|
|
||||||
onClick={handleEditProfile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-card'>
|
<div className='account-card'>
|
||||||
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
||||||
@@ -186,7 +75,9 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account-card__actions__button'>{actionBtn}</div>
|
<div className='account-card__actions__button'>
|
||||||
|
<FollowButton accountId={account.get('id')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { isList } from 'immutable';
|
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
|
||||||
import { useAppSelector } from '@/mastodon/store';
|
import { useAppSelector } from '@/mastodon/store';
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
|
import { cleanExtraEmojis } from './normalize';
|
||||||
import { emojifyElement, emojifyText } from './render';
|
import { emojifyElement, emojifyText } from './render';
|
||||||
import type {
|
import type { CustomEmojiMapArg, EmojiAppState } from './types';
|
||||||
CustomEmojiMapArg,
|
|
||||||
EmojiAppState,
|
|
||||||
ExtraCustomEmojiMap,
|
|
||||||
} from './types';
|
|
||||||
import { stringHasAnyEmoji } from './utils';
|
import { stringHasAnyEmoji } from './utils';
|
||||||
|
|
||||||
interface UseEmojifyOptions {
|
interface UseEmojifyOptions {
|
||||||
@@ -30,20 +24,7 @@ export function useEmojify({
|
|||||||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
const appState = useEmojiAppState();
|
const appState = useEmojiAppState();
|
||||||
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]);
|
||||||
if (!extraEmojis) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (isList(extraEmojis)) {
|
|
||||||
return (
|
|
||||||
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
|
||||||
).reduce<ExtraCustomEmojiMap>(
|
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return extraEmojis;
|
|
||||||
}, [extraEmojis]);
|
|
||||||
|
|
||||||
const emojify = useCallback(
|
const emojify = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
@@ -51,11 +32,11 @@ export function useEmojify({
|
|||||||
if (deep) {
|
if (deep) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = input;
|
wrapper.innerHTML = input;
|
||||||
if (await emojifyElement(wrapper, appState, extra)) {
|
if (await emojifyElement(wrapper, appState, extra ?? {})) {
|
||||||
result = wrapper.innerHTML;
|
result = wrapper.innerHTML;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await emojifyText(text, appState, extra);
|
result = await emojifyText(text, appState, extra ?? {});
|
||||||
}
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
setEmojifiedText(result);
|
setEmojifiedText(result);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isList } from 'immutable';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
VARIATION_SELECTOR_CODE,
|
VARIATION_SELECTOR_CODE,
|
||||||
KEYCAP_CODE,
|
KEYCAP_CODE,
|
||||||
@@ -7,7 +9,11 @@ import {
|
|||||||
EMOJIS_WITH_DARK_BORDER,
|
EMOJIS_WITH_DARK_BORDER,
|
||||||
EMOJIS_WITH_LIGHT_BORDER,
|
EMOJIS_WITH_LIGHT_BORDER,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import type { TwemojiBorderInfo } from './types';
|
import type {
|
||||||
|
CustomEmojiMapArg,
|
||||||
|
ExtraCustomEmojiMap,
|
||||||
|
TwemojiBorderInfo,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// Misc codes that have special handling
|
// Misc codes that have special handling
|
||||||
const SKIER_CODE = 0x26f7;
|
const SKIER_CODE = 0x26f7;
|
||||||
@@ -150,6 +156,21 @@ export function twemojiToUnicodeInfo(
|
|||||||
return hexNumbersToString(mappedCodes);
|
return hexNumbersToString(mappedCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
if (!extraEmojis) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isList(extraEmojis)) {
|
||||||
|
return extraEmojis;
|
||||||
|
}
|
||||||
|
return extraEmojis
|
||||||
|
.toJSON()
|
||||||
|
.reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hexStringToNumbers(hexString: string): number[] {
|
function hexStringToNumbers(hexString: string): number[] {
|
||||||
return hexString
|
return hexString
|
||||||
.split('-')
|
.split('-')
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import { domain } from 'mastodon/initial_state';
|
|||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
dismiss: {
|
dismiss: {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
const breakpoints = {
|
const breakpoints = {
|
||||||
|
narrow: 479, // Device width under which horizontal space is constrained
|
||||||
openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
|
openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
|
||||||
full: 1174, // Device width at which all 3 columns can be displayed
|
full: 1174, // Device width at which all 3 columns can be displayed
|
||||||
};
|
};
|
||||||
|
|
||||||
type Breakpoint = 'openable' | 'full';
|
type Breakpoint = keyof typeof breakpoints;
|
||||||
|
|
||||||
export const useBreakpoint = (breakpoint: Breakpoint) => {
|
export const useBreakpoint = (breakpoint: Breakpoint) => {
|
||||||
const [isMatching, setIsMatching] = useState(false);
|
const [isMatching, setIsMatching] = useState(false);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"account.disable_notifications": "Stop notifying me when @{name} posts",
|
"account.disable_notifications": "Stop notifying me when @{name} posts",
|
||||||
"account.domain_blocking": "Blocking domain",
|
"account.domain_blocking": "Blocking domain",
|
||||||
"account.edit_profile": "Edit profile",
|
"account.edit_profile": "Edit profile",
|
||||||
|
"account.edit_profile_short": "Edit",
|
||||||
"account.enable_notifications": "Notify me when @{name} posts",
|
"account.enable_notifications": "Notify me when @{name} posts",
|
||||||
"account.endorse": "Feature on profile",
|
"account.endorse": "Feature on profile",
|
||||||
"account.familiar_followers_many": "Followed by {name1}, {name2}, and {othersCount, plural, one {one other you know} other {# others you know}}",
|
"account.familiar_followers_many": "Followed by {name1}, {name2}, and {othersCount, plural, one {one other you know} other {# others you know}}",
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
"account.featured_tags.last_status_never": "No posts",
|
"account.featured_tags.last_status_never": "No posts",
|
||||||
"account.follow": "Follow",
|
"account.follow": "Follow",
|
||||||
"account.follow_back": "Follow back",
|
"account.follow_back": "Follow back",
|
||||||
|
"account.follow_back_short": "Follow back",
|
||||||
|
"account.follow_request": "Request to follow",
|
||||||
|
"account.follow_request_cancel": "Cancel request",
|
||||||
|
"account.follow_request_cancel_short": "Cancel",
|
||||||
|
"account.follow_request_short": "Request",
|
||||||
"account.followers": "Followers",
|
"account.followers": "Followers",
|
||||||
"account.followers.empty": "No one follows this user yet.",
|
"account.followers.empty": "No one follows this user yet.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
|
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
|
||||||
@@ -70,7 +76,6 @@
|
|||||||
"account.posts_with_replies": "Posts and replies",
|
"account.posts_with_replies": "Posts and replies",
|
||||||
"account.remove_from_followers": "Remove {name} from followers",
|
"account.remove_from_followers": "Remove {name} from followers",
|
||||||
"account.report": "Report @{name}",
|
"account.report": "Report @{name}",
|
||||||
"account.requested": "Awaiting approval. Click to cancel follow request",
|
|
||||||
"account.requested_follow": "{name} has requested to follow you",
|
"account.requested_follow": "{name} has requested to follow you",
|
||||||
"account.requests_to_follow_you": "Requests to follow you",
|
"account.requests_to_follow_you": "Requests to follow you",
|
||||||
"account.share": "Share @{name}'s profile",
|
"account.share": "Share @{name}'s profile",
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ exports[`html > htmlStringToComponents > handles nested elements 1`] = `
|
|||||||
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
|
exports[`html > htmlStringToComponents > ignores empty text nodes 1`] = `
|
||||||
[
|
[
|
||||||
<p>
|
<p>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
lorem ipsum
|
lorem ipsum
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</p>,
|
</p>,
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
@@ -37,6 +39,7 @@ exports[`html > htmlStringToComponents > respects allowedTags option 1`] = `
|
|||||||
[
|
[
|
||||||
<p>
|
<p>
|
||||||
lorem
|
lorem
|
||||||
|
|
||||||
<em>
|
<em>
|
||||||
dolor
|
dolor
|
||||||
</em>
|
</em>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('html', () => {
|
|||||||
const input = '<p>lorem ipsum</p>';
|
const input = '<p>lorem ipsum</p>';
|
||||||
const onText = vi.fn((text: string) => text);
|
const onText = vi.fn((text: string) => text);
|
||||||
html.htmlStringToComponents(input, { onText });
|
html.htmlStringToComponents(input, { onText });
|
||||||
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum');
|
expect(onText).toHaveBeenCalledExactlyOnceWith('lorem ipsum', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onElement callback', () => {
|
it('calls onElement callback', () => {
|
||||||
@@ -61,6 +61,7 @@ describe('html', () => {
|
|||||||
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
||||||
expect.objectContaining({ tagName: 'P' }),
|
expect.objectContaining({ tagName: 'P' }),
|
||||||
expect.arrayContaining(['lorem ipsum']),
|
expect.arrayContaining(['lorem ipsum']),
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ describe('html', () => {
|
|||||||
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
expect(onElement).toHaveBeenCalledExactlyOnceWith(
|
||||||
expect.objectContaining({ tagName: 'P' }),
|
expect.objectContaining({ tagName: 'P' }),
|
||||||
expect.arrayContaining(['lorem ipsum']),
|
expect.arrayContaining(['lorem ipsum']),
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(output).toMatchSnapshot();
|
expect(output).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
@@ -88,15 +90,16 @@ describe('html', () => {
|
|||||||
'href',
|
'href',
|
||||||
'https://example.com',
|
'https://example.com',
|
||||||
'a',
|
'a',
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a');
|
expect(onAttribute).toHaveBeenCalledWith('target', '_blank', 'a', {});
|
||||||
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a');
|
expect(onAttribute).toHaveBeenCalledWith('rel', 'nofollow', 'a', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respects allowedTags option', () => {
|
it('respects allowedTags option', () => {
|
||||||
const input = '<p>lorem <strong>ipsum</strong> <em>dolor</em></p>';
|
const input = '<p>lorem <strong>ipsum</strong> <em>dolor</em></p>';
|
||||||
const output = html.htmlStringToComponents(input, {
|
const output = html.htmlStringToComponents(input, {
|
||||||
allowedTags: new Set(['p', 'em']),
|
allowedTags: { p: {}, em: {} },
|
||||||
});
|
});
|
||||||
expect(output).toMatchSnapshot();
|
expect(output).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import htmlConfig from '../../config/html-tags.json';
|
||||||
|
|
||||||
// NB: This function can still return unsafe HTML
|
// NB: This function can still return unsafe HTML
|
||||||
export const unescapeHTML = (html: string) => {
|
export const unescapeHTML = (html: string) => {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
@@ -10,64 +12,49 @@ export const unescapeHTML = (html: string) => {
|
|||||||
return wrapper.textContent;
|
return wrapper.textContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AllowedTag {
|
||||||
|
/* True means allow, false disallows global attributes, string renames the attribute name for React. */
|
||||||
|
attributes?: Record<string, boolean | string>;
|
||||||
|
/* If false, the tag cannot have children. Undefined or true means allowed. */
|
||||||
|
children?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllowedTagsType = {
|
||||||
|
[Tag in keyof React.ReactHTML]?: AllowedTag;
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalAttributes: Record<string, boolean | string> = htmlConfig.global;
|
||||||
|
const defaultAllowedTags: AllowedTagsType = htmlConfig.tags;
|
||||||
|
|
||||||
interface QueueItem {
|
interface QueueItem {
|
||||||
node: Node;
|
node: Node;
|
||||||
parent: React.ReactNode[];
|
parent: React.ReactNode[];
|
||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Options {
|
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> {
|
||||||
maxDepth?: number;
|
maxDepth?: number;
|
||||||
onText?: (text: string) => React.ReactNode;
|
onText?: (text: string, extra: Arg) => React.ReactNode;
|
||||||
onElement?: (
|
onElement?: (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
children: React.ReactNode[],
|
children: React.ReactNode[],
|
||||||
|
extra: Arg,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
onAttribute?: (
|
onAttribute?: (
|
||||||
name: string,
|
name: string,
|
||||||
value: string,
|
value: string,
|
||||||
tagName: string,
|
tagName: string,
|
||||||
|
extra: Arg,
|
||||||
) => [string, unknown] | null;
|
) => [string, unknown] | null;
|
||||||
allowedTags?: Set<string>;
|
allowedTags?: AllowedTagsType;
|
||||||
|
extraArgs?: Arg;
|
||||||
}
|
}
|
||||||
const DEFAULT_ALLOWED_TAGS: ReadonlySet<string> = new Set([
|
|
||||||
'a',
|
|
||||||
'abbr',
|
|
||||||
'b',
|
|
||||||
'blockquote',
|
|
||||||
'br',
|
|
||||||
'cite',
|
|
||||||
'code',
|
|
||||||
'del',
|
|
||||||
'dfn',
|
|
||||||
'dl',
|
|
||||||
'dt',
|
|
||||||
'em',
|
|
||||||
'h1',
|
|
||||||
'h2',
|
|
||||||
'h3',
|
|
||||||
'h4',
|
|
||||||
'h5',
|
|
||||||
'h6',
|
|
||||||
'hr',
|
|
||||||
'i',
|
|
||||||
'li',
|
|
||||||
'ol',
|
|
||||||
'p',
|
|
||||||
'pre',
|
|
||||||
'small',
|
|
||||||
'span',
|
|
||||||
'strong',
|
|
||||||
'sub',
|
|
||||||
'sup',
|
|
||||||
'time',
|
|
||||||
'u',
|
|
||||||
'ul',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function htmlStringToComponents(
|
let uniqueIdCounter = 0;
|
||||||
|
|
||||||
|
export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
||||||
htmlString: string,
|
htmlString: string,
|
||||||
options: Options = {},
|
options: HTMLToStringOptions<Arg> = {},
|
||||||
) {
|
) {
|
||||||
const wrapper = document.createElement('template');
|
const wrapper = document.createElement('template');
|
||||||
wrapper.innerHTML = htmlString;
|
wrapper.innerHTML = htmlString;
|
||||||
@@ -79,10 +66,11 @@ export function htmlStringToComponents(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
maxDepth = 10,
|
maxDepth = 10,
|
||||||
allowedTags = DEFAULT_ALLOWED_TAGS,
|
allowedTags = defaultAllowedTags,
|
||||||
onAttribute,
|
onAttribute,
|
||||||
onElement,
|
onElement,
|
||||||
onText,
|
onText,
|
||||||
|
extraArgs = {} as Arg,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -109,9 +97,9 @@ export function htmlStringToComponents(
|
|||||||
// Text can be added directly if it has any non-whitespace content.
|
// Text can be added directly if it has any non-whitespace content.
|
||||||
case Node.TEXT_NODE: {
|
case Node.TEXT_NODE: {
|
||||||
const text = node.textContent;
|
const text = node.textContent;
|
||||||
if (text && text.trim() !== '') {
|
if (text) {
|
||||||
if (onText) {
|
if (onText) {
|
||||||
parent.push(onText(text));
|
parent.push(onText(text, extraArgs));
|
||||||
} else {
|
} else {
|
||||||
parent.push(text);
|
parent.push(text);
|
||||||
}
|
}
|
||||||
@@ -127,7 +115,9 @@ export function htmlStringToComponents(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If the tag is not allowed, skip it and its children.
|
// If the tag is not allowed, skip it and its children.
|
||||||
if (!allowedTags.has(node.tagName.toLowerCase())) {
|
const tagName = node.tagName.toLowerCase();
|
||||||
|
const tagInfo = allowedTags[tagName as keyof typeof allowedTags];
|
||||||
|
if (!tagInfo) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +127,8 @@ export function htmlStringToComponents(
|
|||||||
|
|
||||||
// If onElement is provided, use it to create the element.
|
// If onElement is provided, use it to create the element.
|
||||||
if (onElement) {
|
if (onElement) {
|
||||||
const component = onElement(node, children);
|
const component = onElement(node, children, extraArgs);
|
||||||
|
|
||||||
// Check for undefined to allow returning null.
|
// Check for undefined to allow returning null.
|
||||||
if (component !== undefined) {
|
if (component !== undefined) {
|
||||||
element = component;
|
element = component;
|
||||||
@@ -147,25 +138,56 @@ export function htmlStringToComponents(
|
|||||||
// If the element wasn't created, use the default conversion.
|
// If the element wasn't created, use the default conversion.
|
||||||
if (element === undefined) {
|
if (element === undefined) {
|
||||||
const props: Record<string, unknown> = {};
|
const props: Record<string, unknown> = {};
|
||||||
|
props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
|
||||||
for (const attr of node.attributes) {
|
for (const attr of node.attributes) {
|
||||||
|
let name = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Custom attribute handler.
|
||||||
if (onAttribute) {
|
if (onAttribute) {
|
||||||
const result = onAttribute(
|
const result = onAttribute(
|
||||||
attr.name,
|
name,
|
||||||
attr.value,
|
attr.value,
|
||||||
node.tagName.toLowerCase(),
|
node.tagName.toLowerCase(),
|
||||||
|
extraArgs,
|
||||||
);
|
);
|
||||||
if (result) {
|
if (result) {
|
||||||
const [name, value] = result;
|
const [cbName, value] = result;
|
||||||
props[name] = value;
|
props[cbName] = value;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
props[attr.name] = attr.value;
|
// Check global attributes first, then tag-specific ones.
|
||||||
|
const globalAttr = globalAttributes[name];
|
||||||
|
const tagAttr = tagInfo.attributes?.[name];
|
||||||
|
|
||||||
|
// Exit if neither global nor tag-specific attribute is allowed.
|
||||||
|
if (!globalAttr && !tagAttr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename if needed.
|
||||||
|
if (typeof tagAttr === 'string') {
|
||||||
|
name = tagAttr;
|
||||||
|
} else if (typeof globalAttr === 'string') {
|
||||||
|
name = globalAttr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: string | boolean | number = attr.value;
|
||||||
|
|
||||||
|
// Handle boolean attributes.
|
||||||
|
if (value === 'true') {
|
||||||
|
value = true;
|
||||||
|
} else if (value === 'false') {
|
||||||
|
value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
props[name] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
element = React.createElement(
|
element = React.createElement(
|
||||||
node.tagName.toLowerCase(),
|
tagName,
|
||||||
props,
|
props,
|
||||||
children,
|
tagInfo.children !== false ? children : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ RSpec.describe ActivityPub::TagManager do
|
|||||||
|
|
||||||
subject { described_class.instance }
|
subject { described_class.instance }
|
||||||
|
|
||||||
let(:domain) { "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}" }
|
let(:host_prefix) { "#{Rails.configuration.x.use_https ? 'https' : 'http'}://#{Rails.configuration.x.web_domain}" }
|
||||||
|
|
||||||
describe '#public_collection?' do
|
describe '#public_collection?' do
|
||||||
it 'returns true for the special public collection and common shorthands' do
|
it 'returns true for the special public collection and common shorthands' do
|
||||||
@@ -22,18 +22,123 @@ RSpec.describe ActivityPub::TagManager do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '#url_for' do
|
describe '#url_for' do
|
||||||
it 'returns a string starting with web domain' do
|
context 'with a local account' do
|
||||||
account = Fabricate(:account)
|
let(:account) { Fabricate(:account) }
|
||||||
expect(subject.url_for(account)).to be_a(String)
|
|
||||||
.and start_with(domain)
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.url_for(account))
|
||||||
|
.to eq("#{host_prefix}/@#{account.username}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a remote account' do
|
||||||
|
let(:account) { Fabricate(:account, domain: 'example.com', url: 'https://example.com/profiles/dskjfsdf') }
|
||||||
|
|
||||||
|
it 'returns the expected URL' do
|
||||||
|
expect(subject.url_for(account)).to eq account.url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local status' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.url_for(status))
|
||||||
|
.to eq("#{host_prefix}/@#{status.account.username}/#{status.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a remote status' do
|
||||||
|
let(:account) { Fabricate(:account, domain: 'example.com', url: 'https://example.com/profiles/dskjfsdf') }
|
||||||
|
let(:status) { Fabricate(:status, account: account, url: 'https://example.com/posts/1234') }
|
||||||
|
|
||||||
|
it 'returns the expected URL' do
|
||||||
|
expect(subject.url_for(status)).to eq status.url
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#uri_for' do
|
describe '#uri_for' do
|
||||||
it 'returns a string starting with web domain' do
|
context 'with the instance actor' do
|
||||||
account = Fabricate(:account)
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
expect(subject.uri_for(account)).to be_a(String)
|
expect(subject.uri_for(Account.representative))
|
||||||
.and start_with(domain)
|
.to eq("#{host_prefix}/actor")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local account' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.uri_for(account))
|
||||||
|
.to eq("#{host_prefix}/users/#{account.username}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a remote account' do
|
||||||
|
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') }
|
||||||
|
|
||||||
|
it 'returns the expected URL' do
|
||||||
|
expect(subject.uri_for(account)).to eq account.uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local status' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.uri_for(status))
|
||||||
|
.to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a remote status' do
|
||||||
|
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') }
|
||||||
|
let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/posts/1234') }
|
||||||
|
|
||||||
|
it 'returns the expected URL' do
|
||||||
|
expect(subject.uri_for(status)).to eq status.uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local conversation' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.uri_for(status.conversation))
|
||||||
|
.to eq("#{host_prefix}/contexts/#{status.account.id}-#{status.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a remote conversation' do
|
||||||
|
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/profiles/dskjfsdf') }
|
||||||
|
let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/posts/1234') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
status.conversation.update!(uri: 'https://example.com/conversations/1234')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the expected URL' do
|
||||||
|
expect(subject.uri_for(status.conversation)).to eq status.conversation.uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#key_uri_for' do
|
||||||
|
context 'with the instance actor' do
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.key_uri_for(Account.representative))
|
||||||
|
.to eq("#{host_prefix}/actor#main-key")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local account' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.key_uri_for(account))
|
||||||
|
.to eq("#{host_prefix}/users/#{account.username}#main-key")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -49,7 +154,137 @@ RSpec.describe ActivityPub::TagManager do
|
|||||||
it 'returns a string starting with web domain' do
|
it 'returns a string starting with web domain' do
|
||||||
status = Fabricate(:status)
|
status = Fabricate(:status)
|
||||||
expect(subject.uri_for(status)).to be_a(String)
|
expect(subject.uri_for(status)).to be_a(String)
|
||||||
.and start_with(domain)
|
.and start_with(host_prefix)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#approval_uri_for' do
|
||||||
|
context 'with a valid local approval' do
|
||||||
|
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||||
|
|
||||||
|
it 'returns a string with the web domain and expected path' do
|
||||||
|
expect(subject.approval_uri_for(quote))
|
||||||
|
.to eq("#{host_prefix}/users/#{quote.quoted_account.username}/quote_authorizations/#{quote.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an unapproved local quote' do
|
||||||
|
let(:quote) { Fabricate(:quote, state: :rejected) }
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(subject.approval_uri_for(quote))
|
||||||
|
.to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a valid remote approval' do
|
||||||
|
let(:quoted_account) { Fabricate(:account, domain: 'example.com') }
|
||||||
|
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
||||||
|
let(:quote) { Fabricate(:quote, state: :accepted, quoted_status: quoted_status, approval_uri: 'https://example.com/approvals/1') }
|
||||||
|
|
||||||
|
it 'returns the expected URI' do
|
||||||
|
expect(subject.approval_uri_for(quote)).to eq quote.approval_uri
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an unapproved local quote but check_approval override' do
|
||||||
|
let(:quote) { Fabricate(:quote, state: :rejected) }
|
||||||
|
|
||||||
|
it 'returns a string with the web domain and expected path' do
|
||||||
|
expect(subject.approval_uri_for(quote, check_approval: false))
|
||||||
|
.to eq("#{host_prefix}/users/#{quote.quoted_account.username}/quote_authorizations/#{quote.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#replies_uri_for' do
|
||||||
|
context 'with a local status' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.replies_uri_for(status))
|
||||||
|
.to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/replies")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#likes_uri_for' do
|
||||||
|
context 'with a local status' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.likes_uri_for(status))
|
||||||
|
.to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/likes")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#shares_uri_for' do
|
||||||
|
context 'with a local status' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.shares_uri_for(status))
|
||||||
|
.to eq("#{host_prefix}/users/#{status.account.username}/statuses/#{status.id}/shares")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#following_uri_for' do
|
||||||
|
context 'with a local account' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.following_uri_for(account))
|
||||||
|
.to eq("#{host_prefix}/users/#{account.username}/following")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#followers_uri_for' do
|
||||||
|
context 'with a local account' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.followers_uri_for(account))
|
||||||
|
.to eq("#{host_prefix}/users/#{account.username}/followers")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#inbox_uri_for' do
|
||||||
|
context 'with the instance actor' do
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.inbox_uri_for(Account.representative))
|
||||||
|
.to eq("#{host_prefix}/actor/inbox")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local account' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.inbox_uri_for(account))
|
||||||
|
.to eq("#{host_prefix}/users/#{account.username}/inbox")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#outbox_uri_for' do
|
||||||
|
context 'with the instance actor' do
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.outbox_uri_for(Account.representative))
|
||||||
|
.to eq("#{host_prefix}/actor/outbox")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a local account' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a string starting with web domain and with the expected path' do
|
||||||
|
expect(subject.outbox_uri_for(account))
|
||||||
|
.to eq("#{host_prefix}/users/#{account.username}/outbox")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user