mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-17 17:58:14 +00:00
Compare commits
157 Commits
127ffe874a
...
4bc5e8f66e
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4bc5e8f66e | ||
![]() |
0efb889a9c | ||
![]() |
c0eabe289b | ||
![]() |
5bbc3c5ebb | ||
![]() |
d5e2cf5d3c | ||
![]() |
82a6ff091f | ||
![]() |
4b8e60682d | ||
![]() |
6c2db9b1cf | ||
![]() |
30344d6abf | ||
![]() |
1637297085 | ||
![]() |
dec1fb71f4 | ||
![]() |
7273f6c03c | ||
![]() |
a3ffd2edf8 | ||
![]() |
a2c5eace88 | ||
![]() |
a643d9d498 | ||
![]() |
3b52dca405 | ||
![]() |
853a0c466e | ||
![]() |
94bceb8683 | ||
![]() |
88b0f3a172 | ||
![]() |
b69b5ba775 | ||
![]() |
c442589593 | ||
![]() |
28633a504a | ||
![]() |
ad78701b6f | ||
![]() |
1496488771 | ||
![]() |
dd3d958e75 | ||
![]() |
b363a3651d | ||
![]() |
86645fc14c | ||
![]() |
f9beecb343 | ||
![]() |
4ecfbd3920 | ||
![]() |
a315934314 | ||
![]() |
e9170e2de1 | ||
![]() |
5cfc1fabcf | ||
![]() |
786b12e379 | ||
![]() |
e7c5c25de8 | ||
![]() |
a1e8813522 | ||
![]() |
76c1446416 | ||
![]() |
8bd2c87399 | ||
![]() |
1e2d77f2c7 | ||
![]() |
fb6c22f5c2 | ||
![]() |
f7259f625f | ||
![]() |
b628a98d32 | ||
![]() |
d8fa807998 | ||
![]() |
ef66d8379c | ||
![]() |
8ee6cee36e | ||
![]() |
71b2120e5c | ||
![]() |
b10078633c | ||
![]() |
b5eebd4d2b | ||
![]() |
fdefc4d2b4 | ||
![]() |
f6b2609353 | ||
![]() |
bdffdcb12f | ||
![]() |
1ebb87a6a8 | ||
![]() |
83660ee381 | ||
![]() |
1fa72d6c44 | ||
![]() |
5a7c0d42f7 | ||
![]() |
e8d2432e6a | ||
![]() |
2af17adc34 | ||
![]() |
e97f43399b | ||
![]() |
c66c5fd73d | ||
![]() |
3c0767f543 | ||
![]() |
70cd1fdc63 | ||
![]() |
39028dde40 | ||
![]() |
6e39b5ef04 | ||
![]() |
49db8a9662 | ||
![]() |
2cfa6cb0e0 | ||
![]() |
1ae3510ede | ||
![]() |
6f1135d763 | ||
![]() |
52bc2f64f4 | ||
![]() |
b1375328e1 | ||
![]() |
9443e2cc4b | ||
![]() |
3a533c6c8d | ||
![]() |
c047014214 | ||
![]() |
68b05e994f | ||
![]() |
a203a05eb1 | ||
![]() |
68090cd8be | ||
![]() |
dd064aaa36 | ||
![]() |
e6e8974785 | ||
![]() |
498af63b85 | ||
![]() |
c357a7f8d6 | ||
![]() |
bae258925c | ||
![]() |
e8a603b18f | ||
![]() |
f00c8e3245 | ||
![]() |
153af19f55 | ||
![]() |
964916c71b | ||
![]() |
8782e860b6 | ||
![]() |
641c0c6393 | ||
![]() |
0383100b0e | ||
![]() |
87db28cebc | ||
![]() |
ac4b735c67 | ||
![]() |
6d017dbf10 | ||
![]() |
0d650780e2 | ||
![]() |
1804a87193 | ||
![]() |
9576434d47 | ||
![]() |
b804ed0cba | ||
![]() |
48451b782d | ||
![]() |
2e0a00ab46 | ||
![]() |
a9f2ec45da | ||
![]() |
c1ef1f62d5 | ||
![]() |
dbb20f76a7 | ||
![]() |
91741214e1 | ||
![]() |
8fa32ca8ba | ||
![]() |
8285194451 | ||
![]() |
392eaf1010 | ||
![]() |
c6dddbb66e | ||
![]() |
c52848b444 | ||
![]() |
0a7418e6d8 | ||
![]() |
72f2f35bfb | ||
![]() |
0f9f27972d | ||
![]() |
9f16f41678 | ||
![]() |
47fda2df2c | ||
![]() |
377289c961 | ||
![]() |
f852da50f6 | ||
![]() |
8ba1487f30 | ||
![]() |
644da36336 | ||
![]() |
fb5b8ae0a5 | ||
![]() |
fd902c04f7 | ||
![]() |
8ee8231a43 | ||
![]() |
c4128d89c9 | ||
![]() |
9954acf61d | ||
![]() |
0276354775 | ||
![]() |
dba636da7a | ||
![]() |
43e9186e5d | ||
![]() |
0338733531 | ||
![]() |
1be48d0cab | ||
![]() |
e60014ed9c | ||
![]() |
0d7f1584bc | ||
![]() |
36f01af6c4 | ||
![]() |
16057f550d | ||
![]() |
e79ecabd0a | ||
![]() |
c023ebc87a | ||
![]() |
ebc6897afb | ||
![]() |
b08ccaa5b3 | ||
![]() |
b9b1500fc5 | ||
![]() |
d28a4428b5 | ||
![]() |
6166e61638 | ||
![]() |
e5aa8c1ff3 | ||
![]() |
8837fd8c54 | ||
![]() |
ac039d5f13 | ||
![]() |
adf812efb3 | ||
![]() |
3f743b1a07 | ||
![]() |
204ff46f7e | ||
![]() |
54f9a1b43b | ||
![]() |
e9b1c1edfe | ||
![]() |
0ec6c26af3 | ||
![]() |
08597a1819 | ||
![]() |
102a7635d6 | ||
![]() |
b1fe35d7d2 | ||
![]() |
adf01b021c | ||
![]() |
aac51707d1 | ||
![]() |
aa345c4630 | ||
![]() |
70c6e09e0f | ||
![]() |
1a7fd2f446 | ||
![]() |
474464ffff | ||
![]() |
98f98249ff | ||
![]() |
af157939d9 | ||
![]() |
9d07a31380 | ||
![]() |
1cb026f962 | ||
![]() |
59dc0bd6f3 |
1
.github/.well-known/funding-manifest-urls
vendored
Normal file
1
.github/.well-known/funding-manifest-urls
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
https://joinmastodon.org/funding.json
|
1
.github/workflows/crowdin-upload.yml
vendored
1
.github/workflows/crowdin-upload.yml
vendored
|
@ -14,6 +14,7 @@ on:
|
|||
- config/locales/devise.en.yml
|
||||
- config/locales/doorkeeper.en.yml
|
||||
- .github/workflows/crowdin-upload.yml
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
upload-translations:
|
||||
|
|
|
@ -81,3 +81,6 @@ AUTHORS.md
|
|||
|
||||
# Process a few selected JS files
|
||||
!lint-staged.config.js
|
||||
|
||||
# Ignore config YAML files that include ERB/ruby code prettier does not understand
|
||||
/config/email.yml
|
||||
|
|
|
@ -23,5 +23,6 @@ RSpec/SpecFilePathFormat:
|
|||
ActivityPub: activitypub
|
||||
DeepL: deepl
|
||||
FetchOEmbedService: fetch_oembed_service
|
||||
OAuth: oauth
|
||||
OEmbedController: oembed_controller
|
||||
OStatus: ostatus
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.76.1.
|
||||
# using RuboCop version 1.77.0.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
@ -28,7 +28,7 @@ Metrics/PerceivedComplexity:
|
|||
Max: 27
|
||||
|
||||
# This cop supports safe autocorrection (--autocorrect).
|
||||
# Configuration parameters: AllowedVars.
|
||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
||||
Style/FetchEnvVar:
|
||||
Exclude:
|
||||
- 'config/initializers/paperclip.rb'
|
||||
|
|
|
@ -11,6 +11,21 @@ const config: StorybookConfig = {
|
|||
name: '@storybook/react-vite',
|
||||
options: {},
|
||||
},
|
||||
staticDirs: [
|
||||
'./static',
|
||||
// We need to manually specify the assets because of the symlink in public/sw.js
|
||||
...[
|
||||
'avatars',
|
||||
'emoji',
|
||||
'headers',
|
||||
'sounds',
|
||||
'badge.png',
|
||||
'loading.gif',
|
||||
'loading.png',
|
||||
'oops.gif',
|
||||
'oops.png',
|
||||
].map((path) => ({ from: `../public/${path}`, to: `/${path}` })),
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// If you want to run the dark theme during development,
|
||||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
|
||||
const preview: Preview = {
|
||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
146
.storybook/preview.tsx
Normal file
146
.storybook/preview.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import type { LocaleData } from '@/mastodon/locales';
|
||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||
|
||||
// If you want to run the dark theme during development,
|
||||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
|
||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||
query: { as: 'json' },
|
||||
});
|
||||
|
||||
// Initialize MSW
|
||||
initialize({
|
||||
onUnhandledRequest: unhandledRequestHandler,
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
// Auto-generate docs: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
globalTypes: {
|
||||
locale: {
|
||||
description: 'Locale for the story',
|
||||
toolbar: {
|
||||
title: 'Locale',
|
||||
icon: 'globe',
|
||||
items: Object.keys(localeFiles).map((path) =>
|
||||
path.replace('/mastodon/locales/', '').replace('.json', ''),
|
||||
),
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters }) => {
|
||||
const { state = {} } = parameters;
|
||||
let reducer = rootReducer;
|
||||
if (typeof state === 'object' && state) {
|
||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
||||
}
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
return getDefaultMiddleware(defaultMiddleware);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Story />
|
||||
</Provider>
|
||||
);
|
||||
},
|
||||
(Story, { globals }) => {
|
||||
const currentLocale = (globals.locale as string) || 'en';
|
||||
const [messages, setMessages] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
const currentLocaleData = messages[currentLocale];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadLocaleData() {
|
||||
const { default: localeFile } = (await import(
|
||||
`@/mastodon/locales/${currentLocale}.json`
|
||||
)) as { default: LocaleData['messages'] };
|
||||
setMessages((prevLocales) => ({
|
||||
...prevLocales,
|
||||
[currentLocale]: localeFile,
|
||||
}));
|
||||
}
|
||||
if (!currentLocaleData) {
|
||||
void loadLocaleData();
|
||||
}
|
||||
}, [currentLocale, currentLocaleData]);
|
||||
|
||||
return (
|
||||
<IntlProvider
|
||||
locale={currentLocale}
|
||||
messages={currentLocaleData}
|
||||
textComponent='span'
|
||||
>
|
||||
<Story />
|
||||
</IntlProvider>
|
||||
);
|
||||
},
|
||||
(Story) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
<Route
|
||||
path='*'
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
render={({ location }) => {
|
||||
if (location.pathname !== '/') {
|
||||
action(`route change to ${location.pathname}`)(location);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
|
||||
state: {},
|
||||
|
||||
docs: {},
|
||||
|
||||
msw: {
|
||||
handlers: mockHandlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
344
.storybook/static/mockServiceWorker.js
Normal file
344
.storybook/static/mockServiceWorker.js
Normal file
|
@ -0,0 +1,344 @@
|
|||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.2'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
*/
|
||||
async function handleRequest(event, requestId) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(event, client, requestId)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
75
CHANGELOG.md
75
CHANGELOG.md
|
@ -2,12 +2,22 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.0] - UNRELEASED
|
||||
## [4.4.1] - 2025-07-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix nearly every sub-directory being crawled as part of Vite build (#35323 by @ClearlyClaire)
|
||||
- Fix assets not building when Redis is unavailable (#35321 by @oneiros)
|
||||
- Fix replying from media modal or pop-in-player tagging user `@undefined` (#35317 by @ClearlyClaire)
|
||||
- Fix support for special characters in various environment variables (#35314 by @mjankowski and @ClearlyClaire)
|
||||
- Fix some database migrations failing for indexes manually removed by admins (#35309 by @mjankowski)
|
||||
|
||||
## [4.4.0] - 2025-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Add “Followers you know” widget to user profiles and hover cards** (#34652, #34678, #34681, #34697, #34699, #34769, #34774 and #34914 by @diondiondion)
|
||||
- **Add featured tab to profiles on web UI and rework pinned posts** (#34405, #34483, #34491, #34754, #34855, #34858, #34868, and #34869 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- **Add featured tab to profiles on web UI and rework pinned posts** (#34405, #34483, #34491, #34754, #34855, #34858, #34868, #34869, #34927, #34995, #35056 and #34931 by @ChaosExAnima, @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Add endorsed accounts to featured tab in web UI (#34421 and #34568 by @Gargron)\
|
||||
This also includes the following new REST API endpoints:
|
||||
- `GET /api/v1/accounts/:id/endorsements`: https://docs.joinmastodon.org/methods/accounts/#endorsements
|
||||
|
@ -19,14 +29,14 @@ All notable changes to this project will be documented in this file.
|
|||
- `POST /api/v1/tags/:id/unfeature`: https://docs.joinmastodon.org/methods/tags/#unfeature
|
||||
- Add reminder when about to post without alt text in web UI (#33760 and #33784 by @Gargron)
|
||||
- Add a warning in Web UI when composing a post when the selected and detected language are different (#33042, #33683, #33700, #33724, #33770, and #34193 by @ClearlyClaire and @Gargron)
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, and #34820 by @ChaosExAnima and @ClearlyClaire)\
|
||||
- Add support for verifying and displaying remote quote posts (#34370, #34481, #34510, #34551, #34480, #34479, #34553, #34584, #34623, #34738, #34766, #34770, #34772, #34773, #34786, #34790, #34864, #34957, #34961, #35016, #35022, #35036, #34946, #34945 and #34958 by @ClearlyClaire and @diondiondion)\
|
||||
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented.\
|
||||
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
|
||||
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
|
||||
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820, #34997, #35170, #35174 and #35174 by @ChaosExAnima and @ClearlyClaire)\
|
||||
Rules are now shown in the user’s language, if a translation has been set.\
|
||||
In the REST API, `Rule` entities now have a new `translations` attribute: https://docs.joinmastodon.org/entities/Rule/#translations
|
||||
- Add emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam)
|
||||
- Add experimental support for verifying and displaying remote quote posts (#34370, #34481, #34510, #34551, #34480, #34479, #34553, #34584, #34623, #34738, #34766, #34770, #34772, #34773, #34786, #34790, and #34864 by @ClearlyClaire and @diondiondion)\
|
||||
Support for verifying remote quotes according to [FEP-044f](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) and displaying them in the Web UI has been implemented. Such quotes are currently only processed if the `inbound_quotes` experimental feature is enabled (`EXPERIMENTAL_FEATURES=inbound_quotes`).\
|
||||
Quoting other people is not implemented yet, and it is currently not possible to mark your own posts as allowing quotes. However, a new “Who can quote” setting has been added to the “Posting defaults” section of the user settings. This setting allows you to set a default that will be used for new posts made on Mastodon 4.5 and newer, when quote posts will be fully implemented.\
|
||||
In the REST API, quote posts are represented by a new `quote` attribute on `Status` and `StatusEdit` entities: https://docs.joinmastodon.org/entities/StatusEdit/#quote https://docs.joinmastodon.org/entities/Status/#quote
|
||||
- Add option to remove account from followers in web UI (#34488 by @Gargron)
|
||||
- Add relationship tags to profiles and hover cards in web UI (#34467 and #34792 by @Gargron and @diondiondion)
|
||||
- Add ability to open posts in a new tab by middle-clicking in web UI (#32988, #33106, #33419, and #34700 by @ClearlyClaire, @Gargron, and @tribela)
|
||||
|
@ -38,8 +48,11 @@ All notable changes to this project will be documented in this file.
|
|||
Server administrators can now chose to opt in to transmit referrer information when following an external link. Only the domain name is transmitted, not the referrer path.
|
||||
- Add double tap to zoom and swipe to dismiss to media modal in web UI (#34210 by @Gargron)
|
||||
- Add link from Web UI for Hashtags to the Moderation UI (#31448 by @ThisIsMissEm)
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, and #34527 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service, optionally using a provided template.
|
||||
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
||||
- Add optional bulk mailer settings (#35191 and #35203 by @oneiros)\
|
||||
This adds the optional environment variables `BULK_SMTP_PORT`, `BULK_SMTP_SERVER`, `BULK_SMTP_LOGIN` and so on analogous to `SMTP_PORT`, `SMTP_SERVER`, `SMTP_LOGIN` and related SMTP configuration environment variables.\
|
||||
When `BULK_SMTP_SERVER` is set, this group of variables is used instead of the regular ones for sending announcement notification emails and Terms of Service notification emails.
|
||||
- **Add age verification on sign-up** (#34150, #34663, and #34636 by @ClearlyClaire and @Gargron)\
|
||||
Server administrators now have a setting to set a minimum age requirement for creating a new server, asking users for their date of birth. The date of birth is checked against the minimum age requirement server-side but not stored.\
|
||||
The following REST API changes have been made to accommodate this:
|
||||
|
@ -48,10 +61,12 @@ All notable changes to this project will be documented in this file.
|
|||
- Add ability to dismiss alt text badge by tapping it in web UI (#33737 by @Gargron)
|
||||
- Add loading indicator to timeline gap indicators in web UI (#33762 by @Gargron)
|
||||
- Add interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||
- **Add experimental FASP support** (#34031, #34415, and #34765 by @oneiros)\
|
||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
|
||||
This is a first step towards supporting “Fediverse Auxiliary Service Providers” (https://github.com/mastodon/fediverse_auxiliary_service_provider_specifications). This is mostly interesting to developers who would like to implement their own FASP, but also includes the capability to share data with a discovery provider (see https://www.fediscovery.org).
|
||||
- Add ability for admins to send announcements to all users via email (#33928 and #34411 by @ClearlyClaire)\
|
||||
This is meant for critical announcements only, as this will potentially send a lot of emails and cannot be opted out of by users.
|
||||
- Add Server Moderation Notes (#31529 by @ThisIsMissEm)
|
||||
- Add loading spinner to “Post” button when sending a post (#35153 by @diondiondion)
|
||||
- Add option to use system scrollbar styling (#32117 by @vmstan)
|
||||
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
|
||||
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
|
||||
|
@ -59,8 +74,9 @@ All notable changes to this project will be documented in this file.
|
|||
- Add dropdown menu with quick actions to lists of accounts in web UI (#34391, #34709, and #34767 by @Gargron, @diondiondion, and @mkljczk)
|
||||
- Add support for displaying “year in review” notification in web UI (#32710, #32765, #32709, #32807, #32914, #33148, and #33882 by @Gargron and @mjankowski)\
|
||||
Note that the notification is currently not generated automatically, and at the moment requires a manual undocumented administrator action.
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814 by @oneiros)\
|
||||
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814, #35033, #35109 and #35278 by @oneiros)\
|
||||
For now, this needs to be explicitly enabled through the `http_message_signatures` feature flag (`EXPERIMENTAL_FEATURES=http_message_signatures`). This currently only covers verifying such signatures (inbound HTTP requests), not issuing them (outbound HTTP requests).
|
||||
- Add experimental Async Refreshes API (#34918 by @oneiros)
|
||||
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||
This experimental feature causes the server to recursively fetch replies in background tasks whenever a user opens a remote post. This happens asynchronously and the client is currently not notified of the existence of new replies, which will thus only be displayed the next time this post’s context gets requested.\
|
||||
This feature needs to be explicitly enabled server-side by setting `FETCH_REPLIES_ENABLED` environment variable to `true`.
|
||||
|
@ -85,7 +101,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Add `og:locale` to expose status language in OpenGraph previews (#34012 by @ThisIsMissEm)
|
||||
- Add `-skip-filled-timeline` option to `tootctl feed build` to skip half-filled feeds (#33844 by @ClearlyClaire)
|
||||
- Add support for changing the base Docker registry with the `BASE_REGISTRY` `ARG` (#33712 by @wolfspyre)
|
||||
- Add an optional metric exporter (#33734, #33840, #34172, #34192, 34223)\
|
||||
- Add an optional metric exporter (#33734, #33840, #34172, #34192, #34223, and #35005 by @oneiros and @renchap)\
|
||||
Optionally enable the `prometheus_exporter` ruby gem (see https://github.com/discourse/prometheus_exporter) to collect and expose metrics. See the documentation for all the details: https://docs.joinmastodon.org/admin/config/#prometheus
|
||||
- Add `attribution_domains` attribute to `PATCH /api/v1/accounts/update_credentials` (#32730 by @c960657)\
|
||||
This is documented at https://docs.joinmastodon.org/methods/accounts/#update_credentials
|
||||
|
@ -111,26 +127,31 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Changed
|
||||
|
||||
- Change design of navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067, #35072, #35074, #35075, #35101, #35173, #35183, #35193 and #35225 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron)
|
||||
- Change design of edit media modal in web UI (#33516, #33702, #33725, #33725, #33771, and #34345 by @Gargron)
|
||||
- Change design of audio player in web UI (#34520, #34740, and #34865 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of audio player in web UI (#34520, #34740, #34865, #34929, #34933, and #35034 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||
- Change design of interaction modal in web UI (#33278 by @Gargron)
|
||||
- Change list timelines to reflect added and removed users retroactively (#32930 by @Gargron)
|
||||
- Change account search to be more forgiving of spaces (#34455 by @Gargron)
|
||||
- Change unfollow button label from “Mutual” to “Unfollow” in web UI (#34392 by @Gargron)
|
||||
- Change “Specific people” to “Private mention” in menu in web UI (#33963 by @Gargron)
|
||||
- Change "Explore" to "Trending" and remove explanation banners (#34985 by @Gargron)
|
||||
- Change media attachments of moderated posts to not be accessible (#34872 by @Gargron)
|
||||
Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime.
|
||||
- Change language names in compose box language picker to be localized (#33402 by @c960657)
|
||||
- Change onboarding flow in web UI (#32998, #33119, and #33471 by @ClearlyClaire and @Gargron)
|
||||
- Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 by @ClearlyClaire and @Gargron)
|
||||
- Change Advanced Web UI to use the new main menu instead of the “Getting started” column (#35117 by @diondiondion)
|
||||
- Change emoji categories in admin interface to be ordered by name (#33630 by @ShadowJonathan)
|
||||
- Change design of rich text elements in web UI (#32633 by @Gargron)
|
||||
- Change wording of “single choice” to “pick one” in poll authoring form (#32397 by @ThisIsMissEm)
|
||||
- Change returned favorite and boost counts to use those provided by the remote server, if available (#32620, #34594, #34618, and #34619 by @ClearlyClaire and @sneakers-the-rat)
|
||||
- Change label of favourite notifications on private mentions (#31659 by @ClearlyClaire)
|
||||
- Change wording of "discard draft?" confirmation dialogs (#35192 by @diondiondion)
|
||||
- Change `libvips` to be enabled by default in place of ImageMagick (#34741 and #34753 by @ClearlyClaire and @diondiondion)
|
||||
- Change avatar and header size limits from 2MB to 8MB when using libvips (#33002 by @Gargron)
|
||||
- Change search to use query params in web UI (#32949 and #33670 by @ClearlyClaire and @Gargron)
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, and #34732 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)\
|
||||
One known limitation is that themes’ main style file needs to have a very specific file name: `app/javascript/styles/:name.scss` where `:name` is the name of the theme in `config/themes.yml`
|
||||
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007, #35035 and #35177 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
|
||||
- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm)
|
||||
- Change `/api/v2/instance` to be enabled without authentication when limited federation mode is enabled (#34576 by @ClearlyClaire)
|
||||
- Change `DEFAULT_LOCALE` to not override unauthenticated users’ browser language (#34535 by @ClearlyClaire)\
|
||||
|
@ -198,22 +219,44 @@ All notable changes to this project will be documented in this file.
|
|||
- Fix not being able to scroll dropdown on touch devices in web UI (#34873 by @Gargron)
|
||||
- Fix inconsistent filtering of silenced accounts for other silenced accounts (#34863 by @ClearlyClaire)
|
||||
- Fix update checker listing updates older or equal to current running version (#33906 by @ClearlyClaire)
|
||||
- Fix clicking a status multiple times causing duplicate entries in browser history (#35118 by @ClearlyClaire)
|
||||
- Fix “Alt text” button submitting form in moderation interface (#35147 by @ClearlyClaire)
|
||||
- Fix Firefox sometimes not updating spellcheck language in textarea (#35148 by @ClearlyClaire)
|
||||
- Fix `NoMethodError` in edge case of emoji cache handling (#34749 by @dariusk)
|
||||
- Fix handling of inlined `featured` collections in ActivityPub actor objects (#34789 and #34811 by @ClearlyClaire)
|
||||
- Fix long link names in admin sidebar being truncated (#34727 by @diondiondion)
|
||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
||||
- Fix OIDC account creation failing for long display names (#34639 by @defnull)
|
||||
- Fix use of the deprecated `/api/v1/instance` endpoint in the moderation interface (#34613 by @renchap)
|
||||
- Fix inaccessible “Clear search” button (#35152 and #35281 by @diondiondion)
|
||||
- Fix search operators sometimes getting lost (#35190 by @ClearlyClaire)
|
||||
- Fix directory scroll position reset (#34560 by @przucidlo)
|
||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
||||
- Fix avatar sizing with long account name in some UI elements (#34514 by @gomasy)
|
||||
- Fix empty menu section in status dropdown (#34431 by @ClearlyClaire)
|
||||
- Fix the delete suggestion button not working (#34396 and #34398 by @ClearlyClaire and @renchap)
|
||||
- Fix popover/dialog backgrounds not being blurred on older Webkit browsers (#35220 by @diondiondion)
|
||||
- Fix radio buttons not always being correctly centered (#34389 by @ChaosExAnima)
|
||||
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
|
||||
- Fix bugs with upload progress (#34325 by @ChaosExAnima)
|
||||
- Fix being unable to hide controls in full screen video in web UI (#34308 by @Gargron)
|
||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
||||
- Fix zoomed images being blurry in Safari (#35052 by @diondiondion)
|
||||
- Fix redundant focus stop within status component in Web UI and make focus style more noticeable (#35037, #35051, #35096, #35150 and #35251 by @diondiondion)
|
||||
- Fix digits in media player time readout not having a consistent width (#35038 by @diondiondion)
|
||||
- Fix wrong text color for “Open in advanced web interface” banner in high-contrast theme (#35032 by @diondiondion)
|
||||
- Fix hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
||||
- Fix some animations not respecting the reduced animation preferences (#35018 by @ChaosExAnima)
|
||||
- Fix direction of media gallery arrows in RTL locales (#35014 by @diondiondion)
|
||||
- Fix cramped layout of follower recommendations on small viewports (#34967 and #35023 by @diondiondion)
|
||||
- Fix two composers being shown at the same time in some cases (#35006 by @ChaosExAnima)
|
||||
- Fix handling of remote attachments with multiple media types (#34996 by @ClearlyClaire)
|
||||
- Fix broken colors in some themed SVGs in web UI (#34988 by @Gargron)
|
||||
- Fix wrong dimensions on blurhash previews of news articles in web UI (#34990 by @Gargron)
|
||||
- Fix wrong styles on action bar in media modal in web UI (#34989 by @Gargron)
|
||||
- Fix search column input not updating on param change (#34951 by @PGrayCS)
|
||||
- Fix account note textarea being interactable before the relationship gets fetched (#34932 by @ClearlyClaire)
|
||||
- Fix SASS deprecation notices (#34278 by @ChaosExAnima)
|
||||
- Fix display of failed-to-load image attachments in web UI (#34217 by @Gargron)
|
||||
- Fix duplicate REST API requests on submitting account personal note with ctrl+enter (#34213 by @ClearlyClaire)
|
||||
|
|
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
|||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.17.0
|
||||
ARG VIPS_VERSION=8.17.1
|
||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
|
|
54
Gemfile.lock
54
Gemfile.lock
|
@ -90,7 +90,9 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.15.0)
|
||||
annotaterb (4.17.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
|
@ -114,12 +116,12 @@ GEM
|
|||
base64 (0.3.0)
|
||||
bcp47_spec (0.2.1)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.0)
|
||||
benchmark (0.4.1)
|
||||
better_errors (2.10.1)
|
||||
erubi (>= 1.0.0)
|
||||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.1.9)
|
||||
bigdecimal (3.2.2)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
|
@ -178,7 +180,7 @@ GEM
|
|||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.4.1)
|
||||
debug (1.10.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
debug_inspector (1.2.0)
|
||||
|
@ -222,13 +224,14 @@ GEM
|
|||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (5.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
excon (1.2.5)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.1)
|
||||
faker (3.5.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
|
@ -236,7 +239,7 @@ GEM
|
|||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.1)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.0)
|
||||
net-http (>= 0.5.0)
|
||||
|
@ -284,7 +287,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.62.0)
|
||||
haml_lint (0.64.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
@ -297,8 +300,8 @@ GEM
|
|||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.24.0)
|
||||
redis-client (= 0.24.0)
|
||||
hiredis-client (0.25.1)
|
||||
redis-client (= 0.25.1)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
|
@ -362,7 +365,7 @@ GEM
|
|||
json-ld-preloaded (3.3.1)
|
||||
json-ld (~> 3.3)
|
||||
rdf (~> 3.3)
|
||||
json-schema (5.1.1)
|
||||
json-schema (5.2.1)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (~> 3.1)
|
||||
jsonapi-renderer (0.2.2)
|
||||
|
@ -400,7 +403,7 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.7.3)
|
||||
linzer (0.7.7)
|
||||
cgi (~> 0.4.2)
|
||||
forwardable (~> 1.3, >= 1.3.3)
|
||||
logger (~> 1.7, >= 1.7.0)
|
||||
|
@ -679,7 +682,7 @@ GEM
|
|||
activesupport (= 8.0.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2)
|
||||
rails-dom-testing (2.2.0)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
|
@ -705,11 +708,12 @@ GEM
|
|||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.13.1)
|
||||
rdoc (6.14.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.24.0)
|
||||
redis-client (0.25.1)
|
||||
connection_pool
|
||||
redlock (1.3.2)
|
||||
redis (>= 3.0.0, < 6.0)
|
||||
|
@ -733,17 +737,17 @@ GEM
|
|||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.3)
|
||||
rspec-core (3.13.4)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.4)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-github (3.0.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-mocks (3.13.4)
|
||||
rspec-mocks (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.0)
|
||||
rspec-rails (8.0.1)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
|
@ -756,8 +760,8 @@ GEM
|
|||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.3)
|
||||
rubocop (1.76.1)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.78.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
@ -765,7 +769,7 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.45.0, < 2.0)
|
||||
rubocop-ast (>= 1.45.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.45.1)
|
||||
|
@ -811,7 +815,7 @@ GEM
|
|||
sanitize (7.0.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.16.8)
|
||||
scenic (1.8.0)
|
||||
scenic (1.9.0)
|
||||
activerecord (>= 4.0.0)
|
||||
railties (>= 4.0.0)
|
||||
securerandom (0.4.1)
|
||||
|
@ -851,8 +855,8 @@ GEM
|
|||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.3.0)
|
||||
activerecord (>= 7)
|
||||
strong_migrations (2.4.0)
|
||||
activerecord (>= 7.1)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
|
@ -936,7 +940,7 @@ GEM
|
|||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.2)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
|
64
README.md
64
README.md
|
@ -17,71 +17,71 @@
|
|||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||
</p>
|
||||
|
||||
Mastodon is a **free, open-source social network server** based on ActivityPub where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||
Mastodon is a **free, open-source social network server** based on [ActivityPub](https://www.w3.org/TR/activitypub/) where users can follow friends and discover new ones. On Mastodon, users can publish anything they want: links, pictures, text, and video. All Mastodon servers are interoperable as a federated network (users on one server can seamlessly communicate with users from another one, including non-Mastodon software that implements ActivityPub!)
|
||||
|
||||
## Navigation
|
||||
|
||||
- [Project homepage 🐘](https://joinmastodon.org)
|
||||
- [Support the development via Patreon][patreon]
|
||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||
- [Blog](https://blog.joinmastodon.org)
|
||||
- [Documentation](https://docs.joinmastodon.org)
|
||||
- [Roadmap](https://joinmastodon.org/roadmap)
|
||||
- [Official Docker image](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||
- [Browse Mastodon servers](https://joinmastodon.org/communities)
|
||||
- [Browse Mastodon apps](https://joinmastodon.org/apps)
|
||||
|
||||
[patreon]: https://www.patreon.com/mastodon
|
||||
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
|
||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||
- [Blog 📰](https://blog.joinmastodon.org)
|
||||
- [Documentation 📚](https://docs.joinmastodon.org)
|
||||
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
||||
|
||||
## Features
|
||||
|
||||
<img src="/app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||
<img src="./app/javascript/images/elephant_ui_working.svg?raw=true" align="right" width="30%" />
|
||||
|
||||
**No vendor lock-in: Fully interoperable with any conforming platform** - It doesn't have to be Mastodon; whatever implements ActivityPub is part of the social network! [Learn more](https://blog.joinmastodon.org/2018/06/why-activitypub-is-the-future/)
|
||||
**Part of the Fediverse. Based on open standards, with no vendor lock-in.** - the network goes beyond just Mastodon; anything that implements ActivityPub is part of a broader social network known as [the Fediverse](https://jointhefediverse.net/). You can follow and interact with users on other servers (including those running different software), and they can follow you back.
|
||||
|
||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI via WebSockets. There's a firehose view as well!
|
||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
|
||||
|
||||
**Media attachments like images and short videos** - upload and view images and WebM/MP4 videos attached to the updates. Videos with no audio track are treated like GIFs; normal videos loop continuously!
|
||||
**Media attachments** - upload and view images and videos attached to the updates. Videos with no audio track are treated like animated GIFs; normal videos loop continuously.
|
||||
|
||||
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and all sorts of other features, along with a reporting and moderation system. [Learn more](https://blog.joinmastodon.org/2018/07/cage-the-mastodon/)
|
||||
**Safety and moderation tools** - Mastodon includes private posts, locked accounts, phrase filtering, muting, blocking, and many other features, along with a reporting and moderation system.
|
||||
|
||||
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, so 3rd party apps can use the REST and Streaming APIs. This results in a rich app ecosystem with a lot of choices!
|
||||
**OAuth2 and a straightforward REST API** - Mastodon acts as an OAuth2 provider, and third party apps can use the REST and Streaming APIs. This results in a [rich app ecosystem](https://joinmastodon.org/apps) with a variety of choices!
|
||||
|
||||
## Deployment
|
||||
|
||||
### Tech stack
|
||||
|
||||
- **Ruby on Rails** powers the REST API and other web pages
|
||||
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
||||
- **Node.js** powers the streaming API
|
||||
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
||||
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
||||
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
||||
- [Node.js](https://nodejs.org/) powers the streaming API.
|
||||
- [React.js](https://reactjs.org/) and [Redux](https://redux.js.org/) are used for the dynamic parts of the interface.
|
||||
- [BrowserStack](https://www.browserstack.com/) supports testing on real devices and browsers. (This project is tested with BrowserStack)
|
||||
- [Chromatic](https://www.chromatic.com/) provides visual regression testing. (This project is tested with Chromatic)
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Ruby** 3.2+
|
||||
- **PostgreSQL** 13+
|
||||
- **Redis** 6.2+
|
||||
- **Ruby** 3.2+
|
||||
- **Node.js** 20+
|
||||
|
||||
The repository includes deployment configurations for **Docker and docker-compose** as well as specific platforms like **Heroku**, and **Scalingo**. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). The [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the documentation.
|
||||
This repository includes deployment configurations for **Docker and docker-compose**, as well as for other environments like Heroku and Scalingo. For Helm charts, reference the [mastodon/chart repository](https://github.com/mastodon/chart). A [**standalone** installation guide](https://docs.joinmastodon.org/admin/install/) is available in the main documentation.
|
||||
|
||||
## Contributing
|
||||
|
||||
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
||||
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
|
||||
|
||||
You can open issues for bugs you've found or features you think are missing. You
|
||||
can also submit pull requests to this repository or translations via Crowdin. To
|
||||
get started, look at the [CONTRIBUTING] and [DEVELOPMENT] guides. For changes
|
||||
accepted into Mastodon, you can request to be paid through our [OpenCollective].
|
||||
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
|
||||
|
||||
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
||||
You should also read and understand the [CODE OF CONDUCT](https://github.com/mastodon/.github/blob/main/CODE_OF_CONDUCT.md) that enables us to maintain a welcoming and inclusive community. Collaboration begins with mutual respect and understanding.
|
||||
|
||||
## License
|
||||
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
|
||||
|
||||
If you would like to help with translations 🌐 you can do so on [Crowdin](https://crowdin.com/project/mastodon).
|
||||
|
||||
## LICENSE
|
||||
|
||||
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||
|
||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||
|
||||
```
|
||||
```text
|
||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under
|
||||
|
@ -97,7 +97,3 @@ details.
|
|||
You should have received a copy of the GNU Affero General Public License along
|
||||
with this program. If not, see https://www.gnu.org/licenses/
|
||||
```
|
||||
|
||||
[CONTRIBUTING]: CONTRIBUTING.md
|
||||
[DEVELOPMENT]: docs/DEVELOPMENT.md
|
||||
[OpenCollective]: https://opencollective.com/mastodon
|
||||
|
|
11
SECURITY.md
11
SECURITY.md
|
@ -13,8 +13,9 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Yes |
|
||||
| < 4.2 | No |
|
||||
| Version | Supported |
|
||||
| ------- | ---------------- |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Yes |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
|
|
|
@ -14,16 +14,20 @@ module Admin
|
|||
def create
|
||||
authorize @account, :show?
|
||||
|
||||
account_action = Admin::AccountAction.new(resource_params)
|
||||
account_action.target_account = @account
|
||||
account_action.current_account = current_account
|
||||
@account_action = Admin::AccountAction.new(resource_params)
|
||||
@account_action.target_account = @account
|
||||
@account_action.current_account = current_account
|
||||
|
||||
account_action.save!
|
||||
|
||||
if account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
if @account_action.save
|
||||
if @account_action.with_report?
|
||||
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
end
|
||||
else
|
||||
redirect_to admin_account_path(@account.id)
|
||||
@warning_presets = AccountWarningPreset.all
|
||||
|
||||
render :new
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::Instances::ModerationNotesController < Admin::BaseController
|
||||
before_action :set_instance, only: [:create]
|
||||
before_action :set_instance_note, only: [:destroy]
|
||||
|
||||
def create
|
||||
authorize :instance_moderation_note, :create?
|
||||
|
||||
@instance_moderation_note = current_account.instance_moderation_notes.new(content: resource_params[:content], domain: @instance.domain)
|
||||
|
||||
if @instance_moderation_note.save
|
||||
redirect_to admin_instance_path(@instance.domain, anchor: helpers.dom_id(@instance_moderation_note)), notice: I18n.t('admin.instances.moderation_notes.created_msg')
|
||||
else
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
|
||||
|
||||
render 'admin/instances/show'
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @instance_moderation_note, :destroy?
|
||||
@instance_moderation_note.destroy!
|
||||
redirect_to admin_instance_path(@instance_moderation_note.domain, anchor: 'instance-notes'), notice: I18n.t('admin.instances.moderation_notes.destroyed_msg')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resource_params
|
||||
params
|
||||
.expect(instance_moderation_note: [:content])
|
||||
end
|
||||
|
||||
def set_instance
|
||||
domain = params[:instance_id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instance_note
|
||||
@instance_moderation_note = InstanceModerationNote.find(params[:id])
|
||||
end
|
||||
end
|
|
@ -14,6 +14,9 @@ module Admin
|
|||
|
||||
def show
|
||||
authorize :instance, :show?
|
||||
|
||||
@instance_moderation_note = @instance.moderation_notes.new
|
||||
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
|
||||
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
|
||||
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
|
||||
end
|
||||
|
@ -52,7 +55,8 @@ module Admin
|
|||
private
|
||||
|
||||
def set_instance
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
|
||||
domain = params[:id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
end
|
||||
|
||||
def set_instances
|
||||
|
|
|
@ -13,27 +13,9 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
|
||||
case action_from_button
|
||||
when 'delete', 'mark_as_sensitive'
|
||||
status_batch_action = Admin::StatusBatchAction.new(
|
||||
type: action_from_button,
|
||||
status_ids: @report.status_ids,
|
||||
current_account: current_account,
|
||||
report_id: @report.id,
|
||||
send_email_notification: !@report.spam?,
|
||||
text: params[:text]
|
||||
)
|
||||
|
||||
status_batch_action.save!
|
||||
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
||||
when 'silence', 'suspend'
|
||||
account_action = Admin::AccountAction.new(
|
||||
type: action_from_button,
|
||||
report_id: @report.id,
|
||||
target_account: @report.target_account,
|
||||
current_account: current_account,
|
||||
send_email_notification: !@report.spam?,
|
||||
text: params[:text]
|
||||
)
|
||||
|
||||
account_action.save!
|
||||
Admin::AccountAction.new(account_action_params).save!
|
||||
else
|
||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||
end
|
||||
|
@ -43,6 +25,26 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
|
||||
private
|
||||
|
||||
def status_batch_action_params
|
||||
shared_params
|
||||
.merge(status_ids: @report.status_ids)
|
||||
end
|
||||
|
||||
def account_action_params
|
||||
shared_params
|
||||
.merge(target_account: @report.target_account)
|
||||
end
|
||||
|
||||
def shared_params
|
||||
{
|
||||
current_account: current_account,
|
||||
report_id: @report.id,
|
||||
send_email_notification: !@report.spam?,
|
||||
text: params[:text],
|
||||
type: action_from_button,
|
||||
}
|
||||
end
|
||||
|
||||
def set_report
|
||||
@report = Report.find(params[:report_id])
|
||||
end
|
||||
|
|
|
@ -17,6 +17,9 @@ module Admin
|
|||
|
||||
def edit
|
||||
authorize @rule, :update?
|
||||
|
||||
missing_languages = RuleTranslation.languages - @rule.translations.pluck(:language)
|
||||
missing_languages.each { |lang| @rule.translations.build(language: lang) }
|
||||
end
|
||||
|
||||
def create
|
||||
|
|
|
@ -4,7 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController
|
|||
def index
|
||||
authorize :tag, :review?
|
||||
|
||||
@pending_tags_count = Tag.pending_review.async_count
|
||||
@pending_tags_count = pending_tags.async_count
|
||||
@tags = filtered_tags.page(params[:page])
|
||||
@form = Trends::TagBatch.new
|
||||
end
|
||||
|
@ -22,6 +22,10 @@ class Admin::Trends::TagsController < Admin::BaseController
|
|||
|
||||
private
|
||||
|
||||
def pending_tags
|
||||
Trends::TagFilter.new(status: :pending_review).results
|
||||
end
|
||||
|
||||
def filtered_tags
|
||||
Trends::TagFilter.new(filter_params).results
|
||||
end
|
||||
|
|
|
@ -32,7 +32,7 @@ class Api::V1::FiltersController < Api::BaseController
|
|||
ApplicationRecord.transaction do
|
||||
@filter.update!(keyword_params)
|
||||
@filter.custom_filter.assign_attributes(filter_params)
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
|
||||
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.many?
|
||||
|
||||
@filter.custom_filter.save!
|
||||
end
|
||||
|
|
|
@ -15,8 +15,9 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
|
|||
if params[:date].present?
|
||||
TermsOfService.published.find_by!(effective_date: params[:date])
|
||||
else
|
||||
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet
|
||||
TermsOfService.current
|
||||
end
|
||||
end
|
||||
not_found if @terms_of_service.nil?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V2::SearchController < Api::BaseController
|
||||
include AsyncRefreshesConcern
|
||||
include Authorization
|
||||
|
||||
RESULTS_LIMIT = 20
|
||||
|
@ -13,6 +14,7 @@ class Api::V2::SearchController < Api::BaseController
|
|||
before_action :remote_resolve_error, if: :remote_resolve_requested?
|
||||
end
|
||||
before_action :require_valid_pagination_options!
|
||||
before_action :handle_fasp_requests
|
||||
|
||||
def index
|
||||
@search = Search.new(search_results)
|
||||
|
@ -37,6 +39,21 @@ class Api::V2::SearchController < Api::BaseController
|
|||
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401
|
||||
end
|
||||
|
||||
def handle_fasp_requests
|
||||
return unless Mastodon::Feature.fasp_enabled?
|
||||
return if params[:q].blank?
|
||||
|
||||
# Do not schedule a new retrieval if the request is a follow-up
|
||||
# to an earlier retrieval
|
||||
return if request.headers['Mastodon-Async-Refresh-Id'].present?
|
||||
|
||||
refresh_key = "fasp:account_search:#{Digest::MD5.base64digest(params[:q])}"
|
||||
return if AsyncRefresh.new(refresh_key).running?
|
||||
|
||||
add_async_refresh_header(AsyncRefresh.create(refresh_key))
|
||||
@query_fasp = true
|
||||
end
|
||||
|
||||
def remote_resolve_requested?
|
||||
truthy_param?(:resolve)
|
||||
end
|
||||
|
@ -58,7 +75,8 @@ class Api::V2::SearchController < Api::BaseController
|
|||
search_params.merge(
|
||||
resolve: truthy_param?(:resolve),
|
||||
exclude_unreviewed: truthy_param?(:exclude_unreviewed),
|
||||
following: truthy_param?(:following)
|
||||
following: truthy_param?(:following),
|
||||
query_fasp: @query_fasp
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -98,7 +98,7 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def after_sign_out_path_for(_resource_or_scope)
|
||||
if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true'
|
||||
if ENV['OMNIAUTH_ONLY'] == 'true' && Rails.configuration.x.omniauth.oidc_enabled?
|
||||
'/auth/auth/openid_connect/logout'
|
||||
else
|
||||
new_user_session_path
|
||||
|
|
|
@ -38,8 +38,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
private
|
||||
|
||||
def record_login_activity
|
||||
LoginActivity.create(
|
||||
user: @user,
|
||||
@user.login_activities.create(
|
||||
success: true,
|
||||
authentication_method: :omniauth,
|
||||
provider: @provider,
|
||||
|
|
|
@ -151,12 +151,11 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
sign_in(user)
|
||||
flash.delete(:notice)
|
||||
|
||||
LoginActivity.create(
|
||||
user: user,
|
||||
success: true,
|
||||
authentication_method: security_measure,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
user.login_activities.create(
|
||||
request_details.merge(
|
||||
authentication_method: security_measure,
|
||||
success: true
|
||||
)
|
||||
)
|
||||
|
||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||
|
@ -167,13 +166,12 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
end
|
||||
|
||||
def on_authentication_failure(user, security_measure, failure_reason)
|
||||
LoginActivity.create(
|
||||
user: user,
|
||||
success: false,
|
||||
authentication_method: security_measure,
|
||||
failure_reason: failure_reason,
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
user.login_activities.create(
|
||||
request_details.merge(
|
||||
authentication_method: security_measure,
|
||||
failure_reason: failure_reason,
|
||||
success: false
|
||||
)
|
||||
)
|
||||
|
||||
# Only send a notification email every hour at most
|
||||
|
@ -182,6 +180,13 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||
end
|
||||
|
||||
def request_details
|
||||
{
|
||||
ip: request.remote_ip,
|
||||
user_agent: request.user_agent,
|
||||
}
|
||||
end
|
||||
|
||||
def second_factor_attempts_key(user)
|
||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||
end
|
||||
|
|
|
@ -64,6 +64,9 @@ module SignatureVerification
|
|||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||
|
||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
||||
rescue Mastodon::MalformedHeaderError => e
|
||||
@signature_verification_failure_code = 400
|
||||
fail_with! e.message
|
||||
rescue Mastodon::SignatureVerificationError => e
|
||||
fail_with! e.message
|
||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
|
@ -82,7 +85,7 @@ module SignatureVerification
|
|||
end
|
||||
|
||||
def actor_from_key_id
|
||||
key_id = signature_key_id
|
||||
key_id = signed_request.key_id
|
||||
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||
|
||||
if domain_not_allowed?(domain)
|
||||
|
|
|
@ -50,6 +50,13 @@ module WebAppControllerConcern
|
|||
return unless current_user&.require_tos_interstitial?
|
||||
|
||||
@terms_of_service = TermsOfService.published.first
|
||||
|
||||
# Handle case where terms of service have been removed from the database
|
||||
if @terms_of_service.nil?
|
||||
current_user.update(require_tos_interstitial: false)
|
||||
return
|
||||
end
|
||||
|
||||
render 'terms_of_service_interstitial/show', layout: 'auth'
|
||||
end
|
||||
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||
skip_before_action :authenticate_resource_owner!
|
||||
|
||||
before_action :store_current_location
|
||||
before_action :authenticate_resource_owner!
|
||||
|
||||
layout 'modal'
|
||||
|
||||
content_security_policy do |p|
|
||||
p.form_action(false)
|
||||
end
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||
class OAuth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||
skip_before_action :authenticate_resource_owner!
|
||||
|
||||
before_action :store_current_location
|
||||
|
@ -11,6 +11,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
|
|||
|
||||
skip_before_action :require_functional!
|
||||
|
||||
layout 'admin'
|
||||
|
||||
include Localized
|
||||
|
||||
def destroy
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::TokensController < Doorkeeper::TokensController
|
||||
class OAuth::TokensController < Doorkeeper::TokensController
|
||||
def revoke
|
||||
unsubscribe_for_token if token.present? && authorized? && token.accessible?
|
||||
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Oauth::UserinfoController < Api::BaseController
|
||||
class OAuth::UserinfoController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :profile }, only: [:show]
|
||||
before_action :require_user!
|
||||
|
||||
def show
|
||||
@account = current_account
|
||||
render json: @account, serializer: OauthUserinfoSerializer
|
||||
render json: @account, serializer: OAuthUserinfoSerializer
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
|||
skip_before_action :require_functional!
|
||||
|
||||
def index
|
||||
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
|
||||
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module WellKnown
|
||||
class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
class OAuthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
include CacheConcern
|
||||
|
||||
# Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user`
|
||||
|
@ -13,8 +13,8 @@ module WellKnown
|
|||
# new OAuth scopes are added), we don't use expires_in to cache upstream,
|
||||
# instead just caching in the rails cache:
|
||||
render_with_cache(
|
||||
json: ::OauthMetadataPresenter.new,
|
||||
serializer: ::OauthMetadataSerializer,
|
||||
json: ::OAuthMetadataPresenter.new,
|
||||
serializer: ::OAuthMetadataSerializer,
|
||||
content_type: 'application/json',
|
||||
expires_in: 15.minutes
|
||||
)
|
||||
|
|
|
@ -66,7 +66,7 @@ module ApplicationHelper
|
|||
|
||||
def provider_sign_in_link(provider)
|
||||
label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize)
|
||||
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
||||
link_to label, omniauth_authorize_path(:user, provider), class: "btn button-#{provider}", method: :post
|
||||
end
|
||||
|
||||
def locale_direction
|
||||
|
|
|
@ -26,6 +26,12 @@ module ContextHelper
|
|||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
||||
quotes: {
|
||||
'quote' => 'https://w3id.org/fep/044f#quote',
|
||||
'quoteUri' => 'http://fedibird.com/ns#quoteUri',
|
||||
'_misskey_quote' => 'https://misskey-hub.net/ns#_misskey_quote',
|
||||
'quoteAuthorization' => { '@id' => 'https://w3id.org/fep/044f#quoteAuthorization', '@type' => '@id' },
|
||||
},
|
||||
interaction_policies: {
|
||||
'gts' => 'https://gotosocial.org/ns#',
|
||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||
|
|
|
@ -1,12 +1,30 @@
|
|||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
apiGetTag,
|
||||
apiFollowTag,
|
||||
apiUnfollowTag,
|
||||
apiFeatureTag,
|
||||
apiUnfeatureTag,
|
||||
apiGetFollowedTags,
|
||||
} from 'mastodon/api/tags';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
export const fetchFollowedHashtags = createDataLoadingThunk(
|
||||
'tags/fetch-followed',
|
||||
async ({ next }: { next?: string } = {}) => {
|
||||
const response = await apiGetFollowedTags(next);
|
||||
return {
|
||||
...response,
|
||||
replace: !next,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const markFollowedHashtagsStale = createAction(
|
||||
'tags/mark-followed-stale',
|
||||
);
|
||||
|
||||
export const fetchHashtag = createDataLoadingThunk(
|
||||
'tags/fetch',
|
||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||
|
@ -15,6 +33,9 @@ export const fetchHashtag = createDataLoadingThunk(
|
|||
export const followHashtag = createDataLoadingThunk(
|
||||
'tags/follow',
|
||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||
(_, { dispatch }) => {
|
||||
void dispatch(markFollowedHashtagsStale());
|
||||
},
|
||||
);
|
||||
|
||||
export const unfollowHashtag = createDataLoadingThunk(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { render, fireEvent, screen } from 'mastodon/test_helpers';
|
||||
import { render, fireEvent, screen } from '@/testing/rendering';
|
||||
|
||||
import { Button } from '../button';
|
||||
|
||||
|
|
120
app/javascript/mastodon/components/account/account.stories.tsx
Normal file
120
app/javascript/mastodon/components/account/account.stories.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { accountFactoryState, relationshipsFactory } from '@/testing/factories';
|
||||
|
||||
import { Account } from './index';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Account',
|
||||
component: Account,
|
||||
argTypes: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'ID of the account to display',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: 'Size of the avatar in pixels',
|
||||
},
|
||||
hidden: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the account is hidden or not',
|
||||
},
|
||||
minimal: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display a minimal version of the account',
|
||||
},
|
||||
defaultAction: {
|
||||
type: 'string',
|
||||
control: 'select',
|
||||
options: ['block', 'mute'],
|
||||
description: 'Default action to take on the account',
|
||||
},
|
||||
withBio: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the account bio or not',
|
||||
},
|
||||
withMenu: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the account menu or not',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
id: '1',
|
||||
size: 46,
|
||||
hidden: false,
|
||||
minimal: false,
|
||||
defaultAction: 'mute',
|
||||
withBio: false,
|
||||
withMenu: true,
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
accounts: {
|
||||
'1': accountFactoryState(),
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Account>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
id: '1',
|
||||
},
|
||||
};
|
||||
|
||||
export const Hidden: Story = {
|
||||
args: {
|
||||
hidden: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
minimal: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBio: Story = {
|
||||
args: {
|
||||
withBio: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoMenu: Story = {
|
||||
args: {
|
||||
withMenu: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Blocked: Story = {
|
||||
args: {
|
||||
defaultAction: 'block',
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
relationships: {
|
||||
'1': relationshipsFactory({
|
||||
blocking: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Muted: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
state: {
|
||||
relationships: {
|
||||
'1': relationshipsFactory({
|
||||
muting: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,12 +1,30 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||
|
||||
export const AccountBio: React.FC<{
|
||||
interface AccountBioProps {
|
||||
note: string;
|
||||
className: string;
|
||||
}> = ({ note, className }) => {
|
||||
const handleClick = useLinks();
|
||||
dropdownAccountId?: string;
|
||||
}
|
||||
|
||||
if (note.length === 0 || note === '<p></p>') {
|
||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
||||
note,
|
||||
className,
|
||||
dropdownAccountId,
|
||||
}) => {
|
||||
const handleClick = useLinks(!!dropdownAccountId);
|
||||
const handleNodeChange = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (!dropdownAccountId || !node || node.childNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
addDropdownToHashtags(node, dropdownAccountId);
|
||||
},
|
||||
[dropdownAccountId],
|
||||
);
|
||||
|
||||
if (note.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -15,6 +33,28 @@ export const AccountBio: React.FC<{
|
|||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
ref={handleNodeChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function addDropdownToHashtags(node: HTMLElement | null, accountId: string) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
for (const childNode of node.childNodes) {
|
||||
if (!(childNode instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
childNode instanceof HTMLAnchorElement &&
|
||||
(childNode.classList.contains('hashtag') ||
|
||||
childNode.innerText.startsWith('#')) &&
|
||||
!childNode.dataset.menuHashtag
|
||||
) {
|
||||
childNode.dataset.menuHashtag = accountId;
|
||||
} else if (childNode.childNodes.length > 0) {
|
||||
addDropdownToHashtags(childNode, accountId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ export const AltTextBadge: React.FC<{
|
|||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
|
|
|
@ -162,6 +162,14 @@ const AutosuggestTextarea = forwardRef(({
|
|||
}
|
||||
}, [suggestions, textareaRef, setSuggestionsHidden]);
|
||||
|
||||
// Hack to force Firefox to change language in autocorrect
|
||||
useEffect(() => {
|
||||
if (lang && textareaRef.current && textareaRef.current === document.activeElement) {
|
||||
textareaRef.current.blur();
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
const renderSuggestion = (suggestion, i) => {
|
||||
let inner, key;
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ const meta = {
|
|||
compact: false,
|
||||
dangerous: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
onClick: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
|
@ -36,19 +37,11 @@ export default meta;
|
|||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
|
||||
await userEvent.click(canvas.getByRole('button'));
|
||||
const button = await canvas.findByRole('button');
|
||||
await userEvent.click(button);
|
||||
await expect(args.onClick).toHaveBeenCalled();
|
||||
};
|
||||
|
||||
const disabledButtonTest: Story['play'] = async ({
|
||||
args,
|
||||
canvas,
|
||||
userEvent,
|
||||
}) => {
|
||||
await userEvent.click(canvas.getByRole('button'));
|
||||
await expect(args.onClick).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
children: 'Primary button',
|
||||
|
@ -80,6 +73,18 @@ export const Dangerous: Story = {
|
|||
play: buttonTest,
|
||||
};
|
||||
|
||||
const disabledButtonTest: Story['play'] = async ({
|
||||
args,
|
||||
canvas,
|
||||
userEvent,
|
||||
}) => {
|
||||
const button = await canvas.findByRole('button');
|
||||
await userEvent.click(button);
|
||||
// Disabled controls can't be focused
|
||||
await expect(button).not.toHaveFocus();
|
||||
await expect(args.onClick).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
export const PrimaryDisabled: Story = {
|
||||
args: {
|
||||
...Primary.args,
|
||||
|
@ -95,3 +100,24 @@ export const SecondaryDisabled: Story = {
|
|||
},
|
||||
play: disabledButtonTest,
|
||||
};
|
||||
|
||||
const loadingButtonTest: Story['play'] = async ({
|
||||
args,
|
||||
canvas,
|
||||
userEvent,
|
||||
}) => {
|
||||
const button = await canvas.findByRole('button', {
|
||||
name: 'Primary button Loading…',
|
||||
});
|
||||
await userEvent.click(button);
|
||||
await expect(button).toHaveFocus();
|
||||
await expect(args.onClick).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
...Primary.args,
|
||||
loading: true,
|
||||
},
|
||||
play: loadingButtonTest,
|
||||
};
|
||||
|
|
|
@ -3,12 +3,15 @@ import { useCallback } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
|
||||
interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
compact?: boolean;
|
||||
dangerous?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
|
@ -34,6 +37,7 @@ export const Button: React.FC<Props> = ({
|
|||
secondary,
|
||||
compact,
|
||||
dangerous,
|
||||
loading,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
|
@ -42,13 +46,18 @@ export const Button: React.FC<Props> = ({
|
|||
}) => {
|
||||
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
|
||||
(e) => {
|
||||
if (!disabled && onClick) {
|
||||
if (disabled || loading) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
} else if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
[disabled, loading, onClick],
|
||||
);
|
||||
|
||||
const label = text ?? children;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('button', className, {
|
||||
|
@ -56,14 +65,27 @@ export const Button: React.FC<Props> = ({
|
|||
'button--compact': compact,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
loading,
|
||||
})}
|
||||
disabled={disabled}
|
||||
// Disabled buttons can't have focus, so we don't really
|
||||
// disable the button during loading
|
||||
disabled={disabled && !loading}
|
||||
aria-disabled={loading}
|
||||
// If the loading prop is used, announce label changes
|
||||
aria-live={loading !== undefined ? 'polite' : undefined}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{text ?? children}
|
||||
{loading ? (
|
||||
<>
|
||||
<span className='button__label-wrapper'>{label}</span>
|
||||
<LoadingIndicator role='none' />
|
||||
</>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,7 +18,7 @@ import { useIdentity } from 'mastodon/identity_context';
|
|||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
moveLeft: {
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
useCallback,
|
||||
cloneElement,
|
||||
Children,
|
||||
useId,
|
||||
} from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
@ -16,6 +17,7 @@ import Overlay from 'react-overlays/Overlay';
|
|||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
Placement,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||
|
@ -295,6 +297,11 @@ interface DropdownProps<Item = MenuItem> {
|
|||
title?: string;
|
||||
disabled?: boolean;
|
||||
scrollable?: boolean;
|
||||
placement?: Placement;
|
||||
/**
|
||||
* Prevent the `ScrollableList` with this scrollKey
|
||||
* from being scrolled while the dropdown is open
|
||||
*/
|
||||
scrollKey?: string;
|
||||
status?: ImmutableMap<string, unknown>;
|
||||
forceDropdown?: boolean;
|
||||
|
@ -316,6 +323,7 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
title = 'Menu',
|
||||
disabled,
|
||||
scrollable,
|
||||
placement = 'bottom',
|
||||
status,
|
||||
forceDropdown = false,
|
||||
renderItem,
|
||||
|
@ -331,16 +339,15 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
);
|
||||
const [currentId] = useState(id++);
|
||||
const open = currentId === openDropdownId;
|
||||
const activeElement = useRef<HTMLElement | null>(null);
|
||||
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuId = useId();
|
||||
const prefetchAccountId = status
|
||||
? status.getIn(['account', 'id'])
|
||||
: undefined;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (activeElement.current) {
|
||||
activeElement.current.focus({ preventScroll: true });
|
||||
activeElement.current = null;
|
||||
if (buttonRef.current) {
|
||||
buttonRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
dispatch(
|
||||
|
@ -375,7 +382,7 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
[handleClose, onItemClick, items],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
const toggleDropdown = useCallback(
|
||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const { type } = e;
|
||||
|
||||
|
@ -423,38 +430,6 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!open && document.activeElement instanceof HTMLElement) {
|
||||
activeElement.current = document.activeElement;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleMouseDown();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleMouseDown],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentId === openDropdownId) {
|
||||
|
@ -465,14 +440,16 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
|
||||
let button: React.ReactElement;
|
||||
|
||||
const buttonProps = {
|
||||
disabled,
|
||||
onClick: toggleDropdown,
|
||||
'aria-expanded': open,
|
||||
'aria-controls': menuId,
|
||||
ref: buttonRef,
|
||||
};
|
||||
|
||||
if (children) {
|
||||
button = cloneElement(Children.only(children), {
|
||||
onClick: handleClick,
|
||||
onMouseDown: handleMouseDown,
|
||||
onKeyDown: handleButtonKeyDown,
|
||||
onKeyPress: handleKeyPress,
|
||||
ref: targetRef,
|
||||
});
|
||||
button = cloneElement(Children.only(children), buttonProps);
|
||||
} else if (icon && iconComponent) {
|
||||
button = (
|
||||
<IconButton
|
||||
|
@ -480,12 +457,7 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleButtonKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
ref={targetRef}
|
||||
{...buttonProps}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
@ -499,13 +471,13 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
<Overlay
|
||||
show={open}
|
||||
offset={offset}
|
||||
placement='bottom'
|
||||
placement={placement}
|
||||
flip
|
||||
target={targetRef}
|
||||
target={buttonRef}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
<div {...props} id={menuId}>
|
||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||
<div
|
||||
className={`dropdown-menu__arrow ${placement}`}
|
||||
|
|
|
@ -13,14 +13,13 @@ interface Props extends React.SVGProps<SVGSVGElement> {
|
|||
children?: never;
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<Props> = ({
|
||||
id,
|
||||
icon: IconComponent,
|
||||
className,
|
||||
title: titleProp,
|
||||
'aria-label': ariaLabel,
|
||||
...other
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
|
@ -34,18 +33,19 @@ export const Icon: React.FC<Props> = ({
|
|||
IconComponent = CheckBoxOutlineBlankIcon;
|
||||
}
|
||||
|
||||
const ariaHidden = titleProp ? undefined : true;
|
||||
const ariaHidden = ariaLabel ? undefined : true;
|
||||
const role = !ariaHidden ? 'img' : undefined;
|
||||
|
||||
// Set the title to an empty string to remove the built-in SVG one if any
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const title = titleProp || '';
|
||||
const title = ariaLabel || '';
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
className={classNames('icon', `icon-${id}`, className)}
|
||||
title={title}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-label={ariaLabel}
|
||||
role={role}
|
||||
{...other}
|
||||
/>
|
||||
|
|
|
@ -14,7 +14,6 @@ interface Props {
|
|||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
active?: boolean;
|
||||
expanded?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
|
@ -45,7 +44,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
|||
activeStyle,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onKeyPress,
|
||||
onMouseDown,
|
||||
active = false,
|
||||
disabled = false,
|
||||
|
@ -85,16 +83,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
|||
[disabled, onClick],
|
||||
);
|
||||
|
||||
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
if (!disabled) {
|
||||
onKeyPress?.(e);
|
||||
}
|
||||
},
|
||||
[disabled, onKeyPress],
|
||||
);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
|
@ -161,7 +149,6 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
|||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
style={buttonStyle}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -6,15 +6,34 @@ const messages = defineMessages({
|
|||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
||||
});
|
||||
|
||||
export const LoadingIndicator: React.FC = () => {
|
||||
interface LoadingIndicatorProps {
|
||||
/**
|
||||
* Use role='none' to opt out of the current default role 'progressbar'
|
||||
* and aria attributes which we should re-visit to check if they're appropriate.
|
||||
* In Firefox the aria-label is not applied, instead an implied value of `50` is
|
||||
* used as the label.
|
||||
*/
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
role = 'progressbar',
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const a11yProps =
|
||||
role === 'progressbar'
|
||||
? ({
|
||||
role,
|
||||
'aria-busy': true,
|
||||
'aria-live': 'polite',
|
||||
} as const)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='loading-indicator'
|
||||
role='progressbar'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
{...a11yProps}
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<CircularProgress size={50} strokeWidth={6} />
|
||||
|
|
|
@ -318,7 +318,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||
id='check'
|
||||
icon={CheckIcon}
|
||||
className='poll__voted__mark'
|
||||
title={intl.formatMessage(messages.voted)}
|
||||
aria-label={intl.formatMessage(messages.voted)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -301,7 +301,11 @@ class Status extends ImmutablePureComponent {
|
|||
if (newTab) {
|
||||
window.open(path, '_blank', 'noopener');
|
||||
} else {
|
||||
history.push(path);
|
||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -8,6 +8,10 @@ export enum BannerVariant {
|
|||
Filter = 'filter',
|
||||
}
|
||||
|
||||
const stopPropagation: MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
export const StatusBanner: React.FC<{
|
||||
children: React.ReactNode;
|
||||
variant: BannerVariant;
|
||||
|
@ -38,6 +42,7 @@ export const StatusBanner: React.FC<{
|
|||
: 'content-warning content-warning--filter'
|
||||
}
|
||||
onClick={forwardClick}
|
||||
onMouseUp={stopPropagation}
|
||||
>
|
||||
<p id={descriptionId}>{children}</p>
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
|||
<Icon
|
||||
id={visibilityIcon.icon}
|
||||
icon={visibilityIcon.iconComponent}
|
||||
title={visibilityIcon.text}
|
||||
aria-label={visibilityIcon.text}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ import initialState, { title as siteTitle } from 'mastodon/initial_state';
|
|||
import { IntlProvider } from 'mastodon/locales';
|
||||
import { store } from 'mastodon/store';
|
||||
import { isProduction } from 'mastodon/utils/environment';
|
||||
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
|
||||
|
||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||
|
||||
|
@ -58,6 +59,7 @@ export default class Mastodon extends PureComponent {
|
|||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
<Route path='/' component={UI} />
|
||||
</ScrollContext>
|
||||
<BodyScrollLock />
|
||||
</Router>
|
||||
|
||||
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
|
||||
|
|
|
@ -14,7 +14,6 @@ import MediaModal from 'mastodon/features/ui/components/media_modal';
|
|||
import { Video } from 'mastodon/features/video';
|
||||
import { IntlProvider } from 'mastodon/locales';
|
||||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||
|
||||
|
@ -34,9 +33,6 @@ export default class MediaContainer extends PureComponent {
|
|||
};
|
||||
|
||||
handleOpenMedia = (media, index, lang) => {
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media, index, lang });
|
||||
};
|
||||
|
||||
|
@ -45,16 +41,10 @@ export default class MediaContainer extends PureComponent {
|
|||
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
|
||||
const mediaList = fromJS(media);
|
||||
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
|
||||
this.setState({ media: mediaList, lang, options });
|
||||
};
|
||||
|
||||
handleCloseMedia = () => {
|
||||
document.body.classList.remove('with-modals--active');
|
||||
document.documentElement.style.marginRight = '0';
|
||||
|
||||
this.setState({
|
||||
media: null,
|
||||
index: null,
|
||||
|
|
|
@ -6,6 +6,7 @@ import classNames from 'classnames';
|
|||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
@ -768,12 +769,11 @@ export const AccountHeader: React.FC<{
|
|||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
title={intl.formatMessage(messages.account_locked)}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = { __html: account.note_emojified };
|
||||
const displayNameHtml = { __html: account.display_name_html };
|
||||
const fields = account.fields;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
|
@ -897,12 +897,11 @@ export const AccountHeader: React.FC<{
|
|||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
|
||||
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||
<div
|
||||
className='account__header__content translate'
|
||||
dangerouslySetInnerHTML={content}
|
||||
/>
|
||||
)}
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
dropdownAccountId={accountId}
|
||||
className='account__header__content'
|
||||
/>
|
||||
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
|
|
|
@ -12,9 +12,10 @@ import { length } from 'stringz';
|
|||
|
||||
import { missingAltTextModal } from 'mastodon/initial_state';
|
||||
|
||||
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import { Button } from '../../../components/button';
|
||||
import AutosuggestInput from 'mastodon/components/autosuggest_input';
|
||||
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import PollButtonContainer from '../containers/poll_button_container';
|
||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
|
@ -225,9 +226,8 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
|
||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
||||
const { highlighted } = this.state;
|
||||
const disabled = this.props.isSubmitting;
|
||||
|
||||
return (
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
|
@ -246,7 +246,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<AutosuggestInput
|
||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||
value={this.props.spoilerText}
|
||||
disabled={disabled}
|
||||
disabled={isSubmitting}
|
||||
onChange={this.handleChangeSpoilerText}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={this.setSpoilerText}
|
||||
|
@ -268,7 +268,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<AutosuggestTextarea
|
||||
ref={this.textareaRef}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
disabled={disabled}
|
||||
disabled={isSubmitting}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
suggestions={this.props.suggestions}
|
||||
|
@ -305,9 +305,15 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<Button
|
||||
type='submit'
|
||||
compact
|
||||
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
||||
disabled={!this.canSubmit()}
|
||||
/>
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{intl.formatMessage(
|
||||
this.props.isEditing ?
|
||||
messages.saveChanges :
|
||||
(this.props.isInReply ? messages.reply : messages.publish)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -29,6 +29,7 @@ import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
|||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||
clearSearch: { id: 'search.clear', defaultMessage: 'Clear search' },
|
||||
placeholderSignedIn: {
|
||||
id: 'search.search_or_paste',
|
||||
defaultMessage: 'Search or paste URL',
|
||||
|
@ -46,8 +47,32 @@ const labelForRecentSearch = (search: RecentSearch) => {
|
|||
}
|
||||
};
|
||||
|
||||
const unfocus = () => {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
const ClearButton: React.FC<{
|
||||
onClick: () => void;
|
||||
hasValue: boolean;
|
||||
}> = ({ onClick, hasValue }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('search__icon-wrapper', { 'has-value': hasValue })}
|
||||
>
|
||||
<Icon id='search' icon={SearchIcon} className='search__icon' />
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
className='search__icon search__icon--clear-button'
|
||||
tabIndex={hasValue ? undefined : -1}
|
||||
aria-hidden={!hasValue}
|
||||
>
|
||||
<Icon
|
||||
id='times-circle'
|
||||
icon={CancelIcon}
|
||||
aria-label={intl.formatMessage(messages.clearSearch)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SearchOption {
|
||||
|
@ -78,6 +103,11 @@ export const Search: React.FC<{
|
|||
}, [initialValue]);
|
||||
const searchOptions: SearchOption[] = [];
|
||||
|
||||
const unfocus = useCallback(() => {
|
||||
document.querySelector('.ui')?.parentElement?.focus();
|
||||
setExpanded(false);
|
||||
}, []);
|
||||
|
||||
if (searchEnabled) {
|
||||
searchOptions.push(
|
||||
{
|
||||
|
@ -253,7 +283,7 @@ export const Search: React.FC<{
|
|||
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||
unfocus();
|
||||
},
|
||||
[dispatch, history],
|
||||
[dispatch, history, unfocus],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
|
@ -373,14 +403,15 @@ export const Search: React.FC<{
|
|||
|
||||
setQuickActions(newQuickActions);
|
||||
},
|
||||
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
||||
[signedIn, dispatch, unfocus, history, submit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setValue('');
|
||||
setQuickActions([]);
|
||||
setSelectedOption(-1);
|
||||
}, [setValue, setQuickActions, setSelectedOption]);
|
||||
unfocus();
|
||||
}, [unfocus]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
|
@ -431,7 +462,7 @@ export const Search: React.FC<{
|
|||
break;
|
||||
}
|
||||
},
|
||||
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
||||
[unfocus, navigableOptions, selectedOption, submit, value],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
|
@ -451,12 +482,38 @@ export const Search: React.FC<{
|
|||
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setExpanded(false);
|
||||
setSelectedOption(-1);
|
||||
}, [setExpanded, setSelectedOption]);
|
||||
}, [setSelectedOption]);
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// If the search popover is expanded, close it when tabbing or
|
||||
// clicking outside of it or the search form, while allowing
|
||||
// tabbing or clicking inside of the popover
|
||||
if (expanded) {
|
||||
function closeOnLeave(event: FocusEvent | MouseEvent) {
|
||||
const form = formRef.current;
|
||||
const isClickInsideForm =
|
||||
form &&
|
||||
(form === event.target || form.contains(event.target as Node));
|
||||
if (!isClickInsideForm) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('focusin', closeOnLeave);
|
||||
document.addEventListener('click', closeOnLeave);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', closeOnLeave);
|
||||
document.removeEventListener('click', closeOnLeave);
|
||||
};
|
||||
}
|
||||
return () => null;
|
||||
}, [expanded]);
|
||||
|
||||
return (
|
||||
<form className={classNames('search', { active: expanded })}>
|
||||
<form ref={formRef} className={classNames('search', { active: expanded })}>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className='search__input'
|
||||
|
@ -474,21 +531,9 @@ export const Search: React.FC<{
|
|||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button type='button' className='search__icon' onClick={handleClear}>
|
||||
<Icon
|
||||
id='search'
|
||||
icon={SearchIcon}
|
||||
className={hasValue ? '' : 'active'}
|
||||
/>
|
||||
<Icon
|
||||
id='times-circle'
|
||||
icon={CancelIcon}
|
||||
className={hasValue ? 'active' : ''}
|
||||
aria-label={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
</button>
|
||||
<ClearButton hasValue={hasValue} onClick={handleClear} />
|
||||
|
||||
<div className='search__popout'>
|
||||
<div className='search__popout' tabIndex={-1}>
|
||||
{!hasValue && (
|
||||
<>
|
||||
<h4>
|
||||
|
|
|
@ -15,39 +15,34 @@ import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
|||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { mascot } from 'mastodon/initial_state';
|
||||
import { mascot, reduceMotion } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { messages as navbarMessages } from '../ui/components/navigation_bar';
|
||||
|
||||
import { Search } from './components/search';
|
||||
import ComposeFormContainer from './containers/compose_form_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: {
|
||||
id: 'tabs_bar.notifications',
|
||||
defaultMessage: 'Notifications',
|
||||
live_feed_public: {
|
||||
id: 'navigation_bar.live_feed_public',
|
||||
defaultMessage: 'Live feed (public)',
|
||||
},
|
||||
public: {
|
||||
id: 'navigation_bar.public_timeline',
|
||||
defaultMessage: 'Federated timeline',
|
||||
},
|
||||
community: {
|
||||
id: 'navigation_bar.community_timeline',
|
||||
defaultMessage: 'Local timeline',
|
||||
live_feed_local: {
|
||||
id: 'navigation_bar.live_feed_local',
|
||||
defaultMessage: 'Live feed (local)',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||
});
|
||||
|
||||
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
|
||||
|
@ -82,19 +77,27 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
[dispatch],
|
||||
);
|
||||
|
||||
const scrollNavbarIntoView = useCallback(() => {
|
||||
const navbar = document.querySelector('.navigation-panel');
|
||||
navbar?.scrollIntoView({
|
||||
behavior: reduceMotion ? 'auto' : 'smooth',
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (multiColumn) {
|
||||
return (
|
||||
<div
|
||||
className='drawer'
|
||||
role='region'
|
||||
aria-label={intl.formatMessage(messages.compose)}
|
||||
aria-label={intl.formatMessage(navbarMessages.publish)}
|
||||
>
|
||||
<nav className='drawer__header'>
|
||||
<Link
|
||||
to='/getting-started'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.start)}
|
||||
aria-label={intl.formatMessage(messages.start)}
|
||||
title={intl.formatMessage(navbarMessages.menu)}
|
||||
aria-label={intl.formatMessage(navbarMessages.menu)}
|
||||
onClick={scrollNavbarIntoView}
|
||||
>
|
||||
<Icon id='bars' icon={MenuIcon} />
|
||||
</Link>
|
||||
|
@ -102,8 +105,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
<Link
|
||||
to='/home'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.home_timeline)}
|
||||
aria-label={intl.formatMessage(messages.home_timeline)}
|
||||
title={intl.formatMessage(navbarMessages.home)}
|
||||
aria-label={intl.formatMessage(navbarMessages.home)}
|
||||
>
|
||||
<Icon id='home' icon={HomeIcon} />
|
||||
</Link>
|
||||
|
@ -112,8 +115,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
<Link
|
||||
to='/notifications'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.notifications)}
|
||||
aria-label={intl.formatMessage(messages.notifications)}
|
||||
title={intl.formatMessage(navbarMessages.notifications)}
|
||||
aria-label={intl.formatMessage(navbarMessages.notifications)}
|
||||
>
|
||||
<Icon id='bell' icon={NotificationsIcon} />
|
||||
</Link>
|
||||
|
@ -122,8 +125,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
<Link
|
||||
to='/public/local'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.community)}
|
||||
aria-label={intl.formatMessage(messages.community)}
|
||||
title={intl.formatMessage(messages.live_feed_local)}
|
||||
aria-label={intl.formatMessage(messages.live_feed_local)}
|
||||
>
|
||||
<Icon id='users' icon={PeopleIcon} />
|
||||
</Link>
|
||||
|
@ -132,8 +135,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
<Link
|
||||
to='/public'
|
||||
className='drawer__tab'
|
||||
title={intl.formatMessage(messages.public)}
|
||||
aria-label={intl.formatMessage(messages.public)}
|
||||
title={intl.formatMessage(messages.live_feed_public)}
|
||||
aria-label={intl.formatMessage(messages.live_feed_public)}
|
||||
>
|
||||
<Icon id='globe' icon={PublicIcon} />
|
||||
</Link>
|
||||
|
@ -175,12 +178,12 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.compose)}
|
||||
label={intl.formatMessage(navbarMessages.publish)}
|
||||
>
|
||||
<ColumnHeader
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
title={intl.formatMessage(messages.compose)}
|
||||
title={intl.formatMessage(navbarMessages.publish)}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
|
110
app/javascript/mastodon/features/emoji/constants.ts
Normal file
110
app/javascript/mastodon/features/emoji/constants.ts
Normal file
|
@ -0,0 +1,110 @@
|
|||
// Utility codes
|
||||
export const VARIATION_SELECTOR_CODE = 0xfe0f;
|
||||
export const KEYCAP_CODE = 0x20e3;
|
||||
|
||||
// Gender codes
|
||||
export const GENDER_FEMALE_CODE = 0x2640;
|
||||
export const GENDER_MALE_CODE = 0x2642;
|
||||
|
||||
// Skin tone codes
|
||||
export const SKIN_TONE_CODES = [
|
||||
0x1f3fb, // Light skin tone
|
||||
0x1f3fc, // Medium-light skin tone
|
||||
0x1f3fd, // Medium skin tone
|
||||
0x1f3fe, // Medium-dark skin tone
|
||||
0x1f3ff, // Dark skin tone
|
||||
] as const;
|
||||
|
||||
export const EMOJIS_WITH_DARK_BORDER = [
|
||||
'🎱', // 1F3B1
|
||||
'🐜', // 1F41C
|
||||
'⚫', // 26AB
|
||||
'🖤', // 1F5A4
|
||||
'⬛', // 2B1B
|
||||
'◼️', // 25FC-FE0F
|
||||
'◾', // 25FE
|
||||
'◼️', // 25FC-FE0F
|
||||
'✒️', // 2712-FE0F
|
||||
'▪️', // 25AA-FE0F
|
||||
'💣', // 1F4A3
|
||||
'🎳', // 1F3B3
|
||||
'📷', // 1F4F7
|
||||
'📸', // 1F4F8
|
||||
'♣️', // 2663-FE0F
|
||||
'🕶️', // 1F576-FE0F
|
||||
'✴️', // 2734-FE0F
|
||||
'🔌', // 1F50C
|
||||
'💂♀️', // 1F482-200D-2640-FE0F
|
||||
'📽️', // 1F4FD-FE0F
|
||||
'🍳', // 1F373
|
||||
'🦍', // 1F98D
|
||||
'💂', // 1F482
|
||||
'🔪', // 1F52A
|
||||
'🕳️', // 1F573-FE0F
|
||||
'🕹️', // 1F579-FE0F
|
||||
'🕋', // 1F54B
|
||||
'🖊️', // 1F58A-FE0F
|
||||
'🖋️', // 1F58B-FE0F
|
||||
'💂♂️', // 1F482-200D-2642-FE0F
|
||||
'🎤', // 1F3A4
|
||||
'🎓', // 1F393
|
||||
'🎥', // 1F3A5
|
||||
'🎼', // 1F3BC
|
||||
'♠️', // 2660-FE0F
|
||||
'🎩', // 1F3A9
|
||||
'🦃', // 1F983
|
||||
'📼', // 1F4FC
|
||||
'📹', // 1F4F9
|
||||
'🎮', // 1F3AE
|
||||
'🐃', // 1F403
|
||||
'🏴', // 1F3F4
|
||||
'🐞', // 1F41E
|
||||
'🕺', // 1F57A
|
||||
'📱', // 1F4F1
|
||||
'📲', // 1F4F2
|
||||
'🚲', // 1F6B2
|
||||
'🪮', // 1FAA6
|
||||
'🐦⬛', // 1F426-200D-2B1B
|
||||
];
|
||||
|
||||
export const EMOJIS_WITH_LIGHT_BORDER = [
|
||||
'👽', // 1F47D
|
||||
'⚾', // 26BE
|
||||
'🐔', // 1F414
|
||||
'☁️', // 2601-FE0F
|
||||
'💨', // 1F4A8
|
||||
'🕊️', // 1F54A-FE0F
|
||||
'👀', // 1F440
|
||||
'🍥', // 1F365
|
||||
'👻', // 1F47B
|
||||
'🐐', // 1F410
|
||||
'❕', // 2755
|
||||
'❔', // 2754
|
||||
'⛸️', // 26F8-FE0F
|
||||
'🌩️', // 1F329-FE0F
|
||||
'🔊', // 1F50A
|
||||
'🔇', // 1F507
|
||||
'📃', // 1F4C3
|
||||
'🌧️', // 1F327-FE0F
|
||||
'🐏', // 1F40F
|
||||
'🍚', // 1F35A
|
||||
'🍙', // 1F359
|
||||
'🐓', // 1F413
|
||||
'🐑', // 1F411
|
||||
'💀', // 1F480
|
||||
'☠️', // 2620-FE0F
|
||||
'🌨️', // 1F328-FE0F
|
||||
'🔉', // 1F509
|
||||
'🔈', // 1F508
|
||||
'💬', // 1F4AC
|
||||
'💭', // 1F4AD
|
||||
'🏐', // 1F3D0
|
||||
'🏳️', // 1F3F3-FE0F
|
||||
'⚪', // 26AA
|
||||
'⬜', // 2B1C
|
||||
'◽', // 25FD
|
||||
'◻️', // 25FB-FE0F
|
||||
'▫️', // 25AB-FE0F
|
||||
'🪽', // 1FAE8
|
||||
'🪿', // 1FABF
|
||||
];
|
102
app/javascript/mastodon/features/emoji/database.ts
Normal file
102
app/javascript/mastodon/features/emoji/database.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
import type { FlatCompactEmoji, Locale } from 'emojibase';
|
||||
import type { DBSchema } from 'idb';
|
||||
import { openDB } from 'idb';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
|
||||
import type { LocaleOrCustom } from './locale';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
|
||||
interface EmojiDB extends LocaleTables, DBSchema {
|
||||
custom: {
|
||||
key: string;
|
||||
value: ApiCustomEmojiJSON;
|
||||
indexes: {
|
||||
category: string;
|
||||
};
|
||||
};
|
||||
etags: {
|
||||
key: LocaleOrCustom;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface LocaleTable {
|
||||
key: string;
|
||||
value: FlatCompactEmoji;
|
||||
indexes: {
|
||||
group: number;
|
||||
label: string;
|
||||
order: number;
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
type LocaleTables = Record<Locale, LocaleTable>;
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
const db = await openDB<EmojiDB>('mastodon-emoji', SCHEMA_VERSION, {
|
||||
upgrade(database) {
|
||||
const customTable = database.createObjectStore('custom', {
|
||||
keyPath: 'shortcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
customTable.createIndex('category', 'category');
|
||||
|
||||
database.createObjectStore('etags');
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localeTable = database.createObjectStore(locale, {
|
||||
keyPath: 'hexcode',
|
||||
autoIncrement: false,
|
||||
});
|
||||
localeTable.createIndex('group', 'group');
|
||||
localeTable.createIndex('label', 'label');
|
||||
localeTable.createIndex('order', 'order');
|
||||
localeTable.createIndex('tags', 'tags', { multiEntry: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function putEmojiData(emojis: FlatCompactEmoji[], locale: Locale) {
|
||||
const trx = db.transaction(locale, 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await trx.done;
|
||||
}
|
||||
|
||||
export async function putCustomEmojiData(emojis: ApiCustomEmojiJSON[]) {
|
||||
const trx = db.transaction('custom', 'readwrite');
|
||||
await Promise.all(emojis.map((emoji) => trx.store.put(emoji)));
|
||||
await trx.done;
|
||||
}
|
||||
|
||||
export function putLatestEtag(etag: string, localeString: string) {
|
||||
const locale = toSupportedLocaleOrCustom(localeString);
|
||||
return db.put('etags', etag, locale);
|
||||
}
|
||||
|
||||
export function searchEmojiByHexcode(hexcode: string, localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
return db.get(locale, hexcode);
|
||||
}
|
||||
|
||||
export function searchEmojiByTag(tag: string, localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const range = IDBKeyRange.only(tag.toLowerCase());
|
||||
return db.getAllFromIndex(locale, 'tags', range);
|
||||
}
|
||||
|
||||
export function searchCustomEmojiByShortcode(shortcode: string) {
|
||||
return db.get('custom', shortcode);
|
||||
}
|
||||
|
||||
export async function loadLatestEtag(localeString: string) {
|
||||
const locale = toSupportedLocaleOrCustom(localeString);
|
||||
const rowCount = await db.count(locale);
|
||||
if (!rowCount) {
|
||||
return null; // No data for this locale, return null even if there is an etag.
|
||||
}
|
||||
const etag = await db.get('etags', locale);
|
||||
return etag ?? null;
|
||||
}
|
38
app/javascript/mastodon/features/emoji/index.ts
Normal file
38
app/javascript/mastodon/features/emoji/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import initialState from '@/mastodon/initial_state';
|
||||
|
||||
import { toSupportedLocale } from './locale';
|
||||
|
||||
const serverLocale = toSupportedLocale(initialState?.meta.locale ?? 'en');
|
||||
|
||||
const worker =
|
||||
'Worker' in window
|
||||
? new Worker(new URL('./worker', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
: null;
|
||||
|
||||
export async function initializeEmoji() {
|
||||
if (worker) {
|
||||
worker.addEventListener('message', (event: MessageEvent<string>) => {
|
||||
const { data: message } = event;
|
||||
if (message === 'ready') {
|
||||
worker.postMessage(serverLocale);
|
||||
worker.postMessage('custom');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const { importCustomEmojiData, importEmojiData } = await import('./loader');
|
||||
await Promise.all([importCustomEmojiData(), importEmojiData(serverLocale)]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEmojiLocale(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
|
||||
if (worker) {
|
||||
worker.postMessage(locale);
|
||||
} else {
|
||||
const { importEmojiData } = await import('./loader');
|
||||
await importEmojiData(locale);
|
||||
}
|
||||
}
|
77
app/javascript/mastodon/features/emoji/loader.ts
Normal file
77
app/javascript/mastodon/features/emoji/loader.ts
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { flattenEmojiData } from 'emojibase';
|
||||
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import { isDevelopment } from '@/mastodon/utils/environment';
|
||||
|
||||
import {
|
||||
putEmojiData,
|
||||
putCustomEmojiData,
|
||||
loadLatestEtag,
|
||||
putLatestEtag,
|
||||
} from './database';
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
import type { LocaleOrCustom } from './locale';
|
||||
|
||||
export async function importEmojiData(localeString: string) {
|
||||
const locale = toSupportedLocale(localeString);
|
||||
const emojis = await fetchAndCheckEtag<CompactEmoji[]>(locale);
|
||||
if (!emojis) {
|
||||
return;
|
||||
}
|
||||
const flattenedEmojis: FlatCompactEmoji[] = flattenEmojiData(emojis);
|
||||
await putEmojiData(flattenedEmojis, locale);
|
||||
}
|
||||
|
||||
export async function importCustomEmojiData() {
|
||||
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
|
||||
if (!emojis) {
|
||||
return;
|
||||
}
|
||||
await putCustomEmojiData(emojis);
|
||||
}
|
||||
|
||||
async function fetchAndCheckEtag<ResultType extends object[]>(
|
||||
localeOrCustom: LocaleOrCustom,
|
||||
): Promise<ResultType | null> {
|
||||
const locale = toSupportedLocaleOrCustom(localeOrCustom);
|
||||
|
||||
let uri: string;
|
||||
if (locale === 'custom') {
|
||||
uri = '/api/v1/custom_emojis';
|
||||
} else {
|
||||
uri = `/packs${isDevelopment() ? '-dev' : ''}/emoji/${locale}.json`;
|
||||
}
|
||||
|
||||
const oldEtag = await loadLatestEtag(locale);
|
||||
const response = await fetch(uri, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
|
||||
},
|
||||
});
|
||||
// If not modified, return null
|
||||
if (response.status === 304) {
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch emoji data for ${localeOrCustom}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ResultType;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(
|
||||
`Unexpected data format for ${localeOrCustom}: expected an array`,
|
||||
);
|
||||
}
|
||||
|
||||
// Store the ETag for future requests
|
||||
const etag = response.headers.get('ETag');
|
||||
if (etag) {
|
||||
await putLatestEtag(etag, localeOrCustom);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
29
app/javascript/mastodon/features/emoji/locale.test.ts
Normal file
29
app/javascript/mastodon/features/emoji/locale.test.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
|
||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||
|
||||
describe('toSupportedLocale', () => {
|
||||
test('returns the same locale if it is supported', () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
expect(toSupportedLocale(locale)).toBe(locale);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns "en" for unsupported locales', () => {
|
||||
const unsupportedLocales = ['xx', 'fr-CA'];
|
||||
for (const locale of unsupportedLocales) {
|
||||
expect(toSupportedLocale(locale)).toBe('en');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSupportedLocaleOrCustom', () => {
|
||||
test('returns custom for "custom" locale', () => {
|
||||
expect(toSupportedLocaleOrCustom('custom')).toBe('custom');
|
||||
});
|
||||
test('returns supported locale for valid locales', () => {
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
expect(toSupportedLocaleOrCustom(locale)).toBe(locale);
|
||||
}
|
||||
});
|
||||
});
|
23
app/javascript/mastodon/features/emoji/locale.ts
Normal file
23
app/javascript/mastodon/features/emoji/locale.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import type { Locale } from 'emojibase';
|
||||
import { SUPPORTED_LOCALES } from 'emojibase';
|
||||
|
||||
export type LocaleOrCustom = Locale | 'custom';
|
||||
|
||||
export function toSupportedLocale(localeBase: string): Locale {
|
||||
const locale = localeBase.toLowerCase();
|
||||
if (isSupportedLocale(locale)) {
|
||||
return locale;
|
||||
}
|
||||
return 'en'; // Default to English if unsupported
|
||||
}
|
||||
|
||||
export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
|
||||
if (locale.toLowerCase() === 'custom') {
|
||||
return 'custom';
|
||||
}
|
||||
return toSupportedLocale(locale);
|
||||
}
|
||||
|
||||
function isSupportedLocale(locale: string): locale is Locale {
|
||||
return SUPPORTED_LOCALES.includes(locale.toLowerCase() as Locale);
|
||||
}
|
101
app/javascript/mastodon/features/emoji/normalize.test.ts
Normal file
101
app/javascript/mastodon/features/emoji/normalize.test.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { readdir } from 'fs/promises';
|
||||
import { basename, resolve } from 'path';
|
||||
|
||||
import { flattenEmojiData } from 'emojibase';
|
||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||
|
||||
import {
|
||||
twemojiHasBorder,
|
||||
twemojiToUnicodeInfo,
|
||||
unicodeToTwemojiHex,
|
||||
CODES_WITH_DARK_BORDER,
|
||||
CODES_WITH_LIGHT_BORDER,
|
||||
emojiToUnicodeHex,
|
||||
} from './normalize';
|
||||
|
||||
const emojiSVGFiles = await readdir(
|
||||
// This assumes tests are run from project root
|
||||
resolve(process.cwd(), 'public/emoji'),
|
||||
{
|
||||
withFileTypes: true,
|
||||
},
|
||||
);
|
||||
const svgFileNames = emojiSVGFiles
|
||||
.filter((file) => file.isFile() && file.name.endsWith('.svg'))
|
||||
.map((file) => basename(file.name, '.svg').toUpperCase());
|
||||
const svgFileNamesWithoutBorder = svgFileNames.filter(
|
||||
(fileName) => !fileName.endsWith('_BORDER'),
|
||||
);
|
||||
|
||||
const unicodeEmojis = flattenEmojiData(unicodeRawEmojis);
|
||||
|
||||
describe('emojiToUnicodeHex', () => {
|
||||
test.concurrent.for([
|
||||
['🎱', '1F3B1'],
|
||||
['🐜', '1F41C'],
|
||||
['⚫', '26AB'],
|
||||
['🖤', '1F5A4'],
|
||||
['💀', '1F480'],
|
||||
['💂♂️', '1F482-200D-2642-FE0F'],
|
||||
] as const)(
|
||||
'emojiToUnicodeHex converts %s to %s',
|
||||
([emoji, hexcode], { expect }) => {
|
||||
expect(emojiToUnicodeHex(emoji)).toBe(hexcode);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('unicodeToTwemojiHex', () => {
|
||||
test.concurrent.for(
|
||||
unicodeEmojis
|
||||
// Our version of Twemoji only supports up to version 15.1
|
||||
.filter((emoji) => emoji.version < 16)
|
||||
.map((emoji) => [emoji.hexcode, emoji.label] as [string, string]),
|
||||
)('verifying an emoji exists for %s (%s)', ([hexcode], { expect }) => {
|
||||
const result = unicodeToTwemojiHex(hexcode);
|
||||
expect(svgFileNamesWithoutBorder).toContain(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('twemojiHasBorder', () => {
|
||||
test.concurrent.for(
|
||||
svgFileNames
|
||||
.filter((file) => file.endsWith('_BORDER'))
|
||||
.map((file) => {
|
||||
const hexCode = file.replace('_BORDER', '');
|
||||
return [
|
||||
hexCode,
|
||||
CODES_WITH_LIGHT_BORDER.includes(hexCode),
|
||||
CODES_WITH_DARK_BORDER.includes(hexCode),
|
||||
] as const;
|
||||
}),
|
||||
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
|
||||
const result = twemojiHasBorder(hexCode);
|
||||
expect(result).toHaveProperty('hexCode', hexCode);
|
||||
expect(result).toHaveProperty('hasLightBorder', isLight);
|
||||
expect(result).toHaveProperty('hasDarkBorder', isDark);
|
||||
});
|
||||
});
|
||||
|
||||
describe('twemojiToUnicodeInfo', () => {
|
||||
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
|
||||
|
||||
test.concurrent.for(svgFileNamesWithoutBorder)(
|
||||
'verifying SVG file %s maps to Unicode emoji',
|
||||
(svgFileName, { expect }) => {
|
||||
assert(!!svgFileName);
|
||||
const result = twemojiToUnicodeInfo(svgFileName);
|
||||
const hexcode = typeof result === 'string' ? result : result.unqualified;
|
||||
if (!hexcode) {
|
||||
// No hexcode means this is a special case like the Shibuya 109 emoji
|
||||
expect(result).toHaveProperty('label');
|
||||
return;
|
||||
}
|
||||
assert(!!hexcode);
|
||||
expect(
|
||||
unicodeCodeSet.has(hexcode),
|
||||
`${hexcode} (${svgFileName}) not found`,
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
});
|
173
app/javascript/mastodon/features/emoji/normalize.ts
Normal file
173
app/javascript/mastodon/features/emoji/normalize.ts
Normal file
|
@ -0,0 +1,173 @@
|
|||
import {
|
||||
VARIATION_SELECTOR_CODE,
|
||||
KEYCAP_CODE,
|
||||
GENDER_FEMALE_CODE,
|
||||
GENDER_MALE_CODE,
|
||||
SKIN_TONE_CODES,
|
||||
EMOJIS_WITH_DARK_BORDER,
|
||||
EMOJIS_WITH_LIGHT_BORDER,
|
||||
} from './constants';
|
||||
|
||||
// Misc codes that have special handling
|
||||
const SKIER_CODE = 0x26f7;
|
||||
const CHRISTMAS_TREE_CODE = 0x1f384;
|
||||
const MR_CLAUS_CODE = 0x1f385;
|
||||
const EYE_CODE = 0x1f441;
|
||||
const LEVITATING_PERSON_CODE = 0x1f574;
|
||||
const SPEECH_BUBBLE_CODE = 0x1f5e8;
|
||||
const MS_CLAUS_CODE = 0x1f936;
|
||||
|
||||
export function emojiToUnicodeHex(emoji: string): string {
|
||||
const codes: number[] = [];
|
||||
for (const char of emoji) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code !== undefined) {
|
||||
codes.push(code);
|
||||
}
|
||||
}
|
||||
return hexNumbersToString(codes);
|
||||
}
|
||||
|
||||
export function unicodeToTwemojiHex(unicodeHex: string): string {
|
||||
const codes = hexStringToNumbers(unicodeHex);
|
||||
const normalizedCodes: number[] = [];
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const code = codes[i];
|
||||
if (!code) {
|
||||
continue;
|
||||
}
|
||||
// Some emoji have their variation selector removed
|
||||
if (code === VARIATION_SELECTOR_CODE) {
|
||||
// Key emoji
|
||||
if (i === 1 && codes.at(-1) === KEYCAP_CODE) {
|
||||
continue;
|
||||
}
|
||||
// Eye in speech bubble
|
||||
if (codes.at(0) === EYE_CODE && codes.at(-2) === SPEECH_BUBBLE_CODE) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// This removes zero padding to correctly match the SVG filenames
|
||||
normalizedCodes.push(code);
|
||||
}
|
||||
|
||||
return hexNumbersToString(normalizedCodes, 0);
|
||||
}
|
||||
|
||||
interface TwemojiBorderInfo {
|
||||
hexCode: string;
|
||||
hasLightBorder: boolean;
|
||||
hasDarkBorder: boolean;
|
||||
}
|
||||
|
||||
export const CODES_WITH_DARK_BORDER =
|
||||
EMOJIS_WITH_DARK_BORDER.map(emojiToUnicodeHex);
|
||||
|
||||
export const CODES_WITH_LIGHT_BORDER =
|
||||
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||
|
||||
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
|
||||
const normalizedHex = twemojiHex.toUpperCase();
|
||||
let hasLightBorder = false;
|
||||
let hasDarkBorder = false;
|
||||
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||
hasLightBorder = true;
|
||||
}
|
||||
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
|
||||
hasDarkBorder = true;
|
||||
}
|
||||
return {
|
||||
hexCode: normalizedHex,
|
||||
hasLightBorder,
|
||||
hasDarkBorder,
|
||||
};
|
||||
}
|
||||
|
||||
interface TwemojiSpecificEmoji {
|
||||
unqualified?: string;
|
||||
gender?: number;
|
||||
skin?: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Normalize man/woman to male/female
|
||||
const GENDER_CODES_MAP: Record<number, number> = {
|
||||
[GENDER_FEMALE_CODE]: GENDER_FEMALE_CODE,
|
||||
[GENDER_MALE_CODE]: GENDER_MALE_CODE,
|
||||
// These are man/woman markers, but are used for gender sometimes.
|
||||
[0x1f468]: GENDER_MALE_CODE,
|
||||
[0x1f469]: GENDER_FEMALE_CODE,
|
||||
};
|
||||
|
||||
const TWEMOJI_SPECIAL_CASES: Record<string, string | TwemojiSpecificEmoji> = {
|
||||
'1F441-200D-1F5E8': '1F441-FE0F-200D-1F5E8-FE0F', // Eye in speech bubble
|
||||
// An emoji that was never ported to the Unicode standard.
|
||||
// See: https://emojipedia.org/shibuya
|
||||
E50A: { label: 'Shibuya 109' },
|
||||
};
|
||||
|
||||
export function twemojiToUnicodeInfo(
|
||||
twemojiHex: string,
|
||||
): TwemojiSpecificEmoji | string {
|
||||
const specialCase = TWEMOJI_SPECIAL_CASES[twemojiHex.toUpperCase()];
|
||||
if (specialCase) {
|
||||
return specialCase;
|
||||
}
|
||||
const codes = hexStringToNumbers(twemojiHex);
|
||||
let gender: undefined | number;
|
||||
let skin: undefined | number;
|
||||
for (const code of codes) {
|
||||
if (!gender && code in GENDER_CODES_MAP) {
|
||||
gender = GENDER_CODES_MAP[code];
|
||||
} else if (!skin && code in SKIN_TONE_CODES) {
|
||||
skin = code;
|
||||
}
|
||||
|
||||
// Exit if we have both skin and gender
|
||||
if (skin && gender) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mappedCodes: unknown[] = codes;
|
||||
|
||||
if (codes.at(-1) === CHRISTMAS_TREE_CODE && codes.length >= 3 && gender) {
|
||||
// Twemoji uses the christmas tree with a ZWJ for Mr. and Mrs. Claus,
|
||||
// but in Unicode that only works for Mx. Claus.
|
||||
const START_CODE =
|
||||
gender === GENDER_FEMALE_CODE ? MS_CLAUS_CODE : MR_CLAUS_CODE;
|
||||
mappedCodes = [START_CODE, skin];
|
||||
} else if (codes.at(-1) === KEYCAP_CODE && codes.length === 2) {
|
||||
// For key emoji, insert the variation selector
|
||||
mappedCodes = [codes[0], VARIATION_SELECTOR_CODE, KEYCAP_CODE];
|
||||
} else if (
|
||||
(codes.at(0) === SKIER_CODE || codes.at(0) === LEVITATING_PERSON_CODE) &&
|
||||
codes.length > 1
|
||||
) {
|
||||
// Twemoji offers more gender and skin options for the skier and levitating person emoji.
|
||||
return {
|
||||
unqualified: hexNumbersToString([codes.at(0)]),
|
||||
skin,
|
||||
gender,
|
||||
};
|
||||
}
|
||||
|
||||
return hexNumbersToString(mappedCodes);
|
||||
}
|
||||
|
||||
function hexStringToNumbers(hexString: string): number[] {
|
||||
return hexString
|
||||
.split('-')
|
||||
.map((code) => Number.parseInt(code, 16))
|
||||
.filter((code) => !Number.isNaN(code));
|
||||
}
|
||||
|
||||
function hexNumbersToString(codes: unknown[], padding = 4): string {
|
||||
return codes
|
||||
.filter(
|
||||
(code): code is number =>
|
||||
typeof code === 'number' && code > 0 && !Number.isNaN(code),
|
||||
)
|
||||
.map((code) => code.toString(16).padStart(padding, '0').toUpperCase())
|
||||
.join('-');
|
||||
}
|
13
app/javascript/mastodon/features/emoji/worker.ts
Normal file
13
app/javascript/mastodon/features/emoji/worker.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { importEmojiData, importCustomEmojiData } from './loader';
|
||||
|
||||
addEventListener('message', handleMessage);
|
||||
self.postMessage('ready'); // After the worker is ready, notify the main thread
|
||||
|
||||
function handleMessage(event: MessageEvent<string>) {
|
||||
const { data: locale } = event;
|
||||
if (locale !== 'custom') {
|
||||
void importEmojiData(locale);
|
||||
} else {
|
||||
void importCustomEmojiData();
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
|
@ -7,8 +7,10 @@ import { Helmet } from 'react-helmet';
|
|||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
|
||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||
import {
|
||||
fetchFollowedHashtags,
|
||||
unfollowHashtag,
|
||||
} from 'mastodon/actions/tags_typed';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
|
@ -16,7 +18,7 @@ import type { ColumnRef } from 'mastodon/components/column';
|
|||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||
|
@ -59,55 +61,32 @@ const FollowedTag: React.FC<{
|
|||
|
||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [next, setNext] = useState<string | undefined>();
|
||||
const dispatch = useAppDispatch();
|
||||
const { tags, loading, next, stale } = useAppSelector(
|
||||
(state) => state.followedTags,
|
||||
);
|
||||
const hasMore = !!next;
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags()
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext]);
|
||||
if (stale) {
|
||||
void dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
}, [dispatch, stale]);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(next)
|
||||
.then(({ tags, links }) => {
|
||||
const next = links.refs.find((link) => link.rel === 'next');
|
||||
|
||||
setLoading(false);
|
||||
setTags((previousTags) => [...previousTags, ...tags]);
|
||||
setNext(next?.uri);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setTags, setLoading, setNext, next]);
|
||||
if (next) {
|
||||
void dispatch(fetchFollowedHashtags({ next }));
|
||||
}
|
||||
}, [dispatch, next]);
|
||||
|
||||
const handleUnfollow = useCallback(
|
||||
(tagId: string) => {
|
||||
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
||||
void dispatch(unfollowHashtag({ tagId }));
|
||||
},
|
||||
[setTags],
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const columnRef = useRef<ColumnRef>(null);
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
|
|
@ -1,179 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
|
||||
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||
import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
|
||||
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
|
||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { fetchFollowRequests } from 'mastodon/actions/accounts';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { LinkFooter } from 'mastodon/features/ui/components/link_footer';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
|
||||
|
||||
import { me, showTrends } from '../../initial_state';
|
||||
import { NavigationBar } from '../compose/components/navigation_bar';
|
||||
import { ColumnLink } from '../ui/components/column_link';
|
||||
import ColumnSubheading from '../ui/components/column_subheading';
|
||||
|
||||
import { Trends } from 'mastodon/features/navigation_panel/components/trends';
|
||||
|
||||
const messages = defineMessages({
|
||||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
|
||||
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
|
||||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
||||
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
||||
explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
|
||||
moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
|
||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
|
||||
personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' },
|
||||
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
|
||||
menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
myAccount: state.getIn(['accounts', me]),
|
||||
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
|
||||
});
|
||||
|
||||
const badgeDisplay = (number, limit) => {
|
||||
if (number === 0) {
|
||||
return undefined;
|
||||
} else if (limit && number >= limit) {
|
||||
return `${limit}+`;
|
||||
} else {
|
||||
return number;
|
||||
}
|
||||
};
|
||||
|
||||
class GettingStarted extends ImmutablePureComponent {
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
intl: PropTypes.object.isRequired,
|
||||
myAccount: ImmutablePropTypes.record,
|
||||
multiColumn: PropTypes.bool,
|
||||
fetchFollowRequests: PropTypes.func.isRequired,
|
||||
unreadFollowRequests: PropTypes.number,
|
||||
unreadNotifications: PropTypes.number,
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
const { fetchFollowRequests } = this.props;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (!signedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchFollowRequests();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const navItems = [];
|
||||
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-discover' text={intl.formatMessage(messages.discover)} />,
|
||||
);
|
||||
|
||||
if (showTrends) {
|
||||
navItems.push(
|
||||
<ColumnLink key='explore' icon='explore' iconComponent={ExploreIcon} text={intl.formatMessage(messages.explore)} to='/explore' />,
|
||||
);
|
||||
}
|
||||
|
||||
navItems.push(
|
||||
<ColumnLink key='community_timeline' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.community_timeline)} to='/public/local' />,
|
||||
<ColumnLink key='public_timeline' icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.public_timeline)} to='/public' />,
|
||||
);
|
||||
|
||||
if (signedIn) {
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-personal' text={intl.formatMessage(messages.personal)} />,
|
||||
<ColumnLink key='home' icon='home' iconComponent={HomeIcon} text={intl.formatMessage(messages.home_timeline)} to='/home' />,
|
||||
<ColumnLink key='direct' icon='at' iconComponent={AlternateEmailIcon} text={intl.formatMessage(messages.direct)} to='/conversations' />,
|
||||
<ColumnLink key='bookmark' icon='bookmarks' iconComponent={BookmarksIcon} text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
|
||||
<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||
<ColumnLink key='lists' icon='list-ul' iconComponent={ListAltIcon} text={intl.formatMessage(messages.lists)} to='/lists' />,
|
||||
);
|
||||
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' iconComponent={PersonAddIcon} text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
}
|
||||
|
||||
navItems.push(
|
||||
<ColumnSubheading key='header-settings' text={intl.formatMessage(messages.settings_subheading)} />,
|
||||
<ColumnLink key='preferences' icon='cog' iconComponent={SettingsIcon} text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
|
||||
);
|
||||
|
||||
if (canManageReports(permissions)) {
|
||||
navItems.push(<ColumnLink key='moderation' href='/admin/reports' icon='flag' iconComponent={ModerationIcon} text={intl.formatMessage(messages.moderation)} />);
|
||||
}
|
||||
if (canViewAdminDashboard(permissions)) {
|
||||
navItems.push(<ColumnLink key='administration' href='/admin/dashboard' icon='tachometer' iconComponent={AdministrationIcon} text={intl.formatMessage(messages.administration)} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Column>
|
||||
{(signedIn && !multiColumn) ? <NavigationBar /> : <ColumnHeader title={intl.formatMessage(messages.menu)} icon='bars' iconComponent={MenuIcon} multiColumn={multiColumn} />}
|
||||
|
||||
<div className='getting-started scrollable scrollable--flex'>
|
||||
<div className='getting-started__wrapper'>
|
||||
{navItems}
|
||||
</div>
|
||||
|
||||
{!multiColumn && <div className='flex-spacer' />}
|
||||
|
||||
<LinkFooter multiColumn />
|
||||
</div>
|
||||
|
||||
{(multiColumn && showTrends) && <Trends />}
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.menu)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withIdentity(connect(mapStateToProps, mapDispatchToProps)(injectIntl(GettingStarted)));
|
32
app/javascript/mastodon/features/getting_started/index.tsx
Normal file
32
app/javascript/mastodon/features/getting_started/index.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { Column } from 'mastodon/components/column';
|
||||
|
||||
import { NavigationPanel } from '../navigation_panel';
|
||||
import { LinkFooter } from '../ui/components/link_footer';
|
||||
|
||||
const GettingStarted: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Column>
|
||||
<NavigationPanel multiColumn />
|
||||
|
||||
<LinkFooter multiColumn />
|
||||
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage({
|
||||
id: 'getting_started.heading',
|
||||
defaultMessage: 'Getting started',
|
||||
})}
|
||||
</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default GettingStarted;
|
|
@ -1,11 +1,11 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import { fetchFollowedHashtags } from 'mastodon/actions/tags_typed';
|
||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { CollapsiblePanel } from './collapsible_panel';
|
||||
|
||||
|
@ -24,25 +24,20 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
const TAG_LIMIT = 4;
|
||||
|
||||
export const FollowedTagsPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const { tags, stale, loading } = useAppSelector(
|
||||
(state) => state.followedTags,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
|
||||
void apiGetFollowedTags(undefined, 4)
|
||||
.then(({ tags }) => {
|
||||
setTags(tags);
|
||||
setLoading(false);
|
||||
|
||||
return '';
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [setLoading, setTags]);
|
||||
if (stale) {
|
||||
void dispatch(fetchFollowedHashtags());
|
||||
}
|
||||
}, [dispatch, stale]);
|
||||
|
||||
return (
|
||||
<CollapsiblePanel
|
||||
|
@ -54,14 +49,14 @@ export const FollowedTagsPanel: React.FC = () => {
|
|||
expandTitle={intl.formatMessage(messages.expand)}
|
||||
loading={loading}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
{tags.slice(0, TAG_LIMIT).map((tag) => (
|
||||
<ColumnLink
|
||||
transparent
|
||||
icon='hashtag'
|
||||
key={tag.name}
|
||||
iconComponent={TagIcon}
|
||||
text={`#${tag.name}`}
|
||||
to={`/tags/${tag.name}`}
|
||||
transparent
|
||||
/>
|
||||
))}
|
||||
</CollapsiblePanel>
|
||||
|
|
|
@ -50,16 +50,22 @@ export const MoreLink: React.FC = () => {
|
|||
|
||||
const menu = useMemo(() => {
|
||||
const arr: MenuItem[] = [
|
||||
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||
{ text: intl.formatMessage(messages.blocks), to: '/blocks' },
|
||||
{
|
||||
text: intl.formatMessage(messages.domainBlocks),
|
||||
to: '/domain_blocks',
|
||||
href: '/filters',
|
||||
text: intl.formatMessage(messages.filters),
|
||||
},
|
||||
{
|
||||
to: '/mutes',
|
||||
text: intl.formatMessage(messages.mutes),
|
||||
},
|
||||
{
|
||||
to: '/blocks',
|
||||
text: intl.formatMessage(messages.blocks),
|
||||
},
|
||||
{
|
||||
to: '/domain_blocks',
|
||||
text: intl.formatMessage(messages.domainBlocks),
|
||||
},
|
||||
];
|
||||
|
||||
arr.push(
|
||||
null,
|
||||
{
|
||||
href: '/settings/privacy',
|
||||
|
@ -77,7 +83,7 @@ export const MoreLink: React.FC = () => {
|
|||
href: '/settings/export',
|
||||
text: intl.formatMessage(messages.importExport),
|
||||
},
|
||||
);
|
||||
];
|
||||
|
||||
if (canManageReports(permissions)) {
|
||||
arr.push(null, {
|
||||
|
@ -106,7 +112,7 @@ export const MoreLink: React.FC = () => {
|
|||
}, [intl, dispatch, permissions]);
|
||||
|
||||
return (
|
||||
<Dropdown items={menu}>
|
||||
<Dropdown items={menu} placement='bottom-start'>
|
||||
<button className='column-link column-link--transparent'>
|
||||
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
||||
|
||||
|
|
|
@ -185,13 +185,169 @@ const isFirehoseActive = (
|
|||
|
||||
const MENU_WIDTH = 284;
|
||||
|
||||
export const NavigationPanel: React.FC = () => {
|
||||
export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
multiColumn = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, disabledAccountId } = useIdentity();
|
||||
const location = useLocation();
|
||||
const showSearch = useBreakpoint('full') && !multiColumn;
|
||||
|
||||
let banner: React.ReactNode;
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
|
||||
<a
|
||||
href={`/deck${location.pathname}`}
|
||||
className='switch-to-advanced__toggle'
|
||||
>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'>
|
||||
<WordmarkLogo />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showSearch && <Search singleColumn />}
|
||||
|
||||
{!multiColumn && <ProfileCard />}
|
||||
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
{!multiColumn && (
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
)}
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trendsEnabled && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={TrendingUpIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/public/local'
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
text={intl.formatMessage(messages.firehose)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<NotificationsLink />
|
||||
|
||||
<FollowRequestsLink />
|
||||
|
||||
<hr />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
<FollowedTagsPanel />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
|
||||
<MoreLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/about'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={InfoIcon}
|
||||
text={intl.formatMessage(messages.about)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
|
||||
{disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<Trends />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollapsibleNavigationPanel: React.FC = () => {
|
||||
const open = useAppSelector((state) => state.navigation.open);
|
||||
const dispatch = useAppDispatch();
|
||||
const openable = useBreakpoint('openable');
|
||||
const showSearch = useBreakpoint('full');
|
||||
const location = useLocation();
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
@ -221,14 +377,18 @@ export const NavigationPanel: React.FC = () => {
|
|||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const isLtrDir = getComputedStyle(document.body).direction !== 'rtl';
|
||||
|
||||
const OPEN_MENU_OFFSET = isLtrDir ? MENU_WIDTH : -MENU_WIDTH;
|
||||
|
||||
const [{ x }, spring] = useSpring(
|
||||
() => ({
|
||||
x: open ? 0 : MENU_WIDTH,
|
||||
x: open ? 0 : OPEN_MENU_OFFSET,
|
||||
onRest: {
|
||||
x({ value }: { value: number }) {
|
||||
if (value === 0) {
|
||||
dispatch(openNavigation());
|
||||
} else if (value > 0) {
|
||||
} else if (isLtrDir ? value > 0 : value < 0) {
|
||||
dispatch(closeNavigation());
|
||||
}
|
||||
},
|
||||
|
@ -238,25 +398,38 @@ export const NavigationPanel: React.FC = () => {
|
|||
);
|
||||
|
||||
const bind = useDrag(
|
||||
({ last, offset: [ox], velocity: [vx], direction: [dx], cancel }) => {
|
||||
if (ox < -70) {
|
||||
({
|
||||
last,
|
||||
offset: [xOffset],
|
||||
velocity: [xVelocity],
|
||||
direction: [xDirection],
|
||||
cancel,
|
||||
}) => {
|
||||
const logicalXDirection = isLtrDir ? xDirection : -xDirection;
|
||||
const logicalXOffset = isLtrDir ? xOffset : -xOffset;
|
||||
const hasReachedDragThreshold = logicalXOffset < -70;
|
||||
|
||||
if (hasReachedDragThreshold) {
|
||||
cancel();
|
||||
}
|
||||
|
||||
if (last) {
|
||||
if (ox > MENU_WIDTH / 2 || (vx > 0.5 && dx > 0)) {
|
||||
void spring.start({ x: MENU_WIDTH });
|
||||
const isAboveOpenThreshold = logicalXOffset > MENU_WIDTH / 2;
|
||||
const isQuickFlick = xVelocity > 0.5 && logicalXDirection > 0;
|
||||
|
||||
if (isAboveOpenThreshold || isQuickFlick) {
|
||||
void spring.start({ x: OPEN_MENU_OFFSET });
|
||||
} else {
|
||||
void spring.start({ x: 0 });
|
||||
}
|
||||
} else {
|
||||
void spring.start({ x: ox, immediate: true });
|
||||
void spring.start({ x: xOffset, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
from: () => [x.get(), 0],
|
||||
filterTaps: true,
|
||||
bounds: { left: 0 },
|
||||
bounds: isLtrDir ? { left: 0 } : { right: 0 },
|
||||
rubberband: true,
|
||||
},
|
||||
);
|
||||
|
@ -276,22 +449,6 @@ export const NavigationPanel: React.FC = () => {
|
|||
}
|
||||
}, [open]);
|
||||
|
||||
let banner: React.ReactNode;
|
||||
|
||||
if (transientSingleColumn) {
|
||||
banner = (
|
||||
<div className='switch-to-advanced'>
|
||||
{intl.formatMessage(messages.openedInClassicInterface)}{' '}
|
||||
<a
|
||||
href={`/deck${location.pathname}`}
|
||||
className='switch-to-advanced__toggle'
|
||||
>
|
||||
{intl.formatMessage(messages.advancedInterface)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showOverlay = openable && open;
|
||||
|
||||
return (
|
||||
|
@ -307,139 +464,7 @@ export const NavigationPanel: React.FC = () => {
|
|||
{...bind()}
|
||||
style={openable ? { x } : undefined}
|
||||
>
|
||||
<div className='navigation-panel'>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link to='/' className='column-link column-link--logo'>
|
||||
<WordmarkLogo />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showSearch && <Search singleColumn />}
|
||||
|
||||
<ProfileCard />
|
||||
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trendsEnabled && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={TrendingUpIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(signedIn || timelinePreview) && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/public/local'
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
text={intl.formatMessage(messages.firehose)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<NotificationsLink />
|
||||
|
||||
<FollowRequestsLink />
|
||||
|
||||
<hr />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
<FollowedTagsPanel />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
|
||||
<MoreLink />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/about'
|
||||
icon='ellipsis-h'
|
||||
iconComponent={InfoIcon}
|
||||
text={intl.formatMessage(messages.about)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
|
||||
{disabledAccountId ? (
|
||||
<DisabledAccountBanner />
|
||||
) : (
|
||||
<SignInBanner />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<Trends />
|
||||
</div>
|
||||
<NavigationPanel />
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import SettingsIcon from '@/material-icons/400-20px/settings.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { requestBrowserPermission } from 'mastodon/actions/notifications';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
class NotificationsPermissionBanner extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
this.props.dispatch(requestBrowserPermission());
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.props.dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
|
||||
};
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className='notifications-permission-banner'>
|
||||
<div className='notifications-permission-banner__close'>
|
||||
<IconButton icon='times' iconComponent={CloseIcon} onClick={this.handleClose} title={intl.formatMessage(messages.close)} />
|
||||
</div>
|
||||
|
||||
<h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
|
||||
<p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' icon={SettingsIcon} /> }} /></p>
|
||||
<Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect()(injectIntl(NotificationsPermissionBanner));
|
|
@ -0,0 +1,74 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||
import { requestBrowserPermission } from 'mastodon/actions/notifications';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { messages as columnHeaderMessages } from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
});
|
||||
|
||||
const NotificationsPermissionBanner: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
dispatch(requestBrowserPermission());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
dispatch(changeSetting(['notifications', 'dismissPermissionBanner'], true));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className='notifications-permission-banner'>
|
||||
<div className='notifications-permission-banner__close'>
|
||||
<IconButton
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleClose}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='notifications_permission_banner.title'
|
||||
defaultMessage='Never miss a thing'
|
||||
/>
|
||||
</h2>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='notifications_permission_banner.how_to_control'
|
||||
defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled."
|
||||
values={{
|
||||
icon: (
|
||||
<Icon
|
||||
id='sliders'
|
||||
icon={UnfoldMoreIcon}
|
||||
aria-label={intl.formatMessage(columnHeaderMessages.show)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<Button onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='notifications_permission_banner.enable'
|
||||
defaultMessage='Enable desktop notifications'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default NotificationsPermissionBanner;
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
|
@ -21,6 +21,9 @@ import { openModal } from 'mastodon/actions/modal';
|
|||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
@ -47,6 +50,11 @@ const messages = defineMessages({
|
|||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||
});
|
||||
|
||||
type GetStatusSelector = (
|
||||
state: RootState,
|
||||
props: { id?: string | null; contextType?: string },
|
||||
) => Status | null;
|
||||
|
||||
export const Footer: React.FC<{
|
||||
statusId: string;
|
||||
withOpenButton?: boolean;
|
||||
|
@ -56,7 +64,8 @@ export const Footer: React.FC<{
|
|||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
||||
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
|
||||
const accountId = status?.get('account') as string | undefined;
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { render, fireEvent, screen } from 'mastodon/test_helpers';
|
||||
import { render, fireEvent, screen } from '@/testing/rendering';
|
||||
|
||||
import Column from '../column';
|
||||
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { createAppSelector, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const getShouldLockBodyScroll = createAppSelector(
|
||||
[
|
||||
(state) => state.navigation.open,
|
||||
(state) => state.modal.get('stack').size > 0,
|
||||
],
|
||||
(isMobileMenuOpen: boolean, isModalOpen: boolean) =>
|
||||
isMobileMenuOpen || isModalOpen,
|
||||
);
|
||||
|
||||
/**
|
||||
* This component locks scrolling on the body when
|
||||
* `getShouldLockBodyScroll` returns true.
|
||||
*/
|
||||
|
||||
export const BodyScrollLock: React.FC = () => {
|
||||
const shouldLockBodyScroll = useAppSelector(getShouldLockBodyScroll);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.classList.toggle(
|
||||
'has-modal',
|
||||
shouldLockBodyScroll,
|
||||
);
|
||||
}, [shouldLockBodyScroll]);
|
||||
|
||||
return null;
|
||||
};
|
|
@ -16,7 +16,6 @@ export const ColumnLink: React.FC<{
|
|||
method?: string;
|
||||
badge?: React.ReactNode;
|
||||
transparent?: boolean;
|
||||
optional?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}> = ({
|
||||
|
@ -30,13 +29,11 @@ export const ColumnLink: React.FC<{
|
|||
method,
|
||||
badge,
|
||||
transparent,
|
||||
optional,
|
||||
...other
|
||||
}) => {
|
||||
const match = useRouteMatch(to ?? '');
|
||||
const className = classNames('column-link', {
|
||||
'column-link--transparent': transparent,
|
||||
'column-link--optional': optional,
|
||||
});
|
||||
const badgeElement =
|
||||
typeof badge !== 'undefined' ? (
|
||||
|
|
|
@ -23,9 +23,9 @@ import { useColumnsContext } from '../util/columns_context';
|
|||
|
||||
import BundleColumnError from './bundle_column_error';
|
||||
import { ColumnLoading } from './column_loading';
|
||||
import { ComposePanel } from './compose_panel';
|
||||
import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
|
||||
import DrawerLoading from './drawer_loading';
|
||||
import { NavigationPanel } from 'mastodon/features/navigation_panel';
|
||||
import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel';
|
||||
|
||||
const componentMap = {
|
||||
'COMPOSE': Compose,
|
||||
|
@ -124,6 +124,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
|
||||
<div className='columns-area__panels__pane__inner'>
|
||||
{renderComposePanel && <ComposePanel />}
|
||||
<RedirectToMobileComposeIfNeeded />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -132,7 +133,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
|||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
</div>
|
||||
|
||||
<NavigationPanel />
|
||||
<CollapsibleNavigationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect } from 'react';
|
||||
|
||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
@ -7,6 +7,7 @@ import {
|
|||
mountCompose,
|
||||
unmountCompose,
|
||||
} from 'mastodon/actions/compose';
|
||||
import { useAppHistory } from 'mastodon/components/router';
|
||||
import ServerBanner from 'mastodon/components/server_banner';
|
||||
import { Search } from 'mastodon/features/compose/components/search';
|
||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||
|
@ -54,3 +55,25 @@ export const ComposePanel: React.FC = () => {
|
|||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Redirect the user to the standalone compose page when the
|
||||
* sidebar composer is hidden due to a change in viewport size
|
||||
* while a post is being written.
|
||||
*/
|
||||
|
||||
export const RedirectToMobileComposeIfNeeded: React.FC = () => {
|
||||
const history = useAppHistory();
|
||||
|
||||
const shouldRedirect = useAppSelector((state) =>
|
||||
state.compose.get('should_redirect_to_compose_page'),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (shouldRedirect) {
|
||||
history.push('/publish');
|
||||
}
|
||||
}, [history, shouldRedirect]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ export const ConfirmationModal: React.FC<
|
|||
title: React.ReactNode;
|
||||
message: React.ReactNode;
|
||||
confirm: React.ReactNode;
|
||||
cancel?: React.ReactNode;
|
||||
secondary?: React.ReactNode;
|
||||
onSecondary?: () => void;
|
||||
onConfirm: () => void;
|
||||
|
@ -22,6 +23,7 @@ export const ConfirmationModal: React.FC<
|
|||
title,
|
||||
message,
|
||||
confirm,
|
||||
cancel,
|
||||
onClose,
|
||||
onConfirm,
|
||||
secondary,
|
||||
|
@ -57,10 +59,12 @@ export const ConfirmationModal: React.FC<
|
|||
<div className='safety-action-modal__bottom'>
|
||||
<div className='safety-action-modal__actions'>
|
||||
<button onClick={handleCancel} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
{cancel ?? (
|
||||
<FormattedMessage
|
||||
id='confirmation_modal.cancel'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{secondary && (
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { editStatus } from 'mastodon/actions/statuses';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const editMessages = defineMessages({
|
||||
title: {
|
||||
id: 'confirmations.discard_draft.edit.title',
|
||||
defaultMessage: 'Discard changes to your post?',
|
||||
},
|
||||
message: {
|
||||
id: 'confirmations.discard_draft.edit.message',
|
||||
defaultMessage:
|
||||
'Continuing will discard any changes you have made to the post you are currently editing.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'confirmations.discard_draft.edit.cancel',
|
||||
defaultMessage: 'Resume editing',
|
||||
},
|
||||
});
|
||||
|
||||
const postMessages = defineMessages({
|
||||
title: {
|
||||
id: 'confirmations.discard_draft.post.title',
|
||||
defaultMessage: 'Discard your draft post?',
|
||||
},
|
||||
message: {
|
||||
id: 'confirmations.discard_draft.post.message',
|
||||
defaultMessage:
|
||||
'Continuing will discard the post you are currently composing.',
|
||||
},
|
||||
cancel: {
|
||||
id: 'confirmations.discard_draft.post.cancel',
|
||||
defaultMessage: 'Resume draft',
|
||||
},
|
||||
});
|
||||
|
||||
const messages = defineMessages({
|
||||
confirm: {
|
||||
id: 'confirmations.discard_draft.confirm',
|
||||
defaultMessage: 'Discard and continue',
|
||||
},
|
||||
});
|
||||
|
||||
const DiscardDraftConfirmationModal: React.FC<
|
||||
{
|
||||
onConfirm: () => void;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ onConfirm, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
const contextualMessages = isEditing ? editMessages : postMessages;
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(contextualMessages.title)}
|
||||
message={intl.formatMessage(contextualMessages.message)}
|
||||
cancel={intl.formatMessage(contextualMessages.cancel)}
|
||||
confirm={intl.formatMessage(messages.confirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmReplyModal: React.FC<
|
||||
{
|
||||
status: Status;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ status, onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(replyCompose(status));
|
||||
}, [dispatch, status]);
|
||||
|
||||
return (
|
||||
<DiscardDraftConfirmationModal onConfirm={onConfirm} onClose={onClose} />
|
||||
);
|
||||
};
|
||||
|
||||
export const ConfirmEditStatusModal: React.FC<
|
||||
{
|
||||
statusId: string;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ statusId, onClose }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(editStatus(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
return (
|
||||
<DiscardDraftConfirmationModal onConfirm={onConfirm} onClose={onClose} />
|
||||
);
|
||||
};
|
|
@ -1,45 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { editStatus } from 'mastodon/actions/statuses';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
editTitle: {
|
||||
id: 'confirmations.edit.title',
|
||||
defaultMessage: 'Overwrite post?',
|
||||
},
|
||||
editConfirm: { id: 'confirmations.edit.confirm', defaultMessage: 'Edit' },
|
||||
editMessage: {
|
||||
id: 'confirmations.edit.message',
|
||||
defaultMessage:
|
||||
'Editing now will overwrite the message you are currently composing. Are you sure you want to proceed?',
|
||||
},
|
||||
});
|
||||
|
||||
export const ConfirmEditStatusModal: React.FC<
|
||||
{
|
||||
statusId: string;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ statusId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(editStatus(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.editTitle)}
|
||||
message={intl.formatMessage(messages.editMessage)}
|
||||
confirm={intl.formatMessage(messages.editConfirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,8 +1,10 @@
|
|||
export { ConfirmationModal } from './confirmation_modal';
|
||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||
export { ConfirmDeleteListModal } from './delete_list';
|
||||
export { ConfirmReplyModal } from './reply';
|
||||
export { ConfirmEditStatusModal } from './edit_status';
|
||||
export {
|
||||
ConfirmReplyModal,
|
||||
ConfirmEditStatusModal,
|
||||
} from './discard_draft_confirmation';
|
||||
export { ConfirmUnfollowModal } from './unfollow';
|
||||
export { ConfirmClearNotificationsModal } from './clear_notifications';
|
||||
export { ConfirmLogOutModal } from './log_out';
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||
import { ConfirmationModal } from './confirmation_modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
replyTitle: {
|
||||
id: 'confirmations.reply.title',
|
||||
defaultMessage: 'Overwrite post?',
|
||||
},
|
||||
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
|
||||
replyMessage: {
|
||||
id: 'confirmations.reply.message',
|
||||
defaultMessage:
|
||||
'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?',
|
||||
},
|
||||
});
|
||||
|
||||
export const ConfirmReplyModal: React.FC<
|
||||
{
|
||||
status: Status;
|
||||
} & BaseConfirmationModalProps
|
||||
> = ({ status, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
dispatch(replyCompose(status));
|
||||
}, [dispatch, status]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.replyTitle)}
|
||||
message={intl.formatMessage(messages.replyMessage)}
|
||||
confirm={intl.formatMessage(messages.replyConfirm)}
|
||||
onConfirm={onConfirm}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -20,7 +20,6 @@ import {
|
|||
IgnoreNotificationsModal,
|
||||
AnnualReportModal,
|
||||
} from 'mastodon/features/ui/util/async-components';
|
||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||
|
||||
import BundleContainer from '../containers/bundle_container';
|
||||
|
||||
|
@ -90,20 +89,6 @@ export default class ModalRoot extends PureComponent {
|
|||
backgroundColor: null,
|
||||
};
|
||||
|
||||
getSnapshotBeforeUpdate () {
|
||||
return { visible: !!this.props.type };
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps, prevState, { visible }) {
|
||||
if (visible) {
|
||||
document.body.classList.add('with-modals--active');
|
||||
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||
} else {
|
||||
document.body.classList.remove('with-modals--active');
|
||||
document.documentElement.style.marginRight = '0';
|
||||
}
|
||||
}
|
||||
|
||||
setBackgroundColor = color => {
|
||||
this.setState({ backgroundColor: color });
|
||||
};
|
||||
|
|
|
@ -22,7 +22,7 @@ import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
|
|||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
||||
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
|
||||
|
|
|
@ -142,13 +142,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
};
|
||||
|
||||
UNSAFE_componentWillMount () {
|
||||
if (this.props.singleColumn) {
|
||||
document.body.classList.toggle('layout-single-column', true);
|
||||
document.body.classList.toggle('layout-multiple-columns', false);
|
||||
} else {
|
||||
document.body.classList.toggle('layout-single-column', false);
|
||||
document.body.classList.toggle('layout-multiple-columns', true);
|
||||
}
|
||||
document.body.classList.toggle('layout-single-column', this.props.singleColumn);
|
||||
document.body.classList.toggle('layout-multiple-columns', !this.props.singleColumn);
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
|
@ -200,8 +195,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
||||
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
|
||||
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
|
||||
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
|
||||
{pathName === '/getting-started' ? <Redirect from='/getting-started' to={singleColumn ? '/home' : '/deck/getting-started'} exact /> : null}
|
||||
|
||||
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
|
||||
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
||||
|
|
|
@ -8,13 +8,14 @@ import { openURL } from 'mastodon/actions/search';
|
|||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||
element.classList.contains('mention');
|
||||
element.classList.contains('mention') &&
|
||||
!element.classList.contains('hashtag');
|
||||
|
||||
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||
element.textContent?.[0] === '#' ||
|
||||
element.previousSibling?.textContent?.endsWith('#');
|
||||
|
||||
export const useLinks = () => {
|
||||
export const useLinks = (skipHashtags?: boolean) => {
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
@ -61,12 +62,12 @@ export const useLinks = () => {
|
|||
if (isMentionClick(target)) {
|
||||
e.preventDefault();
|
||||
void handleMentionClick(target);
|
||||
} else if (isHashtagClick(target)) {
|
||||
} else if (isHashtagClick(target) && !skipHashtags) {
|
||||
e.preventDefault();
|
||||
handleHashtagClick(target);
|
||||
}
|
||||
},
|
||||
[handleMentionClick, handleHashtagClick],
|
||||
[skipHashtags, handleMentionClick, handleHashtagClick],
|
||||
);
|
||||
|
||||
return handleClick;
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// @ts-check
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
||||
*/
|
||||
|
@ -64,6 +63,7 @@
|
|||
* @property {boolean=} critical_updates_pending
|
||||
* @property {InitialStateMeta} meta
|
||||
* @property {Role?} role
|
||||
* @property {string[]} features
|
||||
*/
|
||||
|
||||
const element = document.getElementById('initial-state');
|
||||
|
@ -140,4 +140,12 @@ export function getAccessToken() {
|
|||
return getMeta('access_token');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} feature
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isFeatureEnabled(feature) {
|
||||
return initialState?.features?.includes(feature) || false;
|
||||
}
|
||||
|
||||
export default initialState;
|
||||
|
|
|
@ -95,7 +95,6 @@
|
|||
"column_header.pin": "Maak vas",
|
||||
"column_header.show_settings": "Wys instellings",
|
||||
"column_header.unpin": "Maak los",
|
||||
"column_subheading.settings": "Instellings",
|
||||
"community.column_settings.local_only": "Slegs plaaslik",
|
||||
"community.column_settings.media_only": "Slegs media",
|
||||
"community.column_settings.remote_only": "Slegs elders",
|
||||
|
@ -121,7 +120,6 @@
|
|||
"confirmations.discard_edit_media.confirm": "Gooi weg",
|
||||
"confirmations.logout.confirm": "Teken Uit",
|
||||
"confirmations.logout.message": "Is jy seker jy wil uitteken?",
|
||||
"confirmations.reply.confirm": "Antwoord",
|
||||
"conversation.mark_as_read": "Merk as gelees",
|
||||
"conversation.open": "Sien gesprek",
|
||||
"conversation.with": "Met {names}",
|
||||
|
@ -217,15 +215,10 @@
|
|||
"moved_to_account_banner.text": "Jou rekening {disabledAccount} is tans gedeaktiveer omdat jy na {movedToAccount} verhuis het.",
|
||||
"navigation_bar.about": "Oor",
|
||||
"navigation_bar.bookmarks": "Boekmerke",
|
||||
"navigation_bar.community_timeline": "Plaaslike tydlyn",
|
||||
"navigation_bar.compose": "Skep nuwe plasing",
|
||||
"navigation_bar.domain_blocks": "Geblokkeerde domeine",
|
||||
"navigation_bar.lists": "Lyste",
|
||||
"navigation_bar.logout": "Teken uit",
|
||||
"navigation_bar.personal": "Persoonlik",
|
||||
"navigation_bar.pins": "Vasgemaakte plasings",
|
||||
"navigation_bar.preferences": "Voorkeure",
|
||||
"navigation_bar.public_timeline": "Gefedereerde tydlyn",
|
||||
"navigation_bar.search": "Soek",
|
||||
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
|
||||
"notification.reblog": "{name} het jou plasing aangestuur",
|
||||
|
|
|
@ -105,7 +105,6 @@
|
|||
"column_header.pin": "Fixar",
|
||||
"column_header.show_settings": "Amostrar achustes",
|
||||
"column_header.unpin": "Deixar de fixar",
|
||||
"column_subheading.settings": "Achustes",
|
||||
"community.column_settings.local_only": "Solo local",
|
||||
"community.column_settings.media_only": "Solo media",
|
||||
"community.column_settings.remote_only": "Solo remoto",
|
||||
|
@ -134,8 +133,6 @@
|
|||
"confirmations.logout.message": "Yes seguro de querer zarrar la sesión?",
|
||||
"confirmations.mute.confirm": "Silenciar",
|
||||
"confirmations.redraft.confirm": "Borrar y tornar ta borrador",
|
||||
"confirmations.reply.confirm": "Responder",
|
||||
"confirmations.reply.message": "Responder sobrescribirá lo mensache que yes escribindo. Yes seguro que deseyas continar?",
|
||||
"confirmations.unfollow.confirm": "Deixar de seguir",
|
||||
"confirmations.unfollow.message": "Yes seguro que quiers deixar de seguir a {name}?",
|
||||
"conversation.delete": "Borrar conversación",
|
||||
|
@ -289,23 +286,15 @@
|
|||
"navigation_bar.about": "Sobre",
|
||||
"navigation_bar.blocks": "Usuarios blocaus",
|
||||
"navigation_bar.bookmarks": "Marcadors",
|
||||
"navigation_bar.community_timeline": "Linia de tiempo local",
|
||||
"navigation_bar.compose": "Escribir nueva publicación",
|
||||
"navigation_bar.discover": "Descubrir",
|
||||
"navigation_bar.domain_blocks": "Dominios amagaus",
|
||||
"navigation_bar.explore": "Explorar",
|
||||
"navigation_bar.filters": "Parolas silenciadas",
|
||||
"navigation_bar.follow_requests": "Solicitutz pa seguir-te",
|
||||
"navigation_bar.follows_and_followers": "Seguindo y seguidores",
|
||||
"navigation_bar.lists": "Listas",
|
||||
"navigation_bar.logout": "Zarrar sesión",
|
||||
"navigation_bar.mutes": "Usuarios silenciaus",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Publicacions fixadas",
|
||||
"navigation_bar.preferences": "Preferencias",
|
||||
"navigation_bar.public_timeline": "Linia de tiempo federada",
|
||||
"navigation_bar.search": "Buscar",
|
||||
"navigation_bar.security": "Seguranza",
|
||||
"not_signed_in_indicator.not_signed_in": "Amenestes iniciar sesión pa acceder ta este recurso.",
|
||||
"notification.admin.report": "{name} informó {target}",
|
||||
"notification.admin.sign_up": "{name} se rechistró",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "خوادم تحت الإشراف",
|
||||
"about.contact": "للاتصال:",
|
||||
"about.default_locale": "افتراضيالافتراضية",
|
||||
"about.disclaimer": "ماستدون برنامج حر ومفتوح المصدر وعلامة تجارية لـ Mastodon GmbH.",
|
||||
"about.domain_blocks.no_reason_available": "السبب غير متوفر",
|
||||
"about.domain_blocks.preamble": "يتيح مَستُدون عمومًا لمستخدميه مطالعة المحتوى من المستخدمين من الخواديم الأخرى في الفدرالية والتفاعل معهم. وهذه هي الاستثناءات التي وضعت على هذا الخادوم.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "محدود",
|
||||
"about.domain_blocks.suspended.explanation": "لن يتم معالجة أي بيانات من هذا الخادم أو تخزينها أو تبادلها، مما يجعل أي تفاعل أو اتصال مع المستخدمين من هذا الخادم مستحيلا.",
|
||||
"about.domain_blocks.suspended.title": "مُعلّق",
|
||||
"about.language_label": "اللغة",
|
||||
"about.not_available": "لم يتم توفير هذه المعلومات على هذا الخادم.",
|
||||
"about.powered_by": "شبكة اجتماعية لامركزية مدعومة من {mastodon}",
|
||||
"about.rules": "قواعد الخادم",
|
||||
|
@ -19,13 +21,21 @@
|
|||
"account.block_domain": "حظر اسم النِّطاق {domain}",
|
||||
"account.block_short": "حظر",
|
||||
"account.blocked": "محظور",
|
||||
"account.blocking": "محظور",
|
||||
"account.cancel_follow_request": "إلغاء طلب المتابعة",
|
||||
"account.copy": "نسخ الرابط إلى الملف الشخصي",
|
||||
"account.direct": "إشارة خاصة لـ @{name}",
|
||||
"account.disable_notifications": "توقف عن إشعاري عندما ينشر @{name}",
|
||||
"account.domain_blocking": "نطاق محظور",
|
||||
"account.edit_profile": "تعديل الملف الشخصي",
|
||||
"account.enable_notifications": "أشعرني عندما ينشر @{name}",
|
||||
"account.endorse": "أوصِ به على صفحتك الشخصية",
|
||||
"account.familiar_followers_many": "يتبعه {name1}، {name2} و{othersCount, plural, one {شخص آخر تعرفه} other {# أشخاص آخرون تعرفهم}}",
|
||||
"account.familiar_followers_one": "يتبعه {name1}",
|
||||
"account.familiar_followers_two": "يتبعه {name1} و {name2}",
|
||||
"account.featured": "معروض",
|
||||
"account.featured.accounts": "ملفات شخصية",
|
||||
"account.featured.hashtags": "هاشتاقات",
|
||||
"account.featured_tags.last_status_at": "آخر منشور في {date}",
|
||||
"account.featured_tags.last_status_never": "لا توجد رسائل",
|
||||
"account.follow": "متابعة",
|
||||
|
@ -33,9 +43,11 @@
|
|||
"account.followers": "مُتابِعون",
|
||||
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
|
||||
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
|
||||
"account.followers_you_know_counter": "{counter} شخص تعرفه",
|
||||
"account.following": "الاشتراكات",
|
||||
"account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
|
||||
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
|
||||
"account.follows_you": "يتابعك",
|
||||
"account.go_to_profile": "اذهب إلى الملف الشخصي",
|
||||
"account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
|
||||
"account.in_memoriam": "في الذكرى.",
|
||||
|
@ -50,17 +62,23 @@
|
|||
"account.mute_notifications_short": "كتم الإشعارات",
|
||||
"account.mute_short": "اكتم",
|
||||
"account.muted": "مَكتوم",
|
||||
"account.muting": "مكتوم",
|
||||
"account.mutual": "أنتم تتابعون بعضكم البعض",
|
||||
"account.no_bio": "لم يتم تقديم وصف.",
|
||||
"account.open_original_page": "افتح الصفحة الأصلية",
|
||||
"account.posts": "منشورات",
|
||||
"account.posts_with_replies": "المنشورات والرُدود",
|
||||
"account.remove_from_followers": "إزالة {name} من المتابعين",
|
||||
"account.report": "الإبلاغ عن @{name}",
|
||||
"account.requested": "في انتظار القبول. اضغط لإلغاء طلب المُتابعة",
|
||||
"account.requested_follow": "لقد طلب {name} متابعتك",
|
||||
"account.requests_to_follow_you": "طلبات المتابعة",
|
||||
"account.share": "شارِك الملف التعريفي لـ @{name}",
|
||||
"account.show_reblogs": "اعرض إعادات نشر @{name}",
|
||||
"account.statuses_counter": "{count, plural, zero {}one {{counter} مشور} two {{counter} منشور} few {{counter} منشور} many {{counter} منشور} other {{counter} منشور}}",
|
||||
"account.unblock": "إلغاء الحَظر عن @{name}",
|
||||
"account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
|
||||
"account.unblock_domain_short": "رفع الحظر",
|
||||
"account.unblock_short": "ألغ الحجب",
|
||||
"account.unendorse": "لا تُرَوِّج لهُ في الملف الشخصي",
|
||||
"account.unfollow": "إلغاء المُتابعة",
|
||||
|
@ -82,9 +100,33 @@
|
|||
"alert.unexpected.message": "لقد طرأ خطأ غير متوقّع.",
|
||||
"alert.unexpected.title": "المعذرة!",
|
||||
"alt_text_badge.title": "نص بديل",
|
||||
"alt_text_modal.add_alt_text": "أضف نصًا بديلًا",
|
||||
"alt_text_modal.add_text_from_image": "أضف النص من الصورة",
|
||||
"alt_text_modal.cancel": "إلغاء",
|
||||
"alt_text_modal.change_thumbnail": "غيّر الصورة المصغرة",
|
||||
"alt_text_modal.describe_for_people_with_hearing_impairments": "قم بوصفها للأشخاص ذوي الإعاقة السمعية…",
|
||||
"alt_text_modal.describe_for_people_with_visual_impairments": "قم بوصفها للأشخاص ذوي الإعاقة البصرية…",
|
||||
"alt_text_modal.done": "تمّ",
|
||||
"announcement.announcement": "إعلان",
|
||||
"annual_report.summary.archetype.booster": "The cool-hunter",
|
||||
"annual_report.summary.archetype.lurker": "المتصفح الصامت",
|
||||
"annual_report.summary.archetype.oracle": "حكيم",
|
||||
"annual_report.summary.archetype.pollster": "مستطلع للرأي",
|
||||
"annual_report.summary.archetype.replier": "الفراشة الاجتماعية",
|
||||
"annual_report.summary.followers.followers": "المُتابِعُون",
|
||||
"annual_report.summary.followers.total": "{count} في المجمل",
|
||||
"annual_report.summary.here_it_is": "هذا ملخص الخص بك لسنة {year}:",
|
||||
"annual_report.summary.highlighted_post.by_favourites": "المنشور ذو أعلى عدد تفضيلات",
|
||||
"annual_report.summary.highlighted_post.by_reblogs": "أكثر منشور مُعاد نشره",
|
||||
"annual_report.summary.highlighted_post.by_replies": "المنشور بأعلى عدد تعليقات",
|
||||
"annual_report.summary.highlighted_post.possessive": "من قبل {name}",
|
||||
"annual_report.summary.most_used_app.most_used_app": "التطبيق الأكثر استخداماً",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "الهاشتاق الأكثر استخداماً",
|
||||
"annual_report.summary.most_used_hashtag.none": "لا شيء",
|
||||
"annual_report.summary.new_posts.new_posts": "المنشورات الجديدة",
|
||||
"annual_report.summary.percentile.text": "<topLabel>هذا يجعلك من بين أكثر </topLabel><percentage></percentage><bottomLabel>مستخدمي {domain} نشاطاً </bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "سيبقى هذا الأمر بيننا.",
|
||||
"annual_report.summary.thanks": "شكرا لكونك جزءاً من ماستدون!",
|
||||
"attachments_list.unprocessed": "(غير معالَج)",
|
||||
"audio.hide": "إخفاء المقطع الصوتي",
|
||||
"block_modal.remote_users_caveat": "سوف نطلب من الخادم {domain} أن يحترم قرارك، لكن الالتزام غير مضمون لأن بعض الخواديم قد تتعامل مع نصوص الكتل بشكل مختلف. قد تظل المنشورات العامة مرئية للمستخدمين غير المسجلين الدخول.",
|
||||
|
@ -108,6 +150,7 @@
|
|||
"bundle_column_error.routing.body": "تعذر العثور على الصفحة المطلوبة. هل أنت متأكد من أنّ الرابط التشعبي URL في شريط العناوين صحيح؟",
|
||||
"bundle_column_error.routing.title": "404",
|
||||
"bundle_modal_error.close": "إغلاق",
|
||||
"bundle_modal_error.message": "حدث خطأ أثناء تحميل هذه الشاشة.",
|
||||
"bundle_modal_error.retry": "إعادة المُحاولة",
|
||||
"closed_registrations.other_server_instructions": "بما أن ماستدون لامركزي، يمكنك إنشاء حساب على خادم آخر للاستمرار في التفاعل مع هذا الخادم.",
|
||||
"closed_registrations_modal.description": "لا يمكن إنشاء حساب على {domain} حاليا، ولكن على فكرة لست بحاجة إلى حساب على {domain} بذاته لاستخدام ماستدون.",
|
||||
|
@ -141,7 +184,6 @@
|
|||
"column_header.show_settings": "إظهار الإعدادات",
|
||||
"column_header.unpin": "إلغاء التَّثبيت",
|
||||
"column_search.cancel": "إلغاء",
|
||||
"column_subheading.settings": "الإعدادات",
|
||||
"community.column_settings.local_only": "المحلي فقط",
|
||||
"community.column_settings.media_only": "الوسائط فقط",
|
||||
"community.column_settings.remote_only": "عن بُعد فقط",
|
||||
|
@ -177,21 +219,32 @@
|
|||
"confirmations.delete_list.confirm": "حذف",
|
||||
"confirmations.delete_list.message": "هل أنتَ مُتأكدٌ أنكَ تُريدُ حَذفَ هذِهِ القائمة بشكلٍ دائم؟",
|
||||
"confirmations.delete_list.title": "أتريد حذف القائمة؟",
|
||||
"confirmations.discard_draft.confirm": "تجاهل ومتابعة",
|
||||
"confirmations.discard_draft.edit.cancel": "استئناف التعديل",
|
||||
"confirmations.discard_draft.edit.message": "سيتم تجاهل أي تغييرات قمت بها على هذا المنشور.",
|
||||
"confirmations.discard_draft.edit.title": "تجاهل التغييرات على منشورك؟",
|
||||
"confirmations.discard_draft.post.cancel": "استئناف المسودة",
|
||||
"confirmations.discard_draft.post.message": "عبر الاستمرار سيتم تجاهل المنشور الذي تقوم بكتابته الآن.",
|
||||
"confirmations.discard_draft.post.title": "تجاهل مسودة منشورك؟",
|
||||
"confirmations.discard_edit_media.confirm": "تجاهل",
|
||||
"confirmations.discard_edit_media.message": "لديك تغييرات غير محفوظة لوصف الوسائط أو معاينتها، أتريد تجاهلها على أي حال؟",
|
||||
"confirmations.edit.confirm": "تعديل",
|
||||
"confirmations.edit.message": "التعديل في الحين سوف يُعيد كتابة الرسالة التي أنت بصدد تحريرها. متأكد من أنك تريد المواصلة؟",
|
||||
"confirmations.edit.title": "هل تريد استبدال المنشور؟",
|
||||
"confirmations.follow_to_list.confirm": "متابعة وأضفه للقائمة",
|
||||
"confirmations.follow_to_list.message": "يجب أن تتابع {name} لإضافتهم إلى قائمة.",
|
||||
"confirmations.follow_to_list.title": "متابعة المستخدم؟",
|
||||
"confirmations.logout.confirm": "خروج",
|
||||
"confirmations.logout.message": "متأكد من أنك تريد الخروج؟",
|
||||
"confirmations.logout.title": "أتريد المغادرة؟",
|
||||
"confirmations.missing_alt_text.confirm": "أضف نصًا بديلًا",
|
||||
"confirmations.missing_alt_text.message": "يحتوي منشورك على وسائط دون نص بديل. إضافة أوصاف تساعد على جعل المحتوى متاحاً للمزيد من الأشخاص.",
|
||||
"confirmations.missing_alt_text.secondary": "انشر على أي حال",
|
||||
"confirmations.missing_alt_text.title": "أضف نصًا بديلًا؟",
|
||||
"confirmations.mute.confirm": "أكتم",
|
||||
"confirmations.redraft.confirm": "إزالة وإعادة الصياغة",
|
||||
"confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته؟ سوف تفقد جميع الإعجابات و الترقيات أما الردود المتصلة به فستُصبِح يتيمة.",
|
||||
"confirmations.redraft.title": "أتريد حذف وإعادة صياغة المنشور؟",
|
||||
"confirmations.reply.confirm": "رد",
|
||||
"confirmations.reply.message": "الرد في الحين سوف يُعيد كتابة الرسالة التي أنت بصدد كتابتها. متأكد من أنك تريد المواصلة؟",
|
||||
"confirmations.reply.title": "هل تريد استبدال المنشور؟",
|
||||
"confirmations.remove_from_followers.confirm": "إزالة المتابع",
|
||||
"confirmations.remove_from_followers.message": "سيتوقف {name} عن متابعتك. هل بالتأكيد تريد المتابعة؟",
|
||||
"confirmations.remove_from_followers.title": "إزالة المتابع؟",
|
||||
"confirmations.unfollow.confirm": "إلغاء المتابعة",
|
||||
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
|
||||
"confirmations.unfollow.title": "إلغاء متابعة المستخدم؟",
|
||||
|
@ -213,12 +266,15 @@
|
|||
"disabled_account_banner.text": "حسابك {disabledAccount} معطل حاليا.",
|
||||
"dismissable_banner.community_timeline": "هذه هي أحدث المنشورات العامة من أشخاص تُستضاف حساباتهم على {domain}.",
|
||||
"dismissable_banner.dismiss": "رفض",
|
||||
"dismissable_banner.public_timeline": "هذه أحدث المنشورات العامة على الشبكة الفيدرالية التي يتابعها مستخدمي نطاق {domain}.",
|
||||
"domain_block_modal.block": "حظر الخادم",
|
||||
"domain_block_modal.block_account_instead": "أحجب @{name} بدلاً من ذلك",
|
||||
"domain_block_modal.they_can_interact_with_old_posts": "يمكن للأشخاص من هذا الخادم التفاعل مع منشوراتك القديمة.",
|
||||
"domain_block_modal.they_cant_follow": "لا أحد من هذا الخادم يمكنه متابعتك.",
|
||||
"domain_block_modal.they_wont_know": "لن يَعرف أنه قد تم حظره.",
|
||||
"domain_block_modal.title": "أتريد حظر النطاق؟",
|
||||
"domain_block_modal.you_will_lose_num_followers": "ستخسر {followersCount, plural, zero {}one {{followersCountDisplay} متابع} two {{followersCountDisplay} متابع} few {{followersCountDisplay} متابعين} many {{followersCountDisplay} متابعين} other {{followersCountDisplay} متابعين}} و {followingCount, plural, zero {}one {{followingCountDisplay} شخص تتابعه} two {{followingCountDisplay} شخص تتابعهما} few {{followingCountDisplay} أشخاص تتابعهم} many {{followingCountDisplay} أشخاص تتابعهم} other {{followingCountDisplay} أشخاص تتابعهم}}.",
|
||||
"domain_block_modal.you_will_lose_relationships": "ستفقد جميع المتابعين والأشخاص الذين تتابعهم من هذا الخادم.",
|
||||
"domain_block_modal.you_wont_see_posts": "لن ترى منشورات أو إشعارات من المستخدمين على هذا الخادم.",
|
||||
"domain_pill.activitypub_lets_connect": "يتيح لك التواصل والتفاعل مع الناس ليس فقط على ماستدون، ولكن عبر تطبيقات اجتماعية مختلفة أيضا.",
|
||||
"domain_pill.activitypub_like_language": "إنّ ActivityPub مثل لغة ماستدون التي يتحدث بها مع شبكات اجتماعية أخرى.",
|
||||
|
@ -250,6 +306,9 @@
|
|||
"emoji_button.search_results": "نتائج البحث",
|
||||
"emoji_button.symbols": "رموز",
|
||||
"emoji_button.travel": "الأماكن والسفر",
|
||||
"empty_column.account_featured.me": "لم تعرض أي شيء حتى الآن. هل تعلم أنه يمكنك عرض الهاشتاقات التي تستخدمها، وحتى حسابات أصدقاءك على ملفك الشخصي؟",
|
||||
"empty_column.account_featured.other": "{acct} لم يعرض أي شيء حتى الآن. هل تعلم أنه يمكنك عرض الهاشتاقات التي تستخدمها، وحتى حسابات أصدقاءك على ملفك الشخصي؟",
|
||||
"empty_column.account_featured_other.unknown": "هذا الحساب لم يعرض أي شيء حتى الآن.",
|
||||
"empty_column.account_hides_collections": "اختار هذا المستخدم عدم إتاحة هذه المعلومات للعامة",
|
||||
"empty_column.account_suspended": "حساب معلق",
|
||||
"empty_column.account_timeline": "لا توجد منشورات هنا!",
|
||||
|
@ -278,9 +337,15 @@
|
|||
"errors.unexpected_crash.copy_stacktrace": "انسخ تتبع الارتباطات إلى الحافظة",
|
||||
"errors.unexpected_crash.report_issue": "الإبلاغ عن خلل",
|
||||
"explore.suggested_follows": "أشخاص",
|
||||
"explore.title": "رائج",
|
||||
"explore.trending_links": "المُستجدّات",
|
||||
"explore.trending_statuses": "المنشورات",
|
||||
"explore.trending_tags": "وُسُوم",
|
||||
"featured_carousel.header": "{count, plural, zero {}one {منشور معروض} two {منشور معروضَين} few {منشورات معروضة} many {منشورات معروضة} other {منشورات معروضة}}",
|
||||
"featured_carousel.next": "التالي",
|
||||
"featured_carousel.post": "منشور",
|
||||
"featured_carousel.previous": "السابق",
|
||||
"featured_carousel.slide": "{index} من {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "فئة عامل التصفية هذه لا تنطبق على السياق الذي وصلت فيه إلى هذه المشاركة. إذا كنت ترغب في تصفية المنشور في هذا السياق أيضا، فسيتعين عليك تعديل عامل التصفية.",
|
||||
"filter_modal.added.context_mismatch_title": "عدم تطابق السياق!",
|
||||
"filter_modal.added.expired_explanation": "انتهت صلاحية فئة عامل التصفية هذه، سوف تحتاج إلى تغيير تاريخ انتهاء الصلاحية لتطبيقها.",
|
||||
|
@ -297,6 +362,8 @@
|
|||
"filter_modal.select_filter.subtitle": "استخدم فئة موجودة أو قم بإنشاء فئة جديدة",
|
||||
"filter_modal.select_filter.title": "تصفية هذا المنشور",
|
||||
"filter_modal.title.status": "تصفية منشور",
|
||||
"filter_warning.matches_filter": "يطابق عامل التصفية “<span>{title}</span>”",
|
||||
"filtered_notifications_banner.pending_requests": "من {count, plural, zero {}=0 {لا أحد} one {شخص واحد قد تعرفه} two {# شخص قد تعرفهما} few {# أشخاص قد تعرفهم} many {# أشخاص قد تعرفهم} other {# أشخاص قد تعرفهم}}",
|
||||
"filtered_notifications_banner.title": "الإشعارات المصفاة",
|
||||
"firehose.all": "الكل",
|
||||
"firehose.local": "هذا الخادم",
|
||||
|
@ -330,6 +397,9 @@
|
|||
"footer.terms_of_service": "شروط الخدمة",
|
||||
"generic.saved": "تم الحفظ",
|
||||
"getting_started.heading": "استعدّ للبدء",
|
||||
"hashtag.admin_moderation": "افتح الواجهة الإشراف لـ #{name}",
|
||||
"hashtag.browse": "تصفح المنشورات التي تحتوي #{hashtag}",
|
||||
"hashtag.browse_from_account": "تصفح المنشورات من @{name} التي تحتوي على #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "و {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "أو {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "بدون {additional}",
|
||||
|
@ -342,13 +412,21 @@
|
|||
"hashtag.counter_by_accounts": "{count, plural, zero {لَا مُشارك} one {مُشارَك واحد} two {مُشارِكان إثنان} few {{counter} مشاركين} many {{counter} مُشاركًا} other {{counter} مُشارِك}}",
|
||||
"hashtag.counter_by_uses": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
|
||||
"hashtag.feature": "اعرضه على صفحتك الشخصية",
|
||||
"hashtag.follow": "اتبع الوسم",
|
||||
"hashtag.mute": "اكتم #{hashtag}",
|
||||
"hashtag.unfeature": "أزله من العرض على الملف الشخصي",
|
||||
"hashtag.unfollow": "ألغِ متابعة الوسم",
|
||||
"hashtags.and_other": "…و {count, plural, zero {} one {# واحد آخر} two {# اثنان آخران} few {# آخرون} many {# آخَرًا}other {# آخرون}}",
|
||||
"hints.profiles.followers_may_be_missing": "قد يكون الأشخاص الذي يتبعهم هذا الملف الشخصي ناقصين.",
|
||||
"hints.profiles.follows_may_be_missing": "قد يكون المتابعين لهذا الملف الشخصي ناقصين.",
|
||||
"hints.profiles.posts_may_be_missing": "قد تكون بعض المنشورات من هذا الملف الشخصي ناقصة.",
|
||||
"hints.profiles.see_more_followers": "عرض المزيد من المتابعين على {domain}",
|
||||
"hints.profiles.see_more_follows": "اطلع على المزيد من المتابعين على {domain}",
|
||||
"hints.profiles.see_more_posts": "عرض المزيد من المنشورات من {domain}",
|
||||
"hints.threads.replies_may_be_missing": "قد تكون الردود الواردة من الخوادم الأخرى غائبة.",
|
||||
"hints.threads.see_more": "اطلع على المزيد من الردود على {domain}",
|
||||
"home.column_settings.show_quotes": "إظهار الاقتباسات",
|
||||
"home.column_settings.show_reblogs": "اعرض المعاد نشرها",
|
||||
"home.column_settings.show_replies": "اعرض الردود",
|
||||
"home.hide_announcements": "إخفاء الإعلانات",
|
||||
|
@ -358,9 +436,23 @@
|
|||
"home.show_announcements": "إظهار الإعلانات",
|
||||
"ignore_notifications_modal.disclaimer": "لا يمكن لـ Mastodon إبلاغ المستخدمين بأنك قد تجاهلت إشعاراتهم. تجاهل الإشعارات لن يمنع إرسال الرسائل نفسها.",
|
||||
"ignore_notifications_modal.filter_instead": "تصفيتها بدلا من ذلك",
|
||||
"ignore_notifications_modal.filter_to_act_users": "ستبقى قادراً على قبول المستخدمين أو رفضهم أو الإبلاغ عنهم",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "التصفية تساعد على تجنب أي ارتباك",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "يمكنك مراجعة الإشعارات المصفاة بشكل منفصل",
|
||||
"ignore_notifications_modal.ignore": "تجاهل الإشعارات",
|
||||
"ignore_notifications_modal.limited_accounts_title": "تجاهل الإشعارات من الحسابات التي هي تحت الإشراف؟",
|
||||
"ignore_notifications_modal.new_accounts_title": "تجاهل الإشعارات الصادرة من الحسابات الجديدة؟",
|
||||
"ignore_notifications_modal.not_followers_title": "تجاهل الإشعارات من أشخاص لا يتابعونك؟",
|
||||
"ignore_notifications_modal.not_following_title": "تجاهل الإشعارات من أشخاص لا تتابعهم؟",
|
||||
"ignore_notifications_modal.private_mentions_title": "تجاهل الإشعارات للرسائل التي لم تطلبها؟",
|
||||
"info_button.label": "المساعدة",
|
||||
"info_button.what_is_alt_text": "<h1> ماهو النص البديل؟</h1><p> يوفر النص البديل أوصافا للصور للأشخاص الذين يعانون من إعاقات بصرية أو اتصالات شبكة ضعيفة أو أولئك الذين يبحثون عن سياق إضافي.</p><p> يمكنك تحسين إمكانية الوصول والفهم للجميع من خلال كتابة نص بديل واضح وموجز وموضوعي. </p><ul><li> حدد العناصر المهمة</li><li>لخص النص في الصور</li><li>استخدام بنية الجمل العادية</li><li>تجنب المعلومات الزائدة</li><li> ركز على الاتجاهات والنتائج الرئيسية في العناصر المرئية المعقدة (مثل الرسوم البيانية أو الخرائط)</li></ul>",
|
||||
"interaction_modal.action.favourite": "للمتابعة، تحتاج إلى تفضيل المنشور من حسابك.",
|
||||
"interaction_modal.action.follow": "للمتابعة، تحتاج إلى متابعة المنشور من حسابك.",
|
||||
"interaction_modal.action.reblog": "للمتابعة، تحتاج إلى إعادة نشر المنشور من حسابك.",
|
||||
"interaction_modal.action.reply": "للمتابعة، تحتاج إلى الرد من حسابك.",
|
||||
"interaction_modal.action.vote": "للمتابعة، تحتاج إلى التصويت من حسابك.",
|
||||
"interaction_modal.go": "اذهب",
|
||||
"interaction_modal.no_account_yet": "لا تملك حساباً بعد؟",
|
||||
"interaction_modal.on_another_server": "على خادم مختلف",
|
||||
"interaction_modal.on_this_server": "على هذا الخادم",
|
||||
|
@ -368,6 +460,8 @@
|
|||
"interaction_modal.title.follow": "اتبع {name}",
|
||||
"interaction_modal.title.reblog": "إعادة نشر منشور {name}",
|
||||
"interaction_modal.title.reply": "الرد على منشور {name}",
|
||||
"interaction_modal.title.vote": "صوّت في استطلاع {name}",
|
||||
"interaction_modal.username_prompt": "مثلاً {example}",
|
||||
"intervals.full.days": "{number, plural, one {# يوم} other {# أيام}}",
|
||||
"intervals.full.hours": "{number, plural, one {# ساعة} other {# ساعات}}",
|
||||
"intervals.full.minutes": "{number, plural, one {دقيقة واحدة}two {دقيقتان} other {# دقائق}}",
|
||||
|
@ -403,30 +497,44 @@
|
|||
"keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط",
|
||||
"keyboard_shortcuts.toot": "للشروع في تحرير منشور جديد",
|
||||
"keyboard_shortcuts.translate": "لترجمة منشور",
|
||||
"keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث",
|
||||
"keyboard_shortcuts.up": "للانتقال إلى أعلى القائمة",
|
||||
"lightbox.close": "إغلاق",
|
||||
"lightbox.next": "التالي",
|
||||
"lightbox.previous": "العودة",
|
||||
"lightbox.zoom_in": "التكبير إلى الحجم الفعلي",
|
||||
"lightbox.zoom_out": "التكبير ليناسب الحجم",
|
||||
"limited_account_hint.action": "إظهار الملف التعريفي على أي حال",
|
||||
"limited_account_hint.title": "تم إخفاء هذا الملف الشخصي من قبل مشرفي {domain}.",
|
||||
"link_preview.author": "مِن {name}",
|
||||
"link_preview.more_from_author": "المزيد من {name}",
|
||||
"link_preview.shares": "{count, plural, zero {{counter} منشور}one {{counter} منشور} two {{counter} منشور} few {{counter} منشور} many {{counter} منشور} other {{counter} منشور}}",
|
||||
"lists.add_member": "إضافة",
|
||||
"lists.add_to_list": "إضافة إلى القائمة",
|
||||
"lists.add_to_lists": "إضافة {name} إلى القوائم",
|
||||
"lists.create": "إنشاء",
|
||||
"lists.create_a_list_to_organize": "أنشئ قائمة جديدة لتنظم الصفحة الرئيسة خاصتك",
|
||||
"lists.create_list": "إنشاء قائمة",
|
||||
"lists.delete": "احذف القائمة",
|
||||
"lists.done": "تمّ",
|
||||
"lists.edit": "عدّل القائمة",
|
||||
"lists.exclusive": "إخفاء الأعضاء في الصفحة الرئيسية",
|
||||
"lists.exclusive_hint": "إذا يوجد شخص في هذه القائمة، فقم بإخفائه في صفحتك الرئيسة لتجنب رؤية منشوراته مرتين.",
|
||||
"lists.find_users_to_add": "ابحث عن مستخدمين للإضافة",
|
||||
"lists.list_members_count": "{count, plural, zero {}one {# عضو} two {# عضو} few {# عضو} many {# عضو} other {# عضو}}",
|
||||
"lists.list_name": "اسم القائمة",
|
||||
"lists.new_list_name": "اسم القائمة الجديدة",
|
||||
"lists.no_lists_yet": "لا توجد قوائم بعد.",
|
||||
"lists.no_members_yet": "لا أعضاء حتى الآن.",
|
||||
"lists.no_results_found": "لم يتمّ العثور على أي نتيجة.",
|
||||
"lists.remove_member": "إزالة",
|
||||
"lists.replies_policy.followed": "أي مستخدم متابَع",
|
||||
"lists.replies_policy.list": "أعضاء القائمة",
|
||||
"lists.replies_policy.none": "لا أحد",
|
||||
"lists.save": "حفظ",
|
||||
"lists.search": "بحث",
|
||||
"lists.show_replies_to": "تضمين الردود من أعضاء القائمة إلى",
|
||||
"load_pending": "{count, plural, one {# عنصر جديد} other {# عناصر جديدة}}",
|
||||
"loading_indicator.label": "جاري التحميل…",
|
||||
"media_gallery.hide": "إخفاء",
|
||||
|
@ -441,38 +549,54 @@
|
|||
"mute_modal.you_wont_see_mentions": "لن تر المنشورات التي يُشار فيها إليه.",
|
||||
"mute_modal.you_wont_see_posts": "سيكون بإمكانه رؤية منشوراتك، لكنك لن ترى منشوراته.",
|
||||
"navigation_bar.about": "عن",
|
||||
"navigation_bar.account_settings": "كلمة المرور والأمان",
|
||||
"navigation_bar.administration": "الإدارة",
|
||||
"navigation_bar.advanced_interface": "افتحه في واجهة الويب المتقدمة",
|
||||
"navigation_bar.automated_deletion": "الحذف الآلي للمنشورات",
|
||||
"navigation_bar.blocks": "الحسابات المحجوبة",
|
||||
"navigation_bar.bookmarks": "الفواصل المرجعية",
|
||||
"navigation_bar.community_timeline": "الخيط المحلي",
|
||||
"navigation_bar.compose": "تحرير منشور جديد",
|
||||
"navigation_bar.direct": "الإشارات الخاصة",
|
||||
"navigation_bar.discover": "اكتشف",
|
||||
"navigation_bar.domain_blocks": "النطاقات المحظورة",
|
||||
"navigation_bar.explore": "استكشف",
|
||||
"navigation_bar.favourites": "المفضلة",
|
||||
"navigation_bar.filters": "الكلمات المكتومة",
|
||||
"navigation_bar.follow_requests": "طلبات المتابعة",
|
||||
"navigation_bar.followed_tags": "الوسوم المتابَعة",
|
||||
"navigation_bar.follows_and_followers": "المتابِعون والمتابَعون",
|
||||
"navigation_bar.import_export": "الاستيراد والتصدير",
|
||||
"navigation_bar.lists": "القوائم",
|
||||
"navigation_bar.live_feed_local": "البث الحي للمنشورات المحلية",
|
||||
"navigation_bar.live_feed_public": "البث الحي للمنشورات العالمية",
|
||||
"navigation_bar.logout": "خروج",
|
||||
"navigation_bar.moderation": "الإشراف",
|
||||
"navigation_bar.more": "المزيد",
|
||||
"navigation_bar.mutes": "الحسابات المكتومة",
|
||||
"navigation_bar.opened_in_classic_interface": "تُفتَح المنشورات والحسابات وغيرها من الصفحات الخاصة بشكل مبدئي على واجهة الويب التقليدية.",
|
||||
"navigation_bar.personal": "شخصي",
|
||||
"navigation_bar.pins": "المنشورات المُثَبَّتَة",
|
||||
"navigation_bar.preferences": "التفضيلات",
|
||||
"navigation_bar.public_timeline": "الخيط الفيدرالي",
|
||||
"navigation_bar.privacy_and_reach": "الخصوصية و الوصول",
|
||||
"navigation_bar.search": "البحث",
|
||||
"navigation_bar.security": "الأمان",
|
||||
"navigation_bar.search_trends": "البحث / الرائج",
|
||||
"navigation_panel.collapse_followed_tags": "طي قائمة الهاشتاقات المتابعة",
|
||||
"navigation_panel.collapse_lists": "طي قائمة القائمة",
|
||||
"navigation_panel.expand_followed_tags": "توسيع قائمة الهاشتاقات المتابعة",
|
||||
"navigation_panel.expand_lists": "توسيع قائمة القائمة",
|
||||
"not_signed_in_indicator.not_signed_in": "تحتاج إلى تسجيل الدخول للوصول إلى هذا المصدر.",
|
||||
"notification.admin.report": "{name} أبلغ عن {target}",
|
||||
"notification.admin.report_account": "{name} أبلغ عن {count, plural, zero {}one {منشور} two {منشورين} few {# منشورات} many {# منشورات} other {# منشورات}} من قبل {target} بسبب {category}",
|
||||
"notification.admin.report_account_other": "{name} أبلغ عن {count, plural, zero {}one {منشور} two {منشورين} few {# منشورات} many {# منشورات} other {# منشورات}} من قبل {target}",
|
||||
"notification.admin.report_statuses": "{name} أبلغ عن {target} بسبب {category}",
|
||||
"notification.admin.report_statuses_other": "{name} أبلغ عن {target}",
|
||||
"notification.admin.sign_up": "أنشأ {name} حسابًا",
|
||||
"notification.admin.sign_up.name_and_others": "{name} و{count, plural, zero {}one {شخص آخر قاما} two {# آخرون قاموا} few {# آخرون قاموا} many {# آخرون قاموا} other {# آخرون قاموا}} بالتسجيل",
|
||||
"notification.annual_report.message": "إن #Wrapstodon الخاص بك لسنة {year} ينتظرك! تعرّف إلى النقاط البارزة واللحظات التي لا تنسى على ماستدون!",
|
||||
"notification.annual_report.view": "عرض #Wrapstodon",
|
||||
"notification.favourite": "أضاف {name} منشورك إلى مفضلته",
|
||||
"notification.favourite.name_and_others_with_link": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بتفضيل منشورك",
|
||||
"notification.favourite_pm": "قام {name} بتفضيل إشارتك الخاصة",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرَين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بتفضيل إشارتك الخاصة",
|
||||
"notification.follow": "يتابعك {name}",
|
||||
"notification.follow.name_and_others": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بمتابعتك",
|
||||
"notification.follow_request": "لقد طلب {name} متابعتك",
|
||||
"notification.follow_request.name_and_others": "{name} و{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}} أرسلوا طلب متابعة لك",
|
||||
"notification.label.mention": "إشارة",
|
||||
"notification.label.private_mention": "إشارة خاصة",
|
||||
"notification.label.private_reply": "رد خاص",
|
||||
|
@ -491,6 +615,7 @@
|
|||
"notification.own_poll": "انتهى استطلاعك للرأي",
|
||||
"notification.poll": "لقد انتهى استطلاع رأي صوتت فيه",
|
||||
"notification.reblog": "قام {name} بمشاركة منشورك",
|
||||
"notification.reblog.name_and_others_with_link": "{name} و<a>{count, plural, zero {}one {شخص آخر} two {شخصان آخرين} few {# أشخاص آخرون} many {# أشخاص آخرون} other {# أشخاص آخرون}}</a> قاموا بإعادة نشر منشورك",
|
||||
"notification.relationships_severance_event": "فقدت الاتصالات مع {name}",
|
||||
"notification.relationships_severance_event.account_suspension": "قام مشرف من {from} بتعليق {target}، مما يعني أنك لم يعد بإمكانك تلقي التحديثات منهم أو التفاعل معهم.",
|
||||
"notification.relationships_severance_event.domain_block": "قام مشرف من {from} بحظر {target}، بما في ذلك {followersCount} من متابعينك و {followingCount, plural, one {# حساب} other {# حسابات}} تتابعها.",
|
||||
|
@ -499,12 +624,20 @@
|
|||
"notification.status": "{name} نشر للتو",
|
||||
"notification.update": "عدّلَ {name} منشورًا",
|
||||
"notification_requests.accept": "موافقة",
|
||||
"notification_requests.accept_multiple": "قبول {count, plural, zero {}one {طلب واحد…} two {# طلب…} few {# طلبات…} many {# طلبات…} other {# طلبات…}}",
|
||||
"notification_requests.confirm_accept_multiple.button": "قبول {count, plural, zero {}one {الطلب} two {2 طلب} few {الطلبات} many {الطلبات} other {الطلبات}}",
|
||||
"notification_requests.confirm_accept_multiple.message": "أنت على وشك قبول {count, plural, zero {}one {طلب إشعار واحد} two {# طلبات إشعار} few {# طلبات إشعار} many {# طلبات إشعار} other {# طلبات إشعار}}. هل أنت متأكد من أنك تريد المتابعة؟",
|
||||
"notification_requests.confirm_accept_multiple.title": "قبول طلبات الإشعار؟",
|
||||
"notification_requests.confirm_dismiss_multiple.button": "رفض {count, plural, zero {}one {الطلب} two {2 طلب} few {الطلبات} many {الطلبات} other {الطلبات}}",
|
||||
"notification_requests.confirm_dismiss_multiple.message": "أنت على وشك رفض {count, plural, zero {}one {طلب إشعار واحد} two {# طلبات إشعار} few {# طلبات إشعار} many {# طلبات إشعار} other {# طلبات إشعار}}. لن تتمكن من الوصول بسهولة {count, plural, zero {}one {إليه} two {إليهما} few {إليهم} many {إليهم} other {إليهم}} مرة أخرى. هل أنت متأكد من أنك تريد المتابعة؟",
|
||||
"notification_requests.confirm_dismiss_multiple.title": "تجاهل طلبات الإشعار؟",
|
||||
"notification_requests.dismiss": "تخطي",
|
||||
"notification_requests.dismiss_multiple": "رفض {count, plural, zero {}one {# طلب…} two {# طلب…} few {# طلبات…} many {# طلبات…} other {# طلبات…}}",
|
||||
"notification_requests.edit_selection": "تعديل",
|
||||
"notification_requests.exit_selection": "تمّ",
|
||||
"notification_requests.explainer_for_limited_account": "تم تصفية الإشعارات من هذا الحساب لأن الحساب تم تقييده من قبل مشرف.",
|
||||
"notification_requests.explainer_for_limited_remote_account": "تم تصفية الإشعارات من هذا الحساب لأنه أو لأن خادمه مقيد من قبل مشرف.",
|
||||
"notification_requests.maximize": "تكبير",
|
||||
"notification_requests.minimize_banner": "تصغير شريط الإشعارات المُصفاة",
|
||||
"notification_requests.notifications_from": "إشعارات من {name}",
|
||||
"notification_requests.title": "الإشعارات المصفاة",
|
||||
|
@ -520,6 +653,7 @@
|
|||
"notifications.column_settings.filter_bar.category": "شريط التصفية السريعة",
|
||||
"notifications.column_settings.follow": "متابعُون جُدُد:",
|
||||
"notifications.column_settings.follow_request": "الطلبات الجديدة لِمتابَعتك:",
|
||||
"notifications.column_settings.group": "قم بتجميعهم",
|
||||
"notifications.column_settings.mention": "الإشارات:",
|
||||
"notifications.column_settings.poll": "نتائج استطلاع الرأي:",
|
||||
"notifications.column_settings.push": "الإشعارات",
|
||||
|
@ -546,7 +680,9 @@
|
|||
"notifications.policy.accept": "قبول",
|
||||
"notifications.policy.accept_hint": "إظهار في الإشعارات",
|
||||
"notifications.policy.drop": "تجاهل",
|
||||
"notifications.policy.drop_hint": "التخلص منها بشكل دائم",
|
||||
"notifications.policy.filter": "تصفية",
|
||||
"notifications.policy.filter_hint": "إرسال إلى صندوق الإشعارات المصفاة",
|
||||
"notifications.policy.filter_limited_accounts_hint": "المحدودة من قبل مشرفي الخادم",
|
||||
"notifications.policy.filter_limited_accounts_title": "حسابات تحت الإشراف",
|
||||
"notifications.policy.filter_new_accounts.hint": "تم إنشاؤها منذ {days, plural, zero {}one {يوم واحد} two {يومان} few {# أيام} many {# أيام} other {# أيام}}",
|
||||
|
@ -561,7 +697,11 @@
|
|||
"notifications_permission_banner.enable": "تفعيل إشعارات سطح المكتب",
|
||||
"notifications_permission_banner.how_to_control": "لتلقي الإشعارات عندما لا يكون ماستدون مفتوح، قم بتفعيل إشعارات سطح المكتب، يمكنك التحكم بدقة في أنواع التفاعلات التي تولد إشعارات سطح المكتب من خلال زر الـ{icon} أعلاه بمجرد تفعيلها.",
|
||||
"notifications_permission_banner.title": "لا تفوت شيئاً أبداً",
|
||||
"onboarding.follows.back": "عودة",
|
||||
"onboarding.follows.done": "تمّ",
|
||||
"onboarding.follows.empty": "نأسف، لا يمكن عرض نتائج في الوقت الحالي. جرب البحث أو انتقل لصفحة الاستكشاف لإيجاد أشخاص للمتابعة، أو حاول مرة أخرى.",
|
||||
"onboarding.follows.search": "بحث",
|
||||
"onboarding.follows.title": "للبدء قم بمتابعة أشخاص",
|
||||
"onboarding.profile.discoverable": "اجعل ملفي الشخصي قابلاً للاكتشاف",
|
||||
"onboarding.profile.discoverable_hint": "عندما تختار تفعيل إمكانية الاكتشاف على ماستدون، قد تظهر منشوراتك في نتائج البحث والمواضيع الرائجة، وقد يتم اقتراح ملفك الشخصي لأشخاص ذوي اهتمامات مماثلة معك.",
|
||||
"onboarding.profile.display_name": "الاسم العلني",
|
||||
|
@ -587,6 +727,7 @@
|
|||
"poll_button.remove_poll": "إزالة استطلاع الرأي",
|
||||
"privacy.change": "اضبط خصوصية المنشور",
|
||||
"privacy.direct.long": "كل من ذُكر في المنشور",
|
||||
"privacy.direct.short": "إشارة خاصة",
|
||||
"privacy.private.long": "متابعيك فقط",
|
||||
"privacy.private.short": "للمتابِعين",
|
||||
"privacy.public.long": "أي شخص على أو خارج ماستدون",
|
||||
|
@ -598,6 +739,8 @@
|
|||
"privacy_policy.title": "سياسة الخصوصية",
|
||||
"recommended": "موصى به",
|
||||
"refresh": "أنعِش",
|
||||
"regeneration_indicator.please_stand_by": "الرجاء الانتظار.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "جارٍ إعداد صفحتك الرئيسة…",
|
||||
"relative_time.days": "{number}ي",
|
||||
"relative_time.full.days": "منذ {number, plural, zero {} one {# يوم} two {# يومين} few {# أيام} many {# أيام} other {# يوم}}",
|
||||
"relative_time.full.hours": "منذ {number, plural, zero {} one {ساعة واحدة} two {ساعتَيْن} few {# ساعات} many {# ساعة} other {# ساعة}}",
|
||||
|
@ -662,6 +805,7 @@
|
|||
"report_notification.categories.violation": "القاعدة المنتهَكة",
|
||||
"report_notification.categories.violation_sentence": "انتهاك لقاعدة",
|
||||
"report_notification.open": "فتح التقرير",
|
||||
"search.clear": "مسح البحث",
|
||||
"search.no_recent_searches": "ما من عمليات بحث تمت مؤخرًا",
|
||||
"search.placeholder": "ابحث",
|
||||
"search.quick_action.account_search": "الملفات التعريفية المطابقة لـ {x}",
|
||||
|
@ -681,14 +825,19 @@
|
|||
"search_results.accounts": "الصفحات التعريفية",
|
||||
"search_results.all": "الكل",
|
||||
"search_results.hashtags": "الوُسوم",
|
||||
"search_results.no_results": "لا توجد نتائج.",
|
||||
"search_results.no_search_yet": "حاول البحث عن المنشورات، ملفات الشخصية أو الهاشتاقات.",
|
||||
"search_results.see_all": "رؤية الكل",
|
||||
"search_results.statuses": "المنشورات",
|
||||
"search_results.title": "البحث عن \"{q}\"",
|
||||
"server_banner.about_active_users": "الأشخاص الذين يستخدمون هذا الخادم خلال الأيام الثلاثين الأخيرة (المستخدمون النشطون شهريًا)",
|
||||
"server_banner.active_users": "مستخدم نشط",
|
||||
"server_banner.administered_by": "يُديره:",
|
||||
"server_banner.is_one_of_many": "{domain} هو واحد من بين العديد من خوادم ماستدون المستقلة التي يمكنك استخدامها للمشاركة في الفديفرس.",
|
||||
"server_banner.server_stats": "إحصائيات الخادم:",
|
||||
"sign_in_banner.create_account": "أنشئ حسابًا",
|
||||
"sign_in_banner.follow_anyone": "تابع أي شخص من عالم الفدرالية وشاهد منشوراته بالترتيب الزمني. دون خوارزميات أو إعلانات أو عنواين مضللة.",
|
||||
"sign_in_banner.mastodon_is": "ماستودون هو أفضل وسيلة لمواكبة الأحداث.",
|
||||
"sign_in_banner.sign_in": "تسجيل الدخول",
|
||||
"sign_in_banner.sso_redirect": "تسجيل الدخول أو إنشاء حساب",
|
||||
"status.admin_account": "افتح الواجهة الإدارية لـ @{name}",
|
||||
|
@ -723,6 +872,13 @@
|
|||
"status.mute_conversation": "كتم المحادثة",
|
||||
"status.open": "وسّع هذا المنشور",
|
||||
"status.pin": "دبّسه على الصفحة التعريفية",
|
||||
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
|
||||
"status.quote_error.not_found": "لا يمكن عرض هذا المنشور.",
|
||||
"status.quote_error.pending_approval": "هذا المنشور ينتظر موافقة صاحب المنشور الأصلي.",
|
||||
"status.quote_error.rejected": "لا يمكن عرض هذا المنشور لأن صاحب المنشور الأصلي لا يسمح له بأن يكون مقتبس.",
|
||||
"status.quote_error.removed": "تمت إزالة المنشور من قبل صاحبه.",
|
||||
"status.quote_error.unauthorized": "لا يمكن عرض هذا المنشور لأنك لست مخولاً برؤيته.",
|
||||
"status.quote_post_author": "منشور من {name}",
|
||||
"status.read_more": "اقرأ المزيد",
|
||||
"status.reblog": "إعادة النشر",
|
||||
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
|
||||
|
@ -731,6 +887,7 @@
|
|||
"status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",
|
||||
"status.redraft": "إزالة وإعادة الصياغة",
|
||||
"status.remove_bookmark": "احذفه مِن الفواصل المرجعية",
|
||||
"status.remove_favourite": "إزالة من التفضيلات",
|
||||
"status.replied_in_thread": "رد في خيط",
|
||||
"status.replied_to": "رَدًا على {name}",
|
||||
"status.reply": "ردّ",
|
||||
|
@ -751,8 +908,13 @@
|
|||
"subscribed_languages.save": "حفظ التغييرات",
|
||||
"subscribed_languages.target": "تغيير اللغات المشتركة لـ {target}",
|
||||
"tabs_bar.home": "الرئيسية",
|
||||
"tabs_bar.menu": "القائمة",
|
||||
"tabs_bar.notifications": "الإشعارات",
|
||||
"tabs_bar.publish": "منشور جديد",
|
||||
"tabs_bar.search": "ابحث",
|
||||
"terms_of_service.effective_as_of": "مطبق اعتباراً من {date}",
|
||||
"terms_of_service.title": "شروط الخدمة",
|
||||
"terms_of_service.upcoming_changes_on": "تغييرات قادمة في تاريخ {date}",
|
||||
"time_remaining.days": "{number, plural, one {# يوم} other {# أيام}} متبقية",
|
||||
"time_remaining.hours": "{number, plural, one {# ساعة} other {# ساعات}} متبقية",
|
||||
"time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية",
|
||||
|
@ -768,6 +930,11 @@
|
|||
"upload_button.label": "إضافة وسائط",
|
||||
"upload_error.limit": "لقد تم بلوغ الحد الأقصى المسموح به لإرسال الملفات.",
|
||||
"upload_error.poll": "لا يمكن إدراج ملفات في استطلاعات الرأي.",
|
||||
"upload_form.drag_and_drop.instructions": "لحمل مرفق، اضغط على space أو Enter. وفي أثناء السحب، استخدم مفاتيح الأسهم لتنقل المرفق في أية اتجاه. اضغط على Space أو Enter مجدداً لتنقل المرفق إلى موضعه الجديد، أو اضغط Escape للإلغاء.",
|
||||
"upload_form.drag_and_drop.on_drag_cancel": "تم إلغاء السحب. تم إسقاط مرفقات الوسائط {item}.",
|
||||
"upload_form.drag_and_drop.on_drag_end": "تم إضافة المرفق {item}.",
|
||||
"upload_form.drag_and_drop.on_drag_over": "تم نقل مرفق الوسائط {item}.",
|
||||
"upload_form.drag_and_drop.on_drag_start": "تم إضافة المرفق {item}.",
|
||||
"upload_form.edit": "تعديل",
|
||||
"upload_progress.label": "يرفع...",
|
||||
"upload_progress.processing": "تتم المعالجة…",
|
||||
|
@ -778,6 +945,12 @@
|
|||
"video.expand": "توسيع الفيديو",
|
||||
"video.fullscreen": "ملء الشاشة",
|
||||
"video.hide": "إخفاء الفيديو",
|
||||
"video.mute": "كتم",
|
||||
"video.pause": "إيقاف مؤقت",
|
||||
"video.play": "تشغيل"
|
||||
"video.play": "تشغيل",
|
||||
"video.skip_backward": "تخطى إلى الوراء",
|
||||
"video.skip_forward": "تخطي للأمام",
|
||||
"video.unmute": "إلغاء الكتم",
|
||||
"video.volume_down": "خفض الصوت",
|
||||
"video.volume_up": "رفع الصوت"
|
||||
}
|
||||
|
|
|
@ -121,7 +121,6 @@
|
|||
"column_header.show_settings": "Amosar la configuración",
|
||||
"column_header.unpin": "Lliberar",
|
||||
"column_search.cancel": "Encaboxar",
|
||||
"column_subheading.settings": "Configuración",
|
||||
"community.column_settings.media_only": "Namás el conteníu multimedia",
|
||||
"community.column_settings.remote_only": "Namás lo remoto",
|
||||
"compose.language.change": "Camudar la llingua",
|
||||
|
@ -147,8 +146,6 @@
|
|||
"confirmations.delete_list.message": "¿De xuru que quies desaniciar permanentemente esta llista?",
|
||||
"confirmations.delete_list.title": "¿Quies desaniciar la llista?",
|
||||
"confirmations.discard_edit_media.confirm": "Escartar",
|
||||
"confirmations.edit.confirm": "Editar",
|
||||
"confirmations.edit.message": "La edición va sobrescribir el mensaxe que tas escribiendo. ¿De xuru que quies siguir?",
|
||||
"confirmations.follow_to_list.title": "¿Siguir al usuariu?",
|
||||
"confirmations.logout.confirm": "Zarrar la sesión",
|
||||
"confirmations.logout.message": "¿De xuru que quies zarrar la sesión?",
|
||||
|
@ -157,8 +154,6 @@
|
|||
"confirmations.missing_alt_text.title": "¿Quies amestar testu alternativu?",
|
||||
"confirmations.redraft.confirm": "Desaniciar y reeditar",
|
||||
"confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?",
|
||||
"confirmations.reply.confirm": "Responder",
|
||||
"confirmations.reply.message": "Responder agora va sobrescribir el mensaxe que tas componiendo anguaño. ¿De xuru que quies siguir?",
|
||||
"confirmations.unfollow.confirm": "Dexar de siguir",
|
||||
"confirmations.unfollow.message": "¿De xuru que quies dexar de siguir a {name}?",
|
||||
"confirmations.unfollow.title": "¿Dexar de siguir al usuariu?",
|
||||
|
@ -342,10 +337,8 @@
|
|||
"navigation_bar.about": "Tocante a",
|
||||
"navigation_bar.blocks": "Perfiles bloquiaos",
|
||||
"navigation_bar.bookmarks": "Marcadores",
|
||||
"navigation_bar.community_timeline": "Llinia de tiempu llocal",
|
||||
"navigation_bar.direct": "Menciones privaes",
|
||||
"navigation_bar.domain_blocks": "Dominios bloquiaos",
|
||||
"navigation_bar.explore": "Esploración",
|
||||
"navigation_bar.favourites": "Favoritos",
|
||||
"navigation_bar.filters": "Pallabres desactivaes",
|
||||
"navigation_bar.follow_requests": "Solicitúes de siguimientu",
|
||||
|
@ -356,11 +349,7 @@
|
|||
"navigation_bar.moderation": "Moderación",
|
||||
"navigation_bar.mutes": "Perfiles colos avisos desactivaos",
|
||||
"navigation_bar.opened_in_classic_interface": "Los artículos, les cuentes y otres páxines específiques ábrense por defeutu na interfaz web clásica.",
|
||||
"navigation_bar.personal": "Personal",
|
||||
"navigation_bar.pins": "Artículos fixaos",
|
||||
"navigation_bar.preferences": "Preferencies",
|
||||
"navigation_bar.public_timeline": "Llinia de tiempu federada",
|
||||
"navigation_bar.security": "Seguranza",
|
||||
"not_signed_in_indicator.not_signed_in": "Tienes d'aniciar la sesión p'acceder a esti recursu.",
|
||||
"notification.admin.report": "{name} informó de: {target}",
|
||||
"notification.admin.sign_up": "{name} rexistróse",
|
||||
|
|
|
@ -169,7 +169,6 @@
|
|||
"column_header.show_settings": "Parametrləri göstər",
|
||||
"column_header.unpin": "Bərkitmə",
|
||||
"column_search.cancel": "İmtina",
|
||||
"column_subheading.settings": "Parametrlər",
|
||||
"community.column_settings.local_only": "Sadəcə lokalda",
|
||||
"community.column_settings.media_only": "Sadəcə media",
|
||||
"community.column_settings.remote_only": "Sadəcə uzaq serverlər",
|
||||
|
@ -207,9 +206,6 @@
|
|||
"confirmations.delete_list.title": "Siyahı silinsin?",
|
||||
"confirmations.discard_edit_media.confirm": "Ləğv et",
|
||||
"confirmations.discard_edit_media.message": "Media təsvirində və ya önizləmədə yadda saxlanmamış dəyişiklikləriniz var, ləğv edilsin?",
|
||||
"confirmations.edit.confirm": "Redaktə et",
|
||||
"confirmations.edit.message": "Redaktə etmək hazırda tərtib etdiyiniz mesajın üzərinə yazacaq. Davam etmək istədiyinizə əminsiniz?",
|
||||
"confirmations.edit.title": "Paylaşım yenidə yazılsın?",
|
||||
"confirmations.follow_to_list.confirm": "İzlə və siyahıya əlavə et",
|
||||
"confirmations.follow_to_list.message": "{name} istifadəçisini siyahıya əlavə etmək üçün onu izləməlisiniz.",
|
||||
"confirmations.follow_to_list.title": "İstifadəçini izlə?",
|
||||
|
@ -224,9 +220,6 @@
|
|||
"confirmations.redraft.confirm": "Sil və qaralamaya köçür",
|
||||
"confirmations.redraft.message": "Bu paylaşımı silmək və qaralamaya köçürmək istədiyinizə əminsiniz? Bəyənmələr və gücləndirmələr itəcək və orijinal paylaşıma olan cavablar tənha qalacaq.",
|
||||
"confirmations.redraft.title": "Paylaşım silinsin & qaralamaya köçürülsün?",
|
||||
"confirmations.reply.confirm": "Cavabla",
|
||||
"confirmations.reply.message": "İndi cavab vermək hal-hazırda yazdığınız mesajın üzərinə yazacaq. Davam etmək istədiyinizə əminsiniz?",
|
||||
"confirmations.reply.title": "Paylaşım yenidən yazılsın?",
|
||||
"confirmations.unfollow.confirm": "İzləmədən çıxar",
|
||||
"confirmations.unfollow.message": "{name} izləmədən çıxmaq istədiyinizə əminsiniz?",
|
||||
"confirmations.unfollow.title": "İstifadəçi izləmədən çıxarılsın?",
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"about.blocks": "Мадэраваныя серверы",
|
||||
"about.contact": "Кантакт:",
|
||||
"about.default_locale": "Прадвызначаная",
|
||||
"about.disclaimer": "Mastodon - свабоднае праграмнае забеспячэнне, з адкрытым зыходным кодам, і гандлёвай маркай Mastodon gGmbH.",
|
||||
"about.domain_blocks.no_reason_available": "Прычына недаступная",
|
||||
"about.domain_blocks.preamble": "Mastodon, у асноўным, дазваляе вам праглядаць кантэнт і ўзаемадзейнічаць з карыстальнікамі з іншых сервераў у федэсвету. Гэтыя выключэнні былі зроблены дакладна на гэтым серверы.",
|
||||
|
@ -8,6 +9,7 @@
|
|||
"about.domain_blocks.silenced.title": "Абмежаваны",
|
||||
"about.domain_blocks.suspended.explanation": "Ніякая інфармацыя з гэтага сервера не будзе апрацавана, захавана або абменена, узаемадзеянне або камунікацыя з карыстальнікамі гэтага сервера немагчымы.",
|
||||
"about.domain_blocks.suspended.title": "Прыпынены",
|
||||
"about.language_label": "Мова",
|
||||
"about.not_available": "Дадзеная інфармацыя не дасяжная на гэтым серверы.",
|
||||
"about.powered_by": "Дэцэнтралізаваная сацыяльная сетка, створаная {mastodon}",
|
||||
"about.rules": "Правілы сервера",
|
||||
|
@ -19,13 +21,21 @@
|
|||
"account.block_domain": "Заблакіраваць дамен {domain}",
|
||||
"account.block_short": "Заблакіраваць",
|
||||
"account.blocked": "Заблакіраваны",
|
||||
"account.blocking": "Блакіраванне",
|
||||
"account.cancel_follow_request": "Скасаваць запыт на падпіску",
|
||||
"account.copy": "Скапіраваць спасылку на профіль",
|
||||
"account.direct": "Згадаць асабіста @{name}",
|
||||
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
||||
"account.domain_blocking": "Блакіраванне дамена",
|
||||
"account.edit_profile": "Рэдагаваць профіль",
|
||||
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
|
||||
"account.endorse": "Паказваць у профілі",
|
||||
"account.familiar_followers_many": "Мае сярод падпісчыкаў {name1}, {name2}, і {othersCount, plural, one {яшчэ # чалавека, знаёмага вам} few {яшчэ # чалавекі, знаёмыя вам} many {яшчэ # чалавек, знаёмых вам} other {яшчэ # чалавекі, знаёмыя вам}}",
|
||||
"account.familiar_followers_one": "Мае сярод падпісчыкаў {name1}",
|
||||
"account.familiar_followers_two": "Мае сярод падпісчыкаў {name1} і {name2}",
|
||||
"account.featured": "Рэкамендаванае",
|
||||
"account.featured.accounts": "Профілі",
|
||||
"account.featured.hashtags": "Хэштэгі",
|
||||
"account.featured_tags.last_status_at": "Апошні допіс ад {date}",
|
||||
"account.featured_tags.last_status_never": "Няма допісаў",
|
||||
"account.follow": "Падпісацца",
|
||||
|
@ -33,9 +43,11 @@
|
|||
"account.followers": "Падпісчыкі",
|
||||
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
|
||||
"account.followers_you_know_counter": "{count, one {{counter}, знаёмы вам} other {{counter}, знаёмых вам}}",
|
||||
"account.following": "Падпіскі",
|
||||
"account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
|
||||
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
|
||||
"account.follows_you": "Падпісаны на вас",
|
||||
"account.go_to_profile": "Перайсці да профілю",
|
||||
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
|
||||
"account.in_memoriam": "У памяць.",
|
||||
|
@ -50,18 +62,22 @@
|
|||
"account.mute_notifications_short": "Не апавяшчаць",
|
||||
"account.mute_short": "Ігнараваць",
|
||||
"account.muted": "Ігнаруецца",
|
||||
"account.mutual": "Вы падпісаны адно на аднаго",
|
||||
"account.no_bio": "Апісанне адсутнічае.",
|
||||
"account.open_original_page": "Адкрыць арыгінальную старонку",
|
||||
"account.posts": "Допісы",
|
||||
"account.posts_with_replies": "Допісы і адказы",
|
||||
"account.remove_from_followers": "Выдаліць карыстальніка {name} з падпісчыкаў",
|
||||
"account.report": "Паскардзіцца на @{name}",
|
||||
"account.requested": "Чакаецца ўхваленне. Націсніце, каб скасаваць запыт на падпіску",
|
||||
"account.requested_follow": "{name} адправіў запыт на падпіску",
|
||||
"account.requests_to_follow_you": "Хоча падпісацца на вас",
|
||||
"account.share": "Абагуліць профіль @{name}",
|
||||
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
|
||||
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
||||
"account.unblock": "Разблакіраваць @{name}",
|
||||
"account.unblock_domain": "Разблакіраваць дамен {domain}",
|
||||
"account.unblock_domain_short": "Разблакіраваць",
|
||||
"account.unblock_short": "Разблакіраваць",
|
||||
"account.unendorse": "Не паказваць у профілі",
|
||||
"account.unfollow": "Адпісацца",
|
||||
|
@ -83,26 +99,30 @@
|
|||
"alert.unexpected.message": "Узнікла нечаканая памылка.",
|
||||
"alert.unexpected.title": "Вой!",
|
||||
"alt_text_badge.title": "Альтэрнатыўны тэкст",
|
||||
"alt_text_modal.add_alt_text": "Дадаць альтэрнатыўны тэкст",
|
||||
"alt_text_modal.add_text_from_image": "Дадаць тэкст з відарыса",
|
||||
"alt_text_modal.cancel": "Скасаваць",
|
||||
"alt_text_modal.change_thumbnail": "Змяніць мініяцюру",
|
||||
"alt_text_modal.done": "Гатова",
|
||||
"announcement.announcement": "Аб'ява",
|
||||
"annual_report.summary.archetype.booster": "Трэнда-сьледнік",
|
||||
"annual_report.summary.archetype.booster": "Паляўнічы на трэнды",
|
||||
"annual_report.summary.archetype.lurker": "Назіральнік",
|
||||
"annual_report.summary.archetype.oracle": "Аракул",
|
||||
"annual_report.summary.archetype.pollster": "Апытвальнік",
|
||||
"annual_report.summary.archetype.replier": "Душа кампанійі",
|
||||
"annual_report.summary.followers.followers": "падпісанты",
|
||||
"annual_report.summary.followers.total": "Усяго {count}",
|
||||
"annual_report.summary.here_it_is": "Вось вашыя вынікі {year} году:",
|
||||
"annual_report.summary.archetype.replier": "Душа кампаніі",
|
||||
"annual_report.summary.followers.followers": "падпісчыкі",
|
||||
"annual_report.summary.followers.total": "Агулам {count}",
|
||||
"annual_report.summary.here_it_is": "Вось вашы вынікі {year} за год:",
|
||||
"annual_report.summary.highlighted_post.by_favourites": "самы ўпадабаны допіс",
|
||||
"annual_report.summary.highlighted_post.by_reblogs": "самы пашыраны допіс",
|
||||
"annual_report.summary.highlighted_post.by_replies": "самы каментаваны допіс",
|
||||
"annual_report.summary.highlighted_post.possessive": "{name}",
|
||||
"annual_report.summary.most_used_app.most_used_app": "самая выкарыстоўваная аплікацыя",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "самы выкарыстоўваны гэштаґ",
|
||||
"annual_report.summary.most_used_app.most_used_app": "праграма, якой карысталіся часцей",
|
||||
"annual_report.summary.most_used_hashtag.most_used_hashtag": "хэштэг, якім карысталіся часцей",
|
||||
"annual_report.summary.most_used_hashtag.none": "Няма",
|
||||
"annual_report.summary.new_posts.new_posts": "новыя допісы",
|
||||
"annual_report.summary.percentile.text": "<topLabel>Мэта месьціць вас у топ</topLabel><percentage></percentage><bottomLabel> карыстальнікаў {domain}.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "Мы ня скажам аб гэтым Сіняпальцаму.",
|
||||
"annual_report.summary.percentile.text": "<topLabel>З-за гэтага, вы знаходзіцеся ў топе</topLabel><percentage></percentage><bottomLabel> карыстальнікаў {domain}.</bottomLabel>",
|
||||
"annual_report.summary.percentile.we_wont_tell_bernie": "КДБ пра гэта не даведаецца.",
|
||||
"annual_report.summary.thanks": "Дзякуй за ўдзел у Mastodon!",
|
||||
"attachments_list.unprocessed": "(неапрацаваны)",
|
||||
"audio.hide": "Схаваць аўдыя",
|
||||
|
@ -127,7 +147,7 @@
|
|||
"bundle_column_error.routing.body": "Запытаная старонка не знойдзена. Вы ўпэўнены, што URL у адрасным радку правільны?",
|
||||
"bundle_column_error.routing.title": "404",
|
||||
"bundle_modal_error.close": "Закрыць",
|
||||
"bundle_modal_error.message": "Падчас загрузкі гэтага экрана штосьці пайшло ня так.",
|
||||
"bundle_modal_error.message": "Падчас загрузкі гэтага экрана штосьці пайшло не так.",
|
||||
"bundle_modal_error.retry": "Паспрабуйце зноў",
|
||||
"closed_registrations.other_server_instructions": "Паколькі Mastodon дэцэнтралізаваны, вы можаце стварыць уліковы запіс на іншым серверы і працягваць узаемадзейнічаць з ім.",
|
||||
"closed_registrations_modal.description": "Стварэнне ўліковага запісу на {domain} цяпер немагчыма. Заўважце, што няма неабходнасці мець уліковы запіс менавіта на {domain}, каб выкарыстоўваць Mastodon.",
|
||||
|
@ -147,7 +167,7 @@
|
|||
"column.firehose": "Стужкі",
|
||||
"column.follow_requests": "Запыты на падпіску",
|
||||
"column.home": "Галоўная",
|
||||
"column.list_members": "Кіраванне ўдзельнікамі спісу",
|
||||
"column.list_members": "Кіраванне ўдзельнікамі спіса",
|
||||
"column.lists": "Спісы",
|
||||
"column.mutes": "Ігнараваныя карыстальнікі",
|
||||
"column.notifications": "Апавяшчэнні",
|
||||
|
@ -161,7 +181,6 @@
|
|||
"column_header.show_settings": "Паказаць налады",
|
||||
"column_header.unpin": "Адмацаваць",
|
||||
"column_search.cancel": "Скасаваць",
|
||||
"column_subheading.settings": "Налады",
|
||||
"community.column_settings.local_only": "Толькі лакальныя",
|
||||
"community.column_settings.media_only": "Толькі медыя",
|
||||
"community.column_settings.remote_only": "Толькі дыстанцыйна",
|
||||
|
@ -197,25 +216,24 @@
|
|||
"confirmations.delete_list.confirm": "Выдаліць",
|
||||
"confirmations.delete_list.message": "Вы ўпэўненыя, што хочаце беззваротна выдаліць гэты чарнавік?",
|
||||
"confirmations.delete_list.title": "Выдаліць спіс?",
|
||||
"confirmations.discard_draft.edit.cancel": "Працягнуць рэдагаванне",
|
||||
"confirmations.discard_edit_media.confirm": "Адмяніць",
|
||||
"confirmations.discard_edit_media.message": "У вас ёсць незахаваныя змены ў апісанні або прэв'ю, усе роўна скасаваць іх?",
|
||||
"confirmations.edit.confirm": "Рэдагаваць",
|
||||
"confirmations.edit.message": "Калі вы зменіце зараз, гэта ператрэ паведамленне, якое вы пішаце. Вы ўпэўнены, што хочаце працягнуць?",
|
||||
"confirmations.edit.title": "Замяніць допіс?",
|
||||
"confirmations.follow_to_list.confirm": "Падпісацца й дадаць у сьпіс",
|
||||
"confirmations.follow_to_list.message": "Вы мусіце быць падпісаныя на {name} каб дадаць яго ў сьпіс.",
|
||||
"confirmations.follow_to_list.confirm": "Падпісацца і дадаць у спіс",
|
||||
"confirmations.follow_to_list.message": "Вам трэба падпісацца на карыстальніка {name}, каб дадаць яго ў спіс.",
|
||||
"confirmations.follow_to_list.title": "Падпісацца на карыстальніка?",
|
||||
"confirmations.logout.confirm": "Выйсці",
|
||||
"confirmations.logout.message": "Вы ўпэўненыя, што хочаце выйсці?",
|
||||
"confirmations.logout.title": "Выйсці?",
|
||||
"confirmations.missing_alt_text.title": "Дадаць апісаньне?",
|
||||
"confirmations.missing_alt_text.confirm": "Дадаць альтэрнатыўны тэкст",
|
||||
"confirmations.missing_alt_text.secondary": "Усё адно апублікаваць",
|
||||
"confirmations.missing_alt_text.title": "Дадаць альтэрнатыўны тэкст?",
|
||||
"confirmations.mute.confirm": "Ігнараваць",
|
||||
"confirmations.redraft.confirm": "Выдаліць і перапісаць",
|
||||
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
|
||||
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
|
||||
"confirmations.reply.confirm": "Адказаць",
|
||||
"confirmations.reply.message": "Калі вы адкажаце зараз, гэта ператрэ паведамленне, якое вы пішаце. Вы ўпэўнены, што хочаце працягнуць?",
|
||||
"confirmations.reply.title": "Замяніць допіс?",
|
||||
"confirmations.remove_from_followers.confirm": "Выдаліць падпісчыка",
|
||||
"confirmations.remove_from_followers.title": "Выдаліць падпісчыка?",
|
||||
"confirmations.unfollow.confirm": "Адпісацца",
|
||||
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
|
||||
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
|
||||
|
@ -228,7 +246,7 @@
|
|||
"conversation.with": "З {names}",
|
||||
"copy_icon_button.copied": "Скапіявана ў буфер абмену",
|
||||
"copypaste.copied": "Скапіравана",
|
||||
"copypaste.copy_to_clipboard": "Капіраваць у буфер абмену",
|
||||
"copypaste.copy_to_clipboard": "Скапіяваць у буфер абмену",
|
||||
"directory.federated": "З вядомага федэсвету",
|
||||
"directory.local": "Толькі з {domain}",
|
||||
"directory.new_arrivals": "Новыя карыстальнікі",
|
||||
|
@ -237,7 +255,7 @@
|
|||
"disabled_account_banner.text": "Ваш уліковы запіс {disabledAccount} часова адключаны.",
|
||||
"dismissable_banner.community_timeline": "Гэта самыя апошнія допісы ад людзей, уліковыя запісы якіх размяшчаюцца на {domain}.",
|
||||
"dismissable_banner.dismiss": "Адхіліць",
|
||||
"dismissable_banner.public_timeline": "Гэта самыя новыя публічныя допісы ад карыстальнікаў фэдывёрсу на якіх падпісаныя карыстальнікі {domain}.",
|
||||
"dismissable_banner.public_timeline": "Вось апошнія публічныя допісы ад карыстальнікаў fediverse на якіх падпісаны карыстальнікі {domain}.",
|
||||
"domain_block_modal.block": "Заблакіраваць сервер",
|
||||
"domain_block_modal.block_account_instead": "Заблакіраваць @{name} замест гэтага",
|
||||
"domain_block_modal.they_can_interact_with_old_posts": "Людзі з гэтага сервера змогуць узаемадзейнічаць з вашымі старымі допісамі.",
|
||||
|
@ -282,15 +300,15 @@
|
|||
"empty_column.account_timeline": "Тут няма допісаў!",
|
||||
"empty_column.account_unavailable": "Профіль недаступны",
|
||||
"empty_column.blocks": "Вы яшчэ нікога не заблакіравалі.",
|
||||
"empty_column.bookmarked_statuses": "У вас яшчэ няма паведамленняў з закладкамі. Калі вы дадасце закладку, яна з'явіцца тут.",
|
||||
"empty_column.community": "Мясцовая стужка пустая. Напішыце што-небудзь публічна, каб зрушыць з месца!",
|
||||
"empty_column.direct": "Пакуль у вас няма асабістых згадак. Калі вы дашляце або атрымаеце штось, яно з'явіцца тут.",
|
||||
"empty_column.bookmarked_statuses": "У вашых закладках яшчэ няма допісаў. Калі вы дадасце закладку, яна з’явіцца тут.",
|
||||
"empty_column.community": "Мясцовая стужка пустая. Напішыце нешта публічнае, каб разварушыць справу!",
|
||||
"empty_column.direct": "Пакуль у вас няма асабістых згадванняў. Калі вы дашляце або атрымаеце штосьці, яно з’явіцца тут.",
|
||||
"empty_column.domain_blocks": "Заблакіраваных даменаў пакуль няма.",
|
||||
"empty_column.explore_statuses": "Зараз не ў трэндзе. Праверце пазней",
|
||||
"empty_column.favourited_statuses": "Вы яшчэ не ўпадабалі ніводны допіс. Калі гэта адбудзецца, вы ўбачыце яго тут.",
|
||||
"empty_column.favourites": "Ніхто яшчэ не ўпадабаў гэты допіс. Калі гэта адбудзецца, вы ўбачыце гэтых людзей тут.",
|
||||
"empty_column.follow_requests": "У вас яшчэ няма запытаў на падпіскуі. Калі вы атрымаеце запыт, ён з'явяцца тут.",
|
||||
"empty_column.followed_tags": "Вы пакуль не падпісаны ні на адзін хэштэг. Калі падпішацеся, яны з'явяцца тут.",
|
||||
"empty_column.follow_requests": "У вас яшчэ няма запытаў на падпіску. Калі вы атрымаеце запыт, ён з’явіцца тут.",
|
||||
"empty_column.followed_tags": "Вы пакуль не падпісаны ні на адзін хэштэг. Калі падпішацеся, яны з’явяцца тут.",
|
||||
"empty_column.hashtag": "Па гэтаму хэштэгу пакуль што нічога няма.",
|
||||
"empty_column.home": "Галоўная стужка пустая! Падпішыцеся на іншых людзей, каб запоўніць яе. {suggestions}",
|
||||
"empty_column.list": "У гэтым спісе пакуль што нічога няма. Калі члены лісту апублікуюць новыя запісы, яны з'явяцца тут.",
|
||||
|
@ -301,13 +319,18 @@
|
|||
"error.unexpected_crash.explanation": "Гэта старонка не можа быць адлюстравана карэктна з-за памылкі ў нашым кодзе, або праблемы з сумяшчальнасцю браўзера.",
|
||||
"error.unexpected_crash.explanation_addons": "Гэтая старонка не можа быць адлюстравана карэктна. Верагодна, гэтая памылка выклікана дадатковым кампанентам браўзера або інструментамі аўтаматычнага перакладу",
|
||||
"error.unexpected_crash.next_steps": "Паспрабуйце абнавіць старонку. Калі гэта не дапаможа, вы можаце паспрабаваць іншы браўзер, альбо выкарыстаць усталяваную праграму.",
|
||||
"error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і аднавіць старонку. Калі гэта не дапаможа, вы можаце карыстацца Мастадонт праз другі браўзер ці аплікацыю.",
|
||||
"error.unexpected_crash.next_steps_addons": "Паспрабуйце выключыць іх і абнавіць старонку. Калі гэта не дапамагае, вы ўсё яшчэ можаце карыстацца Mastodon праз іншы браўзер ці натыўную праграму.",
|
||||
"errors.unexpected_crash.copy_stacktrace": "Дадаць дыягнастычны стэк у буфер абмену",
|
||||
"errors.unexpected_crash.report_issue": "Паведаміць аб праблеме",
|
||||
"explore.suggested_follows": "Людзі",
|
||||
"explore.title": "Трэндавае",
|
||||
"explore.trending_links": "Навіны",
|
||||
"explore.trending_statuses": "Допісы",
|
||||
"explore.trending_tags": "Хэштэгі",
|
||||
"featured_carousel.next": "Далей",
|
||||
"featured_carousel.post": "Допіс",
|
||||
"featured_carousel.previous": "Назад",
|
||||
"featured_carousel.slide": "{index} з {total}",
|
||||
"filter_modal.added.context_mismatch_explanation": "Гэтая катэгорыя фільтра не прымяняецца да кантэксту, у якім вы адкрылі гэты пост. Калі вы хочаце, каб паведамленне таксама было адфільтравана ў гэтым кантэксце, вам трэба будзе адрэдагаваць фільтр",
|
||||
"filter_modal.added.context_mismatch_title": "Неадпаведны кантэкст!",
|
||||
"filter_modal.added.expired_explanation": "Тэрмін дзеяння гэтай катэгорыі фільтраў скончыўся, вам трэба будзе змяніць дату заканчэння тэрміну дзеяння, каб яна прымянялася",
|
||||
|
@ -356,10 +379,10 @@
|
|||
"footer.privacy_policy": "Палітыка прыватнасці",
|
||||
"footer.source_code": "Прагледзець зыходны код",
|
||||
"footer.status": "Статус",
|
||||
"footer.terms_of_service": "Умовы абслугоўваньня",
|
||||
"footer.terms_of_service": "Умовы выкарыстання",
|
||||
"generic.saved": "Захавана",
|
||||
"getting_started.heading": "Пачатак працы",
|
||||
"hashtag.admin_moderation": "Адкрыць інтэрфэйс мадаратара для #{name}",
|
||||
"hashtag.admin_moderation": "Адкрыць інтэрфейс мадэратара для #{name}",
|
||||
"hashtag.column_header.tag_mode.all": "і {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "або {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "без {additional}",
|
||||
|
@ -381,35 +404,39 @@
|
|||
"hints.profiles.see_more_followers": "Глядзець больш падпісаных на {domain}",
|
||||
"hints.profiles.see_more_follows": "Глядзець больш падпісак на {domain}",
|
||||
"hints.profiles.see_more_posts": "Глядзець больш допісаў на {domain}",
|
||||
"hints.threads.replies_may_be_missing": "Адказы зь іншых сэрвэраў могуць адсутнічаць.",
|
||||
"hints.threads.replies_may_be_missing": "Адказы з іншых сервераў могуць адсутнічаць.",
|
||||
"hints.threads.see_more": "Глядзіце больш адказаў на {domain}",
|
||||
"home.column_settings.show_quotes": "Паказаць цытаты",
|
||||
"home.column_settings.show_reblogs": "Паказваць пашырэнні",
|
||||
"home.column_settings.show_replies": "Паказваць адказы",
|
||||
"home.hide_announcements": "Схаваць аб'явы",
|
||||
"home.pending_critical_update.body": "Калі ласка, абнавіце свой сервер Mastodon як мага хутчэй!",
|
||||
"home.pending_critical_update.body": "Абнавіце свой сервер Mastodon як мага хутчэй!",
|
||||
"home.pending_critical_update.link": "Прагледзець абнаўленні",
|
||||
"home.pending_critical_update.title": "Даступна крытычнае абнаўленне бяспекі!",
|
||||
"home.show_announcements": "Паказаць аб'явы",
|
||||
"ignore_notifications_modal.disclaimer": "Mastodon ня можа йнфармаваць карыстальнікаў аб тым, што вы прайігнаравалі йх паведамленьні. Ігнараваньне паведамленьняў не спыніць іх адпраўку.",
|
||||
"ignore_notifications_modal.disclaimer": "Mastodon не можа паведамляць карыстальнікам, што вы праігнаравалі апавяшчэнні ад іх. Ігнараванне апавяшчэнняў не спыніць адпраўку саміх паведамленняў.",
|
||||
"ignore_notifications_modal.filter_instead": "Замест гэтага адфільтраваць",
|
||||
"ignore_notifications_modal.filter_to_act_users": "Вы па-ранейшаму зможаце прымаць, адхіляць ці скардзіцца на карыстальнікаў",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Фільтраваньне дапамагае пазьбегнуць патэнцыйнай блытаніны",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "Вы можаце прагледзець адфільтраваныя паведамленьні асобна",
|
||||
"ignore_notifications_modal.ignore": "Ігнараваць паведамленьні",
|
||||
"ignore_notifications_modal.limited_accounts_title": "Ігнараваць паведамленьні ад абмежаваных уліковых запісаў?",
|
||||
"ignore_notifications_modal.new_accounts_title": "Ігнараваць паведамленьні ад новых уліковых запісаў?",
|
||||
"ignore_notifications_modal.not_followers_title": "Ігнараваць паведамленьні ад людзей, якія ня падпісаныя на вас?",
|
||||
"ignore_notifications_modal.filter_to_avoid_confusion": "Выкарыстанне фільтраў дапамагае пазбягаць патэнцыйнай блытаніны",
|
||||
"ignore_notifications_modal.filter_to_review_separately": "Вы можаце прагледзець адфільтраваныя апавяшчэнні асобна",
|
||||
"ignore_notifications_modal.ignore": "Ігнараваць апавяшчэнні",
|
||||
"ignore_notifications_modal.limited_accounts_title": "Ігнараваць апавяшчэнні ад уліковых запісаў пад мадэрацыяй?",
|
||||
"ignore_notifications_modal.new_accounts_title": "Ігнараваць апавяшчэнні ад новых уліковых запісаў?",
|
||||
"ignore_notifications_modal.not_followers_title": "Ігнараваць апавяшчэнні ад людзей, якія не падпісаныя на вас?",
|
||||
"ignore_notifications_modal.not_following_title": "Ігнараваць апавяшчэнні ад людзей на якіх вы не падпісаны?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Ігнараваць паведамленьні аб непажаданых прыватных згадках?",
|
||||
"ignore_notifications_modal.private_mentions_title": "Ігнараваць апавяшчэнні пра непажаданыя асабістыя згадванні?",
|
||||
"info_button.label": "Даведка",
|
||||
"interaction_modal.action.favourite": "Каб працягнуць, вы мусіце ўпадабаць з вашага ўліковага запісу.",
|
||||
"interaction_modal.action.follow": "Каб працягнуць, вы мусіце падпісацца з вашага ўліковага запісу.",
|
||||
"interaction_modal.action.favourite": "Каб працягнуць, вы мусіце ўпадабаць нешта са свайго ўліковага запісу.",
|
||||
"interaction_modal.action.follow": "Каб працягнуць, вы мусіце падпісацца на некага са свайго ўліковага запісу.",
|
||||
"interaction_modal.go": "Перайсці",
|
||||
"interaction_modal.no_account_yet": "Не маеце ўліковага запісу?",
|
||||
"interaction_modal.on_another_server": "На іншым серверы",
|
||||
"interaction_modal.on_this_server": "На гэтым серверы",
|
||||
"interaction_modal.title.favourite": "Упадабаць допіс {name}",
|
||||
"interaction_modal.title.follow": "Падпісацца на {name}",
|
||||
"interaction_modal.title.reblog": "Пашырыць допіс ад {name}",
|
||||
"interaction_modal.title.reply": "Адказаць на допіс {name}",
|
||||
"interaction_modal.username_prompt": "Напр., {example}",
|
||||
"intervals.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
|
||||
"intervals.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# хвіліна} few {# хвіліны} many {# хвілін} other {# хвіліны}}",
|
||||
|
@ -445,21 +472,32 @@
|
|||
"keyboard_shortcuts.toggle_hidden": "Паказаць/схаваць тэкст за папярэджаннем пра кантэнт",
|
||||
"keyboard_shortcuts.toggle_sensitivity": "Паказаць/схаваць медыя",
|
||||
"keyboard_shortcuts.toot": "Стварыць новы допіс",
|
||||
"keyboard_shortcuts.translate": "каб перакласці допіс",
|
||||
"keyboard_shortcuts.unfocus": "Расфакусаваць тэкставую вобласць/пошукавы радок",
|
||||
"keyboard_shortcuts.up": "Перамясціцца ўверх па спісе",
|
||||
"lightbox.close": "Закрыць",
|
||||
"lightbox.next": "Далей",
|
||||
"lightbox.previous": "Назад",
|
||||
"lightbox.zoom_in": "Маштабаваць да фактычнага памеру",
|
||||
"lightbox.zoom_out": "Дапасаваць усё змесціва пад памеры экрана",
|
||||
"limited_account_hint.action": "Усе роўна паказваць профіль",
|
||||
"limited_account_hint.title": "Гэты профіль быў схаваны мадэратарамі",
|
||||
"link_preview.author": "Ад {name}",
|
||||
"link_preview.more_from_author": "Больш ад {name}",
|
||||
"link_preview.shares": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
|
||||
"lists.add_member": "Дадаць",
|
||||
"lists.add_to_list": "Дадаць у спіс",
|
||||
"lists.add_to_lists": "Дадаць {name} у спісы",
|
||||
"lists.create": "Стварыць",
|
||||
"lists.create_list": "Стварыць спіс",
|
||||
"lists.delete": "Выдаліць спіс",
|
||||
"lists.done": "Гатова",
|
||||
"lists.edit": "Рэдагаваць спіс",
|
||||
"lists.list_name": "Назва спіса",
|
||||
"lists.new_list_name": "Назва новага спіса",
|
||||
"lists.no_lists_yet": "Пакуль няма спісаў.",
|
||||
"lists.no_members_yet": "Пакуль няма ўдзельнікаў.",
|
||||
"lists.no_results_found": "Нічога не знойдзена.",
|
||||
"lists.remove_member": "Выдаліць",
|
||||
"lists.replies_policy.followed": "Любы карыстальнік, на якога вы падпісаліся",
|
||||
"lists.replies_policy.list": "Удзельнікі гэтага спісу",
|
||||
|
@ -467,7 +505,7 @@
|
|||
"lists.save": "Захаваць",
|
||||
"lists.search": "Пошук",
|
||||
"load_pending": "{count, plural, one {# новы элемент} few {# новыя элементы} many {# новых элементаў} other {# новых элементаў}}",
|
||||
"loading_indicator.label": "Загрузка…",
|
||||
"loading_indicator.label": "Ідзе загрузка…",
|
||||
"media_gallery.hide": "Схаваць",
|
||||
"moved_to_account_banner.text": "Ваш уліковы запіс {disabledAccount} зараз адключаны таму што вы перанесены на {movedToAccount}.",
|
||||
"mute_modal.hide_from_notifications": "Схаваць з апавяшчэнняў",
|
||||
|
@ -480,31 +518,34 @@
|
|||
"mute_modal.you_wont_see_mentions": "Вы не ўбачыце паведамленняў са згадваннем карыстальніка.",
|
||||
"mute_modal.you_wont_see_posts": "Карыстальнік па-ранейшаму будзе бачыць вашыя паведамленні, але вы не будзеце паведамленні карыстальніка.",
|
||||
"navigation_bar.about": "Пра нас",
|
||||
"navigation_bar.account_settings": "Пароль і бяспека",
|
||||
"navigation_bar.administration": "Адміністрацыя",
|
||||
"navigation_bar.advanced_interface": "Адкрыць у пашыраным вэб-інтэрфейсе",
|
||||
"navigation_bar.automated_deletion": "Аўтаматычнае выдаленне допісаў",
|
||||
"navigation_bar.blocks": "Заблакіраваныя карыстальнікі",
|
||||
"navigation_bar.bookmarks": "Закладкі",
|
||||
"navigation_bar.community_timeline": "Лакальная стужка",
|
||||
"navigation_bar.compose": "Стварыць новы допіс",
|
||||
"navigation_bar.direct": "Асабістыя згадванні",
|
||||
"navigation_bar.discover": "Даведайцесь",
|
||||
"navigation_bar.domain_blocks": "Заблакіраваныя дамены",
|
||||
"navigation_bar.explore": "Агляд",
|
||||
"navigation_bar.favourites": "Упадабанае",
|
||||
"navigation_bar.filters": "Ігнараваныя словы",
|
||||
"navigation_bar.follow_requests": "Запыты на падпіску",
|
||||
"navigation_bar.followed_tags": "Падпіскі",
|
||||
"navigation_bar.follows_and_followers": "Падпіскі і падпісчыкі",
|
||||
"navigation_bar.import_export": "Імпарт і экспарт",
|
||||
"navigation_bar.lists": "Спісы",
|
||||
"navigation_bar.live_feed_local": "Жывая стужка (лакальная)",
|
||||
"navigation_bar.live_feed_public": "Жывая стужка (публічная)",
|
||||
"navigation_bar.logout": "Выйсці",
|
||||
"navigation_bar.moderation": "Мадэрацыя",
|
||||
"navigation_bar.more": "Больш",
|
||||
"navigation_bar.mutes": "Ігнараваныя карыстальнікі",
|
||||
"navigation_bar.opened_in_classic_interface": "Допісы, уліковыя запісы і іншыя спецыфічныя старонкі па змоўчанні адчыняюцца ў класічным вэб-інтэрфейсе.",
|
||||
"navigation_bar.personal": "Асабістае",
|
||||
"navigation_bar.pins": "Замацаваныя допісы",
|
||||
"navigation_bar.preferences": "Налады",
|
||||
"navigation_bar.public_timeline": "Глабальная стужка",
|
||||
"navigation_bar.privacy_and_reach": "Прыватнасць і пошук",
|
||||
"navigation_bar.search": "Пошук",
|
||||
"navigation_bar.security": "Бяспека",
|
||||
"navigation_bar.search_trends": "Пошук / Трэндавае",
|
||||
"navigation_panel.collapse_followed_tags": "Згарнуць меню падпісак на хэштэгі",
|
||||
"navigation_panel.collapse_lists": "Згарнуць меню спісаў",
|
||||
"not_signed_in_indicator.not_signed_in": "Вам трэба ўвайсці каб атрымаць доступ да гэтага рэсурсу.",
|
||||
"notification.admin.report": "{name} паскардзіўся на {target}",
|
||||
"notification.admin.report_account": "{name} паскардзіўся на {count, plural, one {# допіс} many {# допісаў} other {# допіса}} ад {target} з прычыны {category}",
|
||||
|
@ -512,7 +553,10 @@
|
|||
"notification.admin.report_statuses": "{name} паскардзіўся на {target} з прычыны {category}",
|
||||
"notification.admin.report_statuses_other": "{name} паскардзіўся на {target}",
|
||||
"notification.admin.sign_up": "{name} зарэгістраваўся",
|
||||
"notification.annual_report.view": "Перайсці да #Wrapstodon",
|
||||
"notification.favourite": "Ваш допіс упадабаны {name}",
|
||||
"notification.favourite_pm": "Ваша асабістае згадванне ўпадабана {name}",
|
||||
"notification.favourite_pm.name_and_others_with_link": "{name} і <a>{count, plural, one {# іншы} few {# іншыя} many {# іншых} other {# іншых}}</a> ўпадабалі ваша асабістае згадванне",
|
||||
"notification.follow": "{name} падпісаўся на вас",
|
||||
"notification.follow_request": "{name} адправіў запыт на падпіску",
|
||||
"notification.follow_request.name_and_others": "{name} і {count, plural, one {# іншы} many {# іншых} other {# іншых}} запыталіся падпісацца на вас",
|
||||
|
@ -523,7 +567,7 @@
|
|||
"notification.mention": "Згадванне",
|
||||
"notification.mentioned_you": "{name} згадаў вас",
|
||||
"notification.moderation-warning.learn_more": "Даведацца больш",
|
||||
"notification.moderation_warning": "Вы атрымалі папярэджанне аб мадэрацыі",
|
||||
"notification.moderation_warning": "Вы атрымалі папярэджанне ад мадэратараў",
|
||||
"notification.moderation_warning.action_delete_statuses": "Некаторыя вашыя допісы былі выдаленыя.",
|
||||
"notification.moderation_warning.action_disable": "Ваш уліковы запіс быў адключаны.",
|
||||
"notification.moderation_warning.action_mark_statuses_as_sensitive": "Некаторыя з вашых допісаў былі пазначаныя як далікатныя.",
|
||||
|
@ -554,7 +598,7 @@
|
|||
"notification_requests.title": "Адфільтраваныя апавяшчэнні",
|
||||
"notification_requests.view": "Прагляд апавяшчэнняў",
|
||||
"notifications.clear": "Ачысціць апавяшчэнні",
|
||||
"notifications.clear_confirmation": "Вы ўпэўнены, што жадаеце назаўсёды сцерці ўсё паведамленні?",
|
||||
"notifications.clear_confirmation": "Вы ўпэўнены, што хочаце назаўсёды сцерці ўсе свае паведамленні?",
|
||||
"notifications.clear_title": "Ачысціць апавяшчэнні?",
|
||||
"notifications.column_settings.admin.report": "Новыя скаргі:",
|
||||
"notifications.column_settings.admin.sign_up": "Новыя ўваходы:",
|
||||
|
@ -564,7 +608,7 @@
|
|||
"notifications.column_settings.filter_bar.category": "Панэль хуткай фільтрацыі",
|
||||
"notifications.column_settings.follow": "Новыя падпісчыкі:",
|
||||
"notifications.column_settings.follow_request": "Новыя запыты на падпіску:",
|
||||
"notifications.column_settings.group": "Аб'яднаць апавяшчэнні ад падпісчыкаў",
|
||||
"notifications.column_settings.group": "Аб’яднаць апавяшчэнні ад падпісчыкаў",
|
||||
"notifications.column_settings.mention": "Згадванні:",
|
||||
"notifications.column_settings.poll": "Вынікі апытання:",
|
||||
"notifications.column_settings.push": "Push-апавяшчэнні",
|
||||
|
@ -586,13 +630,13 @@
|
|||
"notifications.group": "{count} Апавяшчэнняў",
|
||||
"notifications.mark_as_read": "Пазначыць усе апавяшчэнні як прачытаныя",
|
||||
"notifications.permission_denied": "Апавяшчэнні на працоўным стале недаступныя з-за папярэдне адхіленага запыта праў браўзера",
|
||||
"notifications.permission_denied_alert": "Апавяшчэнні на працоўным стале не могуць быць уключаныя, з-за таго што запыт браўзера быў адхілены",
|
||||
"notifications.permission_denied_alert": "З-за таго, што запыт браўзера быў раней адхілены, немагчыма ўключыць апавяшчэнні на працоўным стале",
|
||||
"notifications.permission_required": "Апавяшчэнні на працоўным стале недаступныя, з-за таго што неабходны дазвол не быў дадзены.",
|
||||
"notifications.policy.accept": "Прыняць",
|
||||
"notifications.policy.accept_hint": "Паказваць у апавяшчэннях",
|
||||
"notifications.policy.drop": "Iгнараваць",
|
||||
"notifications.policy.filter": "Фільтраваць",
|
||||
"notifications.policy.filter_limited_accounts_title": "Абмежаваныя ўліковыя запісы",
|
||||
"notifications.policy.filter_limited_accounts_title": "Уліковыя запісы пад мадэрацыяй",
|
||||
"notifications.policy.filter_new_accounts.hint": "Створаныя на працягу {days, plural, one {апошняга # дня} few {апошніх # дзён} many {апошніх # дзён} other {апошняй # дня}}",
|
||||
"notifications.policy.filter_new_accounts_title": "Новыя ўліковыя запісы",
|
||||
"notifications.policy.filter_not_followers_hint": "Уключаючы людзей, якія падпісаны на вас менш, чым {days, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}}",
|
||||
|
@ -609,13 +653,13 @@
|
|||
"onboarding.follows.done": "Гатова",
|
||||
"onboarding.follows.empty": "На жаль, зараз немагчыма паказаць вынікі. Вы можаце паспрабаваць выкарыстоўваць пошук і праглядзець старонку агляду, каб знайсці людзей, на якіх можна падпісацца, або паўтарыце спробу пазней.",
|
||||
"onboarding.follows.search": "Пошук",
|
||||
"onboarding.follows.title": "Падпішыцеся каб пачаць",
|
||||
"onboarding.follows.title": "Падпішыцеся на некага, каб пачаць",
|
||||
"onboarding.profile.discoverable": "Зрабіць мой профіль бачным",
|
||||
"onboarding.profile.discoverable_hint": "Калі вы звяртаецеся да адкрытасці на Mastodon, вашы паведамленні могуць з'яўляцца ў выніках пошуку і тэндэнцый, а ваш профіль можа быць прапанаваны людзям з такімі ж інтарэсамі.",
|
||||
"onboarding.profile.display_name": "Бачнае імя",
|
||||
"onboarding.profile.display_name_hint": "Ваша поўнае імя або ваш псеўданім…",
|
||||
"onboarding.profile.note": "Біяграфія",
|
||||
"onboarding.profile.note_hint": "Вы можаце @згадаць іншых людзей або выкарыстоўваць #хэштэгі…",
|
||||
"onboarding.profile.note_hint": "Вы можаце @згадваць іншых людзей або выкарыстоўваць #хэштэгі…",
|
||||
"onboarding.profile.save_and_continue": "Захаваць і працягнуць",
|
||||
"onboarding.profile.title": "Налады профілю",
|
||||
"onboarding.profile.upload_avatar": "Загрузіць фота профілю",
|
||||
|
@ -635,6 +679,7 @@
|
|||
"poll_button.remove_poll": "Выдаліць апытанне",
|
||||
"privacy.change": "Змяніць прыватнасць допісу",
|
||||
"privacy.direct.long": "Усе згаданыя ў допісе",
|
||||
"privacy.direct.short": "Асабістае згадванне",
|
||||
"privacy.private.long": "Толькі вашыя падпісчыкі",
|
||||
"privacy.private.short": "Падпісчыкі",
|
||||
"privacy.public.long": "Усе, хто ёсць і каго няма ў Mastodon",
|
||||
|
@ -644,10 +689,10 @@
|
|||
"privacy.unlisted.short": "Ціхі публічны",
|
||||
"privacy_policy.last_updated": "Адноўлена {date}",
|
||||
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
||||
"recommended": "Рэкамендуем",
|
||||
"recommended": "Рэкамендаванае",
|
||||
"refresh": "Абнавiць",
|
||||
"regeneration_indicator.please_stand_by": "Калі ласка, пачакайце.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Рыхтуем вашую стужку…",
|
||||
"regeneration_indicator.please_stand_by": "Пачакайце.",
|
||||
"regeneration_indicator.preparing_your_home_feed": "Рыхтуем вашу галоўную стужку…",
|
||||
"relative_time.days": "{number} д",
|
||||
"relative_time.full.days": "{number, plural, one {# дзень} few {# дні} many {# дзён} other {# дня}} таму",
|
||||
"relative_time.full.hours": "{number, plural, one {# гадзіна} few {# гадзіны} many {# гадзін} other {# гадзіны}} таму",
|
||||
|
@ -664,7 +709,7 @@
|
|||
"reply_indicator.poll": "Апытанне",
|
||||
"report.block": "Заблакіраваць",
|
||||
"report.block_explanation": "Вы перастанеце бачыць допісы гэтага карыстальніка. Ён не зможа сачыць за вамі і бачыць вашы допісы. Ён зможа зразумець, што яго заблакіравалі.",
|
||||
"report.categories.legal": "Права",
|
||||
"report.categories.legal": "Звязанае з правам",
|
||||
"report.categories.other": "Іншае",
|
||||
"report.categories.spam": "Спам",
|
||||
"report.categories.violation": "Змест парушае адно ці некалькі правілаў сервера",
|
||||
|
@ -731,8 +776,8 @@
|
|||
"search_results.accounts": "Профілі",
|
||||
"search_results.all": "Усё",
|
||||
"search_results.hashtags": "Хэштэгі",
|
||||
"search_results.no_results": "Анічога ня знойдзена.",
|
||||
"search_results.no_search_yet": "Паспрабуйце пашукаць допісы, профілі або гэштаґі.",
|
||||
"search_results.no_results": "Няма вынікаў.",
|
||||
"search_results.no_search_yet": "Паспрабуйце пашукаць допісы, профілі або хэштэгі.",
|
||||
"search_results.see_all": "Праглядзець усе",
|
||||
"search_results.statuses": "Допісы",
|
||||
"search_results.title": "Шукаць \"{q}\"",
|
||||
|
@ -753,7 +798,7 @@
|
|||
"status.bookmark": "Дадаць закладку",
|
||||
"status.cancel_reblog_private": "Прыбраць",
|
||||
"status.cannot_reblog": "Гэты пост нельга пашырыць",
|
||||
"status.continued_thread": "Працяг тэмы",
|
||||
"status.continued_thread": "Працяг ланцужка",
|
||||
"status.copy": "Скапіраваць спасылку на допіс",
|
||||
"status.delete": "Выдаліць",
|
||||
"status.detailed_status": "Дэтальны агляд размовы",
|
||||
|
@ -778,6 +823,7 @@
|
|||
"status.mute_conversation": "Ігнараваць размову",
|
||||
"status.open": "Разгарнуць гэты допіс",
|
||||
"status.pin": "Замацаваць у профілі",
|
||||
"status.quote_post_author": "Допіс карыстальніка @{name}",
|
||||
"status.read_more": "Чытаць болей",
|
||||
"status.reblog": "Пашырыць",
|
||||
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
||||
|
@ -786,7 +832,7 @@
|
|||
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
|
||||
"status.redraft": "Выдаліць і паправіць",
|
||||
"status.remove_bookmark": "Выдаліць закладку",
|
||||
"status.replied_in_thread": "Адказаў у тэме",
|
||||
"status.replied_in_thread": "Адказаў у ланцужку",
|
||||
"status.replied_to": "Адказаў {name}",
|
||||
"status.reply": "Адказаць",
|
||||
"status.replyAll": "Адказаць у ланцугу",
|
||||
|
@ -806,8 +852,11 @@
|
|||
"subscribed_languages.save": "Захаваць змены",
|
||||
"subscribed_languages.target": "Змяніць мовы падпіскі для {target}",
|
||||
"tabs_bar.home": "Галоўная",
|
||||
"tabs_bar.menu": "Меню",
|
||||
"tabs_bar.notifications": "Апавяшчэнні",
|
||||
"terms_of_service.title": "Умовы абслугоўваньня",
|
||||
"tabs_bar.publish": "Новы допіс",
|
||||
"tabs_bar.search": "Пошук",
|
||||
"terms_of_service.title": "Умовы выкарыстання",
|
||||
"time_remaining.days": "{number, plural, one {застаўся # дзень} few {засталося # дні} many {засталося # дзён} other {засталося # дня}}",
|
||||
"time_remaining.hours": "{number, plural, one {засталася # гадзіна} few {засталося # гадзіны} many {засталося # гадзін} other {засталося # гадзіны}}",
|
||||
"time_remaining.minutes": "{number, plural, one {засталася # хвіліна} few {засталося # хвіліны} many {засталося # хвілін} other {засталося # хвіліны}}",
|
||||
|
@ -833,6 +882,12 @@
|
|||
"video.expand": "Разгарнуць відэа",
|
||||
"video.fullscreen": "Увесь экран",
|
||||
"video.hide": "Схаваць відэа",
|
||||
"video.mute": "Выключыць гук",
|
||||
"video.pause": "Паўза",
|
||||
"video.play": "Прайграць"
|
||||
"video.play": "Прайграць",
|
||||
"video.skip_backward": "Праматаць назад",
|
||||
"video.skip_forward": "Праматаць уперад",
|
||||
"video.unmute": "Уключыць гук",
|
||||
"video.volume_down": "Паменшыць гучнасць",
|
||||
"video.volume_up": "Павялічыць гучнасць"
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user