mirror of
https://github.com/mastodon/mastodon.git
synced 2025-07-21 11:48:14 +00:00
Compare commits
No commits in common. "main" and "v4.4.0-beta.2" have entirely different histories.
main
...
v4.4.0-bet
1
.github/.well-known/funding-manifest-urls
vendored
1
.github/.well-known/funding-manifest-urls
vendored
|
@ -1 +0,0 @@
|
||||||
https://joinmastodon.org/funding.json
|
|
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/1.web_bug_report.yml
vendored
|
@ -1,6 +1,6 @@
|
||||||
name: Bug Report (Web Interface)
|
name: Bug Report (Web Interface)
|
||||||
description: There is a problem using Mastodon's web interface.
|
description: There is a problem using Mastodon's web interface.
|
||||||
labels: ['area/web interface']
|
labels: ['status/to triage', 'area/web interface']
|
||||||
type: Bug
|
type: Bug
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
name: Bug Report (server / API)
|
name: Bug Report (server / API)
|
||||||
description: |
|
description: |
|
||||||
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
There is a problem with the HTTP server, REST API, ActivityPub interaction, etc.
|
||||||
|
labels: ['status/to triage']
|
||||||
type: 'Bug'
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
|
|
1
.github/workflows/crowdin-upload.yml
vendored
1
.github/workflows/crowdin-upload.yml
vendored
|
@ -14,7 +14,6 @@ on:
|
||||||
- config/locales/devise.en.yml
|
- config/locales/devise.en.yml
|
||||||
- config/locales/doorkeeper.en.yml
|
- config/locales/doorkeeper.en.yml
|
||||||
- .github/workflows/crowdin-upload.yml
|
- .github/workflows/crowdin-upload.yml
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
upload-translations:
|
upload-translations:
|
||||||
|
|
|
@ -81,6 +81,3 @@ AUTHORS.md
|
||||||
|
|
||||||
# Process a few selected JS files
|
# Process a few selected JS files
|
||||||
!lint-staged.config.js
|
!lint-staged.config.js
|
||||||
|
|
||||||
# Ignore config YAML files that include ERB/ruby code prettier does not understand
|
|
||||||
/config/email.yml
|
|
||||||
|
|
|
@ -23,6 +23,5 @@ RSpec/SpecFilePathFormat:
|
||||||
ActivityPub: activitypub
|
ActivityPub: activitypub
|
||||||
DeepL: deepl
|
DeepL: deepl
|
||||||
FetchOEmbedService: fetch_oembed_service
|
FetchOEmbedService: fetch_oembed_service
|
||||||
OAuth: oauth
|
|
||||||
OEmbedController: oembed_controller
|
OEmbedController: oembed_controller
|
||||||
OStatus: ostatus
|
OStatus: ostatus
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.77.0.
|
# using RuboCop version 1.76.1.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
@ -28,7 +28,7 @@ Metrics/PerceivedComplexity:
|
||||||
Max: 27
|
Max: 27
|
||||||
|
|
||||||
# This cop supports safe autocorrection (--autocorrect).
|
# This cop supports safe autocorrection (--autocorrect).
|
||||||
# Configuration parameters: AllowedVars, DefaultToNil.
|
# Configuration parameters: AllowedVars.
|
||||||
Style/FetchEnvVar:
|
Style/FetchEnvVar:
|
||||||
Exclude:
|
Exclude:
|
||||||
- 'config/initializers/paperclip.rb'
|
- 'config/initializers/paperclip.rb'
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
3.4.5
|
3.4.4
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import { resolve } from 'node:path';
|
|
||||||
|
|
||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
|
@ -13,27 +11,6 @@ const config: StorybookConfig = {
|
||||||
name: '@storybook/react-vite',
|
name: '@storybook/react-vite',
|
||||||
options: {},
|
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}` })),
|
|
||||||
],
|
|
||||||
viteFinal(config) {
|
|
||||||
// For an unknown reason, Storybook does not use the root
|
|
||||||
// from the Vite config so we need to set it manually.
|
|
||||||
config.root = resolve(__dirname, '../app/javascript');
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|
29
.storybook/preview.ts
Normal file
29
.storybook/preview.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
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;
|
|
@ -1,146 +0,0 @@
|
||||||
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;
|
|
|
@ -1,344 +0,0 @@
|
||||||
/* 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,
|
|
||||||
}
|
|
||||||
}
|
|
41
CHANGELOG.md
41
CHANGELOG.md
|
@ -2,17 +2,7 @@
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [4.4.1] - 2025-07-09
|
## [4.4.0] - UNRELEASED
|
||||||
|
|
||||||
### 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
|
### Added
|
||||||
|
|
||||||
|
@ -33,7 +23,7 @@ All notable changes to this project will be documented in this file.
|
||||||
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.\
|
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.\
|
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
|
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)\
|
- Add ability to reorder and translate server rules (#34637, #34737, #34494, #34756, #34820 and #34997 by @ChaosExAnima and @ClearlyClaire)\
|
||||||
Rules are now shown in the user’s language, if a translation has been set.\
|
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
|
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 emoji from Twemoji 15.1.0, including in the emoji picker/completion (#33395, #34321, #34620, and #34677 by @ChaosExAnima, @ClearlyClaire, @TheEssem, and @eramdam)
|
||||||
|
@ -48,11 +38,8 @@ 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.
|
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 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 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, #34527, #35053, #35115, #35126, #35127 and #35233 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
- **Add terms of service** (#33055, #33233, #33230, #33703, #33699, #33994, #33993, #34105, #34122, #34200, #34527 and #35053 by @ClearlyClaire, @Gargron, @mjankowski, and @oneiros)\
|
||||||
Server administrators can now fill in Terms of Service and notify their users of upcoming changes.
|
Server administrators can now fill in Terms of Service, optionally using a provided template.
|
||||||
- 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)\
|
- **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.\
|
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:
|
The following REST API changes have been made to accommodate this:
|
||||||
|
@ -61,12 +48,10 @@ 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 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 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 interaction modal when trying to interact with a poll while logged out (#32609 by @ThisIsMissEm)
|
||||||
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, #34964, #34033, #35218, #35262 and #35263 by @oneiros)\
|
- **Add experimental FASP support** (#34031, #34415, #34765, #34965, and #34964 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).
|
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)\
|
- 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.
|
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 option to use system scrollbar styling (#32117 by @vmstan)
|
||||||
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
|
- Add hover cards to follow suggestions (#33749 by @ClearlyClaire)
|
||||||
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
|
- Add `t` hotkey for post translations (#33441 by @ClearlyClaire)
|
||||||
|
@ -74,7 +59,7 @@ 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 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)\
|
- 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.
|
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, #35033, #35109 and #35278 by @oneiros)\
|
- Add experimental support for receiving HTTP Message Signatures (RFC9421) (#34814 and #35033 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).
|
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 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)\
|
- Add experimental server-side feature to fetch remote replies (#32615, #34147, #34149, #34151, #34615, #34682, and #34702 by @ClearlyClaire and @sneakers-the-rat)\
|
||||||
|
@ -127,7 +112,7 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
### Changed
|
### 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 navigation panel in Web UI, change layout on narrow screens (#34910, #34987, #35017, #34986, #35029, #35065, #35067 and #35072 by @ClearlyClaire, @Gargron, and @diondiondion)
|
||||||
- Change design of lists in web UI (#32881, #33054, and #33036 by @Gargron)
|
- 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 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, #34865, #34929, #34933, and #35034 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)
|
||||||
|
@ -141,17 +126,15 @@ All notable changes to this project will be documented in this file.
|
||||||
Moderators will still be able to access them while they are kept, but they won't be accessible to the public in the meantime.
|
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 language names in compose box language picker to be localized (#33402 by @c960657)
|
||||||
- Change onboarding flow in web UI (#32998, #33119, #33471 and #34962 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 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 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 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 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 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 `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 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 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, #34732, #35007, #35035 and #35177 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
|
- Change build system from Webpack to Vite (#34454, #34450, #34758, #34768, #34813, #34808, #34837, #34732, #35007 and #35035 by @ChaosExAnima, @ClearlyClaire, @mjankowski, and @renchap)
|
||||||
- Change account creation API to forbid creation from user tokens (#34828 by @ThisIsMissEm)
|
- 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 `/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)\
|
- Change `DEFAULT_LOCALE` to not override unauthenticated users’ browser language (#34535 by @ClearlyClaire)\
|
||||||
|
@ -219,23 +202,17 @@ 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 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 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 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 `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 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 long link names in admin sidebar being truncated (#34727 by @diondiondion)
|
||||||
- Fix admin dashboard crash on specific Elasticsearch connection errors (#34683 by @ClearlyClaire)
|
- 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 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 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 directory scroll position reset (#34560 by @przucidlo)
|
||||||
- Fix needlessly complex SVG paths for oEmbed and logo (#34538 by @edent)
|
- 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 avatar sizing with long account name in some UI elements (#34514 by @gomasy)
|
||||||
- Fix empty menu section in status dropdown (#34431 by @ClearlyClaire)
|
- 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 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 radio buttons not always being correctly centered (#34389 by @ChaosExAnima)
|
||||||
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
|
- Fix visual glitches with adding post filters (#34387 by @ChaosExAnima)
|
||||||
- Fix bugs with upload progress (#34325 by @ChaosExAnima)
|
- Fix bugs with upload progress (#34325 by @ChaosExAnima)
|
||||||
|
@ -243,7 +220,7 @@ All notable changes to this project will be documented in this file.
|
||||||
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
- Fix extra space under left-indented vertical videos (#34313 by @ClearlyClaire)
|
||||||
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
- Fix glitchy iOS media attachment drag interactions (#35057 by @diondiondion)
|
||||||
- Fix zoomed images being blurry in Safari (#35052 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 redundant focus stop within status component in Web UI (#35037 and #35051 by @diondiondion)
|
||||||
- Fix digits in media player time readout not having a consistent width (#35038 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 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 hover card for limited accounts not hiding information as expected (#35024 by @diondiondion)
|
||||||
|
|
|
@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
|
||||||
|
|
||||||
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.5"
|
ARG RUBY_VERSION="3.4.4"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="22"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
|
@ -186,7 +186,7 @@ FROM build AS libvips
|
||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
ARG VIPS_VERSION=8.17.1
|
ARG VIPS_VERSION=8.17.0
|
||||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
|
4
Gemfile
4
Gemfile
|
@ -62,7 +62,7 @@ gem 'inline_svg'
|
||||||
gem 'irb', '~> 1.8'
|
gem 'irb', '~> 1.8'
|
||||||
gem 'kaminari', '~> 1.2'
|
gem 'kaminari', '~> 1.2'
|
||||||
gem 'link_header', '~> 0.0'
|
gem 'link_header', '~> 0.0'
|
||||||
gem 'linzer', '~> 0.7.7'
|
gem 'linzer', '~> 0.7.2'
|
||||||
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
|
||||||
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
|
||||||
gem 'mutex_m'
|
gem 'mutex_m'
|
||||||
|
@ -111,7 +111,7 @@ group :opentelemetry do
|
||||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
gem 'opentelemetry-instrumentation-excon', '~> 0.23.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
gem 'opentelemetry-instrumentation-faraday', '~> 0.27.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.24.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.23.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||||
|
|
124
Gemfile.lock
124
Gemfile.lock
|
@ -90,13 +90,11 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotaterb (4.17.0)
|
annotaterb (4.15.0)
|
||||||
activerecord (>= 6.0.0)
|
|
||||||
activesupport (>= 6.0.0)
|
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.3.2)
|
||||||
aws-partitions (1.1131.0)
|
aws-partitions (1.1103.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
@ -109,19 +107,19 @@ GEM
|
||||||
aws-sdk-core (~> 3, >= 3.210.0)
|
aws-sdk-core (~> 3, >= 3.210.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.11.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
azure-blob (0.5.9.1)
|
azure-blob (0.5.8)
|
||||||
rexml
|
rexml
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcp47_spec (0.2.1)
|
bcp47_spec (0.2.1)
|
||||||
bcrypt (3.1.20)
|
bcrypt (3.1.20)
|
||||||
benchmark (0.4.1)
|
benchmark (0.4.0)
|
||||||
better_errors (2.10.1)
|
better_errors (2.10.1)
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
rouge (>= 1.0.0)
|
rouge (>= 1.0.0)
|
||||||
bigdecimal (3.2.2)
|
bigdecimal (3.1.9)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
binding_of_caller (1.0.1)
|
binding_of_caller (1.0.1)
|
||||||
debug_inspector (>= 1.2.0)
|
debug_inspector (>= 1.2.0)
|
||||||
|
@ -180,7 +178,7 @@ GEM
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.4.1)
|
date (3.4.1)
|
||||||
debug (1.11.0)
|
debug (1.10.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
debug_inspector (1.2.0)
|
debug_inspector (1.2.0)
|
||||||
|
@ -224,24 +222,23 @@ GEM
|
||||||
mail (~> 2.7)
|
mail (~> 2.7)
|
||||||
email_validator (2.2.4)
|
email_validator (2.2.4)
|
||||||
activemodel
|
activemodel
|
||||||
erb (5.0.2)
|
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.2.8)
|
excon (1.2.5)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.5.2)
|
faker (3.5.1)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.2)
|
faraday (2.13.1)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-follow_redirects (0.3.0)
|
faraday-follow_redirects (0.3.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (2.0.2)
|
faraday-httpclient (2.0.1)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
faraday-net_http (3.4.1)
|
faraday-net_http (3.4.0)
|
||||||
net-http (>= 0.5.0)
|
net-http (>= 0.5.0)
|
||||||
fast_blank (1.0.1)
|
fast_blank (1.0.1)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.0)
|
||||||
|
@ -266,14 +263,14 @@ GEM
|
||||||
fog-openstack (1.1.5)
|
fog-openstack (1.1.5)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.1.1)
|
formatador (1.1.0)
|
||||||
forwardable (1.3.3)
|
forwardable (1.3.3)
|
||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.31.1)
|
google-protobuf (4.31.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (>= 13)
|
||||||
googleapis-common-protos-types (1.20.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
|
@ -287,21 +284,21 @@ GEM
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
haml_lint (0.65.0)
|
haml_lint (0.62.0)
|
||||||
haml (>= 5.0)
|
haml (>= 5.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.2.0)
|
hashdiff (1.1.2)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.25.1)
|
hiredis-client (0.24.0)
|
||||||
redis-client (= 0.25.1)
|
redis-client (= 0.24.0)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
|
@ -315,7 +312,7 @@ GEM
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
httplog (1.7.1)
|
httplog (1.7.0)
|
||||||
rack (>= 2.0)
|
rack (>= 2.0)
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
|
@ -335,7 +332,7 @@ GEM
|
||||||
inline_svg (1.10.0)
|
inline_svg (1.10.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
io-console (0.8.1)
|
io-console (0.8.0)
|
||||||
irb (1.15.2)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
|
@ -345,7 +342,7 @@ GEM
|
||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.13.0)
|
json (2.12.2)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
|
@ -365,11 +362,11 @@ GEM
|
||||||
json-ld-preloaded (3.3.1)
|
json-ld-preloaded (3.3.1)
|
||||||
json-ld (~> 3.3)
|
json-ld (~> 3.3)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
json-schema (5.2.1)
|
json-schema (5.1.1)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
bigdecimal (~> 3.1)
|
bigdecimal (~> 3.1)
|
||||||
jsonapi-renderer (0.2.2)
|
jsonapi-renderer (0.2.2)
|
||||||
jwt (2.10.2)
|
jwt (2.10.1)
|
||||||
base64
|
base64
|
||||||
kaminari (1.2.2)
|
kaminari (1.2.2)
|
||||||
activesupport (>= 4.1.0)
|
activesupport (>= 4.1.0)
|
||||||
|
@ -403,7 +400,7 @@ GEM
|
||||||
rexml
|
rexml
|
||||||
link_header (0.0.8)
|
link_header (0.0.8)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
linzer (0.7.7)
|
linzer (0.7.3)
|
||||||
cgi (~> 0.4.2)
|
cgi (~> 0.4.2)
|
||||||
forwardable (~> 1.3, >= 1.3.3)
|
forwardable (~> 1.3, >= 1.3.3)
|
||||||
logger (~> 1.7, >= 1.7.0)
|
logger (~> 1.7, >= 1.7.0)
|
||||||
|
@ -433,7 +430,7 @@ GEM
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
mario-redis-lock (1.2.1)
|
mario-redis-lock (1.2.1)
|
||||||
redis (>= 3.0.5)
|
redis (>= 3.0.5)
|
||||||
matrix (0.4.3)
|
matrix (0.4.2)
|
||||||
memory_profiler (1.1.0)
|
memory_profiler (1.1.0)
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
|
@ -443,11 +440,11 @@ GEM
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.0)
|
||||||
multi_json (1.17.0)
|
multi_json (1.15.0)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.9)
|
net-imap (0.5.8)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.19.0)
|
||||||
|
@ -515,7 +512,7 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_pack (0.12.3)
|
opentelemetry-instrumentation-action_pack (0.12.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
|
@ -553,7 +550,7 @@ GEM
|
||||||
opentelemetry-instrumentation-faraday (0.27.0)
|
opentelemetry-instrumentation-faraday (0.27.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http (0.25.1)
|
opentelemetry-instrumentation-http (0.24.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-http_client (0.23.0)
|
opentelemetry-instrumentation-http_client (0.23.0)
|
||||||
|
@ -597,7 +594,7 @@ GEM
|
||||||
opentelemetry-semantic_conventions (1.11.0)
|
opentelemetry-semantic_conventions (1.11.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.1)
|
||||||
ox (2.14.23)
|
ox (2.14.23)
|
||||||
bigdecimal (>= 3.0)
|
bigdecimal (>= 3.0)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
|
@ -627,10 +624,11 @@ GEM
|
||||||
prism (1.4.0)
|
prism (1.4.0)
|
||||||
prometheus_exporter (2.2.0)
|
prometheus_exporter (2.2.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.2.0)
|
propshaft (1.1.0)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
|
railties (>= 7.0.0)
|
||||||
psych (5.2.6)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
|
@ -681,7 +679,7 @@ GEM
|
||||||
activesupport (= 8.0.2)
|
activesupport (= 8.0.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2)
|
railties (= 8.0.2)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
|
@ -701,23 +699,17 @@ GEM
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.0)
|
||||||
rdf (3.3.4)
|
rdf (3.3.2)
|
||||||
bcp47_spec (~> 0.2)
|
bcp47_spec (~> 0.2)
|
||||||
bigdecimal (~> 3.1, >= 3.1.5)
|
bigdecimal (~> 3.1, >= 3.1.5)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
logger (~> 1.5)
|
|
||||||
ostruct (~> 0.6)
|
|
||||||
readline (~> 0.0)
|
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.14.2)
|
rdoc (6.13.1)
|
||||||
erb
|
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
readline (0.0.4)
|
|
||||||
reline
|
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.25.1)
|
redis-client (0.24.0)
|
||||||
connection_pool
|
connection_pool
|
||||||
redlock (1.3.2)
|
redlock (1.3.2)
|
||||||
redis (>= 3.0.0, < 6.0)
|
redis (>= 3.0.0, < 6.0)
|
||||||
|
@ -737,21 +729,21 @@ GEM
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.0.0)
|
rqrcode_core (2.0.0)
|
||||||
rspec (3.13.1)
|
rspec (3.13.0)
|
||||||
rspec-core (~> 3.13.0)
|
rspec-core (~> 3.13.0)
|
||||||
rspec-expectations (~> 3.13.0)
|
rspec-expectations (~> 3.13.0)
|
||||||
rspec-mocks (~> 3.13.0)
|
rspec-mocks (~> 3.13.0)
|
||||||
rspec-core (3.13.5)
|
rspec-core (3.13.3)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.5)
|
rspec-expectations (3.13.4)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-github (3.0.0)
|
rspec-github (3.0.0)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
rspec-mocks (3.13.5)
|
rspec-mocks (3.13.4)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (8.0.1)
|
rspec-rails (8.0.0)
|
||||||
actionpack (>= 7.2)
|
actionpack (>= 7.2)
|
||||||
activesupport (>= 7.2)
|
activesupport (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
|
@ -764,8 +756,8 @@ GEM
|
||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.3)
|
||||||
rubocop (1.78.0)
|
rubocop (1.76.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
@ -773,10 +765,10 @@ GEM
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.45.1, < 2.0)
|
rubocop-ast (>= 1.45.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.45.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
|
@ -819,7 +811,7 @@ GEM
|
||||||
sanitize (7.0.0)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
scenic (1.9.0)
|
scenic (1.8.0)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
|
@ -850,7 +842,7 @@ GEM
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.2)
|
simplecov-html (0.13.1)
|
||||||
simplecov-lcov (0.8.0)
|
simplecov-lcov (0.8.0)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
|
@ -859,8 +851,8 @@ GEM
|
||||||
stoplight (4.1.1)
|
stoplight (4.1.1)
|
||||||
redlock (~> 1.0)
|
redlock (~> 1.0)
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
strong_migrations (2.4.0)
|
strong_migrations (2.3.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7)
|
||||||
swd (2.0.3)
|
swd (2.0.3)
|
||||||
activesupport (>= 3)
|
activesupport (>= 3)
|
||||||
attr_required (>= 0.0.5)
|
attr_required (>= 0.0.5)
|
||||||
|
@ -870,11 +862,11 @@ GEM
|
||||||
temple (0.10.3)
|
temple (0.10.3)
|
||||||
terminal-table (4.0.0)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 4)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
terrapin (1.1.1)
|
terrapin (1.1.0)
|
||||||
climate_control
|
climate_control
|
||||||
test-prof (1.4.4)
|
test-prof (1.4.4)
|
||||||
thor (1.4.0)
|
thor (1.3.2)
|
||||||
tilt (2.6.1)
|
tilt (2.6.0)
|
||||||
timeout (0.4.3)
|
timeout (0.4.3)
|
||||||
tpm-key_attestation (0.14.1)
|
tpm-key_attestation (0.14.1)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
|
@ -936,7 +928,7 @@ GEM
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
webrick (1.9.1)
|
webrick (1.9.1)
|
||||||
websocket-driver (0.8.0)
|
websocket-driver (0.7.7)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
@ -944,7 +936,7 @@ GEM
|
||||||
xorcist (1.1.3)
|
xorcist (1.1.3)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.7.2)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
|
@ -1012,7 +1004,7 @@ DEPENDENCIES
|
||||||
letter_opener (~> 1.8)
|
letter_opener (~> 1.8)
|
||||||
letter_opener_web (~> 3.0)
|
letter_opener_web (~> 3.0)
|
||||||
link_header (~> 0.0)
|
link_header (~> 0.0)
|
||||||
linzer (~> 0.7.7)
|
linzer (~> 0.7.2)
|
||||||
lograge (~> 0.12)
|
lograge (~> 0.12)
|
||||||
mail (~> 2.8)
|
mail (~> 2.8)
|
||||||
mario-redis-lock (~> 1.2)
|
mario-redis-lock (~> 1.2)
|
||||||
|
@ -1035,7 +1027,7 @@ DEPENDENCIES
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
||||||
opentelemetry-instrumentation-excon (~> 0.23.0)
|
opentelemetry-instrumentation-excon (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-faraday (~> 0.27.0)
|
opentelemetry-instrumentation-faraday (~> 0.27.0)
|
||||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
opentelemetry-instrumentation-http (~> 0.24.0)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.23.0)
|
opentelemetry-instrumentation-http_client (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||||
|
@ -1106,4 +1098,4 @@ RUBY VERSION
|
||||||
ruby 3.4.1p0
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.0
|
2.6.9
|
||||||
|
|
64
README.md
64
README.md
|
@ -17,71 +17,71 @@
|
||||||
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
<img src="https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg" alt="Crowdin" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
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!)
|
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!)
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
|
|
||||||
- [Project homepage 🐘](https://joinmastodon.org)
|
- [Project homepage 🐘](https://joinmastodon.org)
|
||||||
- [Donate to support development 🎁](https://joinmastodon.org/sponsors#donate)
|
- [Support the development via Patreon][patreon]
|
||||||
- [View sponsors](https://joinmastodon.org/sponsors)
|
- [View sponsors](https://joinmastodon.org/sponsors)
|
||||||
- [Blog 📰](https://blog.joinmastodon.org)
|
- [Blog](https://blog.joinmastodon.org)
|
||||||
- [Documentation 📚](https://docs.joinmastodon.org)
|
- [Documentation](https://docs.joinmastodon.org)
|
||||||
- [Official container image 🚢](https://github.com/mastodon/mastodon/pkgs/container/mastodon)
|
- [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
|
||||||
|
|
||||||
## Features
|
## 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%" />
|
||||||
|
|
||||||
**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.
|
**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/)
|
||||||
|
|
||||||
**Real-time, chronological timeline updates** - updates of people you're following appear in real-time in the UI.
|
**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!
|
||||||
|
|
||||||
**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.
|
**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!
|
||||||
|
|
||||||
**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.
|
**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/)
|
||||||
|
|
||||||
**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!
|
**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!
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Tech stack
|
### Tech stack
|
||||||
|
|
||||||
- [Ruby on Rails](https://github.com/rails/rails) powers the REST API and other web pages.
|
- **Ruby on Rails** powers the REST API and other web pages
|
||||||
- [PostgreSQL](https://www.postgresql.org/) is the main database.
|
- **React.js** and **Redux** are used for the dynamic parts of the interface
|
||||||
- [Redis](https://redis.io/) and [Sidekiq](https://sidekiq.org/) are used for caching and queueing.
|
- **Node.js** powers the streaming API
|
||||||
- [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
|
### Requirements
|
||||||
|
|
||||||
- **Ruby** 3.2+
|
|
||||||
- **PostgreSQL** 13+
|
- **PostgreSQL** 13+
|
||||||
- **Redis** 6.2+
|
- **Redis** 6.2+
|
||||||
|
- **Ruby** 3.2+
|
||||||
- **Node.js** 20+
|
- **Node.js** 20+
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Mastodon is **free, open-source software** licensed under **AGPLv3**. We welcome contributions and help from anyone who wants to improve the project.
|
Mastodon is **free, open-source software** licensed under **AGPLv3**.
|
||||||
|
|
||||||
You should read the overall [CONTRIBUTING](https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md) guide, which covers our development processes.
|
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 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.
|
**IRC channel**: #mastodon on [`irc.libera.chat`](https://libera.chat)
|
||||||
|
|
||||||
You can learn about setting up a development environment in the [DEVELOPMENT](docs/DEVELOPMENT.md) documentation.
|
## License
|
||||||
|
|
||||||
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))
|
Copyright (c) 2016-2025 Eugen Rochko (+ [`mastodon authors`](AUTHORS.md))
|
||||||
|
|
||||||
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
Licensed under GNU Affero General Public License as stated in the [LICENSE](LICENSE):
|
||||||
|
|
||||||
```text
|
```
|
||||||
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
Copyright (c) 2016-2025 Eugen Rochko & other Mastodon contributors
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify it under
|
This program is free software: you can redistribute it and/or modify it under
|
||||||
|
@ -97,3 +97,7 @@ details.
|
||||||
You should have received a copy of the GNU Affero General Public License along
|
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/
|
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,9 +13,8 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
||||||
|
|
||||||
## Supported Versions
|
## Supported Versions
|
||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ---------------- |
|
| ------- | --------- |
|
||||||
| 4.4.x | Yes |
|
| 4.3.x | Yes |
|
||||||
| 4.3.x | Yes |
|
| 4.2.x | Yes |
|
||||||
| 4.2.x | Until 2026-01-08 |
|
| < 4.2 | No |
|
||||||
| < 4.2 | No |
|
|
||||||
|
|
|
@ -14,20 +14,16 @@ module Admin
|
||||||
def create
|
def create
|
||||||
authorize @account, :show?
|
authorize @account, :show?
|
||||||
|
|
||||||
@account_action = Admin::AccountAction.new(resource_params)
|
account_action = Admin::AccountAction.new(resource_params)
|
||||||
@account_action.target_account = @account
|
account_action.target_account = @account
|
||||||
@account_action.current_account = current_account
|
account_action.current_account = current_account
|
||||||
|
|
||||||
if @account_action.save
|
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.with_report?
|
||||||
else
|
redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: resource_params[:report_id])
|
||||||
redirect_to admin_account_path(@account.id)
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
@warning_presets = AccountWarningPreset.all
|
redirect_to admin_account_path(@account.id)
|
||||||
|
|
||||||
render :new
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
# 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,9 +14,6 @@ module Admin
|
||||||
|
|
||||||
def show
|
def show
|
||||||
authorize :instance, :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)
|
@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)
|
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
|
||||||
end
|
end
|
||||||
|
@ -55,8 +52,7 @@ module Admin
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_instance
|
def set_instance
|
||||||
domain = params[:id]&.strip
|
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
|
||||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_instances
|
def set_instances
|
||||||
|
|
|
@ -13,9 +13,27 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
case action_from_button
|
case action_from_button
|
||||||
when 'delete', 'mark_as_sensitive'
|
when 'delete', 'mark_as_sensitive'
|
||||||
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
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!
|
||||||
when 'silence', 'suspend'
|
when 'silence', 'suspend'
|
||||||
Admin::AccountAction.new(account_action_params).save!
|
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!
|
||||||
else
|
else
|
||||||
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
|
||||||
end
|
end
|
||||||
|
@ -25,26 +43,6 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
||||||
|
|
||||||
private
|
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
|
def set_report
|
||||||
@report = Report.find(params[:report_id])
|
@report = Report.find(params[:report_id])
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,9 +17,6 @@ module Admin
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
authorize @rule, :update?
|
authorize @rule, :update?
|
||||||
|
|
||||||
missing_languages = RuleTranslation.languages - @rule.translations.pluck(:language)
|
|
||||||
missing_languages.each { |lang| @rule.translations.build(language: lang) }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
|
|
@ -4,7 +4,7 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||||
def index
|
def index
|
||||||
authorize :tag, :review?
|
authorize :tag, :review?
|
||||||
|
|
||||||
@pending_tags_count = pending_tags.async_count
|
@pending_tags_count = Tag.pending_review.async_count
|
||||||
@tags = filtered_tags.page(params[:page])
|
@tags = filtered_tags.page(params[:page])
|
||||||
@form = Trends::TagBatch.new
|
@form = Trends::TagBatch.new
|
||||||
end
|
end
|
||||||
|
@ -22,10 +22,6 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def pending_tags
|
|
||||||
Trends::TagFilter.new(status: :pending_review).results
|
|
||||||
end
|
|
||||||
|
|
||||||
def filtered_tags
|
def filtered_tags
|
||||||
Trends::TagFilter.new(filter_params).results
|
Trends::TagFilter.new(filter_params).results
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,7 +32,7 @@ class Api::V1::FiltersController < Api::BaseController
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
@filter.update!(keyword_params)
|
@filter.update!(keyword_params)
|
||||||
@filter.custom_filter.assign_attributes(filter_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.many?
|
raise Mastodon::ValidationError, I18n.t('filters.errors.deprecated_api_multiple_keywords') if @filter.custom_filter.changed? && @filter.custom_filter.keywords.count > 1
|
||||||
|
|
||||||
@filter.custom_filter.save!
|
@filter.custom_filter.save!
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,9 +15,8 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
|
||||||
if params[:date].present?
|
if params[:date].present?
|
||||||
TermsOfService.published.find_by!(effective_date: params[:date])
|
TermsOfService.published.find_by!(effective_date: params[:date])
|
||||||
else
|
else
|
||||||
TermsOfService.current
|
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
not_found if @terms_of_service.nil?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V2::SearchController < Api::BaseController
|
class Api::V2::SearchController < Api::BaseController
|
||||||
include AsyncRefreshesConcern
|
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
RESULTS_LIMIT = 20
|
RESULTS_LIMIT = 20
|
||||||
|
@ -14,7 +13,6 @@ class Api::V2::SearchController < Api::BaseController
|
||||||
before_action :remote_resolve_error, if: :remote_resolve_requested?
|
before_action :remote_resolve_error, if: :remote_resolve_requested?
|
||||||
end
|
end
|
||||||
before_action :require_valid_pagination_options!
|
before_action :require_valid_pagination_options!
|
||||||
before_action :handle_fasp_requests
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@search = Search.new(search_results)
|
@search = Search.new(search_results)
|
||||||
|
@ -39,21 +37,6 @@ class Api::V2::SearchController < Api::BaseController
|
||||||
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401
|
render json: { error: 'Search queries that resolve remote resources are not supported without authentication' }, status: 401
|
||||||
end
|
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?
|
def remote_resolve_requested?
|
||||||
truthy_param?(:resolve)
|
truthy_param?(:resolve)
|
||||||
end
|
end
|
||||||
|
@ -75,8 +58,7 @@ class Api::V2::SearchController < Api::BaseController
|
||||||
search_params.merge(
|
search_params.merge(
|
||||||
resolve: truthy_param?(:resolve),
|
resolve: truthy_param?(:resolve),
|
||||||
exclude_unreviewed: truthy_param?(:exclude_unreviewed),
|
exclude_unreviewed: truthy_param?(:exclude_unreviewed),
|
||||||
following: truthy_param?(:following),
|
following: truthy_param?(:following)
|
||||||
query_fasp: @query_fasp
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -98,7 +98,7 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def after_sign_out_path_for(_resource_or_scope)
|
def after_sign_out_path_for(_resource_or_scope)
|
||||||
if ENV['OMNIAUTH_ONLY'] == 'true' && Rails.configuration.x.omniauth.oidc_enabled?
|
if ENV['OMNIAUTH_ONLY'] == 'true' && ENV['OIDC_ENABLED'] == 'true'
|
||||||
'/auth/auth/openid_connect/logout'
|
'/auth/auth/openid_connect/logout'
|
||||||
else
|
else
|
||||||
new_user_session_path
|
new_user_session_path
|
||||||
|
|
|
@ -38,7 +38,8 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
private
|
private
|
||||||
|
|
||||||
def record_login_activity
|
def record_login_activity
|
||||||
@user.login_activities.create(
|
LoginActivity.create(
|
||||||
|
user: @user,
|
||||||
success: true,
|
success: true,
|
||||||
authentication_method: :omniauth,
|
authentication_method: :omniauth,
|
||||||
provider: @provider,
|
provider: @provider,
|
||||||
|
|
|
@ -151,11 +151,12 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
flash.delete(:notice)
|
flash.delete(:notice)
|
||||||
|
|
||||||
user.login_activities.create(
|
LoginActivity.create(
|
||||||
request_details.merge(
|
user: user,
|
||||||
authentication_method: security_measure,
|
success: true,
|
||||||
success: true
|
authentication_method: security_measure,
|
||||||
)
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
UserMailer.suspicious_sign_in(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later! if @login_is_suspicious
|
||||||
|
@ -166,12 +167,13 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_authentication_failure(user, security_measure, failure_reason)
|
def on_authentication_failure(user, security_measure, failure_reason)
|
||||||
user.login_activities.create(
|
LoginActivity.create(
|
||||||
request_details.merge(
|
user: user,
|
||||||
authentication_method: security_measure,
|
success: false,
|
||||||
failure_reason: failure_reason,
|
authentication_method: security_measure,
|
||||||
success: false
|
failure_reason: failure_reason,
|
||||||
)
|
ip: request.remote_ip,
|
||||||
|
user_agent: request.user_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only send a notification email every hour at most
|
# Only send a notification email every hour at most
|
||||||
|
@ -180,13 +182,6 @@ class Auth::SessionsController < Devise::SessionsController
|
||||||
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_details
|
|
||||||
{
|
|
||||||
ip: request.remote_ip,
|
|
||||||
user_agent: request.user_agent,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def second_factor_attempts_key(user)
|
def second_factor_attempts_key(user)
|
||||||
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
"2fa_auth_attempts:#{user.id}:#{Time.now.utc.hour}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -64,9 +64,6 @@ module SignatureVerification
|
||||||
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
return (@signed_request_actor = actor) if signed_request.verified?(actor)
|
||||||
|
|
||||||
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri}"
|
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
|
rescue Mastodon::SignatureVerificationError => e
|
||||||
fail_with! e.message
|
fail_with! e.message
|
||||||
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
rescue *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||||
|
@ -85,7 +82,7 @@ module SignatureVerification
|
||||||
end
|
end
|
||||||
|
|
||||||
def actor_from_key_id
|
def actor_from_key_id
|
||||||
key_id = signed_request.key_id
|
key_id = signature_key_id
|
||||||
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
|
||||||
|
|
||||||
if domain_not_allowed?(domain)
|
if domain_not_allowed?(domain)
|
||||||
|
|
|
@ -50,13 +50,6 @@ module WebAppControllerConcern
|
||||||
return unless current_user&.require_tos_interstitial?
|
return unless current_user&.require_tos_interstitial?
|
||||||
|
|
||||||
@terms_of_service = TermsOfService.published.first
|
@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'
|
render 'terms_of_service_interstitial/show', layout: 'auth'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
||||||
skip_before_action :authenticate_resource_owner!
|
skip_before_action :authenticate_resource_owner!
|
||||||
|
|
||||||
before_action :store_current_location
|
before_action :store_current_location
|
||||||
before_action :authenticate_resource_owner!
|
before_action :authenticate_resource_owner!
|
||||||
|
|
||||||
layout 'modal'
|
|
||||||
|
|
||||||
content_security_policy do |p|
|
content_security_policy do |p|
|
||||||
p.form_action(false)
|
p.form_action(false)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class OAuth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicationsController
|
||||||
skip_before_action :authenticate_resource_owner!
|
skip_before_action :authenticate_resource_owner!
|
||||||
|
|
||||||
before_action :store_current_location
|
before_action :store_current_location
|
||||||
|
@ -11,8 +11,6 @@ class OAuth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
|
||||||
|
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
layout 'admin'
|
|
||||||
|
|
||||||
include Localized
|
include Localized
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class OAuth::TokensController < Doorkeeper::TokensController
|
class Oauth::TokensController < Doorkeeper::TokensController
|
||||||
def revoke
|
def revoke
|
||||||
unsubscribe_for_token if token.present? && authorized? && token.accessible?
|
unsubscribe_for_token if token.present? && authorized? && token.accessible?
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class OAuth::UserinfoController < Api::BaseController
|
class Oauth::UserinfoController < Api::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :profile }, only: [:show]
|
before_action -> { doorkeeper_authorize! :profile }, only: [:show]
|
||||||
before_action :require_user!
|
before_action :require_user!
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@account = current_account
|
@account = current_account
|
||||||
render json: @account, serializer: OAuthUserinfoSerializer
|
render json: @account, serializer: OauthUserinfoSerializer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,6 @@ class Settings::LoginActivitiesController < Settings::BaseController
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@login_activities = current_user.login_activities.order(id: :desc).page(params[:page])
|
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module WellKnown
|
module WellKnown
|
||||||
class OAuthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
class OauthMetadataController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||||
include CacheConcern
|
include CacheConcern
|
||||||
|
|
||||||
# Prevent `active_model_serializer`'s `ActionController::Serialization` from calling `current_user`
|
# 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,
|
# new OAuth scopes are added), we don't use expires_in to cache upstream,
|
||||||
# instead just caching in the rails cache:
|
# instead just caching in the rails cache:
|
||||||
render_with_cache(
|
render_with_cache(
|
||||||
json: ::OAuthMetadataPresenter.new,
|
json: ::OauthMetadataPresenter.new,
|
||||||
serializer: ::OAuthMetadataSerializer,
|
serializer: ::OauthMetadataSerializer,
|
||||||
content_type: 'application/json',
|
content_type: 'application/json',
|
||||||
expires_in: 15.minutes
|
expires_in: 15.minutes
|
||||||
)
|
)
|
||||||
|
|
|
@ -66,7 +66,7 @@ module ApplicationHelper
|
||||||
|
|
||||||
def provider_sign_in_link(provider)
|
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)
|
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: "btn button-#{provider}", method: :post
|
link_to label, omniauth_authorize_path(:user, provider), class: "button button-#{provider}", method: :post
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale_direction
|
def locale_direction
|
||||||
|
|
|
@ -26,12 +26,6 @@ module ContextHelper
|
||||||
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
|
||||||
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
|
||||||
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
|
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: {
|
interaction_policies: {
|
||||||
'gts' => 'https://gotosocial.org/ns#',
|
'gts' => 'https://gotosocial.org/ns#',
|
||||||
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
|
||||||
|
|
|
@ -1,30 +1,12 @@
|
||||||
import { createAction } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apiGetTag,
|
apiGetTag,
|
||||||
apiFollowTag,
|
apiFollowTag,
|
||||||
apiUnfollowTag,
|
apiUnfollowTag,
|
||||||
apiFeatureTag,
|
apiFeatureTag,
|
||||||
apiUnfeatureTag,
|
apiUnfeatureTag,
|
||||||
apiGetFollowedTags,
|
|
||||||
} from 'mastodon/api/tags';
|
} from 'mastodon/api/tags';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
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(
|
export const fetchHashtag = createDataLoadingThunk(
|
||||||
'tags/fetch',
|
'tags/fetch',
|
||||||
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
({ tagId }: { tagId: string }) => apiGetTag(tagId),
|
||||||
|
@ -33,9 +15,6 @@ export const fetchHashtag = createDataLoadingThunk(
|
||||||
export const followHashtag = createDataLoadingThunk(
|
export const followHashtag = createDataLoadingThunk(
|
||||||
'tags/follow',
|
'tags/follow',
|
||||||
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
({ tagId }: { tagId: string }) => apiFollowTag(tagId),
|
||||||
(_, { dispatch }) => {
|
|
||||||
void dispatch(markFollowedHashtagsStale());
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const unfollowHashtag = createDataLoadingThunk(
|
export const unfollowHashtag = createDataLoadingThunk(
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import renderer from 'react-test-renderer';
|
import renderer from 'react-test-renderer';
|
||||||
|
|
||||||
import { render, fireEvent, screen } from '@/testing/rendering';
|
import { render, fireEvent, screen } from 'mastodon/test_helpers';
|
||||||
|
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
|
|
||||||
|
|
|
@ -1,120 +0,0 @@
|
||||||
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,30 +1,12 @@
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||||
|
|
||||||
interface AccountBioProps {
|
export const AccountBio: React.FC<{
|
||||||
note: string;
|
note: string;
|
||||||
className: string;
|
className: string;
|
||||||
dropdownAccountId?: string;
|
}> = ({ note, className }) => {
|
||||||
}
|
const handleClick = useLinks();
|
||||||
|
|
||||||
export const AccountBio: React.FC<AccountBioProps> = ({
|
if (note.length === 0 || note === '<p></p>') {
|
||||||
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,28 +15,6 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||||
className={`${className} translate`}
|
className={`${className} translate`}
|
||||||
dangerouslySetInnerHTML={{ __html: note }}
|
dangerouslySetInnerHTML={{ __html: note }}
|
||||||
onClickCapture={handleClick}
|
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,7 +33,6 @@ export const AltTextBadge: React.FC<{
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type='button'
|
|
||||||
ref={anchorRef}
|
ref={anchorRef}
|
||||||
className='media-gallery__alt__label'
|
className='media-gallery__alt__label'
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|
|
@ -162,14 +162,6 @@ const AutosuggestTextarea = forwardRef(({
|
||||||
}
|
}
|
||||||
}, [suggestions, textareaRef, setSuggestionsHidden]);
|
}, [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) => {
|
const renderSuggestion = (suggestion, i) => {
|
||||||
let inner, key;
|
let inner, key;
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ const meta = {
|
||||||
compact: false,
|
compact: false,
|
||||||
dangerous: false,
|
dangerous: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
loading: false,
|
|
||||||
onClick: fn(),
|
onClick: fn(),
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
|
@ -37,11 +36,19 @@ export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
|
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
|
||||||
const button = await canvas.findByRole('button');
|
await userEvent.click(canvas.getByRole('button'));
|
||||||
await userEvent.click(button);
|
|
||||||
await expect(args.onClick).toHaveBeenCalled();
|
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 = {
|
export const Primary: Story = {
|
||||||
args: {
|
args: {
|
||||||
children: 'Primary button',
|
children: 'Primary button',
|
||||||
|
@ -73,18 +80,6 @@ export const Dangerous: Story = {
|
||||||
play: buttonTest,
|
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 = {
|
export const PrimaryDisabled: Story = {
|
||||||
args: {
|
args: {
|
||||||
...Primary.args,
|
...Primary.args,
|
||||||
|
@ -100,24 +95,3 @@ export const SecondaryDisabled: Story = {
|
||||||
},
|
},
|
||||||
play: disabledButtonTest,
|
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,15 +3,12 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|
||||||
|
|
||||||
interface BaseProps
|
interface BaseProps
|
||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
loading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||||
|
@ -37,7 +34,6 @@ export const Button: React.FC<Props> = ({
|
||||||
secondary,
|
secondary,
|
||||||
compact,
|
compact,
|
||||||
dangerous,
|
dangerous,
|
||||||
loading,
|
|
||||||
className,
|
className,
|
||||||
title,
|
title,
|
||||||
text,
|
text,
|
||||||
|
@ -46,18 +42,13 @@ export const Button: React.FC<Props> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
|
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
|
||||||
(e) => {
|
(e) => {
|
||||||
if (disabled || loading) {
|
if (!disabled && onClick) {
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
} else if (onClick) {
|
|
||||||
onClick(e);
|
onClick(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[disabled, loading, onClick],
|
[disabled, onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const label = text ?? children;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
|
@ -65,27 +56,14 @@ export const Button: React.FC<Props> = ({
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
'button--dangerous': dangerous,
|
'button--dangerous': dangerous,
|
||||||
loading,
|
|
||||||
})}
|
})}
|
||||||
// Disabled buttons can't have focus, so we don't really
|
disabled={disabled}
|
||||||
// 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}
|
onClick={handleClick}
|
||||||
title={title}
|
title={title}
|
||||||
type={type}
|
type={type}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{text ?? children}
|
||||||
<>
|
|
||||||
<span className='button__label-wrapper'>{label}</span>
|
|
||||||
<LoadingIndicator role='none' />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,7 +18,7 @@ import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
|
||||||
import { useAppHistory } from './router';
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
export const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||||
moveLeft: {
|
moveLeft: {
|
||||||
|
|
|
@ -5,7 +5,6 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
cloneElement,
|
cloneElement,
|
||||||
Children,
|
Children,
|
||||||
useId,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -17,7 +16,6 @@ import Overlay from 'react-overlays/Overlay';
|
||||||
import type {
|
import type {
|
||||||
OffsetValue,
|
OffsetValue,
|
||||||
UsePopperOptions,
|
UsePopperOptions,
|
||||||
Placement,
|
|
||||||
} from 'react-overlays/esm/usePopper';
|
} from 'react-overlays/esm/usePopper';
|
||||||
|
|
||||||
import { fetchRelationships } from 'mastodon/actions/accounts';
|
import { fetchRelationships } from 'mastodon/actions/accounts';
|
||||||
|
@ -297,11 +295,6 @@ interface DropdownProps<Item = MenuItem> {
|
||||||
title?: string;
|
title?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
placement?: Placement;
|
|
||||||
/**
|
|
||||||
* Prevent the `ScrollableList` with this scrollKey
|
|
||||||
* from being scrolled while the dropdown is open
|
|
||||||
*/
|
|
||||||
scrollKey?: string;
|
scrollKey?: string;
|
||||||
status?: ImmutableMap<string, unknown>;
|
status?: ImmutableMap<string, unknown>;
|
||||||
forceDropdown?: boolean;
|
forceDropdown?: boolean;
|
||||||
|
@ -323,7 +316,6 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
title = 'Menu',
|
title = 'Menu',
|
||||||
disabled,
|
disabled,
|
||||||
scrollable,
|
scrollable,
|
||||||
placement = 'bottom',
|
|
||||||
status,
|
status,
|
||||||
forceDropdown = false,
|
forceDropdown = false,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
@ -339,15 +331,16 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
);
|
);
|
||||||
const [currentId] = useState(id++);
|
const [currentId] = useState(id++);
|
||||||
const open = currentId === openDropdownId;
|
const open = currentId === openDropdownId;
|
||||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
const activeElement = useRef<HTMLElement | null>(null);
|
||||||
const menuId = useId();
|
const targetRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const prefetchAccountId = status
|
const prefetchAccountId = status
|
||||||
? status.getIn(['account', 'id'])
|
? status.getIn(['account', 'id'])
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (buttonRef.current) {
|
if (activeElement.current) {
|
||||||
buttonRef.current.focus({ preventScroll: true });
|
activeElement.current.focus({ preventScroll: true });
|
||||||
|
activeElement.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(
|
dispatch(
|
||||||
|
@ -382,7 +375,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
[handleClose, onItemClick, items],
|
[handleClose, onItemClick, items],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleDropdown = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
const { type } = e;
|
const { type } = e;
|
||||||
|
|
||||||
|
@ -430,6 +423,38 @@ 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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (currentId === openDropdownId) {
|
if (currentId === openDropdownId) {
|
||||||
|
@ -440,16 +465,14 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
|
|
||||||
let button: React.ReactElement;
|
let button: React.ReactElement;
|
||||||
|
|
||||||
const buttonProps = {
|
|
||||||
disabled,
|
|
||||||
onClick: toggleDropdown,
|
|
||||||
'aria-expanded': open,
|
|
||||||
'aria-controls': menuId,
|
|
||||||
ref: buttonRef,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (children) {
|
if (children) {
|
||||||
button = cloneElement(Children.only(children), buttonProps);
|
button = cloneElement(Children.only(children), {
|
||||||
|
onClick: handleClick,
|
||||||
|
onMouseDown: handleMouseDown,
|
||||||
|
onKeyDown: handleButtonKeyDown,
|
||||||
|
onKeyPress: handleKeyPress,
|
||||||
|
ref: targetRef,
|
||||||
|
});
|
||||||
} else if (icon && iconComponent) {
|
} else if (icon && iconComponent) {
|
||||||
button = (
|
button = (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
@ -457,7 +480,12 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
iconComponent={iconComponent}
|
iconComponent={iconComponent}
|
||||||
title={title}
|
title={title}
|
||||||
active={open}
|
active={open}
|
||||||
{...buttonProps}
|
disabled={disabled}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onKeyDown={handleButtonKeyDown}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
ref={targetRef}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -471,13 +499,13 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
<Overlay
|
<Overlay
|
||||||
show={open}
|
show={open}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
placement={placement}
|
placement='bottom'
|
||||||
flip
|
flip
|
||||||
target={buttonRef}
|
target={targetRef}
|
||||||
popperConfig={popperConfig}
|
popperConfig={popperConfig}
|
||||||
>
|
>
|
||||||
{({ props, arrowProps, placement }) => (
|
{({ props, arrowProps, placement }) => (
|
||||||
<div {...props} id={menuId}>
|
<div {...props}>
|
||||||
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
<div className={`dropdown-animation dropdown-menu ${placement}`}>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-menu__arrow ${placement}`}
|
className={`dropdown-menu__arrow ${placement}`}
|
||||||
|
|
|
@ -13,13 +13,14 @@ interface Props extends React.SVGProps<SVGSVGElement> {
|
||||||
children?: never;
|
children?: never;
|
||||||
id: string;
|
id: string;
|
||||||
icon: IconProp;
|
icon: IconProp;
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Icon: React.FC<Props> = ({
|
export const Icon: React.FC<Props> = ({
|
||||||
id,
|
id,
|
||||||
icon: IconComponent,
|
icon: IconComponent,
|
||||||
className,
|
className,
|
||||||
'aria-label': ariaLabel,
|
title: titleProp,
|
||||||
...other
|
...other
|
||||||
}) => {
|
}) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
@ -33,19 +34,18 @@ export const Icon: React.FC<Props> = ({
|
||||||
IconComponent = CheckBoxOutlineBlankIcon;
|
IconComponent = CheckBoxOutlineBlankIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ariaHidden = ariaLabel ? undefined : true;
|
const ariaHidden = titleProp ? undefined : true;
|
||||||
const role = !ariaHidden ? 'img' : undefined;
|
const role = !ariaHidden ? 'img' : undefined;
|
||||||
|
|
||||||
// Set the title to an empty string to remove the built-in SVG one if any
|
// 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
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
const title = ariaLabel || '';
|
const title = titleProp || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconComponent
|
<IconComponent
|
||||||
className={classNames('icon', `icon-${id}`, className)}
|
className={classNames('icon', `icon-${id}`, className)}
|
||||||
title={title}
|
title={title}
|
||||||
aria-hidden={ariaHidden}
|
aria-hidden={ariaHidden}
|
||||||
aria-label={ariaLabel}
|
|
||||||
role={role}
|
role={role}
|
||||||
{...other}
|
{...other}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -14,6 +14,7 @@ interface Props {
|
||||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
@ -44,6 +45,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
activeStyle,
|
activeStyle,
|
||||||
onClick,
|
onClick,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
|
onKeyPress,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
active = false,
|
active = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
@ -83,6 +85,16 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
[disabled, onClick],
|
[disabled, onClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> =
|
||||||
|
useCallback(
|
||||||
|
(e) => {
|
||||||
|
if (!disabled) {
|
||||||
|
onKeyPress?.(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onKeyPress],
|
||||||
|
);
|
||||||
|
|
||||||
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> =
|
||||||
useCallback(
|
useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
@ -149,6 +161,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onKeyPress={handleKeyPress} // eslint-disable-line @typescript-eslint/no-deprecated
|
||||||
style={buttonStyle}
|
style={buttonStyle}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -6,34 +6,15 @@ const messages = defineMessages({
|
||||||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface LoadingIndicatorProps {
|
export const LoadingIndicator: React.FC = () => {
|
||||||
/**
|
|
||||||
* 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 intl = useIntl();
|
||||||
|
|
||||||
const a11yProps =
|
|
||||||
role === 'progressbar'
|
|
||||||
? ({
|
|
||||||
role,
|
|
||||||
'aria-busy': true,
|
|
||||||
'aria-live': 'polite',
|
|
||||||
} as const)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='loading-indicator'
|
className='loading-indicator'
|
||||||
{...a11yProps}
|
role='progressbar'
|
||||||
|
aria-busy
|
||||||
|
aria-live='polite'
|
||||||
aria-label={intl.formatMessage(messages.loading)}
|
aria-label={intl.formatMessage(messages.loading)}
|
||||||
>
|
>
|
||||||
<CircularProgress size={50} strokeWidth={6} />
|
<CircularProgress size={50} strokeWidth={6} />
|
||||||
|
|
|
@ -318,7 +318,7 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
||||||
id='check'
|
id='check'
|
||||||
icon={CheckIcon}
|
icon={CheckIcon}
|
||||||
className='poll__voted__mark'
|
className='poll__voted__mark'
|
||||||
aria-label={intl.formatMessage(messages.voted)}
|
title={intl.formatMessage(messages.voted)}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -301,11 +301,7 @@ class Status extends ImmutablePureComponent {
|
||||||
if (newTab) {
|
if (newTab) {
|
||||||
window.open(path, '_blank', 'noopener');
|
window.open(path, '_blank', 'noopener');
|
||||||
} else {
|
} else {
|
||||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
history.push(path);
|
||||||
history.replace(path);
|
|
||||||
} else {
|
|
||||||
history.push(path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -8,10 +8,6 @@ export enum BannerVariant {
|
||||||
Filter = 'filter',
|
Filter = 'filter',
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopPropagation: MouseEventHandler = (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const StatusBanner: React.FC<{
|
export const StatusBanner: React.FC<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
variant: BannerVariant;
|
variant: BannerVariant;
|
||||||
|
@ -42,7 +38,6 @@ export const StatusBanner: React.FC<{
|
||||||
: 'content-warning content-warning--filter'
|
: 'content-warning content-warning--filter'
|
||||||
}
|
}
|
||||||
onClick={forwardClick}
|
onClick={forwardClick}
|
||||||
onMouseUp={stopPropagation}
|
|
||||||
>
|
>
|
||||||
<p id={descriptionId}>{children}</p>
|
<p id={descriptionId}>{children}</p>
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
||||||
<Icon
|
<Icon
|
||||||
id={visibilityIcon.icon}
|
id={visibilityIcon.icon}
|
||||||
icon={visibilityIcon.iconComponent}
|
icon={visibilityIcon.iconComponent}
|
||||||
aria-label={visibilityIcon.text}
|
title={visibilityIcon.text}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,7 +18,6 @@ import initialState, { title as siteTitle } from 'mastodon/initial_state';
|
||||||
import { IntlProvider } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { store } from 'mastodon/store';
|
import { store } from 'mastodon/store';
|
||||||
import { isProduction } from 'mastodon/utils/environment';
|
import { isProduction } from 'mastodon/utils/environment';
|
||||||
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
|
|
||||||
|
|
||||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||||
|
|
||||||
|
@ -59,7 +58,6 @@ export default class Mastodon extends PureComponent {
|
||||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||||
<Route path='/' component={UI} />
|
<Route path='/' component={UI} />
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
|
||||||
</Router>
|
</Router>
|
||||||
|
|
||||||
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
|
<Helmet defaultTitle={title} titleTemplate={`%s - ${title}`} />
|
||||||
|
|
|
@ -14,6 +14,7 @@ import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { IntlProvider } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
import { createPollFromServerJSON } from 'mastodon/models/poll';
|
||||||
|
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||||
|
|
||||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||||
|
|
||||||
|
@ -33,6 +34,9 @@ export default class MediaContainer extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleOpenMedia = (media, index, lang) => {
|
handleOpenMedia = (media, index, lang) => {
|
||||||
|
document.body.classList.add('with-modals--active');
|
||||||
|
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||||
|
|
||||||
this.setState({ media, index, lang });
|
this.setState({ media, index, lang });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -41,10 +45,16 @@ export default class MediaContainer extends PureComponent {
|
||||||
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
|
const { media } = JSON.parse(components[options.componentIndex].getAttribute('data-props'));
|
||||||
const mediaList = fromJS(media);
|
const mediaList = fromJS(media);
|
||||||
|
|
||||||
|
document.body.classList.add('with-modals--active');
|
||||||
|
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
|
||||||
|
|
||||||
this.setState({ media: mediaList, lang, options });
|
this.setState({ media: mediaList, lang, options });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCloseMedia = () => {
|
handleCloseMedia = () => {
|
||||||
|
document.body.classList.remove('with-modals--active');
|
||||||
|
document.documentElement.style.marginRight = '0';
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
media: null,
|
media: null,
|
||||||
index: null,
|
index: null,
|
||||||
|
|
|
@ -6,7 +6,6 @@ import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
@ -769,11 +768,12 @@ export const AccountHeader: React.FC<{
|
||||||
<Icon
|
<Icon
|
||||||
id='lock'
|
id='lock'
|
||||||
icon={LockIcon}
|
icon={LockIcon}
|
||||||
aria-label={intl.formatMessage(messages.account_locked)}
|
title={intl.formatMessage(messages.account_locked)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const content = { __html: account.note_emojified };
|
||||||
const displayNameHtml = { __html: account.display_name_html };
|
const displayNameHtml = { __html: account.display_name_html };
|
||||||
const fields = account.fields;
|
const fields = account.fields;
|
||||||
const isLocal = !account.acct.includes('@');
|
const isLocal = !account.acct.includes('@');
|
||||||
|
@ -897,11 +897,12 @@ export const AccountHeader: React.FC<{
|
||||||
<AccountNote accountId={accountId} />
|
<AccountNote accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AccountBio
|
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||||
note={account.note_emojified}
|
<div
|
||||||
dropdownAccountId={accountId}
|
className='account__header__content translate'
|
||||||
className='account__header__content'
|
dangerouslySetInnerHTML={content}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='account__header__fields'>
|
<div className='account__header__fields'>
|
||||||
<dl>
|
<dl>
|
||||||
|
|
|
@ -261,9 +261,7 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
|
||||||
);
|
);
|
||||||
const lang = useAppSelector(
|
const lang = useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
(state.compose as ImmutableMap<string, unknown>).get(
|
(state.compose as ImmutableMap<string, unknown>).get('lang') as string,
|
||||||
'language',
|
|
||||||
) as string,
|
|
||||||
);
|
);
|
||||||
const focusX =
|
const focusX =
|
||||||
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
(media?.getIn(['meta', 'focus', 'x'], 0) as number | undefined) ?? 0;
|
||||||
|
|
|
@ -12,10 +12,9 @@ import { length } from 'stringz';
|
||||||
|
|
||||||
import { missingAltTextModal } from 'mastodon/initial_state';
|
import { missingAltTextModal } from 'mastodon/initial_state';
|
||||||
|
|
||||||
import AutosuggestInput from 'mastodon/components/autosuggest_input';
|
import AutosuggestInput from '../../../components/autosuggest_input';
|
||||||
import AutosuggestTextarea from 'mastodon/components/autosuggest_textarea';
|
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from '../../../components/button';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
|
||||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||||
import PollButtonContainer from '../containers/poll_button_container';
|
import PollButtonContainer from '../containers/poll_button_container';
|
||||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||||
|
@ -226,8 +225,9 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, onPaste, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
const { intl, onPaste, autoFocus, withoutNavigation, maxChars } = this.props;
|
||||||
const { highlighted } = this.state;
|
const { highlighted } = this.state;
|
||||||
|
const disabled = this.props.isSubmitting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||||
|
@ -246,7 +246,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<AutosuggestInput
|
<AutosuggestInput
|
||||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||||
value={this.props.spoilerText}
|
value={this.props.spoilerText}
|
||||||
disabled={isSubmitting}
|
disabled={disabled}
|
||||||
onChange={this.handleChangeSpoilerText}
|
onChange={this.handleChangeSpoilerText}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
ref={this.setSpoilerText}
|
ref={this.setSpoilerText}
|
||||||
|
@ -268,7 +268,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<AutosuggestTextarea
|
<AutosuggestTextarea
|
||||||
ref={this.textareaRef}
|
ref={this.textareaRef}
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
disabled={isSubmitting}
|
disabled={disabled}
|
||||||
value={this.props.text}
|
value={this.props.text}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
|
@ -305,15 +305,9 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<Button
|
<Button
|
||||||
type='submit'
|
type='submit'
|
||||||
compact
|
compact
|
||||||
|
text={intl.formatMessage(this.props.isEditing ? messages.saveChanges : (this.props.isInReply ? messages.reply : messages.publish))}
|
||||||
disabled={!this.canSubmit()}
|
disabled={!this.canSubmit()}
|
||||||
loading={isSubmitting}
|
/>
|
||||||
>
|
|
||||||
{intl.formatMessage(
|
|
||||||
this.props.isEditing ?
|
|
||||||
messages.saveChanges :
|
|
||||||
(this.props.isInReply ? messages.reply : messages.publish)
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -29,7 +29,6 @@ import { HASHTAG_REGEX } from 'mastodon/utils/hashtags';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
|
||||||
clearSearch: { id: 'search.clear', defaultMessage: 'Clear search' },
|
|
||||||
placeholderSignedIn: {
|
placeholderSignedIn: {
|
||||||
id: 'search.search_or_paste',
|
id: 'search.search_or_paste',
|
||||||
defaultMessage: 'Search or paste URL',
|
defaultMessage: 'Search or paste URL',
|
||||||
|
@ -47,32 +46,8 @@ const labelForRecentSearch = (search: RecentSearch) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ClearButton: React.FC<{
|
const unfocus = () => {
|
||||||
onClick: () => void;
|
document.querySelector('.ui')?.parentElement?.focus();
|
||||||
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 {
|
interface SearchOption {
|
||||||
|
@ -103,11 +78,6 @@ export const Search: React.FC<{
|
||||||
}, [initialValue]);
|
}, [initialValue]);
|
||||||
const searchOptions: SearchOption[] = [];
|
const searchOptions: SearchOption[] = [];
|
||||||
|
|
||||||
const unfocus = useCallback(() => {
|
|
||||||
document.querySelector('.ui')?.parentElement?.focus();
|
|
||||||
setExpanded(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (searchEnabled) {
|
if (searchEnabled) {
|
||||||
searchOptions.push(
|
searchOptions.push(
|
||||||
{
|
{
|
||||||
|
@ -283,7 +253,7 @@ export const Search: React.FC<{
|
||||||
history.push({ pathname: '/search', search: queryParams.toString() });
|
history.push({ pathname: '/search', search: queryParams.toString() });
|
||||||
unfocus();
|
unfocus();
|
||||||
},
|
},
|
||||||
[dispatch, history, unfocus],
|
[dispatch, history],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
|
@ -403,15 +373,14 @@ export const Search: React.FC<{
|
||||||
|
|
||||||
setQuickActions(newQuickActions);
|
setQuickActions(newQuickActions);
|
||||||
},
|
},
|
||||||
[signedIn, dispatch, unfocus, history, submit],
|
[dispatch, history, signedIn, setValue, setQuickActions, submit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleClear = useCallback(() => {
|
const handleClear = useCallback(() => {
|
||||||
setValue('');
|
setValue('');
|
||||||
setQuickActions([]);
|
setQuickActions([]);
|
||||||
setSelectedOption(-1);
|
setSelectedOption(-1);
|
||||||
unfocus();
|
}, [setValue, setQuickActions, setSelectedOption]);
|
||||||
}, [unfocus]);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
|
@ -462,7 +431,7 @@ export const Search: React.FC<{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[unfocus, navigableOptions, selectedOption, submit, value],
|
[navigableOptions, value, selectedOption, setSelectedOption, submit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback(() => {
|
const handleFocus = useCallback(() => {
|
||||||
|
@ -482,38 +451,12 @@ export const Search: React.FC<{
|
||||||
}, [setExpanded, setSelectedOption, singleColumn]);
|
}, [setExpanded, setSelectedOption, singleColumn]);
|
||||||
|
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
|
setExpanded(false);
|
||||||
setSelectedOption(-1);
|
setSelectedOption(-1);
|
||||||
}, [setSelectedOption]);
|
}, [setExpanded, 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 (
|
return (
|
||||||
<form ref={formRef} className={classNames('search', { active: expanded })}>
|
<form className={classNames('search', { active: expanded })}>
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
className='search__input'
|
className='search__input'
|
||||||
|
@ -531,9 +474,21 @@ export const Search: React.FC<{
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClearButton hasValue={hasValue} onClick={handleClear} />
|
<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>
|
||||||
|
|
||||||
<div className='search__popout' tabIndex={-1}>
|
<div className='search__popout'>
|
||||||
{!hasValue && (
|
{!hasValue && (
|
||||||
<>
|
<>
|
||||||
<h4>
|
<h4>
|
||||||
|
|
|
@ -15,34 +15,39 @@ import LogoutIcon from '@/material-icons/400-24px/logout.svg?react';
|
||||||
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
import NotificationsIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
import SettingsIcon from '@/material-icons/400-24px/settings-fill.svg?react';
|
||||||
import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
import { mountCompose, unmountCompose } from 'mastodon/actions/compose';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { mascot, reduceMotion } from 'mastodon/initial_state';
|
import { mascot } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import { messages as navbarMessages } from '../ui/components/navigation_bar';
|
|
||||||
|
|
||||||
import { Search } from './components/search';
|
import { Search } from './components/search';
|
||||||
import ComposeFormContainer from './containers/compose_form_container';
|
import ComposeFormContainer from './containers/compose_form_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
live_feed_public: {
|
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||||
id: 'navigation_bar.live_feed_public',
|
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||||
defaultMessage: 'Live feed (public)',
|
notifications: {
|
||||||
|
id: 'tabs_bar.notifications',
|
||||||
|
defaultMessage: 'Notifications',
|
||||||
},
|
},
|
||||||
live_feed_local: {
|
public: {
|
||||||
id: 'navigation_bar.live_feed_local',
|
id: 'navigation_bar.public_timeline',
|
||||||
defaultMessage: 'Live feed (local)',
|
defaultMessage: 'Federated timeline',
|
||||||
|
},
|
||||||
|
community: {
|
||||||
|
id: 'navigation_bar.community_timeline',
|
||||||
|
defaultMessage: 'Local timeline',
|
||||||
},
|
},
|
||||||
preferences: {
|
preferences: {
|
||||||
id: 'navigation_bar.preferences',
|
id: 'navigation_bar.preferences',
|
||||||
defaultMessage: 'Preferences',
|
defaultMessage: 'Preferences',
|
||||||
},
|
},
|
||||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
|
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
|
type ColumnMap = ImmutableMap<'id' | 'uuid' | 'params', string>;
|
||||||
|
@ -77,27 +82,19 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scrollNavbarIntoView = useCallback(() => {
|
|
||||||
const navbar = document.querySelector('.navigation-panel');
|
|
||||||
navbar?.scrollIntoView({
|
|
||||||
behavior: reduceMotion ? 'auto' : 'smooth',
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (multiColumn) {
|
if (multiColumn) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='drawer'
|
className='drawer'
|
||||||
role='region'
|
role='region'
|
||||||
aria-label={intl.formatMessage(navbarMessages.publish)}
|
aria-label={intl.formatMessage(messages.compose)}
|
||||||
>
|
>
|
||||||
<nav className='drawer__header'>
|
<nav className='drawer__header'>
|
||||||
<Link
|
<Link
|
||||||
to='/getting-started'
|
to='/getting-started'
|
||||||
className='drawer__tab'
|
className='drawer__tab'
|
||||||
title={intl.formatMessage(navbarMessages.menu)}
|
title={intl.formatMessage(messages.start)}
|
||||||
aria-label={intl.formatMessage(navbarMessages.menu)}
|
aria-label={intl.formatMessage(messages.start)}
|
||||||
onClick={scrollNavbarIntoView}
|
|
||||||
>
|
>
|
||||||
<Icon id='bars' icon={MenuIcon} />
|
<Icon id='bars' icon={MenuIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -105,8 +102,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
<Link
|
<Link
|
||||||
to='/home'
|
to='/home'
|
||||||
className='drawer__tab'
|
className='drawer__tab'
|
||||||
title={intl.formatMessage(navbarMessages.home)}
|
title={intl.formatMessage(messages.home_timeline)}
|
||||||
aria-label={intl.formatMessage(navbarMessages.home)}
|
aria-label={intl.formatMessage(messages.home_timeline)}
|
||||||
>
|
>
|
||||||
<Icon id='home' icon={HomeIcon} />
|
<Icon id='home' icon={HomeIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -115,8 +112,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
<Link
|
<Link
|
||||||
to='/notifications'
|
to='/notifications'
|
||||||
className='drawer__tab'
|
className='drawer__tab'
|
||||||
title={intl.formatMessage(navbarMessages.notifications)}
|
title={intl.formatMessage(messages.notifications)}
|
||||||
aria-label={intl.formatMessage(navbarMessages.notifications)}
|
aria-label={intl.formatMessage(messages.notifications)}
|
||||||
>
|
>
|
||||||
<Icon id='bell' icon={NotificationsIcon} />
|
<Icon id='bell' icon={NotificationsIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -125,8 +122,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
<Link
|
<Link
|
||||||
to='/public/local'
|
to='/public/local'
|
||||||
className='drawer__tab'
|
className='drawer__tab'
|
||||||
title={intl.formatMessage(messages.live_feed_local)}
|
title={intl.formatMessage(messages.community)}
|
||||||
aria-label={intl.formatMessage(messages.live_feed_local)}
|
aria-label={intl.formatMessage(messages.community)}
|
||||||
>
|
>
|
||||||
<Icon id='users' icon={PeopleIcon} />
|
<Icon id='users' icon={PeopleIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -135,8 +132,8 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
<Link
|
<Link
|
||||||
to='/public'
|
to='/public'
|
||||||
className='drawer__tab'
|
className='drawer__tab'
|
||||||
title={intl.formatMessage(messages.live_feed_public)}
|
title={intl.formatMessage(messages.public)}
|
||||||
aria-label={intl.formatMessage(messages.live_feed_public)}
|
aria-label={intl.formatMessage(messages.public)}
|
||||||
>
|
>
|
||||||
<Icon id='globe' icon={PublicIcon} />
|
<Icon id='globe' icon={PublicIcon} />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -178,12 +175,12 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
label={intl.formatMessage(navbarMessages.publish)}
|
label={intl.formatMessage(messages.compose)}
|
||||||
>
|
>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
icon='pencil'
|
icon='pencil'
|
||||||
iconComponent={EditIcon}
|
iconComponent={EditIcon}
|
||||||
title={intl.formatMessage(navbarMessages.publish)}
|
title={intl.formatMessage(messages.compose)}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
showBackButton
|
showBackButton
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,110 +0,0 @@
|
||||||
// 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
|
|
||||||
];
|
|
|
@ -1,102 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,77 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,29 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,23 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
|
@ -1,101 +0,0 @@
|
||||||
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();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
|
@ -1,173 +0,0 @@
|
||||||
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('-');
|
|
||||||
}
|
|
|
@ -1,13 +0,0 @@
|
||||||
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, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
@ -7,10 +7,8 @@ import { Helmet } from 'react-helmet';
|
||||||
import { isFulfilled } from '@reduxjs/toolkit';
|
import { isFulfilled } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||||
import {
|
import { unfollowHashtag } from 'mastodon/actions/tags_typed';
|
||||||
fetchFollowedHashtags,
|
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||||
unfollowHashtag,
|
|
||||||
} from 'mastodon/actions/tags_typed';
|
|
||||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
|
@ -18,7 +16,7 @@ import type { ColumnRef } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { Hashtag } from 'mastodon/components/hashtag';
|
import { Hashtag } from 'mastodon/components/hashtag';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
|
||||||
|
@ -61,32 +59,55 @@ const FollowedTag: React.FC<{
|
||||||
|
|
||||||
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
const FollowedTags: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||||
const { tags, loading, next, stale } = useAppSelector(
|
const [loading, setLoading] = useState(false);
|
||||||
(state) => state.followedTags,
|
const [next, setNext] = useState<string | undefined>();
|
||||||
);
|
|
||||||
const hasMore = !!next;
|
const hasMore = !!next;
|
||||||
|
const columnRef = useRef<ColumnRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stale) {
|
setLoading(true);
|
||||||
void dispatch(fetchFollowedHashtags());
|
|
||||||
}
|
void apiGetFollowedTags()
|
||||||
}, [dispatch, stale]);
|
.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]);
|
||||||
|
|
||||||
const handleLoadMore = useCallback(() => {
|
const handleLoadMore = useCallback(() => {
|
||||||
if (next) {
|
setLoading(true);
|
||||||
void dispatch(fetchFollowedHashtags({ next }));
|
|
||||||
}
|
void apiGetFollowedTags(next)
|
||||||
}, [dispatch, 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]);
|
||||||
|
|
||||||
const handleUnfollow = useCallback(
|
const handleUnfollow = useCallback(
|
||||||
(tagId: string) => {
|
(tagId: string) => {
|
||||||
void dispatch(unfollowHashtag({ tagId }));
|
setTags((tags) => tags.filter((tag) => tag.name !== tagId));
|
||||||
},
|
},
|
||||||
[dispatch],
|
[setTags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columnRef = useRef<ColumnRef>(null);
|
|
||||||
const handleHeaderClick = useCallback(() => {
|
const handleHeaderClick = useCallback(() => {
|
||||||
columnRef.current?.scrollTop();
|
columnRef.current?.scrollTop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
179
app/javascript/mastodon/features/getting_started/index.jsx
Normal file
179
app/javascript/mastodon/features/getting_started/index.jsx
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
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)));
|
|
@ -1,32 +0,0 @@
|
||||||
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 } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useIntl, defineMessages } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
import TagIcon from '@/material-icons/400-24px/tag.svg?react';
|
||||||
import { fetchFollowedHashtags } from 'mastodon/actions/tags_typed';
|
import { apiGetFollowedTags } from 'mastodon/api/tags';
|
||||||
|
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||||
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
import { ColumnLink } from 'mastodon/features/ui/components/column_link';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
|
||||||
|
|
||||||
import { CollapsiblePanel } from './collapsible_panel';
|
import { CollapsiblePanel } from './collapsible_panel';
|
||||||
|
|
||||||
|
@ -24,20 +24,25 @@ const messages = defineMessages({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const TAG_LIMIT = 4;
|
|
||||||
|
|
||||||
export const FollowedTagsPanel: React.FC = () => {
|
export const FollowedTagsPanel: React.FC = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const [tags, setTags] = useState<ApiHashtagJSON[]>([]);
|
||||||
const { tags, stale, loading } = useAppSelector(
|
const [loading, setLoading] = useState(false);
|
||||||
(state) => state.followedTags,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stale) {
|
setLoading(true);
|
||||||
void dispatch(fetchFollowedHashtags());
|
|
||||||
}
|
void apiGetFollowedTags(undefined, 4)
|
||||||
}, [dispatch, stale]);
|
.then(({ tags }) => {
|
||||||
|
setTags(tags);
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
return '';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [setLoading, setTags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CollapsiblePanel
|
<CollapsiblePanel
|
||||||
|
@ -49,14 +54,14 @@ export const FollowedTagsPanel: React.FC = () => {
|
||||||
expandTitle={intl.formatMessage(messages.expand)}
|
expandTitle={intl.formatMessage(messages.expand)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
{tags.slice(0, TAG_LIMIT).map((tag) => (
|
{tags.map((tag) => (
|
||||||
<ColumnLink
|
<ColumnLink
|
||||||
transparent
|
|
||||||
icon='hashtag'
|
icon='hashtag'
|
||||||
key={tag.name}
|
key={tag.name}
|
||||||
iconComponent={TagIcon}
|
iconComponent={TagIcon}
|
||||||
text={`#${tag.name}`}
|
text={`#${tag.name}`}
|
||||||
to={`/tags/${tag.name}`}
|
to={`/tags/${tag.name}`}
|
||||||
|
transparent
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CollapsiblePanel>
|
</CollapsiblePanel>
|
||||||
|
|
|
@ -50,22 +50,16 @@ export const MoreLink: React.FC = () => {
|
||||||
|
|
||||||
const menu = useMemo(() => {
|
const menu = useMemo(() => {
|
||||||
const arr: MenuItem[] = [
|
const arr: MenuItem[] = [
|
||||||
|
{ text: intl.formatMessage(messages.filters), href: '/filters' },
|
||||||
|
{ text: intl.formatMessage(messages.mutes), to: '/mutes' },
|
||||||
|
{ text: intl.formatMessage(messages.blocks), to: '/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),
|
text: intl.formatMessage(messages.domainBlocks),
|
||||||
|
to: '/domain_blocks',
|
||||||
},
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
arr.push(
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
href: '/settings/privacy',
|
href: '/settings/privacy',
|
||||||
|
@ -83,7 +77,7 @@ export const MoreLink: React.FC = () => {
|
||||||
href: '/settings/export',
|
href: '/settings/export',
|
||||||
text: intl.formatMessage(messages.importExport),
|
text: intl.formatMessage(messages.importExport),
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|
||||||
if (canManageReports(permissions)) {
|
if (canManageReports(permissions)) {
|
||||||
arr.push(null, {
|
arr.push(null, {
|
||||||
|
@ -112,7 +106,7 @@ export const MoreLink: React.FC = () => {
|
||||||
}, [intl, dispatch, permissions]);
|
}, [intl, dispatch, permissions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown items={menu} placement='bottom-start'>
|
<Dropdown items={menu}>
|
||||||
<button className='column-link column-link--transparent'>
|
<button className='column-link column-link--transparent'>
|
||||||
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
<Icon id='' icon={MoreHorizIcon} className='column-link__icon' />
|
||||||
|
|
||||||
|
|
|
@ -185,169 +185,13 @@ const isFirehoseActive = (
|
||||||
|
|
||||||
const MENU_WIDTH = 284;
|
const MENU_WIDTH = 284;
|
||||||
|
|
||||||
export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
export const NavigationPanel: React.FC = () => {
|
||||||
multiColumn = false,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { signedIn, disabledAccountId } = useIdentity();
|
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 open = useAppSelector((state) => state.navigation.open);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const openable = useBreakpoint('openable');
|
const openable = useBreakpoint('openable');
|
||||||
|
const showSearch = useBreakpoint('full');
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
@ -431,7 +275,6 @@ export const CollapsibleNavigationPanel: React.FC = () => {
|
||||||
filterTaps: true,
|
filterTaps: true,
|
||||||
bounds: isLtrDir ? { left: 0 } : { right: 0 },
|
bounds: isLtrDir ? { left: 0 } : { right: 0 },
|
||||||
rubberband: true,
|
rubberband: true,
|
||||||
enabled: openable,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -450,6 +293,22 @@ export const CollapsibleNavigationPanel: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [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;
|
const showOverlay = openable && open;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -465,7 +324,139 @@ export const CollapsibleNavigationPanel: React.FC = () => {
|
||||||
{...bind()}
|
{...bind()}
|
||||||
style={openable ? { x } : undefined}
|
style={openable ? { x } : undefined}
|
||||||
>
|
>
|
||||||
<NavigationPanel />
|
<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>
|
||||||
</animated.div>
|
</animated.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
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));
|
|
@ -1,74 +0,0 @@
|
||||||
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;
|
|
|
@ -122,93 +122,98 @@ export const PolicyControls: React.FC = () => {
|
||||||
value={notificationPolicy.for_not_following}
|
value={notificationPolicy.for_not_following}
|
||||||
onChange={handleFilterNotFollowing}
|
onChange={handleFilterNotFollowing}
|
||||||
options={options}
|
options={options}
|
||||||
label={
|
>
|
||||||
|
<strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_following_title'
|
id='notifications.policy.filter_not_following_title'
|
||||||
defaultMessage="People you don't follow"
|
defaultMessage="People you don't follow"
|
||||||
/>
|
/>
|
||||||
}
|
</strong>
|
||||||
hint={
|
<span className='hint'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_following_hint'
|
id='notifications.policy.filter_not_following_hint'
|
||||||
defaultMessage='Until you manually approve them'
|
defaultMessage='Until you manually approve them'
|
||||||
/>
|
/>
|
||||||
}
|
</span>
|
||||||
/>
|
</SelectWithLabel>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_not_followers}
|
value={notificationPolicy.for_not_followers}
|
||||||
onChange={handleFilterNotFollowers}
|
onChange={handleFilterNotFollowers}
|
||||||
options={options}
|
options={options}
|
||||||
label={
|
>
|
||||||
|
<strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_followers_title'
|
id='notifications.policy.filter_not_followers_title'
|
||||||
defaultMessage='People not following you'
|
defaultMessage='People not following you'
|
||||||
/>
|
/>
|
||||||
}
|
</strong>
|
||||||
hint={
|
<span className='hint'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_not_followers_hint'
|
id='notifications.policy.filter_not_followers_hint'
|
||||||
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
defaultMessage='Including people who have been following you fewer than {days, plural, one {one day} other {# days}}'
|
||||||
values={{ days: 3 }}
|
values={{ days: 3 }}
|
||||||
/>
|
/>
|
||||||
}
|
</span>
|
||||||
/>
|
</SelectWithLabel>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_new_accounts}
|
value={notificationPolicy.for_new_accounts}
|
||||||
onChange={handleFilterNewAccounts}
|
onChange={handleFilterNewAccounts}
|
||||||
options={options}
|
options={options}
|
||||||
label={
|
>
|
||||||
|
<strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_new_accounts_title'
|
id='notifications.policy.filter_new_accounts_title'
|
||||||
defaultMessage='New accounts'
|
defaultMessage='New accounts'
|
||||||
/>
|
/>
|
||||||
}
|
</strong>
|
||||||
hint={
|
<span className='hint'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_new_accounts.hint'
|
id='notifications.policy.filter_new_accounts.hint'
|
||||||
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
defaultMessage='Created within the past {days, plural, one {one day} other {# days}}'
|
||||||
values={{ days: 30 }}
|
values={{ days: 30 }}
|
||||||
/>
|
/>
|
||||||
}
|
</span>
|
||||||
/>
|
</SelectWithLabel>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_private_mentions}
|
value={notificationPolicy.for_private_mentions}
|
||||||
onChange={handleFilterPrivateMentions}
|
onChange={handleFilterPrivateMentions}
|
||||||
options={options}
|
options={options}
|
||||||
label={
|
>
|
||||||
|
<strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_private_mentions_title'
|
id='notifications.policy.filter_private_mentions_title'
|
||||||
defaultMessage='Unsolicited private mentions'
|
defaultMessage='Unsolicited private mentions'
|
||||||
/>
|
/>
|
||||||
}
|
</strong>
|
||||||
hint={
|
<span className='hint'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_private_mentions_hint'
|
id='notifications.policy.filter_private_mentions_hint'
|
||||||
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender"
|
||||||
/>
|
/>
|
||||||
}
|
</span>
|
||||||
/>
|
</SelectWithLabel>
|
||||||
|
|
||||||
<SelectWithLabel
|
<SelectWithLabel
|
||||||
value={notificationPolicy.for_limited_accounts}
|
value={notificationPolicy.for_limited_accounts}
|
||||||
onChange={handleFilterLimitedAccounts}
|
onChange={handleFilterLimitedAccounts}
|
||||||
options={options}
|
options={options}
|
||||||
label={
|
>
|
||||||
|
<strong>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_limited_accounts_title'
|
id='notifications.policy.filter_limited_accounts_title'
|
||||||
defaultMessage='Moderated accounts'
|
defaultMessage='Moderated accounts'
|
||||||
/>
|
/>
|
||||||
}
|
</strong>
|
||||||
hint={
|
<span className='hint'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='notifications.policy.filter_limited_accounts_hint'
|
id='notifications.policy.filter_limited_accounts_hint'
|
||||||
defaultMessage='Limited by server moderators'
|
defaultMessage='Limited by server moderators'
|
||||||
/>
|
/>
|
||||||
}
|
</span>
|
||||||
/>
|
</SelectWithLabel>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useState, useRef, useId } from 'react';
|
import { useCallback, useState, useRef } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
@ -16,8 +16,6 @@ interface DropdownProps {
|
||||||
options: SelectItem[];
|
options: SelectItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
'aria-labelledby': string;
|
|
||||||
'aria-describedby'?: string;
|
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,32 +24,50 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
options,
|
options,
|
||||||
disabled,
|
disabled,
|
||||||
onChange,
|
onChange,
|
||||||
'aria-labelledby': ariaLabelledBy,
|
|
||||||
'aria-describedby': ariaDescribedBy,
|
|
||||||
placement: initialPlacement = 'bottom-end',
|
placement: initialPlacement = 'bottom-end',
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const activeElementRef = useRef<Element | null>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const containerRef = useRef(null);
|
||||||
const [isOpen, setOpen] = useState<boolean>(false);
|
const [isOpen, setOpen] = useState<boolean>(false);
|
||||||
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
const [placement, setPlacement] = useState<Placement>(initialPlacement);
|
||||||
const uniqueId = useId();
|
|
||||||
const menuId = `${uniqueId}-menu`;
|
|
||||||
const buttonLabelId = `${uniqueId}-button`;
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
if (isOpen && buttonRef.current) {
|
|
||||||
buttonRef.current.focus({ preventScroll: true });
|
|
||||||
}
|
|
||||||
setOpen(false);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (isOpen) {
|
if (
|
||||||
handleClose();
|
isOpen &&
|
||||||
} else {
|
activeElementRef.current &&
|
||||||
setOpen(true);
|
activeElementRef.current instanceof HTMLElement
|
||||||
|
) {
|
||||||
|
activeElementRef.current.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}, [isOpen, handleClose]);
|
|
||||||
|
setOpen(!isOpen);
|
||||||
|
}, [isOpen, setOpen]);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(() => {
|
||||||
|
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
if (!isOpen) activeElementRef.current = document.activeElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (
|
||||||
|
isOpen &&
|
||||||
|
activeElementRef.current &&
|
||||||
|
activeElementRef.current instanceof HTMLElement
|
||||||
|
)
|
||||||
|
activeElementRef.current.focus({ preventScroll: true });
|
||||||
|
setOpen(false);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const handleOverlayEnter = useCallback(
|
const handleOverlayEnter = useCallback(
|
||||||
(state: Partial<PopperState>) => {
|
(state: Partial<PopperState>) => {
|
||||||
|
@ -66,18 +82,13 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
<div ref={containerRef}>
|
<div ref={containerRef}>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
ref={buttonRef}
|
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-controls={menuId}
|
|
||||||
aria-labelledby={`${ariaLabelledBy} ${buttonLabelId}`}
|
|
||||||
aria-describedby={ariaDescribedBy}
|
|
||||||
className={classNames('dropdown-button', { active: isOpen })}
|
className={classNames('dropdown-button', { active: isOpen })}
|
||||||
>
|
>
|
||||||
<span id={buttonLabelId} className='dropdown-button__label'>
|
<span className='dropdown-button__label'>{valueOption?.text}</span>
|
||||||
{valueOption?.text}
|
|
||||||
</span>
|
|
||||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
@ -90,7 +101,7 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
popperConfig={{ strategy: 'fixed', onFirstUpdate: handleOverlayEnter }}
|
||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
<div {...props} id={menuId}>
|
<div {...props}>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}
|
||||||
>
|
>
|
||||||
|
@ -112,8 +123,6 @@ const Dropdown: React.FC<DropdownProps> = ({
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
options: SelectItem[];
|
options: SelectItem[];
|
||||||
label: string | React.ReactElement;
|
|
||||||
hint: string | React.ReactElement;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
@ -121,26 +130,13 @@ interface Props {
|
||||||
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
label,
|
|
||||||
hint,
|
|
||||||
disabled,
|
disabled,
|
||||||
|
children,
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const uniqueId = useId();
|
|
||||||
const labelId = `${uniqueId}-label`;
|
|
||||||
const descId = `${uniqueId}-desc`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// This label is only used for its click-forwarding behaviour,
|
|
||||||
// accessible names are assigned manually
|
|
||||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
|
||||||
<label className='app-form__toggle'>
|
<label className='app-form__toggle'>
|
||||||
<div className='app-form__toggle__label'>
|
<div className='app-form__toggle__label'>{children}</div>
|
||||||
<strong id={labelId}>{label}</strong>
|
|
||||||
<span className='hint' id={descId}>
|
|
||||||
{hint}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='app-form__toggle__toggle'>
|
<div className='app-form__toggle__toggle'>
|
||||||
<div>
|
<div>
|
||||||
|
@ -148,8 +144,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-labelledby={labelId}
|
|
||||||
aria-describedby={descId}
|
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
@ -21,9 +21,6 @@ import { openModal } from 'mastodon/actions/modal';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { useIdentity } from 'mastodon/identity_context';
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
import { me } from 'mastodon/initial_state';
|
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';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -50,11 +47,6 @@ const messages = defineMessages({
|
||||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
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<{
|
export const Footer: React.FC<{
|
||||||
statusId: string;
|
statusId: string;
|
||||||
withOpenButton?: boolean;
|
withOpenButton?: boolean;
|
||||||
|
@ -64,8 +56,7 @@ export const Footer: React.FC<{
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||||
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
|
|
||||||
const accountId = status?.get('account') as string | undefined;
|
const accountId = status?.get('account') as string | undefined;
|
||||||
const account = useAppSelector((state) =>
|
const account = useAppSelector((state) =>
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { render, fireEvent, screen } from '@/testing/rendering';
|
import { render, fireEvent, screen } from 'mastodon/test_helpers';
|
||||||
|
|
||||||
import Column from '../column';
|
import Column from '../column';
|
||||||
|
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
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,6 +16,7 @@ export const ColumnLink: React.FC<{
|
||||||
method?: string;
|
method?: string;
|
||||||
badge?: React.ReactNode;
|
badge?: React.ReactNode;
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
|
optional?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
}> = ({
|
}> = ({
|
||||||
|
@ -29,11 +30,13 @@ export const ColumnLink: React.FC<{
|
||||||
method,
|
method,
|
||||||
badge,
|
badge,
|
||||||
transparent,
|
transparent,
|
||||||
|
optional,
|
||||||
...other
|
...other
|
||||||
}) => {
|
}) => {
|
||||||
const match = useRouteMatch(to ?? '');
|
const match = useRouteMatch(to ?? '');
|
||||||
const className = classNames('column-link', {
|
const className = classNames('column-link', {
|
||||||
'column-link--transparent': transparent,
|
'column-link--transparent': transparent,
|
||||||
|
'column-link--optional': optional,
|
||||||
});
|
});
|
||||||
const badgeElement =
|
const badgeElement =
|
||||||
typeof badge !== 'undefined' ? (
|
typeof badge !== 'undefined' ? (
|
||||||
|
|
|
@ -23,9 +23,9 @@ import { useColumnsContext } from '../util/columns_context';
|
||||||
|
|
||||||
import BundleColumnError from './bundle_column_error';
|
import BundleColumnError from './bundle_column_error';
|
||||||
import { ColumnLoading } from './column_loading';
|
import { ColumnLoading } from './column_loading';
|
||||||
import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
|
import { ComposePanel } from './compose_panel';
|
||||||
import DrawerLoading from './drawer_loading';
|
import DrawerLoading from './drawer_loading';
|
||||||
import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel';
|
import { NavigationPanel } from 'mastodon/features/navigation_panel';
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
'COMPOSE': Compose,
|
'COMPOSE': Compose,
|
||||||
|
@ -124,7 +124,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||||
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
|
<div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
|
||||||
<div className='columns-area__panels__pane__inner'>
|
<div className='columns-area__panels__pane__inner'>
|
||||||
{renderComposePanel && <ComposePanel />}
|
{renderComposePanel && <ComposePanel />}
|
||||||
<RedirectToMobileComposeIfNeeded />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -133,7 +132,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
|
||||||
<div className='columns-area columns-area--mobile'>{children}</div>
|
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollapsibleNavigationPanel />
|
<NavigationPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
@ -7,7 +7,6 @@ import {
|
||||||
mountCompose,
|
mountCompose,
|
||||||
unmountCompose,
|
unmountCompose,
|
||||||
} from 'mastodon/actions/compose';
|
} from 'mastodon/actions/compose';
|
||||||
import { useAppHistory } from 'mastodon/components/router';
|
|
||||||
import ServerBanner from 'mastodon/components/server_banner';
|
import ServerBanner from 'mastodon/components/server_banner';
|
||||||
import { Search } from 'mastodon/features/compose/components/search';
|
import { Search } from 'mastodon/features/compose/components/search';
|
||||||
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
|
||||||
|
@ -55,25 +54,3 @@ export const ComposePanel: React.FC = () => {
|
||||||
</div>
|
</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,7 +13,6 @@ export const ConfirmationModal: React.FC<
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
message: React.ReactNode;
|
message: React.ReactNode;
|
||||||
confirm: React.ReactNode;
|
confirm: React.ReactNode;
|
||||||
cancel?: React.ReactNode;
|
|
||||||
secondary?: React.ReactNode;
|
secondary?: React.ReactNode;
|
||||||
onSecondary?: () => void;
|
onSecondary?: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
|
@ -23,7 +22,6 @@ export const ConfirmationModal: React.FC<
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
confirm,
|
confirm,
|
||||||
cancel,
|
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
secondary,
|
secondary,
|
||||||
|
@ -59,12 +57,10 @@ export const ConfirmationModal: React.FC<
|
||||||
<div className='safety-action-modal__bottom'>
|
<div className='safety-action-modal__bottom'>
|
||||||
<div className='safety-action-modal__actions'>
|
<div className='safety-action-modal__actions'>
|
||||||
<button onClick={handleCancel} className='link-button'>
|
<button onClick={handleCancel} className='link-button'>
|
||||||
{cancel ?? (
|
<FormattedMessage
|
||||||
<FormattedMessage
|
id='confirmation_modal.cancel'
|
||||||
id='confirmation_modal.cancel'
|
defaultMessage='Cancel'
|
||||||
defaultMessage='Cancel'
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{secondary && (
|
{secondary && (
|
||||||
|
|
|
@ -1,104 +0,0 @@
|
||||||
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} />
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
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,10 +1,8 @@
|
||||||
export { ConfirmationModal } from './confirmation_modal';
|
export { ConfirmationModal } from './confirmation_modal';
|
||||||
export { ConfirmDeleteStatusModal } from './delete_status';
|
export { ConfirmDeleteStatusModal } from './delete_status';
|
||||||
export { ConfirmDeleteListModal } from './delete_list';
|
export { ConfirmDeleteListModal } from './delete_list';
|
||||||
export {
|
export { ConfirmReplyModal } from './reply';
|
||||||
ConfirmReplyModal,
|
export { ConfirmEditStatusModal } from './edit_status';
|
||||||
ConfirmEditStatusModal,
|
|
||||||
} from './discard_draft_confirmation';
|
|
||||||
export { ConfirmUnfollowModal } from './unfollow';
|
export { ConfirmUnfollowModal } from './unfollow';
|
||||||
export { ConfirmClearNotificationsModal } from './clear_notifications';
|
export { ConfirmClearNotificationsModal } from './clear_notifications';
|
||||||
export { ConfirmLogOutModal } from './log_out';
|
export { ConfirmLogOutModal } from './log_out';
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
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,6 +20,7 @@ import {
|
||||||
IgnoreNotificationsModal,
|
IgnoreNotificationsModal,
|
||||||
AnnualReportModal,
|
AnnualReportModal,
|
||||||
} from 'mastodon/features/ui/util/async-components';
|
} from 'mastodon/features/ui/util/async-components';
|
||||||
|
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
|
||||||
|
|
||||||
import BundleContainer from '../containers/bundle_container';
|
import BundleContainer from '../containers/bundle_container';
|
||||||
|
|
||||||
|
@ -89,6 +90,20 @@ export default class ModalRoot extends PureComponent {
|
||||||
backgroundColor: null,
|
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 => {
|
setBackgroundColor = color => {
|
||||||
this.setState({ backgroundColor: color });
|
this.setState({ backgroundColor: color });
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { registrationsOpen, sso_redirect } from 'mastodon/initial_state';
|
||||||
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
export const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
|
||||||
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
|
||||||
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
|
publish: { id: 'tabs_bar.publish', defaultMessage: 'New Post' },
|
||||||
|
|
|
@ -142,8 +142,13 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
UNSAFE_componentWillMount () {
|
UNSAFE_componentWillMount () {
|
||||||
document.body.classList.toggle('layout-single-column', this.props.singleColumn);
|
if (this.props.singleColumn) {
|
||||||
document.body.classList.toggle('layout-multiple-columns', !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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps) {
|
||||||
|
@ -195,8 +200,8 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : 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 */}
|
{/* 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}
|
{!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='/getting-started' component={GettingStarted} content={children} />
|
||||||
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
|
||||||
|
|
|
@ -8,14 +8,13 @@ import { openURL } from 'mastodon/actions/search';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
element.classList.contains('mention') &&
|
element.classList.contains('mention');
|
||||||
!element.classList.contains('hashtag');
|
|
||||||
|
|
||||||
const isHashtagClick = (element: HTMLAnchorElement) =>
|
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||||
element.textContent?.[0] === '#' ||
|
element.textContent?.[0] === '#' ||
|
||||||
element.previousSibling?.textContent?.endsWith('#');
|
element.previousSibling?.textContent?.endsWith('#');
|
||||||
|
|
||||||
export const useLinks = (skipHashtags?: boolean) => {
|
export const useLinks = () => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
@ -62,12 +61,12 @@ export const useLinks = (skipHashtags?: boolean) => {
|
||||||
if (isMentionClick(target)) {
|
if (isMentionClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void handleMentionClick(target);
|
void handleMentionClick(target);
|
||||||
} else if (isHashtagClick(target) && !skipHashtags) {
|
} else if (isHashtagClick(target)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleHashtagClick(target);
|
handleHashtagClick(target);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[skipHashtags, handleMentionClick, handleHashtagClick],
|
[handleMentionClick, handleHashtagClick],
|
||||||
);
|
);
|
||||||
|
|
||||||
return handleClick;
|
return handleClick;
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user