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)',
|
':labels(dependencies)',
|
||||||
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
|
':prConcurrentLimitNone', // Remove limit for open PRs at any time.
|
||||||
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
|
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
|
||||||
|
':enableVulnerabilityAlertsWithLabel(security)',
|
||||||
],
|
],
|
||||||
rebaseWhen: 'conflicted',
|
rebaseWhen: 'conflicted',
|
||||||
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
|
minimumReleaseAge: '3', // Wait 3 days after the package has been published before upgrading it
|
||||||
|
|
@ -93,6 +94,19 @@
|
||||||
matchUpdateTypes: ['patch', 'minor'],
|
matchUpdateTypes: ['patch', 'minor'],
|
||||||
groupName: 'eslint (non-major)',
|
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
|
// Group actions/*-artifact in the same PR
|
||||||
matchManagers: ['github-actions'],
|
matchManagers: ['github-actions'],
|
||||||
|
|
@ -141,6 +155,12 @@
|
||||||
matchUpdateTypes: ['patch', 'minor'],
|
matchUpdateTypes: ['patch', 'minor'],
|
||||||
groupName: 'opentelemetry-ruby (non-major)',
|
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
|
// Add labels depending on package manager
|
||||||
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
|
{ matchManagers: ['npm', 'nvm'], addLabels: ['javascript'] },
|
||||||
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
|
{ matchManagers: ['bundler', 'ruby-version'], addLabels: ['ruby'] },
|
||||||
|
|
|
||||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -25,8 +25,8 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: ['javascript', 'ruby']
|
language: ['actions', 'javascript', 'ruby']
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', '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
|
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
||||||
# using RuboCop version 1.79.2.
|
# using RuboCop version 1.80.2.
|
||||||
# The point is for the user to remove these configuration records
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
|
|
||||||
|
|
@ -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 { action } from 'storybook/actions';
|
||||||
|
|
||||||
import type { LocaleData } from '@/mastodon/locales';
|
import type { LocaleData } from '@/mastodon/locales';
|
||||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
import { reducerWithInitialState } from '@/mastodon/reducers';
|
||||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||||
|
|
||||||
// If you want to run the dark theme during development,
|
// If you want to run the dark theme during development,
|
||||||
// you can change the below to `/application.scss`
|
// you can change the below to `/application.scss`
|
||||||
import '../app/javascript/styles/mastodon-light.scss';
|
import '../app/javascript/styles/mastodon-light.scss';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||||
query: { as: 'json' },
|
query: { as: 'json' },
|
||||||
|
|
@ -49,12 +50,17 @@ const preview: Preview = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story, { parameters }) => {
|
(Story, { parameters, globals }) => {
|
||||||
|
const { locale } = globals as { locale: string };
|
||||||
const { state = {} } = parameters;
|
const { state = {} } = parameters;
|
||||||
let reducer = rootReducer;
|
const reducer = reducerWithInitialState(
|
||||||
if (typeof state === 'object' && state) {
|
{
|
||||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
meta: {
|
||||||
}
|
locale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state as Record<string, unknown>,
|
||||||
|
);
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer,
|
reducer,
|
||||||
middleware(getDefaultMiddleware) {
|
middleware(getDefaultMiddleware) {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
* - Please do NOT modify this file.
|
* - Please do NOT modify this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PACKAGE_VERSION = '2.10.4'
|
const PACKAGE_VERSION = '2.11.3'
|
||||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||||
const activeClientIds = new Set()
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
|
@ -71,11 +71,6 @@ addEventListener('message', async function (event) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'MOCK_DEACTIVATE': {
|
|
||||||
activeClientIds.delete(clientId)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'CLIENT_CLOSED': {
|
case 'CLIENT_CLOSED': {
|
||||||
activeClientIds.delete(clientId)
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
|
@ -94,6 +89,8 @@ addEventListener('message', async function (event) {
|
||||||
})
|
})
|
||||||
|
|
||||||
addEventListener('fetch', function (event) {
|
addEventListener('fetch', function (event) {
|
||||||
|
const requestInterceptedAt = Date.now()
|
||||||
|
|
||||||
// Bypass navigation requests.
|
// Bypass navigation requests.
|
||||||
if (event.request.mode === 'navigate') {
|
if (event.request.mode === 'navigate') {
|
||||||
return
|
return
|
||||||
|
|
@ -110,23 +107,29 @@ addEventListener('fetch', function (event) {
|
||||||
|
|
||||||
// Bypass all requests when there are no active clients.
|
// Bypass all requests when there are no active clients.
|
||||||
// Prevents the self-unregistered worked from handling requests
|
// 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) {
|
if (activeClientIds.size === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = crypto.randomUUID()
|
const requestId = crypto.randomUUID()
|
||||||
event.respondWith(handleRequest(event, requestId))
|
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FetchEvent} event
|
* @param {FetchEvent} event
|
||||||
* @param {string} requestId
|
* @param {string} requestId
|
||||||
|
* @param {number} requestInterceptedAt
|
||||||
*/
|
*/
|
||||||
async function handleRequest(event, requestId) {
|
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||||
const client = await resolveMainClient(event)
|
const client = await resolveMainClient(event)
|
||||||
const requestCloneForEvents = event.request.clone()
|
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.
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
// Ensure MSW is active and ready to handle the message, otherwise
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
|
@ -204,7 +207,7 @@ async function resolveMainClient(event) {
|
||||||
* @param {string} requestId
|
* @param {string} requestId
|
||||||
* @returns {Promise<Response>}
|
* @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
|
// Clone the request because it might've been already used
|
||||||
// (i.e. its body has been read and sent to the client).
|
// (i.e. its body has been read and sent to the client).
|
||||||
const requestClone = event.request.clone()
|
const requestClone = event.request.clone()
|
||||||
|
|
@ -255,6 +258,7 @@ async function getResponse(event, client, requestId) {
|
||||||
type: 'REQUEST',
|
type: 'REQUEST',
|
||||||
payload: {
|
payload: {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
|
interceptedAt: requestInterceptedAt,
|
||||||
...serializedRequest,
|
...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.
|
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
|
## [4.4.3] - 2025-08-05
|
||||||
|
|
||||||
### Security
|
### 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
|
# 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
|
# 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"]
|
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
|
||||||
# renovate: datasource=docker depName=docker.io/ruby
|
# renovate: datasource=docker depName=docker.io/ruby
|
||||||
ARG RUBY_VERSION="3.4.5"
|
ARG RUBY_VERSION="3.4.6"
|
||||||
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
|
||||||
# renovate: datasource=node-version depName=node
|
# renovate: datasource=node-version depName=node
|
||||||
ARG NODE_MAJOR_VERSION="22"
|
ARG NODE_MAJOR_VERSION="22"
|
||||||
|
|
@ -183,7 +183,7 @@ FROM build AS libvips
|
||||||
|
|
||||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||||
ARG VIPS_VERSION=8.17.1
|
ARG VIPS_VERSION=8.17.2
|
||||||
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
# libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"]
|
||||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||||
|
|
||||||
|
|
@ -206,7 +206,7 @@ FROM build AS ffmpeg
|
||||||
|
|
||||||
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
|
||||||
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
|
# 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"]
|
# ffmpeg download URL, change with [--build-arg FFMPEG_URL="https://ffmpeg.org/releases"]
|
||||||
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 'prometheus_exporter', '~> 2.2', require: false
|
||||||
|
|
||||||
gem 'opentelemetry-api', '~> 1.6.0'
|
gem 'opentelemetry-api', '~> 1.7.0'
|
||||||
|
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
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-faraday', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.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-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-pg', '~> 0.30.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.36.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||||
|
|
@ -138,6 +138,7 @@ group :test do
|
||||||
# Browser integration testing
|
# Browser integration testing
|
||||||
gem 'capybara', '~> 3.39'
|
gem 'capybara', '~> 3.39'
|
||||||
gem 'capybara-playwright-driver'
|
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
|
# Used to reset the database between system tests
|
||||||
gem 'database_cleaner-active_record'
|
gem 'database_cleaner-active_record'
|
||||||
|
|
|
||||||
69
Gemfile.lock
69
Gemfile.lock
|
|
@ -90,7 +90,7 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
android_key_attestation (0.3.0)
|
android_key_attestation (0.3.0)
|
||||||
annotaterb (4.18.0)
|
annotaterb (4.19.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
|
|
@ -121,7 +121,7 @@ GEM
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
rouge (>= 1.0.0)
|
rouge (>= 1.0.0)
|
||||||
bigdecimal (3.2.2)
|
bigdecimal (3.2.3)
|
||||||
bindata (2.5.1)
|
bindata (2.5.1)
|
||||||
binding_of_caller (1.0.1)
|
binding_of_caller (1.0.1)
|
||||||
debug_inspector (>= 1.2.0)
|
debug_inspector (>= 1.2.0)
|
||||||
|
|
@ -164,7 +164,7 @@ GEM
|
||||||
cocoon (1.2.15)
|
cocoon (1.2.15)
|
||||||
color_diff (0.1)
|
color_diff (0.1)
|
||||||
concurrent-ruby (1.3.5)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (2.5.3)
|
connection_pool (2.5.4)
|
||||||
cose (1.3.1)
|
cose (1.3.1)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
|
|
@ -300,8 +300,8 @@ GEM
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.25.2)
|
hiredis-client (0.25.3)
|
||||||
redis-client (= 0.25.2)
|
redis-client (= 0.25.3)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
|
|
@ -438,7 +438,7 @@ GEM
|
||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2025.0729)
|
mime-types-data (3.2025.0916)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
|
|
@ -450,7 +450,9 @@ GEM
|
||||||
net-imap (0.5.9)
|
net-imap (0.5.9)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.19.0)
|
net-ldap (0.20.0)
|
||||||
|
base64
|
||||||
|
ostruct
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.2.2)
|
net-protocol (0.2.2)
|
||||||
|
|
@ -458,7 +460,7 @@ GEM
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.4)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.18.9)
|
nokogiri (1.18.10)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
oj (3.16.11)
|
oj (3.16.11)
|
||||||
|
|
@ -497,7 +499,7 @@ GEM
|
||||||
openssl (3.3.0)
|
openssl (3.3.0)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
opentelemetry-api (1.6.0)
|
opentelemetry-api (1.7.0)
|
||||||
opentelemetry-common (0.22.0)
|
opentelemetry-common (0.22.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-exporter-otlp (0.30.0)
|
opentelemetry-exporter-otlp (0.30.0)
|
||||||
|
|
@ -515,7 +517,7 @@ GEM
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-action_pack (0.12.3)
|
opentelemetry-instrumentation-action_pack (0.13.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
|
|
@ -559,7 +561,7 @@ GEM
|
||||||
opentelemetry-instrumentation-http_client (0.24.0)
|
opentelemetry-instrumentation-http_client (0.24.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-net_http (0.23.1)
|
opentelemetry-instrumentation-net_http (0.24.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-pg (0.30.1)
|
opentelemetry-instrumentation-pg (0.30.1)
|
||||||
|
|
@ -567,13 +569,13 @@ GEM
|
||||||
opentelemetry-helpers-sql
|
opentelemetry-helpers-sql
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rack (0.26.0)
|
opentelemetry-instrumentation-rack (0.27.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-rails (0.36.0)
|
opentelemetry-instrumentation-rails (0.37.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
opentelemetry-instrumentation-action_mailer (~> 0.4.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-action_view (~> 0.9.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-active_record (~> 0.9.0)
|
opentelemetry-instrumentation-active_record (~> 0.9.0)
|
||||||
|
|
@ -589,12 +591,12 @@ GEM
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.23.0)
|
||||||
opentelemetry-registry (0.4.0)
|
opentelemetry-registry (0.4.0)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-sdk (1.8.1)
|
opentelemetry-sdk (1.9.0)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-registry (~> 0.2)
|
opentelemetry-registry (~> 0.2)
|
||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-semantic_conventions (1.11.0)
|
opentelemetry-semantic_conventions (1.36.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
|
|
@ -607,10 +609,10 @@ GEM
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
pastel (0.8.0)
|
pastel (0.8.0)
|
||||||
tty-color (~> 0.5)
|
tty-color (~> 0.5)
|
||||||
pg (1.6.1)
|
pg (1.6.2)
|
||||||
pghero (3.7.0)
|
pghero (3.7.0)
|
||||||
activerecord (>= 7.1)
|
activerecord (>= 7.1)
|
||||||
playwright-ruby-client (1.54.1)
|
playwright-ruby-client (1.55.0)
|
||||||
concurrent-ruby (>= 1.1.6)
|
concurrent-ruby (>= 1.1.6)
|
||||||
mime-types (>= 3.0)
|
mime-types (>= 3.0)
|
||||||
pp (0.6.2)
|
pp (0.6.2)
|
||||||
|
|
@ -637,7 +639,7 @@ GEM
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.1)
|
puma (6.6.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.0)
|
pundit (2.5.1)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
|
|
@ -717,7 +719,7 @@ GEM
|
||||||
reline
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.25.2)
|
redis-client (0.25.3)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.2)
|
||||||
reline (0.6.2)
|
reline (0.6.2)
|
||||||
|
|
@ -727,7 +729,7 @@ GEM
|
||||||
responders (3.1.1)
|
responders (3.1.1)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.4.1)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.6.0)
|
rouge (4.6.0)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
|
|
@ -763,7 +765,7 @@ GEM
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.79.2)
|
rubocop (1.80.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
|
@ -783,17 +785,17 @@ GEM
|
||||||
rubocop-i18n (3.2.3)
|
rubocop-i18n (3.2.3)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.72.1)
|
rubocop (>= 1.72.1)
|
||||||
rubocop-performance (1.25.0)
|
rubocop-performance (1.26.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
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)
|
rubocop-rails (2.33.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rspec (3.6.0)
|
rubocop-rspec (3.7.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (~> 1.72, >= 1.72.1)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop-rspec_rails (2.31.0)
|
rubocop-rspec_rails (2.31.0)
|
||||||
|
|
@ -809,7 +811,7 @@ GEM
|
||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.1.0)
|
||||||
rufus-scheduler (3.9.2)
|
rufus-scheduler (3.9.2)
|
||||||
fugit (~> 1.1, >= 1.11.1)
|
fugit (~> 1.1, >= 1.11.1)
|
||||||
safety_net_attestation (0.4.0)
|
safety_net_attestation (0.4.0)
|
||||||
|
|
@ -848,12 +850,12 @@ GEM
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.13.2)
|
simplecov-html (0.13.2)
|
||||||
simplecov-lcov (0.8.0)
|
simplecov-lcov (0.9.0)
|
||||||
simplecov_json_formatter (0.1.4)
|
simplecov_json_formatter (0.1.4)
|
||||||
stackprof (0.2.27)
|
stackprof (0.2.27)
|
||||||
starry (0.2.0)
|
starry (0.2.0)
|
||||||
base64
|
base64
|
||||||
stoplight (5.3.1)
|
stoplight (5.3.8)
|
||||||
zeitwerk
|
zeitwerk
|
||||||
stringio (3.1.7)
|
stringio (3.1.7)
|
||||||
strong_migrations (2.5.0)
|
strong_migrations (2.5.0)
|
||||||
|
|
@ -897,7 +899,7 @@ GEM
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.9.1)
|
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.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uri (1.0.3)
|
uri (1.0.3)
|
||||||
|
|
@ -1025,7 +1027,7 @@ DEPENDENCIES
|
||||||
omniauth-rails_csrf_protection (~> 1.0)
|
omniauth-rails_csrf_protection (~> 1.0)
|
||||||
omniauth-saml (~> 2.0)
|
omniauth-saml (~> 2.0)
|
||||||
omniauth_openid_connect (~> 0.8.0)
|
omniauth_openid_connect (~> 0.8.0)
|
||||||
opentelemetry-api (~> 1.6.0)
|
opentelemetry-api (~> 1.7.0)
|
||||||
opentelemetry-exporter-otlp (~> 0.30.0)
|
opentelemetry-exporter-otlp (~> 0.30.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
||||||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
||||||
|
|
@ -1034,10 +1036,10 @@ DEPENDENCIES
|
||||||
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
opentelemetry-instrumentation-http (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.24.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-pg (~> 0.30.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.26.0)
|
opentelemetry-instrumentation-rack (~> 0.27.0)
|
||||||
opentelemetry-instrumentation-rails (~> 0.36.0)
|
opentelemetry-instrumentation-rails (~> 0.37.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.26.0)
|
opentelemetry-instrumentation-redis (~> 0.26.0)
|
||||||
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
|
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
|
||||||
opentelemetry-sdk (~> 1.4)
|
opentelemetry-sdk (~> 1.4)
|
||||||
|
|
@ -1045,6 +1047,7 @@ DEPENDENCIES
|
||||||
parslet
|
parslet
|
||||||
pg (~> 1.5)
|
pg (~> 1.5)
|
||||||
pghero
|
pghero
|
||||||
|
playwright-ruby-client (= 1.55.0)
|
||||||
premailer-rails
|
premailer-rails
|
||||||
prometheus_exporter (~> 2.2)
|
prometheus_exporter (~> 2.2)
|
||||||
propshaft
|
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
|
before_action :set_quote_authorization
|
||||||
|
|
||||||
def show
|
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'
|
render json: @quote, serializer: ActivityPub::QuoteAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -21,6 +21,8 @@ class ActivityPub::QuoteAuthorizationsController < ActivityPub::BaseController
|
||||||
|
|
||||||
def set_quote_authorization
|
def set_quote_authorization
|
||||||
@quote = Quote.accepted.where(quoted_account: @account).find(params[:id])
|
@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?
|
authorize @quote.status, :show?
|
||||||
rescue Mastodon::NotPermittedError
|
rescue Mastodon::NotPermittedError
|
||||||
not_found
|
not_found
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ module Admin
|
||||||
|
|
||||||
def export_data
|
def export_data
|
||||||
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
||||||
DomainAllow.allowed_domains.each do |instance|
|
DomainAllow.allowed_domains.each do |domain|
|
||||||
content << [instance.domain]
|
content << [domain]
|
||||||
end
|
end
|
||||||
end
|
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_privacy: source_params.fetch(:privacy, @account.user.setting_default_privacy),
|
||||||
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
|
default_sensitive: source_params.fetch(:sensitive, @account.user.setting_default_sensitive),
|
||||||
default_language: source_params.fetch(:language, @account.user.setting_default_language),
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ module Api::InteractionPoliciesConcern
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def quote_approval_policy
|
def quote_approval_policy
|
||||||
# TODO: handle `nil` separately
|
return nil unless Mastodon::Feature.outgoing_quotes_enabled?
|
||||||
return nil unless Mastodon::Feature.outgoing_quotes_enabled? && status_params[:quote_approval_policy].present?
|
|
||||||
|
|
||||||
case status_params[:quote_approval_policy]
|
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
||||||
when 'public'
|
when 'public'
|
||||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||||
when 'followers'
|
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)
|
tag.input(type: :text, maxlength: 999, spellcheck: false, readonly: true, **options)
|
||||||
end
|
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)
|
def recent_tag_usage(tag)
|
||||||
people = tag.history.aggregate(2.days.ago.to_date..Time.zone.today).accounts
|
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
|
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'
|
'https://play.google.com/store/apps/details?id=org.joinmastodon.android'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def within_authorization_flow?
|
||||||
|
session[:user_return_to].present? && Rails.application.routes.recognize_path(session[:user_return_to])[:controller] == 'oauth/authorizations'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def storage_host_var
|
def storage_host_var
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ module FormattingHelper
|
||||||
module_function :extract_status_plain_text
|
module_function :extract_status_plain_text
|
||||||
|
|
||||||
def status_content_format(status)
|
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
|
end
|
||||||
|
|
||||||
def rss_status_content_format(status)
|
def rss_status_content_format(status)
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ module LanguagesHelper
|
||||||
mk: ['Macedonian', 'македонски јазик'].freeze,
|
mk: ['Macedonian', 'македонски јазик'].freeze,
|
||||||
ml: ['Malayalam', 'മലയാളം'].freeze,
|
ml: ['Malayalam', 'മലയാളം'].freeze,
|
||||||
mn: ['Mongolian', 'Монгол хэл'].freeze,
|
mn: ['Mongolian', 'Монгол хэл'].freeze,
|
||||||
|
'mn-Mong': ['Traditional Mongolian', 'ᠮᠣᠩᠭᠣᠯ ᠬᠡᠯᠡ'].freeze,
|
||||||
mr: ['Marathi', 'मराठी'].freeze,
|
mr: ['Marathi', 'मराठी'].freeze,
|
||||||
ms: ['Malay', 'Bahasa Melayu'].freeze,
|
ms: ['Malay', 'Bahasa Melayu'].freeze,
|
||||||
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
|
'ms-Arab': ['Jawi Malay', 'بهاس ملايو'].freeze,
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,16 @@ module StatusesHelper
|
||||||
def prefers_autoplay?
|
def prefers_autoplay?
|
||||||
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
|
ActiveModel::Type::Boolean.new.cast(params[:autoplay]) || current_user&.setting_auto_play_gif
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,10 @@ function loaded() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDefaultQuotePrivacyFromPrivacy(
|
||||||
|
document.querySelector('#user_settings_attributes_default_privacy'),
|
||||||
|
);
|
||||||
|
|
||||||
const reactComponents = document.querySelectorAll('[data-component]');
|
const reactComponents = document.querySelectorAll('[data-component]');
|
||||||
|
|
||||||
if (reactComponents.length > 0) {
|
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(
|
Rails.delegate(
|
||||||
document,
|
document,
|
||||||
'#account_statuses_cleanup_policy_enabled',
|
'#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
|
// Empty the honeypot fields in JS in case something like an extension
|
||||||
// automatically filled them.
|
// automatically filled them.
|
||||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||||
|
|
|
||||||
|
|
@ -97,12 +97,17 @@ export const ensureComposeIsVisible = (getState) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function setComposeToStatus(status, text, spoiler_text) {
|
export function setComposeToStatus(status, text, spoiler_text) {
|
||||||
return{
|
return (dispatch, getState) => {
|
||||||
|
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
type: COMPOSE_SET_STATUS,
|
type: COMPOSE_SET_STATUS,
|
||||||
status,
|
status,
|
||||||
text,
|
text,
|
||||||
spoiler_text,
|
spoiler_text,
|
||||||
};
|
maxOptions,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeCompose(text) {
|
export function changeCompose(text) {
|
||||||
|
|
@ -216,6 +221,7 @@ export function submitCompose(successCallback) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibility = getState().getIn(['compose', 'privacy']);
|
||||||
api().request({
|
api().request({
|
||||||
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
|
||||||
method: statusId === null ? 'post' : 'put',
|
method: statusId === null ? 'post' : 'put',
|
||||||
|
|
@ -226,11 +232,11 @@ export function submitCompose(successCallback) {
|
||||||
media_attributes,
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: visibility,
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
quoted_status_id: getState().getIn(['compose', 'quoted_status_id']),
|
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: {
|
headers: {
|
||||||
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import type { Status } from '../models/status';
|
||||||
|
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { focusCompose } from './compose';
|
import { focusCompose } from './compose';
|
||||||
|
import { openModal } from './modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
quoteErrorUpload: {
|
quoteErrorUpload: {
|
||||||
|
|
@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk(
|
||||||
|
|
||||||
export const quoteComposeByStatus = createAppThunk(
|
export const quoteComposeByStatus = createAppThunk(
|
||||||
(status: Status, { dispatch, getState }) => {
|
(status: Status, { dispatch, getState }) => {
|
||||||
const composeState = getState().compose;
|
const state = getState();
|
||||||
|
const composeState = state.compose;
|
||||||
const mediaAttachments = composeState.get('media_attachments');
|
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')) {
|
if (composeState.get('poll')) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||||
|
|
@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk(
|
||||||
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
||||||
) {
|
) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
||||||
|
} else if (
|
||||||
|
status.get('visibility') === 'unlisted' &&
|
||||||
|
!wasQuietPostHintModalDismissed
|
||||||
|
) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM_QUIET_QUOTE',
|
||||||
|
modalProps: { status },
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(quoteCompose(status));
|
dispatch(quoteCompose(status));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
|
||||||
return normalResult;
|
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) {
|
export function normalizeStatus(status, normalOldStatus) {
|
||||||
const normalStatus = { ...status };
|
const normalStatus = { ...status };
|
||||||
|
|
||||||
|
|
@ -72,7 +81,7 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
} else {
|
} else {
|
||||||
// If the status has a CW but no contents, treat the CW as if it were the
|
// 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.
|
// 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.content = normalStatus.spoiler_text;
|
||||||
normalStatus.spoiler_text = '';
|
normalStatus.spoiler_text = '';
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
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://'))) {
|
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||||
normalStatus.url = null;
|
normalStatus.url = null;
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) {
|
||||||
spoiler_text: translation.spoiler_text,
|
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;
|
return normalTranslation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ import {
|
||||||
apiReblog,
|
apiReblog,
|
||||||
apiUnreblog,
|
apiUnreblog,
|
||||||
apiRevokeQuote,
|
apiRevokeQuote,
|
||||||
|
apiGetQuotes,
|
||||||
} from 'mastodon/api/interactions';
|
} from 'mastodon/api/interactions';
|
||||||
import type { StatusVisibility } from 'mastodon/models/status';
|
import type { StatusVisibility } from 'mastodon/models/status';
|
||||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
import { importFetchedStatus } from './importer';
|
import { importFetchedStatus, importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
export const reblog = createDataLoadingThunk(
|
export const reblog = createDataLoadingThunk(
|
||||||
'status/reblog',
|
'status/reblog',
|
||||||
|
|
@ -53,3 +54,19 @@ export const revokeQuote = createDataLoadingThunk(
|
||||||
return discardLoadData;
|
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 { NOTIFICATIONS_FILTER_SET } from './notifications';
|
||||||
import { saveSettings } from './settings';
|
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) {
|
function excludeAllTypesExcept(filter: string) {
|
||||||
return allNotificationTypes.filter(
|
return allNotificationTypes.filter(
|
||||||
(item) => item !== filter && !(item === 'quote' && filter === 'mention'),
|
(item) => notificationTypeForQuickFilter(item) !== filter,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,16 +168,17 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||||
|
|
||||||
const showInColumn =
|
const showInColumn =
|
||||||
activeFilter === 'all'
|
activeFilter === 'all'
|
||||||
? notificationShows[notification.type] !== false
|
? notificationShows[notificationTypeForFilter(notification.type)] !==
|
||||||
: activeFilter === notification.type ||
|
false
|
||||||
(activeFilter === 'mention' && notification.type === 'quote');
|
: activeFilter === notificationTypeForQuickFilter(notification.type);
|
||||||
|
|
||||||
if (!showInColumn) return;
|
if (!showInColumn) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(notification.type === 'mention' ||
|
(notification.type === 'mention' ||
|
||||||
|
notification.type === 'quote' ||
|
||||||
notification.type === 'update' ||
|
notification.type === 'update' ||
|
||||||
notification.type === 'quote') &&
|
notification.type === 'quoted_update') &&
|
||||||
notification.status?.filtered
|
notification.status?.filtered
|
||||||
) {
|
) {
|
||||||
const filters = notification.status.filtered.filter((result) =>
|
const filters = notification.status.filtered.filter((result) =>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
|
|
||||||
let filtered = false;
|
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'));
|
const filters = notification.status.filtered.filter(result => result.filter.context.includes('notifications'));
|
||||||
|
|
||||||
if (filters.some(result => result.filter.filter_action === 'hide')) {
|
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 { browserHistory } from 'mastodon/components/router';
|
||||||
|
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
|
import { showAlert } from './alerts';
|
||||||
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
|
||||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
|
import { importFetchedStatus, importFetchedAccount } from './importer';
|
||||||
import { fetchContext } from './statuses_typed';
|
import { fetchContext } from './statuses_typed';
|
||||||
import { deleteFromTimelines } from './timelines';
|
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_FAIL = 'STATUS_TRANSLATE_FAIL';
|
||||||
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
|
export const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
deleteSuccess: { id: 'status.delete.success', defaultMessage: 'Post deleted' },
|
||||||
|
});
|
||||||
|
|
||||||
export function fetchStatusRequest(id, skipLoading) {
|
export function fetchStatusRequest(id, skipLoading) {
|
||||||
return {
|
return {
|
||||||
type: STATUS_FETCH_REQUEST,
|
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) => {
|
return (dispatch, getState) => {
|
||||||
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
|
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(importFetchedStatus(response.data));
|
||||||
dispatch(fetchStatusSuccess(skipLoading));
|
dispatch(fetchStatusSuccess(skipLoading));
|
||||||
}).catch(error => {
|
}).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 {
|
return {
|
||||||
type: STATUS_FETCH_FAIL,
|
type: STATUS_FETCH_FAIL,
|
||||||
id,
|
id,
|
||||||
error,
|
error,
|
||||||
|
parentQuotePostId,
|
||||||
skipLoading,
|
skipLoading,
|
||||||
skipAlert: true,
|
skipAlert: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redraft(status, raw_text) {
|
export function redraft(status, raw_text) {
|
||||||
return {
|
return (dispatch, getState) => {
|
||||||
|
const maxOptions = getState().server.getIn(['server', 'configuration', 'polls', 'max_options']);
|
||||||
|
|
||||||
|
dispatch({
|
||||||
type: REDRAFT,
|
type: REDRAFT,
|
||||||
status,
|
status,
|
||||||
raw_text,
|
raw_text,
|
||||||
|
maxOptions,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +161,7 @@ export function deleteStatus(id, withRedraft = false) {
|
||||||
|
|
||||||
dispatch(deleteStatusRequest(id));
|
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(deleteStatusSuccess(id));
|
||||||
dispatch(deleteFromTimelines(id));
|
dispatch(deleteFromTimelines(id));
|
||||||
dispatch(importFetchedAccount(response.data.account));
|
dispatch(importFetchedAccount(response.data.account));
|
||||||
|
|
@ -145,9 +169,14 @@ export function deleteStatus(id, withRedraft = false) {
|
||||||
if (withRedraft) {
|
if (withRedraft) {
|
||||||
dispatch(redraft(status, response.data.text));
|
dispatch(redraft(status, response.data.text));
|
||||||
ensureComposeIsVisible(getState);
|
ensureComposeIsVisible(getState);
|
||||||
|
} else {
|
||||||
|
dispatch(showAlert({ message: messages.deleteSuccess }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(deleteStatusFail(id, error));
|
dispatch(deleteStatusFail(id, error));
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,28 @@
|
||||||
import { apiRequestPost } from 'mastodon/api';
|
import api, { apiRequestPost, getLinks } from 'mastodon/api';
|
||||||
import type { Status, StatusVisibility } from 'mastodon/models/status';
|
import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
|
||||||
|
import type { StatusVisibility } from 'mastodon/models/status';
|
||||||
|
|
||||||
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
|
||||||
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
|
apiRequestPost<{ reblog: ApiStatusJSON }>(`v1/statuses/${statusId}/reblog`, {
|
||||||
visibility,
|
visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiUnreblog = (statusId: string) =>
|
export const apiUnreblog = (statusId: string) =>
|
||||||
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);
|
apiRequestPost<ApiStatusJSON>(`v1/statuses/${statusId}/unreblog`);
|
||||||
|
|
||||||
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
export const apiRevokeQuote = (quotedStatusId: string, statusId: string) =>
|
||||||
apiRequestPost<Status>(
|
apiRequestPost<ApiStatusJSON>(
|
||||||
`v1/statuses/${quotedStatusId}/quotes/${statusId}/revoke`,
|
`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';
|
import type { ApiStatusJSON } from './statuses';
|
||||||
|
|
||||||
// See app/model/notification.rb
|
// See app/model/notification.rb
|
||||||
export const allNotificationTypes = [
|
export const allNotificationTypes: NotificationType[] = [
|
||||||
'follow',
|
'follow',
|
||||||
'follow_request',
|
'follow_request',
|
||||||
'favourite',
|
'favourite',
|
||||||
|
|
@ -31,7 +31,8 @@ export type NotificationWithStatusType =
|
||||||
| 'mention'
|
| 'mention'
|
||||||
| 'quote'
|
| 'quote'
|
||||||
| 'poll'
|
| 'poll'
|
||||||
| 'update';
|
| 'update'
|
||||||
|
| 'quoted_update';
|
||||||
|
|
||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
| NotificationWithStatusType
|
| NotificationWithStatusType
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
import type { ApiStatusJSON } from './statuses';
|
import type { ApiStatusJSON } from './statuses';
|
||||||
|
|
||||||
export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized';
|
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';
|
export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown';
|
||||||
|
|
||||||
interface ApiQuoteEmptyJSON {
|
interface ApiQuoteEmptyJSON {
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export interface ApiStatusJSON {
|
||||||
replies_count: number;
|
replies_count: number;
|
||||||
reblogs_count: number;
|
reblogs_count: number;
|
||||||
favorites_count: number;
|
favorites_count: number;
|
||||||
|
quotes_count: number;
|
||||||
edited_at?: string;
|
edited_at?: string;
|
||||||
|
|
||||||
favorited?: boolean;
|
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 { useIntl } from 'react-intl';
|
||||||
import type { IntlShape } from 'react-intl';
|
import type { IntlShape } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { dismissAlert } from 'mastodon/actions/alerts';
|
import { dismissAlert } from 'mastodon/actions/alerts';
|
||||||
import type {
|
import type {
|
||||||
Alert,
|
Alert as AlertType,
|
||||||
TranslatableString,
|
TranslatableString,
|
||||||
TranslatableValues,
|
TranslatableValues,
|
||||||
} from 'mastodon/models/alert';
|
} from 'mastodon/models/alert';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { Alert } from './alert';
|
||||||
|
|
||||||
const formatIfNeeded = (
|
const formatIfNeeded = (
|
||||||
intl: IntlShape,
|
intl: IntlShape,
|
||||||
message: TranslatableString,
|
message: TranslatableString,
|
||||||
|
|
@ -25,8 +25,8 @@ const formatIfNeeded = (
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Alert: React.FC<{
|
const TimedAlert: React.FC<{
|
||||||
alert: Alert;
|
alert: AlertType;
|
||||||
dismissAfter: number;
|
dismissAfter: number;
|
||||||
}> = ({
|
}> = ({
|
||||||
alert: { key, title, message, values, action, onClick },
|
alert: { key, title, message, values, action, onClick },
|
||||||
|
|
@ -62,29 +62,13 @@ const Alert: React.FC<{
|
||||||
}, [dispatch, setActive, key, dismissAfter]);
|
}, [dispatch, setActive, key, dismissAfter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Alert
|
||||||
className={classNames('notification-bar', {
|
isActive={active}
|
||||||
'notification-bar-active': active,
|
title={title ? formatIfNeeded(intl, title, values) : undefined}
|
||||||
})}
|
message={formatIfNeeded(intl, message, values)}
|
||||||
>
|
action={action ? formatIfNeeded(intl, action, values) : undefined}
|
||||||
<div className='notification-bar-wrapper'>
|
onActionClick={onClick}
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -98,7 +82,11 @@ export const AlertsController: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className='notification-list'>
|
<div className='notification-list'>
|
||||||
{alerts.map((alert, idx) => (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
|
||||||
const offset = [0, 4] as OffsetValue;
|
const offset = [0, 4] as OffsetValue;
|
||||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
export const AltTextBadge: React.FC<{
|
export const AltTextBadge: React.FC<{ description: string }> = ({
|
||||||
description: string;
|
description,
|
||||||
}> = ({ description }) => {
|
}) => {
|
||||||
const accessibilityId = useId();
|
const accessibilityId = useId();
|
||||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
|
||||||
{({ props }) => (
|
{({ props }) => (
|
||||||
<div {...props} className='hover-card-controller'>
|
<div {...props} className='hover-card-controller'>
|
||||||
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
||||||
className='media-gallery__alt__popover dropdown-animation'
|
className='info-tooltip dropdown-animation'
|
||||||
role='region'
|
role='region'
|
||||||
id={accessibilityId}
|
id={accessibilityId}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const meta = {
|
||||||
component: Button,
|
component: Button,
|
||||||
args: {
|
args: {
|
||||||
secondary: false,
|
secondary: false,
|
||||||
|
plain: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
dangerous: false,
|
dangerous: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
|
@ -57,6 +58,14 @@ export const Secondary: Story = {
|
||||||
play: buttonTest,
|
play: buttonTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Plain: Story = {
|
||||||
|
args: {
|
||||||
|
plain: true,
|
||||||
|
children: 'Plain button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
export const Compact: Story = {
|
export const Compact: Story = {
|
||||||
args: {
|
args: {
|
||||||
compact: true,
|
compact: true,
|
||||||
|
|
@ -101,6 +110,14 @@ export const SecondaryDisabled: Story = {
|
||||||
play: disabledButtonTest,
|
play: disabledButtonTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PlainDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Plain.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
play: disabledButtonTest,
|
||||||
|
};
|
||||||
|
|
||||||
const loadingButtonTest: Story['play'] = async ({
|
const loadingButtonTest: Story['play'] = async ({
|
||||||
args,
|
args,
|
||||||
canvas,
|
canvas,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface BaseProps
|
||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
plain?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
|
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
|
||||||
disabled,
|
disabled,
|
||||||
block,
|
block,
|
||||||
secondary,
|
secondary,
|
||||||
|
plain,
|
||||||
compact,
|
compact,
|
||||||
dangerous,
|
dangerous,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
|
||||||
<button
|
<button
|
||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
'button-secondary': secondary,
|
'button-secondary': secondary,
|
||||||
|
'button--plain': plain,
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
'button--dangerous': dangerous,
|
'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 type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
|
@ -23,31 +18,48 @@ interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
export function useDismissableBannerState({ id }: Props) {
|
||||||
id,
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
children,
|
const dismissed: boolean = useAppSelector((state) =>
|
||||||
}) => {
|
/* eslint-disable-next-line */
|
||||||
const dismissed = useAppSelector((state) =>
|
|
||||||
state.settings.getIn(['dismissed_banners', id], false),
|
state.settings.getIn(['dismissed_banners', id], false),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isVisible, setIsVisible] = useState(
|
||||||
|
!bannerSettings.get(id) && !dismissed,
|
||||||
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
|
const dismiss = useCallback(() => {
|
||||||
const intl = useIntl();
|
setIsVisible(false);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
setVisible(false);
|
|
||||||
bannerSettings.set(id, true);
|
bannerSettings.set(id, true);
|
||||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||||
}, [id, dispatch]);
|
}, [id, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible && !dismissed) {
|
// Store legacy localStorage setting on server
|
||||||
|
if (!isVisible && !dismissed) {
|
||||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
||||||
icon='times'
|
icon='times'
|
||||||
iconComponent={CloseIcon}
|
iconComponent={CloseIcon}
|
||||||
title={intl.formatMessage(messages.dismiss)}
|
title={intl.formatMessage(messages.dismiss)}
|
||||||
onClick={handleDismiss}
|
onClick={dismiss}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, FC } 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 type { MessageDescriptor } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||||
|
|
||||||
import type { SelectItem } from '../dropdown_selector';
|
import type { SelectItem } from '../dropdown_selector';
|
||||||
import { DropdownSelector } from '../dropdown_selector';
|
import { DropdownSelector } from '../dropdown_selector';
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
|
||||||
|
import { matchWidth } from './utils';
|
||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
items: SelectItem[];
|
items: SelectItem[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
current: string;
|
current: string;
|
||||||
|
labelId: string;
|
||||||
|
descriptionId?: string;
|
||||||
emptyText?: MessageDescriptor;
|
emptyText?: MessageDescriptor;
|
||||||
classPrefix: string;
|
classPrefix: string;
|
||||||
}
|
}
|
||||||
|
|
@ -24,39 +30,59 @@ interface DropdownProps {
|
||||||
export const Dropdown: FC<
|
export const Dropdown: FC<
|
||||||
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
||||||
> = ({
|
> = ({
|
||||||
title,
|
|
||||||
disabled,
|
disabled,
|
||||||
items,
|
items,
|
||||||
current,
|
current,
|
||||||
onChange,
|
onChange,
|
||||||
|
labelId,
|
||||||
|
descriptionId,
|
||||||
classPrefix,
|
classPrefix,
|
||||||
className,
|
className,
|
||||||
|
id,
|
||||||
...buttonProps
|
...buttonProps
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
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 [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
setOpen((prevOpen) => !prevOpen);
|
setOpen((prevOpen) => {
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
return !prevOpen;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentText = useMemo(
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
title={title}
|
id={buttonId}
|
||||||
|
aria-labelledby={`${labelId} ${buttonId}`}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={accessibilityId}
|
aria-controls={listboxId}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
@ -69,23 +95,24 @@ export const Dropdown: FC<
|
||||||
)}
|
)}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
{currentText ?? (
|
{currentText}
|
||||||
<FormattedMessage
|
<Icon
|
||||||
id='dropdown.empty'
|
id='unfold-icon'
|
||||||
defaultMessage='Select an option'
|
icon={UnfoldMoreIcon}
|
||||||
|
className={`${classPrefix}__icon`}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Overlay
|
<Overlay
|
||||||
show={open}
|
show={open}
|
||||||
offset={[0, 4]}
|
offset={[0, 0]}
|
||||||
placement='bottom-start'
|
placement='bottom-start'
|
||||||
onHide={handleClose}
|
onHide={handleClose}
|
||||||
flip
|
flip
|
||||||
target={buttonRef.current}
|
target={buttonRef.current}
|
||||||
popperConfig={{
|
popperConfig={{
|
||||||
strategy: 'fixed',
|
strategy: 'fixed',
|
||||||
|
modifiers: [matchWidth],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
|
|
@ -96,7 +123,7 @@ export const Dropdown: FC<
|
||||||
`${classPrefix}__dropdown`,
|
`${classPrefix}__dropdown`,
|
||||||
placement,
|
placement,
|
||||||
)}
|
)}
|
||||||
id={accessibilityId}
|
id={listboxId}
|
||||||
>
|
>
|
||||||
<DropdownSelector
|
<DropdownSelector
|
||||||
items={items}
|
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 type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { Icon } from './icon';
|
||||||
import type { IconProp } from './icon';
|
import type { IconProp } from './icon';
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from './icon_button';
|
||||||
|
|
||||||
|
|
@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
|
||||||
onItemClick?: ItemClickFn<Item>;
|
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,>({
|
export const DropdownMenu = <Item = MenuItem,>({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -164,13 +186,16 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||||
(e: React.MouseEvent | React.KeyboardEvent) => {
|
(e: React.MouseEvent | React.KeyboardEvent) => {
|
||||||
const i = Number(e.currentTarget.getAttribute('data-index'));
|
const i = Number(e.currentTarget.getAttribute('data-index'));
|
||||||
const item = items?.[i];
|
const item = items?.[i];
|
||||||
|
const isItemDisabled = Boolean(
|
||||||
|
item && typeof item === 'object' && 'disabled' in item && item.disabled,
|
||||||
|
);
|
||||||
|
|
||||||
onClose();
|
if (!item || isItemDisabled) {
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
if (typeof onItemClick === 'function') {
|
if (typeof onItemClick === 'function') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onItemClick(item, i);
|
onItemClick(item, i);
|
||||||
|
|
@ -200,7 +225,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
|
|
@ -211,8 +236,9 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
|
aria-disabled={disabled}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
|
|
@ -227,7 +253,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -239,7 +265,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +273,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames('dropdown-menu__item', {
|
className={classNames('dropdown-menu__item', {
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
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;
|
children?: React.ReactElement;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconComponent?: IconProp;
|
iconComponent?: IconProp;
|
||||||
|
|
@ -306,6 +333,7 @@ interface DropdownProps<Item = MenuItem> {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
placement?: Placement;
|
placement?: Placement;
|
||||||
|
offset?: OffsetValue;
|
||||||
/**
|
/**
|
||||||
* Prevent the `ScrollableList` with this scrollKey
|
* Prevent the `ScrollableList` with this scrollKey
|
||||||
* from being scrolled while the dropdown is open
|
* from being scrolled while the dropdown is open
|
||||||
|
|
@ -321,10 +349,9 @@ interface DropdownProps<Item = MenuItem> {
|
||||||
onItemClick?: ItemClickFn<Item>;
|
onItemClick?: ItemClickFn<Item>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = [5, 5] as OffsetValue;
|
|
||||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
export const Dropdown = <Item = MenuItem,>({
|
export const Dropdown = <Item extends object | null = MenuItem>({
|
||||||
children,
|
children,
|
||||||
icon,
|
icon,
|
||||||
iconComponent,
|
iconComponent,
|
||||||
|
|
@ -334,6 +361,7 @@ export const Dropdown = <Item = MenuItem,>({
|
||||||
disabled,
|
disabled,
|
||||||
scrollable,
|
scrollable,
|
||||||
placement = 'bottom',
|
placement = 'bottom',
|
||||||
|
offset = [5, 5],
|
||||||
status,
|
status,
|
||||||
forceDropdown = false,
|
forceDropdown = false,
|
||||||
renderItem,
|
renderItem,
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const nodeRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||||
const [currentValue, setCurrentValue] = useState(value);
|
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(
|
const handleClick = useCallback(
|
||||||
(
|
(
|
||||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||||
|
|
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
|
||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index + 1] ??
|
listRef.current?.children[index + 1] ??
|
||||||
nodeRef.current?.firstElementChild;
|
listRef.current?.firstElementChild;
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index - 1] ??
|
listRef.current?.children[index - 1] ??
|
||||||
nodeRef.current?.lastElementChild;
|
listRef.current?.lastElementChild;
|
||||||
break;
|
break;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index + 1] ??
|
listRef.current?.children[index - 1] ??
|
||||||
nodeRef.current?.firstElementChild;
|
listRef.current?.lastElementChild;
|
||||||
} else {
|
} else {
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index - 1] ??
|
listRef.current?.children[index + 1] ??
|
||||||
nodeRef.current?.lastElementChild;
|
listRef.current?.firstElementChild;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
element = nodeRef.current?.firstElementChild;
|
element = listRef.current?.firstElementChild;
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
element = nodeRef.current?.lastElementChild;
|
element = listRef.current?.lastElementChild;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
[items, onClose, handleClick, setCurrentValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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('click', handleDocumentClick, { capture: true });
|
||||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
|
||||||
focusedItemRef.current?.focus({ preventScroll: true });
|
focusedItemRef.current?.focus({ preventScroll: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
||||||
listenerOptions,
|
listenerOptions,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [handleDocumentClick]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul style={style} role='listbox' ref={nodeRef}>
|
<ul style={style} role='listbox' ref={listRef}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li
|
<li
|
||||||
role='option'
|
role='option'
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ export const FollowButton: React.FC<{
|
||||||
openModal({
|
openModal({
|
||||||
modalType: 'INTERACTION',
|
modalType: 'INTERACTION',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
type: 'follow',
|
|
||||||
accountId: accountId,
|
accountId: accountId,
|
||||||
url: account?.url,
|
url: account?.url,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils';
|
||||||
* the hotkey with a higher priority is selected. All others
|
* the hotkey with a higher priority is selected. All others
|
||||||
* are ignored.
|
* are ignored.
|
||||||
*/
|
*/
|
||||||
const hotkeyPriority = {
|
const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const;
|
||||||
singleKey: 0,
|
|
||||||
combo: 1,
|
|
||||||
sequence: 2,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This type of function receives a keyboard event and an array of
|
* This type of function receives a keyboard event and an array of
|
||||||
|
|
@ -105,14 +101,16 @@ const hotkeyMatcherMap = {
|
||||||
new: just('n'),
|
new: just('n'),
|
||||||
forceNew: optionPlus('n'),
|
forceNew: optionPlus('n'),
|
||||||
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||||||
|
focusLoadMore: just('l'),
|
||||||
reply: just('r'),
|
reply: just('r'),
|
||||||
favourite: just('f'),
|
favourite: just('f'),
|
||||||
boost: just('b'),
|
boost: just('b'),
|
||||||
|
quote: just('q'),
|
||||||
mention: just('m'),
|
mention: just('m'),
|
||||||
open: any('enter', 'o'),
|
open: any('enter', 'o'),
|
||||||
openProfile: just('p'),
|
openProfile: just('p'),
|
||||||
moveDown: any('down', 'j'),
|
moveDown: just('j'),
|
||||||
moveUp: any('up', 'k'),
|
moveUp: just('k'),
|
||||||
toggleHidden: just('x'),
|
toggleHidden: just('x'),
|
||||||
toggleSensitive: just('h'),
|
toggleSensitive: just('h'),
|
||||||
toggleComposeSpoilers: optionPlus('x'),
|
toggleComposeSpoilers: optionPlus('x'),
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,6 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
||||||
openModal({
|
openModal({
|
||||||
modalType: 'INTERACTION',
|
modalType: 'INTERACTION',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
type: 'vote',
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
accountId: status.getIn(['account', 'id']),
|
||||||
url: status.get('uri'),
|
url: status.get('uri'),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { displayMedia } from '../initial_state';
|
||||||
|
|
||||||
import { Avatar } from './avatar';
|
import { Avatar } from './avatar';
|
||||||
import { AvatarOverlay } from './avatar_overlay';
|
import { AvatarOverlay } from './avatar_overlay';
|
||||||
import { DisplayName } from './display_name';
|
import { LinkedDisplayName } from './display_name';
|
||||||
import { getHashtagBarForStatus } from './hashtag_bar';
|
import { getHashtagBarForStatus } from './hashtag_bar';
|
||||||
import { RelativeTimestamp } from './relative_timestamp';
|
import { RelativeTimestamp } from './relative_timestamp';
|
||||||
import StatusActionBar from './status_action_bar';
|
import StatusActionBar from './status_action_bar';
|
||||||
|
|
@ -39,7 +39,18 @@ import { IconButton } from './icon_button';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
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 displayName = status.getIn(['account', 'display_name']);
|
||||||
|
|
||||||
const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text');
|
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 contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent;
|
||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
|
isQuote ? intl.formatMessage(messages.quote_noun) : undefined,
|
||||||
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
|
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
|
||||||
spoilerText && status.get('hidden') ? spoilerText : contentText,
|
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' }),
|
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
|
||||||
status.getIn(['account', 'acct']),
|
status.getIn(['account', 'acct']),
|
||||||
];
|
rebloggedByText,
|
||||||
|
].filter(val => !!val);
|
||||||
if (rebloggedByText) {
|
|
||||||
values.push(rebloggedByText);
|
|
||||||
}
|
|
||||||
|
|
||||||
return values.join(', ');
|
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');
|
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 {
|
class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
static contextType = SensitiveMediaContext;
|
static contextType = SensitiveMediaContext;
|
||||||
|
|
@ -96,6 +97,7 @@ class Status extends ImmutablePureComponent {
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onReblog: PropTypes.func,
|
onReblog: PropTypes.func,
|
||||||
|
onQuote: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
onDirect: PropTypes.func,
|
onDirect: PropTypes.func,
|
||||||
onMention: PropTypes.func,
|
onMention: PropTypes.func,
|
||||||
|
|
@ -114,8 +116,6 @@ class Status extends ImmutablePureComponent {
|
||||||
muted: PropTypes.bool,
|
muted: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
onMoveUp: PropTypes.func,
|
|
||||||
onMoveDown: PropTypes.func,
|
|
||||||
showThread: PropTypes.bool,
|
showThread: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
|
|
@ -278,6 +278,10 @@ class Status extends ImmutablePureComponent {
|
||||||
this.props.onReblog(this._properStatus(), e);
|
this.props.onReblog(this._properStatus(), e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleHotkeyQuote = () => {
|
||||||
|
this.props.onQuote(this._properStatus());
|
||||||
|
};
|
||||||
|
|
||||||
handleHotkeyMention = e => {
|
handleHotkeyMention = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onMention(this._properStatus().get('account'));
|
this.props.onMention(this._properStatus().get('account'));
|
||||||
|
|
@ -328,14 +332,6 @@ class Status extends ImmutablePureComponent {
|
||||||
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
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 = () => {
|
handleHotkeyToggleHidden = () => {
|
||||||
const { onToggleHidden } = this.props;
|
const { onToggleHidden } = this.props;
|
||||||
const status = this._properStatus();
|
const status = this._properStatus();
|
||||||
|
|
@ -396,11 +392,10 @@ class Status extends ImmutablePureComponent {
|
||||||
reply: this.handleHotkeyReply,
|
reply: this.handleHotkeyReply,
|
||||||
favourite: this.handleHotkeyFavourite,
|
favourite: this.handleHotkeyFavourite,
|
||||||
boost: this.handleHotkeyBoost,
|
boost: this.handleHotkeyBoost,
|
||||||
|
quote: this.handleHotkeyQuote,
|
||||||
mention: this.handleHotkeyMention,
|
mention: this.handleHotkeyMention,
|
||||||
open: this.handleHotkeyOpen,
|
open: this.handleHotkeyOpen,
|
||||||
openProfile: this.handleHotkeyOpenProfile,
|
openProfile: this.handleHotkeyOpenProfile,
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
|
||||||
toggleHidden: this.handleHotkeyToggleHidden,
|
toggleHidden: this.handleHotkeyToggleHidden,
|
||||||
toggleSensitive: this.handleHotkeyToggleSensitive,
|
toggleSensitive: this.handleHotkeyToggleSensitive,
|
||||||
openMedia: this.handleHotkeyOpenMedia,
|
openMedia: this.handleHotkeyOpenMedia,
|
||||||
|
|
@ -415,12 +410,20 @@ class Status extends ImmutablePureComponent {
|
||||||
const matchedFilters = status.get('matched_filters');
|
const matchedFilters = status.get('matched_filters');
|
||||||
|
|
||||||
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
|
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 = (
|
prepend = (
|
||||||
<div className='status__prepend'>
|
<div className='status__prepend'>
|
||||||
<div className='status__prepend__icon'><Icon id='retweet' icon={RepeatIcon} /></div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -552,7 +555,7 @@ class Status extends ImmutablePureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Hotkeys handlers={handlers} focusable={!unfocusable}>
|
<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}
|
{!skipPrepend && prepend}
|
||||||
|
|
||||||
<div
|
<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>}
|
<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>
|
||||||
|
|
||||||
<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'>
|
<div className='status__avatar'>
|
||||||
{statusAvatar}
|
{statusAvatar}
|
||||||
</div>
|
</div>
|
||||||
|
</LinkedDisplayName>
|
||||||
<DisplayName account={status.get('account')} />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isQuotedPost && !!this.props.onQuoteCancel && (
|
{isQuotedPost && !!this.props.onQuoteCancel && (
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||||
import { statusFactoryState } from '@/testing/factories';
|
import { statusFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
|
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||||
|
|
||||||
interface StoryProps {
|
interface StoryProps {
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
|
|
@ -13,7 +13,7 @@ interface StoryProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Status/ReblogButton',
|
title: 'Components/Status/BoostButton',
|
||||||
args: {
|
args: {
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
quoteAllowed: true,
|
quoteAllowed: true,
|
||||||
|
|
@ -38,7 +38,7 @@ const meta = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<StatusReblogButton
|
<StatusBoostButton
|
||||||
status={argsToStatus(args)}
|
status={argsToStatus(args)}
|
||||||
counters={args.reblogCount > 0}
|
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 { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
|
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { me } from '../initial_state';
|
import { me } from '../../initial_state';
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
import { isFeatureEnabled } from '../utils/environment';
|
import { isFeatureEnabled } from '../../utils/environment';
|
||||||
import { ReblogButton } from './status/reblog_button';
|
import { BoostButton } from '../status/boost_button';
|
||||||
|
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
|
|
@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
relationship: ImmutablePropTypes.record,
|
relationship: ImmutablePropTypes.record,
|
||||||
quotedAccountId: PropTypes.string,
|
quotedAccountId: PropTypes.string,
|
||||||
|
contextType: PropTypes.string,
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
|
|
@ -120,7 +122,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
this.props.onReply(this.props.status);
|
this.props.onReply(this.props.status);
|
||||||
} else {
|
} else {
|
||||||
this.props.onInteractionModal('reply', this.props.status);
|
this.props.onInteractionModal(this.props.status);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -138,7 +140,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
if (signedIn) {
|
if (signedIn) {
|
||||||
this.props.onFavourite(this.props.status);
|
this.props.onFavourite(this.props.status);
|
||||||
} else {
|
} else {
|
||||||
this.props.onInteractionModal('favourite', this.props.status);
|
this.props.onInteractionModal(this.props.status);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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 { signedIn, permissions } = this.props.identity;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
|
|
@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
const writtenByMe = status.getIn(['account', 'id']) === me;
|
const writtenByMe = status.getIn(['account', 'id']) === me;
|
||||||
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
||||||
|
const isQuotingMe = quotedAccountId === me;
|
||||||
|
|
||||||
let menu = [];
|
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({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
if (quotedAccountId === me) {
|
if (isQuotingMe) {
|
||||||
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,13 +364,15 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
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 isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||||
|
|
||||||
|
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='status__action-bar'>
|
<div className='status__action-bar'>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
<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>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<ReblogButton status={status} counters={withCounters} />
|
<BoostButton status={status} counters={withCounters} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<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} />
|
<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,7 +380,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<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} />
|
<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>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
|
||||||
|
{(dismissQuoteHint) => (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
status={status}
|
status={status}
|
||||||
|
|
@ -384,8 +390,13 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
direction='right'
|
direction='right'
|
||||||
title={intl.formatMessage(messages.more)}
|
title={intl.formatMessage(messages.more)}
|
||||||
|
onOpen={() => {
|
||||||
|
dismissQuoteHint();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</RemoveQuoteHint>
|
||||||
</div>
|
</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,43 +138,7 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
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 () {
|
componentDidMount () {
|
||||||
this._updateStatusLinks();
|
this._updateStatusLinks();
|
||||||
|
|
@ -267,7 +231,13 @@ class StatusContent extends PureComponent {
|
||||||
if (this.props.onClick) {
|
if (this.props.onClick) {
|
||||||
return (
|
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
|
<EmojiHTML
|
||||||
className='status__content__text status__content__text--visible translate'
|
className='status__content__text status__content__text--visible translate'
|
||||||
lang={language}
|
lang={language}
|
||||||
|
|
@ -284,7 +254,7 @@ class StatusContent extends PureComponent {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={classNames} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
<div className={classNames} ref={this.setRef}>
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='status__content__text status__content__text--visible translate'
|
className='status__content__text status__content__text--visible translate'
|
||||||
lang={language}
|
lang={language}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted';
|
||||||
import { LoadGap } from './load_gap';
|
import { LoadGap } from './load_gap';
|
||||||
import ScrollableList from './scrollable_list';
|
import ScrollableList from './scrollable_list';
|
||||||
|
|
||||||
|
|
||||||
export default class StatusList extends ImmutablePureComponent {
|
export default class StatusList extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
|
@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
trackScroll: true,
|
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(() => {
|
handleLoadOlder = debounce(() => {
|
||||||
const { statusIds, lastId, onLoadMore } = this.props;
|
const { statusIds, lastId, onLoadMore } = this.props;
|
||||||
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
|
||||||
|
|
@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
<StatusQuoteManager
|
<StatusQuoteManager
|
||||||
key={statusId}
|
key={statusId}
|
||||||
id={statusId}
|
id={statusId}
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
scrollKey={this.props.scrollKey}
|
scrollKey={this.props.scrollKey}
|
||||||
showThread
|
showThread
|
||||||
|
|
@ -176,8 +97,6 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
key={`f-${statusId}`}
|
key={`f-${statusId}`}
|
||||||
id={statusId}
|
id={statusId}
|
||||||
featured
|
featured
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
showThread
|
showThread
|
||||||
withCounters={this.props.withCounters}
|
withCounters={this.props.withCounters}
|
||||||
|
|
@ -191,5 +110,4 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,33 @@
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
||||||
import StatusContainer from 'mastodon/containers/status_container';
|
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 { Status } from 'mastodon/models/status';
|
||||||
import type { RootState } from 'mastodon/store';
|
import type { RootState } from 'mastodon/store';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { revealAccount } from '../actions/accounts_typed';
|
||||||
import { fetchStatus } from '../actions/statuses';
|
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 MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
|
||||||
|
|
||||||
const QuoteWrapper: React.FC<{
|
const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
|
||||||
isError?: boolean;
|
const accountObjectOrId = status.get('account') as string | Account;
|
||||||
children: React.ReactElement;
|
const accountId =
|
||||||
}> = ({ isError, children }) => {
|
typeof accountObjectOrId === 'string'
|
||||||
return (
|
? accountObjectOrId
|
||||||
<div
|
: accountObjectOrId.id;
|
||||||
className={classNames('status__quote', {
|
|
||||||
'status__quote--error': isError,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NestedQuoteLink: React.FC<{
|
|
||||||
status: Status;
|
|
||||||
}> = ({ status }) => {
|
|
||||||
const accountId = status.get('account') as string;
|
|
||||||
const account = useAppSelector((state) =>
|
const account = useAppSelector((state) =>
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
);
|
);
|
||||||
|
|
@ -57,15 +49,43 @@ const NestedQuoteLink: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
|
|
||||||
type GetStatusSelector = (
|
type GetStatusSelector = (
|
||||||
state: RootState,
|
state: RootState,
|
||||||
props: { id?: string | null; contextType?: string },
|
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 {
|
interface QuotedStatusProps {
|
||||||
quote: QuoteMap;
|
quote: QuoteMap;
|
||||||
contextType?: string;
|
contextType?: string;
|
||||||
|
parentQuotePostId?: string | null;
|
||||||
variant?: 'full' | 'link';
|
variant?: 'full' | 'link';
|
||||||
nestingLevel?: number;
|
nestingLevel?: number;
|
||||||
onQuoteCancel?: () => void; // Used for composer.
|
onQuoteCancel?: () => void; // Used for composer.
|
||||||
|
|
@ -74,31 +94,61 @@ interface QuotedStatusProps {
|
||||||
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||||
quote,
|
quote,
|
||||||
contextType,
|
contextType,
|
||||||
|
parentQuotePostId,
|
||||||
nestingLevel = 1,
|
nestingLevel = 1,
|
||||||
variant = 'full',
|
variant = 'full',
|
||||||
onQuoteCancel,
|
onQuoteCancel,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const quotedStatusId = quote.get('quoted_status');
|
const quoteState = useAppSelector((state) =>
|
||||||
const quoteState = quote.get('state');
|
parentQuotePostId
|
||||||
const status = useAppSelector((state) =>
|
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
|
||||||
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (!status && quotedStatusId) {
|
if (isLoaded) {
|
||||||
dispatch(fetchStatus(quotedStatusId));
|
isFetchingQuoteRef.current = false;
|
||||||
}
|
}
|
||||||
}, [status, quotedStatusId, dispatch]);
|
}, [isLoaded]);
|
||||||
|
|
||||||
// In order to find out whether the quoted post should be completely hidden
|
useEffect(() => {
|
||||||
// due to a matching filter, we run it through the selector used by `status_container`.
|
if (shouldFetchQuote && quotedStatusId && !isFetchingQuoteRef.current) {
|
||||||
// If this returns null even though `status` exists, it's because it's filtered.
|
dispatch(
|
||||||
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
|
fetchStatus(quotedStatusId, {
|
||||||
const statusWithExtraData = useAppSelector((state) =>
|
parentQuotePostId,
|
||||||
getStatus(state, { id: quotedStatusId, contextType }),
|
alsoFetchContext: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const isFilteredAndHidden = status && statusWithExtraData === null;
|
isFetchingQuoteRef.current = true;
|
||||||
|
}
|
||||||
|
}, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
||||||
|
|
||||||
|
const isFilteredAndHidden = loadingState === 'filtered';
|
||||||
|
|
||||||
let quoteError: React.ReactNode = null;
|
let quoteError: React.ReactNode = null;
|
||||||
|
|
||||||
|
|
@ -118,27 +168,27 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LearnMoreLink>
|
<LearnMoreLink>
|
||||||
<h6>
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.quote_error.pending_approval_popout.title'
|
|
||||||
defaultMessage='Pending quote? Remain calm'
|
|
||||||
/>
|
|
||||||
</h6>
|
|
||||||
<p>
|
<p>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.quote_error.pending_approval_popout.body'
|
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>
|
</p>
|
||||||
</LearnMoreLink>
|
</LearnMoreLink>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
} else if (quoteState === 'revoked') {
|
||||||
|
quoteError = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.quote_error.revoked'
|
||||||
|
defaultMessage='Post removed by author'
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (
|
} else if (
|
||||||
!status ||
|
!status ||
|
||||||
!quotedStatusId ||
|
!quotedStatusId ||
|
||||||
quoteState === 'deleted' ||
|
quoteState === 'deleted' ||
|
||||||
quoteState === 'rejected' ||
|
quoteState === 'rejected' ||
|
||||||
quoteState === 'revoked' ||
|
|
||||||
quoteState === 'unauthorized'
|
quoteState === 'unauthorized'
|
||||||
) {
|
) {
|
||||||
quoteError = (
|
quoteError = (
|
||||||
|
|
@ -147,10 +197,26 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||||
defaultMessage='Post unavailable'
|
defaultMessage='Post unavailable'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (hiddenAccount && accountId) {
|
||||||
|
quoteError = <LimitedAccountHint accountId={accountId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (quoteError) {
|
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) {
|
if (variant === 'link' && status) {
|
||||||
|
|
@ -162,7 +228,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||||
childQuote && nestingLevel <= MAX_QUOTE_POSTS_NESTING_LEVEL;
|
childQuote && nestingLevel <= MAX_QUOTE_POSTS_NESTING_LEVEL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QuoteWrapper>
|
<div className='status__quote'>
|
||||||
{/* @ts-expect-error Status is not yet typed */}
|
{/* @ts-expect-error Status is not yet typed */}
|
||||||
<StatusContainer
|
<StatusContainer
|
||||||
isQuotedPost
|
isQuotedPost
|
||||||
|
|
@ -174,6 +240,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||||
{canRenderChildQuote && (
|
{canRenderChildQuote && (
|
||||||
<QuotedStatus
|
<QuotedStatus
|
||||||
quote={childQuote}
|
quote={childQuote}
|
||||||
|
parentQuotePostId={quotedStatusId}
|
||||||
contextType={contextType}
|
contextType={contextType}
|
||||||
variant={
|
variant={
|
||||||
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
|
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
|
||||||
|
|
@ -182,7 +249,7 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</StatusContainer>
|
</StatusContainer>
|
||||||
</QuoteWrapper>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -209,7 +276,11 @@ export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
|
||||||
if (quote) {
|
if (quote) {
|
||||||
return (
|
return (
|
||||||
<StatusContainer {...props}>
|
<StatusContainer {...props}>
|
||||||
<QuotedStatus quote={quote} contextType={props.contextType} />
|
<QuotedStatus
|
||||||
|
quote={quote}
|
||||||
|
parentQuotePostId={status?.get('id') as string}
|
||||||
|
contextType={props.contextType}
|
||||||
|
/>
|
||||||
</StatusContainer>
|
</StatusContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { DisplayedName } from 'mastodon/features/notifications_v2/components/displayed_name';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { LinkedDisplayName } from './display_name';
|
||||||
|
|
||||||
export const StatusThreadLabel: React.FC<{
|
export const StatusThreadLabel: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
inReplyToAccountId: string;
|
inReplyToAccountId: string;
|
||||||
|
|
@ -27,7 +28,13 @@ export const StatusThreadLabel: React.FC<{
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='status.replied_to'
|
id='status.replied_to'
|
||||||
defaultMessage='Replied to {name}'
|
defaultMessage='Replied to {name}'
|
||||||
values={{ name: <DisplayedName accountIds={[inReplyToAccountId]} /> }}
|
values={{
|
||||||
|
name: (
|
||||||
|
<LinkedDisplayName
|
||||||
|
displayProps={{ account: inReplyToAccount, variant: 'simple' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
mentionCompose,
|
mentionCompose,
|
||||||
directCompose,
|
directCompose,
|
||||||
} from '../actions/compose';
|
} from '../actions/compose';
|
||||||
|
import { quoteComposeById } from '../actions/compose_typed';
|
||||||
import {
|
import {
|
||||||
initDomainBlockModal,
|
initDomainBlockModal,
|
||||||
unblockDomain,
|
unblockDomain,
|
||||||
|
|
@ -46,6 +47,8 @@ import Status from '../components/status';
|
||||||
import { deleteModal } from '../initial_state';
|
import { deleteModal } from '../initial_state';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||||
|
|
||||||
|
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
const getPictureInPicture = makeGetPictureInPicture();
|
||||||
|
|
@ -77,6 +80,12 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onQuote (status) {
|
||||||
|
if (isFeatureEnabled('outgoing_quotes')) {
|
||||||
|
dispatch(quoteComposeById(status.get('id')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
onFavourite (status) {
|
onFavourite (status) {
|
||||||
dispatch(toggleFavourite(status.get('id')));
|
dispatch(toggleFavourite(status.get('id')));
|
||||||
},
|
},
|
||||||
|
|
@ -108,7 +117,13 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||||
if (!deleteModal) {
|
if (!deleteModal) {
|
||||||
dispatch(deleteStatus(status.get('id'), withRedraft));
|
dispatch(deleteStatus(status.get('id'), withRedraft));
|
||||||
} else {
|
} 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}));
|
dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps}));
|
||||||
},
|
},
|
||||||
|
|
||||||
onInteractionModal (type, status) {
|
onInteractionModal (status) {
|
||||||
dispatch(openModal({
|
dispatch(openModal({
|
||||||
modalType: 'INTERACTION',
|
modalType: 'INTERACTION',
|
||||||
modalProps: {
|
modalProps: {
|
||||||
type,
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
accountId: status.getIn(['account', 'id']),
|
||||||
url: status.get('uri'),
|
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 CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
|
|
||||||
export default class FollowRequestNote extends ImmutablePureComponent {
|
export default class FollowRequestNote extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
|
@ -19,7 +20,7 @@ export default class FollowRequestNote extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div className='follow-request-banner'>
|
<div className='follow-request-banner'>
|
||||||
<div className='follow-request-banner__message'>
|
<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>
|
||||||
|
|
||||||
<div className='follow-request-banner__action'>
|
<div className='follow-request-banner__action'>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { Helmet } from 'react-helmet';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||||
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
|
@ -378,36 +379,6 @@ export const AccountHeader: React.FC<{
|
||||||
});
|
});
|
||||||
}, [account]);
|
}, [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 suspended = account?.suspended;
|
||||||
const isRemote = account?.acct !== account?.username;
|
const isRemote = account?.acct !== account?.username;
|
||||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
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 fields = account.fields;
|
||||||
const isLocal = !account.acct.includes('@');
|
const isLocal = !account.acct.includes('@');
|
||||||
const username = account.acct.split('@')[0];
|
const username = account.acct.split('@')[0];
|
||||||
|
|
@ -808,11 +778,9 @@ export const AccountHeader: React.FC<{
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={classNames('account__header', {
|
className={classNames('account__header animate-parent', {
|
||||||
inactive: !!account.moved,
|
inactive: !!account.moved,
|
||||||
})}
|
})}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
>
|
||||||
{!(suspended || hidden || account.moved) &&
|
{!(suspended || hidden || account.moved) &&
|
||||||
relationship?.requested_by && (
|
relationship?.requested_by && (
|
||||||
|
|
@ -863,7 +831,7 @@ export const AccountHeader: React.FC<{
|
||||||
|
|
||||||
<div className='account__header__tabs__name'>
|
<div className='account__header__tabs__name'>
|
||||||
<h1>
|
<h1>
|
||||||
<span dangerouslySetInnerHTML={displayNameHtml} />
|
<DisplayName account={account} variant='simple' />
|
||||||
<small>
|
<small>
|
||||||
<span>
|
<span>
|
||||||
@{username}
|
@{username}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,26 @@
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Avatar } from '@/mastodon/components/avatar';
|
import { Avatar } from '@/mastodon/components/avatar';
|
||||||
import { AvatarGroup } from '@/mastodon/components/avatar_group';
|
import { AvatarGroup } from '@/mastodon/components/avatar_group';
|
||||||
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
import type { Account } from '@/mastodon/models/account';
|
import type { Account } from '@/mastodon/models/account';
|
||||||
|
|
||||||
import { useFetchFamiliarFollowers } from '../hooks/familiar_followers';
|
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[] }> = ({
|
const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({
|
||||||
familiarFollowers,
|
familiarFollowers,
|
||||||
}) => {
|
}) => {
|
||||||
const messageData = {
|
const messageData = {
|
||||||
name1: <AccountLink account={familiarFollowers.at(0)} />,
|
name1: (
|
||||||
name2: <AccountLink account={familiarFollowers.at(1)} />,
|
<LinkedDisplayName
|
||||||
|
displayProps={{ account: familiarFollowers.at(0), variant: 'simple' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
name2: (
|
||||||
|
<LinkedDisplayName
|
||||||
|
displayProps={{ account: familiarFollowers.at(1), variant: 'simple' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
othersCount: familiarFollowers.length - 2,
|
othersCount: familiarFollowers.length - 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
|
import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
export const MovedNote: React.FC<{
|
export const MovedNote: React.FC<{
|
||||||
|
|
@ -20,15 +20,7 @@ export const MovedNote: React.FC<{
|
||||||
id='account.moved_to'
|
id='account.moved_to'
|
||||||
defaultMessage='{name} has indicated that their new account is now:'
|
defaultMessage='{name} has indicated that their new account is now:'
|
||||||
values={{
|
values={{
|
||||||
name: (
|
name: <DisplayName account={from} variant='simple' />,
|
||||||
<bdi>
|
|
||||||
<strong
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: from?.display_name_html ?? '',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</bdi>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useCallback } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||||
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
|
|
@ -79,11 +80,7 @@ export const HighlightedPost: React.FC<{
|
||||||
id='annual_report.summary.highlighted_post.possessive'
|
id='annual_report.summary.highlighted_post.possessive'
|
||||||
defaultMessage="{name}'s"
|
defaultMessage="{name}'s"
|
||||||
values={{
|
values={{
|
||||||
name: account && (
|
name: <DisplayName account={account} variant='simple' />,
|
||||||
<bdi
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</strong>
|
</strong>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export const messages = defineMessages({
|
||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
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_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ export const ComposeQuotedStatus: FC = () => {
|
||||||
const quotedStatusId = useAppSelector(
|
const quotedStatusId = useAppSelector(
|
||||||
(state) => state.compose.get('quoted_status_id') as string | null,
|
(state) => state.compose.get('quoted_status_id') as string | null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
const isEditing = useAppSelector((state) => !!state.compose.get('id'));
|
||||||
|
|
||||||
const quote = useMemo(
|
const quote = useMemo(
|
||||||
() =>
|
() =>
|
||||||
quotedStatusId
|
quotedStatusId
|
||||||
|
|
@ -22,16 +24,20 @@ export const ComposeQuotedStatus: FC = () => {
|
||||||
: null,
|
: null,
|
||||||
[quotedStatusId],
|
[quotedStatusId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const handleQuoteCancel = useCallback(() => {
|
const handleQuoteCancel = useCallback(() => {
|
||||||
dispatch(quoteComposeCancel());
|
dispatch(quoteComposeCancel());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
if (!quote) {
|
if (!quote) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QuotedStatus
|
<QuotedStatus
|
||||||
quote={quote}
|
quote={quote}
|
||||||
|
contextType='composer'
|
||||||
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
|
onQuoteCancel={!isEditing ? handleQuoteCancel : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -79,10 +79,12 @@ const visibilityOptions = {
|
||||||
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { visibility, quotePolicy } = useAppSelector((state) => ({
|
const quotePolicy = useAppSelector(
|
||||||
visibility: state.compose.get('privacy') as StatusVisibility,
|
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
|
||||||
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
|
);
|
||||||
}));
|
const visibility = useAppSelector(
|
||||||
|
(state) => state.compose.get('privacy') as StatusVisibility,
|
||||||
|
);
|
||||||
|
|
||||||
const { icon, iconComponent } = useMemo(() => {
|
const { icon, iconComponent } = useMemo(() => {
|
||||||
const option = visibilityOptions[visibility];
|
const option = visibilityOptions[visibility];
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
import StatusContent from 'mastodon/components/status_content';
|
import StatusContent from 'mastodon/components/status_content';
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { autoPlayGif } from 'mastodon/initial_state';
|
|
||||||
import { makeGetStatus } from 'mastodon/selectors';
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
|
|
@ -45,7 +45,7 @@ const getAccounts = createSelector(
|
||||||
|
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
|
export const Conversation = ({ conversation, scrollKey }) => {
|
||||||
const id = conversation.get('id');
|
const id = conversation.get('id');
|
||||||
const unread = conversation.get('unread');
|
const unread = conversation.get('unread');
|
||||||
const lastStatusId = conversation.get('last_status');
|
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 lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
|
||||||
const accounts = useSelector(state => getAccounts(state, accountIds));
|
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(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (unread) {
|
if (unread) {
|
||||||
dispatch(markConversationRead(id));
|
dispatch(markConversationRead(id));
|
||||||
|
|
@ -110,14 +84,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
dispatch(deleteConversation(id));
|
dispatch(deleteConversation(id));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
||||||
const handleHotkeyMoveUp = useCallback(() => {
|
|
||||||
onMoveUp(id);
|
|
||||||
}, [id, onMoveUp]);
|
|
||||||
|
|
||||||
const handleHotkeyMoveDown = useCallback(() => {
|
|
||||||
onMoveDown(id);
|
|
||||||
}, [id, onMoveDown]);
|
|
||||||
|
|
||||||
const handleConversationMute = useCallback(() => {
|
const handleConversationMute = useCallback(() => {
|
||||||
if (lastStatus.get('muted')) {
|
if (lastStatus.get('muted')) {
|
||||||
dispatch(unmuteStatus(lastStatus.get('id')));
|
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 });
|
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
||||||
|
|
||||||
const names = accounts.map(a => (
|
const names = accounts.map((account) => (
|
||||||
<Link to={`/@${a.get('acct')}`} key={a.get('id')} data-hover-card-account={a.get('id')}>
|
<LinkedDisplayName displayProps={{account, variant: 'simple'}} key={account.get('id')} />
|
||||||
<bdi>
|
|
||||||
<strong
|
|
||||||
className='display-name__html'
|
|
||||||
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
|
|
||||||
/>
|
|
||||||
</bdi>
|
|
||||||
</Link>
|
|
||||||
)).reduce((prev, cur) => [prev, ', ', cur]);
|
)).reduce((prev, cur) => [prev, ', ', cur]);
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
reply: handleReply,
|
reply: handleReply,
|
||||||
open: handleClick,
|
open: handleClick,
|
||||||
moveUp: handleHotkeyMoveUp,
|
|
||||||
moveDown: handleHotkeyMoveDown,
|
|
||||||
toggleHidden: handleShowMore,
|
toggleHidden: handleShowMore,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -179,7 +136,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
</div>
|
</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> }} />
|
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -224,6 +181,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
|
||||||
Conversation.propTypes = {
|
Conversation.propTypes = {
|
||||||
conversation: ImmutablePropTypes.map.isRequired,
|
conversation: ImmutablePropTypes.map.isRequired,
|
||||||
scrollKey: PropTypes.string,
|
scrollKey: PropTypes.string,
|
||||||
onMoveUp: PropTypes.func,
|
|
||||||
onMoveDown: PropTypes.func,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list';
|
||||||
|
|
||||||
import { Conversation } from './conversation';
|
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 }) => {
|
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
const listRef = useRef();
|
const listRef = useRef();
|
||||||
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||||
|
|
@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const lastStatusId = conversations.last()?.get('last_status');
|
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 => {
|
const debouncedLoadMore = useMemo(() => debounce(id => {
|
||||||
dispatch(expandConversations({ maxId: id }));
|
dispatch(expandConversations({ maxId: id }));
|
||||||
}, 300, { leading: true }), [dispatch]);
|
}, 300, { leading: true }), [dispatch]);
|
||||||
|
|
@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
<Conversation
|
<Conversation
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
conversation={item}
|
conversation={item}
|
||||||
onMoveUp={handleMoveUp}
|
|
||||||
onMoveDown={handleMoveDown}
|
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { MouseEventHandler } from 'react';
|
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
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 account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
const dispatch = useAppDispatch();
|
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(() => {
|
const handleFollow = useCallback(() => {
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
|
|
||||||
|
|
@ -185,9 +151,7 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
{account.get('note').length > 0 && (
|
||||||
<div
|
<div
|
||||||
className='account-card__bio translate'
|
className='account-card__bio translate animate-parent'
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
import { useEmojify } from './hooks';
|
import { useEmojify } from './hooks';
|
||||||
|
|
@ -7,28 +9,39 @@ import type { CustomEmojiMapArg } from './types';
|
||||||
|
|
||||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
ComponentPropsWithoutRef<Element>,
|
ComponentPropsWithoutRef<Element>,
|
||||||
'dangerouslySetInnerHTML'
|
'dangerouslySetInnerHTML' | 'className'
|
||||||
> & {
|
> & {
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
as?: Element;
|
||||||
|
shallow?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModernEmojiHTML = <Element extends ElementType>({
|
export const ModernEmojiHTML = ({
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
htmlString,
|
htmlString,
|
||||||
as: asElement, // Rename for syntax highlighting
|
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||||
|
shallow,
|
||||||
|
className = '',
|
||||||
...props
|
...props
|
||||||
}: EmojiHTMLProps<Element>) => {
|
}: EmojiHTMLProps<ElementType>) => {
|
||||||
const Wrapper = asElement ?? 'div';
|
const emojifiedHtml = useEmojify({
|
||||||
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
text: htmlString,
|
||||||
|
extraEmojis,
|
||||||
|
deep: !shallow,
|
||||||
|
});
|
||||||
|
|
||||||
if (emojifiedHtml === null) {
|
if (emojifiedHtml === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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()) {
|
if (isModernEmojiEnabled()) {
|
||||||
return <ModernEmojiHTML {...props} />;
|
return <ModernEmojiHTML {...props} />;
|
||||||
}
|
}
|
||||||
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
|
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||||
const Wrapper = asElement ?? 'div';
|
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 { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
|
import { emojifyElement, emojifyText } from './render';
|
||||||
import type {
|
import type {
|
||||||
CustomEmojiMapArg,
|
CustomEmojiMapArg,
|
||||||
EmojiAppState,
|
EmojiAppState,
|
||||||
|
|
@ -15,7 +16,17 @@ import type {
|
||||||
} from './types';
|
} from './types';
|
||||||
import { stringHasAnyEmoji } from './utils';
|
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 [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
const appState = useEmojiAppState();
|
const appState = useEmojiAppState();
|
||||||
|
|
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
|
||||||
const emojify = useCallback(
|
const emojify = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
|
let result: string | null = null;
|
||||||
|
if (deep) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = input;
|
wrapper.innerHTML = input;
|
||||||
const { emojifyElement } = await import('./render');
|
if (await emojifyElement(wrapper, appState, extra)) {
|
||||||
const result = await emojifyElement(wrapper, appState, extra);
|
result = wrapper.innerHTML;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await emojifyText(text, appState, extra);
|
||||||
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
setEmojifiedText(result.innerHTML);
|
setEmojifiedText(result);
|
||||||
} else {
|
} else {
|
||||||
setEmojifiedText(input);
|
setEmojifiedText(input);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState, extra],
|
[appState, deep, extra, text],
|
||||||
);
|
);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
|
|
||||||
export const AuthorLink = ({ accountId }) => {
|
export const AuthorLink = ({ accountId }) => {
|
||||||
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
|
||||||
|
|
@ -13,10 +12,9 @@ export const AuthorLink = ({ accountId }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<Avatar account={account} size={16} />
|
||||||
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
|
</LinkedDisplayName>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
render () {
|
||||||
const { announcement } = this.props;
|
const { announcement } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='announcements__item__content translate'
|
className='announcements__item__content translate animate-parent'
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
|
||||||
onMouseEnter={this.handleMouseEnter}
|
|
||||||
onMouseLeave={this.handleMouseLeave}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -238,9 +210,21 @@ class Reaction extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}>
|
<animated.button
|
||||||
<span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
|
className={classNames('reactions-bar__item', { active: reaction.get('me') })}
|
||||||
<span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
|
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>
|
</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';
|
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 { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||||
import { domain } from 'mastodon/initial_state';
|
import { domain } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
@ -56,9 +57,7 @@ const messages = defineMessages({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const Source: React.FC<{
|
const Source: React.FC<{ id: ApiSuggestionSourceJSON }> = ({ id }) => {
|
||||||
id: ApiSuggestionSourceJSON;
|
|
||||||
}> = ({ id }) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
let label, hint;
|
let label, hint;
|
||||||
|
|
@ -168,10 +167,11 @@ const Card: React.FC<{
|
||||||
|
|
||||||
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
const DISMISSIBLE_ID = 'home/follow-suggestions';
|
||||||
|
|
||||||
export const InlineFollowSuggestions: React.FC<{
|
export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||||
hidden?: boolean;
|
hidden,
|
||||||
}> = ({ hidden }) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const uniqueId = useId();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const suggestions = useAppSelector((state) => state.suggestions.items);
|
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||||
|
|
@ -257,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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'>
|
<div className='inline-follow-suggestions__header'>
|
||||||
<h3>
|
<h3 id={uniqueId}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='follow_suggestions.who_to_follow'
|
id='follow_suggestions.who_to_follow'
|
||||||
defaultMessage='Who to follow'
|
defaultMessage='Who to follow'
|
||||||
|
|
@ -288,13 +293,17 @@ export const InlineFollowSuggestions: React.FC<{
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{suggestions.map((suggestion) => (
|
{isLoading ? (
|
||||||
|
<LoadingIndicator />
|
||||||
|
) : (
|
||||||
|
suggestions.map((suggestion) => (
|
||||||
<Card
|
<Card
|
||||||
key={suggestion.account_id}
|
key={suggestion.account_id}
|
||||||
id={suggestion.account_id}
|
id={suggestion.account_id}
|
||||||
sources={suggestion.sources}
|
sources={suggestion.sources}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canScrollLeft && (
|
{canScrollLeft && (
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,10 @@ import classNames from 'classnames';
|
||||||
import { escapeRegExp } from 'lodash';
|
import { escapeRegExp } from 'lodash';
|
||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
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 { openModal, closeModal } from 'mastodon/actions/modal';
|
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||||
import { apiRequest } from 'mastodon/api';
|
import { apiRequest } from 'mastodon/api';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import {
|
import {
|
||||||
domain as localDomain,
|
domain as localDomain,
|
||||||
registrationsOpen,
|
registrationsOpen,
|
||||||
|
|
@ -408,18 +403,15 @@ const LoginForm: React.FC<{
|
||||||
const InteractionModal: React.FC<{
|
const InteractionModal: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
url: string;
|
url: string;
|
||||||
type: 'reply' | 'reblog' | 'favourite' | 'follow' | 'vote';
|
}> = ({ accountId, url }) => {
|
||||||
}> = ({ accountId, url, type }) => {
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const displayNameHtml = useAppSelector(
|
|
||||||
(state) => state.accounts.get(accountId)?.display_name_html ?? '',
|
|
||||||
);
|
|
||||||
const signupUrl = useAppSelector(
|
const signupUrl = useAppSelector(
|
||||||
(state) =>
|
(state) =>
|
||||||
(state.server.getIn(['server', 'registrations', 'url'], null) ||
|
(state.server.getIn(['server', 'registrations', 'url'], null) ||
|
||||||
'/auth/sign_up') as string,
|
'/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(() => {
|
const handleSignupClick = useCallback(() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
|
|
@ -437,93 +429,6 @@ const InteractionModal: React.FC<{
|
||||||
);
|
);
|
||||||
}, [dispatch]);
|
}, [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;
|
let signupButton;
|
||||||
|
|
||||||
if (sso_redirect) {
|
if (sso_redirect) {
|
||||||
|
|
@ -559,9 +464,18 @@ const InteractionModal: React.FC<{
|
||||||
<div className='modal-root__modal interaction-modal'>
|
<div className='modal-root__modal interaction-modal'>
|
||||||
<div className='interaction-modal__lead'>
|
<div className='interaction-modal__lead'>
|
||||||
<h3>
|
<h3>
|
||||||
<span className='interaction-modal__icon'>{icon}</span> {title}
|
<FormattedMessage
|
||||||
|
id='interaction_modal.title'
|
||||||
|
defaultMessage='Sign in to continue'
|
||||||
|
/>
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
|
|
||||||
<LoginForm resourceUrl={url} />
|
<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 InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import Column from 'mastodon/components/column';
|
import Column from 'mastodon/components/column';
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
|
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||||
|
|
@ -62,6 +63,12 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||||
<td><kbd>b</kbd></td>
|
<td><kbd>b</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{isFeatureEnabled('outgoing_quotes') && (
|
||||||
|
<tr>
|
||||||
|
<td><kbd>q</kbd></td>
|
||||||
|
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></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>
|
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<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>
|
<td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><kbd>l</kbd></td>
|
||||||
|
<td><FormattedMessage id='keyboard_shortcuts.load_more' defaultMessage='Focus "Load more" button' /></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>1</kbd>-<kbd>9</kbd></td>
|
<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>
|
<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 EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.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 HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||||
import PersonIcon from '@/material-icons/400-24px/person-fill.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 RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||||
import { Account } from 'mastodon/components/account';
|
import { Account } from 'mastodon/components/account';
|
||||||
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||||
|
|
@ -38,6 +39,7 @@ const messages = defineMessages({
|
||||||
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
|
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' },
|
||||||
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
|
||||||
update: { id: 'notification.update', defaultMessage: '{name} edited a post' },
|
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' },
|
adminSignUp: { id: 'notification.admin.sign_up', defaultMessage: '{name} signed up' },
|
||||||
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
|
||||||
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
|
||||||
|
|
@ -57,8 +59,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
notification: ImmutablePropTypes.map.isRequired,
|
notification: ImmutablePropTypes.map.isRequired,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
onMoveUp: PropTypes.func.isRequired,
|
|
||||||
onMoveDown: PropTypes.func.isRequired,
|
|
||||||
onMention: PropTypes.func.isRequired,
|
onMention: PropTypes.func.isRequired,
|
||||||
onFavourite: PropTypes.func.isRequired,
|
onFavourite: PropTypes.func.isRequired,
|
||||||
onReblog: PropTypes.func.isRequired,
|
onReblog: PropTypes.func.isRequired,
|
||||||
|
|
@ -73,16 +73,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
...WithRouterPropTypes,
|
...WithRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMoveUp = () => {
|
|
||||||
const { notification, onMoveUp } = this.props;
|
|
||||||
onMoveUp(notification.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMoveDown = () => {
|
|
||||||
const { notification, onMoveDown } = this.props;
|
|
||||||
onMoveDown(notification.get('id'));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOpen = () => {
|
handleOpen = () => {
|
||||||
const { notification } = this.props;
|
const { notification } = this.props;
|
||||||
|
|
||||||
|
|
@ -128,8 +118,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
mention: this.handleMention,
|
mention: this.handleMention,
|
||||||
open: this.handleOpen,
|
open: this.handleOpen,
|
||||||
openProfile: this.handleOpenProfile,
|
openProfile: this.handleOpenProfile,
|
||||||
moveUp: this.handleMoveUp,
|
|
||||||
moveDown: this.handleMoveDown,
|
|
||||||
toggleHidden: this.handleHotkeyToggleHidden,
|
toggleHidden: this.handleHotkeyToggleHidden,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -180,8 +168,6 @@ class Notification extends ImmutablePureComponent {
|
||||||
id={notification.get('status')}
|
id={notification.get('status')}
|
||||||
withDismiss
|
withDismiss
|
||||||
hidden={this.props.hidden}
|
hidden={this.props.hidden}
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
contextType='notifications'
|
contextType='notifications'
|
||||||
getScrollPosition={this.props.getScrollPosition}
|
getScrollPosition={this.props.getScrollPosition}
|
||||||
updateScrollBottom={this.props.updateScrollBottom}
|
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) {
|
renderPoll (notification, account) {
|
||||||
const { intl, unread, status } = this.props;
|
const { intl, unread, status } = this.props;
|
||||||
const ownPoll = me === account.get('id');
|
const ownPoll = me === account.get('id');
|
||||||
|
|
@ -465,8 +486,10 @@ class Notification extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetAccount = report.get('target_account');
|
const targetAccount = report.get('target_account');
|
||||||
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
|
const targetLink = <LinkedDisplayName
|
||||||
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>;
|
className='notification__display-name'
|
||||||
|
displayProps={{account:targetAccount, variant: 'simple'}}
|
||||||
|
/>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Hotkeys handlers={this.getHandlers()}>
|
<Hotkeys handlers={this.getHandlers()}>
|
||||||
|
|
@ -488,8 +511,7 @@ class Notification extends ImmutablePureComponent {
|
||||||
render () {
|
render () {
|
||||||
const { notification } = this.props;
|
const { notification } = this.props;
|
||||||
const account = notification.get('account');
|
const account = notification.get('account');
|
||||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
const link = <LinkedDisplayName className='notification__display-name' displayProps={{account, variant: 'simple'}} />;
|
||||||
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>;
|
|
||||||
|
|
||||||
switch(notification.get('type')) {
|
switch(notification.get('type')) {
|
||||||
case 'follow':
|
case 'follow':
|
||||||
|
|
@ -508,6 +530,8 @@ class Notification extends ImmutablePureComponent {
|
||||||
return this.renderStatus(notification, link);
|
return this.renderStatus(notification, link);
|
||||||
case 'update':
|
case 'update':
|
||||||
return this.renderUpdate(notification, link);
|
return this.renderUpdate(notification, link);
|
||||||
|
case 'quoted_update':
|
||||||
|
return this.renderQuotedUpdate(notification, link);
|
||||||
case 'poll':
|
case 'poll':
|
||||||
return this.renderPoll(notification, account);
|
return this.renderPoll(notification, account);
|
||||||
case 'severed_relationships':
|
case 'severed_relationships':
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/
|
||||||
import { initReport } from 'mastodon/actions/reports';
|
import { initReport } from 'mastodon/actions/reports';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { CheckBox } from 'mastodon/components/check_box';
|
import { CheckBox } from 'mastodon/components/check_box';
|
||||||
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
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'>
|
||||||
<div className='notification-request__name__display-name'>
|
<div className='notification-request__name__display-name'>
|
||||||
<bdi><strong dangerouslySetInnerHTML={{ __html: account?.get('display_name_html') }} /></bdi>
|
<DisplayName account={account} variant='simple' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span>@{account?.get('acct')}</span>
|
<span>@{account?.get('acct')}</span>
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='app-form__toggle__toggle'>
|
<div className='app-form__toggle__toggle'>
|
||||||
<div>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
|
@ -153,7 +152,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,21 +31,6 @@ const messages = defineMessages({
|
||||||
dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' },
|
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 } }) => {
|
export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
const columnRef = useRef();
|
const columnRef = useRef();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
dispatch(acceptNotificationRequest({ id }));
|
dispatch(acceptNotificationRequest({ id }));
|
||||||
}, [dispatch, 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(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchNotificationRequest({ id }));
|
dispatch(fetchNotificationRequest({ id }));
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|
@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => {
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
notification={item}
|
notification={item}
|
||||||
accountId={item.get('account')}
|
accountId={item.get('account')}
|
||||||
onMoveUp={handleMoveUp}
|
|
||||||
onMoveDown={handleMoveDown}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</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],
|
[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(() => {
|
const handleContentWarningClick = useCallback(() => {
|
||||||
dispatch(toggleStatusSpoilers(statusId));
|
dispatch(toggleStatusSpoilers(statusId));
|
||||||
}, [dispatch, statusId]);
|
}, [dispatch, statusId]);
|
||||||
|
|
@ -123,13 +97,11 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='notification-group__embedded-status'
|
className='notification-group__embedded-status animate-parent'
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
>
|
>
|
||||||
<div className='notification-group__embedded-status__account'>
|
<div className='notification-group__embedded-status__account'>
|
||||||
<Avatar account={account} size={16} />
|
<Avatar account={account} size={16} />
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
|
|
@ -42,11 +43,9 @@ export const NotificationAdminReport: React.FC<{
|
||||||
|
|
||||||
if (!account || !targetAccount) return null;
|
if (!account || !targetAccount) return null;
|
||||||
|
|
||||||
const domain = account.acct.split('@')[1];
|
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
name: <bdi>{domain ?? `@${account.acct}`}</bdi>,
|
name: <DisplayName account={account} variant='simple' />,
|
||||||
target: <bdi>@{targetAccount.acct}</bdi>,
|
target: <DisplayName account={targetAccount} variant='simple' />,
|
||||||
category: intl.formatMessage(messages[report.category]),
|
category: intl.formatMessage(messages[report.category]),
|
||||||
count: report.status_ids.length,
|
count: report.status_ids.length,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { NotificationMention } from './notification_mention';
|
||||||
import { NotificationModerationWarning } from './notification_moderation_warning';
|
import { NotificationModerationWarning } from './notification_moderation_warning';
|
||||||
import { NotificationPoll } from './notification_poll';
|
import { NotificationPoll } from './notification_poll';
|
||||||
import { NotificationQuote } from './notification_quote';
|
import { NotificationQuote } from './notification_quote';
|
||||||
|
import { NotificationQuotedUpdate } from './notification_quoted_update';
|
||||||
import { NotificationReblog } from './notification_reblog';
|
import { NotificationReblog } from './notification_reblog';
|
||||||
import { NotificationSeveredRelationships } from './notification_severed_relationships';
|
import { NotificationSeveredRelationships } from './notification_severed_relationships';
|
||||||
import { NotificationStatus } from './notification_status';
|
import { NotificationStatus } from './notification_status';
|
||||||
|
|
@ -24,9 +25,7 @@ import { NotificationUpdate } from './notification_update';
|
||||||
export const NotificationGroup: React.FC<{
|
export const NotificationGroup: React.FC<{
|
||||||
notificationGroupId: NotificationGroupModel['group_key'];
|
notificationGroupId: NotificationGroupModel['group_key'];
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
onMoveUp: (groupId: string) => void;
|
}> = ({ notificationGroupId, unread }) => {
|
||||||
onMoveDown: (groupId: string) => void;
|
|
||||||
}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => {
|
|
||||||
const notificationGroup = useAppSelector((state) =>
|
const notificationGroup = useAppSelector((state) =>
|
||||||
state.notificationGroups.groups.find(
|
state.notificationGroups.groups.find(
|
||||||
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
(item) => item.type !== 'gap' && item.group_key === notificationGroupId,
|
||||||
|
|
@ -42,14 +41,6 @@ export const NotificationGroup: React.FC<{
|
||||||
|
|
||||||
const handlers = useMemo(
|
const handlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
moveUp: () => {
|
|
||||||
onMoveUp(notificationGroupId);
|
|
||||||
},
|
|
||||||
|
|
||||||
moveDown: () => {
|
|
||||||
onMoveDown(notificationGroupId);
|
|
||||||
},
|
|
||||||
|
|
||||||
openProfile: () => {
|
openProfile: () => {
|
||||||
if (accountId) dispatch(navigateToProfile(accountId));
|
if (accountId) dispatch(navigateToProfile(accountId));
|
||||||
},
|
},
|
||||||
|
|
@ -58,7 +49,7 @@ export const NotificationGroup: React.FC<{
|
||||||
if (accountId) dispatch(mentionComposeById(accountId));
|
if (accountId) dispatch(mentionComposeById(accountId));
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown],
|
[dispatch, accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
if (!notificationGroup || notificationGroup.type === 'gap') return null;
|
||||||
|
|
@ -125,6 +116,14 @@ export const NotificationGroup: React.FC<{
|
||||||
<NotificationUpdate unread={unread} notification={notificationGroup} />
|
<NotificationUpdate unread={unread} notification={notificationGroup} />
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'quoted_update':
|
||||||
|
content = (
|
||||||
|
<NotificationQuotedUpdate
|
||||||
|
unread={unread}
|
||||||
|
notification={notificationGroup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
case 'admin.sign_up':
|
case 'admin.sign_up':
|
||||||
content = (
|
content = (
|
||||||
<NotificationAdminSignUp
|
<NotificationAdminSignUp
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { JSX } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
import { replyComposeById } from 'mastodon/actions/compose';
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
import { navigateToStatus } from 'mastodon/actions/statuses';
|
import { navigateToStatus } from 'mastodon/actions/statuses';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
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 { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { DisplayedName } from './displayed_name';
|
|
||||||
import { EmbeddedStatus } from './embedded_status';
|
import { EmbeddedStatus } from './embedded_status';
|
||||||
|
|
||||||
const AVATAR_SIZE = 28;
|
const AVATAR_SIZE = 28;
|
||||||
|
|
@ -61,15 +61,18 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||||
additionalContent,
|
additionalContent,
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
state.accounts.get(accountIds.at(0) ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
const label = useMemo(
|
const label = useMemo(
|
||||||
() =>
|
() =>
|
||||||
labelRenderer(
|
labelRenderer(
|
||||||
<DisplayedName accountIds={accountIds} />,
|
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />,
|
||||||
count,
|
count,
|
||||||
labelSeeMoreHref,
|
labelSeeMoreHref,
|
||||||
),
|
),
|
||||||
[labelRenderer, accountIds, count, labelSeeMoreHref],
|
[labelRenderer, account, count, labelSeeMoreHref],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPrivateMention = useAppSelector(
|
const isPrivateMention = useAppSelector(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { FormattedMessage } from 'react-intl';
|
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 { NotificationGroupQuote } from 'mastodon/models/notification_group';
|
||||||
|
|
||||||
import type { LabelRenderer } from './notification_group_with_status';
|
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 classNames from 'classnames';
|
||||||
|
|
||||||
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
import { replyComposeById } from 'mastodon/actions/compose';
|
import { replyComposeById } from 'mastodon/actions/compose';
|
||||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||||
import {
|
import {
|
||||||
|
|
@ -15,7 +16,6 @@ import { StatusQuoteManager } from 'mastodon/components/status_quoted';
|
||||||
import { getStatusHidden } from 'mastodon/selectors/filters';
|
import { getStatusHidden } from 'mastodon/selectors/filters';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { DisplayedName } from './displayed_name';
|
|
||||||
import type { LabelRenderer } from './notification_group_with_status';
|
import type { LabelRenderer } from './notification_group_with_status';
|
||||||
|
|
||||||
export const NotificationWithStatus: React.FC<{
|
export const NotificationWithStatus: React.FC<{
|
||||||
|
|
@ -39,9 +39,16 @@ export const NotificationWithStatus: React.FC<{
|
||||||
}) => {
|
}) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
state.accounts.get(accountIds.at(0) ?? ''),
|
||||||
|
);
|
||||||
const label = useMemo(
|
const label = useMemo(
|
||||||
() => labelRenderer(<DisplayedName accountIds={accountIds} />, count),
|
() =>
|
||||||
[labelRenderer, accountIds, count],
|
labelRenderer(
|
||||||
|
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />,
|
||||||
|
count,
|
||||||
|
),
|
||||||
|
[labelRenderer, account, count],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isPrivateMention = useAppSelector(
|
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