mirror of
https://github.com/mastodon/mastodon.git
synced 2026-03-03 05:51:02 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
fdd024630d
|
|
@ -9,7 +9,7 @@ RUN /bin/bash --login -i -c "nvm install"
|
|||
# Install additional OS packages
|
||||
RUN apt-get update && \
|
||||
export DEBIAN_FRONTEND=noninteractive && \
|
||||
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg imagemagick libvips42 libpam-dev
|
||||
apt-get -y install --no-install-recommends libicu-dev libidn11-dev ffmpeg libvips42 libpam-dev
|
||||
|
||||
# Disable download prompt for Corepack
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
|
|
|||
75
.github/workflows/bundlesize-compare.yml
vendored
Normal file
75
.github/workflows/bundlesize-compare.yml
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
name: Compare JS bundle size
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'app/javascript/**'
|
||||
- 'vite.config.mts'
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- .github/workflows/bundlesize-compare.yml
|
||||
|
||||
jobs:
|
||||
build-head:
|
||||
name: 'Build head'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ANALYZE_BUNDLE_SIZE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{github.event.pull_request.head.ref}}
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
- name: Build
|
||||
run: yarn run build:production
|
||||
|
||||
- name: Upload stats.json
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: head-stats
|
||||
path: ./stats.json
|
||||
if-no-files-found: error
|
||||
|
||||
build-base:
|
||||
name: 'Build base'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
ANALYZE_BUNDLE_SIZE: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
|
||||
- name: Set up Javascript environment
|
||||
uses: ./.github/actions/setup-javascript
|
||||
|
||||
- name: Build
|
||||
run: yarn run build:production
|
||||
|
||||
- name: Upload stats.json
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: base-stats
|
||||
path: ./stats.json
|
||||
if-no-files-found: error
|
||||
|
||||
compare:
|
||||
name: 'Compare base & head bundle sizes'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-base, build-head]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@v5
|
||||
|
||||
- uses: twk3/rollup-size-compare-action@v1.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
current-stats-json-path: ./head-stats/stats.json
|
||||
base-stats-json-path: ./base-stats/stats.json
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
|
|
@ -58,5 +58,5 @@ jobs:
|
|||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
zip: true
|
||||
storybookBuildDir: 'storybook-static'
|
||||
exitZeroOnChanges: false # Fail workflow if changes are found
|
||||
exitOnceUploaded: true # Exit immediately after upload
|
||||
autoAcceptChanges: 'main' # Auto-accept changes on main branch only
|
||||
|
|
|
|||
87
.github/workflows/test-ruby.yml
vendored
87
.github/workflows/test-ruby.yml
vendored
|
|
@ -173,93 +173,6 @@ jobs:
|
|||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
test-imagemagick:
|
||||
name: ImageMagick tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs:
|
||||
- build
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_USER: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10ms
|
||||
--health-timeout 3s
|
||||
--health-retries 50
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
env:
|
||||
DB_HOST: localhost
|
||||
DB_USER: postgres
|
||||
DB_PASS: postgres
|
||||
COVERAGE: ${{ matrix.ruby-version == '.ruby-version' }}
|
||||
RAILS_ENV: test
|
||||
ALLOW_NOPAM: true
|
||||
PAM_ENABLED: true
|
||||
PAM_DEFAULT_SERVICE: pam_test
|
||||
PAM_CONTROLLED_SERVICE: pam_test_controlled
|
||||
OIDC_ENABLED: true
|
||||
OIDC_SCOPE: read
|
||||
SAML_ENABLED: true
|
||||
CAS_ENABLED: true
|
||||
BUNDLE_WITH: 'pam_authentication test'
|
||||
GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }}
|
||||
MASTODON_USE_LIBVIPS: false
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ruby-version:
|
||||
- '3.2'
|
||||
- '3.3'
|
||||
- '.ruby-version'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
path: './'
|
||||
name: ${{ github.sha }}
|
||||
|
||||
- name: Expand archived asset artifacts
|
||||
run: |
|
||||
tar xvzf artifacts.tar.gz
|
||||
|
||||
- name: Set up Ruby environment
|
||||
uses: ./.github/actions/setup-ruby
|
||||
with:
|
||||
ruby-version: ${{ matrix.ruby-version}}
|
||||
additional-system-dependencies: ffmpeg imagemagick libpam-dev
|
||||
|
||||
- name: Load database schema
|
||||
run: './bin/rails db:create db:schema:load db:seed'
|
||||
|
||||
- run: bin/rspec --tag attachment_processing
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
files: coverage/lcov/mastodon.lcov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
test-e2e:
|
||||
name: End to End testing
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
8
.storybook/modes.ts
Normal file
8
.storybook/modes.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export const modes = {
|
||||
darkTheme: {
|
||||
theme: 'dark',
|
||||
},
|
||||
lightTheme: {
|
||||
theme: 'light',
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -1,2 +1,2 @@
|
|||
<html class="no-reduce-motion theme-light">
|
||||
<html class="no-reduce-motion" data-color-scheme="light">
|
||||
</html>
|
||||
|
|
@ -25,6 +25,7 @@ import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
|||
// you can change the below to `/application.scss`
|
||||
import '../app/javascript/styles/mastodon-light.scss';
|
||||
import './styles.css';
|
||||
import { modes } from './modes';
|
||||
|
||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||
query: { as: 'json' },
|
||||
|
|
@ -50,9 +51,19 @@ const preview: Preview = {
|
|||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
description: 'Theme for the story',
|
||||
toolbar: {
|
||||
title: 'Theme',
|
||||
icon: 'circlehollow',
|
||||
items: [{ value: 'light' }, { value: 'dark' }],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initialGlobals: {
|
||||
locale: 'en',
|
||||
theme: 'light',
|
||||
},
|
||||
decorators: [
|
||||
(Story, { parameters, globals, args, argTypes }) => {
|
||||
|
|
@ -135,6 +146,13 @@ const preview: Preview = {
|
|||
</IntlProvider>
|
||||
);
|
||||
},
|
||||
(Story, { globals }) => {
|
||||
const theme = (globals.theme as string) || 'light';
|
||||
useEffect(() => {
|
||||
document.body.setAttribute('data-color-scheme', theme);
|
||||
}, [theme]);
|
||||
return <Story />;
|
||||
},
|
||||
(Story) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
|
|
@ -181,6 +199,13 @@ const preview: Preview = {
|
|||
msw: {
|
||||
handlers: mockHandlers,
|
||||
},
|
||||
|
||||
chromatic: {
|
||||
modes: {
|
||||
dark: modes.darkTheme,
|
||||
light: modes.lightTheme,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
27
CHANGELOG.md
27
CHANGELOG.md
|
|
@ -2,6 +2,33 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.5] - 2026-01-20
|
||||
|
||||
### Security
|
||||
|
||||
- Fix missing limits on various federated properties [GHSA-gg8q-rcg7-p79g](https://github.com/mastodon/mastodon/security/advisories/GHSA-gg8q-rcg7-p79g)
|
||||
- Fix remote user suspension bypass [GHSA-5h2f-wg8j-xqwp](https://github.com/mastodon/mastodon/security/advisories/GHSA-5h2f-wg8j-xqwp)
|
||||
- Fix missing length limits on some user-provided fields [GHSA-6x3w-9g92-gvf3](https://github.com/mastodon/mastodon/security/advisories/GHSA-6x3w-9g92-gvf3)
|
||||
- Fix missing access check for push notification settings update [GHSA-f3q8-7vw3-69v4](https://github.com/mastodon/mastodon/security/advisories/GHSA-f3q8-7vw3-69v4)
|
||||
|
||||
### Changed
|
||||
|
||||
- Skip tombstone creation on deleting from 404 (#37533 by @ClearlyClaire)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix potential duplicate handling of quote accept/reject/delete (#37537 by @ClearlyClaire)
|
||||
- Fix `FeedManager#filter_from_home` error when handling a reblog of a deleted status (#37486 by @ClearlyClaire)
|
||||
- Fix needlessly complicated SQL query in status batch removal (#37469 by @ClearlyClaire)
|
||||
- Fix `quote_approval_policy` being reset to user defaults when omitted in status update (#37436 and #37474 by @mjankowski and @shleeable)
|
||||
- Fix `Vary` parsing in cache control enforcement (#37426 by @MegaManSec)
|
||||
- Fix missing URI scheme test in `QuoteRequest` handling (#37425 by @MegaManSec)
|
||||
- Fix thread-unsafe ActivityPub activity dispatch (#37423 by @MegaManSec)
|
||||
- Fix URI generation for reblogs by accounts with numerical ActivityPub identifiers (#37415 by @oneiros)
|
||||
- Fix SignatureParser accepting duplicate parameters in HTTP Signature header (#37375 by @shleeable)
|
||||
- Fix emoji with variant selector not being rendered properly (#37320 by @ChaosExAnima)
|
||||
- Fix mobile admin sidebar displaying under batch table toolbar (#37307 by @diondiondion)
|
||||
|
||||
## [4.5.4] - 2026-01-07
|
||||
|
||||
### Security
|
||||
|
|
|
|||
|
|
@ -70,8 +70,6 @@ ENV \
|
|||
PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \
|
||||
# Optimize jemalloc 5.x performance
|
||||
MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \
|
||||
# Enable libvips, should not be changed
|
||||
MASTODON_USE_LIBVIPS=true \
|
||||
# Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes
|
||||
MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs
|
||||
|
||||
|
|
@ -183,7 +181,7 @@ FROM build AS libvips
|
|||
|
||||
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
|
||||
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
|
||||
ARG VIPS_VERSION=8.17.3
|
||||
ARG VIPS_VERSION=8.18.0
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -48,3 +48,23 @@ Mastodon requires all `POST` requests to be signed, and MAY require `GET` reques
|
|||
### Additional documentation
|
||||
|
||||
- [Mastodon documentation](https://docs.joinmastodon.org/)
|
||||
|
||||
## Size limits
|
||||
|
||||
Mastodon imposes a few hard limits on federated content.
|
||||
These limits are intended to be very generous and way above what the Mastodon user experience is optimized for, so as to accommodate future changes and unusual or unforeseen usage patterns, while still providing some limits for performance reasons.
|
||||
The following table summarizes those limits.
|
||||
|
||||
| Limited property | Size limit | Consequence of exceeding the limit |
|
||||
| ------------------------------------------------------------- | ---------- | ---------------------------------- |
|
||||
| Serialized JSON-LD | 1MB | **Activity is rejected/dropped** |
|
||||
| Profile fields (actor `PropertyValue` attachments) name/value | 2047 | Field name/value is truncated |
|
||||
| Number of profile fields (actor `PropertyValue` attachments) | 50 | Fields list is truncated |
|
||||
| Poll options (number of `anyOf`/`oneOf` in a `Question`) | 500 | Items list is truncated |
|
||||
| Account username (actor `preferredUsername`) length | 2048 | **Actor will be rejected** |
|
||||
| Account display name (actor `name`) length | 2048 | Display name will be truncated |
|
||||
| Account note (actor `summary`) length | 20kB | Account note will be truncated |
|
||||
| Account `attributionDomains` | 256 | List will be truncated |
|
||||
| Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated |
|
||||
| Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected |
|
||||
| Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated |
|
||||
|
|
|
|||
14
Gemfile
14
Gemfile
|
|
@ -55,7 +55,7 @@ gem 'hiredis-client'
|
|||
gem 'htmlentities', '~> 4.3'
|
||||
gem 'http', '~> 5.3.0'
|
||||
gem 'http_accept_language', '~> 2.1'
|
||||
gem 'httplog', '~> 1.7.0', require: false
|
||||
gem 'httplog', '~> 1.8.0', require: false
|
||||
gem 'i18n'
|
||||
gem 'idn-ruby', require: 'idn'
|
||||
gem 'inline_svg'
|
||||
|
|
@ -109,12 +109,12 @@ group :opentelemetry do
|
|||
gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false
|
||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.30.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.26.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.34.0', require: false
|
||||
gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false
|
||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false
|
||||
gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false
|
||||
gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false
|
||||
gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false
|
||||
|
|
|
|||
85
Gemfile.lock
85
Gemfile.lock
|
|
@ -96,8 +96,8 @@ GEM
|
|||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1201.0)
|
||||
aws-sdk-core (3.241.3)
|
||||
aws-partitions (1.1206.0)
|
||||
aws-sdk-core (3.241.4)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
|
|
@ -105,11 +105,11 @@ GEM
|
|||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.120.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.211.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.3)
|
||||
aws-sdk-s3 (1.212.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
|
|
@ -193,9 +193,9 @@ GEM
|
|||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (6.2.0)
|
||||
devise-two-factor (6.3.1)
|
||||
activesupport (>= 7.0, < 8.2)
|
||||
devise (~> 4.0)
|
||||
devise (>= 4.0, < 5.0)
|
||||
railties (>= 7.0, < 8.2)
|
||||
rotp (~> 6.0)
|
||||
devise_pam_authenticatable2 (9.2.0)
|
||||
|
|
@ -234,7 +234,7 @@ GEM
|
|||
excon (1.3.2)
|
||||
logger
|
||||
fabrication (3.0.0)
|
||||
faker (3.5.3)
|
||||
faker (3.6.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
|
|
@ -282,7 +282,7 @@ GEM
|
|||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.22.0)
|
||||
google-protobuf (~> 4.26)
|
||||
haml (7.1.0)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
|
|
@ -291,7 +291,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.68.0)
|
||||
haml_lint (0.69.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
|
@ -305,8 +305,8 @@ GEM
|
|||
highline (3.1.2)
|
||||
reline
|
||||
hiredis (0.6.3)
|
||||
hiredis-client (0.26.3)
|
||||
redis-client (= 0.26.3)
|
||||
hiredis-client (0.26.4)
|
||||
redis-client (= 0.26.4)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
http (5.3.1)
|
||||
|
|
@ -320,7 +320,8 @@ GEM
|
|||
http_accept_language (2.1.1)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
httplog (1.7.3)
|
||||
httplog (1.8.0)
|
||||
benchmark
|
||||
rack (>= 2.0)
|
||||
rainbow (>= 2.0.0)
|
||||
i18n (1.14.8)
|
||||
|
|
@ -520,7 +521,8 @@ GEM
|
|||
opentelemetry-semantic_conventions
|
||||
opentelemetry-helpers-sql (0.3.0)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-helpers-sql-processor (0.3.1)
|
||||
opentelemetry-helpers-sql-processor (0.4.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-instrumentation-action_mailer (0.6.1)
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
|
|
@ -544,17 +546,17 @@ GEM
|
|||
opentelemetry-registry (~> 0.1)
|
||||
opentelemetry-instrumentation-concurrent_ruby (0.24.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-excon (0.26.1)
|
||||
opentelemetry-instrumentation-excon (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-faraday (0.30.1)
|
||||
opentelemetry-instrumentation-faraday (0.31.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http (0.27.1)
|
||||
opentelemetry-instrumentation-http (0.28.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-http_client (0.26.1)
|
||||
opentelemetry-instrumentation-http_client (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-net_http (0.26.1)
|
||||
opentelemetry-instrumentation-net_http (0.27.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-pg (0.34.1)
|
||||
opentelemetry-instrumentation-pg (0.35.0)
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-processor
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
|
|
@ -587,7 +589,7 @@ GEM
|
|||
ox (2.14.23)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.0)
|
||||
parser (3.3.10.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
|
|
@ -611,7 +613,7 @@ GEM
|
|||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.7.0)
|
||||
prism (1.8.0)
|
||||
prometheus_exporter (2.3.1)
|
||||
webrick
|
||||
propshaft (1.3.1)
|
||||
|
|
@ -698,7 +700,7 @@ GEM
|
|||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (7.0.3)
|
||||
rdoc (7.1.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
|
|
@ -706,7 +708,7 @@ GEM
|
|||
reline
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
redis-client (0.26.3)
|
||||
redis-client (0.26.4)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
|
|
@ -720,10 +722,10 @@ GEM
|
|||
rotp (6.3.0)
|
||||
rouge (4.7.0)
|
||||
rpam2 (4.0.2)
|
||||
rqrcode (3.1.1)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.1)
|
||||
rqrcode_core (2.1.0)
|
||||
rspec (3.13.2)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
|
|
@ -752,7 +754,7 @@ GEM
|
|||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.6)
|
||||
rubocop (1.81.7)
|
||||
rubocop (1.84.0)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -760,7 +762,7 @@ GEM
|
|||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
|
|
@ -782,7 +784,7 @@ GEM
|
|||
rack (>= 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rspec (3.8.0)
|
||||
rubocop-rspec (3.9.0)
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.81)
|
||||
rubocop-rspec_rails (2.32.0)
|
||||
|
|
@ -827,8 +829,9 @@ GEM
|
|||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 7.0.0, < 9.0.0)
|
||||
thor (>= 1.0, < 3.0)
|
||||
simple-navigation (4.4.0)
|
||||
simple-navigation (4.4.1)
|
||||
activesupport (>= 2.3.2)
|
||||
ostruct
|
||||
simple_form (5.4.1)
|
||||
actionpack (>= 7.0)
|
||||
activemodel (>= 7.0)
|
||||
|
|
@ -859,9 +862,9 @@ GEM
|
|||
unicode-display_width (>= 1.1.1, < 4)
|
||||
terrapin (1.1.1)
|
||||
climate_control
|
||||
test-prof (1.5.0)
|
||||
thor (1.4.0)
|
||||
tilt (2.6.1)
|
||||
test-prof (1.5.1)
|
||||
thor (1.5.0)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
|
|
@ -985,7 +988,7 @@ DEPENDENCIES
|
|||
htmlentities (~> 4.3)
|
||||
http (~> 5.3.0)
|
||||
http_accept_language (~> 2.1)
|
||||
httplog (~> 1.7.0)
|
||||
httplog (~> 1.8.0)
|
||||
i18n
|
||||
i18n-tasks (~> 1.0)
|
||||
idn-ruby
|
||||
|
|
@ -1021,12 +1024,12 @@ DEPENDENCIES
|
|||
opentelemetry-instrumentation-active_job (~> 0.10.0)
|
||||
opentelemetry-instrumentation-active_model_serializers (~> 0.24.0)
|
||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.26.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.30.0)
|
||||
opentelemetry-instrumentation-http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.26.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.26.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.34.0)
|
||||
opentelemetry-instrumentation-excon (~> 0.27.0)
|
||||
opentelemetry-instrumentation-faraday (~> 0.31.0)
|
||||
opentelemetry-instrumentation-http (~> 0.28.0)
|
||||
opentelemetry-instrumentation-http_client (~> 0.27.0)
|
||||
opentelemetry-instrumentation-net_http (~> 0.27.0)
|
||||
opentelemetry-instrumentation-pg (~> 0.35.0)
|
||||
opentelemetry-instrumentation-rack (~> 0.29.0)
|
||||
opentelemetry-instrumentation-rails (~> 0.39.0)
|
||||
opentelemetry-instrumentation-redis (~> 0.28.0)
|
||||
|
|
|
|||
|
|
@ -18,5 +18,4 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through
|
|||
| 4.5.x | Yes |
|
||||
| 4.4.x | Yes |
|
||||
| 4.3.x | Until 2026-05-06 |
|
||||
| 4.2.x | Until 2026-01-08 |
|
||||
| < 4.2 | No |
|
||||
| < 4.3 | No |
|
||||
|
|
|
|||
1
Vagrantfile
vendored
1
Vagrantfile
vendored
|
|
@ -29,7 +29,6 @@ sudo apt-get install \
|
|||
libpq-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
imagemagick \
|
||||
nodejs \
|
||||
redis-server \
|
||||
redis-tools \
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::CollectionsController < ActivityPub::BaseController
|
||||
SUPPORTED_COLLECTIONS = %w(featured tags).freeze
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FeaturedCollectionsController < ApplicationController
|
||||
include SignatureAuthentication
|
||||
include Authorization
|
||||
include AccountOwnedConcern
|
||||
|
||||
PER_PAGE = 5
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, if: -> { authorized_fetch_mode? }
|
||||
before_action :set_collections
|
||||
|
||||
skip_around_action :set_locale
|
||||
skip_before_action :require_functional!, unless: :limited_federation_mode?
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?)
|
||||
|
||||
render json: collection_presenter,
|
||||
serializer: ActivityPub::CollectionSerializer,
|
||||
adapter: ActivityPub::Adapter,
|
||||
content_type: 'application/activity+json'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collections
|
||||
authorize @account, :index_collections?
|
||||
@collections = @account.collections.page(params[:page]).per(PER_PAGE)
|
||||
rescue Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def page_requested?
|
||||
params[:page].present?
|
||||
end
|
||||
|
||||
def next_page_url
|
||||
ap_account_featured_collections_url(@account, page: @collections.next_page) if @collections.respond_to?(:next_page)
|
||||
end
|
||||
|
||||
def prev_page_url
|
||||
ap_account_featured_collections_url(@account, page: @collections.prev_page) if @collections.respond_to?(:prev_page)
|
||||
end
|
||||
|
||||
def collection_presenter
|
||||
if page_requested?
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: ap_account_featured_collections_url(@account, page: params.fetch(:page, 1)),
|
||||
type: :unordered,
|
||||
size: @account.collections.count,
|
||||
items: @collections,
|
||||
part_of: ap_account_featured_collections_url(@account),
|
||||
next: next_page_url,
|
||||
prev: prev_page_url
|
||||
)
|
||||
else
|
||||
ActivityPub::CollectionPresenter.new(
|
||||
id: ap_account_featured_collections_url(@account),
|
||||
type: :unordered,
|
||||
size: @account.collections.count,
|
||||
first: ap_account_featured_collections_url(@account, page: 1)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
class ActivityPub::InboxesController < ActivityPub::BaseController
|
||||
include JsonLdHelper
|
||||
|
||||
before_action :skip_large_payload
|
||||
before_action :skip_unknown_actor_activity
|
||||
before_action :require_actor_signature!
|
||||
skip_before_action :authenticate_user!
|
||||
|
|
@ -16,6 +17,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
|
|||
|
||||
private
|
||||
|
||||
def skip_large_payload
|
||||
head 413 if request.content_length > ActivityPub::Activity::MAX_JSON_SIZE
|
||||
end
|
||||
|
||||
def skip_unknown_actor_activity
|
||||
head 202 if unknown_affected_account?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ class Api::V1::Accounts::NotesController < Api::BaseController
|
|||
|
||||
def create
|
||||
if params[:comment].blank?
|
||||
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
|
||||
current_account.account_notes.find_by(target_account: @account)&.destroy
|
||||
else
|
||||
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
|
||||
@note = current_account.account_notes.find_or_initialize_by(target_account: @account)
|
||||
@note.comment = params[:comment]
|
||||
@note.save! if @note.changed?
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
|
|||
|
||||
@item = AddAccountToCollectionService.new.call(@collection, @account)
|
||||
|
||||
render json: @item, serializer: REST::CollectionItemSerializer
|
||||
render json: @item, serializer: REST::CollectionItemSerializer, adapter: :json
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
|
|||
|
|
@ -26,16 +26,18 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
|||
|
||||
def index
|
||||
cache_if_unauthenticated!
|
||||
authorize Collection, :index?
|
||||
authorize @account, :index_collections?
|
||||
|
||||
render json: @collections, each_serializer: REST::BaseCollectionSerializer
|
||||
render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json
|
||||
rescue Mastodon::NotPermittedError
|
||||
render json: { collections: [] }
|
||||
end
|
||||
|
||||
def show
|
||||
cache_if_unauthenticated!
|
||||
authorize @collection, :show?
|
||||
|
||||
render json: @collection, serializer: REST::CollectionSerializer
|
||||
render json: @collection, serializer: REST::CollectionWithAccountsSerializer
|
||||
end
|
||||
|
||||
def create
|
||||
|
|
@ -43,7 +45,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
|||
|
||||
@collection = CreateCollectionService.new.call(collection_creation_params, current_user.account)
|
||||
|
||||
render json: @collection, serializer: REST::CollectionSerializer
|
||||
render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
|
||||
end
|
||||
|
||||
def update
|
||||
|
|
@ -51,7 +53,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
|||
|
||||
@collection.update!(collection_update_params) # TODO: Create a service for this to federate changes
|
||||
|
||||
render json: @collection, serializer: REST::CollectionSerializer
|
||||
render json: @collection, serializer: REST::CollectionSerializer, adapter: :json
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
|
@ -74,6 +76,7 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
|||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
@collections = @collections.discoverable unless @account == current_account
|
||||
end
|
||||
|
||||
def set_collection
|
||||
|
|
@ -81,11 +84,11 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
|||
end
|
||||
|
||||
def collection_creation_params
|
||||
params.permit(:name, :description, :sensitive, :discoverable, :tag_name, account_ids: [])
|
||||
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name, account_ids: [])
|
||||
end
|
||||
|
||||
def collection_update_params
|
||||
params.permit(:name, :description, :sensitive, :discoverable, :tag_name)
|
||||
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name)
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
|
|||
end
|
||||
|
||||
def set_push_subscription
|
||||
@push_subscription = ::Web::PushSubscription.find(params[:id])
|
||||
@push_subscription = ::Web::PushSubscription.where(user_id: active_session.user_id).find(params[:id])
|
||||
end
|
||||
|
||||
def subscription_params
|
||||
|
|
|
|||
|
|
@ -130,14 +130,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
|||
end
|
||||
|
||||
def require_rules_acceptance!
|
||||
return if @rules.empty? || (session[:accept_token].present? && params[:accept] == session[:accept_token])
|
||||
return if @rules.empty? || validated_accept_token?
|
||||
|
||||
@accept_token = session[:accept_token] = SecureRandom.hex
|
||||
@invite_code = invite_code
|
||||
@invite_code = invite_code
|
||||
@rule_translations = @rules.map { |rule| rule.translation_for(I18n.locale) }
|
||||
|
||||
render :rules
|
||||
end
|
||||
|
||||
def validated_accept_token?
|
||||
session[:accept_token].present? && params[:accept] == session[:accept_token]
|
||||
end
|
||||
|
||||
def is_flashing_format? # rubocop:disable Naming/PredicatePrefix
|
||||
if params[:action] == 'create'
|
||||
false # Disable flash messages for sign-up
|
||||
|
|
|
|||
46
app/controllers/collections_controller.rb
Normal file
46
app/controllers/collections_controller.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CollectionsController < ApplicationController
|
||||
include WebAppControllerConcern
|
||||
include SignatureAuthentication
|
||||
include Authorization
|
||||
include AccountOwnedConcern
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_collection
|
||||
|
||||
skip_around_action :set_locale, if: -> { request.format == :json }
|
||||
skip_before_action :require_functional!, only: :show, unless: :limited_federation_mode?
|
||||
|
||||
def show
|
||||
respond_to do |format|
|
||||
# TODO: format.html
|
||||
|
||||
format.json do
|
||||
expires_in expiration_duration, public: true if public_fetch_mode?
|
||||
render_with_cache json: @collection, content_type: 'application/activity+json', serializer: ActivityPub::FeaturedCollectionSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection
|
||||
@collection = @account.collections.find(params[:id])
|
||||
authorize @collection, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def expiration_duration
|
||||
recently_updated = @collection.updated_at > 15.minutes.ago
|
||||
recently_updated ? 30.seconds : 5.minutes
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
|
|
@ -4,10 +4,8 @@ module AccountableConcern
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
def log_action(action, target)
|
||||
Admin::ActionLog.create(
|
||||
account: current_account,
|
||||
action: action,
|
||||
target: target
|
||||
)
|
||||
current_account
|
||||
.action_logs
|
||||
.create(action:, target:)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ class MediaProxyController < ApplicationController
|
|||
before_action :authenticate_user!, if: :limited_federation_mode?
|
||||
before_action :set_media_attachment
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :not_found
|
||||
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
|
||||
rescue_from Mastodon::NotPermittedError, with: :not_found
|
||||
rescue_from ActiveRecord::RecordInvalid, Mastodon::NotPermittedError, Mastodon::UnexpectedResponseError, with: :not_found
|
||||
rescue_from(*Mastodon::HTTP_CONNECTION_ERRORS, with: :internal_server_error)
|
||||
|
||||
def show
|
||||
|
|
|
|||
|
|
@ -20,14 +20,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController
|
|||
store_location_for(:user, request.url)
|
||||
end
|
||||
|
||||
def render_success
|
||||
if skip_authorization? || (matching_token? && !truthy_param?('force_login'))
|
||||
redirect_or_render authorize_response
|
||||
elsif Doorkeeper.configuration.api_only
|
||||
render json: pre_auth
|
||||
else
|
||||
render :new
|
||||
end
|
||||
def can_authorize_response?
|
||||
!truthy_param?('force_login') && super
|
||||
end
|
||||
|
||||
def truthy_param?(key)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class StatusesController < ApplicationController
|
|||
end
|
||||
|
||||
format.json do
|
||||
expires_in 3.minutes, public: true if @status.distributable? && public_fetch_mode?
|
||||
expires_in @status.quote&.pending? ? 5.seconds : 3.minutes, public: true if @status.distributable? && public_fetch_mode?
|
||||
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -89,6 +89,12 @@ module ApplicationHelper
|
|||
Rails.env.production? ? site_title : "#{site_title} (Dev)"
|
||||
end
|
||||
|
||||
def page_color_scheme
|
||||
return content_for(:force_color_scheme) if content_for(:force_color_scheme)
|
||||
|
||||
color_scheme
|
||||
end
|
||||
|
||||
def label_for_scope(scope)
|
||||
safe_join [
|
||||
tag.samp(scope, class: { 'scope-danger' => SessionActivation::DEFAULT_SCOPES.include?(scope.to_s) }),
|
||||
|
|
@ -153,6 +159,19 @@ module ApplicationHelper
|
|||
tag.meta(content: content, property: property)
|
||||
end
|
||||
|
||||
def html_attributes
|
||||
base = {
|
||||
lang: I18n.locale,
|
||||
class: html_classes,
|
||||
'data-contrast': contrast.parameterize,
|
||||
'data-color-scheme': page_color_scheme.parameterize,
|
||||
}
|
||||
|
||||
base[:'data-system-theme'] = 'true' if page_color_scheme == 'auto'
|
||||
|
||||
base
|
||||
end
|
||||
|
||||
def html_classes
|
||||
output = []
|
||||
output << content_for(:html_classes)
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ module LanguagesHelper
|
|||
'es-AR': 'Español (Argentina)',
|
||||
'es-MX': 'Español (México)',
|
||||
'fr-CA': 'Français (Canadien)',
|
||||
'nan-TW': '臺語 (Hô-ló話)',
|
||||
'pt-BR': 'Português (Brasil)',
|
||||
'pt-PT': 'Português (Portugal)',
|
||||
'sr-Latn': 'Srpski (latinica)',
|
||||
|
|
|
|||
|
|
@ -18,24 +18,23 @@ module ThemeHelper
|
|||
end
|
||||
|
||||
def theme_style_tags(theme)
|
||||
if theme == 'system'
|
||||
''.html_safe.tap do |tags|
|
||||
tags << vite_stylesheet_tag('themes/mastodon-light', type: :virtual, media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
tags << vite_stylesheet_tag('themes/default', type: :virtual, media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
|
||||
end
|
||||
else
|
||||
vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
|
||||
end
|
||||
# TODO: get rid of that when we retire the themes and perform the settings migration
|
||||
theme = 'default' if %w(mastodon-light contrast system).include?(theme)
|
||||
|
||||
vite_stylesheet_tag "themes/#{theme}", type: :virtual, media: 'all', crossorigin: 'anonymous'
|
||||
end
|
||||
|
||||
def theme_color_tags(theme)
|
||||
if theme == 'system'
|
||||
def theme_color_tags(color_scheme)
|
||||
case color_scheme
|
||||
when 'auto'
|
||||
''.html_safe.tap do |tags|
|
||||
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:dark], media: '(prefers-color-scheme: dark)')
|
||||
tags << tag.meta(name: 'theme-color', content: Themes::THEME_COLORS[:light], media: '(prefers-color-scheme: light)')
|
||||
end
|
||||
else
|
||||
tag.meta name: 'theme-color', content: theme_color_for(theme)
|
||||
when 'light'
|
||||
tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:light]
|
||||
when 'dark'
|
||||
tag.meta name: 'theme-color', content: Themes::THEME_COLORS[:dark]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -65,8 +64,4 @@ module ThemeHelper
|
|||
Setting.custom_css&.then { |content| Digest::SHA256.hexdigest(content) }
|
||||
end
|
||||
end
|
||||
|
||||
def theme_color_for(theme)
|
||||
theme == 'mastodon-light' ? Themes::THEME_COLORS[:light] : Themes::THEME_COLORS[:dark]
|
||||
end
|
||||
end
|
||||
|
|
|
|||
1
app/javascript/entrypoints/theme-selection.ts
Normal file
1
app/javascript/entrypoints/theme-selection.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '../inline/theme-selection';
|
||||
10
app/javascript/images/icons/icon_admin.svg
Normal file
10
app/javascript/images/icons/icon_admin.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="url(#badgeGradient)" d="M8 .666c.301 0 .595.094.839.268l.101.079.006.005c1.092.956 2.603 1.649 3.721 1.649A1.333 1.333 0 0 1 14 4v4.667c0 1.842-.652 3.259-1.702 4.336-1.032 1.058-2.417 1.76-3.852 2.26l-.005.002a1.334 1.334 0 0 1-.879-.01c-1.437-.496-2.825-1.195-3.858-2.253C2.654 11.926 2 10.509 2 8.667V4a1.334 1.334 0 0 1 1.334-1.333c1.118 0 2.634-.7 3.72-1.65l.007-.004C7.322.789 7.656.666 8 .666Z"/>
|
||||
<path stroke="#fff" fill="none" stroke-linecap="round" stroke-linejoin="round" d="M5 10.998A3.053 3.053 0 0 1 6.173 9.57a3.333 3.333 0 0 1 1.828-.54m0 0c.653 0 1.29.189 1.826.54.537.353.946.852 1.173 1.43M8 9.03c1.179 0 2.133-.903 2.133-2.015C10.133 5.902 9.178 5 8 5c-1.179 0-2.134.902-2.134 2.015 0 1.112.956 2.015 2.135 2.015Z"/>
|
||||
<defs>
|
||||
<linearGradient id="badgeGradient" x1=".5" x2="14" y1="2.5" y2="15" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E17100"/>
|
||||
<stop offset="1" stop-color="#F0B100"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
10
app/javascript/images/icons/icon_verified.svg
Normal file
10
app/javascript/images/icons/icon_verified.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||
<path fill="url(#VerifiedGradient)" d="M8 .837a3.168 3.168 0 0 1 2.47 1.187 3.166 3.166 0 0 1 2.601.906 3.168 3.168 0 0 1 .905 2.6A3.167 3.167 0 0 1 15.164 8a3.172 3.172 0 0 1-1.188 2.47 3.167 3.167 0 0 1-.903 2.597 3.168 3.168 0 0 1-2.596.909 3.167 3.167 0 0 1-4.95.001 3.166 3.166 0 0 1-3.397-2.258 3.169 3.169 0 0 1-.107-1.24A3.168 3.168 0 0 1 .826 8a3.17 3.17 0 0 1 1.197-2.479 3.168 3.168 0 0 1 .91-2.593 3.166 3.166 0 0 1 2.596-.905A3.169 3.169 0 0 1 8 .837Z"/>
|
||||
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="m6 8 1.333 1.333L10 6.667"/>
|
||||
<defs>
|
||||
<linearGradient id="VerifiedGradient" x1="-.966" x2="12.162" y1="2.629" y2="17.493" gradientUnits="userSpaceOnUse">
|
||||
<stop offset=".13" stop-color="#5638CC"/>
|
||||
<stop offset=".995" stop-color="#DC03F0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
|
|
@ -1,16 +1,17 @@
|
|||
(function (element) {
|
||||
const {userTheme} = element.dataset;
|
||||
const {colorScheme, contrast} = element.dataset;
|
||||
|
||||
const colorSchemeMediaWatcher = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMediaWatcher = window.matchMedia('(prefers-contrast: more)');
|
||||
|
||||
const updateColorScheme = () => {
|
||||
const useDarkMode = userTheme === 'system' ? colorSchemeMediaWatcher.matches : userTheme !== 'mastodon-light';
|
||||
element.dataset.mode = useDarkMode ? 'dark' : 'light';
|
||||
const useDarkMode = colorScheme === 'auto' ? colorSchemeMediaWatcher.matches : colorScheme === 'dark';
|
||||
|
||||
element.dataset.colorScheme = useDarkMode ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const updateContrast = () => {
|
||||
const useHighContrast = userTheme === 'contrast' || contrastMediaWatcher.matches;
|
||||
const useHighContrast = contrast === 'high' || contrastMediaWatcher.matches;
|
||||
|
||||
element.dataset.contrast = useHighContrast ? 'high' : 'default';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,17 @@ import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
|||
import { fetchRelationships } from './accounts';
|
||||
import { importFetchedAccounts } from './importer';
|
||||
|
||||
const DIRECTORY_FETCH_LIMIT = 20;
|
||||
|
||||
export const fetchDirectory = createDataLoadingThunk(
|
||||
'directory/fetch',
|
||||
async (params: Parameters<typeof apiGetDirectory>[0]) =>
|
||||
apiGetDirectory(params),
|
||||
apiGetDirectory(params, DIRECTORY_FETCH_LIMIT),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||
|
||||
return { accounts: data };
|
||||
return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT };
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -26,12 +28,15 @@ export const expandDirectory = createDataLoadingThunk(
|
|||
'items',
|
||||
]) as ImmutableList<unknown>;
|
||||
|
||||
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
|
||||
return apiGetDirectory(
|
||||
{ ...params, offset: loadedItems.size },
|
||||
DIRECTORY_FETCH_LIMIT,
|
||||
);
|
||||
},
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
dispatch(fetchRelationships(data.map((x) => x.id)));
|
||||
|
||||
return { accounts: data };
|
||||
return { accounts: data, isLast: data.length < DIRECTORY_FETCH_LIMIT };
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,15 @@ export const fetchServer = () => (dispatch, getState) => {
|
|||
|
||||
api()
|
||||
.get('/api/v2/instance').then(({ data }) => {
|
||||
if (data.contact.account) dispatch(importFetchedAccount(data.contact.account));
|
||||
// Only import the account if it doesn't already exist,
|
||||
// because the API is cached even for logged in users.
|
||||
const account = data.contact.account;
|
||||
if (account) {
|
||||
const existingAccount = getState().getIn(['accounts', account.id]);
|
||||
if (!existingAccount) {
|
||||
dispatch(importFetchedAccount(account));
|
||||
}
|
||||
}
|
||||
dispatch(fetchServerSuccess(data));
|
||||
}).catch(err => dispatch(fetchServerFail(err)));
|
||||
};
|
||||
|
|
|
|||
60
app/javascript/mastodon/actions/timelines.test.ts
Normal file
60
app/javascript/mastodon/actions/timelines.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { parseTimelineKey, timelineKey } from './timelines_typed';
|
||||
|
||||
describe('timelineKey', () => {
|
||||
test('returns expected key for account timeline with filters', () => {
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: '123',
|
||||
replies: true,
|
||||
boosts: false,
|
||||
media: true,
|
||||
});
|
||||
expect(key).toBe('account:123:0110');
|
||||
});
|
||||
|
||||
test('returns expected key for account timeline with tag', () => {
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: '456',
|
||||
tagged: 'nature',
|
||||
replies: true,
|
||||
});
|
||||
expect(key).toBe('account:456:0100:nature');
|
||||
});
|
||||
|
||||
test('returns expected key for account timeline with pins', () => {
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: '789',
|
||||
pinned: true,
|
||||
});
|
||||
expect(key).toBe('account:789:0001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimelineKey', () => {
|
||||
test('parses account timeline key with filters correctly', () => {
|
||||
const params = parseTimelineKey('account:123:1010');
|
||||
expect(params).toEqual({
|
||||
type: 'account',
|
||||
userId: '123',
|
||||
boosts: true,
|
||||
replies: false,
|
||||
media: true,
|
||||
pinned: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('parses account timeline key with tag correctly', () => {
|
||||
const params = parseTimelineKey('account:456:0100:nature');
|
||||
expect(params).toEqual({
|
||||
type: 'account',
|
||||
userId: '456',
|
||||
replies: true,
|
||||
boosts: false,
|
||||
media: false,
|
||||
pinned: false,
|
||||
tagged: 'nature',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,153 @@ import { createAction } from '@reduxjs/toolkit';
|
|||
|
||||
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
|
||||
|
||||
import { TIMELINE_NON_STATUS_MARKERS } from './timelines';
|
||||
import { createAppThunk } from '../store/typed_functions';
|
||||
|
||||
import { expandTimeline, TIMELINE_NON_STATUS_MARKERS } from './timelines';
|
||||
|
||||
export const expandTimelineByKey = createAppThunk(
|
||||
(args: { key: string; maxId?: number }, { dispatch }) => {
|
||||
const params = parseTimelineKey(args.key);
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
|
||||
void dispatch(expandTimelineByParams({ ...params, maxId: args.maxId }));
|
||||
},
|
||||
);
|
||||
|
||||
export const expandTimelineByParams = createAppThunk(
|
||||
(params: TimelineParams & { maxId?: number }, { dispatch }) => {
|
||||
let url = '';
|
||||
const extra: Record<string, string | boolean> = {};
|
||||
|
||||
if (params.type === 'account') {
|
||||
url = `/api/v1/accounts/${params.userId}/statuses`;
|
||||
|
||||
if (!params.replies) {
|
||||
extra.exclude_replies = true;
|
||||
}
|
||||
if (!params.boosts) {
|
||||
extra.exclude_reblogs = true;
|
||||
}
|
||||
if (params.pinned) {
|
||||
extra.pinned = true;
|
||||
}
|
||||
if (params.media) {
|
||||
extra.only_media = true;
|
||||
}
|
||||
if (params.tagged) {
|
||||
extra.tagged = params.tagged;
|
||||
}
|
||||
} else if (params.type === 'public') {
|
||||
url = '/api/v1/timelines/public';
|
||||
}
|
||||
|
||||
if (params.maxId) {
|
||||
extra.max_id = params.maxId.toString();
|
||||
}
|
||||
|
||||
return dispatch(expandTimeline(timelineKey(params), url, extra));
|
||||
},
|
||||
);
|
||||
|
||||
export interface AccountTimelineParams {
|
||||
type: 'account';
|
||||
userId: string;
|
||||
tagged?: string;
|
||||
media?: boolean;
|
||||
pinned?: boolean;
|
||||
boosts?: boolean;
|
||||
replies?: boolean;
|
||||
}
|
||||
export type PublicTimelineServer = 'local' | 'remote' | 'all';
|
||||
export interface PublicTimelineParams {
|
||||
type: 'public';
|
||||
tagged?: string;
|
||||
server?: PublicTimelineServer; // Defaults to 'all'
|
||||
media?: boolean;
|
||||
}
|
||||
export interface HomeTimelineParams {
|
||||
type: 'home';
|
||||
}
|
||||
export type TimelineParams =
|
||||
| AccountTimelineParams
|
||||
| PublicTimelineParams
|
||||
| HomeTimelineParams;
|
||||
|
||||
const ACCOUNT_FILTERS = ['boosts', 'replies', 'media', 'pinned'] as const;
|
||||
|
||||
export function timelineKey(params: TimelineParams): string {
|
||||
const { type } = params;
|
||||
const key: string[] = [type];
|
||||
|
||||
if (type === 'account') {
|
||||
key.push(params.userId);
|
||||
|
||||
const view = ACCOUNT_FILTERS.reduce(
|
||||
(prev, curr) => prev + (params[curr] ? '1' : '0'),
|
||||
'',
|
||||
);
|
||||
|
||||
key.push(view);
|
||||
} else if (type === 'public') {
|
||||
key.push(params.server ?? 'all');
|
||||
if (params.media) {
|
||||
key.push('media');
|
||||
}
|
||||
}
|
||||
|
||||
if (type !== 'home' && params.tagged) {
|
||||
key.push(params.tagged);
|
||||
}
|
||||
|
||||
return key.filter(Boolean).join(':');
|
||||
}
|
||||
|
||||
export function parseTimelineKey(key: string): TimelineParams | null {
|
||||
const segments = key.split(':');
|
||||
const type = segments[0];
|
||||
|
||||
if (type === 'account') {
|
||||
const userId = segments[1];
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed: TimelineParams = {
|
||||
type: 'account',
|
||||
userId,
|
||||
tagged: segments[3],
|
||||
};
|
||||
|
||||
const view = segments[2]?.split('') ?? [];
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
const flagName = ACCOUNT_FILTERS[i];
|
||||
if (flagName) {
|
||||
parsed[flagName] = view[i] === '1';
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (type === 'public') {
|
||||
return {
|
||||
type: 'public',
|
||||
server:
|
||||
segments[1] === 'remote' || segments[1] === 'local'
|
||||
? segments[1]
|
||||
: 'all',
|
||||
tagged: segments[2],
|
||||
media: segments[3] === 'media',
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'home') {
|
||||
return { type: 'home' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isNonStatusId(value: unknown) {
|
||||
return TIMELINE_NON_STATUS_MARKERS.includes(value as string | null);
|
||||
|
|
|
|||
|
|
@ -128,15 +128,18 @@ export default function api(withAuthorization = true) {
|
|||
}
|
||||
|
||||
type ApiUrl = `v${1 | '1_alpha' | 2}/${string}`;
|
||||
type RequestParamsOrData = Record<string, unknown>;
|
||||
type RequestParamsOrData<T = unknown> = T | Record<string, unknown>;
|
||||
|
||||
export async function apiRequest<ApiResponse = unknown>(
|
||||
export async function apiRequest<
|
||||
ApiResponse = unknown,
|
||||
ApiParamsOrData = unknown,
|
||||
>(
|
||||
method: Method,
|
||||
url: string,
|
||||
args: {
|
||||
signal?: AbortSignal;
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
params?: RequestParamsOrData<ApiParamsOrData>;
|
||||
data?: RequestParamsOrData<ApiParamsOrData>;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
|
|
@ -149,30 +152,30 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||
return data;
|
||||
}
|
||||
|
||||
export async function apiRequestGet<ApiResponse = unknown>(
|
||||
export async function apiRequestGet<ApiResponse = unknown, ApiParams = unknown>(
|
||||
url: ApiUrl,
|
||||
params?: RequestParamsOrData,
|
||||
params?: RequestParamsOrData<ApiParams>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('GET', url, { params });
|
||||
}
|
||||
|
||||
export async function apiRequestPost<ApiResponse = unknown>(
|
||||
export async function apiRequestPost<ApiResponse = unknown, ApiData = unknown>(
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData,
|
||||
data?: RequestParamsOrData<ApiData>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('POST', url, { data });
|
||||
}
|
||||
|
||||
export async function apiRequestPut<ApiResponse = unknown>(
|
||||
export async function apiRequestPut<ApiResponse = unknown, ApiData = unknown>(
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData,
|
||||
data?: RequestParamsOrData<ApiData>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('PUT', url, { data });
|
||||
}
|
||||
|
||||
export async function apiRequestDelete<ApiResponse = unknown>(
|
||||
url: ApiUrl,
|
||||
params?: RequestParamsOrData,
|
||||
) {
|
||||
export async function apiRequestDelete<
|
||||
ApiResponse = unknown,
|
||||
ApiParams = unknown,
|
||||
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
|
||||
return apiRequest<ApiResponse>('DELETE', url, { params });
|
||||
}
|
||||
|
|
|
|||
39
app/javascript/mastodon/api/collections.ts
Normal file
39
app/javascript/mastodon/api/collections.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import {
|
||||
apiRequestPost,
|
||||
apiRequestPut,
|
||||
apiRequestGet,
|
||||
apiRequestDelete,
|
||||
} from 'mastodon/api';
|
||||
|
||||
import type {
|
||||
ApiWrappedCollectionJSON,
|
||||
ApiCollectionWithAccountsJSON,
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
ApiCollectionsJSON,
|
||||
} from '../api_types/collections';
|
||||
|
||||
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
|
||||
apiRequestPost<ApiWrappedCollectionJSON>('v1_alpha/collections', collection);
|
||||
|
||||
export const apiUpdateCollection = ({
|
||||
id,
|
||||
...collection
|
||||
}: ApiUpdateCollectionPayload) =>
|
||||
apiRequestPut<ApiWrappedCollectionJSON>(
|
||||
`v1_alpha/collections/${id}`,
|
||||
collection,
|
||||
);
|
||||
|
||||
export const apiDeleteCollection = (collectionId: string) =>
|
||||
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
|
||||
|
||||
export const apiGetCollection = (collectionId: string) =>
|
||||
apiRequestGet<ApiCollectionWithAccountsJSON>(
|
||||
`v1_alpha/collections/${collectionId}`,
|
||||
);
|
||||
|
||||
export const apiGetAccountCollections = (accountId: string) =>
|
||||
apiRequestGet<ApiCollectionsJSON>(
|
||||
`v1_alpha/accounts/${accountId}/collections`,
|
||||
);
|
||||
82
app/javascript/mastodon/api_types/collections.ts
Normal file
82
app/javascript/mastodon/api_types/collections.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// See app/serializers/rest/base_collection_serializer.rb
|
||||
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiTagJSON } from './statuses';
|
||||
|
||||
/**
|
||||
* Returned when fetching all collections for an account,
|
||||
* doesn't contain account and item data
|
||||
*/
|
||||
export interface ApiCollectionJSON {
|
||||
account_id: string;
|
||||
|
||||
id: string;
|
||||
uri: string;
|
||||
local: boolean;
|
||||
item_count: number;
|
||||
|
||||
name: string;
|
||||
description: string;
|
||||
tag?: ApiTagJSON;
|
||||
language: string;
|
||||
sensitive: boolean;
|
||||
discoverable: boolean;
|
||||
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
items: CollectionAccountItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when fetching all collections for an account
|
||||
*/
|
||||
export interface ApiCollectionsJSON {
|
||||
collections: ApiCollectionJSON[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when creating, updating, and adding to a collection
|
||||
*/
|
||||
export interface ApiWrappedCollectionJSON {
|
||||
collection: ApiCollectionJSON;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned when fetching a single collection
|
||||
*/
|
||||
export interface ApiCollectionWithAccountsJSON extends ApiWrappedCollectionJSON {
|
||||
accounts: ApiAccountJSON[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nested account item
|
||||
*/
|
||||
interface CollectionAccountItem {
|
||||
account_id?: string; // Only present when state is 'accepted' (or the collection is your own)
|
||||
state: 'pending' | 'accepted' | 'rejected' | 'revoked';
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface WrappedCollectionAccountItem {
|
||||
collection_item: CollectionAccountItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload types
|
||||
*/
|
||||
|
||||
type CommonPayloadFields = Pick<
|
||||
ApiCollectionJSON,
|
||||
'name' | 'description' | 'sensitive' | 'discoverable'
|
||||
> & {
|
||||
tag_name?: string;
|
||||
};
|
||||
|
||||
export interface ApiUpdateCollectionPayload extends Partial<CommonPayloadFields> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ApiCreateCollectionPayload extends CommonPayloadFields {
|
||||
account_ids?: string[];
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
|
||||
|
||||
|
||||
export const Badge = ({ icon = <PersonIcon />, label, domain, roleId }) => (
|
||||
<div className='account-role' data-account-role-id={roleId}>
|
||||
{icon}
|
||||
{label}
|
||||
{domain && <span className='account-role__domain'>{domain}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
Badge.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
label: PropTypes.node,
|
||||
domain: PropTypes.node,
|
||||
roleId: PropTypes.string
|
||||
};
|
||||
|
||||
export const GroupBadge = () => (
|
||||
<Badge icon={<GroupsIcon />} label={<FormattedMessage id='account.badges.group' defaultMessage='Group' />} />
|
||||
);
|
||||
|
||||
export const AutomatedBadge = () => (
|
||||
<Badge icon={<SmartToyIcon />} label={<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />} />
|
||||
);
|
||||
46
app/javascript/mastodon/components/badge.tsx
Normal file
46
app/javascript/mastodon/components/badge.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
|
||||
|
||||
export const Badge: FC<{
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
domain?: ReactNode;
|
||||
roleId?: string;
|
||||
}> = ({ icon = <PersonIcon />, label, className, domain, roleId }) => (
|
||||
<div
|
||||
className={classNames('account-role', className)}
|
||||
data-account-role-id={roleId}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{domain && <span className='account-role__domain'>{domain}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const GroupBadge: FC<{ className?: string }> = ({ className }) => (
|
||||
<Badge
|
||||
icon={<GroupsIcon />}
|
||||
label={
|
||||
<FormattedMessage id='account.badges.group' defaultMessage='Group' />
|
||||
}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
export const AutomatedBadge: FC<{ className?: string }> = ({ className }) => (
|
||||
<Badge
|
||||
icon={<SmartToyIcon />}
|
||||
label={
|
||||
<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />
|
||||
}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { Callout } from '.';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Callout',
|
||||
args: {
|
||||
children: 'Contents here',
|
||||
title: 'Title',
|
||||
onPrimary: action('Primary action clicked'),
|
||||
primaryLabel: 'Primary',
|
||||
onSecondary: action('Secondary action clicked'),
|
||||
secondaryLabel: 'Secondary',
|
||||
onClose: action('Close clicked'),
|
||||
},
|
||||
component: Callout,
|
||||
render(args) {
|
||||
return (
|
||||
<div style={{ minWidth: 'min(400px, calc(100vw - 2rem))' }}>
|
||||
<Callout {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof Callout>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
variant: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
export const NoIcon: Story = {
|
||||
args: {
|
||||
icon: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoActions: Story = {
|
||||
args: {
|
||||
onPrimary: undefined,
|
||||
onSecondary: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlyText: Story = {
|
||||
args: {
|
||||
onClose: undefined,
|
||||
onPrimary: undefined,
|
||||
onSecondary: undefined,
|
||||
icon: false,
|
||||
},
|
||||
};
|
||||
|
||||
// export const Subtle: Story = {
|
||||
// args: {
|
||||
// variant: 'subtle',
|
||||
// },
|
||||
// };
|
||||
|
||||
export const Feature: Story = {
|
||||
args: {
|
||||
variant: 'feature',
|
||||
},
|
||||
};
|
||||
|
||||
export const Inverted: Story = {
|
||||
args: {
|
||||
variant: 'inverted',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
variant: 'error',
|
||||
},
|
||||
};
|
||||
27
app/javascript/mastodon/components/callout/dismissible.tsx
Normal file
27
app/javascript/mastodon/components/callout/dismissible.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { useDismissible } from '@/mastodon/hooks/useDismissible';
|
||||
|
||||
import { Callout } from '.';
|
||||
import type { CalloutProps } from '.';
|
||||
|
||||
type DismissibleCalloutProps = CalloutProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const DismissibleCallout: FC<DismissibleCalloutProps> = (props) => {
|
||||
const { dismiss, wasDismissed } = useDismissible(props.id);
|
||||
|
||||
const { onClose } = props;
|
||||
const handleClose = useCallback(() => {
|
||||
dismiss();
|
||||
onClose?.();
|
||||
}, [dismiss, onClose]);
|
||||
|
||||
if (wasDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Callout {...props} onClose={handleClose} />;
|
||||
};
|
||||
154
app/javascript/mastodon/components/callout/index.tsx
Normal file
154
app/javascript/mastodon/components/callout/index.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import ErrorIcon from '@/material-icons/400-24px/error.svg?react';
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
|
||||
import type { IconProp } from '../icon';
|
||||
import { Icon } from '../icon';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface CalloutProps {
|
||||
variant?:
|
||||
| 'default'
|
||||
// | 'subtle'
|
||||
| 'feature'
|
||||
| 'inverted'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'error';
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Set to false to hide the icon. */
|
||||
icon?: IconProp | boolean;
|
||||
onPrimary?: () => void;
|
||||
primaryLabel?: string;
|
||||
onSecondary?: () => void;
|
||||
secondaryLabel?: string;
|
||||
onClose?: () => void;
|
||||
id?: string;
|
||||
extraContent?: ReactNode;
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
default: classes.variantDefault as string,
|
||||
// subtle: classes.variantSubtle as string,
|
||||
feature: classes.variantFeature as string,
|
||||
inverted: classes.variantInverted as string,
|
||||
success: classes.variantSuccess as string,
|
||||
warning: classes.variantWarning as string,
|
||||
error: classes.variantError as string,
|
||||
} as const;
|
||||
|
||||
export const Callout: FC<CalloutProps> = ({
|
||||
className,
|
||||
variant = 'default',
|
||||
title,
|
||||
children,
|
||||
icon,
|
||||
onPrimary: primaryAction,
|
||||
primaryLabel,
|
||||
onSecondary: secondaryAction,
|
||||
secondaryLabel,
|
||||
onClose,
|
||||
extraContent,
|
||||
id,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={classNames(
|
||||
className,
|
||||
classes.wrapper,
|
||||
variantClasses[variant],
|
||||
)}
|
||||
data-variant={variant}
|
||||
id={id}
|
||||
>
|
||||
<CalloutIcon variant={variant} icon={icon} />
|
||||
<div className={classes.content}>
|
||||
<div className={classes.body}>
|
||||
{title && <h3>{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{(primaryAction ?? secondaryAction) && (
|
||||
<div className={classes.actionWrapper}>
|
||||
{secondaryAction && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={secondaryAction}
|
||||
className={classes.action}
|
||||
>
|
||||
{secondaryLabel ?? 'Click'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{primaryAction && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={primaryAction}
|
||||
className={classes.action}
|
||||
>
|
||||
{primaryLabel ?? 'Click'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{extraContent}
|
||||
|
||||
{onClose && (
|
||||
<IconButton
|
||||
icon='close'
|
||||
title={intl.formatMessage({
|
||||
id: 'callout.dismiss',
|
||||
defaultMessage: 'Dismiss',
|
||||
})}
|
||||
iconComponent={CloseIcon}
|
||||
className={classes.close}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
const CalloutIcon: FC<Pick<CalloutProps, 'variant' | 'icon'>> = ({
|
||||
variant = 'default',
|
||||
icon,
|
||||
}) => {
|
||||
if (icon === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!icon || icon === true) {
|
||||
switch (variant) {
|
||||
case 'inverted':
|
||||
case 'success':
|
||||
icon = CheckIcon;
|
||||
break;
|
||||
case 'warning':
|
||||
icon = WarningIcon;
|
||||
break;
|
||||
case 'error':
|
||||
icon = ErrorIcon;
|
||||
break;
|
||||
default:
|
||||
icon = InfoIcon;
|
||||
}
|
||||
}
|
||||
|
||||
return <Icon id={variant} icon={icon} className={classes.icon} />;
|
||||
};
|
||||
128
app/javascript/mastodon/components/callout/styles.module.css
Normal file
128
app/javascript/mastodon/components/callout/styles.module.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
.wrapper {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 4px;
|
||||
border-radius: 9999px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@media screen and (width >= 630px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
flex-grow: 1;
|
||||
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.actionWrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.action {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-text-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.close {
|
||||
color: inherit;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.variantDefault {
|
||||
.icon {
|
||||
background-color: var(--color-bg-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
/* .variantSubtle {
|
||||
border: 1px solid var(--color-bg-brand-softer);
|
||||
background-color: var(--color-bg-primary);
|
||||
|
||||
.icon {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
}
|
||||
} */
|
||||
|
||||
.variantFeature {
|
||||
background-color: var(--color-bg-brand-base);
|
||||
color: var(--color-text-on-brand-base);
|
||||
|
||||
button:hover {
|
||||
color: color-mix(var(--color-text-on-brand-base), transparent 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.variantInverted {
|
||||
background-color: var(--color-bg-inverted);
|
||||
color: var(--color-text-on-inverted);
|
||||
}
|
||||
|
||||
.variantSuccess {
|
||||
background-color: var(--color-bg-success-softer);
|
||||
|
||||
.icon {
|
||||
background-color: var(--color-bg-success-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.variantWarning {
|
||||
background-color: var(--color-bg-warning-softer);
|
||||
|
||||
.icon {
|
||||
background-color: var(--color-bg-warning-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.variantError {
|
||||
background-color: var(--color-bg-error-softer);
|
||||
|
||||
.icon {
|
||||
background-color: var(--color-bg-error-soft);
|
||||
}
|
||||
}
|
||||
4
app/javascript/mastodon/components/form_fields/index.ts
Normal file
4
app/javascript/mastodon/components/form_fields/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { TextInputField } from './text_input_field';
|
||||
export { TextAreaField } from './text_area_field';
|
||||
export { ToggleField, PlainToggleField } from './toggle_field';
|
||||
export { SelectField } from './select_field';
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { SelectField } from './select_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/SelectField',
|
||||
component: SelectField,
|
||||
args: {
|
||||
label: 'Fruit preference',
|
||||
hint: 'Select your favourite fruit or not. Up to you.',
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<SelectField {...args}>
|
||||
<option>Apple</option>
|
||||
<option>Banana</option>
|
||||
<option>Kiwi</option>
|
||||
<option>Lemon</option>
|
||||
<option>Mango</option>
|
||||
<option>Orange</option>
|
||||
<option>Pomelo</option>
|
||||
<option>Strawberries</option>
|
||||
<option>Something else</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof SelectField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Optional: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { FormFieldWrapper } from './wrapper';
|
||||
import type { CommonFieldWrapperProps } from './wrapper';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'select'>, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-item selections.
|
||||
* Provide selectable items via nested `<option>` elements.
|
||||
*
|
||||
* Accepts an optional `hint` and can be marked as required
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const SelectField = forwardRef<HTMLSelectElement, Props>(
|
||||
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<div className='select-wrapper'>
|
||||
<select {...otherProps} {...inputProps} ref={ref}>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
SelectField.displayName = 'SelectField';
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { TextAreaField } from './text_area_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/TextAreaField',
|
||||
component: TextAreaField,
|
||||
args: {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<TextAreaField {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof TextAreaField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Optional: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { FormFieldWrapper } from './wrapper';
|
||||
import type { CommonFieldWrapperProps } from './wrapper';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'textarea'>, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* A simple form field for multi-line text.
|
||||
*
|
||||
* Accepts an optional `hint` and can be marked as required
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const TextAreaField = forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <textarea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
TextAreaField.displayName = 'TextAreaField';
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { TextInputField } from './text_input_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/TextInputField',
|
||||
component: TextInputField,
|
||||
args: {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<TextInputField {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof TextInputField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Optional: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { FormFieldWrapper } from './wrapper';
|
||||
import type { CommonFieldWrapperProps } from './wrapper';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-line text.
|
||||
*
|
||||
* Accepts an optional `hint` and can be marked as required
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
(
|
||||
{ id, label, hint, hasError, required, type = 'text', ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<input type={type} {...otherProps} {...inputProps} ref={ref} />
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
TextInputField.displayName = 'TextInputField';
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
.input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
--diameter: 20px;
|
||||
--padding: 2px;
|
||||
--transition: 0.2s ease-in-out;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
width: calc(var(--diameter) * 2);
|
||||
background: var(--color-bg-tertiary);
|
||||
padding: var(--padding);
|
||||
transition: background var(--transition);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.toggle::before {
|
||||
content: '';
|
||||
height: var(--diameter);
|
||||
width: var(--diameter);
|
||||
border-radius: 9999px;
|
||||
background: var(--color-text-on-brand-base);
|
||||
box-shadow: 0 2px 4px 0 color-mix(var(--color-black), transparent 75%);
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toggle,
|
||||
.toggle::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input:checked + .toggle {
|
||||
background: var(--color-bg-brand-base);
|
||||
}
|
||||
|
||||
.input:checked:is(:hover, :focus) + .toggle {
|
||||
background: var(--color-bg-brand-base-hover);
|
||||
}
|
||||
|
||||
.input:focus-visible + .toggle {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.input:checked + .toggle::before {
|
||||
transform: translateX(calc(var(--diameter) - (var(--padding) * 2)));
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input:disabled + .toggle {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .input:checked + .toggle::before {
|
||||
transform: translateX(calc(-1 * (var(--diameter) - (var(--padding) * 2))));
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { PlainToggleField, ToggleField } from './toggle_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/ToggleField',
|
||||
component: ToggleField,
|
||||
args: {
|
||||
label: 'Label',
|
||||
hint: 'This is a description of this form field',
|
||||
disabled: false,
|
||||
size: 20,
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: { type: 'range', min: 10, max: 40, step: 1 },
|
||||
},
|
||||
},
|
||||
render(args) {
|
||||
// Component styles require a wrapper class at the moment
|
||||
return (
|
||||
<div className='simple_form'>
|
||||
<ToggleField {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof ToggleField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Optional: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
required: false,
|
||||
hasError: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
checked: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(props) {
|
||||
return <PlainToggleField {...props} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 12,
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 36,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './toggle.module.css';
|
||||
import type { CommonFieldWrapperProps } from './wrapper';
|
||||
import { FormFieldWrapper } from './wrapper';
|
||||
|
||||
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ToggleField = forwardRef<
|
||||
HTMLInputElement,
|
||||
Props & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<PlainToggleField {...otherProps} {...inputProps} ref={ref} />
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
));
|
||||
|
||||
ToggleField.displayName = 'ToggleField';
|
||||
|
||||
export const PlainToggleField = forwardRef<HTMLInputElement, Props>(
|
||||
({ className, size, ...otherProps }, ref) => (
|
||||
<span className={classes.wrapper}>
|
||||
<input
|
||||
{...otherProps}
|
||||
type='checkbox'
|
||||
className={classes.input}
|
||||
ref={ref}
|
||||
/>
|
||||
<span
|
||||
className={classNames(classes.toggle, className)}
|
||||
style={
|
||||
{ '--diameter': size ? `${size}px` : undefined } as CSSProperties
|
||||
}
|
||||
hidden
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
);
|
||||
PlainToggleField.displayName = 'PlainToggleField';
|
||||
100
app/javascript/mastodon/components/form_fields/wrapper.tsx
Normal file
100
app/javascript/mastodon/components/form_fields/wrapper.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
|
||||
import type { ReactNode, FC } from 'react';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface InputProps {
|
||||
id: string;
|
||||
required?: boolean;
|
||||
'aria-describedby'?: string;
|
||||
}
|
||||
|
||||
interface FieldWrapperProps {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
required?: boolean;
|
||||
hasError?: boolean;
|
||||
inputId?: string;
|
||||
children: (inputProps: InputProps) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* These types can be extended when creating individual field components.
|
||||
*/
|
||||
export type CommonFieldWrapperProps = Pick<
|
||||
FieldWrapperProps,
|
||||
'label' | 'hint' | 'hasError'
|
||||
>;
|
||||
|
||||
/**
|
||||
* A simple form field wrapper for adding a label and hint to enclosed components.
|
||||
* Accepts an optional `hint` and can be marked as required
|
||||
* or optional (by explicitly setting `required={false}`)
|
||||
*/
|
||||
|
||||
export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
||||
inputId: inputIdProp,
|
||||
label,
|
||||
hint,
|
||||
required,
|
||||
hasError,
|
||||
children,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const inputId = inputIdProp || `${uniqueId}-input`;
|
||||
const hintId = `${inputIdProp || uniqueId}-hint`;
|
||||
const hasHint = !!hint;
|
||||
|
||||
const inputProps: InputProps = {
|
||||
required,
|
||||
id: inputId,
|
||||
};
|
||||
if (hasHint) {
|
||||
inputProps['aria-describedby'] = hintId;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('input with_block_label', {
|
||||
field_with_errors: hasError,
|
||||
})}
|
||||
>
|
||||
<div className='label_input'>
|
||||
<label htmlFor={inputId}>
|
||||
{label}
|
||||
{required !== undefined && <RequiredMark required={required} />}
|
||||
</label>
|
||||
|
||||
{hasHint && (
|
||||
<span className='hint' id={hintId}>
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className='label_input__wrapper'>{children(inputProps)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* If `required` is explicitly set to `false` rather than `undefined`,
|
||||
* the field will be visually marked as "optional".
|
||||
*/
|
||||
|
||||
const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
|
||||
required ? (
|
||||
<>
|
||||
{' '}
|
||||
<abbr aria-hidden='true'>*</abbr>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
|
||||
</>
|
||||
);
|
||||
|
|
@ -13,6 +13,7 @@ interface Props extends React.SVGProps<SVGSVGElement> {
|
|||
children?: never;
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
noFill?: boolean;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<Props> = ({
|
||||
|
|
@ -20,6 +21,7 @@ export const Icon: React.FC<Props> = ({
|
|||
icon: IconComponent,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
noFill = false,
|
||||
...other
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
|
|
@ -42,7 +44,12 @@ export const Icon: React.FC<Props> = ({
|
|||
|
||||
return (
|
||||
<IconComponent
|
||||
className={classNames('icon', `icon-${id}`, className)}
|
||||
className={classNames(
|
||||
'icon',
|
||||
`icon-${id}`,
|
||||
noFill && 'icon--no-fill',
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
aria-hidden={ariaHidden}
|
||||
aria-label={ariaLabel}
|
||||
|
|
|
|||
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
33
app/javascript/mastodon/components/mini_card/index.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
export interface MiniCardProps {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const MiniCard: FC<MiniCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
hidden,
|
||||
}) => {
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(classes.card, className)}
|
||||
inert={hidden ? '' : undefined}
|
||||
>
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
69
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
69
app/javascript/mastodon/components/mini_card/list.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import type { FC, Key, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
|
||||
import { MiniCard } from '.';
|
||||
import type { MiniCardProps } from '.';
|
||||
import classes from './styles.module.css';
|
||||
|
||||
interface MiniCardListProps {
|
||||
cards?: (Pick<MiniCardProps, 'label' | 'value' | 'className'> & {
|
||||
key?: Key;
|
||||
})[];
|
||||
className?: string;
|
||||
onOverflowClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
export const MiniCardList: FC<MiniCardListProps> = ({
|
||||
cards = [],
|
||||
className,
|
||||
onOverflowClick,
|
||||
}) => {
|
||||
const {
|
||||
wrapperRef,
|
||||
listRef,
|
||||
hiddenCount,
|
||||
hasOverflow,
|
||||
hiddenIndex,
|
||||
maxWidth,
|
||||
} = useOverflow();
|
||||
|
||||
if (!cards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(classes.wrapper, className)} ref={wrapperRef}>
|
||||
<dl className={classes.list} ref={listRef} style={{ maxWidth }}>
|
||||
{cards.map((card, index) => (
|
||||
<MiniCard
|
||||
key={card.key ?? index}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
hidden={hasOverflow && index >= hiddenIndex}
|
||||
className={card.className}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
{cards.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(classes.more, !hasOverflow && classes.hidden)}
|
||||
onClick={onOverflowClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='minicard.more_items'
|
||||
defaultMessage='+{count}'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { MiniCardList } from './list';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/MiniCard',
|
||||
component: MiniCardList,
|
||||
args: {
|
||||
onOverflowClick: action('Overflow clicked'),
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
resize: 'horizontal',
|
||||
padding: '1rem',
|
||||
border: '1px solid gray',
|
||||
overflow: 'auto',
|
||||
width: '400px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<MiniCardList {...args} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<typeof MiniCardList>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
cards: [
|
||||
{ label: 'Pronouns', value: 'they/them' },
|
||||
{
|
||||
label: 'Website',
|
||||
value: <a href='https://example.com'>bowie-the-db.meow</a>,
|
||||
},
|
||||
{
|
||||
label: 'Free playlists',
|
||||
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
|
||||
},
|
||||
{ label: 'Location', value: 'Purris, France' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const LongValue: Story = {
|
||||
args: {
|
||||
cards: [
|
||||
{
|
||||
label: 'Username',
|
||||
value: 'bowie-the-dj',
|
||||
},
|
||||
{
|
||||
label: 'Bio',
|
||||
value:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const OneCard: Story = {
|
||||
args: {
|
||||
cards: [{ label: 'Pronouns', value: 'they/them' }],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
.wrapper {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card,
|
||||
.more {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
background: none;
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
transition: all 300ms linear;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label,
|
||||
.value {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent {
|
|||
'hidden',
|
||||
'unread',
|
||||
'pictureInPicture',
|
||||
'onQuoteCancel',
|
||||
];
|
||||
|
||||
state = {
|
||||
|
|
|
|||
50
app/javascript/mastodon/components/tags/style.module.css
Normal file
50
app/javascript/mastodon/components/tags/style.module.css
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
.tag {
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
appearance: none;
|
||||
background: none;
|
||||
padding: 6px 8px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
color: var(--color-text-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
button.tag:hover,
|
||||
button.tag:focus {
|
||||
border-color: var(--color-bg-brand-base-hover);
|
||||
}
|
||||
|
||||
button.tag:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--color-text-brand);
|
||||
background: var(--color-bg-brand-softer);
|
||||
color: var(--color-text-brand);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1.2em;
|
||||
width: 1.2em;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
svg {
|
||||
height: 1.2em;
|
||||
width: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.active .closeButton {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tagsWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
46
app/javascript/mastodon/components/tags/tag.stories.tsx
Normal file
46
app/javascript/mastodon/components/tags/tag.stories.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
|
||||
|
||||
import { EditableTag, Tag } from './tag';
|
||||
|
||||
const meta = {
|
||||
component: Tag,
|
||||
title: 'Components/Tags/Single Tag',
|
||||
args: {
|
||||
name: 'example-tag',
|
||||
active: false,
|
||||
onClick: action('Click'),
|
||||
},
|
||||
} satisfies Meta<typeof Tag>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
icon: MusicNoteIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const Editable: Story = {
|
||||
render(args) {
|
||||
return <EditableTag {...args} onRemove={action('Remove')} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const EditableWithIcon: Story = {
|
||||
render(args) {
|
||||
return (
|
||||
<EditableTag
|
||||
{...args}
|
||||
removeIcon={MusicNoteIcon}
|
||||
onRemove={action('Remove')}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
99
app/javascript/mastodon/components/tags/tag.tsx
Normal file
99
app/javascript/mastodon/components/tags/tag.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import type { IconProp } from '../icon';
|
||||
import { Icon } from '../icon';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import classes from './style.module.css';
|
||||
|
||||
export interface TagProps {
|
||||
name: ReactNode;
|
||||
active?: boolean;
|
||||
icon?: IconProp;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const Tag = forwardRef<
|
||||
HTMLButtonElement,
|
||||
OmitUnion<ComponentPropsWithoutRef<'button'>, TagProps>
|
||||
>(({ name, active, icon, className, children, ...props }, ref) => {
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type='button'
|
||||
ref={ref}
|
||||
className={classNames(className, classes.tag, active && classes.active)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||
{typeof name === 'string' ? `#${name}` : name}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
Tag.displayName = 'Tag';
|
||||
|
||||
export const EditableTag = forwardRef<
|
||||
HTMLSpanElement,
|
||||
OmitUnion<
|
||||
ComponentPropsWithoutRef<'span'>,
|
||||
TagProps & {
|
||||
onRemove: () => void;
|
||||
removeIcon?: IconProp;
|
||||
}
|
||||
>
|
||||
>(
|
||||
(
|
||||
{
|
||||
name,
|
||||
active,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
removeIcon = CloseIcon,
|
||||
onRemove,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(className, classes.tag, active && classes.active)}
|
||||
>
|
||||
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
|
||||
{typeof name === 'string' ? `#${name}` : name}
|
||||
{children}
|
||||
<IconButton
|
||||
className={classes.closeButton}
|
||||
iconComponent={removeIcon}
|
||||
onClick={onRemove}
|
||||
icon='remove'
|
||||
title={intl.formatMessage({
|
||||
id: 'tag.remove',
|
||||
defaultMessage: 'Remove',
|
||||
})}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
EditableTag.displayName = 'EditableTag';
|
||||
29
app/javascript/mastodon/components/tags/tags.stories.tsx
Normal file
29
app/javascript/mastodon/components/tags/tags.stories.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import { Tags } from './tags';
|
||||
|
||||
const meta = {
|
||||
component: Tags,
|
||||
title: 'Components/Tags/List',
|
||||
args: {
|
||||
tags: [{ name: 'tag-one' }, { name: 'tag-two' }],
|
||||
active: 'tag-one',
|
||||
},
|
||||
} satisfies Meta<typeof Tags>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render(args) {
|
||||
return <Tags {...args} />;
|
||||
},
|
||||
};
|
||||
|
||||
export const Editable: Story = {
|
||||
args: {
|
||||
onRemove: action('Remove'),
|
||||
},
|
||||
};
|
||||
64
app/javascript/mastodon/components/tags/tags.tsx
Normal file
64
app/javascript/mastodon/components/tags/tags.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { forwardRef, useCallback } from 'react';
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import classes from './style.module.css';
|
||||
import { EditableTag, Tag } from './tag';
|
||||
import type { TagProps } from './tag';
|
||||
|
||||
type Tag = TagProps & { name: string };
|
||||
|
||||
export type TagsProps = {
|
||||
tags: Tag[];
|
||||
active?: string;
|
||||
} & (
|
||||
| ({
|
||||
onRemove?: never;
|
||||
} & ComponentPropsWithoutRef<'button'>)
|
||||
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
|
||||
);
|
||||
|
||||
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
|
||||
({ tags, active, onRemove, className, ...props }, ref) => {
|
||||
if (onRemove) {
|
||||
return (
|
||||
<div className={classNames(classes.tagsWrapper, className)}>
|
||||
{tags.map((tag) => (
|
||||
<MappedTag
|
||||
key={tag.name}
|
||||
active={tag.name === active}
|
||||
onRemove={onRemove}
|
||||
{...tag}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
|
||||
{tags.map((tag) => (
|
||||
<Tag
|
||||
key={tag.name}
|
||||
active={tag.name === active}
|
||||
{...tag}
|
||||
{...props}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Tags.displayName = 'Tags';
|
||||
|
||||
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
|
||||
onRemove,
|
||||
...props
|
||||
}) => {
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove?.(props.name);
|
||||
}, [onRemove, props.name]);
|
||||
return <EditableTag {...props} onRemove={handleRemove} />;
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { useState, useRef, useCallback, useId } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
|
@ -15,7 +16,9 @@ export const DomainPill: React.FC<{
|
|||
domain: string;
|
||||
username: string;
|
||||
isSelf: boolean;
|
||||
}> = ({ domain, username, isSelf }) => {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}> = ({ domain, username, isSelf, children, className }) => {
|
||||
const accessibilityId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
|
@ -32,14 +35,16 @@ export const DomainPill: React.FC<{
|
|||
return (
|
||||
<>
|
||||
<button
|
||||
className={classNames('account__domain-pill', { active: open })}
|
||||
className={classNames('account__domain-pill', className, {
|
||||
active: open,
|
||||
})}
|
||||
ref={triggerRef}
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
aria-controls={accessibilityId}
|
||||
type='button'
|
||||
>
|
||||
{domain}
|
||||
{children ?? domain}
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function isRedesignEnabled() {
|
||||
return isClientFeatureEnabled('profile_redesign');
|
||||
}
|
||||
|
|
@ -1,177 +1,33 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
import {
|
||||
followAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
pinAccount,
|
||||
unpinAccount,
|
||||
removeAccountFromFollowers,
|
||||
} from 'mastodon/actions/accounts';
|
||||
import { initBlockModal } from 'mastodon/actions/blocks';
|
||||
import { mentionCompose, directCompose } from 'mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from 'mastodon/actions/domain_blocks';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||
import { initReport } from 'mastodon/actions/reports';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
|
||||
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from 'mastodon/components/counters';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { AccountNote } from 'mastodon/features/account/components/account_note';
|
||||
import { DomainPill } from 'mastodon/features/account/components/domain_pill';
|
||||
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_USERS,
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
} from 'mastodon/permissions';
|
||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import { AccountName } from './account_name';
|
||||
import { AccountBadges } from './badges';
|
||||
import { AccountButtons } from './buttons';
|
||||
import { FamiliarFollowers } from './familiar_followers';
|
||||
import { AccountHeaderFields } from './fields';
|
||||
import { AccountInfo } from './info';
|
||||
import { MemorialNote } from './memorial_note';
|
||||
import { MovedNote } from './moved_note';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
linkVerifiedOn: {
|
||||
id: 'account.link_verified_on',
|
||||
defaultMessage: 'Ownership of this link was checked on {date}',
|
||||
},
|
||||
account_locked: {
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
},
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
media: { id: 'account.media', defaultMessage: 'Media' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
preferences: {
|
||||
id: 'navigation_bar.preferences',
|
||||
defaultMessage: 'Preferences',
|
||||
},
|
||||
follow_requests: {
|
||||
id: 'navigation_bar.follow_requests',
|
||||
defaultMessage: 'Follow requests',
|
||||
},
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
|
||||
followed_tags: {
|
||||
id: 'navigation_bar.followed_tags',
|
||||
defaultMessage: 'Followed hashtags',
|
||||
},
|
||||
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
|
||||
domain_blocks: {
|
||||
id: 'navigation_bar.domain_blocks',
|
||||
defaultMessage: 'Blocked domains',
|
||||
},
|
||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
import { AccountNote as AccountNoteRedesign } from './note';
|
||||
import { AccountNumberFields } from './number_fields';
|
||||
import redesignClasses from './redesign.module.scss';
|
||||
import { AccountTabs } from './tabs';
|
||||
|
||||
const titleFromAccount = (account: Account) => {
|
||||
const displayName = account.display_name;
|
||||
|
|
@ -190,150 +46,12 @@ export const AccountHeader: React.FC<{
|
|||
hideTabs?: boolean;
|
||||
}> = ({ accountId, hideTabs }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
const account = useAppSelector((state) => state.accounts.get(accountId));
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
|
||||
const handleBlock = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
dispatch(unblockAccount(account.id));
|
||||
} else {
|
||||
dispatch(initBlockModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMention = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mentionCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleDirect = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(directCompose(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReport = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initReport(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleReblogToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.showing_reblogs) {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.notifying) {
|
||||
dispatch(followAccount(account.id, { notify: false }));
|
||||
} else {
|
||||
dispatch(followAccount(account.id, { notify: true }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
} else {
|
||||
dispatch(initMuteModal(account));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleBlockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(initDomainBlockModal(account));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleUnblockDomain = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = account.acct.split('@')[1];
|
||||
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(unblockDomain(domain));
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleEndorseToggle = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (relationship?.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
|
||||
const handleAddToList = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleChangeLanguages = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, account]);
|
||||
|
||||
const handleOpenAvatar = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||
|
|
@ -359,410 +77,12 @@ export const AccountHeader: React.FC<{
|
|||
[dispatch, account],
|
||||
);
|
||||
|
||||
const handleShare = useCallback(() => {
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.share({
|
||||
url: account.url,
|
||||
});
|
||||
}, [account]);
|
||||
|
||||
const suspended = account?.suspended;
|
||||
const isRemote = account?.acct !== account?.username;
|
||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMention,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleDirect,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (signedIn) {
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReblogToggle,
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: handleChangeLanguages,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: handleEndorseToggle,
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: handleAddToList,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(removeAccountFromFollowers({ accountId }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleMute,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleBlock,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleReport,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (signedIn && isRemote) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleUnblockDomain,
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: handleBlockDomain,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [
|
||||
dispatch,
|
||||
accountId,
|
||||
account,
|
||||
relationship,
|
||||
permissions,
|
||||
isRemote,
|
||||
remoteDomain,
|
||||
intl,
|
||||
signedIn,
|
||||
handleAddToList,
|
||||
handleBlock,
|
||||
handleBlockDomain,
|
||||
handleChangeLanguages,
|
||||
handleDirect,
|
||||
handleEndorseToggle,
|
||||
handleMention,
|
||||
handleMute,
|
||||
handleReblogToggle,
|
||||
handleReport,
|
||||
handleUnblockDomain,
|
||||
]);
|
||||
|
||||
const menu = accountId !== me && (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let actionBtn: React.ReactNode,
|
||||
bellBtn: React.ReactNode,
|
||||
lockedIcon: React.ReactNode,
|
||||
shareBtn: React.ReactNode;
|
||||
|
||||
const info: React.ReactNode[] = [];
|
||||
|
||||
if (me !== account.id && relationship) {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
info.push(
|
||||
<span key='mutual' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
info.push(
|
||||
<span key='followed_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.follows_you'
|
||||
defaultMessage='Follows you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
info.push(
|
||||
<span key='requested_by' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.blocking) {
|
||||
info.push(
|
||||
<span key='blocking' className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.muting) {
|
||||
info.push(
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (relationship.domain_blocking) {
|
||||
info.push(
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (relationship?.requested || relationship?.following) {
|
||||
bellBtn = (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if ('share' in navigator) {
|
||||
shareBtn = (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
shareBtn = (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
|
||||
if (!isMovedAndUnfollowedAccount) {
|
||||
actionBtn = (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (account.locked) {
|
||||
lockedIcon = (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage(messages.account_locked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = account.fields;
|
||||
const suspendedOrHidden = hidden || account.suspended;
|
||||
const isLocal = !account.acct.includes('@');
|
||||
const username = account.acct.split('@')[0];
|
||||
const domain = isLocal ? localDomain : account.acct.split('@')[1];
|
||||
const isIndexable = !account.noindex;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' />);
|
||||
}
|
||||
|
||||
account.roles.forEach((role) => {
|
||||
badges.push(
|
||||
<Badge
|
||||
key={`role-badge-${role.get('id')}`}
|
||||
label={<span>{role.get('name')}</span>}
|
||||
domain={domain}
|
||||
roleId={role.get('id')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='account-timeline__header'>
|
||||
|
|
@ -776,15 +96,16 @@ export const AccountHeader: React.FC<{
|
|||
inactive: !!account.moved,
|
||||
})}
|
||||
>
|
||||
{!(suspended || hidden || account.moved) &&
|
||||
relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
|
||||
<FollowRequestNoteContainer account={account} />
|
||||
)}
|
||||
|
||||
<div className='account__header__image'>
|
||||
<div className='account__header__info'>{info}</div>
|
||||
{me !== account.id && relationship && (
|
||||
<AccountInfo relationship={relationship} />
|
||||
)}
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<img
|
||||
src={autoPlayGif ? account.header : account.header_static}
|
||||
alt=''
|
||||
|
|
@ -803,148 +124,73 @@ export const AccountHeader: React.FC<{
|
|||
onClick={handleOpenAvatar}
|
||||
>
|
||||
<Avatar
|
||||
account={suspended || hidden ? undefined : account}
|
||||
account={suspendedOrHidden ? undefined : account}
|
||||
size={92}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--desktop'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{!hidden && shareBtn}
|
||||
{menu}
|
||||
</div>
|
||||
{!isRedesignEnabled() && (
|
||||
<AccountButtons
|
||||
accountId={accountId}
|
||||
className='account__header__buttons--desktop'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='account__header__tabs__name'>
|
||||
<h1>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
<small>
|
||||
<span>
|
||||
@{username}
|
||||
<span className='invisible'>@{domain}</span>
|
||||
</span>
|
||||
<DomainPill
|
||||
username={username ?? ''}
|
||||
domain={domain ?? ''}
|
||||
isSelf={me === account.id}
|
||||
/>
|
||||
{lockedIcon}
|
||||
</small>
|
||||
</h1>
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__tabs__name',
|
||||
isRedesignEnabled() && redesignClasses.nameWrapper,
|
||||
)}
|
||||
>
|
||||
<AccountName accountId={accountId} />
|
||||
{isRedesignEnabled() && <AccountButtons accountId={accountId} />}
|
||||
</div>
|
||||
|
||||
{badges.length > 0 && (
|
||||
<div className='account__header__badges'>{badges}</div>
|
||||
)}
|
||||
<AccountBadges accountId={accountId} />
|
||||
|
||||
{account.id !== me && signedIn && !(suspended || hidden) && (
|
||||
{me && account.id !== me && !suspendedOrHidden && (
|
||||
<FamiliarFollowers accountId={accountId} />
|
||||
)}
|
||||
|
||||
<div className='account__header__buttons account__header__buttons--mobile'>
|
||||
{!hidden && actionBtn}
|
||||
{!hidden && bellBtn}
|
||||
{menu}
|
||||
</div>
|
||||
<AccountButtons
|
||||
className='account__header__buttons--mobile'
|
||||
accountId={accountId}
|
||||
noShare
|
||||
/>
|
||||
|
||||
{!(suspended || hidden) && (
|
||||
{!suspendedOrHidden && (
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{account.id !== me && signedIn && (
|
||||
<AccountNote accountId={accountId} />
|
||||
)}
|
||||
{me &&
|
||||
account.id !== me &&
|
||||
(isRedesignEnabled() ? (
|
||||
<AccountNoteRedesign accountId={accountId} />
|
||||
) : (
|
||||
<AccountNote accountId={accountId} />
|
||||
))}
|
||||
|
||||
<AccountBio
|
||||
accountId={accountId}
|
||||
className='account__header__content'
|
||||
/>
|
||||
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
<dt>
|
||||
<FormattedMessage
|
||||
id='account.joined_short'
|
||||
defaultMessage='Joined'
|
||||
/>
|
||||
</dt>
|
||||
<dd>
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<AccountFields fields={fields} emojis={account.emojis} />
|
||||
</div>
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
</div>
|
||||
|
||||
<div className='account__header__extra__links'>
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
</div>
|
||||
<AccountNumberFields accountId={accountId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AnimateEmojiProvider>
|
||||
|
||||
{!(hideTabs || hidden) && (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${account.acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
|
||||
|
||||
<Helmet>
|
||||
<title>{titleFromAccount(account)}</title>
|
||||
<meta
|
||||
name='robots'
|
||||
content={isLocal && isIndexable ? 'all' : 'noindex'}
|
||||
content={isLocal && !account.noindex ? 'all' : 'noindex'}
|
||||
/>
|
||||
<link rel='canonical' href={account.url} />
|
||||
</Helmet>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
|
||||
import { DomainPill } from '../../account/components/domain_pill';
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
const localDomain = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [username = '', domain = localDomain] = account.acct.split('@');
|
||||
|
||||
if (!isRedesignEnabled()) {
|
||||
return (
|
||||
<h1>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
<small>
|
||||
<span>
|
||||
@{username}
|
||||
<span className='invisible'>@{domain}</span>
|
||||
</span>
|
||||
<DomainPill
|
||||
username={username}
|
||||
domain={domain}
|
||||
isSelf={me === account.id}
|
||||
/>
|
||||
{account.locked && (
|
||||
<Icon
|
||||
id='lock'
|
||||
icon={LockIcon}
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'account.locked_info',
|
||||
defaultMessage:
|
||||
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</small>
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.name}>
|
||||
<h1>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
</h1>
|
||||
<p className={classes.username}>
|
||||
@{username}@{domain}
|
||||
<DomainPill
|
||||
username={username}
|
||||
domain={domain}
|
||||
isSelf={me === account.id}
|
||||
className={classes.domainPill}
|
||||
>
|
||||
<Icon id='help' icon={HelpIcon} />
|
||||
</DomainPill>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import IconAdmin from '@/images/icons/icon_admin.svg?react';
|
||||
import { AutomatedBadge, Badge, GroupBadge } from '@/mastodon/components/badge';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { AccountRole } from '@/mastodon/models/account';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const account = useAccount(accountId);
|
||||
const localDomain = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
const badges = [];
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = isRedesignEnabled() ? classes.badge : '';
|
||||
|
||||
if (account.bot) {
|
||||
badges.push(<AutomatedBadge key='bot-badge' className={className} />);
|
||||
} else if (account.group) {
|
||||
badges.push(<GroupBadge key='group-badge' className={className} />);
|
||||
}
|
||||
|
||||
const domain = account.acct.includes('@')
|
||||
? account.acct.split('@')[1]
|
||||
: localDomain;
|
||||
account.roles.forEach((role) => {
|
||||
let icon: ReactNode = undefined;
|
||||
if (isAdminBadge(role)) {
|
||||
icon = (
|
||||
<Icon
|
||||
icon={IconAdmin}
|
||||
id='badge-admin'
|
||||
className={classes.badgeIcon}
|
||||
noFill
|
||||
/>
|
||||
);
|
||||
}
|
||||
badges.push(
|
||||
<Badge
|
||||
key={role.id}
|
||||
label={role.name}
|
||||
className={className}
|
||||
domain={isRedesignEnabled() ? `(${domain})` : domain}
|
||||
roleId={role.id}
|
||||
icon={icon}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
if (!badges.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={'account__header__badges'}>{badges}</div>;
|
||||
};
|
||||
|
||||
function isAdminBadge(role: AccountRole) {
|
||||
const name = role.name.toLowerCase();
|
||||
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { followAccount } from '@/mastodon/actions/accounts';
|
||||
import { CopyIconButton } from '@/mastodon/components/copy_icon_button';
|
||||
import { FollowButton } from '@/mastodon/components/follow_button';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { getAccountHidden } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
|
||||
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
|
||||
|
||||
import { AccountMenu } from './menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
enableNotifications: {
|
||||
id: 'account.enable_notifications',
|
||||
defaultMessage: 'Notify me when @{name} posts',
|
||||
},
|
||||
disableNotifications: {
|
||||
id: 'account.disable_notifications',
|
||||
defaultMessage: 'Stop notifying me when @{name} posts',
|
||||
},
|
||||
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
|
||||
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
|
||||
});
|
||||
|
||||
interface AccountButtonsProps {
|
||||
accountId: string;
|
||||
className?: string;
|
||||
noShare?: boolean;
|
||||
}
|
||||
|
||||
export const AccountButtons: FC<AccountButtonsProps> = ({
|
||||
accountId,
|
||||
className,
|
||||
noShare,
|
||||
}) => {
|
||||
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
|
||||
return (
|
||||
<div className={classNames('account__header__buttons', className)}>
|
||||
{!hidden && (
|
||||
<AccountButtonsOther accountId={accountId} noShare={noShare} />
|
||||
)}
|
||||
{accountId !== me && <AccountMenu accountId={accountId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountButtonsOther: FC<
|
||||
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
|
||||
> = ({ accountId, noShare }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleNotifyToggle = useCallback(() => {
|
||||
if (account) {
|
||||
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
|
||||
}
|
||||
}, [dispatch, account, relationship]);
|
||||
const accountUrl = account?.url;
|
||||
const handleShare = useCallback(() => {
|
||||
if (accountUrl) {
|
||||
void navigator.share({
|
||||
url: accountUrl,
|
||||
});
|
||||
}
|
||||
}, [accountUrl]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||
const isFollowing = relationship?.requested || relationship?.following;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isMovedAndUnfollowedAccount && (
|
||||
<FollowButton
|
||||
accountId={accountId}
|
||||
className='account__header__follow-button'
|
||||
labelLength='long'
|
||||
/>
|
||||
)}
|
||||
{isFollowing && (
|
||||
<IconButton
|
||||
icon={relationship.notifying ? 'bell' : 'bell-o'}
|
||||
iconComponent={
|
||||
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
|
||||
}
|
||||
active={relationship.notifying}
|
||||
title={intl.formatMessage(
|
||||
relationship.notifying
|
||||
? messages.disableNotifications
|
||||
: messages.enableNotifications,
|
||||
{ name: account.username },
|
||||
)}
|
||||
onClick={handleNotifyToggle}
|
||||
/>
|
||||
)}
|
||||
{!noShare &&
|
||||
('share' in navigator ? (
|
||||
<IconButton
|
||||
className='optional'
|
||||
icon=''
|
||||
iconComponent={ShareIcon}
|
||||
title={intl.formatMessage(messages.share, {
|
||||
name: account.username,
|
||||
})}
|
||||
onClick={handleShare}
|
||||
/>
|
||||
) : (
|
||||
<CopyIconButton
|
||||
className='optional'
|
||||
title={intl.formatMessage(messages.copy)}
|
||||
value={account.url}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { MiniCardList } from '@/mastodon/components/mini_card/list';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountHeaderFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
const account = useAccount(accountId);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
return <RedesignAccountHeaderFields account={account} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__fields'>
|
||||
<dl>
|
||||
<dt>
|
||||
<FormattedMessage id='account.joined_short' defaultMessage='Joined' />
|
||||
</dt>
|
||||
<dd>
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<AccountFields fields={account.fields} emojis={account.emojis} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
account.fields
|
||||
.toArray()
|
||||
.map(({ value_emojified, name_emojified, verified_at }) => ({
|
||||
label: (
|
||||
<>
|
||||
<EmojiHTML
|
||||
htmlString={name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
as='span'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
{!!verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldIconVerified}
|
||||
noFill
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
value: (
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
),
|
||||
className: classNames(
|
||||
classes.fieldCard,
|
||||
!!verified_at && classes.fieldCardVerified,
|
||||
),
|
||||
})),
|
||||
[account.emojis, account.fields, htmlHandlers],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleOverflowClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_FIELDS',
|
||||
modalProps: { accountId: account.id },
|
||||
}),
|
||||
);
|
||||
}, [account.id, dispatch]);
|
||||
|
||||
return (
|
||||
<MiniCardList
|
||||
cards={cards}
|
||||
className={classes.fieldList}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountFieldsModal: FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const htmlHandlers = useElementHandledLink();
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal dialog-modal'>
|
||||
<div className='dialog-modal__header'>
|
||||
<IconButton
|
||||
icon='close'
|
||||
className={classes.modalCloseButton}
|
||||
onClick={onClose}
|
||||
iconComponent={CloseIcon}
|
||||
title={intl.formatMessage({
|
||||
id: 'account_fields_modal.close',
|
||||
defaultMessage: 'Close',
|
||||
})}
|
||||
/>
|
||||
<span className={`${classes.modalTitle} dialog-modal__header__title`}>
|
||||
<FormattedMessage
|
||||
id='account_fields_modal.title'
|
||||
defaultMessage="{name}'s info"
|
||||
values={{
|
||||
name: <DisplayName account={account} variant='simple' />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className='dialog-modal__content'>
|
||||
<AnimateEmojiProvider>
|
||||
<dl className={classes.modalFieldsList}>
|
||||
{account.fields.map((field, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`${classes.modalFieldItem} ${classes.fieldCard}`}
|
||||
>
|
||||
<EmojiHTML
|
||||
as='dt'
|
||||
htmlString={field.name_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
<dd>
|
||||
<EmojiHTML
|
||||
as='span'
|
||||
htmlString={field.value_emojified}
|
||||
extraEmojis={account.emojis}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
{!!field.verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldIconVerified}
|
||||
noFill
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</AnimateEmojiProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
|
||||
export const AccountInfo: FC<{ relationship?: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (!relationship) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className='account__header__info'>
|
||||
{(relationship.followed_by || relationship.requested_by) && (
|
||||
<span className='relationship-tag'>
|
||||
<AccountInfoFollower relationship={relationship} />
|
||||
</span>
|
||||
)}
|
||||
{relationship.blocking && (
|
||||
<span className='relationship-tag'>
|
||||
<FormattedMessage id='account.blocking' defaultMessage='Blocking' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.muting && (
|
||||
<span key='muting' className='relationship-tag'>
|
||||
<FormattedMessage id='account.muting' defaultMessage='Muting' />
|
||||
</span>
|
||||
)}
|
||||
{relationship.domain_blocking && (
|
||||
<span key='domain_blocking' className='relationship-tag'>
|
||||
<FormattedMessage
|
||||
id='account.domain_blocking'
|
||||
defaultMessage='Blocking domain'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountInfoFollower: FC<{ relationship: Relationship }> = ({
|
||||
relationship,
|
||||
}) => {
|
||||
if (
|
||||
relationship.followed_by &&
|
||||
(relationship.following || relationship.requested)
|
||||
) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.mutual'
|
||||
defaultMessage='You follow each other'
|
||||
/>
|
||||
);
|
||||
} else if (relationship.followed_by) {
|
||||
return (
|
||||
<FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
|
||||
);
|
||||
} else if (relationship.requested_by) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.requests_to_follow_you'
|
||||
defaultMessage='Requests to follow you'
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
import { useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import {
|
||||
blockAccount,
|
||||
followAccount,
|
||||
pinAccount,
|
||||
unblockAccount,
|
||||
unmuteAccount,
|
||||
unpinAccount,
|
||||
} from '@/mastodon/actions/accounts';
|
||||
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
|
||||
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
|
||||
import {
|
||||
initDomainBlockModal,
|
||||
unblockDomain,
|
||||
} from '@/mastodon/actions/domain_blocks';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { initMuteModal } from '@/mastodon/actions/mutes';
|
||||
import { initReport } from '@/mastodon/actions/reports';
|
||||
import { Dropdown } from '@/mastodon/components/dropdown_menu';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
|
||||
import {
|
||||
PERMISSION_MANAGE_FEDERATION,
|
||||
PERMISSION_MANAGE_USERS,
|
||||
} from '@/mastodon/permissions';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
const messages = defineMessages({
|
||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
|
||||
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
|
||||
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
|
||||
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
|
||||
blockDomain: {
|
||||
id: 'account.block_domain',
|
||||
defaultMessage: 'Block domain {domain}',
|
||||
},
|
||||
unblockDomain: {
|
||||
id: 'account.unblock_domain',
|
||||
defaultMessage: 'Unblock domain {domain}',
|
||||
},
|
||||
hideReblogs: {
|
||||
id: 'account.hide_reblogs',
|
||||
defaultMessage: 'Hide boosts from @{name}',
|
||||
},
|
||||
showReblogs: {
|
||||
id: 'account.show_reblogs',
|
||||
defaultMessage: 'Show boosts from @{name}',
|
||||
},
|
||||
addNote: {
|
||||
id: 'account.add_note',
|
||||
defaultMessage: 'Add a personal note',
|
||||
},
|
||||
editNote: {
|
||||
id: 'account.edit_note',
|
||||
defaultMessage: 'Edit personal note',
|
||||
},
|
||||
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
|
||||
unendorse: {
|
||||
id: 'account.unendorse',
|
||||
defaultMessage: "Don't feature on profile",
|
||||
},
|
||||
add_or_remove_from_list: {
|
||||
id: 'account.add_or_remove_from_list',
|
||||
defaultMessage: 'Add or Remove from lists',
|
||||
},
|
||||
admin_account: {
|
||||
id: 'status.admin_account',
|
||||
defaultMessage: 'Open moderation interface for @{name}',
|
||||
},
|
||||
admin_domain: {
|
||||
id: 'status.admin_domain',
|
||||
defaultMessage: 'Open moderation interface for {domain}',
|
||||
},
|
||||
languages: {
|
||||
id: 'account.languages',
|
||||
defaultMessage: 'Change subscribed languages',
|
||||
},
|
||||
openOriginalPage: {
|
||||
id: 'account.open_original_page',
|
||||
defaultMessage: 'Open original page',
|
||||
},
|
||||
removeFromFollowers: {
|
||||
id: 'account.remove_from_followers',
|
||||
defaultMessage: 'Remove {name} from followers',
|
||||
},
|
||||
confirmRemoveFromFollowersTitle: {
|
||||
id: 'confirmations.remove_from_followers.title',
|
||||
defaultMessage: 'Remove follower?',
|
||||
},
|
||||
confirmRemoveFromFollowersMessage: {
|
||||
id: 'confirmations.remove_from_followers.message',
|
||||
defaultMessage:
|
||||
'{name} will stop following you. Are you sure you want to proceed?',
|
||||
},
|
||||
confirmRemoveFromFollowersButton: {
|
||||
id: 'confirmations.remove_from_followers.confirm',
|
||||
defaultMessage: 'Remove follower',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn, permissions } = useIdentity();
|
||||
|
||||
const account = useAccount(accountId);
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const menuItems = useMemo(() => {
|
||||
const arr: MenuItem[] = [];
|
||||
|
||||
if (!account) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const isRemote = account.acct !== account.username;
|
||||
|
||||
if (signedIn && !account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mention, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(mentionCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.direct, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(directCompose(account));
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (isRemote) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.openOriginalPage),
|
||||
href: account.url,
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (!signedIn) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
if (!relationship.muting) {
|
||||
if (relationship.showing_reblogs) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.hideReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: false }));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.showReblogs, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(followAccount(account.id, { reblogs: true }));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.languages),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'SUBSCRIBED_LANGUAGES',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRedesignEnabled()) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship?.note ? messages.editNote : messages.addNote,
|
||||
),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_NOTE',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
if (!relationship?.following) {
|
||||
arr.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (relationship?.following) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(
|
||||
relationship.endorsed ? messages.unendorse : messages.endorse,
|
||||
),
|
||||
action: () => {
|
||||
if (relationship.endorsed) {
|
||||
dispatch(unpinAccount(account.id));
|
||||
} else {
|
||||
dispatch(pinAccount(account.id));
|
||||
}
|
||||
},
|
||||
});
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.add_or_remove_from_list),
|
||||
action: () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'LIST_ADDER',
|
||||
modalProps: {
|
||||
accountId: account.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
arr.push(null);
|
||||
}
|
||||
|
||||
if (relationship?.followed_by) {
|
||||
const handleRemoveFromFollowers = () => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersTitle,
|
||||
),
|
||||
message: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersMessage,
|
||||
{ name: <strong>{account.acct}</strong> },
|
||||
),
|
||||
confirm: intl.formatMessage(
|
||||
messages.confirmRemoveFromFollowersButton,
|
||||
),
|
||||
onConfirm: () => {
|
||||
void dispatch(
|
||||
removeAccountFromFollowers({ accountId: account.id }),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.removeFromFollowers, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: handleRemoveFromFollowers,
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.muting) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unmute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unmuteAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.mute, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initMuteModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationship?.blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblock, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockAccount(account.id));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.block, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(blockAccount(account.id));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account.suspended) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.report, {
|
||||
name: account.username,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initReport(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
|
||||
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
|
||||
if (remoteDomain) {
|
||||
arr.push(null);
|
||||
|
||||
if (relationship?.domain_blocking) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.unblockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(unblockDomain(remoteDomain));
|
||||
},
|
||||
});
|
||||
} else {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.blockDomain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
action: () => {
|
||||
dispatch(initDomainBlockModal(account));
|
||||
},
|
||||
dangerous: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
|
||||
(isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION)
|
||||
) {
|
||||
arr.push(null);
|
||||
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_account, {
|
||||
name: account.username,
|
||||
}),
|
||||
href: `/admin/accounts/${account.id}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
isRemote &&
|
||||
(permissions & PERMISSION_MANAGE_FEDERATION) ===
|
||||
PERMISSION_MANAGE_FEDERATION
|
||||
) {
|
||||
arr.push({
|
||||
text: intl.formatMessage(messages.admin_domain, {
|
||||
domain: remoteDomain,
|
||||
}),
|
||||
href: `/admin/instances/${remoteDomain}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}, [account, signedIn, permissions, intl, relationship, dispatch]);
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={menuItems.length === 0}
|
||||
items={menuItems}
|
||||
icon='ellipsis-v'
|
||||
iconComponent={MoreHorizIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchRelationships } from '@/mastodon/actions/accounts';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'account.note.title',
|
||||
defaultMessage: 'Personal note (visible only to you)',
|
||||
},
|
||||
editButton: {
|
||||
id: 'account.note.edit_button',
|
||||
defaultMessage: 'Edit',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!relationship) {
|
||||
dispatch(fetchRelationships([accountId]));
|
||||
}
|
||||
}, [accountId, dispatch, relationship]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_NOTE',
|
||||
modalProps: { accountId },
|
||||
}),
|
||||
);
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
if (!relationship?.note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Callout
|
||||
icon={false}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
className={classes.note}
|
||||
extraContent={
|
||||
<IconButton
|
||||
icon='edit'
|
||||
iconComponent={EditIcon}
|
||||
title={intl.formatMessage(messages.editButton)}
|
||||
className={classes.noteEditButton}
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{relationship.note}
|
||||
</Callout>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
FollowersCounter,
|
||||
FollowingCounter,
|
||||
StatusesCounter,
|
||||
} from '@/mastodon/components/counters';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { ShortNumber } from '@/mastodon/components/short_number';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'account__header__extra__links',
|
||||
isRedesignEnabled() && classes.fieldNumbersWrapper,
|
||||
)}
|
||||
>
|
||||
{!isRedesignEnabled() && (
|
||||
<NavLink
|
||||
to={`/@${account.acct}`}
|
||||
title={intl.formatNumber(account.statuses_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.statuses_count}
|
||||
renderer={StatusesCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/following`}
|
||||
title={intl.formatNumber(account.following_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.following_count}
|
||||
renderer={FollowingCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
exact
|
||||
to={`/@${account.acct}/followers`}
|
||||
title={intl.formatNumber(account.followers_count)}
|
||||
>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</NavLink>
|
||||
|
||||
{isRedesignEnabled() && (
|
||||
<NavLink exact to={`/@${account.acct}`}>
|
||||
<FormattedMessage
|
||||
id='account.joined_long'
|
||||
defaultMessage='Joined on {date}'
|
||||
values={{
|
||||
date: (
|
||||
<strong>
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
</strong>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
.nameWrapper {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-grow: 1;
|
||||
font-size: 22px;
|
||||
white-space: initial;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
align-items: center;
|
||||
user-select: all;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.domainPill {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: initial;
|
||||
margin-left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: color 0.2s ease-in-out;
|
||||
|
||||
> svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:global(.active) {
|
||||
background: none;
|
||||
color: var(--color-text-brand-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
|
||||
> span {
|
||||
font-weight: unset;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg.badgeIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.noteEditButton {
|
||||
color: inherit;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldList {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.fieldCard {
|
||||
position: relative;
|
||||
|
||||
a {
|
||||
color: var(--color-text-brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldCardVerified {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
|
||||
dt {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.fieldNumbersWrapper {
|
||||
a {
|
||||
font-weight: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.modalCloseButton {
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modalFieldsList {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modalFieldItem {
|
||||
&:not(:first-child) {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: '';
|
||||
display: block;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
dd {
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fieldIconVerified {
|
||||
vertical-align: middle;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
padding: 18px 4px;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
:global(.active) {
|
||||
color: var(--color-text-brand);
|
||||
border-bottom: 4px solid var(--color-text-brand);
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
if (isRedesignEnabled()) {
|
||||
return (
|
||||
<div className={classes.tabs}>
|
||||
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='account__section-headline'>
|
||||
<NavLink exact to={`/@${acct}/featured`}>
|
||||
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/with_replies`}>
|
||||
<FormattedMessage
|
||||
id='account.posts_with_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
<FormattedMessage id='account.media' defaultMessage='Media' />
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
|
||||
match?.url === location.pathname ||
|
||||
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { useSearchParam } from '@/mastodon/hooks/useSearchParam';
|
||||
|
||||
export function useFilters() {
|
||||
const [boosts, setBoosts] = useSearchParam('boosts');
|
||||
const [replies, setReplies] = useSearchParam('replies');
|
||||
|
||||
const handleSetBoosts = useCallback(
|
||||
(value: boolean) => {
|
||||
setBoosts(value ? '1' : null);
|
||||
},
|
||||
[setBoosts],
|
||||
);
|
||||
const handleSetReplies = useCallback(
|
||||
(value: boolean) => {
|
||||
setReplies(value ? '1' : null);
|
||||
},
|
||||
[setReplies],
|
||||
);
|
||||
|
||||
return {
|
||||
boosts: boosts === '1',
|
||||
replies: replies === '1',
|
||||
setBoosts: handleSetBoosts,
|
||||
setReplies: handleSetReplies,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.noteCallout {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.noteInput {
|
||||
min-height: 70px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
appearance: none;
|
||||
resize: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.noteInput:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { submitAccountNote } from '@/mastodon/actions/account_notes';
|
||||
import { fetchRelationships } from '@/mastodon/actions/accounts';
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { TextAreaField } from '@/mastodon/components/form_fields';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import type { Relationship } from '@/mastodon/models/relationship';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
|
||||
|
||||
import classes from './modals.module.css';
|
||||
|
||||
const messages = defineMessages({
|
||||
newTitle: {
|
||||
id: 'account.node_modal.title',
|
||||
defaultMessage: 'Add a personal note',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account.node_modal.edit_title',
|
||||
defaultMessage: 'Edit personal note',
|
||||
},
|
||||
save: {
|
||||
id: 'account.node_modal.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
fieldLabel: {
|
||||
id: 'account.node_modal.field_label',
|
||||
defaultMessage: 'Personal Note',
|
||||
},
|
||||
errorUnknown: {
|
||||
id: 'account.node_modal.error_unknown',
|
||||
defaultMessage: 'Could not save the note',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountNoteModal: FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const relationship = useAppSelector((state) =>
|
||||
state.relationships.get(accountId),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!relationship) {
|
||||
dispatch(fetchRelationships([accountId]));
|
||||
}
|
||||
}, [accountId, dispatch, relationship]);
|
||||
|
||||
if (!relationship) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InnerNodeModal
|
||||
relationship={relationship}
|
||||
accountId={accountId}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InnerNodeModal: FC<{
|
||||
relationship: Relationship;
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ relationship, accountId, onClose }) => {
|
||||
// Set up the state.
|
||||
const initialContents = relationship.note;
|
||||
const [note, setNote] = useState(initialContents);
|
||||
const [errorText, setErrorText] = useState('');
|
||||
const [state, setState] = useState<'idle' | 'saving' | 'error'>('idle');
|
||||
const isDirty = note !== initialContents;
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(e) => {
|
||||
if (state !== 'saving') {
|
||||
setNote(e.target.value);
|
||||
}
|
||||
},
|
||||
[state],
|
||||
);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// Create an abort controller to cancel the request if the modal is closed.
|
||||
const abortController = useRef(new AbortController());
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSave = useCallback(() => {
|
||||
if (state === 'saving' || !isDirty) {
|
||||
return;
|
||||
}
|
||||
setState('saving');
|
||||
dispatch(
|
||||
submitAccountNote(
|
||||
{ accountId, note },
|
||||
{ signal: abortController.current.signal },
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
setState('idle');
|
||||
onClose();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setState('error');
|
||||
if (err instanceof Error) {
|
||||
setErrorText(err.message);
|
||||
} else {
|
||||
setErrorText(intl.formatMessage(messages.errorUnknown));
|
||||
}
|
||||
});
|
||||
}, [accountId, dispatch, intl, isDirty, note, onClose, state]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
abortController.current.abort();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={
|
||||
initialContents
|
||||
? intl.formatMessage(messages.editTitle)
|
||||
: intl.formatMessage(messages.newTitle)
|
||||
}
|
||||
extraContent={
|
||||
<>
|
||||
<Callout className={classes.noteCallout}>
|
||||
<FormattedMessage
|
||||
id='account.node_modal.callout'
|
||||
defaultMessage='Personal notes are visible only to you.'
|
||||
/>
|
||||
</Callout>
|
||||
<TextAreaField
|
||||
value={note}
|
||||
onChange={handleChange}
|
||||
label={intl.formatMessage(messages.fieldLabel)}
|
||||
className={classes.noteInput}
|
||||
hasError={state === 'error'}
|
||||
hint={errorText}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
|
||||
autoFocus
|
||||
/>
|
||||
</>
|
||||
}
|
||||
onClose={handleCancel}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={handleSave}
|
||||
updating={state === 'saving'}
|
||||
disabled={!isDirty}
|
||||
closeWhenConfirm={false}
|
||||
noFocusButton
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
|
||||
import { useAppHistory } from '@/mastodon/components/router';
|
||||
import { Tag } from '@/mastodon/components/tags/tag';
|
||||
import { useOverflow } from '@/mastodon/hooks/useOverflow';
|
||||
import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
// Fetch tags.
|
||||
const featuredTags = useAppSelector((state) =>
|
||||
selectAccountFeaturedTags(state, accountId),
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
void dispatch(fetchFeaturedTags({ accountId }));
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
// Get list of tags with overflow handling.
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
|
||||
useOverflow();
|
||||
|
||||
// Handle whether to show all tags.
|
||||
const handleOverflowClick: MouseEventHandler = useCallback(() => {
|
||||
setShowOverflow(true);
|
||||
}, []);
|
||||
|
||||
const { onClick, currentTag } = useTagNavigate();
|
||||
|
||||
if (featuredTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.tagsWrapper} ref={wrapperRef}>
|
||||
<div
|
||||
className={classNames(
|
||||
classes.tagsList,
|
||||
showOverflow && classes.tagsListShowAll,
|
||||
)}
|
||||
style={{ maxWidth }}
|
||||
ref={listRef}
|
||||
>
|
||||
{featuredTags.map(({ id, name }, index) => (
|
||||
<Tag
|
||||
name={name}
|
||||
key={id}
|
||||
inert={hiddenIndex > 0 && index >= hiddenIndex ? '' : undefined}
|
||||
onClick={onClick}
|
||||
active={currentTag === name}
|
||||
data-name={name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!showOverflow && hiddenCount > 0 && (
|
||||
<Tag
|
||||
onClick={handleOverflowClick}
|
||||
name={
|
||||
<FormattedMessage
|
||||
id='featured_tags.more_items'
|
||||
defaultMessage='+{count}'
|
||||
values={{ count: hiddenCount }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useTagNavigate() {
|
||||
// Get current account, tag, and filters.
|
||||
const { acct, tagged } = useParams<{ acct: string; tagged?: string }>();
|
||||
const { boosts, replies } = useFilters();
|
||||
|
||||
const history = useAppHistory();
|
||||
|
||||
const handleTagClick: MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(event) => {
|
||||
const name = event.currentTarget.getAttribute('data-name');
|
||||
if (!name || !acct) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine whether to navigate to or from the tag.
|
||||
let url = `/@${acct}/tagged/${encodeURIComponent(name)}`;
|
||||
if (name === tagged) {
|
||||
url = `/@${acct}`;
|
||||
}
|
||||
|
||||
// Append filters.
|
||||
const params = new URLSearchParams();
|
||||
if (boosts) {
|
||||
params.append('boosts', '1');
|
||||
}
|
||||
if (replies) {
|
||||
params.append('replies', '1');
|
||||
}
|
||||
|
||||
history.push({
|
||||
pathname: url,
|
||||
search: params.toString(),
|
||||
});
|
||||
},
|
||||
[acct, tagged, boosts, replies, history],
|
||||
);
|
||||
|
||||
return {
|
||||
onClick: handleTagClick,
|
||||
currentTag: tagged,
|
||||
};
|
||||
}
|
||||
146
app/javascript/mastodon/features/account_timeline/v2/filters.tsx
Normal file
146
app/javascript/mastodon/features/account_timeline/v2/filters.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import Overlay from 'react-overlays/esm/Overlay';
|
||||
|
||||
import { PlainToggleField } from '@/mastodon/components/form_fields/toggle_field';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||
|
||||
import { AccountTabs } from '../components/tabs';
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const AccountFilters: FC = () => {
|
||||
const { acct } = useParams<{ acct: string }>();
|
||||
if (!acct) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AccountTabs acct={acct} />
|
||||
<div className={classes.filtersWrapper}>
|
||||
<FilterDropdown />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FilterDropdown: FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const handleHide = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const { boosts, replies, setBoosts, setReplies } = useFilters();
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
const { name, checked } = event.target;
|
||||
if (name === 'boosts') {
|
||||
setBoosts(checked);
|
||||
} else if (name === 'replies') {
|
||||
setReplies(checked);
|
||||
}
|
||||
},
|
||||
[setBoosts, setReplies],
|
||||
);
|
||||
|
||||
const accessibleId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<button
|
||||
type='button'
|
||||
className={classes.filterSelectButton}
|
||||
ref={buttonRef}
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
aria-controls={`${accessibleId}-wrapper`}
|
||||
>
|
||||
{boosts && replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.all'
|
||||
defaultMessage='All activity'
|
||||
/>
|
||||
)}
|
||||
{!boosts && replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.posts_replies'
|
||||
defaultMessage='Posts and replies'
|
||||
/>
|
||||
)}
|
||||
{boosts && !replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.posts_boosts'
|
||||
defaultMessage='Posts and boosts'
|
||||
/>
|
||||
)}
|
||||
{!boosts && !replies && (
|
||||
<FormattedMessage
|
||||
id='account.filters.posts_only'
|
||||
defaultMessage='Posts'
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
id='unfold_more'
|
||||
icon={KeyboardArrowDownIcon}
|
||||
className={classes.filterSelectIcon}
|
||||
/>
|
||||
</button>
|
||||
<Overlay
|
||||
show={open}
|
||||
target={buttonRef}
|
||||
flip
|
||||
placement='bottom-start'
|
||||
rootClose
|
||||
onHide={handleHide}
|
||||
container={containerRef}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div
|
||||
{...props}
|
||||
id={`${accessibleId}-wrapper`}
|
||||
className={classes.filterOverlay}
|
||||
>
|
||||
<label htmlFor={`${accessibleId}-replies`}>
|
||||
<FormattedMessage
|
||||
id='account.filters.replies_toggle'
|
||||
defaultMessage='Show replies'
|
||||
/>
|
||||
</label>
|
||||
<PlainToggleField
|
||||
name='replies'
|
||||
checked={replies}
|
||||
onChange={handleChange}
|
||||
id={`${accessibleId}-replies`}
|
||||
/>
|
||||
|
||||
<label htmlFor={`${accessibleId}-boosts`}>
|
||||
<FormattedMessage
|
||||
id='account.filters.boosts_toggle'
|
||||
defaultMessage='Show boosts'
|
||||
/>
|
||||
</label>
|
||||
<PlainToggleField
|
||||
name='boosts'
|
||||
checked={boosts}
|
||||
onChange={handleChange}
|
||||
id={`${accessibleId}-boosts`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
174
app/javascript/mastodon/features/account_timeline/v2/index.tsx
Normal file
174
app/javascript/mastodon/features/account_timeline/v2/index.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import {
|
||||
expandTimelineByKey,
|
||||
timelineKey,
|
||||
} from '@/mastodon/actions/timelines_typed';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import StatusList from '@/mastodon/components/status_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../components/account_header';
|
||||
import { LimitedAccountHint } from '../components/limited_account_hint';
|
||||
import { useFilters } from '../hooks/useFilters';
|
||||
|
||||
import { FeaturedTags } from './featured_tags';
|
||||
import { AccountFilters } from './filters';
|
||||
|
||||
const emptyList = ImmutableList<string>();
|
||||
|
||||
const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const accountId = useAccountId();
|
||||
|
||||
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
if (!accountId) {
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
// Add this key to remount the timeline when accountId changes.
|
||||
return (
|
||||
<InnerTimeline
|
||||
accountId={accountId}
|
||||
key={accountId}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
|
||||
accountId,
|
||||
multiColumn,
|
||||
}) => {
|
||||
const { tagged } = useParams<{ tagged?: string }>();
|
||||
const { boosts, replies } = useFilters();
|
||||
const key = timelineKey({
|
||||
type: 'account',
|
||||
userId: accountId,
|
||||
tagged,
|
||||
boosts,
|
||||
replies,
|
||||
});
|
||||
|
||||
const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
|
||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
if (!timeline && !!accountId) {
|
||||
dispatch(expandTimelineByKey({ key }));
|
||||
}
|
||||
}, [accountId, dispatch, key, timeline]);
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
(maxId: number) => {
|
||||
if (accountId) {
|
||||
dispatch(expandTimelineByKey({ key, maxId }));
|
||||
}
|
||||
},
|
||||
[accountId, dispatch, key],
|
||||
);
|
||||
|
||||
const forceEmptyState = blockedBy || hidden || suspended;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnBackButton />
|
||||
|
||||
<StatusList
|
||||
alwaysPrepend
|
||||
prepend={
|
||||
<Prepend
|
||||
accountId={accountId}
|
||||
tagged={tagged}
|
||||
forceEmpty={forceEmptyState}
|
||||
/>
|
||||
}
|
||||
append={<RemoteHint accountId={accountId} />}
|
||||
scrollKey='account_timeline'
|
||||
// We want to have this component when timeline is undefined (loading),
|
||||
// because if we don't the prepended component will re-render with every filter change.
|
||||
statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
|
||||
isLoading={!!timeline?.isLoading}
|
||||
hasMore={!forceEmptyState && !!timeline?.hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<EmptyMessage accountId={accountId} />}
|
||||
bindToDocument={!multiColumn}
|
||||
timelineId='account'
|
||||
withCounters
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
const Prepend: FC<{
|
||||
accountId: string;
|
||||
tagged?: string;
|
||||
forceEmpty: boolean;
|
||||
}> = ({ forceEmpty, accountId, tagged }) => {
|
||||
if (forceEmpty) {
|
||||
return <AccountHeader accountId={accountId} hideTabs />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AccountHeader accountId={accountId} hideTabs />
|
||||
<AccountFilters />
|
||||
<FeaturedTags accountId={accountId} />
|
||||
<FeaturedCarousel accountId={accountId} tagged={tagged} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||
if (suspended) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_suspended'
|
||||
defaultMessage='Account suspended'
|
||||
/>
|
||||
);
|
||||
} else if (hidden) {
|
||||
return <LimitedAccountHint accountId={accountId} />;
|
||||
} else if (blockedBy) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_unavailable'
|
||||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_timeline'
|
||||
defaultMessage='No posts found'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default AccountTimelineV2;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
.filtersWrapper {
|
||||
padding: 16px 24px 8px;
|
||||
}
|
||||
|
||||
.filterSelectButton {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 8px 0;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filterSelectIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.filterOverlay {
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
min-width: 230px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: stretch;
|
||||
row-gap: 16px;
|
||||
padding: 8px 12px;
|
||||
z-index: 1;
|
||||
|
||||
> label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.tagsWrapper {
|
||||
margin: 0 24px 8px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagsList {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tagsListShowAll {
|
||||
flex-wrap: wrap;
|
||||
overflow: visible;
|
||||
max-width: none !important;
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { ApiAnnualReportState } from '@/mastodon/api/annual_report';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
|
||||
|
|
@ -19,7 +17,7 @@ export const AnnualReportAnnouncement: React.FC<
|
|||
AnnualReportAnnouncementProps
|
||||
> = ({ year, state, onRequestBuild, onOpen, onDismiss }) => {
|
||||
return (
|
||||
<div className={classNames('theme-dark', styles.wrapper)}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<FormattedMessage
|
||||
id='annual_report.announcement.title'
|
||||
defaultMessage='Wrapstodon {year} has arrived'
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export const AnnualReport: FC<{ context?: 'modal' | 'standalone' }> = ({
|
|||
const topHashtag = report.data.top_hashtags[0];
|
||||
|
||||
return (
|
||||
<div className={moduleClassNames(styles.wrapper, 'theme-dark')}>
|
||||
<div className={styles.wrapper} data-color-scheme='dark'>
|
||||
<div className={styles.header}>
|
||||
<h1>Wrapstodon {report.year}</h1>
|
||||
{account && <p>@{account.acct}</p>}
|
||||
|
|
|
|||
|
|
@ -60,11 +60,8 @@ const AnnualReportModal: React.FC<{
|
|||
// default modal backdrop, preventing clicks to pass through.
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
styles.modalWrapper,
|
||||
'theme-dark',
|
||||
)}
|
||||
className={classNames('modal-root__modal', styles.modalWrapper)}
|
||||
data-color-scheme='dark'
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
{!showAnnouncement ? (
|
||||
|
|
|
|||
272
app/javascript/mastodon/features/collections/editor.tsx
Normal file
272
app/javascript/mastodon/features/collections/editor.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { useCallback, useState, useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useParams, useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import type {
|
||||
ApiCollectionJSON,
|
||||
ApiCreateCollectionPayload,
|
||||
ApiUpdateCollectionPayload,
|
||||
} from 'mastodon/api_types/collections';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { TextAreaField, ToggleField } from 'mastodon/components/form_fields';
|
||||
import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import {
|
||||
createCollection,
|
||||
fetchCollection,
|
||||
updateCollection,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' },
|
||||
create: {
|
||||
id: 'column.create_collection',
|
||||
defaultMessage: 'Create collection',
|
||||
},
|
||||
});
|
||||
|
||||
const CollectionSettings: React.FC<{
|
||||
collection?: ApiCollectionJSON | null;
|
||||
}> = ({ collection }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
id,
|
||||
name: initialName = '',
|
||||
description: initialDescription = '',
|
||||
tag,
|
||||
discoverable: initialDiscoverable = true,
|
||||
sensitive: initialSensitive = false,
|
||||
} = collection ?? {};
|
||||
|
||||
const [name, setName] = useState(initialName);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [topic, setTopic] = useState(tag?.name ?? '');
|
||||
const [discoverable] = useState(initialDiscoverable);
|
||||
const [sensitive, setSensitive] = useState(initialSensitive);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDescription(event.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTopicChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTopic(event.target.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSensitiveChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSensitive(event.target.checked);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (id) {
|
||||
const payload: ApiUpdateCollectionPayload = {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
tag_name: topic,
|
||||
discoverable,
|
||||
sensitive,
|
||||
};
|
||||
|
||||
void dispatch(updateCollection({ payload })).then(() => {
|
||||
history.push(`/collections`);
|
||||
});
|
||||
} else {
|
||||
const payload: ApiCreateCollectionPayload = {
|
||||
name,
|
||||
description,
|
||||
discoverable,
|
||||
sensitive,
|
||||
};
|
||||
if (topic) {
|
||||
payload.tag_name = topic;
|
||||
}
|
||||
|
||||
void dispatch(
|
||||
createCollection({
|
||||
payload,
|
||||
}),
|
||||
).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
history.replace(
|
||||
`/collections/${result.payload.collection.id}/edit`,
|
||||
);
|
||||
history.push(`/collections`);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[id, dispatch, name, description, topic, discoverable, sensitive, history],
|
||||
);
|
||||
|
||||
return (
|
||||
<form className='simple_form app-form' onSubmit={handleSubmit}>
|
||||
<div className='fields-group'>
|
||||
<TextInputField
|
||||
required
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_name'
|
||||
defaultMessage='Name'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.name_length_hint'
|
||||
defaultMessage='40 characters limit'
|
||||
/>
|
||||
}
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<TextAreaField
|
||||
required
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_description'
|
||||
defaultMessage='Description'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.description_length_hint'
|
||||
defaultMessage='100 characters limit'
|
||||
/>
|
||||
}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<TextInputField
|
||||
required={false}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.collection_topic'
|
||||
defaultMessage='Topic'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.topic_hint'
|
||||
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
|
||||
/>
|
||||
}
|
||||
value={topic}
|
||||
onChange={handleTopicChange}
|
||||
maxLength={40}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='fields-group'>
|
||||
<ToggleField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='collections.mark_as_sensitive'
|
||||
defaultMessage='Mark as sensitive'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='collections.mark_as_sensitive_hint'
|
||||
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
|
||||
/>
|
||||
}
|
||||
checked={sensitive}
|
||||
onChange={handleSensitiveChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='actions'>
|
||||
<Button type='submit'>
|
||||
{id ? (
|
||||
<FormattedMessage id='lists.save' defaultMessage='Save' />
|
||||
) : (
|
||||
<FormattedMessage id='lists.create' defaultMessage='Create' />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const CollectionEditorPage: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const collection = useAppSelector((state) =>
|
||||
id ? state.collections.collections[id] : undefined,
|
||||
);
|
||||
const isEditMode = !!id;
|
||||
const isLoading = isEditMode && !collection;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
void dispatch(fetchCollection({ collectionId: id }));
|
||||
}
|
||||
}, [dispatch, id]);
|
||||
|
||||
const pageTitle = intl.formatMessage(id ? messages.edit : messages.create);
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={pageTitle}>
|
||||
<ColumnHeader
|
||||
title={pageTitle}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
<div className='scrollable'>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<CollectionSettings collection={collection} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
160
app/javascript/mastodon/features/collections/index.tsx
Normal file
160
app/javascript/mastodon/features/collections/index.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { useEffect, useMemo, useCallback } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import {
|
||||
fetchAccountCollections,
|
||||
selectMyCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.collections', defaultMessage: 'My collections' },
|
||||
create: {
|
||||
id: 'collections.create_collection',
|
||||
defaultMessage: 'Create collection',
|
||||
},
|
||||
view: {
|
||||
id: 'collections.view_collection',
|
||||
defaultMessage: 'View collection',
|
||||
},
|
||||
delete: {
|
||||
id: 'collections.delete_collection',
|
||||
defaultMessage: 'Delete collection',
|
||||
},
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
const ListItem: React.FC<{
|
||||
id: string;
|
||||
name: string;
|
||||
}> = ({ id, name }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleDeleteClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_DELETE_COLLECTION',
|
||||
modalProps: {
|
||||
name,
|
||||
id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id, name]);
|
||||
|
||||
const menu = useMemo(
|
||||
() => [
|
||||
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
|
||||
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
|
||||
],
|
||||
[intl, id, handleDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='lists__item'>
|
||||
<Link to={`/collections/${id}/edit`} className='lists__item__title'>
|
||||
<span>{name}</span>
|
||||
</Link>
|
||||
|
||||
<Dropdown
|
||||
scrollKey='collections'
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Collections: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const me = useAppSelector((state) => state.meta.get('me') as string);
|
||||
const { collections, status } = useAppSelector(selectMyCollections);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchAccountCollections({ accountId: me }));
|
||||
}, [dispatch, me]);
|
||||
|
||||
const emptyMessage =
|
||||
status === 'error' ? (
|
||||
<FormattedMessage
|
||||
id='collections.error_loading_collections'
|
||||
defaultMessage='There was an error when trying to load your collections.'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='collections.no_collections_yet'
|
||||
defaultMessage='No collections yet.'
|
||||
/>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id='collections.create_a_collection_hint'
|
||||
defaultMessage='Create a collection to recommend or share your favourite accounts with others.'
|
||||
/>
|
||||
</span>
|
||||
|
||||
<SquigglyArrow className='empty-column-indicator__arrow' />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Column
|
||||
bindToDocument={!multiColumn}
|
||||
label={intl.formatMessage(messages.heading)}
|
||||
>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage(messages.heading)}
|
||||
icon='list-ul'
|
||||
iconComponent={ListAltIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
<Link
|
||||
to='/collections/new'
|
||||
className='column-header__button'
|
||||
title={intl.formatMessage(messages.create)}
|
||||
aria-label={intl.formatMessage(messages.create)}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='collections'
|
||||
emptyMessage={emptyMessage}
|
||||
isLoading={status === 'loading'}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{collections.map((item) => (
|
||||
<ListItem key={item.id} id={item.id} name={item.name} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages.heading)}</title>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
11
app/javascript/mastodon/features/collections/utils.ts
Normal file
11
app/javascript/mastodon/features/collections/utils.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import {
|
||||
isClientFeatureEnabled,
|
||||
isServerFeatureEnabled,
|
||||
} from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
return (
|
||||
isClientFeatureEnabled('collections') &&
|
||||
isServerFeatureEnabled('collections')
|
||||
);
|
||||
}
|
||||
|
|
@ -10,8 +10,8 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
|
||||
import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
import SoundIcon from '@/material-icons/400-24px/graphic_eq.svg?react';
|
||||
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
|
||||
import { undoUploadCompose } from 'mastodon/actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
|
|
|||
|
|
@ -83,6 +83,9 @@ export const Directory: React.FC<{
|
|||
(state) =>
|
||||
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
|
||||
);
|
||||
const hasMore = useAppSelector(
|
||||
(state) => !!state.user_lists.getIn(['directory', 'next']),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchDirectory({ order, local }));
|
||||
|
|
@ -182,7 +185,7 @@ export const Directory: React.FC<{
|
|||
|
||||
<LoadMore
|
||||
onClick={handleLoadMore}
|
||||
visible={!initialLoad}
|
||||
visible={!initialLoad && hasMore}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ describe('emoji', () => {
|
|||
|
||||
it('does an emoji containing ZWJ properly', () => {
|
||||
expect(emojify('💂♀️💂♂️'))
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg"><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg">');
|
||||
.toEqual('<img draggable="false" class="emojione" alt="💂♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f.svg"><img draggable="false" class="emojione" alt="💂♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f.svg">');
|
||||
});
|
||||
|
||||
it('keeps ordering as expected (issue fixed by PR 20677)', () => {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user