Merge pull request #3209 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to a44a3f6d4047568921469ff9fbd212f553b1e7f4
This commit is contained in:
commit
e871d4ebbb
18
Gemfile.lock
18
Gemfile.lock
@ -345,7 +345,7 @@ GEM
|
|||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.13.2)
|
json (2.15.0)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
@ -626,7 +626,7 @@ GEM
|
|||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.4.0)
|
prism (1.5.1)
|
||||||
prometheus_exporter (2.3.0)
|
prometheus_exporter (2.3.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.3.1)
|
propshaft (1.3.1)
|
||||||
@ -722,7 +722,7 @@ GEM
|
|||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.26.0)
|
redis-client (0.26.0)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
@ -766,7 +766,7 @@ GEM
|
|||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.80.2)
|
rubocop (1.81.0)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@ -774,10 +774,10 @@ GEM
|
|||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.46.0, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.47.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
@ -901,9 +901,9 @@ GEM
|
|||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.9.1)
|
unf_ext (0.0.9.1)
|
||||||
unicode-display_width (3.1.5)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.3)
|
uri (1.0.3)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
|
|||||||
78
app/javascript/config/html-tags.json
Normal file
78
app/javascript/config/html-tags.json
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
"title": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abbr": {
|
||||||
|
"attributes": {
|
||||||
|
"title": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"del": {},
|
||||||
|
"s": {},
|
||||||
|
"pre": {},
|
||||||
|
"blockquote": {
|
||||||
|
"attributes": {
|
||||||
|
"cite": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"code": {},
|
||||||
|
"b": {},
|
||||||
|
"strong": {},
|
||||||
|
"u": {},
|
||||||
|
"sub": {},
|
||||||
|
"sup": {},
|
||||||
|
"i": {},
|
||||||
|
"img": {
|
||||||
|
"children": false,
|
||||||
|
"attributes": {
|
||||||
|
"src": true,
|
||||||
|
"alt": true,
|
||||||
|
"title": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"em": {},
|
||||||
|
"h1": {},
|
||||||
|
"h2": {},
|
||||||
|
"h3": {},
|
||||||
|
"h4": {},
|
||||||
|
"h5": {},
|
||||||
|
"ul": {},
|
||||||
|
"ol": {
|
||||||
|
"attributes": {
|
||||||
|
"start": true,
|
||||||
|
"reversed": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"li": {
|
||||||
|
"attributes": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ruby": {},
|
||||||
|
"rt": {},
|
||||||
|
"rp": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
|
|||||||
import {
|
import {
|
||||||
fetchRelationships,
|
fetchRelationships,
|
||||||
followAccount,
|
followAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
} from 'flavours/glitch/actions/accounts';
|
} from 'flavours/glitch/actions/accounts';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
@ -15,17 +17,49 @@ import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
|||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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',
|
||||||
|
},
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
edit_profile: { 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();
|
||||||
@ -60,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.edit_profile);
|
label = intl.formatMessage(messages.edit_profile);
|
||||||
} 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { cleanExtraEmojis } from '@/flavours/glitch/features/emoji/normalize';
|
||||||
|
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
|
||||||
|
import { createLimitedCache } from '@/flavours/glitch/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,6 +1,7 @@
|
|||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
|
import type { useLocation } from 'react-router';
|
||||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -18,7 +19,9 @@ interface MastodonLocationState {
|
|||||||
mastodonModalKey?: string;
|
mastodonModalKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationState = MastodonLocationState | null | undefined;
|
export type LocationState = MastodonLocationState | null | undefined;
|
||||||
|
|
||||||
|
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
|
||||||
|
|
||||||
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { connect } from 'react-redux';
|
|||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
|
||||||
|
|
||||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||||
@ -399,7 +399,7 @@ class ScrollableList extends PureComponent {
|
|||||||
|
|
||||||
if (trackScroll) {
|
if (trackScroll) {
|
||||||
return (
|
return (
|
||||||
<ScrollContainer scrollKey={scrollKey}>
|
<ScrollContainer scrollKey={scrollKey} childRef={this.setRef}>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { Route } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
|
||||||
import { ScrollContext } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
|
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
|
||||||
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
|
import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_settings';
|
||||||
@ -21,6 +20,8 @@ import { store } from 'flavours/glitch/store';
|
|||||||
import { isProduction } from 'flavours/glitch/utils/environment';
|
import { isProduction } from 'flavours/glitch/utils/environment';
|
||||||
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
|
import { BodyScrollLock } from 'flavours/glitch/features/ui/components/body_scroll_lock';
|
||||||
|
|
||||||
|
import { ScrollContext } from './scroll_container/scroll_context';
|
||||||
|
|
||||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||||
|
|
||||||
const hydrateAction = hydrateStore(initialState);
|
const hydrateAction = hydrateStore(initialState);
|
||||||
@ -50,10 +51,6 @@ export default class Mastodon extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll (prevRouterProps, { location }) {
|
|
||||||
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<IdentityContext.Provider value={this.identity}>
|
<IdentityContext.Provider value={this.identity}>
|
||||||
@ -61,7 +58,7 @@ export default class Mastodon extends PureComponent {
|
|||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContext>
|
||||||
<Route path='/' component={UI} />
|
<Route path='/' component={UI} />
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
// ScrollContainer is used to automatically scroll to the top when pushing a
|
|
||||||
// new history state and remembering the scroll position when going back.
|
|
||||||
// There are a few things we need to do differently, though.
|
|
||||||
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
// If the change is caused by opening a modal, do not scroll to top
|
|
||||||
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default
|
|
||||||
class ScrollContainer extends OriginalScrollContainer {
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
shouldUpdateScroll: defaultShouldUpdateScroll,
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import type { MastodonLocation } from 'flavours/glitch/components/router';
|
||||||
|
|
||||||
|
export type ShouldUpdateScrollFn = (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollBehavior will automatically scroll to the top on navigations
|
||||||
|
* or restore saved scroll positions, but on some location changes we
|
||||||
|
* need to prevent this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = (
|
||||||
|
prevLocation,
|
||||||
|
location,
|
||||||
|
) => {
|
||||||
|
// If the change is caused by opening a modal, do not scroll to top
|
||||||
|
const shouldUpdateScroll = !(
|
||||||
|
location.state?.mastodonModalKey &&
|
||||||
|
location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldUpdateScroll;
|
||||||
|
};
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import React, {
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
||||||
|
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
||||||
|
import { ScrollBehaviorContext } from './scroll_context';
|
||||||
|
|
||||||
|
interface ScrollContainerProps {
|
||||||
|
/**
|
||||||
|
* This key must be static for the element & not change
|
||||||
|
* while the component is mounted.
|
||||||
|
*/
|
||||||
|
scrollKey: string;
|
||||||
|
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
||||||
|
childRef?: React.ForwardedRef<HTMLElement | undefined>;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ScrollContainer` is used to manage the scroll position of elements on the page
|
||||||
|
* that can be scrolled independently of the page body.
|
||||||
|
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ScrollContainer: React.FC<ScrollContainerProps> = ({
|
||||||
|
children,
|
||||||
|
scrollKey,
|
||||||
|
childRef,
|
||||||
|
shouldUpdateScroll = defaultShouldUpdateScroll,
|
||||||
|
}) => {
|
||||||
|
const scrollBehaviorContext = useContext(ScrollBehaviorContext);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLElement>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a childRef is passed, sync it with the containerRef. This
|
||||||
|
* is necessary because in this component's return statement,
|
||||||
|
* we're overwriting the immediate child component's ref prop.
|
||||||
|
*/
|
||||||
|
useImperativeHandle(childRef, () => containerRef.current, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register/unregister scrollable element with ScrollBehavior
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollBehaviorContext || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollBehaviorContext.registerElement(
|
||||||
|
scrollKey,
|
||||||
|
containerRef.current,
|
||||||
|
(prevLocation, location) => {
|
||||||
|
// Hack to allow accessing scrollBehavior._stateStorage
|
||||||
|
return shouldUpdateScroll.call(
|
||||||
|
scrollBehaviorContext.scrollBehavior,
|
||||||
|
prevLocation,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollBehaviorContext.unregisterElement(scrollKey);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return React.Children.only(
|
||||||
|
React.cloneElement(children, { ref: containerRef }),
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLocation, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { LocationBase } from 'scroll-behavior';
|
||||||
|
import ScrollBehavior from 'scroll-behavior';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LocationState,
|
||||||
|
MastodonLocation,
|
||||||
|
} from 'flavours/glitch/components/router';
|
||||||
|
import { usePrevious } from 'flavours/glitch/hooks/usePrevious';
|
||||||
|
|
||||||
|
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
||||||
|
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
||||||
|
import { SessionStorage } from './state_storage';
|
||||||
|
|
||||||
|
type ScrollBehaviorInstance = InstanceType<
|
||||||
|
typeof ScrollBehavior<LocationBase, MastodonLocation>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ScrollBehaviorContextType {
|
||||||
|
registerElement: (
|
||||||
|
key: string,
|
||||||
|
element: HTMLElement,
|
||||||
|
shouldUpdateScroll: (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) => boolean,
|
||||||
|
) => void;
|
||||||
|
unregisterElement: (key: string) => void;
|
||||||
|
scrollBehavior?: ScrollBehaviorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollBehaviorContext =
|
||||||
|
React.createContext<ScrollBehaviorContextType | null>(null);
|
||||||
|
|
||||||
|
interface ScrollContextProps {
|
||||||
|
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-level wrapper that provides the app with an instance of the
|
||||||
|
* ScrollBehavior object. scroll-behavior is a library for managing the
|
||||||
|
* scroll position of a single-page app in the same way the browser would
|
||||||
|
* normally do for a multi-page app. This means it'll scroll back to top
|
||||||
|
* when navigating to a new page, and will restore the scroll position
|
||||||
|
* when navigating e.g. using `history.back`.
|
||||||
|
* The library keeps a record of scroll positions in session storage.
|
||||||
|
*
|
||||||
|
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ScrollContext: React.FC<ScrollContextProps> = ({
|
||||||
|
children,
|
||||||
|
shouldUpdateScroll = defaultShouldUpdateScroll,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation<LocationState>();
|
||||||
|
const history = useHistory<LocationState>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the current location in a mutable ref so that ScrollBehavior's
|
||||||
|
* `getCurrentLocation` can access it without having to recreate the
|
||||||
|
* whole ScrollBehavior object
|
||||||
|
*/
|
||||||
|
const currentLocationRef = useRef(location);
|
||||||
|
useEffect(() => {
|
||||||
|
currentLocationRef.current = location;
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise ScrollBehavior object once – using state rather
|
||||||
|
* than a ref to simplify the types and ensure it's defined immediately.
|
||||||
|
*/
|
||||||
|
const [scrollBehavior] = useState(
|
||||||
|
(): ScrollBehaviorInstance =>
|
||||||
|
new ScrollBehavior({
|
||||||
|
addNavigationListener: history.listen.bind(history),
|
||||||
|
stateStorage: new SessionStorage(),
|
||||||
|
getCurrentLocation: () =>
|
||||||
|
currentLocationRef.current as unknown as LocationBase,
|
||||||
|
shouldUpdateScroll: (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) =>
|
||||||
|
// Hack to allow accessing scrollBehavior._stateStorage
|
||||||
|
shouldUpdateScroll.call(
|
||||||
|
scrollBehavior,
|
||||||
|
prevLocationContext,
|
||||||
|
locationContext,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll update when location changes
|
||||||
|
*/
|
||||||
|
const prevLocation = usePrevious(location) ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
scrollBehavior.updateScroll(prevLocation, location);
|
||||||
|
}, [location, prevLocation, scrollBehavior]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Scrollbehavior on unmount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
scrollBehavior.stop();
|
||||||
|
};
|
||||||
|
}, [scrollBehavior]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the app with a way to register separately scrollable
|
||||||
|
* elements to also be tracked by ScrollBehavior. (By default
|
||||||
|
* ScrollBehavior only handles scrolling on the main document body.)
|
||||||
|
*/
|
||||||
|
const contextValue = useMemo<ScrollBehaviorContextType>(
|
||||||
|
() => ({
|
||||||
|
registerElement: (key, element, shouldUpdateScroll) => {
|
||||||
|
scrollBehavior.registerElement(
|
||||||
|
key,
|
||||||
|
element,
|
||||||
|
shouldUpdateScroll,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
unregisterElement: (key) => {
|
||||||
|
scrollBehavior.unregisterElement(key);
|
||||||
|
},
|
||||||
|
scrollBehavior,
|
||||||
|
}),
|
||||||
|
[location, scrollBehavior],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollBehaviorContext.Provider value={contextValue}>
|
||||||
|
{React.Children.only(children)}
|
||||||
|
</ScrollBehaviorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import type { LocationBase, ScrollPosition } from 'scroll-behavior';
|
||||||
|
|
||||||
|
const STATE_KEY_PREFIX = '@@scroll|';
|
||||||
|
|
||||||
|
interface LocationBaseWithKey extends LocationBase {
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module is part of our port of https://github.com/ytase/react-router-scroll/
|
||||||
|
* and handles storing scroll positions in SessionStorage.
|
||||||
|
* Stored positions (`[x, y]`) are keyed by the location key and an optional
|
||||||
|
* `scrollKey` that's used for to track separately scrollable elements other
|
||||||
|
* than the document body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SessionStorage {
|
||||||
|
read(
|
||||||
|
location: LocationBaseWithKey,
|
||||||
|
key: string | null,
|
||||||
|
): ScrollPosition | null {
|
||||||
|
const stateKey = this.getStateKey(location, key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = sessionStorage.getItem(stateKey);
|
||||||
|
return value ? (JSON.parse(value) as ScrollPosition) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(location: LocationBaseWithKey, key: string | null, value: unknown) {
|
||||||
|
const stateKey = this.getStateKey(location, key);
|
||||||
|
const storedValue = JSON.stringify(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(stateKey, storedValue);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateKey(location: LocationBaseWithKey, key: string | null) {
|
||||||
|
const locationKey = location.key;
|
||||||
|
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
|
||||||
|
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,134 +1,22 @@
|
|||||||
import { useCallback } from 'react';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import {
|
|
||||||
followAccount,
|
|
||||||
unblockAccount,
|
|
||||||
unmuteAccount,
|
|
||||||
} from 'flavours/glitch/actions/accounts';
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
import { Permalink } from 'flavours/glitch/components/permalink';
|
import { Permalink } from 'flavours/glitch/components/permalink';
|
||||||
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
import { ShortNumber } from 'flavours/glitch/components/short_number';
|
||||||
import { autoPlayGif, me } from 'flavours/glitch/initial_state';
|
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||||
import type { Account } from 'flavours/glitch/models/account';
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
import { makeGetAccount } from 'flavours/glitch/selectors';
|
import { makeGetAccount } from 'flavours/glitch/selectors';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppSelector } from 'flavours/glitch/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'>
|
||||||
<Permalink
|
<Permalink
|
||||||
@ -190,7 +78,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header';
|
|||||||
import { LoadMore } from 'flavours/glitch/components/load_more';
|
import { LoadMore } from 'flavours/glitch/components/load_more';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
import { RadioButton } from 'flavours/glitch/components/radio_button';
|
||||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
|
||||||
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
|
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
@ -209,7 +209,6 @@ export const Directory: React.FC<{
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{multiColumn && !pinned ? (
|
{multiColumn && !pinned ? (
|
||||||
// @ts-expect-error ScrollContainer is not properly typed yet
|
|
||||||
<ScrollContainer scrollKey='directory'>
|
<ScrollContainer scrollKey='directory'>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|||||||
@ -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 '@/flavours/glitch/api_types/custom_emoji';
|
|
||||||
import { useAppSelector } from '@/flavours/glitch/store';
|
import { useAppSelector } from '@/flavours/glitch/store';
|
||||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
import { isModernEmojiEnabled } from '@/flavours/glitch/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 'flavours/glitch/initial_state';
|
|||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/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: {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
|
|||||||
import { Hotkeys } from 'flavours/glitch/components/hotkeys';
|
import { Hotkeys } from 'flavours/glitch/components/hotkeys';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
|
import { ScrollContainer } from 'flavours/glitch/containers/scroll_container';
|
||||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
|
||||||
@ -551,9 +551,9 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldUpdateScroll = (prevRouterProps, { location }) => {
|
shouldUpdateScroll = (prevLocation, location) => {
|
||||||
// Do not change scroll when opening a modal
|
// Do not change scroll when opening a modal
|
||||||
if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) {
|
if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -634,7 +634,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
||||||
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -60,23 +60,13 @@ export function focusColumn({
|
|||||||
* Get the index of the currently focused item in one of our item lists
|
* Get the index of the currently focused item in one of our item lists
|
||||||
*/
|
*/
|
||||||
export function getFocusedItemIndex() {
|
export function getFocusedItemIndex() {
|
||||||
const focusedElement = document.activeElement;
|
const focusedItem = document.activeElement?.closest('.item-list > *');
|
||||||
const itemList = focusedElement?.closest('.item-list');
|
|
||||||
|
|
||||||
if (!focusedElement || !itemList) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let focusedItem: HTMLElement | null = null;
|
|
||||||
if (focusedElement.parentElement === itemList) {
|
|
||||||
focusedItem = focusedElement as HTMLElement;
|
|
||||||
} else {
|
|
||||||
focusedItem = focusedElement.closest('.item-list > *');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!focusedItem) return -1;
|
if (!focusedItem) return -1;
|
||||||
|
|
||||||
const items = Array.from(itemList.children);
|
const { parentElement } = focusedItem;
|
||||||
|
if (!parentElement) return -1;
|
||||||
|
|
||||||
|
const items = Array.from(parentElement.children);
|
||||||
return items.indexOf(focusedItem);
|
return items.indexOf(focusedItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3037,8 +3037,12 @@ a.account__display-name {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.unscrollable {
|
.layout-multiple-columns & {
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
&.unscrollable {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__panels {
|
&__panels {
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,6 +1,7 @@
|
|||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
|
import type { useLocation } from 'react-router';
|
||||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@ -18,7 +19,9 @@ interface MastodonLocationState {
|
|||||||
mastodonModalKey?: string;
|
mastodonModalKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationState = MastodonLocationState | null | undefined;
|
export type LocationState = MastodonLocationState | null | undefined;
|
||||||
|
|
||||||
|
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
|
||||||
|
|
||||||
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { connect } from 'react-redux';
|
|||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||||
|
|
||||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||||
@ -399,7 +399,7 @@ class ScrollableList extends PureComponent {
|
|||||||
|
|
||||||
if (trackScroll) {
|
if (trackScroll) {
|
||||||
return (
|
return (
|
||||||
<ScrollContainer scrollKey={scrollKey}>
|
<ScrollContainer scrollKey={scrollKey} childRef={this.setRef}>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { Route } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
|
||||||
import { ScrollContext } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
||||||
import { hydrateStore } from 'mastodon/actions/store';
|
import { hydrateStore } from 'mastodon/actions/store';
|
||||||
@ -20,6 +19,8 @@ import { store } from 'mastodon/store';
|
|||||||
import { isProduction } from 'mastodon/utils/environment';
|
import { isProduction } from 'mastodon/utils/environment';
|
||||||
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
|
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
|
||||||
|
|
||||||
|
import { ScrollContext } from './scroll_container/scroll_context';
|
||||||
|
|
||||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||||
|
|
||||||
const hydrateAction = hydrateStore(initialState);
|
const hydrateAction = hydrateStore(initialState);
|
||||||
@ -45,10 +46,6 @@ export default class Mastodon extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll (prevRouterProps, { location }) {
|
|
||||||
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<IdentityContext.Provider value={this.identity}>
|
<IdentityContext.Provider value={this.identity}>
|
||||||
@ -56,7 +53,7 @@ export default class Mastodon extends PureComponent {
|
|||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContext>
|
||||||
<Route path='/' component={UI} />
|
<Route path='/' component={UI} />
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
// ScrollContainer is used to automatically scroll to the top when pushing a
|
|
||||||
// new history state and remembering the scroll position when going back.
|
|
||||||
// There are a few things we need to do differently, though.
|
|
||||||
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
// If the change is caused by opening a modal, do not scroll to top
|
|
||||||
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default
|
|
||||||
class ScrollContainer extends OriginalScrollContainer {
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
shouldUpdateScroll: defaultShouldUpdateScroll,
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
import type { MastodonLocation } from 'mastodon/components/router';
|
||||||
|
|
||||||
|
export type ShouldUpdateScrollFn = (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollBehavior will automatically scroll to the top on navigations
|
||||||
|
* or restore saved scroll positions, but on some location changes we
|
||||||
|
* need to prevent this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = (
|
||||||
|
prevLocation,
|
||||||
|
location,
|
||||||
|
) => {
|
||||||
|
// If the change is caused by opening a modal, do not scroll to top
|
||||||
|
const shouldUpdateScroll = !(
|
||||||
|
location.state?.mastodonModalKey &&
|
||||||
|
location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldUpdateScroll;
|
||||||
|
};
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import React, {
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
||||||
|
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
||||||
|
import { ScrollBehaviorContext } from './scroll_context';
|
||||||
|
|
||||||
|
interface ScrollContainerProps {
|
||||||
|
/**
|
||||||
|
* This key must be static for the element & not change
|
||||||
|
* while the component is mounted.
|
||||||
|
*/
|
||||||
|
scrollKey: string;
|
||||||
|
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
||||||
|
childRef?: React.ForwardedRef<HTMLElement | undefined>;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ScrollContainer` is used to manage the scroll position of elements on the page
|
||||||
|
* that can be scrolled independently of the page body.
|
||||||
|
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ScrollContainer: React.FC<ScrollContainerProps> = ({
|
||||||
|
children,
|
||||||
|
scrollKey,
|
||||||
|
childRef,
|
||||||
|
shouldUpdateScroll = defaultShouldUpdateScroll,
|
||||||
|
}) => {
|
||||||
|
const scrollBehaviorContext = useContext(ScrollBehaviorContext);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLElement>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a childRef is passed, sync it with the containerRef. This
|
||||||
|
* is necessary because in this component's return statement,
|
||||||
|
* we're overwriting the immediate child component's ref prop.
|
||||||
|
*/
|
||||||
|
useImperativeHandle(childRef, () => containerRef.current, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register/unregister scrollable element with ScrollBehavior
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollBehaviorContext || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollBehaviorContext.registerElement(
|
||||||
|
scrollKey,
|
||||||
|
containerRef.current,
|
||||||
|
(prevLocation, location) => {
|
||||||
|
// Hack to allow accessing scrollBehavior._stateStorage
|
||||||
|
return shouldUpdateScroll.call(
|
||||||
|
scrollBehaviorContext.scrollBehavior,
|
||||||
|
prevLocation,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollBehaviorContext.unregisterElement(scrollKey);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return React.Children.only(
|
||||||
|
React.cloneElement(children, { ref: containerRef }),
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLocation, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { LocationBase } from 'scroll-behavior';
|
||||||
|
import ScrollBehavior from 'scroll-behavior';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LocationState,
|
||||||
|
MastodonLocation,
|
||||||
|
} from 'mastodon/components/router';
|
||||||
|
import { usePrevious } from 'mastodon/hooks/usePrevious';
|
||||||
|
|
||||||
|
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
||||||
|
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
||||||
|
import { SessionStorage } from './state_storage';
|
||||||
|
|
||||||
|
type ScrollBehaviorInstance = InstanceType<
|
||||||
|
typeof ScrollBehavior<LocationBase, MastodonLocation>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ScrollBehaviorContextType {
|
||||||
|
registerElement: (
|
||||||
|
key: string,
|
||||||
|
element: HTMLElement,
|
||||||
|
shouldUpdateScroll: (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) => boolean,
|
||||||
|
) => void;
|
||||||
|
unregisterElement: (key: string) => void;
|
||||||
|
scrollBehavior?: ScrollBehaviorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollBehaviorContext =
|
||||||
|
React.createContext<ScrollBehaviorContextType | null>(null);
|
||||||
|
|
||||||
|
interface ScrollContextProps {
|
||||||
|
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-level wrapper that provides the app with an instance of the
|
||||||
|
* ScrollBehavior object. scroll-behavior is a library for managing the
|
||||||
|
* scroll position of a single-page app in the same way the browser would
|
||||||
|
* normally do for a multi-page app. This means it'll scroll back to top
|
||||||
|
* when navigating to a new page, and will restore the scroll position
|
||||||
|
* when navigating e.g. using `history.back`.
|
||||||
|
* The library keeps a record of scroll positions in session storage.
|
||||||
|
*
|
||||||
|
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ScrollContext: React.FC<ScrollContextProps> = ({
|
||||||
|
children,
|
||||||
|
shouldUpdateScroll = defaultShouldUpdateScroll,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation<LocationState>();
|
||||||
|
const history = useHistory<LocationState>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the current location in a mutable ref so that ScrollBehavior's
|
||||||
|
* `getCurrentLocation` can access it without having to recreate the
|
||||||
|
* whole ScrollBehavior object
|
||||||
|
*/
|
||||||
|
const currentLocationRef = useRef(location);
|
||||||
|
useEffect(() => {
|
||||||
|
currentLocationRef.current = location;
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise ScrollBehavior object once – using state rather
|
||||||
|
* than a ref to simplify the types and ensure it's defined immediately.
|
||||||
|
*/
|
||||||
|
const [scrollBehavior] = useState(
|
||||||
|
(): ScrollBehaviorInstance =>
|
||||||
|
new ScrollBehavior({
|
||||||
|
addNavigationListener: history.listen.bind(history),
|
||||||
|
stateStorage: new SessionStorage(),
|
||||||
|
getCurrentLocation: () =>
|
||||||
|
currentLocationRef.current as unknown as LocationBase,
|
||||||
|
shouldUpdateScroll: (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) =>
|
||||||
|
// Hack to allow accessing scrollBehavior._stateStorage
|
||||||
|
shouldUpdateScroll.call(
|
||||||
|
scrollBehavior,
|
||||||
|
prevLocationContext,
|
||||||
|
locationContext,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll update when location changes
|
||||||
|
*/
|
||||||
|
const prevLocation = usePrevious(location) ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
scrollBehavior.updateScroll(prevLocation, location);
|
||||||
|
}, [location, prevLocation, scrollBehavior]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Scrollbehavior on unmount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
scrollBehavior.stop();
|
||||||
|
};
|
||||||
|
}, [scrollBehavior]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the app with a way to register separately scrollable
|
||||||
|
* elements to also be tracked by ScrollBehavior. (By default
|
||||||
|
* ScrollBehavior only handles scrolling on the main document body.)
|
||||||
|
*/
|
||||||
|
const contextValue = useMemo<ScrollBehaviorContextType>(
|
||||||
|
() => ({
|
||||||
|
registerElement: (key, element, shouldUpdateScroll) => {
|
||||||
|
scrollBehavior.registerElement(
|
||||||
|
key,
|
||||||
|
element,
|
||||||
|
shouldUpdateScroll,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
unregisterElement: (key) => {
|
||||||
|
scrollBehavior.unregisterElement(key);
|
||||||
|
},
|
||||||
|
scrollBehavior,
|
||||||
|
}),
|
||||||
|
[location, scrollBehavior],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollBehaviorContext.Provider value={contextValue}>
|
||||||
|
{React.Children.only(children)}
|
||||||
|
</ScrollBehaviorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import type { LocationBase, ScrollPosition } from 'scroll-behavior';
|
||||||
|
|
||||||
|
const STATE_KEY_PREFIX = '@@scroll|';
|
||||||
|
|
||||||
|
interface LocationBaseWithKey extends LocationBase {
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module is part of our port of https://github.com/ytase/react-router-scroll/
|
||||||
|
* and handles storing scroll positions in SessionStorage.
|
||||||
|
* Stored positions (`[x, y]`) are keyed by the location key and an optional
|
||||||
|
* `scrollKey` that's used for to track separately scrollable elements other
|
||||||
|
* than the document body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SessionStorage {
|
||||||
|
read(
|
||||||
|
location: LocationBaseWithKey,
|
||||||
|
key: string | null,
|
||||||
|
): ScrollPosition | null {
|
||||||
|
const stateKey = this.getStateKey(location, key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = sessionStorage.getItem(stateKey);
|
||||||
|
return value ? (JSON.parse(value) as ScrollPosition) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(location: LocationBaseWithKey, key: string | null, value: unknown) {
|
||||||
|
const stateKey = this.getStateKey(location, key);
|
||||||
|
const storedValue = JSON.stringify(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(stateKey, storedValue);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateKey(location: LocationBaseWithKey, key: string | null) {
|
||||||
|
const locationKey = location.key;
|
||||||
|
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
|
||||||
|
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { ColumnHeader } from 'mastodon/components/column_header';
|
|||||||
import { LoadMore } from 'mastodon/components/load_more';
|
import { LoadMore } from 'mastodon/components/load_more';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { RadioButton } from 'mastodon/components/radio_button';
|
import { RadioButton } from 'mastodon/components/radio_button';
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||||
import { useSearchParam } from 'mastodon/hooks/useSearchParam';
|
import { useSearchParam } from 'mastodon/hooks/useSearchParam';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
@ -206,7 +206,6 @@ export const Directory: React.FC<{
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{multiColumn && !pinned ? (
|
{multiColumn && !pinned ? (
|
||||||
// @ts-expect-error ScrollContainer is not properly typed yet
|
|
||||||
<ScrollContainer scrollKey='directory'>
|
<ScrollContainer scrollKey='directory'>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
|
|||||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
@ -526,9 +526,9 @@ class Status extends ImmutablePureComponent {
|
|||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldUpdateScroll = (prevRouterProps, { location }) => {
|
shouldUpdateScroll = (prevLocation, location) => {
|
||||||
// Do not change scroll when opening a modal
|
// Do not change scroll when opening a modal
|
||||||
if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) {
|
if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -602,7 +602,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
||||||
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -60,23 +60,13 @@ export function focusColumn({
|
|||||||
* Get the index of the currently focused item in one of our item lists
|
* Get the index of the currently focused item in one of our item lists
|
||||||
*/
|
*/
|
||||||
export function getFocusedItemIndex() {
|
export function getFocusedItemIndex() {
|
||||||
const focusedElement = document.activeElement;
|
const focusedItem = document.activeElement?.closest('.item-list > *');
|
||||||
const itemList = focusedElement?.closest('.item-list');
|
|
||||||
|
|
||||||
if (!focusedElement || !itemList) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let focusedItem: HTMLElement | null = null;
|
|
||||||
if (focusedElement.parentElement === itemList) {
|
|
||||||
focusedItem = focusedElement as HTMLElement;
|
|
||||||
} else {
|
|
||||||
focusedItem = focusedElement.closest('.item-list > *');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!focusedItem) return -1;
|
if (!focusedItem) return -1;
|
||||||
|
|
||||||
const items = Array.from(itemList.children);
|
const { parentElement } = focusedItem;
|
||||||
|
if (!parentElement) return -1;
|
||||||
|
|
||||||
|
const items = Array.from(parentElement.children);
|
||||||
return items.indexOf(focusedItem);
|
return items.indexOf(focusedItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -864,6 +864,14 @@
|
|||||||
"status.cancel_reblog_private": "Прыбраць",
|
"status.cancel_reblog_private": "Прыбраць",
|
||||||
"status.cannot_quote": "Вы не маеце дазвол цытаваць гэты допіс",
|
"status.cannot_quote": "Вы не маеце дазвол цытаваць гэты допіс",
|
||||||
"status.cannot_reblog": "Гэты допіс нельга пашырыць",
|
"status.cannot_reblog": "Гэты допіс нельга пашырыць",
|
||||||
|
"status.contains_quote": "Утрымлівае цытату",
|
||||||
|
"status.context.loading": "Загружаюцца іншыя адказы",
|
||||||
|
"status.context.loading_error": "Немагчыма загрузіць новыя адказы",
|
||||||
|
"status.context.loading_more": "Загружаюцца іншыя адказы",
|
||||||
|
"status.context.loading_success": "Усе адказы загружаныя",
|
||||||
|
"status.context.more_replies_found": "Знойдзеныя іншыя адказы",
|
||||||
|
"status.context.retry": "Паспрабаваць зноў",
|
||||||
|
"status.context.show": "Паказаць",
|
||||||
"status.continued_thread": "Працяг ланцужка",
|
"status.continued_thread": "Працяг ланцужка",
|
||||||
"status.copy": "Скапіраваць спасылку на допіс",
|
"status.copy": "Скапіраваць спасылку на допіс",
|
||||||
"status.delete": "Выдаліць",
|
"status.delete": "Выдаліць",
|
||||||
@ -901,6 +909,7 @@
|
|||||||
"status.quote_error.revoked": "Аўтар выдаліў допіс",
|
"status.quote_error.revoked": "Аўтар выдаліў допіс",
|
||||||
"status.quote_followers_only": "Толькі падпісчыкі могуць цытаваць гэты допіс",
|
"status.quote_followers_only": "Толькі падпісчыкі могуць цытаваць гэты допіс",
|
||||||
"status.quote_manual_review": "Аўтар зробіць агляд уручную",
|
"status.quote_manual_review": "Аўтар зробіць агляд уручную",
|
||||||
|
"status.quote_noun": "Цытаваць",
|
||||||
"status.quote_policy_change": "Змяніць, хто можа цытаваць",
|
"status.quote_policy_change": "Змяніць, хто можа цытаваць",
|
||||||
"status.quote_post_author": "Цытаваў допіс @{name}",
|
"status.quote_post_author": "Цытаваў допіс @{name}",
|
||||||
"status.quote_private": "Прыватныя допісы нельга цытаваць",
|
"status.quote_private": "Прыватныя допісы нельга цытаваць",
|
||||||
|
|||||||
@ -865,6 +865,13 @@
|
|||||||
"status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek",
|
"status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek",
|
||||||
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
||||||
"status.contains_quote": "Obsahuje citaci",
|
"status.contains_quote": "Obsahuje citaci",
|
||||||
|
"status.context.loading": "Načítání dalších odpovědí",
|
||||||
|
"status.context.loading_error": "Nelze načíst nové odpovědi",
|
||||||
|
"status.context.loading_more": "Načítání dalších odpovědí",
|
||||||
|
"status.context.loading_success": "Všechny odpovědi načteny",
|
||||||
|
"status.context.more_replies_found": "Nalezeny další odpovědi",
|
||||||
|
"status.context.retry": "Zkusit znovu",
|
||||||
|
"status.context.show": "Zobrazit",
|
||||||
"status.continued_thread": "Pokračuje ve vlákně",
|
"status.continued_thread": "Pokračuje ve vlákně",
|
||||||
"status.copy": "Zkopírovat odkaz na příspěvek",
|
"status.copy": "Zkopírovat odkaz na příspěvek",
|
||||||
"status.delete": "Smazat",
|
"status.delete": "Smazat",
|
||||||
|
|||||||
@ -865,12 +865,12 @@
|
|||||||
"status.cannot_quote": "Dir ist es nicht gestattet, diesen Beitrag zu zitieren",
|
"status.cannot_quote": "Dir ist es nicht gestattet, diesen Beitrag zu zitieren",
|
||||||
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
|
"status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
|
||||||
"status.contains_quote": "Enthält Zitat",
|
"status.contains_quote": "Enthält Zitat",
|
||||||
"status.context.loading": "Weitere Antworten werden geladen",
|
"status.context.loading": "Weitere Antworten laden",
|
||||||
"status.context.loading_error": "Neue Antworten konnten nicht geladen werden",
|
"status.context.loading_error": "Weitere Antworten konnten nicht geladen werden",
|
||||||
"status.context.loading_more": "Weitere Antworten werden geladen",
|
"status.context.loading_more": "Weitere Antworten laden",
|
||||||
"status.context.loading_success": "Alle Antworten geladen",
|
"status.context.loading_success": "Alle weiteren Antworten geladen",
|
||||||
"status.context.more_replies_found": "Weitere Antworten verfügbar",
|
"status.context.more_replies_found": "Weitere Antworten verfügbar",
|
||||||
"status.context.retry": "Wiederholen",
|
"status.context.retry": "Erneut versuchen",
|
||||||
"status.context.show": "Anzeigen",
|
"status.context.show": "Anzeigen",
|
||||||
"status.continued_thread": "Fortgeführter Thread",
|
"status.continued_thread": "Fortgeführter Thread",
|
||||||
"status.copy": "Link zum Beitrag kopieren",
|
"status.copy": "Link zum Beitrag kopieren",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -865,6 +865,13 @@
|
|||||||
"status.cannot_quote": "Sul pole õigust seda postitust tsiteerida",
|
"status.cannot_quote": "Sul pole õigust seda postitust tsiteerida",
|
||||||
"status.cannot_reblog": "Seda postitust ei saa jagada",
|
"status.cannot_reblog": "Seda postitust ei saa jagada",
|
||||||
"status.contains_quote": "Sisaldab tsitaati",
|
"status.contains_quote": "Sisaldab tsitaati",
|
||||||
|
"status.context.loading": "Laadin veel vastuseid",
|
||||||
|
"status.context.loading_error": "Uute vastuste laadimine ei õnnestunud",
|
||||||
|
"status.context.loading_more": "Laadin veel vastuseid",
|
||||||
|
"status.context.loading_success": "Kõik vastused on laaditud",
|
||||||
|
"status.context.more_replies_found": "Leidub veel vastuseid",
|
||||||
|
"status.context.retry": "Proovi uuesti",
|
||||||
|
"status.context.show": "Näita",
|
||||||
"status.continued_thread": "Jätkatud lõim",
|
"status.continued_thread": "Jätkatud lõim",
|
||||||
"status.copy": "Kopeeri postituse link",
|
"status.copy": "Kopeeri postituse link",
|
||||||
"status.delete": "Kustuta",
|
"status.delete": "Kustuta",
|
||||||
|
|||||||
@ -865,6 +865,13 @@
|
|||||||
"status.cannot_quote": "Tú hevur ikki loyvi at sitera hendan postin",
|
"status.cannot_quote": "Tú hevur ikki loyvi at sitera hendan postin",
|
||||||
"status.cannot_reblog": "Tað ber ikki til at stimbra hendan postin",
|
"status.cannot_reblog": "Tað ber ikki til at stimbra hendan postin",
|
||||||
"status.contains_quote": "Inniheldur sitat",
|
"status.contains_quote": "Inniheldur sitat",
|
||||||
|
"status.context.loading": "Tekur fleiri svar niður",
|
||||||
|
"status.context.loading_error": "Fekk ikki tikið nýggj svar niður",
|
||||||
|
"status.context.loading_more": "Tekur fleiri svar niður",
|
||||||
|
"status.context.loading_success": "Øll svar tikin niður",
|
||||||
|
"status.context.more_replies_found": "Fleiri svar funnin",
|
||||||
|
"status.context.retry": "Royn aftur",
|
||||||
|
"status.context.show": "Vís",
|
||||||
"status.continued_thread": "Framhaldandi tráður",
|
"status.continued_thread": "Framhaldandi tráður",
|
||||||
"status.copy": "Kopiera leinki til postin",
|
"status.copy": "Kopiera leinki til postin",
|
||||||
"status.delete": "Strika",
|
"status.delete": "Strika",
|
||||||
|
|||||||
@ -865,6 +865,13 @@
|
|||||||
"status.cannot_quote": "Ní cheadaítear duit an post seo a lua",
|
"status.cannot_quote": "Ní cheadaítear duit an post seo a lua",
|
||||||
"status.cannot_reblog": "Ní féidir an phostáil seo a mholadh",
|
"status.cannot_reblog": "Ní féidir an phostáil seo a mholadh",
|
||||||
"status.contains_quote": "Tá luachan ann",
|
"status.contains_quote": "Tá luachan ann",
|
||||||
|
"status.context.loading": "Ag lódáil tuilleadh freagraí",
|
||||||
|
"status.context.loading_error": "Níorbh fhéidir freagraí nua a lódáil",
|
||||||
|
"status.context.loading_more": "Ag lódáil tuilleadh freagraí",
|
||||||
|
"status.context.loading_success": "Luchtaithe na freagraí uile",
|
||||||
|
"status.context.more_replies_found": "Tuilleadh freagraí aimsithe",
|
||||||
|
"status.context.retry": "Déan iarracht arís",
|
||||||
|
"status.context.show": "Taispeáin",
|
||||||
"status.continued_thread": "Snáithe ar lean",
|
"status.continued_thread": "Snáithe ar lean",
|
||||||
"status.copy": "Cóipeáil an nasc chuig an bpostáil",
|
"status.copy": "Cóipeáil an nasc chuig an bpostáil",
|
||||||
"status.delete": "Scrios",
|
"status.delete": "Scrios",
|
||||||
|
|||||||
@ -865,6 +865,13 @@
|
|||||||
"status.cannot_quote": "Je bent niet gemachtigd om dit bericht te citeren",
|
"status.cannot_quote": "Je bent niet gemachtigd om dit bericht te citeren",
|
||||||
"status.cannot_reblog": "Dit bericht kan niet geboost worden",
|
"status.cannot_reblog": "Dit bericht kan niet geboost worden",
|
||||||
"status.contains_quote": "Bevat citaat",
|
"status.contains_quote": "Bevat citaat",
|
||||||
|
"status.context.loading": "Meer reacties laden",
|
||||||
|
"status.context.loading_error": "Kon geen nieuwe reacties laden",
|
||||||
|
"status.context.loading_more": "Meer reacties laden",
|
||||||
|
"status.context.loading_success": "Alle reacties zijn geladen",
|
||||||
|
"status.context.more_replies_found": "Meer reacties gevonden",
|
||||||
|
"status.context.retry": "Opnieuw proberen",
|
||||||
|
"status.context.show": "Tonen",
|
||||||
"status.continued_thread": "Vervolg van gesprek",
|
"status.continued_thread": "Vervolg van gesprek",
|
||||||
"status.copy": "Link naar bericht kopiëren",
|
"status.copy": "Link naar bericht kopiëren",
|
||||||
"status.delete": "Verwijderen",
|
"status.delete": "Verwijderen",
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2972,8 +2972,12 @@ a.account__display-name {
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.unscrollable {
|
.layout-multiple-columns & {
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
&.unscrollable {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__panels {
|
&__panels {
|
||||||
|
|||||||
@ -97,15 +97,17 @@ class Form::AdminSettings
|
|||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
DESCRIPTION_LIMIT = 200
|
DESCRIPTION_LIMIT = 200
|
||||||
|
DOMAIN_BLOCK_AUDIENCES = %w(disabled users all).freeze
|
||||||
|
REGISTRATION_MODES = %w(open approved none).freeze
|
||||||
|
|
||||||
attr_accessor(*KEYS)
|
attr_accessor(*KEYS)
|
||||||
|
|
||||||
validates :registrations_mode, inclusion: { in: %w(open approved none) }, if: -> { defined?(@registrations_mode) }
|
validates :registrations_mode, inclusion: { in: REGISTRATION_MODES }, if: -> { defined?(@registrations_mode) }
|
||||||
validates :site_contact_email, :site_contact_username, presence: true, if: -> { defined?(@site_contact_username) || defined?(@site_contact_email) }
|
validates :site_contact_email, :site_contact_username, presence: true, if: -> { defined?(@site_contact_username) || defined?(@site_contact_email) }
|
||||||
validates :site_contact_username, existing_username: true, if: -> { defined?(@site_contact_username) }
|
validates :site_contact_username, existing_username: true, if: -> { defined?(@site_contact_username) }
|
||||||
validates :bootstrap_timeline_accounts, existing_username: { multiple: true }, if: -> { defined?(@bootstrap_timeline_accounts) }
|
validates :bootstrap_timeline_accounts, existing_username: { multiple: true }, if: -> { defined?(@bootstrap_timeline_accounts) }
|
||||||
validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) }
|
validates :show_domain_blocks, inclusion: { in: DOMAIN_BLOCK_AUDIENCES }, if: -> { defined?(@show_domain_blocks) }
|
||||||
validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) }
|
validates :show_domain_blocks_rationale, inclusion: { in: DOMAIN_BLOCK_AUDIENCES }, if: -> { defined?(@show_domain_blocks_rationale) }
|
||||||
validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
|
validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
|
||||||
validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) }
|
validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) }
|
||||||
validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) }
|
validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) }
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
.fields-row__column.fields-row__column-6.fields-group
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
= f.input :show_domain_blocks,
|
= f.input :show_domain_blocks,
|
||||||
collection_wrapper_tag: 'ul',
|
collection_wrapper_tag: 'ul',
|
||||||
collection: %i(disabled users all),
|
collection: f.object.class::DOMAIN_BLOCK_AUDIENCES,
|
||||||
include_blank: false,
|
include_blank: false,
|
||||||
item_wrapper_tag: 'li',
|
item_wrapper_tag: 'li',
|
||||||
label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") },
|
label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") },
|
||||||
@ -32,7 +32,7 @@
|
|||||||
.fields-row__column.fields-row__column-6.fields-group
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
= f.input :show_domain_blocks_rationale,
|
= f.input :show_domain_blocks_rationale,
|
||||||
collection_wrapper_tag: 'ul',
|
collection_wrapper_tag: 'ul',
|
||||||
collection: %i(disabled users all),
|
collection: f.object.class::DOMAIN_BLOCK_AUDIENCES,
|
||||||
include_blank: false,
|
include_blank: false,
|
||||||
item_wrapper_tag: 'li',
|
item_wrapper_tag: 'li',
|
||||||
label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") },
|
label_method: ->(value) { t("admin.settings.domain_blocks.#{value}") },
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
.fields-row
|
.fields-row
|
||||||
.fields-row__column.fields-row__column-6.fields-group
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
= f.input :registrations_mode,
|
= f.input :registrations_mode,
|
||||||
collection: %w(open approved none),
|
collection: f.object.class::REGISTRATION_MODES,
|
||||||
include_blank: false,
|
include_blank: false,
|
||||||
label_method: ->(mode) { I18n.t("admin.settings.registrations_mode.modes.#{mode}") },
|
label_method: ->(mode) { I18n.t("admin.settings.registrations_mode.modes.#{mode}") },
|
||||||
warning_hint: I18n.t('admin.settings.registrations_mode.warning_hint'),
|
warning_hint: I18n.t('admin.settings.registrations_mode.warning_hint'),
|
||||||
|
|||||||
@ -101,7 +101,6 @@
|
|||||||
"react-redux-loading-bar": "^5.0.8",
|
"react-redux-loading-bar": "^5.0.8",
|
||||||
"react-router": "^5.3.4",
|
"react-router": "^5.3.4",
|
||||||
"react-router-dom": "^5.3.4",
|
"react-router-dom": "^5.3.4",
|
||||||
"react-router-scroll-4": "^1.0.0-beta.1",
|
|
||||||
"react-select": "^5.7.3",
|
"react-select": "^5.7.3",
|
||||||
"react-sparklines": "^1.7.0",
|
"react-sparklines": "^1.7.0",
|
||||||
"react-swipeable-views": "^0.14.0",
|
"react-swipeable-views": "^0.14.0",
|
||||||
@ -113,6 +112,7 @@
|
|||||||
"rollup-plugin-gzip": "^4.1.1",
|
"rollup-plugin-gzip": "^4.1.1",
|
||||||
"rollup-plugin-visualizer": "^6.0.3",
|
"rollup-plugin-visualizer": "^6.0.3",
|
||||||
"sass": "^1.62.1",
|
"sass": "^1.62.1",
|
||||||
|
"scroll-behavior": "^0.11.0",
|
||||||
"stacktrace-js": "^2.0.2",
|
"stacktrace-js": "^2.0.2",
|
||||||
"stringz": "^2.1.0",
|
"stringz": "^2.1.0",
|
||||||
"substring-trie": "^1.0.2",
|
"substring-trie": "^1.0.2",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
55
yarn.lock
55
yarn.lock
@ -2838,7 +2838,6 @@ __metadata:
|
|||||||
react-redux-loading-bar: "npm:^5.0.8"
|
react-redux-loading-bar: "npm:^5.0.8"
|
||||||
react-router: "npm:^5.3.4"
|
react-router: "npm:^5.3.4"
|
||||||
react-router-dom: "npm:^5.3.4"
|
react-router-dom: "npm:^5.3.4"
|
||||||
react-router-scroll-4: "npm:^1.0.0-beta.1"
|
|
||||||
react-select: "npm:^5.7.3"
|
react-select: "npm:^5.7.3"
|
||||||
react-sparklines: "npm:^1.7.0"
|
react-sparklines: "npm:^1.7.0"
|
||||||
react-swipeable-views: "npm:^0.14.0"
|
react-swipeable-views: "npm:^0.14.0"
|
||||||
@ -2851,6 +2850,7 @@ __metadata:
|
|||||||
rollup-plugin-gzip: "npm:^4.1.1"
|
rollup-plugin-gzip: "npm:^4.1.1"
|
||||||
rollup-plugin-visualizer: "npm:^6.0.3"
|
rollup-plugin-visualizer: "npm:^6.0.3"
|
||||||
sass: "npm:^1.62.1"
|
sass: "npm:^1.62.1"
|
||||||
|
scroll-behavior: "npm:^0.11.0"
|
||||||
stacktrace-js: "npm:^2.0.2"
|
stacktrace-js: "npm:^2.0.2"
|
||||||
storybook: "npm:^9.1.1"
|
storybook: "npm:^9.1.1"
|
||||||
stringz: "npm:^2.1.0"
|
stringz: "npm:^2.1.0"
|
||||||
@ -6487,16 +6487,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"dom-helpers@npm:^3.4.0":
|
"dom-helpers@npm:^5.0.1, dom-helpers@npm:^5.1.4, dom-helpers@npm:^5.2.0":
|
||||||
version: 3.4.0
|
|
||||||
resolution: "dom-helpers@npm:3.4.0"
|
|
||||||
dependencies:
|
|
||||||
"@babel/runtime": "npm:^7.1.2"
|
|
||||||
checksum: 10c0/1d2d3e4eadac2c4f4c8c7470a737ab32b7ec28237c4d094ea967ec3184168fd12452196fcc424a5d7860b6176117301aeaecba39467bf1a6e8492a8e5c9639d1
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"dom-helpers@npm:^5.0.1, dom-helpers@npm:^5.2.0":
|
|
||||||
version: 5.2.1
|
version: 5.2.1
|
||||||
resolution: "dom-helpers@npm:5.2.1"
|
resolution: "dom-helpers@npm:5.2.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -10052,6 +10043,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"page-lifecycle@npm:^0.1.2":
|
||||||
|
version: 0.1.2
|
||||||
|
resolution: "page-lifecycle@npm:0.1.2"
|
||||||
|
checksum: 10c0/509dbbc2ad2000dffcf591f66ab13d80fb1dba9337d85c76269173f7a5c3959b5a876e3bfb1e4494f6b932c1dc02a0b5824ebd452ab1a7204d4abdf498cb27c5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"parent-module@npm:^1.0.0":
|
"parent-module@npm:^1.0.0":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "parent-module@npm:1.0.1"
|
resolution: "parent-module@npm:1.0.1"
|
||||||
@ -11293,21 +11291,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-router-scroll-4@npm:^1.0.0-beta.1":
|
|
||||||
version: 1.0.0-beta.2
|
|
||||||
resolution: "react-router-scroll-4@npm:1.0.0-beta.2"
|
|
||||||
dependencies:
|
|
||||||
scroll-behavior: "npm:^0.9.1"
|
|
||||||
warning: "npm:^3.0.0"
|
|
||||||
peerDependencies:
|
|
||||||
prop-types: ^15.6.0
|
|
||||||
react: ^15.0.0 || ^16.0.0
|
|
||||||
react-dom: ^15.0.0 || ^16.0.0
|
|
||||||
react-router-dom: ^4.0
|
|
||||||
checksum: 10c0/ad195b7359fd3146530cf299ec437f0a619c577b2cacfb2c76a156d3cd9d5d3e97af56e17c300c37ca8c485041e93124fe63f0c86db6aea468caf838281e62cb
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"react-router@npm:5.3.4, react-router@npm:^5.3.4":
|
"react-router@npm:5.3.4, react-router@npm:^5.3.4":
|
||||||
version: 5.3.4
|
version: 5.3.4
|
||||||
resolution: "react-router@npm:5.3.4"
|
resolution: "react-router@npm:5.3.4"
|
||||||
@ -12067,13 +12050,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"scroll-behavior@npm:^0.9.1":
|
"scroll-behavior@npm:^0.11.0":
|
||||||
version: 0.9.12
|
version: 0.11.0
|
||||||
resolution: "scroll-behavior@npm:0.9.12"
|
resolution: "scroll-behavior@npm:0.11.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
dom-helpers: "npm:^3.4.0"
|
dom-helpers: "npm:^5.1.4"
|
||||||
invariant: "npm:^2.2.4"
|
invariant: "npm:^2.2.4"
|
||||||
checksum: 10c0/4f438c48b93a1dcc2ab51a18670fac6f5ce41885291d8aa13251b4a187be9d0c6dd518ee974eb52ac9bbe227b9811c2615ecca73192a1a190b78dfdadb9c2cf2
|
page-lifecycle: "npm:^0.1.2"
|
||||||
|
checksum: 10c0/c54010c9fdd9fc360fd7887ecf64f16972f9557ac679723709612cd54fc4778c7433ab46a9637933179ef31471f78e2591fb35351dc0e15537fecf1c8c89d32c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@ -14029,15 +14013,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"warning@npm:^3.0.0":
|
|
||||||
version: 3.0.0
|
|
||||||
resolution: "warning@npm:3.0.0"
|
|
||||||
dependencies:
|
|
||||||
loose-envify: "npm:^1.0.0"
|
|
||||||
checksum: 10c0/6a2a56ab3139d3927193d926a027e74e1449fa47cc692feea95f8a81a4bb5b7f10c312def94cce03f3b58cb26ba3247858e75d17d596451d2c483a62e8204705
|
|
||||||
languageName: node
|
|
||||||
linkType: hard
|
|
||||||
|
|
||||||
"warning@npm:^4.0.1, warning@npm:^4.0.3":
|
"warning@npm:^4.0.1, warning@npm:^4.0.3":
|
||||||
version: 4.0.3
|
version: 4.0.3
|
||||||
resolution: "warning@npm:4.0.3"
|
resolution: "warning@npm:4.0.3"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user