Merge pull request #3451 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to cffa8de6267a57673b7f6264fab671153f965d23
This commit is contained in:
commit
ae1defcfd1
@ -181,7 +181,7 @@ FROM build AS libvips
|
|||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# 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"]
|
# 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
|
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_job', '~> 0.10.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.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-concurrent_ruby', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false
|
gem 'opentelemetry-instrumentation-excon', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false
|
gem 'opentelemetry-instrumentation-faraday', '~> 0.32.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.29.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.27.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-pg', '~> 0.35.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.30.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.40.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', 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)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
capybara-playwright-driver (0.5.8)
|
capybara-playwright-driver (0.5.9)
|
||||||
addressable
|
addressable
|
||||||
capybara
|
capybara
|
||||||
playwright-ruby-client (>= 1.16.0)
|
playwright-ruby-client (>= 1.16.0)
|
||||||
@ -446,7 +446,7 @@ GEM
|
|||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
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_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (6.0.2)
|
minitest (6.0.2)
|
||||||
@ -525,7 +525,7 @@ GEM
|
|||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
opentelemetry-instrumentation-action_mailer (0.6.1)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
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-rack (~> 0.29)
|
||||||
opentelemetry-instrumentation-action_view (0.11.2)
|
opentelemetry-instrumentation-action_view (0.11.2)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||||
@ -545,23 +545,23 @@ GEM
|
|||||||
opentelemetry-registry (~> 0.1)
|
opentelemetry-registry (~> 0.1)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.25)
|
||||||
opentelemetry-instrumentation-excon (0.27.0)
|
opentelemetry-instrumentation-excon (0.28.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.25)
|
||||||
opentelemetry-instrumentation-faraday (0.31.0)
|
opentelemetry-instrumentation-faraday (0.32.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.25)
|
||||||
opentelemetry-instrumentation-http (0.28.0)
|
opentelemetry-instrumentation-http (0.29.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
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-base (~> 0.25)
|
||||||
opentelemetry-instrumentation-net_http (0.27.0)
|
opentelemetry-instrumentation-net_http (0.28.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.25)
|
||||||
opentelemetry-instrumentation-pg (0.35.0)
|
opentelemetry-instrumentation-pg (0.35.0)
|
||||||
opentelemetry-helpers-sql
|
opentelemetry-helpers-sql
|
||||||
opentelemetry-helpers-sql-processor
|
opentelemetry-helpers-sql-processor
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
opentelemetry-instrumentation-base (~> 0.25)
|
||||||
opentelemetry-instrumentation-rack (0.29.0)
|
opentelemetry-instrumentation-rack (0.30.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.25)
|
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_mailer (~> 0.6)
|
||||||
opentelemetry-instrumentation-action_pack (~> 0.15)
|
opentelemetry-instrumentation-action_pack (~> 0.15)
|
||||||
opentelemetry-instrumentation-action_view (~> 0.11)
|
opentelemetry-instrumentation-action_view (~> 0.11)
|
||||||
@ -922,7 +922,7 @@ GEM
|
|||||||
activesupport
|
activesupport
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
webmock (3.26.1)
|
webmock (3.26.2)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
@ -1023,14 +1023,14 @@ DEPENDENCIES
|
|||||||
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
||||||
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
||||||
opentelemetry-instrumentation-excon (~> 0.27.0)
|
opentelemetry-instrumentation-excon (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-faraday (~> 0.31.0)
|
opentelemetry-instrumentation-faraday (~> 0.32.0)
|
||||||
opentelemetry-instrumentation-http (~> 0.28.0)
|
opentelemetry-instrumentation-http (~> 0.29.0)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.27.0)
|
opentelemetry-instrumentation-http_client (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-net_http (~> 0.27.0)
|
opentelemetry-instrumentation-net_http (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-pg (~> 0.35.0)
|
opentelemetry-instrumentation-pg (~> 0.35.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
opentelemetry-instrumentation-rack (~> 0.30.0)
|
||||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
opentelemetry-instrumentation-rails (~> 0.40.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
opentelemetry-instrumentation-redis (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-sidekiq (~> 0.28.0)
|
opentelemetry-instrumentation-sidekiq (~> 0.28.0)
|
||||||
opentelemetry-sdk (~> 1.4)
|
opentelemetry-sdk (~> 1.4)
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { IntlMessageFormat } from 'intl-messageformat';
|
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 { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { on } from 'delegated-events';
|
import { on } from 'delegated-events';
|
||||||
import { throttle } from 'lodash';
|
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 emojify from '../mastodon/features/emoji/emoji';
|
||||||
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
|
||||||
import { loadLocale, getLocale } from '../mastodon/locales';
|
import { loadLocale, getLocale } from '../mastodon/locales';
|
||||||
@ -58,7 +64,7 @@ function loaded() {
|
|||||||
const formatMessage = (
|
const formatMessage = (
|
||||||
{ id, defaultMessage }: MessageDescriptor,
|
{ id, defaultMessage }: MessageDescriptor,
|
||||||
values?: Record<string, PrimitiveType>,
|
values?: Record<string, PrimitiveType>,
|
||||||
) => {
|
): string => {
|
||||||
let message: string | undefined = undefined;
|
let message: string | undefined = undefined;
|
||||||
|
|
||||||
if (id) message = localeData[id];
|
if (id) message = localeData[id];
|
||||||
@ -126,23 +132,23 @@ function loaded() {
|
|||||||
.querySelectorAll<HTMLTimeElement>('time.time-ago')
|
.querySelectorAll<HTMLTimeElement>('time.time-ago')
|
||||||
.forEach((content) => {
|
.forEach((content) => {
|
||||||
const datetime = new Date(content.dateTime);
|
const datetime = new Date(content.dateTime);
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const timeGiven = content.dateTime.includes('T');
|
const timeGiven = content.dateTime.includes('T');
|
||||||
content.title = timeGiven
|
content.title = timeGiven
|
||||||
? dateTimeFormat.format(datetime)
|
? dateTimeFormat.format(datetime)
|
||||||
: dateFormat.format(datetime);
|
: dateFormat.format(datetime);
|
||||||
content.textContent = timeAgoString(
|
const now = Date.now();
|
||||||
{
|
content.textContent = formatTime({
|
||||||
formatMessage,
|
// We don't want to show future dates.
|
||||||
formatDate: (date: Date, options) =>
|
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),
|
new Intl.DateTimeFormat(locale, options).format(date),
|
||||||
},
|
},
|
||||||
datetime,
|
noTime: !timeGiven,
|
||||||
now.getTime(),
|
});
|
||||||
now.getFullYear(),
|
|
||||||
timeGiven,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
updateDefaultQuotePrivacyFromPrivacy(
|
updateDefaultQuotePrivacyFromPrivacy(
|
||||||
|
|||||||
@ -276,7 +276,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
if (account?.mute_expires_at) {
|
if (account?.mute_expires_at) {
|
||||||
muteTimeRemaining = (
|
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(
|
const renderItem = useCallback(
|
||||||
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
||||||
const formattedDate = (
|
const formattedDate = (
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp timestamp={item.get('created_at') as string} long />
|
||||||
timestamp={item.get('created_at') as string}
|
|
||||||
short={false}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
const formattedName = (
|
const formattedName = (
|
||||||
<InlineAccount accountId={item.get('account') as string} />
|
<InlineAccount accountId={item.get('account') as string} />
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
|||||||
if (expired) {
|
if (expired) {
|
||||||
return intl.formatMessage(messages.closed);
|
return intl.formatMessage(messages.closed);
|
||||||
}
|
}
|
||||||
return <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
|
return <RelativeTimestamp timestamp={poll.expires_at} />;
|
||||||
}, [expired, intl, poll]);
|
}, [expired, intl, poll]);
|
||||||
const votesCount = useMemo(() => {
|
const votesCount = useMemo(() => {
|
||||||
if (!poll) {
|
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 '@/flavours/glitch/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 '@/flavours/glitch/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,14 +1,19 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import { IntlMessageFormat } from 'intl-messageformat';
|
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 { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { on } from 'delegated-events';
|
import { on } from 'delegated-events';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
import { timeAgoString } from 'flavours/glitch/components/relative_timestamp';
|
import { formatTime } from '@/flavours/glitch/utils/time';
|
||||||
import emojify from 'flavours/glitch/features/emoji/emoji';
|
import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||||
import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
|
import loadKeyboardExtensions from 'flavours/glitch/load_keyboard_extensions';
|
||||||
import { loadLocale, getLocale } from 'flavours/glitch/locales';
|
import { loadLocale, getLocale } from 'flavours/glitch/locales';
|
||||||
@ -58,7 +63,7 @@ function loaded() {
|
|||||||
const formatMessage = (
|
const formatMessage = (
|
||||||
{ id, defaultMessage }: MessageDescriptor,
|
{ id, defaultMessage }: MessageDescriptor,
|
||||||
values?: Record<string, PrimitiveType>,
|
values?: Record<string, PrimitiveType>,
|
||||||
) => {
|
): string => {
|
||||||
let message: string | undefined = undefined;
|
let message: string | undefined = undefined;
|
||||||
|
|
||||||
if (id) message = localeData[id];
|
if (id) message = localeData[id];
|
||||||
@ -126,23 +131,23 @@ function loaded() {
|
|||||||
.querySelectorAll<HTMLTimeElement>('time.time-ago')
|
.querySelectorAll<HTMLTimeElement>('time.time-ago')
|
||||||
.forEach((content) => {
|
.forEach((content) => {
|
||||||
const datetime = new Date(content.dateTime);
|
const datetime = new Date(content.dateTime);
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
const timeGiven = content.dateTime.includes('T');
|
const timeGiven = content.dateTime.includes('T');
|
||||||
content.title = timeGiven
|
content.title = timeGiven
|
||||||
? dateTimeFormat.format(datetime)
|
? dateTimeFormat.format(datetime)
|
||||||
: dateFormat.format(datetime);
|
: dateFormat.format(datetime);
|
||||||
content.textContent = timeAgoString(
|
const now = Date.now();
|
||||||
{
|
content.textContent = formatTime({
|
||||||
formatMessage,
|
// We don't want to show future dates.
|
||||||
formatDate: (date: Date, options) =>
|
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),
|
new Intl.DateTimeFormat(locale, options).format(date),
|
||||||
},
|
},
|
||||||
datetime,
|
noTime: !timeGiven,
|
||||||
now.getTime(),
|
});
|
||||||
now.getFullYear(),
|
|
||||||
timeGiven,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
updateDefaultQuotePrivacyFromPrivacy(
|
updateDefaultQuotePrivacyFromPrivacy(
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
@ -18,9 +19,7 @@ import { isRedesignEnabled } from '../common';
|
|||||||
|
|
||||||
import classes from './redesign.module.scss';
|
import classes from './redesign.module.scss';
|
||||||
|
|
||||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
const LegacyNumberFields: FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
accountId,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
|
|
||||||
@ -29,23 +28,16 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='account__header__extra__links'>
|
||||||
className={classNames(
|
<NavLink
|
||||||
'account__header__extra__links',
|
to={`/@${account.acct}`}
|
||||||
isRedesignEnabled() && classes.fieldNumbersWrapper,
|
title={intl.formatNumber(account.statuses_count)}
|
||||||
)}
|
>
|
||||||
>
|
<ShortNumber
|
||||||
{!isRedesignEnabled() && (
|
value={account.statuses_count}
|
||||||
<NavLink
|
renderer={StatusesCounter}
|
||||||
to={`/@${account.acct}`}
|
/>
|
||||||
title={intl.formatNumber(account.statuses_count)}
|
</NavLink>
|
||||||
>
|
|
||||||
<ShortNumber
|
|
||||||
value={account.statuses_count}
|
|
||||||
renderer={StatusesCounter}
|
|
||||||
/>
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
exact
|
exact
|
||||||
@ -68,25 +60,80 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
|||||||
renderer={FollowersCounter}
|
renderer={FollowersCounter}
|
||||||
/>
|
/>
|
||||||
</NavLink>
|
</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>
|
</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 {
|
.fieldNumbersWrapper {
|
||||||
|
display: flex;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 8px 0;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
@container (width < 420px) {
|
||||||
|
flex: 1 1 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
color: inherit;
|
||||||
font-weight: unset;
|
font-weight: unset;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--color-text-brand-soft);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,12 +53,7 @@ export const CollectionMetaData: React.FC<{
|
|||||||
id='collections.last_updated_at'
|
id='collections.last_updated_at'
|
||||||
defaultMessage='Last updated: {date}'
|
defaultMessage='Last updated: {date}'
|
||||||
values={{
|
values={{
|
||||||
date: (
|
date: <RelativeTimestamp timestamp={collection.updated_at} long />,
|
||||||
<RelativeTimestamp
|
|
||||||
timestamp={collection.updated_at}
|
|
||||||
short={false}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
tagName='li'
|
tagName='li'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class Report extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='notification__report__details'>
|
<div className='notification__report__details'>
|
||||||
<div>
|
<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 />
|
<br />
|
||||||
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
|
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class CompareHistoryModal extends PureComponent {
|
|||||||
const content = currentVersion.get('content');
|
const content = currentVersion.get('content');
|
||||||
const spoilerContent = escapeTextContentForBrowser(currentVersion.get('spoiler_text'));
|
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 formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
||||||
|
|
||||||
const label = currentVersion.get('original') ? (
|
const label = currentVersion.get('original') ? (
|
||||||
|
|||||||
@ -85,6 +85,11 @@ $fluid-breakpoint: $maximum-width + 20px;
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[aria-expanded='true'] .icon-expand,
|
||||||
|
&[aria-expanded='false'] .icon-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 1lh;
|
width: 1lh;
|
||||||
height: 1lh;
|
height: 1lh;
|
||||||
|
|||||||
36
app/javascript/flavours/glitch/utils/time.test.ts
Normal file
36
app/javascript/flavours/glitch/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/flavours/glitch/utils/time.ts
Normal file
246
app/javascript/flavours/glitch/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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -275,7 +275,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
if (account?.mute_expires_at) {
|
if (account?.mute_expires_at) {
|
||||||
muteTimeRemaining = (
|
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(
|
const renderItem = useCallback(
|
||||||
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
(item: HistoryItem, index: number, onClick: React.MouseEventHandler) => {
|
||||||
const formattedDate = (
|
const formattedDate = (
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp timestamp={item.get('created_at') as string} long />
|
||||||
timestamp={item.get('created_at') as string}
|
|
||||||
short={false}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
const formattedName = (
|
const formattedName = (
|
||||||
<InlineAccount accountId={item.get('account') as string} />
|
<InlineAccount accountId={item.get('account') as string} />
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
|||||||
if (expired) {
|
if (expired) {
|
||||||
return intl.formatMessage(messages.closed);
|
return intl.formatMessage(messages.closed);
|
||||||
}
|
}
|
||||||
return <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
|
return <RelativeTimestamp timestamp={poll.expires_at} />;
|
||||||
}, [expired, intl, poll]);
|
}, [expired, intl, poll]);
|
||||||
const votesCount = useMemo(() => {
|
const votesCount = useMemo(() => {
|
||||||
if (!poll) {
|
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 type { FC } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
@ -18,9 +19,7 @@ import { isRedesignEnabled } from '../common';
|
|||||||
|
|
||||||
import classes from './redesign.module.scss';
|
import classes from './redesign.module.scss';
|
||||||
|
|
||||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
const LegacyNumberFields: FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
accountId,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId);
|
||||||
|
|
||||||
@ -29,23 +28,16 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='account__header__extra__links'>
|
||||||
className={classNames(
|
<NavLink
|
||||||
'account__header__extra__links',
|
to={`/@${account.acct}`}
|
||||||
isRedesignEnabled() && classes.fieldNumbersWrapper,
|
title={intl.formatNumber(account.statuses_count)}
|
||||||
)}
|
>
|
||||||
>
|
<ShortNumber
|
||||||
{!isRedesignEnabled() && (
|
value={account.statuses_count}
|
||||||
<NavLink
|
renderer={StatusesCounter}
|
||||||
to={`/@${account.acct}`}
|
/>
|
||||||
title={intl.formatNumber(account.statuses_count)}
|
</NavLink>
|
||||||
>
|
|
||||||
<ShortNumber
|
|
||||||
value={account.statuses_count}
|
|
||||||
renderer={StatusesCounter}
|
|
||||||
/>
|
|
||||||
</NavLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
exact
|
exact
|
||||||
@ -68,25 +60,80 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
|||||||
renderer={FollowersCounter}
|
renderer={FollowersCounter}
|
||||||
/>
|
/>
|
||||||
</NavLink>
|
</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>
|
</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 {
|
.fieldNumbersWrapper {
|
||||||
|
display: flex;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 8px 0;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
@container (width < 420px) {
|
||||||
|
flex: 1 1 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
color: inherit;
|
||||||
font-weight: unset;
|
font-weight: unset;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--color-text-brand-soft);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
|
display: block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -53,12 +53,7 @@ export const CollectionMetaData: React.FC<{
|
|||||||
id='collections.last_updated_at'
|
id='collections.last_updated_at'
|
||||||
defaultMessage='Last updated: {date}'
|
defaultMessage='Last updated: {date}'
|
||||||
values={{
|
values={{
|
||||||
date: (
|
date: <RelativeTimestamp timestamp={collection.updated_at} long />,
|
||||||
<RelativeTimestamp
|
|
||||||
timestamp={collection.updated_at}
|
|
||||||
short={false}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
tagName='li'
|
tagName='li'
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class Report extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='notification__report__details'>
|
<div className='notification__report__details'>
|
||||||
<div>
|
<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 />
|
<br />
|
||||||
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
|
<strong>{intl.formatMessage(messages[report.get('category')])}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class CompareHistoryModal extends PureComponent {
|
|||||||
const content = currentVersion.get('content');
|
const content = currentVersion.get('content');
|
||||||
const spoilerContent = escapeTextContentForBrowser(currentVersion.get('spoiler_text'));
|
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 formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
||||||
|
|
||||||
const label = currentVersion.get('original') ? (
|
const label = currentVersion.get('original') ? (
|
||||||
|
|||||||
@ -73,7 +73,6 @@
|
|||||||
"account.go_to_profile": "Go to profile",
|
"account.go_to_profile": "Go to profile",
|
||||||
"account.hide_reblogs": "Hide boosts from @{name}",
|
"account.hide_reblogs": "Hide boosts from @{name}",
|
||||||
"account.in_memoriam": "In Memoriam.",
|
"account.in_memoriam": "In Memoriam.",
|
||||||
"account.joined_long": "Joined on {date}",
|
|
||||||
"account.joined_short": "Joined",
|
"account.joined_short": "Joined",
|
||||||
"account.languages": "Change subscribed languages",
|
"account.languages": "Change subscribed languages",
|
||||||
"account.link_verified_on": "Ownership of this link was checked on {date}",
|
"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_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.details_title": "Vinkkejä: profiilikuvien tekstivastineet",
|
||||||
"account_edit.image_alt_modal.edit_title": "Muokkaa tekstivastinetta",
|
"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_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.confirm": "Haluatko varmasti poistaa tämän kuvan? Tätä toimea ei voi kumota.",
|
||||||
"account_edit.image_delete_modal.delete_button": "Poista",
|
"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.drag_start": "Réimse \"{item}\" bailithe.",
|
||||||
"account_edit.field_reorder_modal.handle_label": "Tarraing réimse \"{item}\"",
|
"account_edit.field_reorder_modal.handle_label": "Tarraing réimse \"{item}\"",
|
||||||
"account_edit.field_reorder_modal.title": "Athshocraigh réimsí",
|
"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.add_button": "Cuir íomhá leis",
|
||||||
"account_edit.image_edit.alt_add_button": "Cuir téacs alt 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",
|
"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.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.profile_tab.title": "Socruithe an chluaisín próifíle",
|
||||||
"account_edit.save": "Sábháil",
|
"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.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.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?",
|
"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.search_accounts_max_reached": "Tá an líon uasta cuntas curtha leis agat",
|
||||||
"collections.sensitive": "Íogair",
|
"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_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_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.view_other_collections_by_user": "Féach ar bhailiúcháin eile ón úsáideoir seo",
|
||||||
"collections.visibility_public": "Poiblí",
|
"collections.visibility_public": "Poiblí",
|
||||||
|
|||||||
@ -153,16 +153,51 @@
|
|||||||
"account_edit.column_title": "Rediger profil",
|
"account_edit.column_title": "Rediger profil",
|
||||||
"account_edit.custom_fields.name": "felt",
|
"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.placeholder": "Legg til pronomen, lenkjer eller kva du elles vil dela.",
|
||||||
"account_edit.custom_fields.reorder_button": "Omorganiser felter",
|
"account_edit.custom_fields.reorder_button": "Omorganiser felt",
|
||||||
"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_content": "Du kan auka truverdet til Mastodon-kontoen din ved å stadfesta lenker til nettstader du eig.",
|
||||||
"account_edit.custom_fields.tip_title": "Legg til bekreftede lenker",
|
"account_edit.custom_fields.tip_title": "Tips: Legg til stadfesta lenker",
|
||||||
"account_edit.custom_fields.title": "Eigne felt",
|
"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.placeholder": "Det synlege namnet ditt er det som syner på profilen din og i tidsliner.",
|
||||||
"account_edit.display_name.title": "Synleg namn",
|
"account_edit.display_name.title": "Synleg namn",
|
||||||
"account_edit.featured_hashtags.item": "emneknaggar",
|
"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.placeholder": "Hjelp andre å finna og få rask tilgang til favorittemna dine.",
|
||||||
"account_edit.featured_hashtags.title": "Utvalde emneknaggar",
|
"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.add_title": "Legg til synleg namn",
|
||||||
"account_edit.name_modal.edit_title": "Endre synleg namn",
|
"account_edit.name_modal.edit_title": "Endre synleg namn",
|
||||||
"account_edit.profile_tab.button_label": "Tilpass",
|
"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.subtitle": "Tilpass fanene på profilen din og kva dei syner.",
|
||||||
"account_edit.profile_tab.title": "Innstillingar for profilfane",
|
"account_edit.profile_tab.title": "Innstillingar for profilfane",
|
||||||
"account_edit.save": "Lagre",
|
"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.add_tag": "Legg til #{tagName}",
|
||||||
"account_edit_tags.column_title": "Rediger utvalde emneknaggar",
|
"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.",
|
"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.subtitle": "自定义你个人资料的标签页及其显示的内容。",
|
||||||
"account_edit.profile_tab.title": "个人资料标签页设置",
|
"account_edit.profile_tab.title": "个人资料标签页设置",
|
||||||
"account_edit.save": "保存",
|
"account_edit.save": "保存",
|
||||||
"account_edit.upload_modal.back": "返回",
|
"account_edit.upload_modal.back": "上一步",
|
||||||
"account_edit.upload_modal.done": "完成",
|
"account_edit.upload_modal.done": "完成",
|
||||||
"account_edit.upload_modal.next": "下一步",
|
"account_edit.upload_modal.next": "下一步",
|
||||||
"account_edit.upload_modal.step_crop.zoom": "缩放",
|
"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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[aria-expanded='true'] .icon-expand,
|
||||||
|
&[aria-expanded='false'] .icon-collapse {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 1lh;
|
width: 1lh;
|
||||||
height: 1lh;
|
height: 1lh;
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity
|
|||||||
|
|
||||||
add_collection
|
add_collection
|
||||||
else
|
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?
|
add_collection_item if @collection && Mastodon::Feature.collections_federation_enabled?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -9,6 +9,11 @@ class BackupService < BaseService
|
|||||||
CHUNK_SIZE = 1.megabyte
|
CHUNK_SIZE = 1.megabyte
|
||||||
PLACEHOLDER = '!PLACEHOLDER!'
|
PLACEHOLDER = '!PLACEHOLDER!'
|
||||||
|
|
||||||
|
STREAM_ACTOR = 'actor.json'
|
||||||
|
STREAM_BOOKMARKS = 'bookmarks.json'
|
||||||
|
STREAM_LIKES = 'likes.json'
|
||||||
|
STREAM_OUTBOX = 'outbox.json'
|
||||||
|
|
||||||
attr_reader :account, :backup
|
attr_reader :account, :backup
|
||||||
|
|
||||||
def call(backup)
|
def call(backup)
|
||||||
@ -21,7 +26,7 @@ class BackupService < BaseService
|
|||||||
private
|
private
|
||||||
|
|
||||||
def build_outbox_json!(file)
|
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[:@context] = full_context
|
||||||
skeleton[:orderedItems] = [PLACEHOLDER]
|
skeleton[:orderedItems] = [PLACEHOLDER]
|
||||||
skeleton = JSON.generate(skeleton)
|
skeleton = JSON.generate(skeleton)
|
||||||
@ -55,17 +60,9 @@ class BackupService < BaseService
|
|||||||
def build_archive!
|
def build_archive!
|
||||||
tmp_file = Tempfile.new(%w(archive .zip))
|
tmp_file = Tempfile.new(%w(archive .zip))
|
||||||
|
|
||||||
Zip::File.open(tmp_file, create: true) do |zipfile|
|
build_zip_file(tmp_file)
|
||||||
dump_outbox!(zipfile)
|
|
||||||
dump_media_attachments!(zipfile)
|
|
||||||
dump_likes!(zipfile)
|
|
||||||
dump_bookmarks!(zipfile)
|
|
||||||
dump_actor!(zipfile)
|
|
||||||
end
|
|
||||||
|
|
||||||
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.processed = true
|
||||||
@backup.save!
|
@backup.save!
|
||||||
ensure
|
ensure
|
||||||
@ -73,6 +70,24 @@ class BackupService < BaseService
|
|||||||
tmp_file.unlink
|
tmp_file.unlink
|
||||||
end
|
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)
|
def dump_media_attachments!(zipfile)
|
||||||
MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
|
MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
|
||||||
media_attachments.each do |m|
|
media_attachments.each do |m|
|
||||||
@ -89,7 +104,7 @@ class BackupService < BaseService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def dump_outbox!(zipfile)
|
def dump_outbox!(zipfile)
|
||||||
zipfile.get_output_stream('outbox.json') do |io|
|
zipfile.get_output_stream(STREAM_OUTBOX) do |io|
|
||||||
build_outbox_json!(io)
|
build_outbox_json!(io)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -99,31 +114,32 @@ class BackupService < BaseService
|
|||||||
|
|
||||||
actor[:icon][:url] = "avatar#{File.extname(actor[:icon][:url])}" if actor[:icon]
|
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[:image][:url] = "header#{File.extname(actor[:image][:url])}" if actor[:image]
|
||||||
actor[:outbox] = 'outbox.json'
|
actor[:outbox] = STREAM_OUTBOX
|
||||||
actor[:likes] = 'likes.json'
|
actor[:likes] = STREAM_LIKES
|
||||||
actor[:bookmarks] = 'bookmarks.json'
|
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.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?
|
download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?
|
||||||
|
|
||||||
json = JSON.generate(actor)
|
json = JSON.generate(actor)
|
||||||
|
|
||||||
zipfile.get_output_stream('actor.json') do |io|
|
zipfile.get_output_stream(STREAM_ACTOR) do |io|
|
||||||
io.write(json)
|
io.write(json)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def dump_likes!(zipfile)
|
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.delete(:totalItems)
|
||||||
skeleton[:orderedItems] = [PLACEHOLDER]
|
skeleton[:orderedItems] = [PLACEHOLDER]
|
||||||
skeleton = JSON.generate(skeleton)
|
skeleton = JSON.generate(skeleton)
|
||||||
prepend, append = skeleton.split(PLACEHOLDER.to_json)
|
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)
|
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(',') unless batch.zero?
|
||||||
|
|
||||||
io.write(statuses.map do |status|
|
io.write(statuses.map do |status|
|
||||||
@ -137,17 +153,21 @@ class BackupService < BaseService
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def favourite_statuses
|
||||||
|
Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites)
|
||||||
|
end
|
||||||
|
|
||||||
def dump_bookmarks!(zipfile)
|
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.delete(:totalItems)
|
||||||
skeleton[:orderedItems] = [PLACEHOLDER]
|
skeleton[:orderedItems] = [PLACEHOLDER]
|
||||||
skeleton = JSON.generate(skeleton)
|
skeleton = JSON.generate(skeleton)
|
||||||
prepend, append = skeleton.split(PLACEHOLDER.to_json)
|
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)
|
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(',') unless batch.zero?
|
||||||
|
|
||||||
io.write(statuses.map do |status|
|
io.write(statuses.map do |status|
|
||||||
@ -161,12 +181,16 @@ class BackupService < BaseService
|
|||||||
end
|
end
|
||||||
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(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: 'outbox.json',
|
id:,
|
||||||
type: :ordered,
|
items: [],
|
||||||
size: account.statuses_count,
|
size:,
|
||||||
items: []
|
type: :ordered
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
%p.lead= t('auth.confirmations.awaiting_review', domain: site_hostname)
|
%p.lead= t('auth.confirmations.awaiting_review', domain: site_hostname)
|
||||||
- else
|
- else
|
||||||
= simple_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f|
|
= 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
|
= render 'shared/error_messages', object: resource
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
= t('auth.reset_password')
|
= t('auth.reset_password')
|
||||||
|
|
||||||
= simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f|
|
= 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
|
= render 'shared/error_messages', object: resource
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
|
|||||||
@ -4,5 +4,6 @@
|
|||||||
.rules-list__hint{ tabIndex: -1 }
|
.rules-list__hint{ tabIndex: -1 }
|
||||||
%span.rules-list__hint-text= rule_translation.hint
|
%span.rules-list__hint-text= rule_translation.hint
|
||||||
%button.rules-list__toggle-button{ type: 'button', hidden: true, 'aria-expanded': 'false' }
|
%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')
|
%span.sr-only= t('auth.rules.read_more')
|
||||||
|
|||||||
@ -38,7 +38,10 @@
|
|||||||
|
|
||||||
- if devise_mapping.omniauthable? && resource_class.omniauth_providers.any?
|
- if devise_mapping.omniauthable? && resource_class.omniauth_providers.any?
|
||||||
.simple_form.alternative-login
|
.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
|
.actions
|
||||||
- resource_class.omniauth_providers.each do |provider|
|
- 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:'
|
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: 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}.
|
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: Nogle grundregler.
|
||||||
title_invited: Du er blevet inviteret.
|
title_invited: Du er blevet inviteret.
|
||||||
security: Sikkerhed
|
security: Sikkerhed
|
||||||
|
|||||||
@ -1295,6 +1295,7 @@ de:
|
|||||||
invited_by: 'Du kannst %{domain} beitreten – dank der Einladung von:'
|
invited_by: 'Du kannst %{domain} beitreten – dank der Einladung von:'
|
||||||
preamble: Diese werden von den Moderator*innen von %{domain} festgelegt und durchgesetzt.
|
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}.
|
preamble_invited: Bevor du fortfährst, beachte bitte die Grundregeln der Moderator*innen von %{domain}.
|
||||||
|
read_more: Weiterlesen
|
||||||
title: Einige Grundregeln.
|
title: Einige Grundregeln.
|
||||||
title_invited: Du wurdest eingeladen.
|
title_invited: Du wurdest eingeladen.
|
||||||
security: Sicherheit
|
security: Sicherheit
|
||||||
|
|||||||
@ -1295,6 +1295,7 @@ el:
|
|||||||
invited_by: 'Μπορείτε να συμμετάσχεις στο %{domain} χάρη στην πρόσκληση που έλαβες από:'
|
invited_by: 'Μπορείτε να συμμετάσχεις στο %{domain} χάρη στην πρόσκληση που έλαβες από:'
|
||||||
preamble: Αυτά ορίζονται και επιβάλλονται από τους συντονιστές του%{domain}.
|
preamble: Αυτά ορίζονται και επιβάλλονται από τους συντονιστές του%{domain}.
|
||||||
preamble_invited: Πριν συνεχίσεις, παρακαλώ δώσε προσοχή στους βασικούς κανόνες που έχουν οριστεί από τους συντονιστές του %{domain}.
|
preamble_invited: Πριν συνεχίσεις, παρακαλώ δώσε προσοχή στους βασικούς κανόνες που έχουν οριστεί από τους συντονιστές του %{domain}.
|
||||||
|
read_more: Διαβάστε περισσότερα
|
||||||
title: Ορισμένοι βασικοί κανόνες.
|
title: Ορισμένοι βασικοί κανόνες.
|
||||||
title_invited: Έχεις προσκληθεί.
|
title_invited: Έχεις προσκληθεί.
|
||||||
security: Ασφάλεια
|
security: Ασφάλεια
|
||||||
|
|||||||
@ -1295,6 +1295,7 @@ es-AR:
|
|||||||
invited_by: 'Podés unirte a %{domain} gracias a la invitación que recibiste de:'
|
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: 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}.
|
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: Algunas reglas básicas.
|
||||||
title_invited: Te invitaron.
|
title_invited: Te invitaron.
|
||||||
security: Seguridad
|
security: Seguridad
|
||||||
|
|||||||
@ -1282,6 +1282,7 @@ fr-CA:
|
|||||||
progress:
|
progress:
|
||||||
confirm: Confirmation de l'adresse mail
|
confirm: Confirmation de l'adresse mail
|
||||||
details: Vos infos
|
details: Vos infos
|
||||||
|
list: Progression de l'inscription
|
||||||
review: Notre avis
|
review: Notre avis
|
||||||
rules: Accepter les règles
|
rules: Accepter les règles
|
||||||
providers:
|
providers:
|
||||||
|
|||||||
@ -1282,6 +1282,7 @@ fr:
|
|||||||
progress:
|
progress:
|
||||||
confirm: Confirmation de l'adresse mail
|
confirm: Confirmation de l'adresse mail
|
||||||
details: Vos infos
|
details: Vos infos
|
||||||
|
list: Progression de l'inscription
|
||||||
review: Notre avis
|
review: Notre avis
|
||||||
rules: Accepter les règles
|
rules: Accepter les règles
|
||||||
providers:
|
providers:
|
||||||
|
|||||||
@ -1344,6 +1344,7 @@ ga:
|
|||||||
progress:
|
progress:
|
||||||
confirm: Deimhnigh ríomhphost
|
confirm: Deimhnigh ríomhphost
|
||||||
details: Do chuid sonraí
|
details: Do chuid sonraí
|
||||||
|
list: Dul chun cinn clárúcháin
|
||||||
review: Ár léirmheas
|
review: Ár léirmheas
|
||||||
rules: Glac le rialacha
|
rules: Glac le rialacha
|
||||||
providers:
|
providers:
|
||||||
|
|||||||
@ -1295,6 +1295,7 @@ gl:
|
|||||||
invited_by: 'Podes unirte a %{domain} grazas ao convite que recibiches de parte de:'
|
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: 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}.
|
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: Algunhas regras básicas.
|
||||||
title_invited: Convidáronte.
|
title_invited: Convidáronte.
|
||||||
security: Seguranza
|
security: Seguranza
|
||||||
|
|||||||
@ -1299,6 +1299,7 @@ is:
|
|||||||
invited_by: 'Þú getur tekið þátt í %{domain} þökk sé boði sem þú fékkst frá:'
|
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: Þæ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.
|
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: Nokkrar grunnreglur.
|
||||||
title_invited: Þér hefur verið boðið.
|
title_invited: Þér hefur verið boðið.
|
||||||
security: Öryggi
|
security: Öryggi
|
||||||
|
|||||||
@ -1295,6 +1295,7 @@ it:
|
|||||||
invited_by: 'Puoi unirti a %{domain} grazie all''invito che hai ricevuto da:'
|
invited_by: 'Puoi unirti a %{domain} grazie all''invito che hai ricevuto da:'
|
||||||
preamble: Questi sono impostati e applicati dai moderatori di %{domain}.
|
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}.
|
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: Alcune regole di base.
|
||||||
title_invited: Sei stato/a invitato/a.
|
title_invited: Sei stato/a invitato/a.
|
||||||
security: Credenziali
|
security: Credenziali
|
||||||
|
|||||||
@ -137,7 +137,7 @@ de:
|
|||||||
indexable: Deine Profilseite kann in Suchergebnissen auf Google, Bing und anderen erscheinen.
|
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.
|
show_application: Du wirst immer sehen können, über welche App dein Beitrag veröffentlicht wurde.
|
||||||
tag:
|
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:
|
terms_of_service:
|
||||||
changelog: Kann mit der Markdown-Syntax formatiert werden.
|
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.
|
effective_date: Ein angemessener Zeitraum liegt zwischen 10 und 30 Tagen, nachdem deine Nutzer*innen benachrichtigt wurden.
|
||||||
|
|||||||
@ -41,7 +41,7 @@ el:
|
|||||||
defaults:
|
defaults:
|
||||||
autofollow: Όσοι εγγραφούν μέσω της πρόσκλησης θα σε ακολουθούν αυτόματα
|
autofollow: Όσοι εγγραφούν μέσω της πρόσκλησης θα σε ακολουθούν αυτόματα
|
||||||
avatar: WEBP, PNG, GIF ή JPG. Το πολύ %{size}. Θα υποβαθμιστεί σε %{dimensions}px
|
avatar: WEBP, PNG, GIF ή JPG. Το πολύ %{size}. Θα υποβαθμιστεί σε %{dimensions}px
|
||||||
bot: Ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται
|
bot: Υποδεικνύει σε άλλους χρήστες ότι ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται
|
||||||
context: Ένα ή περισσότερα πλαίσια στα οποία μπορεί να εφαρμόζεται αυτό το φίλτρο
|
context: Ένα ή περισσότερα πλαίσια στα οποία μπορεί να εφαρμόζεται αυτό το φίλτρο
|
||||||
current_password: Για λόγους ασφαλείας παρακαλώ γράψε τον κωδικό του τρέχοντος λογαριασμού
|
current_password: Για λόγους ασφαλείας παρακαλώ γράψε τον κωδικό του τρέχοντος λογαριασμού
|
||||||
current_username: Για επιβεβαίωση, παρακαλώ γράψε το όνομα χρήστη του τρέχοντος λογαριασμού
|
current_username: Για επιβεβαίωση, παρακαλώ γράψε το όνομα χρήστη του τρέχοντος λογαριασμού
|
||||||
|
|||||||
@ -1284,6 +1284,7 @@ sq:
|
|||||||
invited_by: 'Mund të bëheni pjesë e %{domain} falë ftesës që morët prej:'
|
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: 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}.
|
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: Disa rregulla bazë.
|
||||||
title_invited: Jeni ftuar.
|
title_invited: Jeni ftuar.
|
||||||
security: Siguri
|
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:'
|
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: 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}.
|
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: Några grundregler.
|
||||||
title_invited: Du har blivit inbjuden.
|
title_invited: Du har blivit inbjuden.
|
||||||
security: Säkerhet
|
security: Säkerhet
|
||||||
|
|||||||
@ -1274,6 +1274,7 @@ vi:
|
|||||||
invited_by: 'Bạn có thể tham gia %{domain} với thư mời từ:'
|
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: Đượ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}.
|
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: Nội quy máy chủ.
|
||||||
title_invited: Bạn vừa được mời.
|
title_invited: Bạn vừa được mời.
|
||||||
security: Bảo mật
|
security: Bảo mật
|
||||||
|
|||||||
@ -1274,6 +1274,7 @@ zh-CN:
|
|||||||
invited_by: 欢迎加入%{domain},你是通过以下用户的邀请加入的:
|
invited_by: 欢迎加入%{domain},你是通过以下用户的邀请加入的:
|
||||||
preamble: 以下规则由 %{domain} 的管理员设定并执行。
|
preamble: 以下规则由 %{domain} 的管理员设定并执行。
|
||||||
preamble_invited: 在继续操作前,请先阅读并同意 %{domain} 管理员设置的基本规则。
|
preamble_invited: 在继续操作前,请先阅读并同意 %{domain} 管理员设置的基本规则。
|
||||||
|
read_more: 查看更多
|
||||||
title: 一些基本规则。
|
title: 一些基本规则。
|
||||||
title_invited: 通过邀请加入
|
title_invited: 通过邀请加入
|
||||||
security: 账号安全
|
security: 账号安全
|
||||||
|
|||||||
@ -1276,6 +1276,7 @@ zh-TW:
|
|||||||
invited_by: 您可以藉由來自此處之邀請而加入 %{domain}
|
invited_by: 您可以藉由來自此處之邀請而加入 %{domain}
|
||||||
preamble: 這些被 %{domain} 的管管們制定以及實施。
|
preamble: 這些被 %{domain} 的管管們制定以及實施。
|
||||||
preamble_invited: 在您繼續之前,請考慮由 %{domain} 管理員設立的伺服器規則。
|
preamble_invited: 在您繼續之前,請考慮由 %{domain} 管理員設立的伺服器規則。
|
||||||
|
read_more: 閱讀更多
|
||||||
title: 一些基本守則。
|
title: 一些基本守則。
|
||||||
title_invited: 我們誠摯地邀請您。
|
title_invited: 我們誠摯地邀請您。
|
||||||
security: 登入資訊
|
security: 登入資訊
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user