Merge commit 'cffa8de6267a57673b7f6264fab671153f965d23' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2026-03-19 12:38:31 +01:00
46 changed files with 738 additions and 419 deletions

View File

@@ -181,7 +181,7 @@ FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
ARG VIPS_VERSION=8.18.0
ARG VIPS_VERSION=8.18.1
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download

14
Gemfile
View File

@@ -109,14 +109,14 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false
gem 'opentelemetry-instrumentation-excon', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-faraday', '~> 0.32.0', require: false
gem 'opentelemetry-instrumentation-http', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-http_client', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-net_http', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.40.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false

View File

@@ -150,7 +150,7 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
capybara-playwright-driver (0.5.8)
capybara-playwright-driver (0.5.9)
addressable
capybara
playwright-ruby-client (>= 1.16.0)
@@ -446,7 +446,7 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2026.0303)
mime-types-data (3.2026.0317)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.2)
@@ -525,7 +525,7 @@ GEM
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.6.1)
opentelemetry-instrumentation-active_support (~> 0.10)
opentelemetry-instrumentation-action_pack (0.15.1)
opentelemetry-instrumentation-action_pack (0.16.0)
opentelemetry-instrumentation-rack (~> 0.29)
opentelemetry-instrumentation-action_view (0.11.2)
opentelemetry-instrumentation-active_support (~> 0.10)
@@ -545,23 +545,23 @@ GEM
opentelemetry-registry (~> 0.1)
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-excon (0.27.0)
opentelemetry-instrumentation-excon (0.28.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-faraday (0.31.0)
opentelemetry-instrumentation-faraday (0.32.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-http (0.28.0)
opentelemetry-instrumentation-http (0.29.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-http_client (0.27.0)
opentelemetry-instrumentation-http_client (0.28.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-net_http (0.27.0)
opentelemetry-instrumentation-net_http (0.28.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-pg (0.35.0)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-processor
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-rack (0.29.0)
opentelemetry-instrumentation-rack (0.30.0)
opentelemetry-instrumentation-base (~> 0.25)
opentelemetry-instrumentation-rails (0.39.1)
opentelemetry-instrumentation-rails (0.40.0)
opentelemetry-instrumentation-action_mailer (~> 0.6)
opentelemetry-instrumentation-action_pack (~> 0.15)
opentelemetry-instrumentation-action_view (~> 0.11)
@@ -922,7 +922,7 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.26.1)
webmock (3.26.2)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -1023,14 +1023,14 @@ DEPENDENCIES
opentelemetry-instrumentation-active_job (~> 0.10.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
opentelemetry-instrumentation-excon (~> 0.27.0)
opentelemetry-instrumentation-faraday (~> 0.31.0)
opentelemetry-instrumentation-http (~> 0.28.0)
opentelemetry-instrumentation-http_client (~> 0.27.0)
opentelemetry-instrumentation-net_http (~> 0.27.0)
opentelemetry-instrumentation-excon (~> 0.28.0)
opentelemetry-instrumentation-faraday (~> 0.32.0)
opentelemetry-instrumentation-http (~> 0.29.0)
opentelemetry-instrumentation-http_client (~> 0.28.0)
opentelemetry-instrumentation-net_http (~> 0.28.0)
opentelemetry-instrumentation-pg (~> 0.35.0)
opentelemetry-instrumentation-rack (~> 0.29.0)
opentelemetry-instrumentation-rails (~> 0.39.0)
opentelemetry-instrumentation-rack (~> 0.30.0)
opentelemetry-instrumentation-rails (~> 0.40.0)
opentelemetry-instrumentation-redis (~> 0.28.0)
opentelemetry-instrumentation-sidekiq (~> 0.28.0)
opentelemetry-sdk (~> 1.4)

View File

@@ -1,14 +1,20 @@
import { createRoot } from 'react-dom/client';
import { IntlMessageFormat } from 'intl-messageformat';
import type { MessageDescriptor, PrimitiveType } from 'react-intl';
import type {
FormatDateOptions,
IntlShape,
MessageDescriptor,
PrimitiveType,
} from 'react-intl';
import { defineMessages } from 'react-intl';
import axios from 'axios';
import { on } from 'delegated-events';
import { throttle } from 'lodash';
import { timeAgoString } from '../mastodon/components/relative_timestamp';
import { formatTime } from '@/mastodon/utils/time';
import emojify from '../mastodon/features/emoji/emoji';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
import { loadLocale, getLocale } from '../mastodon/locales';
@@ -58,7 +64,7 @@ function loaded() {
const formatMessage = (
{ id, defaultMessage }: MessageDescriptor,
values?: Record<string, PrimitiveType>,
) => {
): string => {
let message: string | undefined = undefined;
if (id) message = localeData[id];
@@ -126,23 +132,23 @@ function loaded() {
.querySelectorAll<HTMLTimeElement>('time.time-ago')
.forEach((content) => {
const datetime = new Date(content.dateTime);
const now = new Date();
const timeGiven = content.dateTime.includes('T');
content.title = timeGiven
? dateTimeFormat.format(datetime)
: dateFormat.format(datetime);
content.textContent = timeAgoString(
{
formatMessage,
formatDate: (date: Date, options) =>
const now = Date.now();
content.textContent = formatTime({
// We don't want to show future dates.
timestamp: Math.min(datetime.getTime(), now),
now,
intl: {
formatMessage: formatMessage as IntlShape['formatMessage'],
formatDate: (date: Date, options: FormatDateOptions) =>
new Intl.DateTimeFormat(locale, options).format(date),
},
datetime,
now.getTime(),
now.getFullYear(),
timeGiven,
);
noTime: !timeGiven,
});
});
updateDefaultQuotePrivacyFromPrivacy(

View File

@@ -275,7 +275,7 @@ export const Account: React.FC<AccountProps> = ({
if (account?.mute_expires_at) {
muteTimeRemaining = (
<>
· <RelativeTimestamp timestamp={account.mute_expires_at} futureDate />
· <RelativeTimestamp timestamp={account.mute_expires_at} />
</>
);
}

View File

@@ -60,10 +60,7 @@ export const EditedTimestamp: React.FC<{
const renderItem = useCallback(
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
const formattedDate = (
<RelativeTimestamp
timestamp={item.get('created_at') as string}
short={false}
/>
<RelativeTimestamp timestamp={item.get('created_at') as string} long />
);
const formattedName = (
<InlineAccount accountId={item.get('account') as string} />

View File

@@ -70,7 +70,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
if (expired) {
return intl.formatMessage(messages.closed);
}
return <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
return <RelativeTimestamp timestamp={poll.expires_at} />;
}, [expired, intl, poll]);
const votesCount = useMemo(() => {
if (!poll) {

View File

@@ -1,288 +0,0 @@
import { Component } from 'react';
import type { MessageDescriptor, PrimitiveType, IntlShape } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({
today: { id: 'relative_time.today', defaultMessage: 'today' },
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
just_now_full: {
id: 'relative_time.full.just_now',
defaultMessage: 'just now',
},
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
seconds_full: {
id: 'relative_time.full.seconds',
defaultMessage: '{number, plural, one {# second} other {# seconds}} ago',
},
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
minutes_full: {
id: 'relative_time.full.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago',
},
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
hours_full: {
id: 'relative_time.full.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}} ago',
},
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
days_full: {
id: 'relative_time.full.days',
defaultMessage: '{number, plural, one {# day} other {# days}} ago',
},
moments_remaining: {
id: 'time_remaining.moments',
defaultMessage: 'Moments remaining',
},
seconds_remaining: {
id: 'time_remaining.seconds',
defaultMessage: '{number, plural, one {# second} other {# seconds}} left',
},
minutes_remaining: {
id: 'time_remaining.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}} left',
},
hours_remaining: {
id: 'time_remaining.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}} left',
},
days_remaining: {
id: 'time_remaining.days',
defaultMessage: '{number, plural, one {# day} other {# days}} left',
},
});
const dateFormatOptions = {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
} as const;
const shortDateFormatOptions = {
month: 'short',
day: 'numeric',
} as const;
const SECOND = 1000;
const MINUTE = 1000 * 60;
const HOUR = 1000 * 60 * 60;
const DAY = 1000 * 60 * 60 * 24;
const MAX_DELAY = 2147483647;
const selectUnits = (delta: number) => {
const absDelta = Math.abs(delta);
if (absDelta < MINUTE) {
return 'second';
} else if (absDelta < HOUR) {
return 'minute';
} else if (absDelta < DAY) {
return 'hour';
}
return 'day';
};
const getUnitDelay = (units: string) => {
switch (units) {
case 'second':
return SECOND;
case 'minute':
return MINUTE;
case 'hour':
return HOUR;
case 'day':
return DAY;
default:
return MAX_DELAY;
}
};
export const timeAgoString = (
intl: {
formatDate: IntlShape['formatDate'];
formatMessage: (
{ id, defaultMessage }: MessageDescriptor,
values?: Record<string, PrimitiveType>,
) => string;
},
date: Date,
now: number,
year: number,
timeGiven: boolean,
short?: boolean,
) => {
const delta = now - date.getTime();
let relativeTime;
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(
short ? messages.just_now : messages.just_now_full,
);
} else if (delta < 7 * DAY) {
if (delta < MINUTE) {
relativeTime = intl.formatMessage(
short ? messages.seconds : messages.seconds_full,
{ number: Math.floor(delta / SECOND) },
);
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(
short ? messages.minutes : messages.minutes_full,
{ number: Math.floor(delta / MINUTE) },
);
} else if (delta < DAY) {
relativeTime = intl.formatMessage(
short ? messages.hours : messages.hours_full,
{ number: Math.floor(delta / HOUR) },
);
} else {
relativeTime = intl.formatMessage(
short ? messages.days : messages.days_full,
{ number: Math.floor(delta / DAY) },
);
}
} else if (date.getFullYear() === year) {
relativeTime = intl.formatDate(date, shortDateFormatOptions);
} else {
relativeTime = intl.formatDate(date, {
...shortDateFormatOptions,
year: 'numeric',
});
}
return relativeTime;
};
const timeRemainingString = (
intl: IntlShape,
date: Date,
now: number,
timeGiven = true,
) => {
const delta = date.getTime() - now;
let relativeTime;
if (delta < DAY && !timeGiven) {
relativeTime = intl.formatMessage(messages.today);
} else if (delta < 10 * SECOND) {
relativeTime = intl.formatMessage(messages.moments_remaining);
} else if (delta < MINUTE) {
relativeTime = intl.formatMessage(messages.seconds_remaining, {
number: Math.floor(delta / SECOND),
});
} else if (delta < HOUR) {
relativeTime = intl.formatMessage(messages.minutes_remaining, {
number: Math.floor(delta / MINUTE),
});
} else if (delta < DAY) {
relativeTime = intl.formatMessage(messages.hours_remaining, {
number: Math.floor(delta / HOUR),
});
} else {
relativeTime = intl.formatMessage(messages.days_remaining, {
number: Math.floor(delta / DAY),
});
}
return relativeTime;
};
interface Props {
intl: IntlShape;
timestamp: string;
year?: number;
futureDate?: boolean;
short?: boolean;
}
interface States {
now: number;
}
class RelativeTimestamp extends Component<Props, States> {
state = {
now: Date.now(),
};
_timer: number | undefined;
shouldComponentUpdate(nextProps: Props, nextState: States) {
// As of right now the locale doesn't change without a new page load,
// but we might as well check in case that ever changes.
return (
this.props.timestamp !== nextProps.timestamp ||
this.props.intl.locale !== nextProps.intl.locale ||
this.state.now !== nextState.now
);
}
UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (this.props.timestamp !== nextProps.timestamp) {
this.setState({ now: Date.now() });
}
}
componentDidMount() {
this._scheduleNextUpdate(this.props, this.state);
}
UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) {
this._scheduleNextUpdate(nextProps, nextState);
}
componentWillUnmount() {
window.clearTimeout(this._timer);
}
_scheduleNextUpdate(props: Props, state: States) {
window.clearTimeout(this._timer);
const { timestamp } = props;
const delta = new Date(timestamp).getTime() - state.now;
const unitDelay = getUnitDelay(selectUnits(delta));
const unitRemainder = Math.abs(delta % unitDelay);
const updateInterval = 1000 * 10;
const delay =
delta < 0
? Math.max(updateInterval, unitDelay - unitRemainder)
: Math.max(updateInterval, unitRemainder);
this._timer = window.setTimeout(() => {
this.setState({ now: Date.now() });
}, delay);
}
render() {
const {
timestamp,
intl,
futureDate,
year = new Date().getFullYear(),
short = true,
} = this.props;
const timeGiven = timestamp.includes('T');
const date = new Date(timestamp);
const relativeTime = futureDate
? timeRemainingString(intl, date, this.state.now, timeGiven)
: timeAgoString(intl, date, this.state.now, year, timeGiven, short);
return (
<time
dateTime={timestamp}
title={intl.formatDate(date, dateFormatOptions)}
>
{relativeTime}
</time>
);
}
}
const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp);
export { RelativeTimestampWithIntl as RelativeTimestamp };

View File

@@ -0,0 +1,83 @@
import { useEffect, useMemo, useState } from 'react';
import type { FC } from 'react';
import { useIntl } from 'react-intl';
import {
formatTime,
MAX_TIMEOUT,
relativeTimeParts,
SECOND,
unitToTime,
} from '@/mastodon/utils/time';
const dateFormatOptions = {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
} as const;
export const RelativeTimestamp: FC<{
timestamp: string;
long?: boolean;
noTime?: boolean;
noFuture?: boolean;
}> = ({ timestamp, long = false, noTime = false, noFuture = false }) => {
const intl = useIntl();
const [now, setNow] = useState(() => Date.now());
const date = useMemo(() => {
const date = new Date(timestamp);
return noFuture ? new Date(Math.min(date.getTime(), now)) : date;
}, [noFuture, now, timestamp]);
const ts = date.getTime();
useEffect(() => {
let timeoutId = 0;
const scheduleNextUpdate = () => {
const { unit, delta } = relativeTimeParts(ts);
const unitDelay = unitToTime(unit);
const remainder = Math.abs(delta % unitDelay);
const minDelay = 10 * SECOND;
const delay = Math.min(
Math.max(delta < 0 ? unitDelay - remainder : remainder, minDelay),
MAX_TIMEOUT,
);
timeoutId = window.setTimeout(() => {
setNow(Date.now());
scheduleNextUpdate();
}, delay);
};
scheduleNextUpdate();
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [ts]);
const daysOnly = !timestamp.includes('T') || noTime;
const relativeTime = useMemo(
() =>
formatTime({
timestamp: ts,
intl,
short: !long,
noTime: daysOnly,
now,
}),
[ts, intl, long, daysOnly, now],
);
return (
<time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
{relativeTime}
</time>
);
};

View File

@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { DAY } from '@/mastodon/utils/time';
import { RelativeTimestamp } from './index';
const meta = {
title: 'Components/RelativeTimestamp',
component: RelativeTimestamp,
args: {
timestamp: new Date(Date.now() - DAY * 3).toISOString(),
long: false,
noTime: false,
noFuture: false,
},
argTypes: {
timestamp: {
control: 'date',
},
},
render(props) {
const { timestamp } = props;
const dateString = toDateString(timestamp);
return <RelativeTimestamp {...props} timestamp={dateString} />;
},
} satisfies Meta<typeof RelativeTimestamp>;
export default meta;
type Story = StoryObj<typeof RelativeTimestamp>;
export const Plain: Story = {};
export const Long: Story = {
args: {
long: true,
},
};
export const DateOnly: Story = {
args: {
noTime: true,
},
};
export const NoFuture: Story = {
args: {
timestamp: new Date(Date.now() + DAY * 3).toISOString(),
noFuture: true,
},
};
// Storybook has a known bug with changing a date control from a string to number.
function toDateString(timestamp?: number | string) {
if (!timestamp) {
return new Date().toISOString();
}
if (typeof timestamp === 'number') {
return new Date(timestamp).toISOString();
}
return timestamp;
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
@@ -18,9 +19,7 @@ import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountNumberFields: FC<{ accountId: string }> = ({
accountId,
}) => {
const LegacyNumberFields: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
@@ -29,23 +28,16 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
}
return (
<div
className={classNames(
'account__header__extra__links',
isRedesignEnabled() && classes.fieldNumbersWrapper,
)}
>
{!isRedesignEnabled() && (
<NavLink
to={`/@${account.acct}`}
title={intl.formatNumber(account.statuses_count)}
>
<ShortNumber
value={account.statuses_count}
renderer={StatusesCounter}
/>
</NavLink>
)}
<div className='account__header__extra__links'>
<NavLink
to={`/@${account.acct}`}
title={intl.formatNumber(account.statuses_count)}
>
<ShortNumber
value={account.statuses_count}
renderer={StatusesCounter}
/>
</NavLink>
<NavLink
exact
@@ -68,25 +60,80 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
renderer={FollowersCounter}
/>
</NavLink>
{isRedesignEnabled() && (
<FormattedMessage
id='account.joined_long'
defaultMessage='Joined on {date}'
values={{
date: (
<strong>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</strong>
),
}}
/>
)}
</div>
);
};
const RedesignNumberFields: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
const createdThisYear = useMemo(
() => account?.created_at.includes(new Date().getFullYear().toString()),
[account?.created_at],
);
if (!account) {
return null;
}
return (
<ul
className={classNames(
'account__header__extra__links',
classes.fieldNumbersWrapper,
)}
>
<li>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
<strong>
<ShortNumber value={account.statuses_count} />
</strong>
</li>
<li>
<NavLink
exact
to={`/@${account.acct}/followers`}
title={intl.formatNumber(account.followers_count)}
>
<FormattedMessage id='account.followers' defaultMessage='Followers' />
<strong>
<ShortNumber value={account.followers_count} />
</strong>
</NavLink>
</li>
<li>
<NavLink
exact
to={`/@${account.acct}/following`}
title={intl.formatNumber(account.following_count)}
>
<FormattedMessage id='account.following' defaultMessage='Following' />
<strong>
<ShortNumber value={account.following_count} />
</strong>
</NavLink>
</li>
<li>
<FormattedMessage id='account.joined_short' defaultMessage='Joined' />
<strong>
{createdThisYear ? (
<FormattedDateWrapper
value={account.created_at}
month='short'
day='2-digit'
/>
) : (
<FormattedDateWrapper value={account.created_at} year='numeric' />
)}
</strong>
</li>
</ul>
);
};
export const AccountNumberFields = isRedesignEnabled()
? RedesignNumberFields
: LegacyNumberFields;

View File

@@ -308,16 +308,34 @@ svg.badgeIcon {
}
.fieldNumbersWrapper {
display: flex;
font-size: 13px;
padding: 0;
margin: 8px 0;
gap: 20px;
li {
@container (width < 420px) {
flex: 1 1 0px;
}
}
a {
color: inherit;
font-weight: unset;
padding: 0;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
}
}
strong {
display: block;
font-weight: 600;
color: var(--color-text-primary);
font-size: 15px;
}
}

View File

@@ -53,12 +53,7 @@ export const CollectionMetaData: React.FC<{
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
date: <RelativeTimestamp timestamp={collection.updated_at} long />,
}}
tagName='li'
/>

View File

@@ -49,7 +49,7 @@ class Report extends ImmutablePureComponent {
<div className='notification__report__details'>
<div>
<RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {# post} other {# posts}} attached' values={{ count: report.get('status_ids').size }} />
<RelativeTimestamp timestamp={report.get('created_at')} long /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {# post} other {# posts}} attached' values={{ count: report.get('status_ids').size }} />
<br />
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
</div>

View File

@@ -50,7 +50,7 @@ class CompareHistoryModal extends PureComponent {
const content = currentVersion.get('content');
const spoilerContent = escapeTextContentForBrowser(currentVersion.get('spoiler_text'));
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} long />;
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
const label = currentVersion.get('original') ? (

View File

@@ -73,7 +73,6 @@
"account.go_to_profile": "Go to profile",
"account.hide_reblogs": "Hide boosts from @{name}",
"account.in_memoriam": "In Memoriam.",
"account.joined_long": "Joined on {date}",
"account.joined_short": "Joined",
"account.languages": "Change subscribed languages",
"account.link_verified_on": "Ownership of this link was checked on {date}",

View File

@@ -188,7 +188,7 @@
"account_edit.image_alt_modal.details_content": "TEE NÄIN: <ul> <li>Kuvaile itseäsi kuvan mukaisesti</li> <li>Käytä kolmannen persoonan muotoja (esim. ”Aleksi” eikä ”minä”)</li> <li>Ole ytimekäs usein muutama sana riittää</li> </ul> ÄLÄ TEE NÄIN: <ul> <li>Aloita sanalla ”Kuva” sen on tarpeetonta näytönlukuohjelmille</li> </ul> ESIMERKKI: <ul> <li>”Aleksi yllään vihreä paita ja lasit”</li> </ul>",
"account_edit.image_alt_modal.details_title": "Vinkkejä: profiilikuvien tekstivastineet",
"account_edit.image_alt_modal.edit_title": "Muokkaa tekstivastinetta",
"account_edit.image_alt_modal.text_hint": "Teksivastine auttaa näytönlukuohjelmien käyttäjiä ymmärtämään sisältöä.",
"account_edit.image_alt_modal.text_hint": "Tekstivastine auttaa näytönlukuohjelmien käyttäjiä ymmärtämään sisältöä.",
"account_edit.image_alt_modal.text_label": "Tekstivastine",
"account_edit.image_delete_modal.confirm": "Haluatko varmasti poistaa tämän kuvan? Tätä toimea ei voi kumota.",
"account_edit.image_delete_modal.delete_button": "Poista",

View File

@@ -184,6 +184,15 @@
"account_edit.field_reorder_modal.drag_start": "Réimse \"{item}\" bailithe.",
"account_edit.field_reorder_modal.handle_label": "Tarraing réimse \"{item}\"",
"account_edit.field_reorder_modal.title": "Athshocraigh réimsí",
"account_edit.image_alt_modal.add_title": "Cuir téacs malartach leis",
"account_edit.image_alt_modal.details_content": "DÉAN: <ul> <li>Déan cur síos ort féin mar atá sa phictiúr</li> <li>Úsáid teanga an tríú pearsa (m.sh. “Alex” in ionad “mise”)</li> <li>Bí gonta is minic a bhíonn cúpla focal leordhóthanach</li> </ul> NÁ DÉAN: <ul> <li>Tosaigh le “Grianghraf de” tá sé iomarcach do léitheoirí scáileáin</li> </ul> SAMPLA: <ul> <li>“Alex ag caitheamh léine ghlas agus spéaclaí”</li> </ul>",
"account_edit.image_alt_modal.details_title": "Leideanna: Téacs malartach do ghrianghraif phróifíle",
"account_edit.image_alt_modal.edit_title": "Cuir téacs alt in eagar",
"account_edit.image_alt_modal.text_hint": "Cuidíonn téacs malartach le húsáideoirí léitheoirí scáileáin dábhar a thuiscint.",
"account_edit.image_alt_modal.text_label": "Téacs malartach",
"account_edit.image_delete_modal.confirm": "An bhfuil tú cinnte gur mian leat an íomhá seo a scriosadh? Ní féidir an gníomh seo a chealú.",
"account_edit.image_delete_modal.delete_button": "Scrios",
"account_edit.image_delete_modal.title": "Íomhá a scriosadh?",
"account_edit.image_edit.add_button": "Cuir íomhá leis",
"account_edit.image_edit.alt_add_button": "Cuir téacs alt leis",
"account_edit.image_edit.alt_edit_button": "Cuir téacs alt in eagar",
@@ -203,6 +212,16 @@
"account_edit.profile_tab.subtitle": "Saincheap na cluaisíní ar do phróifíl agus a bhfuil á thaispeáint iontu.",
"account_edit.profile_tab.title": "Socruithe an chluaisín próifíle",
"account_edit.save": "Sábháil",
"account_edit.upload_modal.back": "Ar ais",
"account_edit.upload_modal.done": "Déanta",
"account_edit.upload_modal.next": "Ar Aghaidh",
"account_edit.upload_modal.step_crop.zoom": "Zúmáil",
"account_edit.upload_modal.step_upload.button": "Brabhsáil comhaid",
"account_edit.upload_modal.step_upload.dragging": "Scaoil le huaslódáil",
"account_edit.upload_modal.step_upload.header": "Roghnaigh íomhá",
"account_edit.upload_modal.step_upload.hint": "Formáid WEBP, PNG, GIF nó JPG, suas le {limit}MB.{br}Scálfar an íomhá go {width}x{height}px.",
"account_edit.upload_modal.title_add": "Cuir grianghraf próifíle leis",
"account_edit.upload_modal.title_replace": "Athsholáthar grianghraf próifíle",
"account_edit.verified_modal.details": "Cuir creidiúnacht le do phróifíl Mastodon trí naisc chuig láithreáin ghréasáin phearsanta a fhíorú. Seo mar a oibríonn sé:",
"account_edit.verified_modal.invisible_link.details": "Cuir an nasc le do cheanntásc. Is í an chuid thábhachtach ná rel=\"me\" a chuireann cosc ar phearsanú ar shuíomhanna gréasáin a bhfuil inneachar a ghintear ag úsáideoirí. Is féidir leat clib nasc a úsáid fiú i gceanntásc an leathanaigh in ionad {tag}, ach caithfidh an HTML a bheith inrochtana gan JavaScript a chur i gcrích.",
"account_edit.verified_modal.invisible_link.summary": "Conas a dhéanaim an nasc dofheicthe?",
@@ -374,6 +393,7 @@
"collections.search_accounts_max_reached": "Tá an líon uasta cuntas curtha leis agat",
"collections.sensitive": "Íogair",
"collections.topic_hint": "Cuir haischlib leis a chabhraíonn le daoine eile príomhábhar an bhailiúcháin seo a thuiscint.",
"collections.topic_special_chars_hint": "Bainfear carachtair speisialta agus tú ag sábháil",
"collections.view_collection": "Féach ar bhailiúchán",
"collections.view_other_collections_by_user": "Féach ar bhailiúcháin eile ón úsáideoir seo",
"collections.visibility_public": "Poiblí",

View File

@@ -153,16 +153,51 @@
"account_edit.column_title": "Rediger profil",
"account_edit.custom_fields.name": "felt",
"account_edit.custom_fields.placeholder": "Legg til pronomen, lenkjer eller kva du elles vil dela.",
"account_edit.custom_fields.reorder_button": "Omorganiser felter",
"account_edit.custom_fields.tip_content": "Du kan enkelt øke troverdighet til Mastodon-kontoen din ved å verifisere koblinger til nettsider du eier.",
"account_edit.custom_fields.tip_title": "Legg til bekreftede lenker",
"account_edit.custom_fields.reorder_button": "Omorganiser felt",
"account_edit.custom_fields.tip_content": "Du kan auka truverdet til Mastodon-kontoen din ved å stadfesta lenker til nettstader du eig.",
"account_edit.custom_fields.tip_title": "Tips: Legg til stadfesta lenker",
"account_edit.custom_fields.title": "Eigne felt",
"account_edit.custom_fields.verified_hint": "Hvordan legger jeg til en verifisert lenke?",
"account_edit.custom_fields.verified_hint": "Korleis legg eg til ei stadfesta lenke?",
"account_edit.display_name.placeholder": "Det synlege namnet ditt er det som syner på profilen din og i tidsliner.",
"account_edit.display_name.title": "Synleg namn",
"account_edit.featured_hashtags.item": "emneknaggar",
"account_edit.featured_hashtags.placeholder": "Hjelp andre å finna og få rask tilgang til favorittemna dine.",
"account_edit.featured_hashtags.title": "Utvalde emneknaggar",
"account_edit.field_delete_modal.confirm": "Vil du sletta dette tilpassa feltet? Du kan ikkje angra.",
"account_edit.field_delete_modal.delete_button": "Slett",
"account_edit.field_delete_modal.title": "Slett tilpassa felt?",
"account_edit.field_edit_modal.add_title": "Legg til eit tilpassa felt",
"account_edit.field_edit_modal.edit_title": "Rediger tilpassa felt",
"account_edit.field_edit_modal.limit_header": "Over maksgrensa for teikn",
"account_edit.field_edit_modal.limit_message": "Det er ikkje sikkert mobilbrukarar ser heile feltet ditt.",
"account_edit.field_edit_modal.link_emoji_warning": "Me rår frå å bruka eigne smilefjes kombinert med adresser. Tilpassa felt som inneheld båe, vil syna som berre tekst i staden for ei lenke, slik at lesarane ikkje blir forvirra.",
"account_edit.field_edit_modal.name_hint": "Til dømes «Personleg nettstad»",
"account_edit.field_edit_modal.name_label": "Etikett",
"account_edit.field_edit_modal.url_warning": "Skriv {protocol} i starten for å leggja til ei lenke.",
"account_edit.field_edit_modal.value_hint": "Til dømes «https://nettstad.no»",
"account_edit.field_edit_modal.value_label": "Verdi",
"account_edit.field_reorder_modal.drag_cancel": "Du avbraut draginga. Feltet «{item}» vart sleppt.",
"account_edit.field_reorder_modal.drag_end": "Feltet «{item}» vart sleppt.",
"account_edit.field_reorder_modal.drag_instructions": "For å flytta eigne felt, trykkjer du mellomrom eller enter. Bruk piltastane når du dreg for å flytta feltet opp eller ned. Trykk mellomrom eller enter ein gong til for å sleppa feltet på den nye plassen, eller trykk escape for å avbryta.",
"account_edit.field_reorder_modal.drag_move": "Feltet «{item}» vart flytt.",
"account_edit.field_reorder_modal.drag_over": "Feltet «{item}» vart flytt over «{over}».",
"account_edit.field_reorder_modal.drag_start": "Plukka opp feltet «{item}».",
"account_edit.field_reorder_modal.handle_label": "Dra feltet «{item}»",
"account_edit.field_reorder_modal.title": "Flytt felt",
"account_edit.image_alt_modal.add_title": "Legg til skildring",
"account_edit.image_alt_modal.details_content": "JA: <ul> <li>Skildre deg sjølv på biletet</li> <li>Bruk tredjeperson (til dømes «Anne» i staden for «meg»)</li> <li>Skriv stutt</li> </ul> NEI: <ul> <li>Start med «bilete av». Skjermlesarar treng ikkje det.</li> </ul> DØME: <ul> <li>«Anne har på seg ei grøn skjorte og briller»</li> </ul>",
"account_edit.image_alt_modal.details_title": "Tips: Skrildre profilbilete",
"account_edit.image_alt_modal.edit_title": "Rediger skildring",
"account_edit.image_alt_modal.text_hint": "Skildringar hjelper folk som bruker skjermlesarar å forstå deg.",
"account_edit.image_alt_modal.text_label": "Skildring",
"account_edit.image_delete_modal.confirm": "Vil du sletta dette biletet? Du kan ikkje angra.",
"account_edit.image_delete_modal.delete_button": "Slett",
"account_edit.image_delete_modal.title": "Slett bilete?",
"account_edit.image_edit.add_button": "Legg til bilete",
"account_edit.image_edit.alt_add_button": "Legg til skildring",
"account_edit.image_edit.alt_edit_button": "Rediger skildring",
"account_edit.image_edit.remove_button": "Fjern bilete",
"account_edit.image_edit.replace_button": "Erstatt bilete",
"account_edit.name_modal.add_title": "Legg til synleg namn",
"account_edit.name_modal.edit_title": "Endre synleg namn",
"account_edit.profile_tab.button_label": "Tilpass",
@@ -177,6 +212,16 @@
"account_edit.profile_tab.subtitle": "Tilpass fanene på profilen din og kva dei syner.",
"account_edit.profile_tab.title": "Innstillingar for profilfane",
"account_edit.save": "Lagre",
"account_edit.upload_modal.back": "Tilbake",
"account_edit.upload_modal.done": "Ferdig",
"account_edit.upload_modal.next": "Neste",
"account_edit.upload_modal.step_crop.zoom": "Forstørre",
"account_edit.upload_modal.step_upload.button": "Sjå gjennom filer",
"account_edit.upload_modal.step_upload.dragging": "Slepp for å lasta opp",
"account_edit.upload_modal.step_upload.header": "Vel eit bilete",
"account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF eller JPG-format, opp til {limit}MB.{br}Biletet blir skalert til {width}*{height} punkt.",
"account_edit.upload_modal.title_add": "Legg til profilbilete",
"account_edit.upload_modal.title_replace": "Byt ut profilbilete",
"account_edit_tags.add_tag": "Legg til #{tagName}",
"account_edit_tags.column_title": "Rediger utvalde emneknaggar",
"account_edit_tags.help_text": "Utvalde emneknaggar hjelper folk å oppdaga og samhandla med profilen din. Dei blir viste som filter på aktivitetsoversikta på profilsida di.",

View File

@@ -212,7 +212,7 @@
"account_edit.profile_tab.subtitle": "自定义你个人资料的标签页及其显示的内容。",
"account_edit.profile_tab.title": "个人资料标签页设置",
"account_edit.save": "保存",
"account_edit.upload_modal.back": "返回",
"account_edit.upload_modal.back": "上一步",
"account_edit.upload_modal.done": "完成",
"account_edit.upload_modal.next": "下一步",
"account_edit.upload_modal.step_crop.zoom": "缩放",

View File

@@ -0,0 +1,36 @@
import { DAY, HOUR, MINUTE, relativeTimeParts, SECOND } from './time';
describe('relativeTimeParts', () => {
const now = Date.now();
test.concurrent.each([
// Now
[0, { value: 0, unit: 'second' }],
// Past
[-30 * SECOND, { value: -30, unit: 'second' }],
[-90 * SECOND, { value: -2, unit: 'minute' }],
[-30 * MINUTE, { value: -30, unit: 'minute' }],
[-90 * MINUTE, { value: -2, unit: 'hour' }],
[-5 * HOUR, { value: -5, unit: 'hour' }],
[-24 * HOUR, { value: -1, unit: 'day' }],
[-36 * HOUR, { value: -1, unit: 'day' }],
[-47 * HOUR, { value: -2, unit: 'day' }],
[-3 * DAY, { value: -3, unit: 'day' }],
// Future
[SECOND, { value: 1, unit: 'second' }],
[59 * SECOND, { value: 59, unit: 'second' }],
[MINUTE, { value: 1, unit: 'minute' }],
[MINUTE + SECOND, { value: 1, unit: 'minute' }],
[59 * MINUTE, { value: 59, unit: 'minute' }],
[HOUR, { value: 1, unit: 'hour' }],
[HOUR + MINUTE, { value: 1, unit: 'hour' }],
[23 * HOUR, { value: 23, unit: 'hour' }],
[DAY, { value: 1, unit: 'day' }],
[DAY + HOUR, { value: 1, unit: 'day' }],
[2 * DAY, { value: 2, unit: 'day' }],
])('should return correct value and unit for %d ms', (input, expected) => {
expect(relativeTimeParts(now + input, now)).toMatchObject(expected);
});
});

View File

@@ -0,0 +1,246 @@
import type { IntlShape } from 'react-intl';
import { defineMessages } from 'react-intl';
export const SECOND = 1000;
export const MINUTE = SECOND * 60;
export const HOUR = MINUTE * 60;
export const DAY = HOUR * 24;
export const MAX_TIMEOUT = 2147483647; // Maximum delay for setTimeout in browsers (approximately 24.8 days)
export type TimeUnit = 'second' | 'minute' | 'hour' | 'day';
export function relativeTimeParts(
ts: number,
now = Date.now(),
): { value: number; unit: TimeUnit; delta: number } {
const delta = ts - now;
const absDelta = Math.abs(delta);
if (absDelta < MINUTE) {
return { value: Math.floor(delta / SECOND), unit: 'second', delta };
}
if (absDelta < HOUR) {
return { value: Math.floor(delta / MINUTE), unit: 'minute', delta };
}
if (absDelta < DAY) {
return { value: Math.floor(delta / HOUR), unit: 'hour', delta };
}
// Round instead of use floor as days are big enough that the value is usually off by a few hours.
return { value: Math.round(delta / DAY), unit: 'day', delta };
}
export function isToday(ts: number, now = Date.now()): boolean {
const date = new Date(ts);
const nowDate = new Date(now);
return (
date.getDate() === nowDate.getDate() &&
date.getMonth() === nowDate.getMonth() &&
date.getFullYear() === nowDate.getFullYear()
);
}
export function isSameYear(ts: number, now = Date.now()): boolean {
const date = new Date(ts);
const nowDate = new Date(now);
return date.getFullYear() === nowDate.getFullYear();
}
export function unitToTime(unit: TimeUnit): number {
switch (unit) {
case 'second':
return SECOND;
case 'minute':
return MINUTE;
case 'hour':
return HOUR;
case 'day':
return DAY;
}
}
const timeMessages = defineMessages({
today: { id: 'relative_time.today', defaultMessage: 'today' },
just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
just_now_full: {
id: 'relative_time.full.just_now',
defaultMessage: 'just now',
},
seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
seconds_full: {
id: 'relative_time.full.seconds',
defaultMessage: '{number, plural, one {# second} other {# seconds}} ago',
},
minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
minutes_full: {
id: 'relative_time.full.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago',
},
hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
hours_full: {
id: 'relative_time.full.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}} ago',
},
days: { id: 'relative_time.days', defaultMessage: '{number}d' },
days_full: {
id: 'relative_time.full.days',
defaultMessage: '{number, plural, one {# day} other {# days}} ago',
},
moments_remaining: {
id: 'time_remaining.moments',
defaultMessage: 'Moments remaining',
},
seconds_remaining: {
id: 'time_remaining.seconds',
defaultMessage: '{number, plural, one {# second} other {# seconds}} left',
},
minutes_remaining: {
id: 'time_remaining.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}} left',
},
hours_remaining: {
id: 'time_remaining.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}} left',
},
days_remaining: {
id: 'time_remaining.days',
defaultMessage: '{number, plural, one {# day} other {# days}} left',
},
});
const DAYS_LIMIT = 7;
const NOW_SECONDS = 10;
export function formatTime({
timestamp,
intl,
now = Date.now(),
noTime = false,
short = false,
}: {
timestamp: number;
intl: Pick<IntlShape, 'formatDate' | 'formatMessage'>;
now?: number;
noTime?: boolean;
short?: boolean;
}) {
const { value, unit } = relativeTimeParts(timestamp, now);
// If we're only showing days, show "today" for the current day.
if (noTime && isToday(timestamp, now)) {
return intl.formatMessage(timeMessages.today);
}
if (value > 0) {
return formatFuture({ value, unit, intl });
}
if (unit === 'day' && value < -DAYS_LIMIT) {
return formatAbsoluteTime({ timestamp, intl, now });
}
return formatRelativePastTime({ value, unit, intl, short });
}
export function formatAbsoluteTime({
timestamp,
intl,
now = Date.now(),
}: {
timestamp: number;
intl: Pick<IntlShape, 'formatDate'>;
now?: number;
}) {
return intl.formatDate(timestamp, {
month: 'short',
day: 'numeric',
// Only show the year if it's different from the current year.
year: isSameYear(timestamp, now) ? undefined : 'numeric',
});
}
export function formatFuture({
unit,
value,
intl,
}: {
value: number;
unit: TimeUnit;
intl: Pick<IntlShape, 'formatMessage'>;
}) {
if (unit === 'day') {
return intl.formatMessage(timeMessages.days_remaining, { number: value });
}
if (unit === 'hour') {
return intl.formatMessage(timeMessages.hours_remaining, {
number: value,
});
}
if (unit === 'minute') {
return intl.formatMessage(timeMessages.minutes_remaining, {
number: value,
});
}
if (value > NOW_SECONDS) {
return intl.formatMessage(timeMessages.seconds_remaining, {
number: value,
});
}
return intl.formatMessage(timeMessages.moments_remaining);
}
export function formatRelativePastTime({
value,
unit,
intl,
short = false,
}: {
value: number;
unit: TimeUnit;
intl: Pick<IntlShape, 'formatMessage'>;
short?: boolean;
}) {
const absValue = Math.abs(value);
if (unit === 'day') {
return intl.formatMessage(
short ? timeMessages.days : timeMessages.days_full,
{
number: absValue,
},
);
}
if (unit === 'hour') {
return intl.formatMessage(
short ? timeMessages.hours : timeMessages.hours_full,
{
number: absValue,
},
);
}
if (unit === 'minute') {
return intl.formatMessage(
short ? timeMessages.minutes : timeMessages.minutes_full,
{ number: absValue },
);
}
if (absValue >= NOW_SECONDS) {
return intl.formatMessage(
short ? timeMessages.seconds : timeMessages.seconds_full,
{ number: absValue },
);
}
return intl.formatMessage(
short ? timeMessages.just_now : timeMessages.just_now_full,
);
}

View File

@@ -85,6 +85,11 @@ $fluid-breakpoint: $maximum-width + 20px;
display: none;
}
&[aria-expanded='true'] .icon-expand,
&[aria-expanded='false'] .icon-collapse {
display: none;
}
.icon {
width: 1lh;
height: 1lh;

View File

@@ -17,7 +17,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
add_collection
else
@collection = @account.collections.find_by(uri: @json['target'])
@collection = @account.collections.find_by(uri: value_or_id(@json['target']))
add_collection_item if @collection && Mastodon::Feature.collections_federation_enabled?
end
end

View File

@@ -9,6 +9,11 @@ class BackupService < BaseService
CHUNK_SIZE = 1.megabyte
PLACEHOLDER = '!PLACEHOLDER!'
STREAM_ACTOR = 'actor.json'
STREAM_BOOKMARKS = 'bookmarks.json'
STREAM_LIKES = 'likes.json'
STREAM_OUTBOX = 'outbox.json'
attr_reader :account, :backup
def call(backup)
@@ -21,7 +26,7 @@ class BackupService < BaseService
private
def build_outbox_json!(file)
skeleton = serialize(collection_presenter, ActivityPub::CollectionSerializer)
skeleton = serialize(collection_presenter(STREAM_OUTBOX, size: account.statuses.count), ActivityPub::CollectionSerializer)
skeleton[:@context] = full_context
skeleton[:orderedItems] = [PLACEHOLDER]
skeleton = JSON.generate(skeleton)
@@ -55,17 +60,9 @@ class BackupService < BaseService
def build_archive!
tmp_file = Tempfile.new(%w(archive .zip))
Zip::File.open(tmp_file, create: true) do |zipfile|
dump_outbox!(zipfile)
dump_media_attachments!(zipfile)
dump_likes!(zipfile)
dump_bookmarks!(zipfile)
dump_actor!(zipfile)
end
build_zip_file(tmp_file)
archive_filename = "#{['archive', Time.current.to_fs(:number), SecureRandom.hex(16)].join('-')}.zip"
@backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
@backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
@backup.processed = true
@backup.save!
ensure
@@ -73,6 +70,24 @@ class BackupService < BaseService
tmp_file.unlink
end
def build_zip_file(file)
Zip::File.open(file, create: true) do |zip|
dump_outbox!(zip)
dump_media_attachments!(zip)
dump_likes!(zip)
dump_bookmarks!(zip)
dump_actor!(zip)
end
end
def archive_filename
"#{archive_id}.zip"
end
def archive_id
[:archive, Time.current.to_fs(:number), SecureRandom.hex(16)].join('-')
end
def dump_media_attachments!(zipfile)
MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
media_attachments.each do |m|
@@ -89,7 +104,7 @@ class BackupService < BaseService
end
def dump_outbox!(zipfile)
zipfile.get_output_stream('outbox.json') do |io|
zipfile.get_output_stream(STREAM_OUTBOX) do |io|
build_outbox_json!(io)
end
end
@@ -99,31 +114,32 @@ class BackupService < BaseService
actor[:icon][:url] = "avatar#{File.extname(actor[:icon][:url])}" if actor[:icon]
actor[:image][:url] = "header#{File.extname(actor[:image][:url])}" if actor[:image]
actor[:outbox] = 'outbox.json'
actor[:likes] = 'likes.json'
actor[:bookmarks] = 'bookmarks.json'
actor[:outbox] = STREAM_OUTBOX
actor[:likes] = STREAM_LIKES
actor[:bookmarks] = STREAM_BOOKMARKS
download_to_zip(zipfile, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists?
download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?
json = JSON.generate(actor)
zipfile.get_output_stream('actor.json') do |io|
zipfile.get_output_stream(STREAM_ACTOR) do |io|
io.write(json)
end
end
def dump_likes!(zipfile)
skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
skeleton = serialize(collection_presenter(STREAM_LIKES), ActivityPub::CollectionSerializer)
skeleton.delete(:totalItems)
skeleton[:orderedItems] = [PLACEHOLDER]
skeleton = JSON.generate(skeleton)
prepend, append = skeleton.split(PLACEHOLDER.to_json)
zipfile.get_output_stream('likes.json') do |io|
zipfile.get_output_stream(STREAM_LIKES) do |io|
io.write(prepend)
Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites).find_in_batches.with_index do |statuses, batch|
favourite_statuses.find_in_batches.with_index do |statuses, batch|
io.write(',') unless batch.zero?
io.write(statuses.map do |status|
@@ -137,17 +153,21 @@ class BackupService < BaseService
end
end
def favourite_statuses
Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites)
end
def dump_bookmarks!(zipfile)
skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
skeleton = serialize(collection_presenter(STREAM_BOOKMARKS), ActivityPub::CollectionSerializer)
skeleton.delete(:totalItems)
skeleton[:orderedItems] = [PLACEHOLDER]
skeleton = JSON.generate(skeleton)
prepend, append = skeleton.split(PLACEHOLDER.to_json)
zipfile.get_output_stream('bookmarks.json') do |io|
zipfile.get_output_stream(STREAM_BOOKMARKS) do |io|
io.write(prepend)
Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks).find_in_batches.with_index do |statuses, batch|
bookmark_statuses.find_in_batches.with_index do |statuses, batch|
io.write(',') unless batch.zero?
io.write(statuses.map do |status|
@@ -161,12 +181,16 @@ class BackupService < BaseService
end
end
def collection_presenter
def bookmark_statuses
Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks)
end
def collection_presenter(id, size: 0)
ActivityPub::CollectionPresenter.new(
id: 'outbox.json',
type: :ordered,
size: account.statuses_count,
items: []
id:,
items: [],
size:,
type: :ordered
)
end

View File

@@ -18,6 +18,7 @@
%p.lead= t('auth.confirmations.awaiting_review', domain: site_hostname)
- else
= simple_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f|
%h1.title= t('auth.resend_confirmation', domain: site_hostname)
= render 'shared/error_messages', object: resource
.fields-group

View File

@@ -2,6 +2,7 @@
= t('auth.reset_password')
= simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f|
%h1.title= t('auth.reset_password', domain: site_hostname)
= render 'shared/error_messages', object: resource
.fields-group

View File

@@ -4,5 +4,6 @@
.rules-list__hint{ tabIndex: -1 }
%span.rules-list__hint-text= rule_translation.hint
%button.rules-list__toggle-button{ type: 'button', hidden: true, 'aria-expanded': 'false' }
= material_symbol('more_horiz')
= material_symbol('keyboard_arrow_up', { class: 'icon-collapse' })
= material_symbol('more_horiz', { class: 'icon-expand' })
%span.sr-only= t('auth.rules.read_more')

View File

@@ -38,7 +38,10 @@
- if devise_mapping.omniauthable? && resource_class.omniauth_providers.any?
.simple_form.alternative-login
%h2= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with')
- if omniauth_only?
%h1= t('auth.log_in_with')
- else
%h2= t('auth.or_log_in_with')
.actions
- resource_class.omniauth_providers.each do |provider|

View File

@@ -1295,6 +1295,7 @@ da:
invited_by: 'Du kan tilmelde dig %{domain} takket være den invitation, du har modtaget fra:'
preamble: Disse er opsat og håndhæves af %{domain}-moderatorerne.
preamble_invited: Før du fortsætter, bedes du overveje de grundregler, der er fastsat af moderatorerne af %{domain}.
read_more: Læs mere
title: Nogle grundregler.
title_invited: Du er blevet inviteret.
security: Sikkerhed

View File

@@ -1295,6 +1295,7 @@ de:
invited_by: 'Du kannst %{domain} beitreten dank der Einladung von:'
preamble: Diese werden von den Moderator*innen von %{domain} festgelegt und durchgesetzt.
preamble_invited: Bevor du fortfährst, beachte bitte die Grundregeln der Moderator*innen von %{domain}.
read_more: Weiterlesen
title: Einige Grundregeln.
title_invited: Du wurdest eingeladen.
security: Sicherheit

View File

@@ -1295,6 +1295,7 @@ el:
invited_by: 'Μπορείτε να συμμετάσχεις στο %{domain} χάρη στην πρόσκληση που έλαβες από:'
preamble: Αυτά ορίζονται και επιβάλλονται από τους συντονιστές του%{domain}.
preamble_invited: Πριν συνεχίσεις, παρακαλώ δώσε προσοχή στους βασικούς κανόνες που έχουν οριστεί από τους συντονιστές του %{domain}.
read_more: Διαβάστε περισσότερα
title: Ορισμένοι βασικοί κανόνες.
title_invited: Έχεις προσκληθεί.
security: Ασφάλεια

View File

@@ -1295,6 +1295,7 @@ es-AR:
invited_by: 'Podés unirte a %{domain} gracias a la invitación que recibiste de:'
preamble: Estas reglas son establecidas y aplicadas por los moderadores de %{domain}.
preamble_invited: Antes de continuar, por favor, tené en cuenta las reglas básicas establecidas por los moderadores de %{domain}.
read_more: Leé más
title: Algunas reglas básicas.
title_invited: Te invitaron.
security: Seguridad

View File

@@ -1282,6 +1282,7 @@ fr-CA:
progress:
confirm: Confirmation de l'adresse mail
details: Vos infos
list: Progression de l'inscription
review: Notre avis
rules: Accepter les règles
providers:

View File

@@ -1282,6 +1282,7 @@ fr:
progress:
confirm: Confirmation de l'adresse mail
details: Vos infos
list: Progression de l'inscription
review: Notre avis
rules: Accepter les règles
providers:

View File

@@ -1344,6 +1344,7 @@ ga:
progress:
confirm: Deimhnigh ríomhphost
details: Do chuid sonraí
list: Dul chun cinn clárúcháin
review: Ár léirmheas
rules: Glac le rialacha
providers:

View File

@@ -1295,6 +1295,7 @@ gl:
invited_by: 'Podes unirte a %{domain} grazas ao convite que recibiches de parte de:'
preamble: Son establecidas e aplicadas pola moderación de %{domain}.
preamble_invited: Antes de continuar adica un minuto a ler as regras básicas establecidas para %{domain}.
read_more: Ler máis
title: Algunhas regras básicas.
title_invited: Convidáronte.
security: Seguranza

View File

@@ -1299,6 +1299,7 @@ is:
invited_by: 'Þú getur tekið þátt í %{domain} þökk sé boði sem þú fékkst frá:'
preamble: Þær eru settar og þeim framfylgt af umsjónarmönnum %{domain}.
preamble_invited: Áður en haldið er lengra, vinsamlegast kynntu þér reglurnar sem stjórnendur %{domain} hafa sett.
read_more: Lesa meira
title: Nokkrar grunnreglur.
title_invited: Þér hefur verið boðið.
security: Öryggi

View File

@@ -1295,6 +1295,7 @@ it:
invited_by: 'Puoi unirti a %{domain} grazie all''invito che hai ricevuto da:'
preamble: Questi sono impostati e applicati dai moderatori di %{domain}.
preamble_invited: Prima di procedere, si prega di considerare le regole di base stabilite dai moderatori di %{domain}.
read_more: Scopri di più
title: Alcune regole di base.
title_invited: Sei stato/a invitato/a.
security: Credenziali

View File

@@ -137,7 +137,7 @@ de:
indexable: Deine Profilseite kann in Suchergebnissen auf Google, Bing und anderen erscheinen.
show_application: Du wirst immer sehen können, über welche App dein Beitrag veröffentlicht wurde.
tag:
name: Du kannst nur die Groß- und Kleinschreibung der Buchstaben ändern, um es z. B. lesbarer zu machen
name: Du kannst nur die Groß- und Kleinschreibung der Buchstaben ändern, um z. B. die Lesbarkeit zu verbessern
terms_of_service:
changelog: Kann mit der Markdown-Syntax formatiert werden.
effective_date: Ein angemessener Zeitraum liegt zwischen 10 und 30 Tagen, nachdem deine Nutzer*innen benachrichtigt wurden.

View File

@@ -41,7 +41,7 @@ el:
defaults:
autofollow: Όσοι εγγραφούν μέσω της πρόσκλησης θα σε ακολουθούν αυτόματα
avatar: WEBP, PNG, GIF ή JPG. Το πολύ %{size}. Θα υποβαθμιστεί σε %{dimensions}px
bot: Ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται
bot: Υποδεικνύει σε άλλους χρήστες ότι ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται
context: Ένα ή περισσότερα πλαίσια στα οποία μπορεί να εφαρμόζεται αυτό το φίλτρο
current_password: Για λόγους ασφαλείας παρακαλώ γράψε τον κωδικό του τρέχοντος λογαριασμού
current_username: Για επιβεβαίωση, παρακαλώ γράψε το όνομα χρήστη του τρέχοντος λογαριασμού

View File

@@ -1284,6 +1284,7 @@ sq:
invited_by: 'Mund të bëheni pjesë e %{domain} falë ftesës që morët prej:'
preamble: Këto vendosen dhe zbatimi i tyre është nën kujdesin e moderatorëve të %{domain}.
preamble_invited: Para se të vazhdoni më tej, ju lutemi, shihni rregullat bazë të vendosura nga moderatorët e %{domain}.
read_more: Lexoni më tepër
title: Disa rregulla bazë.
title_invited: Jeni ftuar.
security: Siguri

View File

@@ -1291,6 +1291,7 @@ sv:
invited_by: 'Du kan gå med i %{domain} tack vare den inbjudan du har fått från:'
preamble: Dessa bestäms och upprätthålls av moderatorerna för %{domain}.
preamble_invited: Innan du fortsätter bör du överväga grundreglerna som fastställts av moderatorerna för %{domain}.
read_more: Läs mer
title: Några grundregler.
title_invited: Du har blivit inbjuden.
security: Säkerhet

View File

@@ -1274,6 +1274,7 @@ vi:
invited_by: 'Bạn có thể tham gia %{domain} với thư mời từ:'
preamble: Được ban hành và áp dụng bởi quản trị máy chủ %{domain}.
preamble_invited: Trước khi tiếp tục, hãy đọc nội quy của %{domain}.
read_more: Đọc tiếp
title: Nội quy máy chủ.
title_invited: Bạn vừa được mời.
security: Bảo mật

View File

@@ -1274,6 +1274,7 @@ zh-CN:
invited_by: 欢迎加入%{domain},你是通过以下用户的邀请加入的:
preamble: 以下规则由 %{domain} 的管理员设定并执行。
preamble_invited: 在继续操作前,请先阅读并同意 %{domain} 管理员设置的基本规则。
read_more: 查看更多
title: 一些基本规则。
title_invited: 通过邀请加入
security: 账号安全

View File

@@ -1276,6 +1276,7 @@ zh-TW:
invited_by: 您可以藉由來自此處之邀請而加入 %{domain}
preamble: 這些被 %{domain} 的管管們制定以及實施。
preamble_invited: 在您繼續之前,請考慮由 %{domain} 管理員設立的伺服器規則。
read_more: 閱讀更多
title: 一些基本守則。
title_invited: 我們誠摯地邀請您。
security: 登入資訊