mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-27 18:10:58 +00:00
Merge branch 'main' into compose-language-detection
This commit is contained in:
commit
c7a7d9d95a
20
.github/renovate.json5
vendored
20
.github/renovate.json5
vendored
|
|
@ -6,6 +6,7 @@
|
|||
':labels(dependencies)',
|
||||
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
|
||||
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
|
||||
':enableVulnerabilityAlertsWithLabel(security)',
|
||||
],
|
||||
rebaseWhen: 'conflicted',
|
||||
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
|
||||
|
|
@ -93,6 +94,19 @@
|
|||
matchUpdateTypes: ['patch', 'minor'],
|
||||
groupName: 'eslint (non-major)',
|
||||
},
|
||||
{
|
||||
// Group all Storybook-related packages in the same PR
|
||||
matchManagers: ['npm'],
|
||||
matchPackageNames: [
|
||||
'chromatic',
|
||||
'storybook',
|
||||
'@storybook/*',
|
||||
'msw',
|
||||
'msw-storybook-addon',
|
||||
],
|
||||
matchUpdateTypes: ['patch', 'minor'],
|
||||
groupName: 'storybook (non-major)',
|
||||
},
|
||||
{
|
||||
// Group actions/*-artifact in the same PR
|
||||
matchManagers: ['github-actions'],
|
||||
|
|
@ -141,6 +155,12 @@
|
|||
matchUpdateTypes: ['patch', 'minor'],
|
||||
groupName: 'opentelemetry-ruby (non-major)',
|
||||
},
|
||||
{
|
||||
// Group Playwright Ruby & JS deps in the same PR, as they need to be in sync
|
||||
matchManagers: ['bundler', 'npm'],
|
||||
matchPackageNames: ['playwright-ruby-client', 'playwright'],
|
||||
groupName: 'Playwright',
|
||||
},
|
||||
// Add labels depending on package manager
|
||||
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
|
||||
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -25,8 +25,8 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript', 'ruby']
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
language: ['actions', 'javascript', 'ruby']
|
||||
# CodeQL supports [ 'actions', 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# This configuration was generated by
|
||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||
# using RuboCop version 1.79.2.
|
||||
# using RuboCop version 1.80.2.
|
||||
# The point is for the user to remove these configuration records
|
||||
# one by one as the offenses are removed from the code base.
|
||||
# Note that changes in the inspected code, or installation of new
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
3.4.5
|
||||
3.4.6
|
||||
|
|
|
|||
2
.storybook/preview-body.html
Normal file
2
.storybook/preview-body.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<html class="no-reduce-motion">
|
||||
</html>
|
||||
|
|
@ -12,13 +12,14 @@ 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 { reducerWithInitialState } 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';
|
||||
import './styles.css';
|
||||
|
||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||
query: { as: 'json' },
|
||||
|
|
@ -49,12 +50,17 @@ const preview: Preview = {
|
|||
locale: 'en',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters }) => {
|
||||
(Story, { parameters, globals }) => {
|
||||
const { locale } = globals as { locale: string };
|
||||
const { state = {} } = parameters;
|
||||
let reducer = rootReducer;
|
||||
if (typeof state === 'object' && state) {
|
||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
||||
}
|
||||
const reducer = reducerWithInitialState(
|
||||
{
|
||||
meta: {
|
||||
locale,
|
||||
},
|
||||
},
|
||||
state as Record<string, unknown>,
|
||||
);
|
||||
const store = configureStore({
|
||||
reducer,
|
||||
middleware(getDefaultMiddleware) {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.4'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const PACKAGE_VERSION = '2.11.3'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
|
|
@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
|
|||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
|
|
@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
|
|||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
|
|
@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
|
|||
|
||||
// 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).
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId) {
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(event, client, requestId)
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
|
|
@ -204,7 +207,7 @@ async function resolveMainClient(event) {
|
|||
* @param {string} requestId
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId) {
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// 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()
|
||||
|
|
@ -255,6 +258,7 @@ async function getResponse(event, client, requestId) {
|
|||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
8
.storybook/styles.css
Normal file
8
.storybook/styles.css
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -2,6 +2,34 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.4.4] - 2025-09-16
|
||||
|
||||
### Security
|
||||
|
||||
- Update dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix missing memoization in `Web::PushNotificationWorker` (#36085 by @ClearlyClaire)
|
||||
- Fix unresponsive areas around GIFV modals in some cases (#36059 by @ClearlyClaire)
|
||||
- Fix missing `beforeUnload` confirmation when a poll is being authored (#36030 by @ClearlyClaire)
|
||||
- Fix processing of remote edited statuses with new media and no text (#35970 by @unfokus)
|
||||
- Fix polls not being displayed in moderation interface (#35644 and #35933 by @ThisIsMissEm)
|
||||
- Fix WebUI handling of deleted quoted posts (#35909 and #35918 by @ClearlyClaire and @diondiondion)
|
||||
- Fix “Edit” and “Delete & Redraft” on a poll not inserting empty option (#35892 by @ClearlyClaire)
|
||||
- Fix loading of some compatibility CSS on some configurations (#35876 by @shleeable)
|
||||
- Fix HttpLog not being enabled with `RAILS_LOG_LEVEL=debug` (#35833 by @mjankowski)
|
||||
- Fix self-destruct scheduler behavior on some Redis setups (#35823 by @ClearlyClaire)
|
||||
- Fix `tootctl admin create` not bypassing reserved username checks (#35779 by @ClearlyClaire)
|
||||
- Fix interaction policy changes in implicit updates not being saved (#35751 by @ClearlyClaire)
|
||||
- Fix quote revocation not being streamed (#35710 by @ClearlyClaire)
|
||||
- Fix export of large user archives by enabling Zip64 (#35850 by @ClearlyClaire)
|
||||
|
||||
### Changed
|
||||
|
||||
- Change labels for quote policy settings (#35893 by @ClearlyClaire)
|
||||
- Change standalone “Share” page to redirect to web interface after posting (#35763 by @ChaosExAnima)
|
||||
|
||||
## [4.4.3] - 2025-08-05
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# syntax=docker/dockerfile:1.12
|
||||
# syntax=docker/dockerfile:1.18
|
||||
|
||||
# This file is designed for production server deployment, not local development work
|
||||
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/docs/DEVELOPMENT.md#docker
|
||||
|
|
@ -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"]
|
||||
# renovate: datasource=docker depName=docker.io/ruby
|
||||
ARG RUBY_VERSION="3.4.5"
|
||||
ARG RUBY_VERSION="3.4.6"
|
||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||
# renovate: datasource=node-version depName=node
|
||||
ARG NODE_MAJOR_VERSION="22"
|
||||
|
|
@ -183,7 +183,7 @@ FROM build AS libvips
|
|||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.17.1
|
||||
ARG VIPS_VERSION=8.17.2
|
||||
# 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
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ FROM build AS ffmpeg
|
|||
|
||||
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
||||
ARG FFMPEG_VERSION=7.1.1
|
||||
ARG FFMPEG_VERSION=8.0
|
||||
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||
|
||||
|
|
|
|||
9
Gemfile
9
Gemfile
|
|
@ -102,7 +102,7 @@ gem 'rdf-normalize', '~> 0.5'
|
|||
|
||||
gem 'prometheus_exporter', '~> 2.2', require: false
|
||||
|
||||
gem 'opentelemetry-api', '~> 1.6.0'
|
||||
gem 'opentelemetry-api', '~> 1.7.0'
|
||||
|
||||
group :opentelemetry do
|
||||
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
||||
|
|
@ -113,10 +113,10 @@ group :opentelemetry do
|
|||
gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.23.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||
|
|
@ -138,6 +138,7 @@ group :test do
|
|||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
gem 'capybara-playwright-driver'
|
||||
gem 'playwright-ruby-client', '1.55.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
|
||||
|
||||
# Used to reset the database between system tests
|
||||
gem 'database_cleaner-active_record'
|
||||
|
|
|
|||
69
Gemfile.lock
69
Gemfile.lock
|
|
@ -90,7 +90,7 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
annotaterb (4.18.0)
|
||||
annotaterb (4.19.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
|
|
@ -121,7 +121,7 @@ GEM
|
|||
erubi (>= 1.0.0)
|
||||
rack (>= 0.9.0)
|
||||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.2.2)
|
||||
bigdecimal (3.2.3)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
debug_inspector (>= 1.2.0)
|
||||
|
|
@ -164,7 +164,7 @@ GEM
|
|||
cocoon (1.2.15)
|
||||
color_diff (0.1)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
connection_pool (2.5.4)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
|
|
@ -300,8 +300,8 @@ GEM
|
|||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.25.2)
|
||||
redis-client (= 0.25.2)
|
||||
hiredis-client (0.25.3)
|
||||
redis-client (= 0.25.3)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
|
|
@ -438,7 +438,7 @@ GEM
|
|||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2025.0729)
|
||||
mime-types-data (3.2025.0916)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (5.25.5)
|
||||
|
|
@ -450,7 +450,9 @@ GEM
|
|||
net-imap (0.5.9)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.19.0)
|
||||
net-ldap (0.20.0)
|
||||
base64
|
||||
ostruct
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
|
|
@ -458,7 +460,7 @@ GEM
|
|||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.9)
|
||||
nokogiri (1.18.10)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
oj (3.16.11)
|
||||
|
|
@ -497,7 +499,7 @@ GEM
|
|||
openssl (3.3.0)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
opentelemetry-api (1.6.0)
|
||||
opentelemetry-api (1.7.0)
|
||||
opentelemetry-common (0.22.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.30.0)
|
||||
|
|
@ -515,7 +517,7 @@ GEM
|
|||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-action_pack (0.12.3)
|
||||
opentelemetry-instrumentation-action_pack (0.13.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
|
|
@ -559,7 +561,7 @@ GEM
|
|||
opentelemetry-instrumentation-http_client (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (0.23.1)
|
||||
opentelemetry-instrumentation-net_http (0.24.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-pg (0.30.1)
|
||||
|
|
@ -567,13 +569,13 @@ GEM
|
|||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-obfuscation
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rack (0.26.0)
|
||||
opentelemetry-instrumentation-rack (0.27.1)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-instrumentation-rails (0.36.0)
|
||||
opentelemetry-instrumentation-rails (0.37.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.4.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.12.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.13.0)
|
||||
opentelemetry-instrumentation-action_view (~> 0.9.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_record (~> 0.9.0)
|
||||
|
|
@ -589,12 +591,12 @@ GEM
|
|||
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||
opentelemetry-registry (0.4.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.8.1)
|
||||
opentelemetry-sdk (1.9.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-semantic_conventions (1.11.0)
|
||||
opentelemetry-semantic_conventions (1.36.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.3)
|
||||
|
|
@ -607,10 +609,10 @@ GEM
|
|||
parslet (2.0.0)
|
||||
pastel (0.8.0)
|
||||
tty-color (~> 0.5)
|
||||
pg (1.6.1)
|
||||
pg (1.6.2)
|
||||
pghero (3.7.0)
|
||||
activerecord (>= 7.1)
|
||||
playwright-ruby-client (1.54.1)
|
||||
playwright-ruby-client (1.55.0)
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
pp (0.6.2)
|
||||
|
|
@ -637,7 +639,7 @@ GEM
|
|||
public_suffix (6.0.2)
|
||||
puma (6.6.1)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.0)
|
||||
pundit (2.5.1)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
|
|
@ -717,7 +719,7 @@ GEM
|
|||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.25.2)
|
||||
redis-client (0.25.3)
|
||||
connection_pool
|
||||
regexp_parser (2.11.2)
|
||||
reline (0.6.2)
|
||||
|
|
@ -727,7 +729,7 @@ GEM
|
|||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rexml (3.4.4)
|
||||
rotp (6.3.0)
|
||||
rouge (4.6.0)
|
||||
rpam2 (4.0.2)
|
||||
|
|
@ -763,7 +765,7 @@ GEM
|
|||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.4)
|
||||
rubocop (1.79.2)
|
||||
rubocop (1.80.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -783,17 +785,17 @@ GEM
|
|||
rubocop-i18n (3.2.3)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.72.1)
|
||||
rubocop-performance (1.25.0)
|
||||
rubocop-performance (1.26.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails (2.33.3)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rspec (3.6.0)
|
||||
rubocop-rspec (3.7.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec_rails (2.31.0)
|
||||
|
|
@ -809,7 +811,7 @@ GEM
|
|||
ruby-vips (2.2.5)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.0.2)
|
||||
rubyzip (3.1.0)
|
||||
rufus-scheduler (3.9.2)
|
||||
fugit (~> 1.1, >= 1.11.1)
|
||||
safety_net_attestation (0.4.0)
|
||||
|
|
@ -848,12 +850,12 @@ GEM
|
|||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov-lcov (0.8.0)
|
||||
simplecov-lcov (0.9.0)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
stackprof (0.2.27)
|
||||
starry (0.2.0)
|
||||
base64
|
||||
stoplight (5.3.1)
|
||||
stoplight (5.3.8)
|
||||
zeitwerk
|
||||
stringio (3.1.7)
|
||||
strong_migrations (2.5.0)
|
||||
|
|
@ -897,7 +899,7 @@ GEM
|
|||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.9.1)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-display_width (3.1.5)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uri (1.0.3)
|
||||
|
|
@ -1025,7 +1027,7 @@ DEPENDENCIES
|
|||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
omniauth-saml (~> 2.0)
|
||||
omniauth_openid_connect (~> 0.8.0)
|
||||
opentelemetry-api (~> 1.6.0)
|
||||
opentelemetry-api (~> 1.7.0)
|
||||
opentelemetry-exporter-otlp (~> 0.30.0)
|
||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||
|
|
@ -1034,10 +1036,10 @@ DEPENDENCIES
|
|||
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.24.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.23.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.24.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.26.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.36.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.27.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.37.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.26.0)
|
||||
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
|
||||
opentelemetry-sdk (~> 1.4)
|
||||
|
|
@ -1045,6 +1047,7 @@ DEPENDENCIES
|
|||
parslet
|
||||
pg (~> 1.5)
|
||||
pghero
|
||||
playwright-ruby-client (= 1.55.0)
|
||||
premailer-rails
|
||||
prometheus_exporter (~> 2.2)
|
||||
propshaft
|
||||
|
|
|
|||
82
app/controllers/activitypub/contexts_controller.rb
Normal file
82
app/controllers/activitypub/contexts_controller.rb
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::ContextsController < ActivityPub::BaseController
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :set_conversation
|
||||
before_action :set_items
|
||||
|
||||
DESCENDANTS_LIMIT = 60
|
||||
|
||||
def show
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
def items
|
||||
expires_in 3.minutes, public: public_fetch_mode?
|
||||
render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_required?
|
||||
false
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
account_id, status_id = params[:id].split('-')
|
||||
@conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id)
|
||||
end
|
||||
|
||||
def set_items
|
||||
@items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
|
||||
end
|
||||
|
||||
def context_presenter
|
||||
first_page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
|
||||
ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter|
|
||||
presenter.first = first_page
|
||||
end
|
||||
end
|
||||
|
||||
def items_collection_presenter
|
||||
page = ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation, page_params),
|
||||
type: :unordered,
|
||||
part_of: items_context_url(@conversation),
|
||||
next: next_page,
|
||||
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
|
||||
)
|
||||
|
||||
return page if page_requested?
|
||||
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: items_context_url(@conversation),
|
||||
type: :unordered,
|
||||
first: page
|
||||
)
|
||||
end
|
||||
|
||||
def page_requested?
|
||||
truthy_param?(:page)
|
||||
end
|
||||
|
||||
def next_page
|
||||
return nil if @items.size < DESCENDANTS_LIMIT
|
||||
|
||||
items_context_url(@conversation, page: true, min_id: @items.last.id)
|
||||
end
|
||||
|
||||
def page_params
|
||||
params.permit(:page, :min_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -9,7 +9,7 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
|||
before_action :set_quote_authorization
|
||||
|
||||
def show
|
||||
expires_in 0, public: @quote.status.distributable? && public_fetch_mode?
|
||||
expires_in 30.seconds, public: true if @quote.status.distributable? && public_fetch_mode?
|
||||
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
|
|
@ -21,6 +21,8 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
|||
|
||||
def set_quote_authorization
|
||||
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
||||
return not_found unless @quote.status.present? && @quote.quoted_status.present?
|
||||
|
||||
authorize @quote.status, :show?
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ module Admin
|
|||
|
||||
def export_data
|
||||
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
||||
DomainAllow.allowed_domains.each do |instance|
|
||||
content << [instance.domain]
|
||||
DomainAllow.allowed_domains.each do |domain|
|
||||
content << [domain]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
|
|||
default_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
|
||||
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
|
||||
default_language: source_params.fetch(:language, @account.user.setting_default_language),
|
||||
default_quote_policy: source_params.fetch(:quote_policy, @account.user.setting_default_quote_policy),
|
||||
},
|
||||
}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ module Api::InteractionPoliciesConcern
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
def quote_approval_policy
|
||||
# TODO: handle `nil` separately
|
||||
return nil unless Mastodon::Feature.outgoing_quotes_enabled? && status_params[:quote_approval_policy].present?
|
||||
return nil unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||
|
||||
case status_params[:quote_approval_policy]
|
||||
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
||||
when 'public'
|
||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||
when 'followers'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Settings::Preferences::PostingDefaultsController < Settings::Preferences::BaseController
|
||||
private
|
||||
|
||||
def after_update_redirect_path
|
||||
settings_preferences_posting_defaults_path
|
||||
end
|
||||
|
||||
def user_params
|
||||
super.tap do |params|
|
||||
params[:settings_attributes][:default_quote_policy] = 'nobody' if params[:settings_attributes][:default_privacy] == 'private'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -243,6 +243,10 @@ module ApplicationHelper
|
|||
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||
end
|
||||
|
||||
def recent_tag_users(tag)
|
||||
tag.statuses.public_visibility.joins(:account).merge(Account.without_suspended.without_silenced).includes(:account).limit(3).map(&:account)
|
||||
end
|
||||
|
||||
def recent_tag_usage(tag)
|
||||
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
|
||||
I18n.t 'user_mailer.welcome.hashtags_recent_count', people: number_with_delimiter(people), count: people
|
||||
|
|
@ -256,6 +260,10 @@ module ApplicationHelper
|
|||
'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
|
||||
end
|
||||
|
||||
def within_authorization_flow?
|
||||
session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def storage_host_var
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ module FormattingHelper
|
|||
module_function :extract_status_plain_text
|
||||
|
||||
def status_content_format(status)
|
||||
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []))
|
||||
quoted_status = status.quote&.quoted_status if status.local?
|
||||
|
||||
html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), quoted_status: quoted_status)
|
||||
end
|
||||
|
||||
def rss_status_content_format(status)
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ module LanguagesHelper
|
|||
mk: ['Macedonian', 'македонски јазик'].freeze,
|
||||
ml: ['Malayalam', 'മലയാളം'].freeze,
|
||||
mn: ['Mongolian', 'Монгол хэл'].freeze,
|
||||
'mn-Mong': ['Traditional Mongolian', 'ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'].freeze,
|
||||
mr: ['Marathi', 'मराठी'].freeze,
|
||||
ms: ['Malay', 'Bahasa Melayu'].freeze,
|
||||
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
|
||||
|
|
|
|||
|
|
@ -64,4 +64,16 @@ module StatusesHelper
|
|||
def prefers_autoplay?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
|
||||
end
|
||||
|
||||
def render_seo_schema(status)
|
||||
json = ActiveModelSerializers::SerializableResource.new(
|
||||
status,
|
||||
serializer: SEO::SocialMediaPostingSerializer,
|
||||
adapter: SEO::Adapter
|
||||
).to_json
|
||||
|
||||
# rubocop:disable Rails/OutputSafety
|
||||
content_tag(:script, json_escape(json).html_safe, type: 'application/ld+json')
|
||||
# rubocop:enable Rails/OutputSafety
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -145,6 +145,10 @@ function loaded() {
|
|||
);
|
||||
});
|
||||
|
||||
updateDefaultQuotePrivacyFromPrivacy(
|
||||
document.querySelector('#user_settings_attributes_default_privacy'),
|
||||
);
|
||||
|
||||
const reactComponents = document.querySelectorAll('[data-component]');
|
||||
|
||||
if (reactComponents.length > 0) {
|
||||
|
|
@ -347,6 +351,31 @@ const setInputDisabled = (
|
|||
}
|
||||
};
|
||||
|
||||
const setInputHint = (
|
||||
input: HTMLInputElement | HTMLSelectElement,
|
||||
hintPrefix: string,
|
||||
) => {
|
||||
const fieldWrapper = input.closest<HTMLElement>('.fields-group > .input');
|
||||
if (!fieldWrapper) return;
|
||||
|
||||
const hint = fieldWrapper.dataset[`${hintPrefix}Hint`];
|
||||
const hintElement =
|
||||
fieldWrapper.querySelector<HTMLSpanElement>(':scope > .hint');
|
||||
|
||||
if (hint) {
|
||||
if (hintElement) {
|
||||
hintElement.textContent = hint;
|
||||
} else {
|
||||
const newHintElement = document.createElement('span');
|
||||
newHintElement.className = 'hint';
|
||||
newHintElement.textContent = hint;
|
||||
fieldWrapper.appendChild(newHintElement);
|
||||
}
|
||||
} else {
|
||||
hintElement?.remove();
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#account_statuses_cleanup_policy_enabled',
|
||||
|
|
@ -364,6 +393,36 @@ Rails.delegate(
|
|||
},
|
||||
);
|
||||
|
||||
const updateDefaultQuotePrivacyFromPrivacy = (
|
||||
privacySelect: EventTarget | null,
|
||||
) => {
|
||||
if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form)
|
||||
return;
|
||||
|
||||
const select = privacySelect.form.querySelector<HTMLSelectElement>(
|
||||
'select#user_settings_attributes_default_quote_policy',
|
||||
);
|
||||
if (!select) return;
|
||||
|
||||
setInputHint(select, privacySelect.value);
|
||||
|
||||
if (privacySelect.value === 'private') {
|
||||
select.value = 'nobody';
|
||||
setInputDisabled(select, true);
|
||||
} else {
|
||||
setInputDisabled(select, false);
|
||||
}
|
||||
};
|
||||
|
||||
Rails.delegate(
|
||||
document,
|
||||
'#user_settings_attributes_default_privacy',
|
||||
'change',
|
||||
({ target }) => {
|
||||
updateDefaultQuotePrivacyFromPrivacy(target);
|
||||
},
|
||||
);
|
||||
|
||||
// Empty the honeypot fields in JS in case something like an extension
|
||||
// automatically filled them.
|
||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||
|
|
|
|||
|
|
@ -97,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
|
|||
};
|
||||
|
||||
export function setComposeToStatus(status, text, spoiler_text) {
|
||||
return{
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
};
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: COMPOSE_SET_STATUS,
|
||||
status,
|
||||
text,
|
||||
spoiler_text,
|
||||
maxOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function changeCompose(text) {
|
||||
|
|
@ -216,6 +221,7 @@ export function submitCompose(successCallback) {
|
|||
});
|
||||
}
|
||||
|
||||
const visibility = getState().getIn(['compose', 'privacy']);
|
||||
api().request({
|
||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||
method: statusId === null ? 'post' : 'put',
|
||||
|
|
@ -226,11 +232,11 @@ export function submitCompose(successCallback) {
|
|||
media_attributes,
|
||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||
visibility: getState().getIn(['compose', 'privacy']),
|
||||
visibility: visibility,
|
||||
poll: getState().getIn(['compose', 'poll'], null),
|
||||
language: getState().getIn(['compose', 'language']),
|
||||
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
||||
quote_approval_policy: getState().getIn(['compose', 'quote_policy']),
|
||||
quote_approval_policy: visibility === 'private' || visibility === 'direct' ? 'nobody' : getState().getIn(['compose', 'quote_policy']),
|
||||
},
|
||||
headers: {
|
||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { Status } from '../models/status';
|
|||
|
||||
import { showAlert } from './alerts';
|
||||
import { focusCompose } from './compose';
|
||||
import { openModal } from './modal';
|
||||
|
||||
const messages = defineMessages({
|
||||
quoteErrorUpload: {
|
||||
|
|
@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk(
|
|||
|
||||
export const quoteComposeByStatus = createAppThunk(
|
||||
(status: Status, { dispatch, getState }) => {
|
||||
const composeState = getState().compose;
|
||||
const state = getState();
|
||||
const composeState = state.compose;
|
||||
const mediaAttachments = composeState.get('media_attachments');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const wasQuietPostHintModalDismissed: boolean =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||
state.settings.getIn(
|
||||
['dismissed_banners', 'quote/quiet_post_hint'],
|
||||
false,
|
||||
);
|
||||
|
||||
if (composeState.get('poll')) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||
|
|
@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk(
|
|||
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
||||
) {
|
||||
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
||||
} else if (
|
||||
status.get('visibility') === 'unlisted' &&
|
||||
!wasQuietPostHintModalDismissed
|
||||
) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_QUIET_QUOTE',
|
||||
modalProps: { status },
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(quoteCompose(status));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
|
|||
return normalResult;
|
||||
}
|
||||
|
||||
function stripQuoteFallback(text) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = text;
|
||||
|
||||
wrapper.querySelector('.quote-inline')?.remove();
|
||||
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
export function normalizeStatus(status, normalOldStatus) {
|
||||
const normalStatus = { ...status };
|
||||
|
||||
|
|
@ -72,7 +81,7 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
} else {
|
||||
// If the status has a CW but no contents, treat the CW as if it were the
|
||||
// status' contents, to avoid having a CW toggle with seemingly no effect.
|
||||
if (normalStatus.spoiler_text && !normalStatus.content) {
|
||||
if (normalStatus.spoiler_text && !normalStatus.content && !normalStatus.quote) {
|
||||
normalStatus.content = normalStatus.spoiler_text;
|
||||
normalStatus.spoiler_text = '';
|
||||
}
|
||||
|
|
@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (normalStatus.quote) {
|
||||
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
|
||||
}
|
||||
|
||||
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||
normalStatus.url = null;
|
||||
}
|
||||
|
|
@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) {
|
|||
spoiler_text: translation.spoiler_text,
|
||||
};
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
|
||||
if (status.get('quote')) {
|
||||
normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
|
||||
}
|
||||
|
||||
return normalTranslation;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import {
|
|||
apiReblog,
|
||||
apiUnreblog,
|
||||
apiRevokeQuote,
|
||||
apiGetQuotes,
|
||||
} from 'mastodon/api/interactions';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
|
||||
import { importFetchedStatus } from './importer';
|
||||
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||
|
||||
export const reblog = createDataLoadingThunk(
|
||||
'status/reblog',
|
||||
|
|
@ -53,3 +54,19 @@ export const revokeQuote = createDataLoadingThunk(
|
|||
return discardLoadData;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchQuotes = createDataLoadingThunk(
|
||||
'status/fetch_quotes',
|
||||
async ({ statusId, next }: { statusId: string; next?: string }) => {
|
||||
const { links, statuses } = await apiGetQuotes(statusId, next);
|
||||
|
||||
return {
|
||||
links,
|
||||
statuses,
|
||||
replace: !next,
|
||||
};
|
||||
},
|
||||
(payload, { dispatch }) => {
|
||||
dispatch(importFetchedStatuses(payload.statuses));
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -30,9 +30,20 @@ import { importFetchedAccounts, importFetchedStatuses } from './importer';
|
|||
import { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||
import { saveSettings } from './settings';
|
||||
|
||||
function notificationTypeForFilter(type: NotificationType) {
|
||||
if (type === 'quoted_update') return 'update';
|
||||
else return type;
|
||||
}
|
||||
|
||||
function notificationTypeForQuickFilter(type: NotificationType) {
|
||||
if (type === 'quoted_update') return 'update';
|
||||
else if (type === 'quote') return 'mention';
|
||||
else return type;
|
||||
}
|
||||
|
||||
function excludeAllTypesExcept(filter: string) {
|
||||
return allNotificationTypes.filter(
|
||||
(item) => item !== filter && !(item === 'quote' && filter === 'mention'),
|
||||
(item) => notificationTypeForQuickFilter(item) !== filter,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -157,16 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
|||
|
||||
const showInColumn =
|
||||
activeFilter === 'all'
|
||||
? notificationShows[notification.type] !== false
|
||||
: activeFilter === notification.type ||
|
||||
(activeFilter === 'mention' && notification.type === 'quote');
|
||||
? notificationShows[notificationTypeForFilter(notification.type)] !==
|
||||
false
|
||||
: activeFilter === notificationTypeForQuickFilter(notification.type);
|
||||
|
||||
if (!showInColumn) return;
|
||||
|
||||
if (
|
||||
(notification.type === 'mention' ||
|
||||
notification.type === 'quote' ||
|
||||
notification.type === 'update' ||
|
||||
notification.type === 'quote') &&
|
||||
notification.type === 'quoted_update') &&
|
||||
notification.status?.filtered
|
||||
) {
|
||||
const filters = notification.status.filtered.filter((result) =>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
|||
|
||||
let filtered = false;
|
||||
|
||||
if (['mention', 'status', 'quote'].includes(notification.type) && notification.status.filtered) {
|
||||
if (['mention', 'quote', 'status'].includes(notification.type) && notification.status.filtered) {
|
||||
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||
|
||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import { browserHistory } from 'mastodon/components/router';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
||||
import { importFetchedStatus, importFetchedAccount } from './importer';
|
||||
import { fetchContext } from './statuses_typed';
|
||||
import { deleteFromTimelines } from './timelines';
|
||||
|
||||
|
|
@ -40,6 +43,10 @@ export const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS';
|
|||
export const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL';
|
||||
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
|
||||
});
|
||||
|
||||
export function fetchStatusRequest(id, skipLoading) {
|
||||
return {
|
||||
type: STATUS_FETCH_REQUEST,
|
||||
|
|
@ -48,7 +55,18 @@ export function fetchStatusRequest(id, skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.forceFetch]
|
||||
* @param {boolean} [options.alsoFetchContext]
|
||||
* @param {string | null | undefined} [options.parentQuotePostId]
|
||||
*/
|
||||
export function fetchStatus(id, {
|
||||
forceFetch = false,
|
||||
alsoFetchContext = true,
|
||||
parentQuotePostId,
|
||||
} = {}) {
|
||||
return (dispatch, getState) => {
|
||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
||||
|
||||
|
|
@ -66,7 +84,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
|
|||
dispatch(importFetchedStatus(response.data));
|
||||
dispatch(fetchStatusSuccess(skipLoading));
|
||||
}).catch(error => {
|
||||
dispatch(fetchStatusFail(id, error, skipLoading));
|
||||
dispatch(fetchStatusFail(id, error, skipLoading, parentQuotePostId));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -78,21 +96,27 @@ export function fetchStatusSuccess(skipLoading) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchStatusFail(id, error, skipLoading) {
|
||||
export function fetchStatusFail(id, error, skipLoading, parentQuotePostId) {
|
||||
return {
|
||||
type: STATUS_FETCH_FAIL,
|
||||
id,
|
||||
error,
|
||||
parentQuotePostId,
|
||||
skipLoading,
|
||||
skipAlert: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function redraft(status, raw_text) {
|
||||
return {
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
return (dispatch, getState) => {
|
||||
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||
|
||||
dispatch({
|
||||
type: REDRAFT,
|
||||
status,
|
||||
raw_text,
|
||||
maxOptions,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
|
|||
|
||||
dispatch(deleteStatusRequest(id));
|
||||
|
||||
api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
|
||||
return api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
|
||||
dispatch(deleteStatusSuccess(id));
|
||||
dispatch(deleteFromTimelines(id));
|
||||
dispatch(importFetchedAccount(response.data.account));
|
||||
|
|
@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
|
|||
if (withRedraft) {
|
||||
dispatch(redraft(status, response.data.text));
|
||||
ensureComposeIsVisible(getState);
|
||||
} else {
|
||||
dispatch(showAlert({ message: messages.deleteSuccess }));
|
||||
}
|
||||
|
||||
return response;
|
||||
}).catch(error => {
|
||||
dispatch(deleteStatusFail(id, error));
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,28 @@
|
|||
import { apiRequestPost } from 'mastodon/api';
|
||||
import type { Status, StatusVisibility } from 'mastodon/models/status';
|
||||
import api, { apiRequestPost, getLinks } from 'mastodon/api';
|
||||
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
|
||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
||||
apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
|
||||
visibility,
|
||||
});
|
||||
|
||||
export const apiUnreblog = (statusId: string) =>
|
||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
||||
apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);
|
||||
|
||||
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
||||
apiRequestPost<Status>(
|
||||
apiRequestPost<ApiStatusJSON>(
|
||||
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
|
||||
);
|
||||
|
||||
export const apiGetQuotes = async (statusId: string, url?: string) => {
|
||||
const response = await api().request<ApiStatusJSON[]>({
|
||||
method: 'GET',
|
||||
url: url ?? `/api/v1/statuses/${statusId}/quotes`,
|
||||
});
|
||||
|
||||
return {
|
||||
statuses: response.data,
|
||||
links: getLinks(response),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { ApiReportJSON } from './reports';
|
|||
import type { ApiStatusJSON } from './statuses';
|
||||
|
||||
// See app/model/notification.rb
|
||||
export const allNotificationTypes = [
|
||||
export const allNotificationTypes: NotificationType[] = [
|
||||
'follow',
|
||||
'follow_request',
|
||||
'favourite',
|
||||
|
|
@ -31,7 +31,8 @@ export type NotificationWithStatusType =
|
|||
| 'mention'
|
||||
| 'quote'
|
||||
| 'poll'
|
||||
| 'update';
|
||||
| 'update'
|
||||
| 'quoted_update';
|
||||
|
||||
export type NotificationType =
|
||||
| NotificationWithStatusType
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import type { ApiStatusJSON } from './statuses';
|
||||
|
||||
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
|
||||
export type ApiQuotePolicy = 'public' | 'followers' | 'nobody' | 'unknown';
|
||||
export type ApiQuotePolicy =
|
||||
| 'public'
|
||||
| 'followers'
|
||||
| 'following'
|
||||
| 'nobody'
|
||||
| 'unsupported_policy';
|
||||
export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown';
|
||||
|
||||
interface ApiQuoteEmptyJSON {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface ApiStatusJSON {
|
|||
replies_count: number;
|
||||
reblogs_count: number;
|
||||
favorites_count: number;
|
||||
quotes_count: number;
|
||||
edited_at?: string;
|
||||
|
||||
favorited?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<DisplayName /> > renders display name + account name 1`] = `
|
||||
<span
|
||||
className="display-name"
|
||||
onMouseEnter={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
>
|
||||
<bdi>
|
||||
<strong
|
||||
className="display-name__html"
|
||||
dangerouslySetInnerHTML={
|
||||
{
|
||||
"__html": "<p>Foo</p>",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</bdi>
|
||||
|
||||
<span
|
||||
className="display-name__account"
|
||||
>
|
||||
@
|
||||
bar@baz
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { fromJS } from 'immutable';
|
||||
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import { DisplayName } from '../display_name';
|
||||
|
||||
describe('<DisplayName />', () => {
|
||||
it('renders display name + account name', () => {
|
||||
const account = fromJS({
|
||||
username: 'bar',
|
||||
acct: 'bar@baz',
|
||||
display_name_html: '<p>Foo</p>',
|
||||
});
|
||||
const component = renderer.create(<DisplayName account={account} />);
|
||||
const tree = component.toJSON();
|
||||
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
110
app/javascript/mastodon/components/alert/alert.stories.tsx
Normal file
110
app/javascript/mastodon/components/alert/alert.stories.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn, expect } from 'storybook/test';
|
||||
|
||||
import { Alert } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Alert',
|
||||
component: Alert,
|
||||
args: {
|
||||
isActive: true,
|
||||
animateFrom: 'side',
|
||||
title: '',
|
||||
message: '',
|
||||
action: '',
|
||||
onActionClick: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
isActive: {
|
||||
control: 'boolean',
|
||||
type: 'boolean',
|
||||
description: 'Animate to the active (displayed) state of the alert',
|
||||
},
|
||||
animateFrom: {
|
||||
control: 'radio',
|
||||
type: 'string',
|
||||
options: ['side', 'below'],
|
||||
description:
|
||||
'Direction that the alert animates in from when activated. `side` is dependent on reading direction, defaulting to left in ltr languages.',
|
||||
},
|
||||
title: {
|
||||
control: 'text',
|
||||
type: 'string',
|
||||
description: '(Optional) title of the alert',
|
||||
},
|
||||
message: {
|
||||
control: 'text',
|
||||
type: 'string',
|
||||
description: 'Main alert text',
|
||||
},
|
||||
action: {
|
||||
control: 'text',
|
||||
type: 'string',
|
||||
description:
|
||||
'Label of the alert action (requires `onActionClick` handler)',
|
||||
},
|
||||
},
|
||||
tags: ['test'],
|
||||
} satisfies Meta<typeof Alert>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {
|
||||
args: {
|
||||
message: 'Post published.',
|
||||
},
|
||||
render: (args) => (
|
||||
<div style={{ overflow: 'clip', padding: '1rem' }}>
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
...Simple.args,
|
||||
action: 'Open',
|
||||
},
|
||||
render: Simple.render,
|
||||
play: async ({ args, canvas, userEvent }) => {
|
||||
const button = await canvas.findByRole('button', { name: 'Open' });
|
||||
await userEvent.click(button);
|
||||
await expect(args.onActionClick).toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTitle: Story = {
|
||||
args: {
|
||||
title: 'Warning:',
|
||||
message: 'This is an alert',
|
||||
},
|
||||
render: Simple.render,
|
||||
};
|
||||
|
||||
export const WithDismissButton: Story = {
|
||||
args: {
|
||||
message: 'More replies found',
|
||||
action: 'Show',
|
||||
onDismiss: fn(),
|
||||
},
|
||||
render: Simple.render,
|
||||
};
|
||||
|
||||
export const InSizedContainer: Story = {
|
||||
args: WithDismissButton.args,
|
||||
render: (args) => (
|
||||
<div
|
||||
style={{
|
||||
overflow: 'clip',
|
||||
padding: '1rem',
|
||||
width: '380px',
|
||||
maxWidth: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Alert {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
68
app/javascript/mastodon/components/alert/index.tsx
Normal file
68
app/javascript/mastodon/components/alert/index.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
/**
|
||||
* Snackbar/Toast-style notification component.
|
||||
*/
|
||||
export const Alert: React.FC<{
|
||||
isActive?: boolean;
|
||||
animateFrom?: 'side' | 'below';
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: string;
|
||||
onActionClick?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}> = ({
|
||||
isActive,
|
||||
animateFrom = 'side',
|
||||
title,
|
||||
message,
|
||||
action,
|
||||
onActionClick,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const hasAction = Boolean(action && onActionClick);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('notification-bar', {
|
||||
'notification-bar--active': isActive,
|
||||
'from-side': animateFrom === 'side',
|
||||
'from-below': animateFrom === 'below',
|
||||
})}
|
||||
>
|
||||
<span className='notification-bar__content'>
|
||||
{Boolean(title) && (
|
||||
<span className='notification-bar__title'>{title}</span>
|
||||
)}
|
||||
{message}
|
||||
</span>
|
||||
|
||||
{hasAction && (
|
||||
<button className='notification-bar__action' onClick={onActionClick}>
|
||||
{action}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onDismiss && (
|
||||
<IconButton
|
||||
title={intl.formatMessage({
|
||||
id: 'dismissable_banner.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
})}
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
className='notification-bar__dismiss-button'
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -3,16 +3,16 @@ import { useState, useEffect } from 'react';
|
|||
import { useIntl } from 'react-intl';
|
||||
import type { IntlShape } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||
import type {
|
||||
Alert,
|
||||
Alert as AlertType,
|
||||
TranslatableString,
|
||||
TranslatableValues,
|
||||
} from 'mastodon/models/alert';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { Alert } from './alert';
|
||||
|
||||
const formatIfNeeded = (
|
||||
intl: IntlShape,
|
||||
message: TranslatableString,
|
||||
|
|
@ -25,8 +25,8 @@ const formatIfNeeded = (
|
|||
return message;
|
||||
};
|
||||
|
||||
const Alert: React.FC<{
|
||||
alert: Alert;
|
||||
const TimedAlert: React.FC<{
|
||||
alert: AlertType;
|
||||
dismissAfter: number;
|
||||
}> = ({
|
||||
alert: { key, title, message, values, action, onClick },
|
||||
|
|
@ -62,29 +62,13 @@ const Alert: React.FC<{
|
|||
}, [dispatch, setActive, key, dismissAfter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('notification-bar', {
|
||||
'notification-bar-active': active,
|
||||
})}
|
||||
>
|
||||
<div className='notification-bar-wrapper'>
|
||||
{title && (
|
||||
<span className='notification-bar-title'>
|
||||
{formatIfNeeded(intl, title, values)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className='notification-bar-message'>
|
||||
{formatIfNeeded(intl, message, values)}
|
||||
</span>
|
||||
|
||||
{action && (
|
||||
<button className='notification-bar-action' onClick={onClick}>
|
||||
{formatIfNeeded(intl, action, values)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Alert
|
||||
isActive={active}
|
||||
title={title ? formatIfNeeded(intl, title, values) : undefined}
|
||||
message={formatIfNeeded(intl, message, values)}
|
||||
action={action ? formatIfNeeded(intl, action, values) : undefined}
|
||||
onActionClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => {
|
|||
return (
|
||||
<div className='notification-list'>
|
||||
{alerts.map((alert, idx) => (
|
||||
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
|
||||
<TimedAlert
|
||||
key={alert.key}
|
||||
alert={alert}
|
||||
dismissAfter={5000 + idx * 1000}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
|
|||
const offset = [0, 4] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const AltTextBadge: React.FC<{
|
||||
description: string;
|
||||
}> = ({ description }) => {
|
||||
export const AltTextBadge: React.FC<{ description: string }> = ({
|
||||
description,
|
||||
}) => {
|
||||
const accessibilityId = useId();
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
|
|||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
||||
className='media-gallery__alt__popover dropdown-animation'
|
||||
className='info-tooltip dropdown-animation'
|
||||
role='region'
|
||||
id={accessibilityId}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const meta = {
|
|||
component: Button,
|
||||
args: {
|
||||
secondary: false,
|
||||
plain: false,
|
||||
compact: false,
|
||||
dangerous: false,
|
||||
disabled: false,
|
||||
|
|
@ -57,6 +58,14 @@ export const Secondary: Story = {
|
|||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
args: {
|
||||
plain: true,
|
||||
children: 'Plain button',
|
||||
},
|
||||
play: buttonTest,
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: {
|
||||
compact: true,
|
||||
|
|
@ -101,6 +110,14 @@ export const SecondaryDisabled: Story = {
|
|||
play: disabledButtonTest,
|
||||
};
|
||||
|
||||
export const PlainDisabled: Story = {
|
||||
args: {
|
||||
...Plain.args,
|
||||
disabled: true,
|
||||
},
|
||||
play: disabledButtonTest,
|
||||
};
|
||||
|
||||
const loadingButtonTest: Story['play'] = async ({
|
||||
args,
|
||||
canvas,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface BaseProps
|
|||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
plain?: boolean;
|
||||
compact?: boolean;
|
||||
dangerous?: boolean;
|
||||
loading?: boolean;
|
||||
|
|
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
|
|||
disabled,
|
||||
block,
|
||||
secondary,
|
||||
plain,
|
||||
compact,
|
||||
dangerous,
|
||||
loading,
|
||||
|
|
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
|
|||
<button
|
||||
className={classNames('button', className, {
|
||||
'button-secondary': secondary,
|
||||
'button--plain': plain,
|
||||
'button--compact': compact,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,3 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-call,
|
||||
@typescript-eslint/no-unsafe-return,
|
||||
@typescript-eslint/no-unsafe-assignment,
|
||||
@typescript-eslint/no-unsafe-member-access
|
||||
-- the settings store is not yet typed */
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
|
|
@ -23,31 +18,48 @@ interface Props {
|
|||
id: string;
|
||||
}
|
||||
|
||||
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
||||
id,
|
||||
children,
|
||||
}) => {
|
||||
const dismissed = useAppSelector((state) =>
|
||||
export function useDismissableBannerState({ id }: Props) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const dismissed: boolean = useAppSelector((state) =>
|
||||
/* eslint-disable-next-line */
|
||||
state.settings.getIn(['dismissed_banners', id], false),
|
||||
);
|
||||
|
||||
const [isVisible, setIsVisible] = useState(
|
||||
!bannerSettings.get(id) && !dismissed,
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
|
||||
const intl = useIntl();
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setVisible(false);
|
||||
const dismiss = useCallback(() => {
|
||||
setIsVisible(false);
|
||||
bannerSettings.set(id, true);
|
||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible && !dismissed) {
|
||||
// Store legacy localStorage setting on server
|
||||
if (!isVisible && !dismissed) {
|
||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||
}
|
||||
}, [id, dispatch, visible, dismissed]);
|
||||
}, [id, dispatch, isVisible, dismissed]);
|
||||
|
||||
if (!visible) {
|
||||
return {
|
||||
wasDismissed: !isVisible,
|
||||
dismiss,
|
||||
};
|
||||
}
|
||||
|
||||
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
||||
id,
|
||||
children,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id,
|
||||
});
|
||||
|
||||
if (wasDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
|||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
title={intl.formatMessage(messages.dismiss)}
|
||||
onClick={handleDismiss}
|
||||
onClick={dismiss}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
import { Skeleton } from './skeleton';
|
||||
|
||||
interface Props {
|
||||
account?: Account;
|
||||
others?: List<Account>;
|
||||
localDomain?: string;
|
||||
}
|
||||
|
||||
export class DisplayName extends React.PureComponent<Props> {
|
||||
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
|
||||
currentTarget,
|
||||
}) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const originalSrc = emoji.getAttribute('data-original');
|
||||
if (originalSrc != null) emoji.src = originalSrc;
|
||||
});
|
||||
};
|
||||
|
||||
handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({
|
||||
currentTarget,
|
||||
}) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const staticSrc = emoji.getAttribute('data-static');
|
||||
if (staticSrc != null) emoji.src = staticSrc;
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { others, localDomain } = this.props;
|
||||
|
||||
let displayName: React.ReactNode,
|
||||
suffix: React.ReactNode,
|
||||
account: Account | undefined;
|
||||
|
||||
if (others && others.size > 0) {
|
||||
account = others.first();
|
||||
} else if (this.props.account) {
|
||||
account = this.props.account;
|
||||
}
|
||||
|
||||
if (others && others.size > 1) {
|
||||
displayName = others
|
||||
.take(2)
|
||||
.map((a) => (
|
||||
<bdi key={a.get('id')}>
|
||||
<strong
|
||||
className='display-name__html'
|
||||
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
|
||||
/>
|
||||
</bdi>
|
||||
))
|
||||
.reduce((prev, cur) => [prev, ', ', cur]);
|
||||
|
||||
if (others.size - 2 > 0) {
|
||||
suffix = `+${others.size - 2}`;
|
||||
}
|
||||
} else if (account) {
|
||||
let acct = account.get('acct');
|
||||
|
||||
if (!acct.includes('@') && localDomain) {
|
||||
acct = `${acct}@${localDomain}`;
|
||||
}
|
||||
|
||||
displayName = (
|
||||
<bdi>
|
||||
<strong
|
||||
className='display-name__html'
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: account.get('display_name_html'),
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
);
|
||||
suffix = <span className='display-name__account'>@{acct}</span>;
|
||||
} else {
|
||||
displayName = (
|
||||
<bdi>
|
||||
<strong className='display-name__html'>
|
||||
<Skeleton width='10ch' />
|
||||
</strong>
|
||||
</bdi>
|
||||
);
|
||||
suffix = (
|
||||
<span className='display-name__account'>
|
||||
<Skeleton width='7ch' />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className='display-name'
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
{displayName} {suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/javascript/mastodon/components/display_name/default.tsx
Normal file
36
app/javascript/mastodon/components/display_name/default.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
import type { DisplayNameProps } from './index';
|
||||
import { DisplayNameWithoutDomain } from './no-domain';
|
||||
|
||||
export const DisplayNameDefault: FC<
|
||||
Omit<DisplayNameProps, 'variant'> & ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, localDomain, className, ...props }) => {
|
||||
const username = useMemo(() => {
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
let acct = account.get('acct');
|
||||
|
||||
if (!acct.includes('@') && localDomain) {
|
||||
acct = `${acct}@${localDomain}`;
|
||||
}
|
||||
return `@${acct}`;
|
||||
}, [account, localDomain]);
|
||||
|
||||
return (
|
||||
<DisplayNameWithoutDomain
|
||||
account={account}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{' '}
|
||||
<span className='display-name__account'>
|
||||
{username ?? <Skeleton width='7ch' />}
|
||||
</span>
|
||||
</DisplayNameWithoutDomain>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import type { ComponentProps } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { accountFactoryState } from '@/testing/factories';
|
||||
|
||||
import { DisplayName, LinkedDisplayName } from './index';
|
||||
|
||||
type PageProps = Omit<ComponentProps<typeof DisplayName>, 'account'> & {
|
||||
name: string;
|
||||
username: string;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Components/DisplayName',
|
||||
args: {
|
||||
username: 'mastodon@mastodon.social',
|
||||
name: 'Test User 🧪',
|
||||
loading: false,
|
||||
localDomain: 'mastodon.social',
|
||||
},
|
||||
tags: [],
|
||||
render({ name, username, loading, ...args }) {
|
||||
const account = !loading
|
||||
? accountFactoryState({
|
||||
display_name: name,
|
||||
acct: username,
|
||||
})
|
||||
: undefined;
|
||||
return <DisplayName {...args} account={account} />;
|
||||
},
|
||||
} satisfies Meta<PageProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoDomain: Story = {
|
||||
args: {
|
||||
variant: 'noDomain',
|
||||
},
|
||||
};
|
||||
|
||||
export const Simple: Story = {
|
||||
args: {
|
||||
variant: 'simple',
|
||||
},
|
||||
};
|
||||
|
||||
export const LocalUser: Story = {
|
||||
args: {
|
||||
username: 'localuser',
|
||||
name: 'Local User',
|
||||
localDomain: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const Linked: Story = {
|
||||
render({ name, username, loading, ...args }) {
|
||||
const account = !loading
|
||||
? accountFactoryState({
|
||||
display_name: name,
|
||||
acct: username,
|
||||
})
|
||||
: undefined;
|
||||
return <LinkedDisplayName {...args} displayProps={{ account }} />;
|
||||
},
|
||||
};
|
||||
51
app/javascript/mastodon/components/display_name/index.tsx
Normal file
51
app/javascript/mastodon/components/display_name/index.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import type { LinkProps } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
|
||||
import { DisplayNameDefault } from './default';
|
||||
import { DisplayNameWithoutDomain } from './no-domain';
|
||||
import { DisplayNameSimple } from './simple';
|
||||
|
||||
export interface DisplayNameProps {
|
||||
account?: Account;
|
||||
localDomain?: string;
|
||||
variant?: 'default' | 'simple' | 'noDomain';
|
||||
}
|
||||
|
||||
export const DisplayName: FC<
|
||||
DisplayNameProps & ComponentPropsWithoutRef<'span'>
|
||||
> = ({ variant = 'default', ...props }) => {
|
||||
if (variant === 'simple') {
|
||||
return <DisplayNameSimple {...props} />;
|
||||
} else if (variant === 'noDomain') {
|
||||
return <DisplayNameWithoutDomain {...props} />;
|
||||
}
|
||||
return <DisplayNameDefault {...props} />;
|
||||
};
|
||||
|
||||
export const LinkedDisplayName: FC<
|
||||
Omit<LinkProps, 'to'> & {
|
||||
displayProps: DisplayNameProps & ComponentPropsWithoutRef<'span'>;
|
||||
}
|
||||
> = ({ displayProps, children, ...linkProps }) => {
|
||||
const { account } = displayProps;
|
||||
if (!account) {
|
||||
return <DisplayName {...displayProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/@${account.acct}`}
|
||||
title={`@${account.acct}`}
|
||||
data-id={account.id}
|
||||
data-hover-card-account={account.id}
|
||||
{...linkProps}
|
||||
>
|
||||
{children}
|
||||
<DisplayName {...displayProps} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
import type { DisplayNameProps } from './index';
|
||||
|
||||
export const DisplayNameWithoutDomain: FC<
|
||||
Omit<DisplayNameProps, 'variant' | 'localDomain'> &
|
||||
ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, className, children, ...props }) => {
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
className={classNames('display-name animate-parent', className)}
|
||||
>
|
||||
<bdi>
|
||||
{account ? (
|
||||
<EmojiHTML
|
||||
className='display-name__html'
|
||||
htmlString={
|
||||
isModernEmojiEnabled()
|
||||
? account.get('display_name')
|
||||
: account.get('display_name_html')
|
||||
}
|
||||
shallow
|
||||
as='strong'
|
||||
/>
|
||||
) : (
|
||||
<strong className='display-name__html'>
|
||||
<Skeleton width='10ch' />
|
||||
</strong>
|
||||
)}
|
||||
</bdi>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
23
app/javascript/mastodon/components/display_name/simple.tsx
Normal file
23
app/javascript/mastodon/components/display_name/simple.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import type { DisplayNameProps } from './index';
|
||||
|
||||
export const DisplayNameSimple: FC<
|
||||
Omit<DisplayNameProps, 'variant' | 'localDomain'> &
|
||||
ComponentPropsWithoutRef<'span'>
|
||||
> = ({ account, ...props }) => {
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
const accountName = isModernEmojiEnabled()
|
||||
? account.get('display_name')
|
||||
: account.get('display_name_html');
|
||||
return (
|
||||
<bdi>
|
||||
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
|
||||
</bdi>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,22 +1,28 @@
|
|||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useIntl } from 'react-intl';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||
|
||||
import type { SelectItem } from '../dropdown_selector';
|
||||
import { DropdownSelector } from '../dropdown_selector';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
import { matchWidth } from './utils';
|
||||
|
||||
interface DropdownProps {
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
current: string;
|
||||
labelId: string;
|
||||
descriptionId?: string;
|
||||
emptyText?: MessageDescriptor;
|
||||
classPrefix: string;
|
||||
}
|
||||
|
|
@ -24,39 +30,59 @@ interface DropdownProps {
|
|||
export const Dropdown: FC<
|
||||
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
||||
> = ({
|
||||
title,
|
||||
disabled,
|
||||
items,
|
||||
current,
|
||||
onChange,
|
||||
labelId,
|
||||
descriptionId,
|
||||
classPrefix,
|
||||
className,
|
||||
id,
|
||||
...buttonProps
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const accessibilityId = useId();
|
||||
const uniqueId = useId();
|
||||
const buttonId = id ?? `${uniqueId}-button`;
|
||||
const listboxId = `${uniqueId}-listbox`;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!disabled) {
|
||||
setOpen((prevOpen) => !prevOpen);
|
||||
setOpen((prevOpen) => {
|
||||
buttonRef.current?.focus();
|
||||
return !prevOpen;
|
||||
});
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const currentText = useMemo(
|
||||
() => items.find((i) => i.value === current)?.text,
|
||||
[current, items],
|
||||
() =>
|
||||
items.find((i) => i.value === current)?.text ??
|
||||
intl.formatMessage({
|
||||
id: 'dropdown.empty',
|
||||
defaultMessage: 'Select an option',
|
||||
}),
|
||||
[current, intl, items],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
{...buttonProps}
|
||||
title={title}
|
||||
id={buttonId}
|
||||
aria-labelledby={`${labelId} ${buttonId}`}
|
||||
aria-describedby={descriptionId}
|
||||
aria-expanded={open}
|
||||
aria-controls={accessibilityId}
|
||||
aria-controls={listboxId}
|
||||
onClick={handleToggle}
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
|
|
@ -69,23 +95,24 @@ export const Dropdown: FC<
|
|||
)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{currentText ?? (
|
||||
<FormattedMessage
|
||||
id='dropdown.empty'
|
||||
defaultMessage='Select an option'
|
||||
/>
|
||||
)}
|
||||
{currentText}
|
||||
<Icon
|
||||
id='unfold-icon'
|
||||
icon={UnfoldMoreIcon}
|
||||
className={`${classPrefix}__icon`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
show={open}
|
||||
offset={[0, 4]}
|
||||
offset={[0, 0]}
|
||||
placement='bottom-start'
|
||||
onHide={handleClose}
|
||||
flip
|
||||
target={buttonRef.current}
|
||||
popperConfig={{
|
||||
strategy: 'fixed',
|
||||
modifiers: [matchWidth],
|
||||
}}
|
||||
>
|
||||
{({ props, placement }) => (
|
||||
|
|
@ -96,7 +123,7 @@ export const Dropdown: FC<
|
|||
`${classPrefix}__dropdown`,
|
||||
placement,
|
||||
)}
|
||||
id={accessibilityId}
|
||||
id={listboxId}
|
||||
>
|
||||
<DropdownSelector
|
||||
items={items}
|
||||
|
|
|
|||
17
app/javascript/mastodon/components/dropdown/utils.ts
Normal file
17
app/javascript/mastodon/components/dropdown/utils.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Modifier, UsePopperState } from 'react-overlays/esm/usePopper';
|
||||
|
||||
export const matchWidth: Modifier<'sameWidth', UsePopperState> = {
|
||||
name: 'sameWidth',
|
||||
enabled: true,
|
||||
phase: 'beforeWrite',
|
||||
requires: ['computeStyles'],
|
||||
fn: ({ state }) => {
|
||||
if (state.styles.popper) {
|
||||
state.styles.popper.width = `${state.rects.reference.width}px`;
|
||||
}
|
||||
},
|
||||
effect: ({ state }) => {
|
||||
const reference = state.elements.reference as HTMLElement;
|
||||
state.elements.popper.style.width = `${reference.offsetWidth}px`;
|
||||
},
|
||||
};
|
||||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { Icon } from './icon';
|
||||
import type { IconProp } from './icon';
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
|
|
@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
|
|||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
|
||||
item,
|
||||
}) => {
|
||||
if (item === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { text, description, icon } = item;
|
||||
return (
|
||||
<>
|
||||
{icon && <Icon icon={icon} id={`${text}-icon`} />}
|
||||
<span className='dropdown-menu__item-content'>
|
||||
{text}
|
||||
{Boolean(description) && (
|
||||
<span className='dropdown-menu__item-subtitle'>{description}</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DropdownMenu = <Item = MenuItem,>({
|
||||
items,
|
||||
loading,
|
||||
|
|
@ -164,13 +186,16 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||
const item = items?.[i];
|
||||
const isItemDisabled = Boolean(
|
||||
item && typeof item === 'object' && 'disabled' in item && item.disabled,
|
||||
);
|
||||
|
||||
onClose();
|
||||
|
||||
if (!item) {
|
||||
if (!item || isItemDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
if (typeof onItemClick === 'function') {
|
||||
e.preventDefault();
|
||||
onItemClick(item, i);
|
||||
|
|
@ -200,7 +225,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||
}
|
||||
|
||||
const { text, dangerous } = option;
|
||||
const { text, highlighted, disabled, dangerous } = option;
|
||||
|
||||
let element: React.ReactElement;
|
||||
|
||||
|
|
@ -211,8 +236,9 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
onClick={handleItemClick}
|
||||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{text}
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</button>
|
||||
);
|
||||
} else if (isExternalLinkItem(option)) {
|
||||
|
|
@ -227,7 +253,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
|
|
@ -239,7 +265,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
onKeyUp={handleItemKeyUp}
|
||||
data-index={i}
|
||||
>
|
||||
{text}
|
||||
<DropdownMenuItemContent item={option} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -247,6 +273,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
return (
|
||||
<li
|
||||
className={classNames('dropdown-menu__item', {
|
||||
'dropdown-menu__item--highlighted': highlighted,
|
||||
'dropdown-menu__item--dangerous': dangerous,
|
||||
})}
|
||||
key={`${text}-${i}`}
|
||||
|
|
@ -296,7 +323,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||
);
|
||||
};
|
||||
|
||||
interface DropdownProps<Item = MenuItem> {
|
||||
interface DropdownProps<Item extends object | null = MenuItem> {
|
||||
children?: React.ReactElement;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
|
|
@ -306,6 +333,7 @@ interface DropdownProps<Item = MenuItem> {
|
|||
disabled?: boolean;
|
||||
scrollable?: boolean;
|
||||
placement?: Placement;
|
||||
offset?: OffsetValue;
|
||||
/**
|
||||
* Prevent the `ScrollableList` with this scrollKey
|
||||
* from being scrolled while the dropdown is open
|
||||
|
|
@ -321,10 +349,9 @@ interface DropdownProps<Item = MenuItem> {
|
|||
onItemClick?: ItemClickFn<Item>;
|
||||
}
|
||||
|
||||
const offset = [5, 5] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const Dropdown = <Item = MenuItem,>({
|
||||
export const Dropdown = <Item extends object | null = MenuItem>({
|
||||
children,
|
||||
icon,
|
||||
iconComponent,
|
||||
|
|
@ -334,6 +361,7 @@ export const Dropdown = <Item = MenuItem,>({
|
|||
disabled,
|
||||
scrollable,
|
||||
placement = 'bottom',
|
||||
offset = [5, 5],
|
||||
status,
|
||||
forceDropdown = false,
|
||||
renderItem,
|
||||
|
|
|
|||
|
|
@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||
onClose,
|
||||
onChange,
|
||||
}) => {
|
||||
const nodeRef = useRef<HTMLUListElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
nodeRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, onClose],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||
|
|
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||
break;
|
||||
case 'ArrowDown':
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
listRef.current?.children[index + 1] ??
|
||||
listRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
listRef.current?.children[index - 1] ??
|
||||
listRef.current?.lastElementChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
listRef.current?.children[index - 1] ??
|
||||
listRef.current?.lastElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
listRef.current?.children[index + 1] ??
|
||||
listRef.current?.firstElementChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current?.firstElementChild;
|
||||
element = listRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current?.lastElementChild;
|
||||
element = listRef.current?.lastElementChild;
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
||||
[items, onClose, handleClick, setCurrentValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
listRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!listRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
|
|
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||
listenerOptions,
|
||||
);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<ul style={style} role='listbox' ref={nodeRef}>
|
||||
<ul style={style} role='listbox' ref={listRef}>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
role='option'
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export const FollowButton: React.FC<{
|
|||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'follow',
|
||||
accountId: accountId,
|
||||
url: account?.url,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils';
|
|||
* the hotkey with a higher priority is selected. All others
|
||||
* are ignored.
|
||||
*/
|
||||
const hotkeyPriority = {
|
||||
singleKey: 0,
|
||||
combo: 1,
|
||||
sequence: 2,
|
||||
} as const;
|
||||
const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
|
||||
|
||||
/**
|
||||
* This type of function receives a keyboard event and an array of
|
||||
|
|
@ -105,14 +101,16 @@ const hotkeyMatcherMap = {
|
|||
new: just('n'),
|
||||
forceNew: optionPlus('n'),
|
||||
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||
focusLoadMore: just('l'),
|
||||
reply: just('r'),
|
||||
favourite: just('f'),
|
||||
boost: just('b'),
|
||||
quote: just('q'),
|
||||
mention: just('m'),
|
||||
open: any('enter', 'o'),
|
||||
openProfile: just('p'),
|
||||
moveDown: any('down', 'j'),
|
||||
moveUp: any('up', 'k'),
|
||||
moveDown: just('j'),
|
||||
moveUp: just('k'),
|
||||
toggleHidden: just('x'),
|
||||
toggleSensitive: just('h'),
|
||||
toggleComposeSpoilers: optionPlus('x'),
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
|||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'vote',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { displayMedia } from '../initial_state';
|
|||
|
||||
import { Avatar } from './avatar';
|
||||
import { AvatarOverlay } from './avatar_overlay';
|
||||
import { DisplayName } from './display_name';
|
||||
import { LinkedDisplayName } from './display_name';
|
||||
import { getHashtagBarForStatus } from './hashtag_bar';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
|
|
@ -39,7 +39,18 @@ import { IconButton } from './icon_button';
|
|||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
|
||||
quote_noun: { id: 'status.quote_noun', defaultMessage: 'Quote', description: 'Quote as a noun' },
|
||||
contains_quote: { id: 'status.contains_quote', defaultMessage: 'Contains quote' },
|
||||
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
|
||||
});
|
||||
|
||||
export const textForScreenReader = ({intl, status, rebloggedByText = false, isQuote = false}) => {
|
||||
const displayName = status.getIn(['account', 'display_name']);
|
||||
|
||||
const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text');
|
||||
|
|
@ -47,15 +58,14 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => {
|
|||
const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent;
|
||||
|
||||
const values = [
|
||||
isQuote ? intl.formatMessage(messages.quote_noun) : undefined,
|
||||
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
|
||||
spoilerText && status.get('hidden') ? spoilerText : contentText,
|
||||
!!status.get('quote') ? intl.formatMessage(messages.contains_quote) : undefined,
|
||||
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
||||
status.getIn(['account', 'acct']),
|
||||
];
|
||||
|
||||
if (rebloggedByText) {
|
||||
values.push(rebloggedByText);
|
||||
}
|
||||
rebloggedByText,
|
||||
].filter(val => !!val);
|
||||
|
||||
return values.join(', ');
|
||||
};
|
||||
|
|
@ -72,15 +82,6 @@ export const defaultMediaVisibility = (status) => {
|
|||
return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
|
||||
quote_cancel: { id: 'status.quote.cancel', defaultMessage: 'Cancel quote' },
|
||||
});
|
||||
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextType = SensitiveMediaContext;
|
||||
|
|
@ -96,6 +97,7 @@ class Status extends ImmutablePureComponent {
|
|||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
onQuote: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
|
|
@ -114,8 +116,6 @@ class Status extends ImmutablePureComponent {
|
|||
muted: PropTypes.bool,
|
||||
hidden: PropTypes.bool,
|
||||
unread: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
showThread: PropTypes.bool,
|
||||
isQuotedPost: PropTypes.bool,
|
||||
getScrollPosition: PropTypes.func,
|
||||
|
|
@ -278,6 +278,10 @@ class Status extends ImmutablePureComponent {
|
|||
this.props.onReblog(this._properStatus(), e);
|
||||
};
|
||||
|
||||
handleHotkeyQuote = () => {
|
||||
this.props.onQuote(this._properStatus());
|
||||
};
|
||||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this._properStatus().get('account'));
|
||||
|
|
@ -328,14 +332,6 @@ class Status extends ImmutablePureComponent {
|
|||
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyMoveDown = e => {
|
||||
this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured'));
|
||||
};
|
||||
|
||||
handleHotkeyToggleHidden = () => {
|
||||
const { onToggleHidden } = this.props;
|
||||
const status = this._properStatus();
|
||||
|
|
@ -396,11 +392,10 @@ class Status extends ImmutablePureComponent {
|
|||
reply: this.handleHotkeyReply,
|
||||
favourite: this.handleHotkeyFavourite,
|
||||
boost: this.handleHotkeyBoost,
|
||||
quote: this.handleHotkeyQuote,
|
||||
mention: this.handleHotkeyMention,
|
||||
open: this.handleHotkeyOpen,
|
||||
openProfile: this.handleHotkeyOpenProfile,
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||
openMedia: this.handleHotkeyOpenMedia,
|
||||
|
|
@ -415,12 +410,20 @@ class Status extends ImmutablePureComponent {
|
|||
const matchedFilters = status.get('matched_filters');
|
||||
|
||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
const name = (
|
||||
<LinkedDisplayName
|
||||
displayProps={{
|
||||
account: status.get('account'),
|
||||
variant: 'simple'
|
||||
}}
|
||||
className='status__display-name muted'
|
||||
/>
|
||||
)
|
||||
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'><Icon id='retweet' icon={RepeatIcon} /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <Link data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} to={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></Link> }} />
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -552,7 +555,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader({intl, status, rebloggedByText, isQuote: isQuotedPost})} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
{!skipPrepend && prepend}
|
||||
|
||||
<div
|
||||
|
|
@ -576,13 +579,11 @@ class Status extends ImmutablePureComponent {
|
|||
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
||||
</Link>
|
||||
|
||||
<Link to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name'>
|
||||
<LinkedDisplayName displayProps={{account: status.get('account')}} className='status__display-name'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
||||
<DisplayName account={status.get('account')} />
|
||||
</Link>
|
||||
</LinkedDisplayName>
|
||||
|
||||
{isQuotedPost && !!this.props.onQuoteCancel && (
|
||||
<IconButton
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||
import { statusFactoryState } from '@/testing/factories';
|
||||
|
||||
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
|
||||
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||
|
||||
interface StoryProps {
|
||||
visibility: StatusVisibility;
|
||||
|
|
@ -13,7 +13,7 @@ interface StoryProps {
|
|||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Status/ReblogButton',
|
||||
title: 'Components/Status/BoostButton',
|
||||
args: {
|
||||
visibility: 'public',
|
||||
quoteAllowed: true,
|
||||
|
|
@ -38,7 +38,7 @@ const meta = {
|
|||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<StatusReblogButton
|
||||
<StatusBoostButton
|
||||
status={argsToStatus(args)}
|
||||
counters={args.reblogCount > 0}
|
||||
/>
|
||||
256
app/javascript/mastodon/components/status/boost_button.tsx
Normal file
256
app/javascript/mastodon/components/status/boost_button.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
|
||||
import { toggleReblog } from '@/mastodon/actions/interactions';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { Status } from '@/mastodon/models/status';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
import type { SomeRequired } from '@/mastodon/utils/types';
|
||||
|
||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import {
|
||||
boostItemState,
|
||||
messages,
|
||||
quoteItemState,
|
||||
selectStatusState,
|
||||
} from './boost_button_utils';
|
||||
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
|
||||
item,
|
||||
index,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
) => (
|
||||
<ReblogMenuItem
|
||||
index={index}
|
||||
item={item}
|
||||
handlers={handlers}
|
||||
key={`${item.text}-${index}`}
|
||||
focusRefCallback={focusRefCallback}
|
||||
/>
|
||||
);
|
||||
|
||||
interface ReblogButtonProps {
|
||||
status: Status;
|
||||
counters?: boolean;
|
||||
}
|
||||
|
||||
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
||||
|
||||
export const StatusBoostButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
const {
|
||||
isLoggedIn,
|
||||
isReblogged,
|
||||
isReblogAllowed,
|
||||
isQuoteAutomaticallyAccepted,
|
||||
isQuoteManuallyAccepted,
|
||||
} = statusState;
|
||||
|
||||
const isMenuDisabled =
|
||||
!isQuoteAutomaticallyAccepted &&
|
||||
!isQuoteManuallyAccepted &&
|
||||
!isReblogAllowed;
|
||||
|
||||
const statusId = status.get('id') as string;
|
||||
const wasBoosted = !!status.get('reblogged');
|
||||
|
||||
const showLoginPrompt = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, status]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const boostItem = boostItemState(statusState);
|
||||
const quoteItem = quoteItemState(statusState);
|
||||
return [
|
||||
{
|
||||
text: intl.formatMessage(boostItem.title),
|
||||
description: boostItem.meta
|
||||
? intl.formatMessage(boostItem.meta)
|
||||
: undefined,
|
||||
icon: boostItem.iconComponent,
|
||||
highlighted: wasBoosted,
|
||||
disabled: boostItem.disabled,
|
||||
action: (event) => {
|
||||
dispatch(toggleReblog(statusId, event.shiftKey));
|
||||
},
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage(quoteItem.title),
|
||||
description: quoteItem.meta
|
||||
? intl.formatMessage(quoteItem.meta)
|
||||
: undefined,
|
||||
icon: quoteItem.iconComponent,
|
||||
disabled: quoteItem.disabled,
|
||||
action: () => {
|
||||
dispatch(quoteComposeById(statusId));
|
||||
},
|
||||
},
|
||||
] satisfies [ActionMenuItemWithIcon, ActionMenuItemWithIcon];
|
||||
}, [dispatch, intl, statusId, statusState, wasBoosted]);
|
||||
|
||||
const boostIcon = items[0].icon;
|
||||
|
||||
const handleDropdownOpen = useCallback(
|
||||
(event: MouseEvent | KeyboardEvent) => {
|
||||
if (!isLoggedIn) {
|
||||
showLoginPrompt();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.shiftKey) {
|
||||
dispatch(toggleReblog(status.get('id'), true));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[dispatch, isLoggedIn, showLoginPrompt, status],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
placement='bottom-start'
|
||||
offset={[-19, 5]} // This aligns button icon with menu icons
|
||||
items={items}
|
||||
renderItem={renderMenuItem}
|
||||
onOpen={handleDropdownOpen}
|
||||
disabled={isMenuDisabled}
|
||||
>
|
||||
<IconButton
|
||||
title={intl.formatMessage(
|
||||
isMenuDisabled ? messages.all_disabled : messages.reblog_or_quote,
|
||||
)}
|
||||
icon='retweet'
|
||||
iconComponent={boostIcon}
|
||||
counter={
|
||||
counters
|
||||
? (status.get('reblogs_count') as number) +
|
||||
(status.get('quotes_count') as number)
|
||||
: undefined
|
||||
}
|
||||
active={isReblogged}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
interface ReblogMenuItemProps {
|
||||
item: ActionMenuItem;
|
||||
index: number;
|
||||
handlers: RenderItemFnHandlers;
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
index,
|
||||
item,
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
}) => {
|
||||
const { text, highlighted, disabled } = item;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames('dropdown-menu__item reblog-menu-item', {
|
||||
'dropdown-menu__item--highlighted': highlighted,
|
||||
})}
|
||||
key={`${text}-${index}`}
|
||||
>
|
||||
<button
|
||||
{...handlers}
|
||||
ref={focusRefCallback}
|
||||
aria-disabled={disabled}
|
||||
data-index={index}
|
||||
>
|
||||
<DropdownMenuItemContent item={item} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Legacy helpers
|
||||
|
||||
// Switch between the legacy and new reblog button based on feature flag.
|
||||
export const BoostButton: FC<ReblogButtonProps> = (props) => {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
return <StatusBoostButton {...props} />;
|
||||
}
|
||||
return <LegacyReblogButton {...props} />;
|
||||
};
|
||||
|
||||
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
|
||||
const { title, meta, iconComponent, disabled } = useMemo(
|
||||
() => boostItemState(statusState),
|
||||
[statusState],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleClick: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (statusState.isLoggedIn) {
|
||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, status, statusState.isLoggedIn],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
active={!!status.get('reblogged')}
|
||||
title={intl.formatMessage(meta ?? title)}
|
||||
icon='retweet'
|
||||
iconComponent={iconComponent}
|
||||
onClick={!disabled ? handleClick : undefined}
|
||||
counter={
|
||||
counters
|
||||
? (status.get('reblogs_count') as number) +
|
||||
(status.get('quotes_count') as number)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
167
app/javascript/mastodon/components/status/boost_button_utils.ts
Normal file
167
app/javascript/mastodon/components/status/boost_button_utils.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { defineMessages } from 'react-intl';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import type { Status, StatusVisibility } from '@/mastodon/models/status';
|
||||
import { createAppSelector } from '@/mastodon/store';
|
||||
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
|
||||
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
|
||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
|
||||
|
||||
import type { IconProp } from '../icon';
|
||||
|
||||
export const messages = defineMessages({
|
||||
all_disabled: {
|
||||
id: 'status.all_disabled',
|
||||
defaultMessage: 'Boosts and quotes are disabled',
|
||||
},
|
||||
quote: {
|
||||
id: 'status.quote',
|
||||
defaultMessage: 'Quote',
|
||||
description: 'Quote as a verb (e.g. Quote this post)',
|
||||
},
|
||||
quote_cannot: {
|
||||
id: 'status.cannot_quote',
|
||||
defaultMessage: 'You are not allowed to quote this post',
|
||||
},
|
||||
quote_followers_only: {
|
||||
id: 'status.quote_followers_only',
|
||||
defaultMessage: 'Only followers can quote this post',
|
||||
},
|
||||
quote_manual_review: {
|
||||
id: 'status.quote_manual_review',
|
||||
defaultMessage: 'Author will manually review',
|
||||
},
|
||||
quote_private: {
|
||||
id: 'status.quote_private',
|
||||
defaultMessage: 'Private posts cannot be quoted',
|
||||
},
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_or_quote: {
|
||||
id: 'status.reblog_or_quote',
|
||||
defaultMessage: 'Boost or quote',
|
||||
},
|
||||
reblog_cancel: {
|
||||
id: 'status.cancel_reblog_private',
|
||||
defaultMessage: 'Unboost',
|
||||
},
|
||||
reblog_private: {
|
||||
id: 'status.reblog_private',
|
||||
defaultMessage: 'Share again with your followers',
|
||||
},
|
||||
reblog_cannot: {
|
||||
id: 'status.cannot_reblog',
|
||||
defaultMessage: 'This post cannot be boosted',
|
||||
},
|
||||
request_quote: {
|
||||
id: 'status.request_quote',
|
||||
defaultMessage: 'Request to quote',
|
||||
},
|
||||
});
|
||||
|
||||
export const selectStatusState = createAppSelector(
|
||||
[
|
||||
(state) => state.meta.get('me') as string | undefined,
|
||||
(_, status: Status) => status,
|
||||
],
|
||||
(userId, status) => {
|
||||
const isPublic = ['public', 'unlisted'].includes(
|
||||
status.get('visibility') as StatusVisibility,
|
||||
);
|
||||
const isMineAndPrivate =
|
||||
userId === status.getIn(['account', 'id']) &&
|
||||
status.get('visibility') === 'private';
|
||||
return {
|
||||
isLoggedIn: !!userId,
|
||||
isPublic,
|
||||
isMine: userId === status.getIn(['account', 'id']),
|
||||
isPrivateReblog:
|
||||
userId === status.getIn(['account', 'id']) &&
|
||||
status.get('visibility') === 'private',
|
||||
isReblogged: !!status.get('reblogged'),
|
||||
isReblogAllowed: isPublic || isMineAndPrivate,
|
||||
isQuoteAutomaticallyAccepted:
|
||||
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
|
||||
(isPublic || isMineAndPrivate),
|
||||
isQuoteManuallyAccepted:
|
||||
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
|
||||
(isPublic || isMineAndPrivate),
|
||||
isQuoteFollowersOnly:
|
||||
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
|
||||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export type StatusState = ReturnType<typeof selectStatusState>;
|
||||
|
||||
export interface MenuItemState {
|
||||
title: MessageDescriptor;
|
||||
meta?: MessageDescriptor;
|
||||
iconComponent: IconProp;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function boostItemState({
|
||||
isPublic,
|
||||
isPrivateReblog,
|
||||
isReblogged,
|
||||
}: StatusState): MenuItemState {
|
||||
if (isReblogged) {
|
||||
return {
|
||||
title: messages.reblog_cancel,
|
||||
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
|
||||
};
|
||||
}
|
||||
const iconText: MenuItemState = {
|
||||
title: messages.reblog,
|
||||
iconComponent: RepeatIcon,
|
||||
};
|
||||
|
||||
if (isPrivateReblog) {
|
||||
iconText.meta = messages.reblog_private;
|
||||
iconText.iconComponent = RepeatPrivateIcon;
|
||||
} else if (!isPublic) {
|
||||
iconText.meta = messages.reblog_cannot;
|
||||
iconText.iconComponent = RepeatDisabledIcon;
|
||||
iconText.disabled = true;
|
||||
}
|
||||
return iconText;
|
||||
}
|
||||
|
||||
export function quoteItemState({
|
||||
isLoggedIn,
|
||||
isMine,
|
||||
isQuoteAutomaticallyAccepted,
|
||||
isQuoteManuallyAccepted,
|
||||
isQuoteFollowersOnly,
|
||||
isPublic,
|
||||
}: StatusState): MenuItemState {
|
||||
const iconText: MenuItemState = {
|
||||
title: messages.quote,
|
||||
iconComponent: FormatQuote,
|
||||
};
|
||||
|
||||
if (!isPublic && !isMine) {
|
||||
iconText.disabled = true;
|
||||
iconText.iconComponent = FormatQuoteOff;
|
||||
iconText.meta = messages.quote_private;
|
||||
} else if (isQuoteAutomaticallyAccepted) {
|
||||
iconText.title = messages.quote;
|
||||
} else if (isQuoteManuallyAccepted) {
|
||||
iconText.title = messages.request_quote;
|
||||
iconText.meta = messages.quote_manual_review;
|
||||
// We don't show the disabled state when logged out
|
||||
} else if (isLoggedIn) {
|
||||
iconText.disabled = true;
|
||||
iconText.iconComponent = FormatQuoteOff;
|
||||
iconText.meta = isQuoteFollowersOnly
|
||||
? messages.quote_followers_only
|
||||
: messages.quote_cannot;
|
||||
}
|
||||
|
||||
return iconText;
|
||||
}
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
FC,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
MouseEventHandler,
|
||||
SVGProps,
|
||||
} from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
|
||||
import { toggleReblog } from '@/mastodon/actions/interactions';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import type { Status, StatusVisibility } from '@/mastodon/models/status';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
import FormatQuote from '@/material-icons/400-24px/format_quote.svg?react';
|
||||
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
|
||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
|
||||
|
||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||
import { Dropdown } from '../dropdown_menu';
|
||||
import { Icon } from '../icon';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
all_disabled: {
|
||||
id: 'status.all_disabled',
|
||||
defaultMessage: 'Boosts and quotes are disabled',
|
||||
},
|
||||
quote: { id: 'status.quote', defaultMessage: 'Quote' },
|
||||
quote_cannot: {
|
||||
id: 'status.cannot_quote',
|
||||
defaultMessage: 'Author has disabled quoting on this post',
|
||||
},
|
||||
quote_private: {
|
||||
id: 'status.quote_private',
|
||||
defaultMessage: 'Private posts cannot be quoted',
|
||||
},
|
||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||
reblog_cancel: {
|
||||
id: 'status.cancel_reblog_private',
|
||||
defaultMessage: 'Unboost',
|
||||
},
|
||||
reblog_private: {
|
||||
id: 'status.reblog_private',
|
||||
defaultMessage: 'Boost with original visibility',
|
||||
},
|
||||
reblog_cannot: {
|
||||
id: 'status.cannot_reblog',
|
||||
defaultMessage: 'This post cannot be boosted',
|
||||
},
|
||||
});
|
||||
|
||||
interface ReblogButtonProps {
|
||||
status: Status;
|
||||
counters?: boolean;
|
||||
}
|
||||
|
||||
export const StatusReblogButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
const { isLoggedIn, isReblogged, isReblogAllowed, isQuoteAllowed } =
|
||||
statusState;
|
||||
const { iconComponent } = useMemo(
|
||||
() => reblogIconText(statusState),
|
||||
[statusState],
|
||||
);
|
||||
const disabled = !isQuoteAllowed && !isReblogAllowed;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const statusId = status.get('id') as string;
|
||||
const items: ActionMenuItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: 'reblog',
|
||||
action: (event) => {
|
||||
if (isLoggedIn) {
|
||||
dispatch(toggleReblog(statusId, event.shiftKey));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'quote',
|
||||
action: () => {
|
||||
if (isLoggedIn) {
|
||||
dispatch(quoteComposeById(statusId));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
[dispatch, isLoggedIn, statusId],
|
||||
);
|
||||
|
||||
const handleDropdownOpen = useCallback(
|
||||
(event: MouseEvent | KeyboardEvent) => {
|
||||
if (!isLoggedIn) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (event.shiftKey) {
|
||||
dispatch(toggleReblog(status.get('id'), true));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[dispatch, isLoggedIn, status],
|
||||
);
|
||||
|
||||
const renderMenuItem: RenderItemFn<ActionMenuItem> = useCallback(
|
||||
(item, index, handlers, focusRefCallback) => (
|
||||
<ReblogMenuItem
|
||||
status={status}
|
||||
index={index}
|
||||
item={item}
|
||||
handlers={handlers}
|
||||
key={`${item.text}-${index}`}
|
||||
focusRefCallback={focusRefCallback}
|
||||
/>
|
||||
),
|
||||
[status],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={items}
|
||||
renderItem={renderMenuItem}
|
||||
onOpen={handleDropdownOpen}
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconButton
|
||||
title={intl.formatMessage(
|
||||
!disabled ? messages.reblog : messages.all_disabled,
|
||||
)}
|
||||
icon='retweet'
|
||||
iconComponent={iconComponent}
|
||||
counter={counters ? (status.get('reblogs_count') as number) : undefined}
|
||||
active={isReblogged}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
interface ReblogMenuItemProps {
|
||||
status: Status;
|
||||
item: ActionMenuItem;
|
||||
index: number;
|
||||
handlers: RenderItemFnHandlers;
|
||||
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||
status,
|
||||
index,
|
||||
item: { text },
|
||||
handlers,
|
||||
focusRefCallback,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
const { title, meta, iconComponent, disabled } = useMemo(
|
||||
() =>
|
||||
text === 'quote'
|
||||
? quoteIconText(statusState)
|
||||
: reblogIconText(statusState),
|
||||
[statusState, text],
|
||||
);
|
||||
const active = useMemo(
|
||||
() => text === 'reblog' && !!status.get('reblogged'),
|
||||
[status, text],
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames('dropdown-menu__item reblog-button__item', {
|
||||
disabled,
|
||||
active,
|
||||
})}
|
||||
key={`${text}-${index}`}
|
||||
>
|
||||
<button
|
||||
{...handlers}
|
||||
title={intl.formatMessage(title)}
|
||||
ref={focusRefCallback}
|
||||
disabled={disabled}
|
||||
data-index={index}
|
||||
>
|
||||
<Icon
|
||||
id={text === 'quote' ? 'quote' : 'retweet'}
|
||||
icon={iconComponent}
|
||||
/>
|
||||
<div>
|
||||
{intl.formatMessage(title)}
|
||||
{meta && (
|
||||
<span className='reblog-button__meta'>
|
||||
{intl.formatMessage(meta)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Legacy helpers
|
||||
|
||||
// Switch between the legacy and new reblog button based on feature flag.
|
||||
export const ReblogButton: FC<ReblogButtonProps> = (props) => {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
return <StatusReblogButton {...props} />;
|
||||
}
|
||||
return <LegacyReblogButton {...props} />;
|
||||
};
|
||||
|
||||
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
|
||||
status,
|
||||
counters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const statusState = useAppSelector((state) =>
|
||||
selectStatusState(state, status),
|
||||
);
|
||||
|
||||
const { title, meta, iconComponent, disabled } = useMemo(
|
||||
() => reblogIconText(statusState),
|
||||
[statusState],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleClick: MouseEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (statusState.isLoggedIn) {
|
||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'reblog',
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, status, statusState.isLoggedIn],
|
||||
);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
active={!!status.get('reblogged')}
|
||||
title={intl.formatMessage(meta ?? title)}
|
||||
icon='retweet'
|
||||
iconComponent={iconComponent}
|
||||
onClick={!disabled ? handleClick : undefined}
|
||||
counter={counters ? (status.get('reblogs_count') as number) : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Helpers for copy and state for status.
|
||||
const selectStatusState = createAppSelector(
|
||||
[
|
||||
(state) => state.meta.get('me') as string | undefined,
|
||||
(_, status: Status) => status,
|
||||
],
|
||||
(userId, status) => {
|
||||
const isPublic = ['public', 'unlisted'].includes(
|
||||
status.get('visibility') as StatusVisibility,
|
||||
);
|
||||
const isMineAndPrivate =
|
||||
userId === status.getIn(['account', 'id']) &&
|
||||
status.get('visibility') === 'private';
|
||||
return {
|
||||
isLoggedIn: !!userId,
|
||||
isPublic,
|
||||
isMine: userId === status.getIn(['account', 'id']),
|
||||
isPrivateReblog:
|
||||
userId === status.getIn(['account', 'id']) &&
|
||||
status.get('visibility') === 'private',
|
||||
isReblogged: !!status.get('reblogged'),
|
||||
isReblogAllowed: isPublic || isMineAndPrivate,
|
||||
isQuoteAllowed:
|
||||
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
|
||||
(isPublic || isMineAndPrivate),
|
||||
};
|
||||
},
|
||||
);
|
||||
type StatusState = ReturnType<typeof selectStatusState>;
|
||||
|
||||
interface IconText {
|
||||
title: MessageDescriptor;
|
||||
meta?: MessageDescriptor;
|
||||
iconComponent: FC<SVGProps<SVGSVGElement>>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function reblogIconText({
|
||||
isPublic,
|
||||
isPrivateReblog,
|
||||
isReblogged,
|
||||
}: StatusState): IconText {
|
||||
if (isReblogged) {
|
||||
return {
|
||||
title: messages.reblog_cancel,
|
||||
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
|
||||
};
|
||||
}
|
||||
const iconText: IconText = {
|
||||
title: messages.reblog,
|
||||
iconComponent: RepeatIcon,
|
||||
};
|
||||
|
||||
if (isPrivateReblog) {
|
||||
iconText.meta = messages.reblog_private;
|
||||
iconText.iconComponent = RepeatPrivateIcon;
|
||||
} else if (!isPublic) {
|
||||
iconText.meta = messages.reblog_cannot;
|
||||
iconText.iconComponent = RepeatDisabledIcon;
|
||||
iconText.disabled = true;
|
||||
}
|
||||
return iconText;
|
||||
}
|
||||
|
||||
function quoteIconText({
|
||||
isMine,
|
||||
isQuoteAllowed,
|
||||
isPublic,
|
||||
}: StatusState): IconText {
|
||||
const iconText: IconText = {
|
||||
title: messages.quote,
|
||||
iconComponent: FormatQuote,
|
||||
};
|
||||
|
||||
if (!isQuoteAllowed || (!isPublic && !isMine)) {
|
||||
iconText.meta = !isQuoteAllowed
|
||||
? messages.quote_cannot
|
||||
: messages.quote_private;
|
||||
iconText.iconComponent = FormatQuoteOff;
|
||||
iconText.disabled = true;
|
||||
}
|
||||
return iconText;
|
||||
}
|
||||
|
|
@ -20,11 +20,12 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
|
|||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { me } from '../initial_state';
|
||||
import { me } from '../../initial_state';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
import { isFeatureEnabled } from '../utils/environment';
|
||||
import { ReblogButton } from './status/reblog_button';
|
||||
import { IconButton } from '../icon_button';
|
||||
import { isFeatureEnabled } from '../../utils/environment';
|
||||
import { BoostButton } from '../status/boost_button';
|
||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||
|
||||
const messages = defineMessages({
|
||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||
|
|
@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.record,
|
||||
quotedAccountId: PropTypes.string,
|
||||
contextType: PropTypes.string,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
|
|
@ -120,7 +122,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
if (signedIn) {
|
||||
this.props.onReply(this.props.status);
|
||||
} else {
|
||||
this.props.onInteractionModal('reply', this.props.status);
|
||||
this.props.onInteractionModal(this.props.status);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -138,7 +140,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
if (signedIn) {
|
||||
this.props.onFavourite(this.props.status);
|
||||
} else {
|
||||
this.props.onInteractionModal('favourite', this.props.status);
|
||||
this.props.onInteractionModal(this.props.status);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
const { status, relationship, quotedAccountId, contextType, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
|
|
@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
const account = status.get('account');
|
||||
const writtenByMe = status.getIn(['account', 'id']) === me;
|
||||
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
||||
const isQuotingMe = quotedAccountId === me;
|
||||
|
||||
let menu = [];
|
||||
|
||||
|
|
@ -293,7 +296,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||
menu.push(null);
|
||||
|
||||
if (quotedAccountId === me) {
|
||||
if (isQuotingMe) {
|
||||
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
||||
}
|
||||
|
||||
|
|
@ -360,6 +363,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
|
||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||
|
||||
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
|
|
@ -367,7 +372,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<ReblogButton status={status} counters={withCounters} />
|
||||
<BoostButton status={status} counters={withCounters} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
|
|
@ -375,17 +380,23 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
<RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
|
||||
{(dismissQuoteHint) => (
|
||||
<Dropdown
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
onOpen={() => {
|
||||
dismissQuoteHint();
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</RemoveQuoteHint>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { useEffect, useRef, useState, useId } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
import { Button } from '../button';
|
||||
import { useDismissableBannerState } from '../dismissable_banner';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
|
||||
|
||||
/**
|
||||
* We don't want to show this hint in the UI more than once,
|
||||
* so the first time it renders, we store a unique component ID
|
||||
* here to prevent any other hints from being displayed after it.
|
||||
*/
|
||||
let firstHintId: string | null = null;
|
||||
|
||||
export const RemoveQuoteHint: React.FC<{
|
||||
canShowHint: boolean;
|
||||
className?: string;
|
||||
children: (dismiss: () => void) => React.ReactNode;
|
||||
}> = ({ canShowHint, className, children }) => {
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const intl = useIntl();
|
||||
|
||||
const { wasDismissed, dismiss } = useDismissableBannerState({
|
||||
id: DISMISSABLE_BANNER_ID,
|
||||
});
|
||||
|
||||
const shouldShowHint = !wasDismissed && canShowHint;
|
||||
|
||||
const uniqueId = useId();
|
||||
const [isOnlyHint, setIsOnlyHint] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!shouldShowHint) {
|
||||
return () => null;
|
||||
}
|
||||
|
||||
if (!firstHintId) {
|
||||
firstHintId = uniqueId;
|
||||
setIsOnlyHint(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (firstHintId === uniqueId) {
|
||||
firstHintId = null;
|
||||
setIsOnlyHint(false);
|
||||
}
|
||||
};
|
||||
}, [shouldShowHint, uniqueId]);
|
||||
|
||||
return (
|
||||
<div className={className} ref={anchorRef}>
|
||||
{children(dismiss)}
|
||||
{shouldShowHint && isOnlyHint && (
|
||||
<Overlay
|
||||
show
|
||||
flip
|
||||
offset={[12, 10]}
|
||||
placement='bottom-end'
|
||||
target={anchorRef.current}
|
||||
container={anchorRef.current}
|
||||
>
|
||||
{({ props, placement }) => (
|
||||
<div
|
||||
{...props}
|
||||
className={classNames(
|
||||
'info-tooltip info-tooltip--solid dropdown-animation',
|
||||
placement,
|
||||
)}
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='remove_quote_hint.title'
|
||||
defaultMessage='Want to remove your quoted post?'
|
||||
/>
|
||||
</h4>
|
||||
<FormattedMessage
|
||||
id='remove_quote_hint.message'
|
||||
defaultMessage='You can do so from the {icon} options menu.'
|
||||
values={{
|
||||
icon: (
|
||||
<Icon
|
||||
id='ellipsis-h'
|
||||
icon={MoreHorizIcon}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'status.more',
|
||||
defaultMessage: 'More',
|
||||
})}
|
||||
style={{ verticalAlign: 'middle' }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(text) => <p>{text}</p>}
|
||||
</FormattedMessage>
|
||||
<FormattedMessage
|
||||
id='remove_quote_hint.button_label'
|
||||
defaultMessage='Got it'
|
||||
>
|
||||
{(text) => (
|
||||
<Button plain compact onClick={dismiss}>
|
||||
{text}
|
||||
</Button>
|
||||
)}
|
||||
</FormattedMessage>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -138,44 +138,8 @@ class StatusContent extends PureComponent {
|
|||
|
||||
onCollapsedToggle(collapsed);
|
||||
}
|
||||
|
||||
// Remove quote fallback link from the DOM so it doesn't
|
||||
// mess with paragraph margins
|
||||
if (!!status.get('quote')) {
|
||||
const inlineQuote = node.querySelector('.quote-inline');
|
||||
|
||||
if (inlineQuote) {
|
||||
inlineQuote.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount () {
|
||||
this._updateStatusLinks();
|
||||
}
|
||||
|
|
@ -267,7 +231,13 @@ class StatusContent extends PureComponent {
|
|||
if (this.props.onClick) {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div
|
||||
className={classNames}
|
||||
ref={this.setRef}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
onMouseUp={this.handleMouseUp}
|
||||
key='status-content'
|
||||
>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
|
|
@ -284,7 +254,7 @@ class StatusContent extends PureComponent {
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<div className={classNames} ref={this.setRef}>
|
||||
<EmojiHTML
|
||||
className='status__content__text status__content__text--visible translate'
|
||||
lang={language}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted';
|
|||
import { LoadGap } from './load_gap';
|
||||
import ScrollableList from './scrollable_list';
|
||||
|
||||
|
||||
export default class StatusList extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
trackScroll: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.columnHeaderHeight = this.node?.node
|
||||
? parseFloat(
|
||||
getComputedStyle(this.node.node).getPropertyValue('--column-header-height')
|
||||
) || 0
|
||||
: 0;
|
||||
}
|
||||
|
||||
getFeaturedStatusCount = () => {
|
||||
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
|
||||
};
|
||||
|
||||
getCurrentStatusIndex = (id, featured) => {
|
||||
if (featured) {
|
||||
return this.props.featuredStatusIds.indexOf(id);
|
||||
} else {
|
||||
return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
|
||||
}
|
||||
};
|
||||
|
||||
handleMoveUp = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, -1);
|
||||
};
|
||||
|
||||
handleMoveDown = (id, featured) => {
|
||||
const index = this.getCurrentStatusIndex(id, featured);
|
||||
this._selectChild(id, index, 1);
|
||||
};
|
||||
|
||||
_selectChild = (id, index, direction) => {
|
||||
const listContainer = this.node?.node;
|
||||
let listItem = listContainer?.querySelector(
|
||||
// :nth-child uses 1-based indexing
|
||||
`.item-list > :nth-child(${index + 1 + direction})`
|
||||
);
|
||||
|
||||
if (!listItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If selected container element is empty, we skip it
|
||||
if (listItem.matches(':empty')) {
|
||||
this._selectChild(id, index + direction, direction);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the list item is a post
|
||||
let targetElement = listItem.querySelector('.focusable');
|
||||
|
||||
// Otherwise, check if the item contains follow suggestions or
|
||||
// is a 'load more' button.
|
||||
if (
|
||||
!targetElement && (
|
||||
listItem.querySelector('.inline-follow-suggestions') ||
|
||||
listItem.matches('.load-more')
|
||||
)
|
||||
) {
|
||||
targetElement = listItem;
|
||||
}
|
||||
|
||||
if (targetElement) {
|
||||
const elementRect = targetElement.getBoundingClientRect();
|
||||
|
||||
const isFullyVisible =
|
||||
elementRect.top >= this.columnHeaderHeight &&
|
||||
elementRect.bottom <= window.innerHeight;
|
||||
|
||||
if (!isFullyVisible) {
|
||||
targetElement.scrollIntoView({
|
||||
block: direction === 1 ? 'start' : 'center',
|
||||
});
|
||||
}
|
||||
|
||||
targetElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const { statusIds, lastId, onLoadMore } = this.props;
|
||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||
|
|
@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
<StatusQuoteManager
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
scrollKey={this.props.scrollKey}
|
||||
showThread
|
||||
|
|
@ -176,8 +97,6 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
key={`f-${statusId}`}
|
||||
id={statusId}
|
||||
featured
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
showThread
|
||||
withCounters={this.props.withCounters}
|
||||
|
|
@ -191,5 +110,4 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
</ScrollableList>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,33 @@
|
|||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
||||
import StatusContainer from 'mastodon/containers/status_container';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import type { RootState } from 'mastodon/store';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { revealAccount } from '../actions/accounts_typed';
|
||||
import { fetchStatus } from '../actions/statuses';
|
||||
import { makeGetStatus } from '../selectors';
|
||||
import { makeGetStatusWithExtraInfo } from '../selectors';
|
||||
import { getAccountHidden } from '../selectors/accounts';
|
||||
|
||||
import { Button } from './button';
|
||||
|
||||
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
|
||||
|
||||
const QuoteWrapper: React.FC<{
|
||||
isError?: boolean;
|
||||
children: React.ReactElement;
|
||||
}> = ({ isError, children }) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames('status__quote', {
|
||||
'status__quote--error': isError,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
|
||||
const accountObjectOrId = status.get('account') as string | Account;
|
||||
const accountId =
|
||||
typeof accountObjectOrId === 'string'
|
||||
? accountObjectOrId
|
||||
: accountObjectOrId.id;
|
||||
|
||||
const NestedQuoteLink: React.FC<{
|
||||
status: Status;
|
||||
}> = ({ status }) => {
|
||||
const accountId = status.get('account') as string;
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
|
@ -57,15 +49,43 @@ const NestedQuoteLink: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
|
||||
type GetStatusSelector = (
|
||||
state: RootState,
|
||||
props: { id?: string | null; contextType?: string },
|
||||
) => Status | null;
|
||||
) => {
|
||||
status: Status | null;
|
||||
loadingState: 'not-found' | 'loading' | 'filtered' | 'complete';
|
||||
};
|
||||
|
||||
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
|
||||
|
||||
const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const reveal = useCallback(() => {
|
||||
dispatch(revealAccount({ id: accountId }));
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.limited_account_hint.title'
|
||||
defaultMessage='This account has been hidden by the moderators of {domain}.'
|
||||
values={{ domain }}
|
||||
/>
|
||||
<button onClick={reveal} className='link-button'>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.limited_account_hint.action'
|
||||
defaultMessage='Show anyway'
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface QuotedStatusProps {
|
||||
quote: QuoteMap;
|
||||
contextType?: string;
|
||||
parentQuotePostId?: string | null;
|
||||
variant?: 'full' | 'link';
|
||||
nestingLevel?: number;
|
||||
onQuoteCancel?: () => void; // Used for composer.
|
||||
|
|
@ -74,31 +94,61 @@ interface QuotedStatusProps {
|
|||
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||
quote,
|
||||
contextType,
|
||||
parentQuotePostId,
|
||||
nestingLevel = 1,
|
||||
variant = 'full',
|
||||
onQuoteCancel,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const quotedStatusId = quote.get('quoted_status');
|
||||
const quoteState = quote.get('state');
|
||||
const status = useAppSelector((state) =>
|
||||
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
|
||||
const quoteState = useAppSelector((state) =>
|
||||
parentQuotePostId
|
||||
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
|
||||
: quote.get('state'),
|
||||
);
|
||||
|
||||
const quotedStatusId = quote.get('quoted_status');
|
||||
const getStatusSelector = useMemo(
|
||||
() => makeGetStatusWithExtraInfo() as GetStatusSelector,
|
||||
[],
|
||||
);
|
||||
const { status, loadingState } = useAppSelector((state) =>
|
||||
getStatusSelector(state, { id: quotedStatusId, contextType }),
|
||||
);
|
||||
|
||||
const accountId: string | null = status?.get('account')
|
||||
? (status.get('account') as Account).id
|
||||
: null;
|
||||
const hiddenAccount = useAppSelector(
|
||||
(state) => accountId && getAccountHidden(state, accountId),
|
||||
);
|
||||
|
||||
const shouldFetchQuote =
|
||||
!status?.get('isLoading') &&
|
||||
quoteState !== 'deleted' &&
|
||||
loadingState === 'not-found';
|
||||
const isLoaded = loadingState === 'complete';
|
||||
|
||||
const isFetchingQuoteRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status && quotedStatusId) {
|
||||
dispatch(fetchStatus(quotedStatusId));
|
||||
if (isLoaded) {
|
||||
isFetchingQuoteRef.current = false;
|
||||
}
|
||||
}, [status, quotedStatusId, dispatch]);
|
||||
}, [isLoaded]);
|
||||
|
||||
// In order to find out whether the quoted post should be completely hidden
|
||||
// due to a matching filter, we run it through the selector used by `status_container`.
|
||||
// If this returns null even though `status` exists, it's because it's filtered.
|
||||
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
||||
const statusWithExtraData = useAppSelector((state) =>
|
||||
getStatus(state, { id: quotedStatusId, contextType }),
|
||||
);
|
||||
const isFilteredAndHidden = status && statusWithExtraData === null;
|
||||
useEffect(() => {
|
||||
if (shouldFetchQuote && quotedStatusId && !isFetchingQuoteRef.current) {
|
||||
dispatch(
|
||||
fetchStatus(quotedStatusId, {
|
||||
parentQuotePostId,
|
||||
alsoFetchContext: false,
|
||||
}),
|
||||
);
|
||||
isFetchingQuoteRef.current = true;
|
||||
}
|
||||
}, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
||||
|
||||
const isFilteredAndHidden = loadingState === 'filtered';
|
||||
|
||||
let quoteError: React.ReactNode = null;
|
||||
|
||||
|
|
@ -118,27 +168,27 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||
/>
|
||||
|
||||
<LearnMoreLink>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval_popout.title'
|
||||
defaultMessage='Pending quote? Remain calm'
|
||||
/>
|
||||
</h6>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='status.quote_error.pending_approval_popout.body'
|
||||
defaultMessage='Quotes shared across the Fediverse may take time to display, as different servers have different protocols.'
|
||||
defaultMessage="On Mastodon, you can control whether someone can quote you. This post is pending while we're getting the original author's approval."
|
||||
/>
|
||||
</p>
|
||||
</LearnMoreLink>
|
||||
</>
|
||||
);
|
||||
} else if (quoteState === 'revoked') {
|
||||
quoteError = (
|
||||
<FormattedMessage
|
||||
id='status.quote_error.revoked'
|
||||
defaultMessage='Post removed by author'
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
!status ||
|
||||
!quotedStatusId ||
|
||||
quoteState === 'deleted' ||
|
||||
quoteState === 'rejected' ||
|
||||
quoteState === 'revoked' ||
|
||||
quoteState === 'unauthorized'
|
||||
) {
|
||||
quoteError = (
|
||||
|
|
@ -147,10 +197,26 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||
defaultMessage='Post unavailable'
|
||||
/>
|
||||
);
|
||||
} else if (hiddenAccount && accountId) {
|
||||
quoteError = <LimitedAccountHint accountId={accountId} />;
|
||||
}
|
||||
|
||||
if (quoteError) {
|
||||
return <QuoteWrapper isError>{quoteError}</QuoteWrapper>;
|
||||
const hasRemoveButton = contextType === 'composer' && !!onQuoteCancel;
|
||||
|
||||
return (
|
||||
<div className='status__quote status__quote--error'>
|
||||
{quoteError}
|
||||
{hasRemoveButton && (
|
||||
<Button compact plain onClick={onQuoteCancel}>
|
||||
<FormattedMessage
|
||||
id='status.remove_quote'
|
||||
defaultMessage='Remove'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'link' && status) {
|
||||
|
|
@ -162,7 +228,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||
childQuote && nestingLevel <= MAX_QUOTE_POSTS_NESTING_LEVEL;
|
||||
|
||||
return (
|
||||
<QuoteWrapper>
|
||||
<div className='status__quote'>
|
||||
{/* @ts-expect-error Status is not yet typed */}
|
||||
<StatusContainer
|
||||
isQuotedPost
|
||||
|
|
@ -174,6 +240,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||
{canRenderChildQuote && (
|
||||
<QuotedStatus
|
||||
quote={childQuote}
|
||||
parentQuotePostId={quotedStatusId}
|
||||
contextType={contextType}
|
||||
variant={
|
||||
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
|
||||
|
|
@ -182,7 +249,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|||
/>
|
||||
)}
|
||||
</StatusContainer>
|
||||
</QuoteWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -209,7 +276,11 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
|
|||
if (quote) {
|
||||
return (
|
||||
<StatusContainer {...props}>
|
||||
<QuotedStatus quote={quote} contextType={props.contextType} />
|
||||
<QuotedStatus
|
||||
quote={quote}
|
||||
parentQuotePostId={status?.get('id') as string}
|
||||
contextType={props.contextType}
|
||||
/>
|
||||
</StatusContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { DisplayedName } from 'mastodon/features/notifications_v2/components/displayed_name';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { LinkedDisplayName } from './display_name';
|
||||
|
||||
export const StatusThreadLabel: React.FC<{
|
||||
accountId: string;
|
||||
inReplyToAccountId: string;
|
||||
|
|
@ -27,7 +28,13 @@ export const StatusThreadLabel: React.FC<{
|
|||
<FormattedMessage
|
||||
id='status.replied_to'
|
||||
defaultMessage='Replied to {name}'
|
||||
values={{ name: <DisplayedName accountIds={[inReplyToAccountId]} /> }}
|
||||
values={{
|
||||
name: (
|
||||
<LinkedDisplayName
|
||||
displayProps={{ account: inReplyToAccount, variant: 'simple' }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
mentionCompose,
|
||||
directCompose,
|
||||
} from '../actions/compose';
|
||||
import { quoteComposeById } from '../actions/compose_typed';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
|
|
@ -46,6 +47,8 @@ import Status from '../components/status';
|
|||
import { deleteModal } from '../initial_state';
|
||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||
|
||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
const getPictureInPicture = makeGetPictureInPicture();
|
||||
|
|
@ -76,6 +79,12 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
onReblog (status, e) {
|
||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
||||
},
|
||||
|
||||
onQuote (status) {
|
||||
if (isFeatureEnabled('outgoing_quotes')) {
|
||||
dispatch(quoteComposeById(status.get('id')));
|
||||
}
|
||||
},
|
||||
|
||||
onFavourite (status) {
|
||||
dispatch(toggleFavourite(status.get('id')));
|
||||
|
|
@ -108,7 +117,13 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
if (!deleteModal) {
|
||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
||||
} else {
|
||||
dispatch(openModal({ modalType: 'CONFIRM_DELETE_STATUS', modalProps: { statusId: status.get('id'), withRedraft } }));
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM_DELETE_STATUS',
|
||||
modalProps: {
|
||||
statusId: status.get('id'),
|
||||
withRedraft
|
||||
}
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -220,11 +235,10 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
|||
dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps}));
|
||||
},
|
||||
|
||||
onInteractionModal (type, status) {
|
||||
onInteractionModal (status) {
|
||||
dispatch(openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type,
|
||||
accountId: status.getIn(['account', 'id']),
|
||||
url: status.get('uri'),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
|
||||
export default class FollowRequestNote extends ImmutablePureComponent {
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ export default class FollowRequestNote extends ImmutablePureComponent {
|
|||
return (
|
||||
<div className='follow-request-banner'>
|
||||
<div className='follow-request-banner__message'>
|
||||
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} />
|
||||
<FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <DisplayName account={account} variant='simple' /> }} />
|
||||
</div>
|
||||
|
||||
<div className='follow-request-banner__action'>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Helmet } from 'react-helmet';
|
|||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
|
@ -378,36 +379,6 @@ export const AccountHeader: React.FC<{
|
|||
});
|
||||
}, [account]);
|
||||
|
||||
const handleMouseEnter = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget
|
||||
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||
.forEach((emoji) => {
|
||||
emoji.src = emoji.getAttribute('data-original') ?? '';
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(
|
||||
({ currentTarget }: React.MouseEvent) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget
|
||||
.querySelectorAll<HTMLImageElement>('.custom-emoji')
|
||||
.forEach((emoji) => {
|
||||
emoji.src = emoji.getAttribute('data-static') ?? '';
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
|
@ -774,7 +745,6 @@ export const AccountHeader: React.FC<{
|
|||
);
|
||||
}
|
||||
|
||||
const displayNameHtml = { __html: account.display_name_html };
|
||||
const fields = account.fields;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
const username = account.acct.split('@')[0];
|
||||
|
|
@ -808,11 +778,9 @@ export const AccountHeader: React.FC<{
|
|||
)}
|
||||
|
||||
<div
|
||||
className={classNames('account__header', {
|
||||
className={classNames('account__header animate-parent', {
|
||||
inactive: !!account.moved,
|
||||
})}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
|
|
@ -863,7 +831,7 @@ export const AccountHeader: React.FC<{
|
|||
|
||||
<div className='account__header__tabs__name'>
|
||||
<h1>
|
||||
<span dangerouslySetInnerHTML={displayNameHtml} />
|
||||
<DisplayName account={account} variant='simple' />
|
||||
<small>
|
||||
<span>
|
||||
@{username}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,26 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { AvatarGroup } from '@/mastodon/components/avatar_group';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
|
||||
import { useFetchFamiliarFollowers } from '../hooks/familiar_followers';
|
||||
|
||||
const AccountLink: React.FC<{ account?: Account }> = ({ account }) => {
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/@${account.acct}`}
|
||||
data-hover-card-account={account.id}
|
||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({
|
||||
familiarFollowers,
|
||||
}) => {
|
||||
const messageData = {
|
||||
name1: <AccountLink account={familiarFollowers.at(0)} />,
|
||||
name2: <AccountLink account={familiarFollowers.at(1)} />,
|
||||
name1: (
|
||||
<LinkedDisplayName
|
||||
displayProps={{ account: familiarFollowers.at(0), variant: 'simple' }}
|
||||
/>
|
||||
),
|
||||
name2: (
|
||||
<LinkedDisplayName
|
||||
displayProps={{ account: familiarFollowers.at(1), variant: 'simple' }}
|
||||
/>
|
||||
),
|
||||
othersCount: familiarFollowers.length - 2,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const MovedNote: React.FC<{
|
||||
|
|
@ -20,15 +20,7 @@ export const MovedNote: React.FC<{
|
|||
id='account.moved_to'
|
||||
defaultMessage='{name} has indicated that their new account is now:'
|
||||
values={{
|
||||
name: (
|
||||
<bdi>
|
||||
<strong
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: from?.display_name_html ?? '',
|
||||
}}
|
||||
/>
|
||||
</bdi>
|
||||
),
|
||||
name: <DisplayName account={from} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useCallback } from 'react';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
|
|
@ -79,11 +80,7 @@ export const HighlightedPost: React.FC<{
|
|||
id='annual_report.summary.highlighted_post.possessive'
|
||||
defaultMessage="{name}'s"
|
||||
values={{
|
||||
name: account && (
|
||||
<bdi
|
||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
||||
/>
|
||||
),
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export const messages = defineMessages({
|
|||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
|
||||
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Hidden from Mastodon search results, trending, and public timelines' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ export const ComposeQuotedStatus: FC = () => {
|
|||
const quotedStatusId = useAppSelector(
|
||||
(state) => state.compose.get('quoted_status_id') as string | null,
|
||||
);
|
||||
|
||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||
|
||||
const quote = useMemo(
|
||||
() =>
|
||||
quotedStatusId
|
||||
|
|
@ -22,16 +24,20 @@ export const ComposeQuotedStatus: FC = () => {
|
|||
: null,
|
||||
[quotedStatusId],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleQuoteCancel = useCallback(() => {
|
||||
dispatch(quoteComposeCancel());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuotedStatus
|
||||
quote={quote}
|
||||
contextType='composer'
|
||||
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -79,10 +79,12 @@ const visibilityOptions = {
|
|||
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { visibility, quotePolicy } = useAppSelector((state) => ({
|
||||
visibility: state.compose.get('privacy') as StatusVisibility,
|
||||
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
|
||||
}));
|
||||
const quotePolicy = useAppSelector(
|
||||
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
|
||||
);
|
||||
const visibility = useAppSelector(
|
||||
(state) => state.compose.get('privacy') as StatusVisibility,
|
||||
);
|
||||
|
||||
const { icon, iconComponent } = useMemo(() => {
|
||||
const option = visibilityOptions[visibility];
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import { IconButton } from 'mastodon/components/icon_button';
|
|||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import StatusContent from 'mastodon/components/status_content';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import { makeGetStatus } from 'mastodon/selectors';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
|
||||
const messages = defineMessages({
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
|
|
@ -45,7 +45,7 @@ const getAccounts = createSelector(
|
|||
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
|
||||
export const Conversation = ({ conversation, scrollKey }) => {
|
||||
const id = conversation.get('id');
|
||||
const unread = conversation.get('unread');
|
||||
const lastStatusId = conversation.get('last_status');
|
||||
|
|
@ -56,32 +56,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
|
||||
const accounts = useSelector(state => getAccounts(state, accountIds));
|
||||
|
||||
const handleMouseEnter = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (unread) {
|
||||
dispatch(markConversationRead(id));
|
||||
|
|
@ -110,14 +84,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
dispatch(deleteConversation(id));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleHotkeyMoveUp = useCallback(() => {
|
||||
onMoveUp(id);
|
||||
}, [id, onMoveUp]);
|
||||
|
||||
const handleHotkeyMoveDown = useCallback(() => {
|
||||
onMoveDown(id);
|
||||
}, [id, onMoveDown]);
|
||||
|
||||
const handleConversationMute = useCallback(() => {
|
||||
if (lastStatus.get('muted')) {
|
||||
dispatch(unmuteStatus(lastStatus.get('id')));
|
||||
|
|
@ -147,22 +113,13 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
|
||||
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
||||
|
||||
const names = accounts.map(a => (
|
||||
<Link to={`/@${a.get('acct')}`} key={a.get('id')} data-hover-card-account={a.get('id')}>
|
||||
<bdi>
|
||||
<strong
|
||||
className='display-name__html'
|
||||
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
|
||||
/>
|
||||
</bdi>
|
||||
</Link>
|
||||
const names = accounts.map((account) => (
|
||||
<LinkedDisplayName displayProps={{account, variant: 'simple'}} key={account.get('id')} />
|
||||
)).reduce((prev, cur) => [prev, ', ', cur]);
|
||||
|
||||
const handlers = {
|
||||
reply: handleReply,
|
||||
open: handleClick,
|
||||
moveUp: handleHotkeyMoveUp,
|
||||
moveDown: handleHotkeyMoveDown,
|
||||
toggleHidden: handleShowMore,
|
||||
};
|
||||
|
||||
|
|
@ -179,7 +136,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||
</div>
|
||||
|
||||
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<div className='conversation__content__names animate-parent' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -224,6 +181,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
|||
Conversation.propTypes = {
|
||||
conversation: ImmutablePropTypes.map.isRequired,
|
||||
scrollKey: PropTypes.string,
|
||||
onMoveUp: PropTypes.func,
|
||||
onMoveDown: PropTypes.func,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list';
|
|||
|
||||
import { Conversation } from './conversation';
|
||||
|
||||
const focusChild = (node, index, alignTop) => {
|
||||
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (alignTop && node.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
|
||||
element.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||
const listRef = useRef();
|
||||
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||
|
|
@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
|||
const dispatch = useDispatch();
|
||||
const lastStatusId = conversations.last()?.get('last_status');
|
||||
|
||||
const handleMoveUp = useCallback(id => {
|
||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
|
||||
focusChild(listRef.current.node, elementIndex, true);
|
||||
}, [listRef, conversations]);
|
||||
|
||||
const handleMoveDown = useCallback(id => {
|
||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
|
||||
focusChild(listRef.current.node, elementIndex, false);
|
||||
}, [listRef, conversations]);
|
||||
|
||||
const debouncedLoadMore = useMemo(() => debounce(id => {
|
||||
dispatch(expandConversations({ maxId: id }));
|
||||
}, 300, { leading: true }), [dispatch]);
|
||||
|
|
@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
|||
<Conversation
|
||||
key={item.get('id')}
|
||||
conversation={item}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
scrollKey={scrollKey}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { MouseEventHandler } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
|
@ -44,39 +43,6 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleMouseEnter = useCallback<MouseEventHandler>(
|
||||
({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const original = emoji.getAttribute('data-original');
|
||||
if (original) emoji.src = original;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<MouseEventHandler>(
|
||||
({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
emojis.forEach((emoji) => {
|
||||
const staticUrl = emoji.getAttribute('data-static');
|
||||
if (staticUrl) emoji.src = staticUrl;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!account) return;
|
||||
|
||||
|
|
@ -185,9 +151,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||
|
||||
{account.get('note').length > 0 && (
|
||||
<div
|
||||
className='account-card__bio translate'
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className='account-card__bio translate animate-parent'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
import { useEmojify } from './hooks';
|
||||
|
|
@ -7,28 +9,39 @@ import type { CustomEmojiMapArg } from './types';
|
|||
|
||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||
ComponentPropsWithoutRef<Element>,
|
||||
'dangerouslySetInnerHTML'
|
||||
'dangerouslySetInnerHTML' | 'className'
|
||||
> & {
|
||||
htmlString: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
as?: Element;
|
||||
shallow?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ModernEmojiHTML = <Element extends ElementType>({
|
||||
export const ModernEmojiHTML = ({
|
||||
extraEmojis,
|
||||
htmlString,
|
||||
as: asElement, // Rename for syntax highlighting
|
||||
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||
shallow,
|
||||
className = '',
|
||||
...props
|
||||
}: EmojiHTMLProps<Element>) => {
|
||||
const Wrapper = asElement ?? 'div';
|
||||
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
||||
}: EmojiHTMLProps<ElementType>) => {
|
||||
const emojifiedHtml = useEmojify({
|
||||
text: htmlString,
|
||||
extraEmojis,
|
||||
deep: !shallow,
|
||||
});
|
||||
|
||||
if (emojifiedHtml === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
||||
<Wrapper
|
||||
{...props}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -38,7 +51,13 @@ export const EmojiHTML = <Element extends ElementType>(
|
|||
if (isModernEmojiEnabled()) {
|
||||
return <ModernEmojiHTML {...props} />;
|
||||
}
|
||||
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
|
||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||
const Wrapper = asElement ?? 'div';
|
||||
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
|
||||
return (
|
||||
<Wrapper
|
||||
{...rest}
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||
className={classNames(className, 'animate-parent')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
61
app/javascript/mastodon/features/emoji/handlers.ts
Normal file
61
app/javascript/mastodon/features/emoji/handlers.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
|
||||
const PARENT_MAX_DEPTH = 10;
|
||||
|
||||
export function handleAnimateGif(event: MouseEvent) {
|
||||
// We already check this in ui/index.jsx, but just to be sure.
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, type } = event;
|
||||
const animate = type === 'mouseover'; // Mouse over = animate, mouse out = don't animate.
|
||||
|
||||
if (target instanceof HTMLImageElement) {
|
||||
setAnimateGif(target, animate);
|
||||
} else if (!(target instanceof HTMLElement) || target === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parent: HTMLElement | null = null;
|
||||
let iter = 0;
|
||||
|
||||
if (target.classList.contains('animate-parent')) {
|
||||
parent = target;
|
||||
} else {
|
||||
// Iterate up to PARENT_MAX_DEPTH levels up the DOM tree to find a parent with the class 'animate-parent'.
|
||||
let current: HTMLElement | null = target;
|
||||
while (current) {
|
||||
if (iter >= PARENT_MAX_DEPTH) {
|
||||
return; // We can just exit right now.
|
||||
}
|
||||
current = current.parentElement;
|
||||
if (current?.classList.contains('animate-parent')) {
|
||||
parent = current;
|
||||
break;
|
||||
}
|
||||
iter++;
|
||||
}
|
||||
}
|
||||
|
||||
// Affect all animated children within the parent.
|
||||
if (parent) {
|
||||
const animatedChildren =
|
||||
parent.querySelectorAll<HTMLImageElement>('img.custom-emoji');
|
||||
for (const child of animatedChildren) {
|
||||
setAnimateGif(child, animate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAnimateGif(image: HTMLImageElement, animate: boolean) {
|
||||
const { classList, dataset } = image;
|
||||
if (
|
||||
!classList.contains('custom-emoji') ||
|
||||
!dataset.static ||
|
||||
!dataset.original
|
||||
) {
|
||||
return;
|
||||
}
|
||||
image.src = animate ? dataset.original : dataset.static;
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|||
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { determineEmojiMode } from './mode';
|
||||
import { emojifyElement, emojifyText } from './render';
|
||||
import type {
|
||||
CustomEmojiMapArg,
|
||||
EmojiAppState,
|
||||
|
|
@ -15,7 +16,17 @@ import type {
|
|||
} from './types';
|
||||
import { stringHasAnyEmoji } from './utils';
|
||||
|
||||
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||
interface UseEmojifyOptions {
|
||||
text: string;
|
||||
extraEmojis?: CustomEmojiMapArg;
|
||||
deep?: boolean;
|
||||
}
|
||||
|
||||
export function useEmojify({
|
||||
text,
|
||||
extraEmojis,
|
||||
deep = true,
|
||||
}: UseEmojifyOptions) {
|
||||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||
|
||||
const appState = useEmojiAppState();
|
||||
|
|
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
|||
|
||||
const emojify = useCallback(
|
||||
async (input: string) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = input;
|
||||
const { emojifyElement } = await import('./render');
|
||||
const result = await emojifyElement(wrapper, appState, extra);
|
||||
let result: string | null = null;
|
||||
if (deep) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = input;
|
||||
if (await emojifyElement(wrapper, appState, extra)) {
|
||||
result = wrapper.innerHTML;
|
||||
}
|
||||
} else {
|
||||
result = await emojifyText(text, appState, extra);
|
||||
}
|
||||
if (result) {
|
||||
setEmojifiedText(result.innerHTML);
|
||||
setEmojifiedText(result);
|
||||
} else {
|
||||
setEmojifiedText(input);
|
||||
}
|
||||
},
|
||||
[appState, extra],
|
||||
[appState, deep, extra, text],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
|
||||
export const AuthorLink = ({ accountId }) => {
|
||||
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
||||
|
|
@ -13,10 +12,9 @@ export const AuthorLink = ({ accountId }) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
|
||||
<LinkedDisplayName displayProps={{account}} className='story__details__shared__author-link'>
|
||||
<Avatar account={account} size={16} />
|
||||
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
||||
</Link>
|
||||
</LinkedDisplayName>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -111,42 +111,14 @@ class ContentWithRouter extends ImmutablePureComponent {
|
|||
}
|
||||
};
|
||||
|
||||
handleMouseEnter = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-original');
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseLeave = ({ currentTarget }) => {
|
||||
if (autoPlayGif) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emojis = currentTarget.querySelectorAll('.custom-emoji');
|
||||
|
||||
for (var i = 0; i < emojis.length; i++) {
|
||||
let emoji = emojis[i];
|
||||
emoji.src = emoji.getAttribute('data-static');
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { announcement } = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className='announcements__item__content translate'
|
||||
className='announcements__item__content translate animate-parent'
|
||||
ref={this.setRef}
|
||||
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<animated.button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
|
||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
||||
<animated.button
|
||||
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
|
||||
onClick={this.handleClick}
|
||||
title={`:${shortCode}:`}
|
||||
style={this.props.style}
|
||||
// This does not use animate-parent as this component is directly rendered by React.
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
>
|
||||
<span className='reactions-bar__item__emoji'>
|
||||
<Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} />
|
||||
</span>
|
||||
<span className='reactions-bar__item__count'>
|
||||
<AnimatedNumber value={reaction.get('count')} />
|
||||
</span>
|
||||
</animated.button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useId } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ import { DisplayName } from 'mastodon/components/display_name';
|
|||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
|
@ -56,9 +57,7 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
const Source: React.FC<{
|
||||
id: ApiSuggestionSourceJSON;
|
||||
}> = ({ id }) => {
|
||||
const Source: React.FC<{ id: ApiSuggestionSourceJSON }> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
let label, hint;
|
||||
|
|
@ -168,10 +167,11 @@ const Card: React.FC<{
|
|||
|
||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||
|
||||
export const InlineFollowSuggestions: React.FC<{
|
||||
hidden?: boolean;
|
||||
}> = ({ hidden }) => {
|
||||
export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||
hidden,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const uniqueId = useId();
|
||||
const dispatch = useAppDispatch();
|
||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||
|
|
@ -257,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='inline-follow-suggestions'>
|
||||
<div
|
||||
role='group'
|
||||
aria-labelledby={uniqueId}
|
||||
className='inline-follow-suggestions focusable'
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3>
|
||||
<h3 id={uniqueId}>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.who_to_follow'
|
||||
defaultMessage='Who to follow'
|
||||
|
|
@ -288,13 +293,17 @@ export const InlineFollowSuggestions: React.FC<{
|
|||
ref={bodyRef}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Card
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
sources={suggestion.sources}
|
||||
/>
|
||||
))}
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
suggestions.map((suggestion) => (
|
||||
<Card
|
||||
key={suggestion.account_id}
|
||||
id={suggestion.account_id}
|
||||
sources={suggestion.sources}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canScrollLeft && (
|
||||
|
|
|
|||
|
|
@ -7,15 +7,10 @@ import classNames from 'classnames';
|
|||
import { escapeRegExp } from 'lodash';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||
import { apiRequest } from 'mastodon/api';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import {
|
||||
domain as localDomain,
|
||||
registrationsOpen,
|
||||
|
|
@ -408,18 +403,15 @@ const LoginForm: React.FC<{
|
|||
const InteractionModal: React.FC<{
|
||||
accountId: string;
|
||||
url: string;
|
||||
type: 'reply' | 'reblog' | 'favourite' | 'follow' | 'vote';
|
||||
}> = ({ accountId, url, type }) => {
|
||||
}> = ({ accountId, url }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const displayNameHtml = useAppSelector(
|
||||
(state) => state.accounts.get(accountId)?.display_name_html ?? '',
|
||||
);
|
||||
const signupUrl = useAppSelector(
|
||||
(state) =>
|
||||
(state.server.getIn(['server', 'registrations', 'url'], null) ||
|
||||
'/auth/sign_up') as string,
|
||||
);
|
||||
const name = <bdi dangerouslySetInnerHTML={{ __html: displayNameHtml }} />;
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const name = <DisplayName account={account} variant='simple' />;
|
||||
|
||||
const handleSignupClick = useCallback(() => {
|
||||
dispatch(
|
||||
|
|
@ -437,93 +429,6 @@ const InteractionModal: React.FC<{
|
|||
);
|
||||
}, [dispatch]);
|
||||
|
||||
let title: React.ReactNode,
|
||||
icon: React.ReactNode,
|
||||
actionPrompt: React.ReactNode;
|
||||
|
||||
switch (type) {
|
||||
case 'reply':
|
||||
icon = <Icon id='reply' icon={ReplyIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.reply'
|
||||
defaultMessage="Reply to {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.reply'
|
||||
defaultMessage='To continue, you need to reply from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'reblog':
|
||||
icon = <Icon id='retweet' icon={RepeatIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.reblog'
|
||||
defaultMessage="Boost {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.reblog'
|
||||
defaultMessage='To continue, you need to reblog from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'favourite':
|
||||
icon = <Icon id='star' icon={StarIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.favourite'
|
||||
defaultMessage="Favorite {name}'s post"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.favourite'
|
||||
defaultMessage='To continue, you need to favorite from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'follow':
|
||||
icon = <Icon id='user-plus' icon={PersonAddIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.follow'
|
||||
defaultMessage='Follow {name}'
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.follow'
|
||||
defaultMessage='To continue, you need to follow from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'vote':
|
||||
icon = <Icon id='tasks' icon={InsertChartIcon} />;
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title.vote'
|
||||
defaultMessage="Vote in {name}'s poll"
|
||||
values={{ name }}
|
||||
/>
|
||||
);
|
||||
actionPrompt = (
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action.vote'
|
||||
defaultMessage='To continue, you need to vote from your account.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let signupButton;
|
||||
|
||||
if (sso_redirect) {
|
||||
|
|
@ -559,9 +464,18 @@ const InteractionModal: React.FC<{
|
|||
<div className='modal-root__modal interaction-modal'>
|
||||
<div className='interaction-modal__lead'>
|
||||
<h3>
|
||||
<span className='interaction-modal__icon'>{icon}</span> {title}
|
||||
<FormattedMessage
|
||||
id='interaction_modal.title'
|
||||
defaultMessage='Sign in to continue'
|
||||
/>
|
||||
</h3>
|
||||
<p>{actionPrompt}</p>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='interaction_modal.action'
|
||||
defaultMessage="To interact with {name}'s post, you need to sign into your account on whatever Mastodon server you use."
|
||||
values={{ name }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginForm resourceUrl={url} />
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||
|
|
@ -62,6 +63,12 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><kbd>b</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
||||
</tr>
|
||||
{isFeatureEnabled('outgoing_quotes') && (
|
||||
<tr>
|
||||
<td><kbd>q</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
|
||||
|
|
@ -83,13 +90,17 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
|||
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>up</kbd>, <kbd>k</kbd></td>
|
||||
<td><kbd>k</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>down</kbd>, <kbd>j</kbd></td>
|
||||
<td><kbd>j</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>l</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><kbd>1</kbd>-<kbd>9</kbd></td>
|
||||
<td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
|
||||
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote-fill.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react';
|
||||
|
|
@ -18,6 +18,7 @@ import PersonAddIcon from '@/material-icons/400-24px/person_add-fill.svg?react';
|
|||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||
|
|
@ -38,6 +39,7 @@ const messages = defineMessages({
|
|||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
|
||||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
||||
quoted_update: { id: 'notification.quoted_update', defaultMessage: '{name} edited a post you have quoted' },
|
||||
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
||||
|
|
@ -57,8 +59,6 @@ class Notification extends ImmutablePureComponent {
|
|||
static propTypes = {
|
||||
notification: ImmutablePropTypes.map.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
onMoveUp: PropTypes.func.isRequired,
|
||||
onMoveDown: PropTypes.func.isRequired,
|
||||
onMention: PropTypes.func.isRequired,
|
||||
onFavourite: PropTypes.func.isRequired,
|
||||
onReblog: PropTypes.func.isRequired,
|
||||
|
|
@ -73,16 +73,6 @@ class Notification extends ImmutablePureComponent {
|
|||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
handleMoveUp = () => {
|
||||
const { notification, onMoveUp } = this.props;
|
||||
onMoveUp(notification.get('id'));
|
||||
};
|
||||
|
||||
handleMoveDown = () => {
|
||||
const { notification, onMoveDown } = this.props;
|
||||
onMoveDown(notification.get('id'));
|
||||
};
|
||||
|
||||
handleOpen = () => {
|
||||
const { notification } = this.props;
|
||||
|
||||
|
|
@ -128,8 +118,6 @@ class Notification extends ImmutablePureComponent {
|
|||
mention: this.handleMention,
|
||||
open: this.handleOpen,
|
||||
openProfile: this.handleOpenProfile,
|
||||
moveUp: this.handleMoveUp,
|
||||
moveDown: this.handleMoveDown,
|
||||
toggleHidden: this.handleHotkeyToggleHidden,
|
||||
};
|
||||
}
|
||||
|
|
@ -180,8 +168,6 @@ class Notification extends ImmutablePureComponent {
|
|||
id={notification.get('status')}
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
contextType='notifications'
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
|
|
@ -352,6 +338,41 @@ class Notification extends ImmutablePureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
renderQuotedUpdate (notification, link) {
|
||||
const { intl, unread, status } = this.props;
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
<div className={classNames('notification notification-update focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.update, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
|
||||
<div className='notification__message'>
|
||||
<Icon id='pencil' icon={EditIcon} />
|
||||
|
||||
<span title={notification.get('created_at')}>
|
||||
<FormattedMessage id='notification.quoted_update' defaultMessage='{name} edited a post you have quoted' values={{ name: link }} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<StatusQuoteManager
|
||||
id={notification.get('status')}
|
||||
account={notification.get('account')}
|
||||
contextType='notifications'
|
||||
muted
|
||||
withDismiss
|
||||
hidden={this.props.hidden}
|
||||
getScrollPosition={this.props.getScrollPosition}
|
||||
updateScrollBottom={this.props.updateScrollBottom}
|
||||
cachedMediaWidth={this.props.cachedMediaWidth}
|
||||
cacheMediaWidth={this.props.cacheMediaWidth}
|
||||
/>
|
||||
</div>
|
||||
</Hotkeys>
|
||||
);
|
||||
}
|
||||
|
||||
renderPoll (notification, account) {
|
||||
const { intl, unread, status } = this.props;
|
||||
const ownPoll = me === account.get('id');
|
||||
|
|
@ -465,8 +486,10 @@ class Notification extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
const targetAccount = report.get('target_account');
|
||||
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
|
||||
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
|
||||
const targetLink = <LinkedDisplayName
|
||||
className='notification__display-name'
|
||||
displayProps={{account:targetAccount, variant: 'simple'}}
|
||||
/>;
|
||||
|
||||
return (
|
||||
<Hotkeys handlers={this.getHandlers()}>
|
||||
|
|
@ -488,8 +511,7 @@ class Notification extends ImmutablePureComponent {
|
|||
render () {
|
||||
const { notification } = this.props;
|
||||
const account = notification.get('account');
|
||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} data-hover-card-account={account.get('id')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
|
||||
const link = <LinkedDisplayName className='notification__display-name' displayProps={{account, variant: 'simple'}} />;
|
||||
|
||||
switch(notification.get('type')) {
|
||||
case 'follow':
|
||||
|
|
@ -508,6 +530,8 @@ class Notification extends ImmutablePureComponent {
|
|||
return this.renderStatus(notification, link);
|
||||
case 'update':
|
||||
return this.renderUpdate(notification, link);
|
||||
case 'quoted_update':
|
||||
return this.renderQuotedUpdate(notification, link);
|
||||
case 'poll':
|
||||
return this.renderPoll(notification, account);
|
||||
case 'severed_relationships':
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/
|
|||
import { initReport } from 'mastodon/actions/reports';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { makeGetAccount } from 'mastodon/selectors';
|
||||
|
|
@ -96,7 +97,7 @@ export const NotificationRequest = ({ id, accountId, notificationsCount, checked
|
|||
|
||||
<div className='notification-request__name'>
|
||||
<div className='notification-request__name__display-name'>
|
||||
<bdi><strong dangerouslySetInnerHTML={{ __html: account?.get('display_name_html') }} /></bdi>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
</div>
|
||||
|
||||
<span>@{account?.get('acct')}</span>
|
||||
|
|
|
|||
|
|
@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
|||
</div>
|
||||
|
||||
<div className='app-form__toggle__toggle'>
|
||||
<div>
|
||||
<Dropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descId}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={descId}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,21 +31,6 @@ const messages = defineMessages({
|
|||
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
||||
});
|
||||
|
||||
const selectChild = (ref, index, alignTop) => {
|
||||
const container = ref.current.node;
|
||||
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||
|
||||
if (element) {
|
||||
if (alignTop && container.scrollTop > element.offsetTop) {
|
||||
element.scrollIntoView(true);
|
||||
} else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
|
||||
element.focus();
|
||||
}
|
||||
};
|
||||
|
||||
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||
const columnRef = useRef();
|
||||
const intl = useIntl();
|
||||
|
|
@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
dispatch(acceptNotificationRequest({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleMoveUp = useCallback(id => {
|
||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
|
||||
selectChild(columnRef, elementIndex, true);
|
||||
}, [columnRef, notifications]);
|
||||
|
||||
const handleMoveDown = useCallback(id => {
|
||||
const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
|
||||
selectChild(columnRef, elementIndex, false);
|
||||
}, [columnRef, notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchNotificationRequest({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
|
@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
|||
key={item.get('id')}
|
||||
notification={item}
|
||||
accountId={item.get('account')}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const DisplayedName: React.FC<{
|
||||
accountIds: string[];
|
||||
}> = ({ accountIds }) => {
|
||||
const lastAccountId = accountIds[0] ?? '0';
|
||||
const account = useAppSelector((state) => state.accounts.get(lastAccountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/@${account.acct}`}
|
||||
title={`@${account.acct}`}
|
||||
data-hover-card-account={account.id}
|
||||
>
|
||||
<bdi dangerouslySetInnerHTML={{ __html: account.display_name_html }} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -76,32 +76,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||
[clickCoordinatesRef, statusId, account, history],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-original');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback<React.MouseEventHandler<HTMLDivElement>>(
|
||||
({ currentTarget }) => {
|
||||
const emojis =
|
||||
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
|
||||
|
||||
for (const emoji of emojis) {
|
||||
const newSrc = emoji.getAttribute('data-static');
|
||||
if (newSrc) emoji.src = newSrc;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleContentWarningClick = useCallback(() => {
|
||||
dispatch(toggleStatusSpoilers(statusId));
|
||||
}, [dispatch, statusId]);
|
||||
|
|
@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className='notification-group__embedded-status'
|
||||
className='notification-group__embedded-status animate-parent'
|
||||
role='button'
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className='notification-group__embedded-status__account'>
|
||||
<Avatar account={account} size={16} />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
|
@ -42,11 +43,9 @@ export const NotificationAdminReport: React.FC<{
|
|||
|
||||
if (!account || !targetAccount) return null;
|
||||
|
||||
const domain = account.acct.split('@')[1];
|
||||
|
||||
const values = {
|
||||
name: <bdi>{domain ?? `@${account.acct}`}</bdi>,
|
||||
target: <bdi>@{targetAccount.acct}</bdi>,
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
target: <DisplayName account={targetAccount} variant='simple' />,
|
||||
category: intl.formatMessage(messages[report.category]),
|
||||
count: report.status_ids.length,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { NotificationMention } from './notification_mention';
|
|||
import { NotificationModerationWarning } from './notification_moderation_warning';
|
||||
import { NotificationPoll } from './notification_poll';
|
||||
import { NotificationQuote } from './notification_quote';
|
||||
import { NotificationQuotedUpdate } from './notification_quoted_update';
|
||||
import { NotificationReblog } from './notification_reblog';
|
||||
import { NotificationSeveredRelationships } from './notification_severed_relationships';
|
||||
import { NotificationStatus } from './notification_status';
|
||||
|
|
@ -24,9 +25,7 @@ import { NotificationUpdate } from './notification_update';
|
|||
export const NotificationGroup: React.FC<{
|
||||
notificationGroupId: NotificationGroupModel['group_key'];
|
||||
unread: boolean;
|
||||
onMoveUp: (groupId: string) => void;
|
||||
onMoveDown: (groupId: string) => void;
|
||||
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
|
||||
}> = ({ notificationGroupId, unread }) => {
|
||||
const notificationGroup = useAppSelector((state) =>
|
||||
state.notificationGroups.groups.find(
|
||||
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
||||
|
|
@ -42,14 +41,6 @@ export const NotificationGroup: React.FC<{
|
|||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
moveUp: () => {
|
||||
onMoveUp(notificationGroupId);
|
||||
},
|
||||
|
||||
moveDown: () => {
|
||||
onMoveDown(notificationGroupId);
|
||||
},
|
||||
|
||||
openProfile: () => {
|
||||
if (accountId) dispatch(navigateToProfile(accountId));
|
||||
},
|
||||
|
|
@ -58,7 +49,7 @@ export const NotificationGroup: React.FC<{
|
|||
if (accountId) dispatch(mentionComposeById(accountId));
|
||||
},
|
||||
}),
|
||||
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
|
||||
[dispatch, accountId],
|
||||
);
|
||||
|
||||
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||
|
|
@ -125,6 +116,14 @@ export const NotificationGroup: React.FC<{
|
|||
<NotificationUpdate unread={unread} notification={notificationGroup} />
|
||||
);
|
||||
break;
|
||||
case 'quoted_update':
|
||||
content = (
|
||||
<NotificationQuotedUpdate
|
||||
unread={unread}
|
||||
notification={notificationGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'admin.sign_up':
|
||||
content = (
|
||||
<NotificationAdminSignUp
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { JSX } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
import { replyComposeById } from 'mastodon/actions/compose';
|
||||
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
|
|
@ -14,7 +15,6 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
|||
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { DisplayedName } from './displayed_name';
|
||||
import { EmbeddedStatus } from './embedded_status';
|
||||
|
||||
const AVATAR_SIZE = 28;
|
||||
|
|
@ -61,15 +61,18 @@ export const NotificationGroupWithStatus: React.FC<{
|
|||
additionalContent,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const account = useAppSelector((state) =>
|
||||
state.accounts.get(accountIds.at(0) ?? ''),
|
||||
);
|
||||
|
||||
const label = useMemo(
|
||||
() =>
|
||||
labelRenderer(
|
||||
<DisplayedName accountIds={accountIds} />,
|
||||
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />,
|
||||
count,
|
||||
labelSeeMoreHref,
|
||||
),
|
||||
[labelRenderer, accountIds, count, labelSeeMoreHref],
|
||||
[labelRenderer, account, count, labelSeeMoreHref],
|
||||
);
|
||||
|
||||
const isPrivateMention = useAppSelector(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
|
||||
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote-fill.svg?react';
|
||||
import type { NotificationGroupQuote } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import type { NotificationGroupQuotedUpdate } from 'mastodon/models/notification_group';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
import { NotificationWithStatus } from './notification_with_status';
|
||||
|
||||
const labelRenderer: LabelRenderer = (displayedName) => (
|
||||
<FormattedMessage
|
||||
id='notification.quoted_update'
|
||||
defaultMessage='{name} edited a post you have quoted'
|
||||
values={{ name: displayedName }}
|
||||
/>
|
||||
);
|
||||
|
||||
export const NotificationQuotedUpdate: React.FC<{
|
||||
notification: NotificationGroupQuotedUpdate;
|
||||
unread: boolean;
|
||||
}> = ({ notification, unread }) => (
|
||||
<NotificationWithStatus
|
||||
type='update'
|
||||
icon={EditIcon}
|
||||
iconId='edit'
|
||||
accountIds={notification.sampleAccountIds}
|
||||
count={notification.notifications_count}
|
||||
statusId={notification.statusId}
|
||||
labelRenderer={labelRenderer}
|
||||
unread={unread}
|
||||
/>
|
||||
);
|
||||
|
|
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||
import { replyComposeById } from 'mastodon/actions/compose';
|
||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||
import {
|
||||
|
|
@ -15,7 +16,6 @@ import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
|||
import { getStatusHidden } from 'mastodon/selectors/filters';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { DisplayedName } from './displayed_name';
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
|
||||
export const NotificationWithStatus: React.FC<{
|
||||
|
|
@ -39,9 +39,16 @@ export const NotificationWithStatus: React.FC<{
|
|||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
state.accounts.get(accountIds.at(0) ?? ''),
|
||||
);
|
||||
const label = useMemo(
|
||||
() => labelRenderer(<DisplayedName accountIds={accountIds} />, count),
|
||||
[labelRenderer, accountIds, count],
|
||||
() =>
|
||||
labelRenderer(
|
||||
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />,
|
||||
count,
|
||||
),
|
||||
[labelRenderer, account, count],
|
||||
);
|
||||
|
||||
const isPrivateMention = useAppSelector(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user