mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-15 16:59:41 +00:00
Merge commit '9f7075a0ce2b6ecef8d92ef318785fa8ce708688' into glitch-soc/merge-upstream
This commit is contained in:
@@ -38,7 +38,7 @@ export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else if ((text.startsWith('@') || prevText?.endsWith('@')) && mention) {
|
||||
} else if (mention) {
|
||||
// Handle mentions
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -219,7 +219,7 @@ class StatusContent extends PureComponent {
|
||||
{children}
|
||||
</HandledLink>
|
||||
);
|
||||
} else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) {
|
||||
} else if (element.classList.contains('quote-inline')) {
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import {
|
||||
fetchContext,
|
||||
completeContextRefresh,
|
||||
@@ -13,6 +15,8 @@ import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
||||
import { Alert } from 'mastodon/components/alert';
|
||||
import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { useInterval } from 'mastodon/hooks/useInterval';
|
||||
import { useIsDocumentVisible } from 'mastodon/hooks/useIsDocumentVisible';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const AnimatedAlert: React.FC<
|
||||
@@ -52,14 +56,151 @@ const messages = defineMessages({
|
||||
|
||||
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
|
||||
|
||||
/**
|
||||
* Age of thread below which we consider it new & fetch
|
||||
* replies more frequently
|
||||
*/
|
||||
const NEW_THREAD_AGE_THRESHOLD = 30 * 60_000;
|
||||
/**
|
||||
* Interval at which we check for new replies for old threads
|
||||
*/
|
||||
const LONG_AUTO_FETCH_REPLIES_INTERVAL = 5 * 60_000;
|
||||
/**
|
||||
* Interval at which we check for new replies for new threads.
|
||||
* Also used as a threshold to throttle repeated fetch calls
|
||||
*/
|
||||
const SHORT_AUTO_FETCH_REPLIES_INTERVAL = 60_000;
|
||||
/**
|
||||
* Number of refresh_async checks at which an early fetch
|
||||
* will be triggered if there are results
|
||||
*/
|
||||
const LONG_RUNNING_FETCH_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* Returns whether the thread is new, based on NEW_THREAD_AGE_THRESHOLD
|
||||
*/
|
||||
function getIsThreadNew(statusCreatedAt: string) {
|
||||
const now = new Date();
|
||||
const newThreadThreshold = new Date(now.getTime() - NEW_THREAD_AGE_THRESHOLD);
|
||||
|
||||
return new Date(statusCreatedAt) > newThreadThreshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook kicks off a background check for the async refresh job
|
||||
* and loads any newly found replies once the job has finished,
|
||||
* and when LONG_RUNNING_FETCH_THRESHOLD was reached and replies were found
|
||||
*/
|
||||
function useCheckForRemoteReplies({
|
||||
statusId,
|
||||
refreshHeader,
|
||||
isEnabled,
|
||||
onChangeLoadingState,
|
||||
}: {
|
||||
statusId: string;
|
||||
refreshHeader?: AsyncRefreshHeader;
|
||||
isEnabled: boolean;
|
||||
onChangeLoadingState: React.Dispatch<React.SetStateAction<LoadingState>>;
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
|
||||
const scheduleRefresh = (
|
||||
refresh: AsyncRefreshHeader,
|
||||
iteration: number,
|
||||
) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||
const { status, result_count } = result.async_refresh;
|
||||
|
||||
// At three scheduled refreshes, we consider the job
|
||||
// long-running and attempt to fetch any new replies so far
|
||||
const isLongRunning = iteration === LONG_RUNNING_FETCH_THRESHOLD;
|
||||
|
||||
// If the refresh status is not finished and not long-running,
|
||||
// we just schedule another refresh and exit
|
||||
if (status === 'running' && !isLongRunning) {
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// If refresh status is finished, clear `refreshHeader`
|
||||
// (we don't want to do this if it's just a long-running job)
|
||||
if (status === 'finished') {
|
||||
dispatch(completeContextRefresh({ statusId }));
|
||||
}
|
||||
|
||||
// Exit if there's nothing to fetch
|
||||
if (result_count === 0) {
|
||||
if (status === 'finished') {
|
||||
onChangeLoadingState('idle');
|
||||
} else {
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A positive result count means there _might_ be new replies,
|
||||
// so we fetch the context in the background to check if there
|
||||
// are any new replies.
|
||||
// If so, they will populate `contexts.pendingReplies[statusId]`
|
||||
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
|
||||
.then(() => {
|
||||
// Reset loading state to `idle`. If the fetch has
|
||||
// resulted in new pending replies, the `hasPendingReplies`
|
||||
// flag will switch the loading state to 'more-available'
|
||||
if (status === 'finished') {
|
||||
onChangeLoadingState('idle');
|
||||
} else {
|
||||
// Keep background fetch going if `isLongRunning` is true
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Show an error if the fetch failed
|
||||
onChangeLoadingState('error');
|
||||
});
|
||||
});
|
||||
}, refresh.retry * 1000);
|
||||
};
|
||||
|
||||
// Initialise a refresh
|
||||
if (refreshHeader && isEnabled) {
|
||||
scheduleRefresh(refreshHeader, 1);
|
||||
onChangeLoadingState('loading');
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [onChangeLoadingState, dispatch, statusId, refreshHeader, isEnabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This component fetches new post replies in the background
|
||||
* and gives users the option to show them.
|
||||
*
|
||||
* The following three scenarios are handled:
|
||||
*
|
||||
* 1. When the browser tab is visible, replies are refetched periodically
|
||||
* (more frequently for new posts, less frequently for old ones)
|
||||
* 2. Replies are refetched when the browser tab is refocused
|
||||
* after it was hidden or minimised
|
||||
* 3. For remote posts, remote replies that might not yet be known to the
|
||||
* server are imported & fetched using the AsyncRefresh API.
|
||||
*/
|
||||
export const RefreshController: React.FC<{
|
||||
statusId: string;
|
||||
}> = ({ statusId }) => {
|
||||
statusCreatedAt: string;
|
||||
isLocal: boolean;
|
||||
}> = ({ statusId, statusCreatedAt, isLocal }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const refreshHeader = useAppSelector(
|
||||
(state) => state.contexts.refreshing[statusId],
|
||||
const refreshHeader = useAppSelector((state) =>
|
||||
isLocal ? undefined : state.contexts.refreshing[statusId],
|
||||
);
|
||||
const hasPendingReplies = useAppSelector(
|
||||
(state) => !!state.contexts.pendingReplies[statusId]?.length,
|
||||
@@ -78,78 +219,52 @@ export const RefreshController: React.FC<{
|
||||
dispatch(clearPendingReplies({ statusId }));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
// Prevent too-frequent context calls
|
||||
const debouncedFetchContext = useDebouncedCallback(
|
||||
() => {
|
||||
void dispatch(fetchContext({ statusId, prefetchOnly: true }));
|
||||
},
|
||||
// Ensure the debounce is a bit shorter than the auto-fetch interval
|
||||
SHORT_AUTO_FETCH_REPLIES_INTERVAL - 500,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
|
||||
const scheduleRefresh = (
|
||||
refresh: AsyncRefreshHeader,
|
||||
iteration: number,
|
||||
) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||
// At three scheduled refreshes, we consider the job
|
||||
// long-running and attempt to fetch any new replies so far
|
||||
const isLongRunning = iteration === 3;
|
||||
const isDocumentVisible = useIsDocumentVisible({
|
||||
onChange: (isVisible) => {
|
||||
// Auto-fetch new replies when the page is refocused
|
||||
if (isVisible && partialLoadingState !== 'loading' && !wasDismissed) {
|
||||
debouncedFetchContext();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { status, result_count } = result.async_refresh;
|
||||
// Check for remote replies
|
||||
useCheckForRemoteReplies({
|
||||
statusId,
|
||||
refreshHeader,
|
||||
isEnabled: isDocumentVisible && !isLocal && !wasDismissed,
|
||||
onChangeLoadingState: setLoadingState,
|
||||
});
|
||||
|
||||
// If the refresh status is not finished and not long-running,
|
||||
// we just schedule another refresh and exit
|
||||
if (status === 'running' && !isLongRunning) {
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
return;
|
||||
}
|
||||
// Only auto-fetch new replies if there's no ongoing remote replies check
|
||||
const shouldAutoFetchReplies =
|
||||
isDocumentVisible && partialLoadingState !== 'loading' && !wasDismissed;
|
||||
|
||||
// If refresh status is finished, clear `refreshHeader`
|
||||
// (we don't want to do this if it's just a long-running job)
|
||||
if (status === 'finished') {
|
||||
dispatch(completeContextRefresh({ statusId }));
|
||||
}
|
||||
const autoFetchInterval = useMemo(
|
||||
() =>
|
||||
getIsThreadNew(statusCreatedAt)
|
||||
? SHORT_AUTO_FETCH_REPLIES_INTERVAL
|
||||
: LONG_AUTO_FETCH_REPLIES_INTERVAL,
|
||||
[statusCreatedAt],
|
||||
);
|
||||
|
||||
// Exit if there's nothing to fetch
|
||||
if (result_count === 0) {
|
||||
if (status === 'finished') {
|
||||
setLoadingState('idle');
|
||||
} else {
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// A positive result count means there _might_ be new replies,
|
||||
// so we fetch the context in the background to check if there
|
||||
// are any new replies.
|
||||
// If so, they will populate `contexts.pendingReplies[statusId]`
|
||||
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
|
||||
.then(() => {
|
||||
// Reset loading state to `idle`. If the fetch has
|
||||
// resulted in new pending replies, the `hasPendingReplies`
|
||||
// flag will switch the loading state to 'more-available'
|
||||
if (status === 'finished') {
|
||||
setLoadingState('idle');
|
||||
} else {
|
||||
// Keep background fetch going if `isLongRunning` is true
|
||||
scheduleRefresh(refresh, iteration + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Show an error if the fetch failed
|
||||
setLoadingState('error');
|
||||
});
|
||||
});
|
||||
}, refresh.retry * 1000);
|
||||
};
|
||||
|
||||
// Initialise a refresh
|
||||
if (refreshHeader && !wasDismissed) {
|
||||
scheduleRefresh(refreshHeader, 1);
|
||||
setLoadingState('loading');
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [dispatch, statusId, refreshHeader, wasDismissed]);
|
||||
useInterval(debouncedFetchContext, {
|
||||
delay: autoFetchInterval,
|
||||
isEnabled: shouldAutoFetchReplies,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Hide success message after a short delay
|
||||
@@ -172,7 +287,7 @@ export const RefreshController: React.FC<{
|
||||
};
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const showPending = useCallback(() => {
|
||||
dispatch(showPendingReplies({ statusId }));
|
||||
setLoadingState('success');
|
||||
}, [dispatch, statusId]);
|
||||
@@ -196,7 +311,7 @@ export const RefreshController: React.FC<{
|
||||
isActive={loadingState === 'more-available'}
|
||||
message={intl.formatMessage(messages.moreFound)}
|
||||
action={intl.formatMessage(messages.show)}
|
||||
onActionClick={handleClick}
|
||||
onActionClick={showPending}
|
||||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
@@ -205,7 +320,7 @@ export const RefreshController: React.FC<{
|
||||
isActive={loadingState === 'error'}
|
||||
message={intl.formatMessage(messages.error)}
|
||||
action={intl.formatMessage(messages.retry)}
|
||||
onActionClick={handleClick}
|
||||
onActionClick={showPending}
|
||||
onDismiss={dismissPrompt}
|
||||
animateFrom='below'
|
||||
/>
|
||||
|
||||
@@ -571,14 +571,6 @@ class Status extends ImmutablePureComponent {
|
||||
const isLocal = status.getIn(['account', 'acct'], '').indexOf('@') === -1;
|
||||
const isIndexable = !status.getIn(['account', 'noindex']);
|
||||
|
||||
if (!isLocal) {
|
||||
remoteHint = (
|
||||
<RefreshController
|
||||
statusId={status.get('id')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
@@ -649,7 +641,12 @@ class Status extends ImmutablePureComponent {
|
||||
</Hotkeys>
|
||||
|
||||
{descendants}
|
||||
{remoteHint}
|
||||
|
||||
<RefreshController
|
||||
isLocal={isLocal}
|
||||
statusId={status.get('id')}
|
||||
statusCreatedAt={status.get('created_at')}
|
||||
/>
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
|
||||
|
||||
39
app/javascript/mastodon/hooks/useInterval.ts
Normal file
39
app/javascript/mastodon/hooks/useInterval.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to create an interval that invokes a callback function
|
||||
* at a specified delay using the setInterval API.
|
||||
* Based on https://usehooks-ts.com/react-hook/use-interval
|
||||
*/
|
||||
export function useInterval(
|
||||
callback: () => void,
|
||||
{
|
||||
delay,
|
||||
isEnabled = true,
|
||||
}: {
|
||||
delay: number;
|
||||
isEnabled?: boolean;
|
||||
},
|
||||
) {
|
||||
// Write callback to a ref so we can omit it from
|
||||
// the interval effect's dependency array
|
||||
const callbackRef = useRef(callback);
|
||||
useLayoutEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
// Set up the interval.
|
||||
useEffect(() => {
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
callbackRef.current();
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [delay, isEnabled]);
|
||||
}
|
||||
32
app/javascript/mastodon/hooks/useIsDocumentVisible.ts
Normal file
32
app/javascript/mastodon/hooks/useIsDocumentVisible.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function useIsDocumentVisible({
|
||||
onChange,
|
||||
}: {
|
||||
onChange?: (isVisible: boolean) => void;
|
||||
} = {}) {
|
||||
const [isDocumentVisible, setIsDocumentVisible] = useState(
|
||||
() => document.visibilityState === 'visible',
|
||||
);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleVisibilityChange() {
|
||||
const isVisible = document.visibilityState === 'visible';
|
||||
|
||||
setIsDocumentVisible(isVisible);
|
||||
onChangeRef.current?.(isVisible);
|
||||
}
|
||||
window.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isDocumentVisible;
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
"account.disable_notifications": "Na cuir brath thugam tuilleadh nuair a chuireas @{name} post ris",
|
||||
"account.domain_blocking": "Àrainn ’ga bacadh",
|
||||
"account.edit_profile": "Deasaich a’ phròifil",
|
||||
"account.edit_profile_short": "Deasaich",
|
||||
"account.enable_notifications": "Cuir brath thugam nuair a chuireas @{name} post ris",
|
||||
"account.endorse": "Brosnaich air a’ phròifil",
|
||||
"account.familiar_followers_many": "’Ga leantainn le {name1}, {name2}, and {othersCount, plural, one {# eile air a bheil thu eòlach} other {# eile air a bheil thu eòlach}}",
|
||||
@@ -40,6 +41,11 @@
|
||||
"account.featured_tags.last_status_never": "Gun phost",
|
||||
"account.follow": "Lean",
|
||||
"account.follow_back": "Lean air ais",
|
||||
"account.follow_back_short": "Lean air ais",
|
||||
"account.follow_request": "Iarr leantainn",
|
||||
"account.follow_request_cancel": "Sguir dhen iarrtas",
|
||||
"account.follow_request_cancel_short": "Sguir dheth",
|
||||
"account.follow_request_short": "Iarr",
|
||||
"account.followers": "Luchd-leantainn",
|
||||
"account.followers.empty": "Chan eil neach sam bith a’ leantainn air a’ chleachdaiche seo fhathast.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} neach-leantainn} other {{counter} luchd-leantainn}}",
|
||||
@@ -125,7 +131,7 @@
|
||||
"annual_report.summary.new_posts.new_posts": "postaichean ùra",
|
||||
"annual_report.summary.percentile.text": "<topLabel>Tha thu am measg</topLabel><percentage></percentage><bottomLabel>dhen luchd-cleachdaidh as cliùitiche air {domain}.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Ainmeil ’nad latha ’s ’nad linn.",
|
||||
"annual_report.summary.thanks": "Mòran taing airson conaltradh air Mastodon.",
|
||||
"annual_report.summary.thanks": "Mòran taing airson conaltradh air Mastodon!",
|
||||
"attachments_list.unprocessed": "(gun phròiseasadh)",
|
||||
"audio.hide": "Falaich an fhuaim",
|
||||
"block_modal.remote_users_caveat": "Iarraidh sinn air an fhrithealaiche {domain} gun gèill iad ri do cho-dhùnadh. Gidheadh, chan eil barantas gun gèill iad on a làimhsicheas cuid a fhrithealaichean bacaidhean air dòigh eadar-dhealaichte. Dh’fhaoidte gum faic daoine gun chlàradh a-steach na postaichean poblach agad fhathast.",
|
||||
@@ -251,7 +257,12 @@
|
||||
"confirmations.revoke_quote.confirm": "Thoir am post air falbh",
|
||||
"confirmations.revoke_quote.message": "Cha ghabh seo a neo-dhèanamh.",
|
||||
"confirmations.revoke_quote.title": "A bheil thu airson am post a thoirt air falbh?",
|
||||
"confirmations.unblock.confirm": "Dì-bhac",
|
||||
"confirmations.unblock.title": "A bheil thu airson {name} a dhì-bhacadh?",
|
||||
"confirmations.unfollow.confirm": "Na lean tuilleadh",
|
||||
"confirmations.unfollow.title": "A bheil thu airson sgur de {name} a leantainn?",
|
||||
"confirmations.withdraw_request.confirm": "Cuir d’ iarrtas dhan dàrna taobh",
|
||||
"confirmations.withdraw_request.title": "A bheil thu airson d’ iarrtas gus {name} a leantainn a chur dhan dàrna taobh?",
|
||||
"content_warning.hide": "Falaich am post",
|
||||
"content_warning.show": "Seall e co-dhiù",
|
||||
"content_warning.show_more": "Seall barrachd dheth",
|
||||
@@ -742,6 +753,7 @@
|
||||
"privacy.unlisted.short": "Poblach ach sàmhach",
|
||||
"privacy_policy.last_updated": "An t-ùrachadh mu dheireadh {date}",
|
||||
"privacy_policy.title": "Poileasaidh prìobhaideachd",
|
||||
"quote_error.edit": "Chan urrainn dhut luaidh a chur ris nuair a bhios tu ri deasachadh puist.",
|
||||
"quote_error.poll": "Chan fhaod thu luaidh a chur an cois cunntais-bheachd.",
|
||||
"quote_error.quote": "Chan eil taic ach ri aon luaidh aig an aon àm.",
|
||||
"quote_error.unauthorized": "Chan fhaod thu am post seo a luaidh.",
|
||||
@@ -861,6 +873,13 @@
|
||||
"status.cancel_reblog_private": "Na brosnaich tuilleadh",
|
||||
"status.cannot_quote": "Chan fhaod thu am post seo a luaidh",
|
||||
"status.cannot_reblog": "Cha ghabh am post seo brosnachadh",
|
||||
"status.contains_quote": "Tha luaidh na bhroinn",
|
||||
"status.context.loading": "A’ luchdadh barrachd fhreagairtean",
|
||||
"status.context.loading_error": "Cha b’ urrainn dhuinn nam freagairtean ùra a luchdadh",
|
||||
"status.context.loading_success": "Chaidh na freagairtean ùra a luchdadh",
|
||||
"status.context.more_replies_found": "Fhuair sinn lorg air barrachd fhreagairtean",
|
||||
"status.context.retry": "Feuch ris a-rithist",
|
||||
"status.context.show": "Seall",
|
||||
"status.continued_thread": "Pàirt de shnàithlean",
|
||||
"status.copy": "Dèan lethbhreac dhen cheangal dhan phost",
|
||||
"status.delete": "Sguab às",
|
||||
@@ -890,17 +909,22 @@
|
||||
"status.quote": "Luaidh",
|
||||
"status.quote.cancel": "Sguir dhen luaidh",
|
||||
"status.quote_error.filtered": "Falaichte le criathrag a th’ agad",
|
||||
"status.quote_error.limited_account_hint.action": "Seall e co-dhiù",
|
||||
"status.quote_error.limited_account_hint.title": "Chaidh an cunntas seo fhalach le maoir {domain}.",
|
||||
"status.quote_error.not_available": "Chan eil am post ri fhaighinn",
|
||||
"status.quote_error.pending_approval": "Cha deach dèiligeadh ris a’ phost fhathast",
|
||||
"status.quote_error.pending_approval_popout.body": "Air Mastodon, ’s urrainn dhut stiùireadh am faod cuideigin do luaidh gus nach fhaod. Tha am post seo a’ feitheamh air aonta an ùghdair thùsail.",
|
||||
"status.quote_error.revoked": "Chaidh am post a thoirt air falbh leis an ùghdar",
|
||||
"status.quote_followers_only": "Chan fhaod ach luchd-leantainn am post seo a luaidh",
|
||||
"status.quote_manual_review": "Nì an t-ùghdar lèirmheas air a làimh",
|
||||
"status.quote_noun": "Luaidh",
|
||||
"status.quote_policy_change": "Atharraich cò dh’fhaodas luaidh",
|
||||
"status.quote_post_author": "Luaidh air post le @{name}",
|
||||
"status.quote_private": "Chan fhaodar postaichean prìobhaideach a luaidh",
|
||||
"status.quotes": "{count, plural, one {luaidh} two {luaidh} few {luaidhean} other {luaidh}}",
|
||||
"status.quotes.empty": "Chan deach am post seo a luaidh le duine sam bith fhathast. Nuair a luaidheas cuideigin e, nochdaidh iad an-seo.",
|
||||
"status.quotes.local_other_disclaimer": "Cha tèid luaidhean a dhiùilt an ùghdar a shealltainn.",
|
||||
"status.quotes.remote_other_disclaimer": "Cha dèid ach luaidhean o {domain} a shealltainn an-seo le cinnt. Cha dèid luaidhean a dhiùilt an ùghdar a shealltainn.",
|
||||
"status.read_more": "Leugh an còrr",
|
||||
"status.reblog": "Brosnaich",
|
||||
"status.reblog_or_quote": "Brosnaich no luaidh",
|
||||
@@ -987,7 +1011,7 @@
|
||||
"visibility_modal.helper.privacy_private_self_quote": "Chan fhaodar fèin-luaidhean air postaichean prìobhaideach a dhèanamh poblach.",
|
||||
"visibility_modal.helper.private_quoting": "Chan urrainn do chàch postaichean dhan luchd-leantainn a-mhàin a chaidh a sgrìobhadh le Mastodon a luaidh.",
|
||||
"visibility_modal.helper.unlisted_quoting": "Nuair a luaidheas daoine thu, thèid am post aca-san fhalach o loidhnichean-ama nan treandaichean.",
|
||||
"visibility_modal.instructions": "Stiùirich cò dh’fhaodas eadar-ghabhahil leis a’ phost seo. ’S urrainn dhut do roghainnean airson nam postaichean ri teachd a thaghadh aig <link>Roghainnean > Bun-roghainnean a’ phostaidh</link>",
|
||||
"visibility_modal.instructions": "Stiùirich cò dh’fhaodas eadar-ghabhahil leis a’ phost seo. ’S urrainn dhut do roghainnean airson nam postaichean ri teachd a thaghadh aig <link>Roghainnean > Bun-roghainnean a’ phostaidh</link>.",
|
||||
"visibility_modal.privacy_label": "Faicsinneachd",
|
||||
"visibility_modal.quote_followers": "Luchd-leantainn a-mhàin",
|
||||
"visibility_modal.quote_label": "Cò dh’fhaodas luaidh",
|
||||
|
||||
@@ -857,7 +857,7 @@
|
||||
"status.block": "@{name} 차단",
|
||||
"status.bookmark": "북마크",
|
||||
"status.cancel_reblog_private": "부스트 취소",
|
||||
"status.cannot_quote": "인용을 비허용하는 게시물",
|
||||
"status.cannot_quote": "인용을 비허용한 게시물",
|
||||
"status.cannot_reblog": "이 게시물은 부스트 할 수 없습니다",
|
||||
"status.contains_quote": "인용 포함",
|
||||
"status.continued_thread": "이어지는 글타래",
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"account.follows.empty": "Este utilizador ainda não segue ninguém.",
|
||||
"account.follows_you": "Segue-te",
|
||||
"account.go_to_profile": "Ir para o perfil",
|
||||
"account.hide_reblogs": "Esconder partilhas impulsionadas de @{name}",
|
||||
"account.hide_reblogs": "Esconder partilhas de @{name}",
|
||||
"account.in_memoriam": "Em Memória.",
|
||||
"account.joined_short": "Juntou-se a",
|
||||
"account.languages": "Alterar idiomas subscritos",
|
||||
@@ -79,7 +79,7 @@
|
||||
"account.requested_follow": "{name} pediu para seguir-te",
|
||||
"account.requests_to_follow_you": "Pediu para seguir-te",
|
||||
"account.share": "Partilhar o perfil @{name}",
|
||||
"account.show_reblogs": "Mostrar partilhas impulsionadas de @{name}",
|
||||
"account.show_reblogs": "Mostrar partilhas de @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} publicação} other {{counter} publicações}}",
|
||||
"account.unblock": "Desbloquear @{name}",
|
||||
"account.unblock_domain": "Desbloquear o domínio {domain}",
|
||||
@@ -113,7 +113,7 @@
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "Descreve isto para pessoas com problemas de visão…",
|
||||
"alt_text_modal.done": "Concluído",
|
||||
"announcement.announcement": "Mensagem de manutenção",
|
||||
"annual_report.summary.archetype.booster": "O caçador de frescura",
|
||||
"annual_report.summary.archetype.booster": "O caçador de tendências",
|
||||
"annual_report.summary.archetype.lurker": "O espreitador",
|
||||
"annual_report.summary.archetype.oracle": "O oráculo",
|
||||
"annual_report.summary.archetype.pollster": "O sondagens",
|
||||
@@ -122,7 +122,7 @@
|
||||
"annual_report.summary.followers.total": "{count} no total",
|
||||
"annual_report.summary.here_it_is": "Aqui está um resumo do ano {year}:",
|
||||
"annual_report.summary.highlighted_post.by_favourites": "publicação mais favorita",
|
||||
"annual_report.summary.highlighted_post.by_reblogs": "publicação mais impulsionada",
|
||||
"annual_report.summary.highlighted_post.by_reblogs": "publicação mais partilhada",
|
||||
"annual_report.summary.highlighted_post.by_replies": "publicação com o maior número de respostas",
|
||||
"annual_report.summary.highlighted_post.possessive": "{name}",
|
||||
"annual_report.summary.most_used_app.most_used_app": "aplicação mais utilizada",
|
||||
@@ -142,9 +142,9 @@
|
||||
"block_modal.they_will_know": "Ele pode ver que o bloqueaste.",
|
||||
"block_modal.title": "Bloquear utilizador?",
|
||||
"block_modal.you_wont_see_mentions": "Não verás publicações que mencionem este utilizador.",
|
||||
"boost_modal.combo": "Podes premir {combo} para não voltares a ver isto",
|
||||
"boost_modal.reblog": "Impulsionar a publicação?",
|
||||
"boost_modal.undo_reblog": "Não impulsionar a publicação?",
|
||||
"boost_modal.combo": "Pode clicar em {combo} para não voltar a ver isto",
|
||||
"boost_modal.reblog": "Partilhar a publicação?",
|
||||
"boost_modal.undo_reblog": "Deixar de partilhar a publicação?",
|
||||
"bundle_column_error.copy_stacktrace": "Copiar relatório de erros",
|
||||
"bundle_column_error.error.body": "A página solicitada não pôde ser sintetizada. Isto pode ser devido a uma falha no nosso código ou a um problema de compatibilidade com o navegador.",
|
||||
"bundle_column_error.error.title": "Ó, não!",
|
||||
@@ -249,7 +249,7 @@
|
||||
"confirmations.quiet_post_quote_info.message": "Ao citar uma publicação não listada, a sua publicação não será exibida nos destaques.",
|
||||
"confirmations.quiet_post_quote_info.title": "Citação de publicação não listada",
|
||||
"confirmations.redraft.confirm": "Eliminar e reescrever",
|
||||
"confirmations.redraft.message": "Tens a certeza de que queres eliminar e tornar a escrever esta publicação? Os favoritos e as publicações impulsionadas perder-se-ão e as respostas à publicação original ficarão órfãs.",
|
||||
"confirmations.redraft.message": "Tem a certeza que pretende eliminar e tornar a escrever esta publicação? Os favoritos e as partilhas perder-se-ão e as respostas à publicação original ficarão órfãs.",
|
||||
"confirmations.redraft.title": "Eliminar e reescrever publicação?",
|
||||
"confirmations.remove_from_followers.confirm": "Remover seguidor",
|
||||
"confirmations.remove_from_followers.message": "{name} vai parar de seguir-te. Tens a certeza que prentedes continuar?",
|
||||
@@ -330,7 +330,7 @@
|
||||
"empty_column.account_timeline": "Sem publicações por aqui!",
|
||||
"empty_column.account_unavailable": "Perfil indisponível",
|
||||
"empty_column.blocks": "Ainda não bloqueaste nenhum utilizador.",
|
||||
"empty_column.bookmarked_statuses": "Ainda não tens nenhuma publicação marcada. Quando marcares uma, ela aparecerá aqui.",
|
||||
"empty_column.bookmarked_statuses": "Ainda não tem nenhuma publicação salva. Quando salvar uma, ela aparecerá aqui.",
|
||||
"empty_column.community": "A cronologia local está vazia. Escreve algo publicamente para começar!",
|
||||
"empty_column.direct": "Ainda não tens qualquer menção privada. Quando enviares ou receberes uma, ela irá aparecer aqui.",
|
||||
"empty_column.domain_blocks": "Ainda não há qualquer domínio bloqueado.",
|
||||
@@ -366,7 +366,7 @@
|
||||
"filter_modal.added.context_mismatch_title": "O contexto não coincide!",
|
||||
"filter_modal.added.expired_explanation": "Esta categoria de filtro expirou, tens de alterar a data de validade para que ele seja aplicado.",
|
||||
"filter_modal.added.expired_title": "Filtro expirado!",
|
||||
"filter_modal.added.review_and_configure": "Para rever e configurar mais detalhadamente esta categoria de filtro, vai a {settings_link}.",
|
||||
"filter_modal.added.review_and_configure": "Para rever e configurar mais detalhadamente esta categoria de filtro, vá a {settings_link}.",
|
||||
"filter_modal.added.review_and_configure_title": "Definições do filtro",
|
||||
"filter_modal.added.settings_link": "página de definições",
|
||||
"filter_modal.added.short_explanation": "Esta publicação foi adicionada à seguinte categoria de filtro: {title}.",
|
||||
@@ -429,10 +429,10 @@
|
||||
"hashtag.counter_by_uses": "{count, plural, one {{counter} publicação} other {{counter} publicações}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} publicação} other {{counter} publicações}} hoje",
|
||||
"hashtag.feature": "Destacar no perfil",
|
||||
"hashtag.follow": "Seguir #etiqueta",
|
||||
"hashtag.follow": "Seguir etiqueta",
|
||||
"hashtag.mute": "Silenciar #{hashtag}",
|
||||
"hashtag.unfeature": "Não destacar no perfil",
|
||||
"hashtag.unfollow": "Deixar de seguir #etiqueta",
|
||||
"hashtag.unfollow": "Deixar de seguir a etiqueta",
|
||||
"hashtags.and_other": "…e {count, plural, other {mais #}}",
|
||||
"hints.profiles.followers_may_be_missing": "É possível que não estejam a ser mostrados todos os seguidores deste perfil.",
|
||||
"hints.profiles.follows_may_be_missing": "É possível que não estejam a ser mostrados todos os seguidos por este perfil.",
|
||||
@@ -441,7 +441,7 @@
|
||||
"hints.profiles.see_more_follows": "Ver mais perfis seguidos em {domain}",
|
||||
"hints.profiles.see_more_posts": "Ver mais publicações em {domain}",
|
||||
"home.column_settings.show_quotes": "Mostrar citações",
|
||||
"home.column_settings.show_reblogs": "Mostrar impulsos",
|
||||
"home.column_settings.show_reblogs": "Mostrar partilhas",
|
||||
"home.column_settings.show_replies": "Mostrar respostas",
|
||||
"home.hide_announcements": "Ocultar mensagens de manutenção",
|
||||
"home.pending_critical_update.body": "Atualiza o teu servidor Mastodon assim que possível!",
|
||||
@@ -473,7 +473,7 @@
|
||||
"intervals.full.minutes": "{number, plural, one {# minuto} other {# minutos}}",
|
||||
"keyboard_shortcuts.back": "voltar atrás",
|
||||
"keyboard_shortcuts.blocked": "abrir a lista de utilizadores bloqueados",
|
||||
"keyboard_shortcuts.boost": "impulsionar a publicação",
|
||||
"keyboard_shortcuts.boost": "Partilhar a publicação",
|
||||
"keyboard_shortcuts.column": "focar uma publicação numa das colunas",
|
||||
"keyboard_shortcuts.compose": "focar área de texto da publicação",
|
||||
"keyboard_shortcuts.description": "Descrição",
|
||||
@@ -564,7 +564,7 @@
|
||||
"navigation_bar.advanced_interface": "Abrir na interface web avançada",
|
||||
"navigation_bar.automated_deletion": "Eliminação automática de publicações",
|
||||
"navigation_bar.blocks": "Utilizadores bloqueados",
|
||||
"navigation_bar.bookmarks": "Marcadores",
|
||||
"navigation_bar.bookmarks": "Itens salvos",
|
||||
"navigation_bar.direct": "Menções privadas",
|
||||
"navigation_bar.domain_blocks": "Domínios escondidos",
|
||||
"navigation_bar.favourites": "Favoritos",
|
||||
@@ -626,8 +626,8 @@
|
||||
"notification.own_poll": "A tua sondagem terminou",
|
||||
"notification.poll": "Terminou uma sondagem em que votaste",
|
||||
"notification.quoted_update": "{name} editou uma publicação que citou",
|
||||
"notification.reblog": "{name} impulsionou a tua publicação",
|
||||
"notification.reblog.name_and_others_with_link": "{name} e <a>{count, plural, one {# outro} other {# outros}}</a> impulsionaram a tua publicação",
|
||||
"notification.reblog": "{name} partilhou a sua publicação",
|
||||
"notification.reblog.name_and_others_with_link": "{name} e <a>{count, plural, one {# outro} other {# outros}}</a> partilharam a sua publicação",
|
||||
"notification.relationships_severance_event": "Perdeu as ligações com {name}",
|
||||
"notification.relationships_severance_event.account_suspension": "Um administrador de {from} suspendeu {target}, o que significa que já não podes receber atualizações dele ou interagir com ele.",
|
||||
"notification.relationships_severance_event.domain_block": "Um administrador de {from} bloqueou {target}, incluindo {followersCount} dos teus seguidores e {followingCount, plural, one {# conta} other {# contas}} que segues.",
|
||||
@@ -670,7 +670,7 @@
|
||||
"notifications.column_settings.poll": "Resultados da sondagem:",
|
||||
"notifications.column_settings.push": "Notificações \"push\"",
|
||||
"notifications.column_settings.quote": "Citações:",
|
||||
"notifications.column_settings.reblog": "Impulsos:",
|
||||
"notifications.column_settings.reblog": "Partilhas:",
|
||||
"notifications.column_settings.show": "Mostrar na coluna",
|
||||
"notifications.column_settings.sound": "Reproduzir som",
|
||||
"notifications.column_settings.status": "Novas publicações:",
|
||||
@@ -678,7 +678,7 @@
|
||||
"notifications.column_settings.unread_notifications.highlight": "Destacar notificações por ler",
|
||||
"notifications.column_settings.update": "Edições:",
|
||||
"notifications.filter.all": "Todas",
|
||||
"notifications.filter.boosts": "Impulsos",
|
||||
"notifications.filter.boosts": "Partilhas",
|
||||
"notifications.filter.favourites": "Favoritos",
|
||||
"notifications.filter.follows": "Seguidores",
|
||||
"notifications.filter.mentions": "Menções",
|
||||
@@ -867,12 +867,12 @@
|
||||
"status.admin_account": "Abrir a interface de moderação para @{name}",
|
||||
"status.admin_domain": "Abrir interface de moderação para {domain}",
|
||||
"status.admin_status": "Abrir esta publicação na interface de moderação",
|
||||
"status.all_disabled": "Impulsos e citações estão desativados",
|
||||
"status.all_disabled": "Partilhas e citações estão desativados",
|
||||
"status.block": "Bloquear @{name}",
|
||||
"status.bookmark": "Guardar nos marcadores",
|
||||
"status.cancel_reblog_private": "Retirar impulso",
|
||||
"status.cancel_reblog_private": "Deixar de partilhar",
|
||||
"status.cannot_quote": "Não lhe é permitido citar esta publicação",
|
||||
"status.cannot_reblog": "Esta publicação não pode ser impulsionada",
|
||||
"status.cannot_reblog": "Esta publicação não pode ser partilhada",
|
||||
"status.contains_quote": "Contém citação",
|
||||
"status.context.loading": "A carregar mais respostas",
|
||||
"status.context.loading_error": "Não foi possível carregar novas respostas",
|
||||
@@ -926,12 +926,12 @@
|
||||
"status.quotes.local_other_disclaimer": "As citações rejeitadas pelo autor não serão exibidas.",
|
||||
"status.quotes.remote_other_disclaimer": "Apenas citações de {domain} serão garantidamente exibidas aqui. Citações rejeitadas pelo autor não serão exibidas.",
|
||||
"status.read_more": "Ler mais",
|
||||
"status.reblog": "Impulsionar",
|
||||
"status.reblog": "Partilhar",
|
||||
"status.reblog_or_quote": "Partilhe ou cite",
|
||||
"status.reblog_private": "Partilhe novamente com os seus seguidores",
|
||||
"status.reblogged_by": "{name} impulsionou",
|
||||
"status.reblogs": "{count, plural, one {impulso} other {impulsos}}",
|
||||
"status.reblogs.empty": "Ainda ninguém impulsionou esta publicação. Quando alguém o fizer, aparecerá aqui.",
|
||||
"status.reblogged_by": "{name} partilhou",
|
||||
"status.reblogs": "{count, plural, one {partilha} other {partilhas}}",
|
||||
"status.reblogs.empty": "Ainda ninguém partilhou esta publicação. Quando alguém o fizer, aparecerá aqui.",
|
||||
"status.redraft": "Eliminar e reescrever",
|
||||
"status.remove_bookmark": "Retirar dos marcadores",
|
||||
"status.remove_favourite": "Remover dos favoritos",
|
||||
|
||||
@@ -9368,19 +9368,13 @@ noscript {
|
||||
|
||||
&__shared {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
color: $darker-text-color;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__pill {
|
||||
background: var(--surface-variant-background-color);
|
||||
border-radius: 4px;
|
||||
@@ -9390,6 +9384,7 @@ noscript {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__author-link {
|
||||
|
||||
Reference in New Issue
Block a user