mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
Merge commit 'cffa8de6267a57673b7f6264fab671153f965d23' into glitch-soc/merge-upstream
This commit is contained in:
@@ -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
14
Gemfile
@@ -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
|
||||
|
||||
36
Gemfile.lock
36
Gemfile.lock
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') ? (
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "缩放",
|
||||
|
||||
36
app/javascript/mastodon/utils/time.test.ts
Normal file
36
app/javascript/mastodon/utils/time.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
246
app/javascript/mastodon/utils/time.ts
Normal file
246
app/javascript/mastodon/utils/time.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1295,6 +1295,7 @@ el:
|
||||
invited_by: 'Μπορείτε να συμμετάσχεις στο %{domain} χάρη στην πρόσκληση που έλαβες από:'
|
||||
preamble: Αυτά ορίζονται και επιβάλλονται από τους συντονιστές του%{domain}.
|
||||
preamble_invited: Πριν συνεχίσεις, παρακαλώ δώσε προσοχή στους βασικούς κανόνες που έχουν οριστεί από τους συντονιστές του %{domain}.
|
||||
read_more: Διαβάστε περισσότερα
|
||||
title: Ορισμένοι βασικοί κανόνες.
|
||||
title_invited: Έχεις προσκληθεί.
|
||||
security: Ασφάλεια
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,7 +41,7 @@ el:
|
||||
defaults:
|
||||
autofollow: Όσοι εγγραφούν μέσω της πρόσκλησης θα σε ακολουθούν αυτόματα
|
||||
avatar: WEBP, PNG, GIF ή JPG. Το πολύ %{size}. Θα υποβαθμιστεί σε %{dimensions}px
|
||||
bot: Ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται
|
||||
bot: Υποδεικνύει σε άλλους χρήστες ότι ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται
|
||||
context: Ένα ή περισσότερα πλαίσια στα οποία μπορεί να εφαρμόζεται αυτό το φίλτρο
|
||||
current_password: Για λόγους ασφαλείας παρακαλώ γράψε τον κωδικό του τρέχοντος λογαριασμού
|
||||
current_username: Για επιβεβαίωση, παρακαλώ γράψε το όνομα χρήστη του τρέχοντος λογαριασμού
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1274,6 +1274,7 @@ zh-CN:
|
||||
invited_by: 欢迎加入%{domain},你是通过以下用户的邀请加入的:
|
||||
preamble: 以下规则由 %{domain} 的管理员设定并执行。
|
||||
preamble_invited: 在继续操作前,请先阅读并同意 %{domain} 管理员设置的基本规则。
|
||||
read_more: 查看更多
|
||||
title: 一些基本规则。
|
||||
title_invited: 通过邀请加入
|
||||
security: 账号安全
|
||||
|
||||
@@ -1276,6 +1276,7 @@ zh-TW:
|
||||
invited_by: 您可以藉由來自此處之邀請而加入 %{domain}
|
||||
preamble: 這些被 %{domain} 的管管們制定以及實施。
|
||||
preamble_invited: 在您繼續之前,請考慮由 %{domain} 管理員設立的伺服器規則。
|
||||
read_more: 閱讀更多
|
||||
title: 一些基本守則。
|
||||
title_invited: 我們誠摯地邀請您。
|
||||
security: 登入資訊
|
||||
|
||||
Reference in New Issue
Block a user