mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-25 17:51:36 +01:00
Merge commit 'cd0ca4b99473f54464e5134f91b7b1c8d5544011' into glitch-soc/merge-upstream
This commit is contained in:
commit
68000956f4
30 changed files with 398 additions and 185 deletions
|
@ -516,7 +516,7 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_job (0.7.2)
|
opentelemetry-instrumentation-active_job (0.7.3)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||||
opentelemetry-instrumentation-active_model_serializers (0.20.1)
|
opentelemetry-instrumentation-active_model_serializers (0.20.1)
|
||||||
|
@ -607,7 +607,7 @@ GEM
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
psych (5.1.2)
|
psych (5.1.2)
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.0)
|
public_suffix (6.0.1)
|
||||||
puma (6.4.2)
|
puma (6.4.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.2)
|
pundit (2.3.2)
|
||||||
|
|
|
@ -13,6 +13,7 @@ module Admin
|
||||||
def show
|
def show
|
||||||
authorize :instance, :show?
|
authorize :instance, :show?
|
||||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||||
|
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|
|
@ -316,8 +316,8 @@ function loaded() {
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
statusEl.dataset.spoiler === 'expanded'
|
statusEl.dataset.spoiler === 'expanded'
|
||||||
? localeData['status.show_less'] ?? 'Show less'
|
? (localeData['status.show_less'] ?? 'Show less')
|
||||||
: localeData['status.show_more'] ?? 'Show more';
|
: (localeData['status.show_more'] ?? 'Show more');
|
||||||
spoilerLink.textContent = new IntlMessageFormat(
|
spoilerLink.textContent = new IntlMessageFormat(
|
||||||
message,
|
message,
|
||||||
locale,
|
locale,
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { browserHistory } from 'mastodon/components/router';
|
||||||
|
|
||||||
import api, { getLinks } from '../api';
|
import api, { getLinks } from '../api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -676,3 +678,13 @@ export const updateAccount = ({ displayName, note, avatar, header, discoverable,
|
||||||
dispatch(importFetchedAccount(response.data));
|
dispatch(importFetchedAccount(response.data));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const navigateToProfile = (accountId) => {
|
||||||
|
return (_dispatch, getState) => {
|
||||||
|
const acct = getState().accounts.getIn([accountId, 'acct']);
|
||||||
|
|
||||||
|
if (acct) {
|
||||||
|
browserHistory.push(`/@${acct}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -122,6 +122,18 @@ export function replyCompose(status) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function replyComposeById(statusId) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const status = state.statuses.get(statusId);
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
const account = state.accounts.get(status.get('account'));
|
||||||
|
dispatch(replyCompose(status.set('account', account)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function cancelReplyCompose() {
|
export function cancelReplyCompose() {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_REPLY_CANCEL,
|
type: COMPOSE_REPLY_CANCEL,
|
||||||
|
@ -154,6 +166,12 @@ export function mentionCompose(account) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mentionComposeById(accountId) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch(mentionCompose(getState().accounts.get(accountId)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function directCompose(account) {
|
export function directCompose(account) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { browserHistory } from 'mastodon/components/router';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||||
|
@ -363,3 +365,15 @@ export const undoStatusTranslation = (id, pollId) => ({
|
||||||
id,
|
id,
|
||||||
pollId,
|
pollId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const navigateToStatus = (statusId) => {
|
||||||
|
return (_dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const accountId = state.statuses.getIn([statusId, 'account']);
|
||||||
|
const acct = state.accounts.getIn([accountId, 'acct']);
|
||||||
|
|
||||||
|
if (acct) {
|
||||||
|
browserHistory.push(`/@${acct}/${statusId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -119,6 +119,7 @@ class Status extends ImmutablePureComponent {
|
||||||
skipPrepend: PropTypes.bool,
|
skipPrepend: PropTypes.bool,
|
||||||
avatarSize: PropTypes.number,
|
avatarSize: PropTypes.number,
|
||||||
deployPictureInPicture: PropTypes.func,
|
deployPictureInPicture: PropTypes.func,
|
||||||
|
unfocusable: PropTypes.bool,
|
||||||
pictureInPicture: ImmutablePropTypes.contains({
|
pictureInPicture: ImmutablePropTypes.contains({
|
||||||
inUse: PropTypes.bool,
|
inUse: PropTypes.bool,
|
||||||
available: PropTypes.bool,
|
available: PropTypes.bool,
|
||||||
|
@ -355,7 +356,7 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
|
const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
|
||||||
|
|
||||||
let { status, account, ...other } = this.props;
|
let { status, account, ...other } = this.props;
|
||||||
|
|
||||||
|
@ -381,8 +382,8 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}>
|
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
||||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||||
<span>{status.get('content')}</span>
|
<span>{status.get('content')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -402,8 +403,8 @@ class Status extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={minHandlers}>
|
<HotKeys handlers={minHandlers} tabIndex={unfocusable ? null : -1}>
|
||||||
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
|
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={unfocusable ? null : 0} ref={this.handleRef}>
|
||||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
|
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
|
||||||
{' '}
|
{' '}
|
||||||
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
||||||
|
@ -550,8 +551,8 @@ class Status extends ImmutablePureComponent {
|
||||||
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
|
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||||
{!skipPrepend && prepend}
|
{!skipPrepend && prepend}
|
||||||
|
|
||||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
|
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
statusId,
|
statusId,
|
||||||
}) => {
|
}) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||||
|
|
||||||
const status = useAppSelector(
|
const status = useAppSelector(
|
||||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
(state) => state.statuses.get(statusId) as Status | undefined,
|
||||||
|
@ -31,11 +32,69 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
state.accounts.get(status?.get('account') as string),
|
state.accounts.get(status?.get('account') as string),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleMouseDown = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||||
if (!account) return;
|
({ clientX, clientY }) => {
|
||||||
|
clickCoordinatesRef.current = [clientX, clientY];
|
||||||
|
},
|
||||||
|
[clickCoordinatesRef],
|
||||||
|
);
|
||||||
|
|
||||||
history.push(`/@${account.acct}/${statusId}`);
|
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||||
}, [statusId, account, history]);
|
({ clientX, clientY, target, button }) => {
|
||||||
|
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
|
||||||
|
const [deltaX, deltaY] = [
|
||||||
|
Math.abs(clientX - startX),
|
||||||
|
Math.abs(clientY - startY),
|
||||||
|
];
|
||||||
|
|
||||||
|
let element: HTMLDivElement | null = target as HTMLDivElement;
|
||||||
|
|
||||||
|
while (element) {
|
||||||
|
if (
|
||||||
|
element.localName === 'button' ||
|
||||||
|
element.localName === 'a' ||
|
||||||
|
element.localName === 'label'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element = element.parentNode as HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaX + deltaY < 5 && button === 0 && account) {
|
||||||
|
history.push(`/@${account.acct}/${statusId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clickCoordinatesRef.current = null;
|
||||||
|
},
|
||||||
|
[clickCoordinatesRef, statusId, account, history],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
const newSrc = emoji.getAttribute('data-original');
|
||||||
|
if (newSrc) emoji.src = newSrc;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
const emojis =
|
||||||
|
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||||
|
|
||||||
|
for (const emoji of emojis) {
|
||||||
|
const newSrc = emoji.getAttribute('data-static');
|
||||||
|
if (newSrc) emoji.src = newSrc;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -51,7 +110,15 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
).size;
|
).size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='notification-group__embedded-status'>
|
<div
|
||||||
|
className='notification-group__embedded-status'
|
||||||
|
role='button'
|
||||||
|
tabIndex={-1}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
<div className='notification-group__embedded-status__account'>
|
<div className='notification-group__embedded-status__account'>
|
||||||
<Avatar account={account} size={16} />
|
<Avatar account={account} size={16} />
|
||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
|
@ -62,7 +129,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
content={contentHtml}
|
content={contentHtml}
|
||||||
language={language}
|
language={language}
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(poll || mediaAttachmentsSize > 0) && (
|
{(poll || mediaAttachmentsSize > 0) && (
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -34,76 +34,10 @@ export const EmbeddedStatusContent: React.FC<{
|
||||||
content: string;
|
content: string;
|
||||||
mentions: List<Mention>;
|
mentions: List<Mention>;
|
||||||
language: string;
|
language: string;
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ content, mentions, language, onClick, className }) => {
|
}> = ({ content, mentions, language, className }) => {
|
||||||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const handleMouseDown = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
|
||||||
({ clientX, clientY }) => {
|
|
||||||
clickCoordinatesRef.current = [clientX, clientY];
|
|
||||||
},
|
|
||||||
[clickCoordinatesRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
|
||||||
({ clientX, clientY, target, button }) => {
|
|
||||||
const [startX, startY] = clickCoordinatesRef.current ?? [0, 0];
|
|
||||||
const [deltaX, deltaY] = [
|
|
||||||
Math.abs(clientX - startX),
|
|
||||||
Math.abs(clientY - startY),
|
|
||||||
];
|
|
||||||
|
|
||||||
let element: HTMLDivElement | null = target as HTMLDivElement;
|
|
||||||
|
|
||||||
while (element) {
|
|
||||||
if (
|
|
||||||
element.localName === 'button' ||
|
|
||||||
element.localName === 'a' ||
|
|
||||||
element.localName === 'label'
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
element = element.parentNode as HTMLDivElement | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deltaX + deltaY < 5 && button === 0 && onClick) {
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
|
|
||||||
clickCoordinatesRef.current = null;
|
|
||||||
},
|
|
||||||
[clickCoordinatesRef, onClick],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
|
||||||
({ currentTarget }) => {
|
|
||||||
const emojis =
|
|
||||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
|
||||||
|
|
||||||
for (const emoji of emojis) {
|
|
||||||
const newSrc = emoji.getAttribute('data-original');
|
|
||||||
if (newSrc) emoji.src = newSrc;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
|
||||||
({ currentTarget }) => {
|
|
||||||
const emojis =
|
|
||||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
|
||||||
|
|
||||||
for (const emoji of emojis) {
|
|
||||||
const newSrc = emoji.getAttribute('data-static');
|
|
||||||
if (newSrc) emoji.src = newSrc;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleContentRef = useCallback(
|
const handleContentRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
|
@ -150,16 +84,10 @@ export const EmbeddedStatusContent: React.FC<{
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role='button'
|
|
||||||
tabIndex={0}
|
|
||||||
className={className}
|
className={className}
|
||||||
ref={handleContentRef}
|
ref={handleContentRef}
|
||||||
lang={language}
|
lang={language}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,8 +2,10 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
|
import { navigateToProfile } from 'mastodon/actions/accounts';
|
||||||
|
import { mentionComposeById } from 'mastodon/actions/compose';
|
||||||
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
import type { NotificationGroup as NotificationGroupModel } from 'mastodon/models/notification_group';
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { NotificationAdminReport } from './notification_admin_report';
|
import { NotificationAdminReport } from './notification_admin_report';
|
||||||
import { NotificationAdminSignUp } from './notification_admin_sign_up';
|
import { NotificationAdminSignUp } from './notification_admin_sign_up';
|
||||||
|
@ -30,6 +32,13 @@ export const NotificationGroup: React.FC<{
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const accountId =
|
||||||
|
notificationGroup?.type === 'gap'
|
||||||
|
? undefined
|
||||||
|
: notificationGroup?.sampleAccountIds[0];
|
||||||
|
|
||||||
const handlers = useMemo(
|
const handlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
moveUp: () => {
|
moveUp: () => {
|
||||||
|
@ -39,8 +48,16 @@ export const NotificationGroup: React.FC<{
|
||||||
moveDown: () => {
|
moveDown: () => {
|
||||||
onMoveDown(notificationGroupId);
|
onMoveDown(notificationGroupId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openProfile: () => {
|
||||||
|
if (accountId) dispatch(navigateToProfile(accountId));
|
||||||
|
},
|
||||||
|
|
||||||
|
mention: () => {
|
||||||
|
if (accountId) dispatch(mentionComposeById(accountId));
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
[notificationGroupId, onMoveUp, onMoveDown],
|
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||||
|
|
|
@ -2,9 +2,14 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
|
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||||
import type { IconProp } from 'mastodon/components/icon';
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { AvatarGroup } from './avatar_group';
|
import { AvatarGroup } from './avatar_group';
|
||||||
import { EmbeddedStatus } from './embedded_status';
|
import { EmbeddedStatus } from './embedded_status';
|
||||||
|
@ -39,6 +44,8 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
type,
|
type,
|
||||||
unread,
|
unread,
|
||||||
}) => {
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const label = useMemo(
|
const label = useMemo(
|
||||||
() =>
|
() =>
|
||||||
labelRenderer({
|
labelRenderer({
|
||||||
|
@ -53,39 +60,54 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
[labelRenderer, accountIds, count, labelSeeMoreHref],
|
[labelRenderer, accountIds, count, labelSeeMoreHref],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlers = useMemo(
|
||||||
|
() => ({
|
||||||
|
open: () => {
|
||||||
|
dispatch(navigateToStatus(statusId));
|
||||||
|
},
|
||||||
|
|
||||||
|
reply: () => {
|
||||||
|
dispatch(replyComposeById(statusId));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[dispatch, statusId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<HotKeys handlers={handlers}>
|
||||||
role='button'
|
<div
|
||||||
className={classNames(
|
role='button'
|
||||||
`notification-group focusable notification-group--${type}`,
|
className={classNames(
|
||||||
{ 'notification-group--unread': unread },
|
`notification-group focusable notification-group--${type}`,
|
||||||
)}
|
{ 'notification-group--unread': unread },
|
||||||
tabIndex={0}
|
)}
|
||||||
>
|
tabIndex={0}
|
||||||
<div className='notification-group__icon'>
|
>
|
||||||
<Icon icon={icon} id={iconId} />
|
<div className='notification-group__icon'>
|
||||||
</div>
|
<Icon icon={icon} id={iconId} />
|
||||||
|
|
||||||
<div className='notification-group__main'>
|
|
||||||
<div className='notification-group__main__header'>
|
|
||||||
<div className='notification-group__main__header__wrapper'>
|
|
||||||
<AvatarGroup accountIds={accountIds} />
|
|
||||||
|
|
||||||
{actions}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='notification-group__main__header__label'>
|
|
||||||
{label}
|
|
||||||
{timestamp && <RelativeTimestamp timestamp={timestamp} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statusId && (
|
<div className='notification-group__main'>
|
||||||
<div className='notification-group__main__status'>
|
<div className='notification-group__main__header'>
|
||||||
<EmbeddedStatus statusId={statusId} />
|
<div className='notification-group__main__header__wrapper'>
|
||||||
|
<AvatarGroup accountIds={accountIds} />
|
||||||
|
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='notification-group__main__header__label'>
|
||||||
|
{label}
|
||||||
|
{timestamp && <RelativeTimestamp timestamp={timestamp} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{statusId && (
|
||||||
|
<div className='notification-group__main__status'>
|
||||||
|
<EmbeddedStatus statusId={statusId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</HotKeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,10 +2,18 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
|
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||||
|
import {
|
||||||
|
navigateToStatus,
|
||||||
|
toggleStatusSpoilers,
|
||||||
|
} from 'mastodon/actions/statuses';
|
||||||
import type { IconProp } from 'mastodon/components/icon';
|
import type { IconProp } from 'mastodon/components/icon';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import Status from 'mastodon/containers/status_container';
|
import Status from 'mastodon/containers/status_container';
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { NamesList } from './names_list';
|
import { NamesList } from './names_list';
|
||||||
import type { LabelRenderer } from './notification_group_with_status';
|
import type { LabelRenderer } from './notification_group_with_status';
|
||||||
|
@ -29,6 +37,8 @@ export const NotificationWithStatus: React.FC<{
|
||||||
type,
|
type,
|
||||||
unread,
|
unread,
|
||||||
}) => {
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const label = useMemo(
|
const label = useMemo(
|
||||||
() =>
|
() =>
|
||||||
labelRenderer({
|
labelRenderer({
|
||||||
|
@ -41,33 +51,61 @@ export const NotificationWithStatus: React.FC<{
|
||||||
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
|
(state) => state.statuses.getIn([statusId, 'visibility']) === 'direct',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const handlers = useMemo(
|
||||||
<div
|
() => ({
|
||||||
role='button'
|
open: () => {
|
||||||
className={classNames(
|
dispatch(navigateToStatus(statusId));
|
||||||
`notification-ungrouped focusable notification-ungrouped--${type}`,
|
},
|
||||||
{
|
|
||||||
'notification-ungrouped--unread': unread,
|
|
||||||
'notification-ungrouped--direct': isPrivateMention,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className='notification-ungrouped__header'>
|
|
||||||
<div className='notification-ungrouped__header__icon'>
|
|
||||||
<Icon icon={icon} id={iconId} />
|
|
||||||
</div>
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Status
|
reply: () => {
|
||||||
// @ts-expect-error -- <Status> is not yet typed
|
dispatch(replyComposeById(statusId));
|
||||||
id={statusId}
|
},
|
||||||
contextType='notifications'
|
|
||||||
withDismiss
|
boost: () => {
|
||||||
skipPrepend
|
dispatch(toggleReblog(statusId));
|
||||||
avatarSize={40}
|
},
|
||||||
/>
|
|
||||||
</div>
|
favourite: () => {
|
||||||
|
dispatch(toggleFavourite(statusId));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleHidden: () => {
|
||||||
|
dispatch(toggleStatusSpoilers(statusId));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[dispatch, statusId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HotKeys handlers={handlers}>
|
||||||
|
<div
|
||||||
|
role='button'
|
||||||
|
className={classNames(
|
||||||
|
`notification-ungrouped focusable notification-ungrouped--${type}`,
|
||||||
|
{
|
||||||
|
'notification-ungrouped--unread': unread,
|
||||||
|
'notification-ungrouped--direct': isPrivateMention,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className='notification-ungrouped__header'>
|
||||||
|
<div className='notification-ungrouped__header__icon'>
|
||||||
|
<Icon icon={icon} id={iconId} />
|
||||||
|
</div>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Status
|
||||||
|
// @ts-expect-error -- <Status> is not yet typed
|
||||||
|
id={statusId}
|
||||||
|
contextType='notifications'
|
||||||
|
withDismiss
|
||||||
|
skipPrepend
|
||||||
|
avatarSize={40}
|
||||||
|
unfocusable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -10457,6 +10457,8 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__embedded-status {
|
&__embedded-status {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&__account {
|
&__account {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -10478,7 +10480,6 @@ noscript {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
color: $dark-text-color;
|
color: $dark-text-color;
|
||||||
cursor: pointer;
|
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
max-height: 4 * 22px;
|
max-height: 4 * 22px;
|
||||||
|
|
|
@ -6,6 +6,8 @@ class Webfinger
|
||||||
class RedirectError < Error; end
|
class RedirectError < Error; end
|
||||||
|
|
||||||
class Response
|
class Response
|
||||||
|
ACTIVITYPUB_READY_TYPE = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].freeze
|
||||||
|
|
||||||
attr_reader :uri
|
attr_reader :uri
|
||||||
|
|
||||||
def initialize(uri, body)
|
def initialize(uri, body)
|
||||||
|
@ -20,17 +22,28 @@ class Webfinger
|
||||||
end
|
end
|
||||||
|
|
||||||
def link(rel, attribute)
|
def link(rel, attribute)
|
||||||
links.dig(rel, attribute)
|
links.dig(rel, 0, attribute)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self_link_href
|
||||||
|
self_link.fetch('href')
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def links
|
def links
|
||||||
@links ||= @json.fetch('links', []).index_by { |link| link['rel'] }
|
@links ||= @json.fetch('links', []).group_by { |link| link['rel'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self_link
|
||||||
|
links.fetch('self', []).find do |link|
|
||||||
|
ACTIVITYPUB_READY_TYPE.include?(link['type'])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_response!
|
def validate_response!
|
||||||
raise Webfinger::Error, "Missing subject in response for #{@uri}" if subject.blank?
|
raise Webfinger::Error, "Missing subject in response for #{@uri}" if subject.blank?
|
||||||
|
raise Webfinger::Error, "Missing self link in response for #{@uri}" if self_link.blank?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,14 @@ class Admin::ActionLogFilter
|
||||||
action_type
|
action_type
|
||||||
account_id
|
account_id
|
||||||
target_account_id
|
target_account_id
|
||||||
|
target_domain
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
INSTANCE_TARGET_TYPES = %w(
|
||||||
|
DomainBlock
|
||||||
|
DomainAllow
|
||||||
|
Instance
|
||||||
|
UnavailableDomain
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
ACTION_TYPE_MAP = {
|
ACTION_TYPE_MAP = {
|
||||||
|
@ -95,6 +103,9 @@ class Admin::ActionLogFilter
|
||||||
when 'target_account_id'
|
when 'target_account_id'
|
||||||
account = Account.find_or_initialize_by(id: value)
|
account = Account.find_or_initialize_by(id: value)
|
||||||
latest_action_logs.where(target: [account, account.user].compact)
|
latest_action_logs.where(target: [account, account.user].compact)
|
||||||
|
when 'target_domain'
|
||||||
|
normalized_domain = TagManager.instance.normalize_domain(value)
|
||||||
|
latest_action_logs.where(human_identifier: normalized_domain, target_type: INSTANCE_TARGET_TYPES)
|
||||||
else
|
else
|
||||||
raise Mastodon::InvalidParameterError, "Unknown filter: #{key}"
|
raise Mastodon::InvalidParameterError, "Unknown filter: #{key}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
||||||
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
|
confirmed_username, confirmed_domain = split_acct(webfinger.subject)
|
||||||
|
|
||||||
if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
|
if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
|
||||||
raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri
|
raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.self_link_href != @uri
|
||||||
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
@ -58,8 +58,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
|
||||||
@username, @domain = split_acct(webfinger.subject)
|
@username, @domain = split_acct(webfinger.subject)
|
||||||
|
|
||||||
raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{@uri} (stopped at #{@username}@#{@domain})" unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
|
raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{@uri} (stopped at #{@username}@#{@domain})" unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
|
||||||
|
raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.self_link_href != @uri
|
||||||
raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri
|
|
||||||
rescue Webfinger::RedirectError => e
|
rescue Webfinger::RedirectError => e
|
||||||
raise Error, e.message
|
raise Error, e.message
|
||||||
rescue Webfinger::Error => e
|
rescue Webfinger::Error => e
|
||||||
|
|
|
@ -106,8 +106,6 @@ class ResolveAccountService < BaseService
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_account!
|
def fetch_account!
|
||||||
return unless activitypub_ready?
|
|
||||||
|
|
||||||
with_redis_lock("resolve:#{@username}@#{@domain}") do
|
with_redis_lock("resolve:#{@username}@#{@domain}") do
|
||||||
@account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
|
@account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
|
||||||
end
|
end
|
||||||
|
@ -122,12 +120,8 @@ class ResolveAccountService < BaseService
|
||||||
@options[:skip_cache] || @account.nil? || @account.possibly_stale?
|
@options[:skip_cache] || @account.nil? || @account.possibly_stale?
|
||||||
end
|
end
|
||||||
|
|
||||||
def activitypub_ready?
|
|
||||||
['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type'))
|
|
||||||
end
|
|
||||||
|
|
||||||
def actor_url
|
def actor_url
|
||||||
@actor_url ||= @webfinger.link('self', 'href')
|
@actor_url ||= @webfinger.self_link_href
|
||||||
end
|
end
|
||||||
|
|
||||||
def gone_from_origin?
|
def gone_from_origin?
|
||||||
|
|
|
@ -114,6 +114,16 @@
|
||||||
- if @instance.persisted?
|
- if @instance.persisted?
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|
||||||
|
%h3= t('admin.instances.audit_log.title')
|
||||||
|
- if @action_logs.empty?
|
||||||
|
%p= t('accounts.nothing_here')
|
||||||
|
- else
|
||||||
|
.report-notes
|
||||||
|
= render partial: 'admin/action_logs/action_log', collection: @action_logs
|
||||||
|
= link_to t('admin.instances.audit_log.view_all'), admin_action_logs_path(target_domain: @instance.domain), class: 'button'
|
||||||
|
|
||||||
|
%hr.spacer/
|
||||||
|
|
||||||
%h3= t('admin.instances.availability.title')
|
%h3= t('admin.instances.availability.title')
|
||||||
|
|
||||||
%p
|
%p
|
||||||
|
|
|
@ -471,6 +471,9 @@ en:
|
||||||
title: Follow recommendations
|
title: Follow recommendations
|
||||||
unsuppress: Restore follow recommendation
|
unsuppress: Restore follow recommendation
|
||||||
instances:
|
instances:
|
||||||
|
audit_log:
|
||||||
|
title: Recent Audit Logs
|
||||||
|
view_all: View full audit logs
|
||||||
availability:
|
availability:
|
||||||
description_html:
|
description_html:
|
||||||
one: If delivering to the domain fails <strong>%{count} day</strong> without succeeding, no further delivery attempts will be made unless a delivery <em>from</em> the domain is received.
|
one: If delivering to the domain fails <strong>%{count} day</strong> without succeeding, no further delivery attempts will be made unless a delivery <em>from</em> the domain is received.
|
||||||
|
|
|
@ -9,11 +9,13 @@ namespace :mastodon do
|
||||||
[
|
[
|
||||||
['App Libraries', 'app/lib'],
|
['App Libraries', 'app/lib'],
|
||||||
%w(Presenters app/presenters),
|
%w(Presenters app/presenters),
|
||||||
|
%w(Policies app/policies),
|
||||||
|
%w(Serializers app/serializers),
|
||||||
%w(Services app/services),
|
%w(Services app/services),
|
||||||
%w(Validators app/validators),
|
%w(Validators app/validators),
|
||||||
%w(Workers app/workers),
|
%w(Workers app/workers),
|
||||||
].each do |name, dir|
|
].each do |name, dir|
|
||||||
STATS_DIRECTORIES << [name, Rails.root.join(dir)]
|
STATS_DIRECTORIES << [name, dir]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -190,7 +190,7 @@
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-environment-jsdom": "^29.5.0",
|
"jest-environment-jsdom": "^29.5.0",
|
||||||
"lint-staged": "^15.0.0",
|
"lint-staged": "^15.0.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.3.3",
|
||||||
"react-test-renderer": "^18.2.0",
|
"react-test-renderer": "^18.2.0",
|
||||||
"stylelint": "^16.0.2",
|
"stylelint": "^16.0.2",
|
||||||
"stylelint-config-standard-scss": "^13.0.0",
|
"stylelint-config-standard-scss": "^13.0.0",
|
||||||
|
|
|
@ -37,10 +37,32 @@ RSpec.describe Admin::InstancesController do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET #show' do
|
describe 'GET #show' do
|
||||||
|
before do
|
||||||
|
allow(Admin::ActionLogFilter).to receive(:new).and_call_original
|
||||||
|
end
|
||||||
|
|
||||||
it 'shows an instance page' do
|
it 'shows an instance page' do
|
||||||
get :show, params: { id: account_popular_main.domain }
|
get :show, params: { id: account_popular_main.domain }
|
||||||
|
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
|
|
||||||
|
instance = assigns(:instance)
|
||||||
|
expect(instance).to_not be_new_record
|
||||||
|
|
||||||
|
expect(Admin::ActionLogFilter).to have_received(:new).with(target_domain: account_popular_main.domain)
|
||||||
|
|
||||||
|
action_logs = assigns(:action_logs).to_a
|
||||||
|
expect(action_logs.size).to eq 0
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with an unknown domain' do
|
||||||
|
it 'returns http success' do
|
||||||
|
get :show, params: { id: 'unknown.example' }
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
|
||||||
|
instance = assigns(:instance)
|
||||||
|
expect(instance).to be_new_record
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4,4 +4,4 @@ Content-Type: application/jrd+json; charset=utf-8
|
||||||
X-Content-Type-Options: nosniff
|
X-Content-Type-Options: nosniff
|
||||||
Date: Sun, 17 Sep 2017 06:22:50 GMT
|
Date: Sun, 17 Sep 2017 06:22:50 GMT
|
||||||
|
|
||||||
{"subject":"acct:foo@ap.example.com","aliases":["https://ap.example.com/@foo","https://ap.example.com/users/foo"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://ap.example.com/@foo"},{"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"https://ap.example.com/users/foo.atom"},{"rel":"self","type":"application/activity+json","href":"https://ap.example.com/users/foo"},{"rel":"salmon","href":"https://ap.example.com/api/salmon/1"},{"rel":"magic-public-key","href":"data:application/magic-public-key,RSA.u3L4vnpNLzVH31MeWI394F0wKeJFsLDAsNXGeOu0QF2x-h1zLWZw_agqD2R3JPU9_kaDJGPIV2Sn5zLyUA9S6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh8lDET6X4Pyw-ZJU0_OLo_41q9w-OrGtlsTm_PuPIeXnxa6BLqnDaxC-4IcjG_FiPahNCTINl_1F_TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq-t8nhQYkgAkt64euWpva3qL5KD1mTIZQEP-LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3QvuHQ==.AQAB"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://ap.example.com/authorize_follow?acct={uri}"}]}
|
{"subject":"acct:foo@ap.example.com","aliases":["https://ap.example.com/@foo","https://ap.example.com/users/foo"],"links":[{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"https://ap.example.com/@foo"},{"rel":"http://schemas.google.com/g/2010#updates-from","type":"application/atom+xml","href":"https://ap.example.com/users/foo.atom"},{"rel":"self","type":"application/html","href":"https://ap.example.com/users/foo.html"},{"rel":"self","type":"application/activity+json","href":"https://ap.example.com/users/foo"},{"rel":"self","type":"application/json","href":"https://ap.example.com/users/foo.json"},{"rel":"salmon","href":"https://ap.example.com/api/salmon/1"},{"rel":"magic-public-key","href":"data:application/magic-public-key,RSA.u3L4vnpNLzVH31MeWI394F0wKeJFsLDAsNXGeOu0QF2x-h1zLWZw_agqD2R3JPU9_kaDJGPIV2Sn5zLyUA9S6swCCMOtn7BBR9g9sucgXJmUFB0tACH2QSgHywMAybGfmSb3LsEMNKsGJ9VsvYoh8lDET6X4Pyw-ZJU0_OLo_41q9w-OrGtlsTm_PuPIeXnxa6BLqnDaxC-4IcjG_FiPahNCTINl_1F_TgSSDZ4Taf4U9XFEIFw8wmgploELozzIzKq-t8nhQYkgAkt64euWpva3qL5KD1mTIZQEP-LZvh3s2WHrLi3fhbdRuwQ2c0KkJA2oSTFPDpqqbPGZ3QvuHQ==.AQAB"},{"rel":"http://ostatus.org/schema/1.0/subscribe","template":"https://ap.example.com/authorize_follow?acct={uri}"}]}
|
2
spec/fixtures/requests/webfinger.txt
vendored
2
spec/fixtures/requests/webfinger.txt
vendored
|
@ -8,4 +8,4 @@ Access-Control-Allow-Origin: *
|
||||||
Vary: Accept-Encoding,Cookie
|
Vary: Accept-Encoding,Cookie
|
||||||
Strict-Transport-Security: max-age=31536000; includeSubdomains;
|
Strict-Transport-Security: max-age=31536000; includeSubdomains;
|
||||||
|
|
||||||
{"subject":"acct:gargron@quitter.no","aliases":["https:\/\/quitter.no\/user\/7477","https:\/\/quitter.no\/gargron","https:\/\/quitter.no\/index.php\/user\/7477","https:\/\/quitter.no\/index.php\/gargron"],"links":[{"rel":"http:\/\/webfinger.net\/rel\/profile-page","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/gmpg.org\/xfn\/11","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"describedby","type":"application\/rdf+xml","href":"https:\/\/quitter.no\/gargron\/foaf"},{"rel":"http:\/\/apinamespace.org\/atom","type":"application\/atomsvc+xml","href":"https:\/\/quitter.no\/api\/statusnet\/app\/service\/gargron.xml"},{"rel":"http:\/\/apinamespace.org\/twitter","href":"https:\/\/quitter.no\/api\/"},{"rel":"http:\/\/specs.openid.net\/auth\/2.0\/provider","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/schemas.google.com\/g\/2010#updates-from","type":"application\/atom+xml","href":"https:\/\/quitter.no\/api\/statuses\/user_timeline\/7477.atom"},{"rel":"magic-public-key","href":"data:application\/magic-public-key,RSA.1ZBkHTavLvxH3FzlKv4O6WtlILKRFfNami3_Rcu8EuogtXSYiS-bB6hElZfUCSHbC4uLemOA34PEhz__CDMozax1iI_t8dzjDnh1x0iFSup7pSfW9iXk_WU3Dm74yWWW2jildY41vWgrEstuQ1dJ8vVFfSJ9T_tO4c-T9y8vDI8=.AQAB"},{"rel":"salmon","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-replies","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-mention","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/ostatus.org\/schema\/1.0\/subscribe","template":"https:\/\/quitter.no\/main\/ostatussub?profile={uri}"}]}
|
{"subject":"acct:gargron@quitter.no","aliases":["https:\/\/quitter.no\/user\/7477","https:\/\/quitter.no\/gargron","https:\/\/quitter.no\/index.php\/user\/7477","https:\/\/quitter.no\/index.php\/gargron"],"links":[{"rel":"http:\/\/webfinger.net\/rel\/profile-page","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/gmpg.org\/xfn\/11","type":"text\/html","href":"https:\/\/quitter.no\/gargron"},{"rel":"describedby","type":"application\/rdf+xml","href":"https:\/\/quitter.no\/gargron\/foaf"},{"rel":"self","type":"application/activity+json","href":"https://ap.example.com/users/foo"},{"rel":"http:\/\/apinamespace.org\/atom","type":"application\/atomsvc+xml","href":"https:\/\/quitter.no\/api\/statusnet\/app\/service\/gargron.xml"},{"rel":"http:\/\/apinamespace.org\/twitter","href":"https:\/\/quitter.no\/api\/"},{"rel":"http:\/\/specs.openid.net\/auth\/2.0\/provider","href":"https:\/\/quitter.no\/gargron"},{"rel":"http:\/\/schemas.google.com\/g\/2010#updates-from","type":"application\/atom+xml","href":"https:\/\/quitter.no\/api\/statuses\/user_timeline\/7477.atom"},{"rel":"magic-public-key","href":"data:application\/magic-public-key,RSA.1ZBkHTavLvxH3FzlKv4O6WtlILKRFfNami3_Rcu8EuogtXSYiS-bB6hElZfUCSHbC4uLemOA34PEhz__CDMozax1iI_t8dzjDnh1x0iFSup7pSfW9iXk_WU3Dm74yWWW2jildY41vWgrEstuQ1dJ8vVFfSJ9T_tO4c-T9y8vDI8=.AQAB"},{"rel":"salmon","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-replies","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/salmon-protocol.org\/ns\/salmon-mention","href":"https:\/\/quitter.no\/main\/salmon\/user\/7477"},{"rel":"http:\/\/ostatus.org\/schema\/1.0\/subscribe","template":"https:\/\/quitter.no\/main\/ostatussub?profile={uri}"}]}
|
||||||
|
|
41
spec/lib/webfinger_spec.rb
Normal file
41
spec/lib/webfinger_spec.rb
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Webfinger do
|
||||||
|
describe 'self link' do
|
||||||
|
context 'when self link is specified with type application/activity+json' do
|
||||||
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
|
it 'correctly parses the response' do
|
||||||
|
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
|
||||||
|
response = described_class.new('acct:alice@example.com').perform
|
||||||
|
|
||||||
|
expect(response.self_link_href).to eq 'https://example.com/alice'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when self link is specified with type application/ld+json' do
|
||||||
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' }] } }
|
||||||
|
|
||||||
|
it 'correctly parses the response' do
|
||||||
|
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
|
||||||
|
response = described_class.new('acct:alice@example.com').perform
|
||||||
|
|
||||||
|
expect(response.self_link_href).to eq 'https://example.com/alice'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when self link is specified with incorrect type' do
|
||||||
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/json"' }] } }
|
||||||
|
|
||||||
|
it 'raises an error' do
|
||||||
|
stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
|
||||||
|
|
||||||
|
expect { described_class.new('acct:alice@example.com').perform }.to raise_error(Webfinger::Error)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -33,7 +33,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the account does not have a inbox' do
|
context 'when the account does not have a inbox' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
actor[:inbox] = nil
|
actor[:inbox] = nil
|
||||||
|
@ -51,7 +51,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when URI and WebFinger share the same host' do
|
context 'when URI and WebFinger share the same host' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
@ -72,7 +72,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when WebFinger presents different domain than URI' do
|
context 'when WebFinger presents different domain than URI' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
@ -95,7 +95,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when WebFinger returns a different URI' do
|
context 'when WebFinger returns a different URI' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
@ -111,7 +111,7 @@ RSpec.describe ActivityPub::FetchRemoteAccountService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when WebFinger returns a different URI after a redirection' do
|
context 'when WebFinger returns a different URI after a redirection' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
|
|
@ -33,7 +33,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when the account does not have a inbox' do
|
context 'when the account does not have a inbox' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
actor[:inbox] = nil
|
actor[:inbox] = nil
|
||||||
|
@ -51,7 +51,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when URI and WebFinger share the same host' do
|
context 'when URI and WebFinger share the same host' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
@ -72,7 +72,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when WebFinger presents different domain than URI' do
|
context 'when WebFinger presents different domain than URI' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
@ -95,7 +95,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when WebFinger returns a different URI' do
|
context 'when WebFinger returns a different URI' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
@ -111,7 +111,7 @@ RSpec.describe ActivityPub::FetchRemoteActorService do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when WebFinger returns a different URI after a redirection' do
|
context 'when WebFinger returns a different URI after a redirection' do
|
||||||
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob' }] } }
|
let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/bob', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor), headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
|
|
@ -5,7 +5,7 @@ require 'rails_helper'
|
||||||
RSpec.describe ActivityPub::FetchRemoteKeyService do
|
RSpec.describe ActivityPub::FetchRemoteKeyService do
|
||||||
subject { described_class.new }
|
subject { described_class.new }
|
||||||
|
|
||||||
let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice', type: 'application/activity+json' }] } }
|
||||||
|
|
||||||
let(:public_key_pem) do
|
let(:public_key_pem) do
|
||||||
<<~TEXT
|
<<~TEXT
|
||||||
|
|
|
@ -215,7 +215,7 @@ RSpec.describe ActivityPub::ProcessAccountService do
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
webfinger = {
|
webfinger = {
|
||||||
subject: "acct:user#{i}@foo.test",
|
subject: "acct:user#{i}@foo.test",
|
||||||
links: [{ rel: 'self', href: "https://foo.test/users/#{i}" }],
|
links: [{ rel: 'self', href: "https://foo.test/users/#{i}", type: 'application/activity+json' }],
|
||||||
}.with_indifferent_access
|
}.with_indifferent_access
|
||||||
stub_request(:get, "https://foo.test/users/#{i}").to_return(status: 200, body: actor_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, "https://foo.test/users/#{i}").to_return(status: 200, body: actor_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
stub_request(:get, "https://foo.test/users/#{i}/featured").to_return(status: 200, body: featured_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
stub_request(:get, "https://foo.test/users/#{i}/featured").to_return(status: 200, body: featured_json.to_json, headers: { 'Content-Type': 'application/activity+json' })
|
||||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -2874,7 +2874,7 @@ __metadata:
|
||||||
postcss: "npm:^8.4.24"
|
postcss: "npm:^8.4.24"
|
||||||
postcss-loader: "npm:^4.3.0"
|
postcss-loader: "npm:^4.3.0"
|
||||||
postcss-preset-env: "npm:^9.5.2"
|
postcss-preset-env: "npm:^9.5.2"
|
||||||
prettier: "npm:^3.0.0"
|
prettier: "npm:^3.3.3"
|
||||||
prop-types: "npm:^15.8.1"
|
prop-types: "npm:^15.8.1"
|
||||||
punycode: "npm:^2.3.0"
|
punycode: "npm:^2.3.0"
|
||||||
react: "npm:^18.2.0"
|
react: "npm:^18.2.0"
|
||||||
|
@ -14153,12 +14153,12 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prettier@npm:^3.0.0":
|
"prettier@npm:^3.3.3":
|
||||||
version: 3.3.2
|
version: 3.3.3
|
||||||
resolution: "prettier@npm:3.3.2"
|
resolution: "prettier@npm:3.3.3"
|
||||||
bin:
|
bin:
|
||||||
prettier: bin/prettier.cjs
|
prettier: bin/prettier.cjs
|
||||||
checksum: 10c0/39ed27d17f0238da6dd6571d63026566bd790d3d0edac57c285fbab525982060c8f1e01955fe38134ab10f0951a6076da37f015db8173c02f14bc7f0803a384c
|
checksum: 10c0/b85828b08e7505716324e4245549b9205c0cacb25342a030ba8885aba2039a115dbcf75a0b7ca3b37bc9d101ee61fab8113fc69ca3359f2a226f1ecc07ad2e26
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue