Merge branch 'upstream' into main

This commit is contained in:
Holly 2025-03-14 21:50:43 +00:00
commit b5f59575f6
454 changed files with 7718 additions and 4024 deletions

View File

@ -20,3 +20,9 @@ postgres14
redis redis
elasticsearch elasticsearch
chart chart
.yarn/
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

View File

@ -316,3 +316,24 @@ MAX_POLL_OPTION_CHARS=100
# ----------------------- # -----------------------
IP_RETENTION_PERIOD=31556952 IP_RETENTION_PERIOD=31556952
SESSION_RETENTION_PERIOD=31556952 SESSION_RETENTION_PERIOD=31556952
# Fetch All Replies Behavior
# --------------------------
# When a user expands a post (DetailedStatus view), fetch all of its replies
# (default: false)
FETCH_REPLIES_ENABLED=false
# Period to wait between fetching replies (in minutes)
FETCH_REPLIES_COOLDOWN_MINUTES=15
# Period to wait after a post is first created before fetching its replies (in minutes)
FETCH_REPLIES_INITIAL_WAIT_MINUTES=5
# Max number of replies to fetch - total, recursively through a whole reply tree
FETCH_REPLIES_MAX_GLOBAL=1000
# Max number of replies to fetch - for a single post
FETCH_REPLIES_MAX_SINGLE=500
# Max number of replies Collection pages to fetch - total
FETCH_REPLIES_MAX_PAGES=500

View File

@ -43,4 +43,4 @@ jobs:
- name: Run haml-lint - name: Run haml-lint
run: | run: |
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json" echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
bin/haml-lint --parallel --reporter github bin/haml-lint --reporter github

View File

@ -8,6 +8,7 @@ on:
- .github/workflows/test-image-build.yml - .github/workflows/test-image-build.yml
- Dockerfile - Dockerfile
- streaming/Dockerfile - streaming/Dockerfile
- .dockerignore
permissions: permissions:
contents: read contents: read

View File

@ -77,6 +77,18 @@ jobs:
- name: Set up Ruby environment - name: Set up Ruby environment
uses: ./.github/actions/setup-ruby uses: ./.github/actions/setup-ruby
- name: Ensure no errors with `db:prepare`
run: |
bin/rails db:drop
bin/rails db:prepare
bin/rails db:migrate
- name: Ensure no errors with `db:prepare` and SKIP_POST_DEPLOYMENT_MIGRATIONS
run: |
bin/rails db:drop
SKIP_POST_DEPLOYMENT_MIGRATIONS=true bin/rails db:prepare
bin/rails db:migrate
- name: Test "one step migration" flow - name: Test "one step migration" flow
run: | run: |
bin/rails db:drop bin/rails db:drop

View File

@ -63,6 +63,7 @@ docker-compose.override.yml
# Ignore emoji map file # Ignore emoji map file
/app/javascript/mastodon/features/emoji/emoji_map.json /app/javascript/mastodon/features/emoji/emoji_map.json
/app/javascript/mastodon/features/emoji/emoji_sheet.json
# Ignore locale files # Ignore locale files
/app/javascript/mastodon/locales/*.json /app/javascript/mastodon/locales/*.json
@ -86,6 +87,7 @@ AUTHORS.md
# Ignore glitch-soc emoji map file # Ignore glitch-soc emoji map file
/app/javascript/flavours/glitch/features/emoji/emoji_map.json /app/javascript/flavours/glitch/features/emoji/emoji_map.json
/app/javascript/flavours/glitch/features/emoji/emoji_sheet.json
# Ignore glitch-soc locale files # Ignore glitch-soc locale files
/app/javascript/flavours/glitch/locales /app/javascript/flavours/glitch/locales

View File

@ -18,6 +18,7 @@ inherit_from:
- .rubocop/rspec_rails.yml - .rubocop/rspec_rails.yml
- .rubocop/rspec.yml - .rubocop/rspec.yml
- .rubocop/style.yml - .rubocop/style.yml
- .rubocop/i18n.yml
- .rubocop/custom.yml - .rubocop/custom.yml
- .rubocop_todo.yml - .rubocop_todo.yml
- .rubocop/strict.yml - .rubocop/strict.yml
@ -27,10 +28,9 @@ inherit_mode:
- Exclude - Exclude
plugins: plugins:
- rubocop-capybara
- rubocop-i18n
- rubocop-performance
- rubocop-rails - rubocop-rails
- rubocop-rspec - rubocop-rspec
- rubocop-performance
require:
- rubocop-rspec_rails - rubocop-rspec_rails
- rubocop-capybara

12
.rubocop/i18n.yml Normal file
View File

@ -0,0 +1,12 @@
I18n/RailsI18n:
Enabled: true
Exclude:
- 'config/**/*'
- 'db/**/*'
- 'lib/**/*'
- 'spec/**/*'
I18n/GetText:
Enabled: false
I18n/RailsI18n/DecorateStringFormattingUsingInterpolation:
Enabled: false

View File

@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.72.2. # using RuboCop version 1.73.2.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new

View File

@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.3.6] - 2025-03-13
### Security
- Update dependency `omniauth-saml`
- Update dependency `rack`
### Fixed
- Fix Stoplight errors when using `REDIS_NAMESPACE` (#34126 by @ClearlyClaire)
## [4.3.5] - 2025-03-10
### Changed
- Change hashtag suggestion to prefer personal history capitalization (#34070 by @ClearlyClaire)
### Fixed
- Fix processing errors for some HEIF images from iOS 18 (#34086 by @renchap)
- Fix streaming server not filtering unknown-language posts from public timelines (#33774 by @ClearlyClaire)
- Fix preview cards under Content Warnings not being shown in detailed statuses (#34068 by @ClearlyClaire)
- Fix username and display name being hidden on narrow screens in moderation interface (#33064 by @ClearlyClaire)
## [4.3.4] - 2025-02-27 ## [4.3.4] - 2025-02-27
### Security ### Security

View File

@ -14,12 +14,12 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby # renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.4.2" ARG RUBY_VERSION="3.4.2"
# # Node version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node # renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22" ARG NODE_MAJOR_VERSION="22"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"] # Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm" ARG DEBIAN_VERSION="bookworm"
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim) # Node.js image to use for base image based on combined variables (ex: 20-bookworm-slim)
FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
# Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm) # Ruby image to use for base image based on combined variables (ex: 3.4.x-slim-bookworm)
FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
@ -61,7 +61,7 @@ ENV \
ENV \ ENV \
# Configure the IP to bind Mastodon to when serving traffic # Configure the IP to bind Mastodon to when serving traffic
BIND="0.0.0.0" \ BIND="0.0.0.0" \
# Use production settings for Yarn, Node and related nodejs based tools # Use production settings for Yarn, Node.js and related tools
NODE_ENV="production" \ NODE_ENV="production" \
# Use production settings for Ruby on Rails # Use production settings for Ruby on Rails
RAILS_ENV="production" \ RAILS_ENV="production" \
@ -96,6 +96,9 @@ RUN \
# Set /opt/mastodon as working directory # Set /opt/mastodon as working directory
WORKDIR /opt/mastodon WORKDIR /opt/mastodon
# Add backport repository for some specific packages where we need the latest version
RUN echo 'deb http://deb.debian.org/debian bookworm-backports main' >> /etc/apt/sources.list
# hadolint ignore=DL3008,DL3005 # hadolint ignore=DL3008,DL3005
RUN \ RUN \
# Mount Apt cache and lib directories from Docker buildx caches # Mount Apt cache and lib directories from Docker buildx caches
@ -125,13 +128,6 @@ RUN \
# Create temporary build layer from base image # Create temporary build layer from base image
FROM ruby AS build FROM ruby AS build
# Copy Node package configuration files into working directory
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/
COPY .yarn /opt/mastodon/.yarn
COPY --from=node /usr/local/bin /usr/local/bin
COPY --from=node /usr/local/lib /usr/local/lib
ARG TARGETPLATFORM ARG TARGETPLATFORM
# hadolint ignore=DL3008 # hadolint ignore=DL3008
@ -165,7 +161,7 @@ RUN \
libexif-dev \ libexif-dev \
libexpat1-dev \ libexpat1-dev \
libgirepository1.0-dev \ libgirepository1.0-dev \
libheif-dev \ libheif-dev/bookworm-backports \
libimagequant-dev \ libimagequant-dev \
libjpeg62-turbo-dev \ libjpeg62-turbo-dev \
liblcms2-dev \ liblcms2-dev \
@ -185,12 +181,6 @@ RUN \
libx265-dev \ libx265-dev \
; ;
RUN \
# Configure Corepack
rm /usr/local/bin/yarn*; \
corepack enable; \
corepack prepare --activate;
# Create temporary libvips specific build layer from build layer # Create temporary libvips specific build layer from build layer
FROM build AS libvips FROM build AS libvips
@ -281,38 +271,37 @@ RUN \
# Download and install required Gems # Download and install required Gems
bundle install -j"$(nproc)"; bundle install -j"$(nproc)";
# Create temporary node specific build layer from build layer # Create temporary assets build layer from build layer
FROM build AS yarn FROM build AS precompiler
ARG TARGETPLATFORM ARG TARGETPLATFORM
# Copy Node package configuration files into working directory # Copy Mastodon sources into layer
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/ COPY . /opt/mastodon/
COPY streaming/package.json /opt/mastodon/streaming/
COPY .yarn /opt/mastodon/.yarn # Copy Node.js binaries/libraries into layer
COPY --from=node /usr/local/bin /usr/local/bin
COPY --from=node /usr/local/lib /usr/local/lib
RUN \
# Configure Corepack
rm /usr/local/bin/yarn*; \
corepack enable; \
corepack prepare --activate;
# hadolint ignore=DL3008 # hadolint ignore=DL3008
RUN \ RUN \
--mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \
--mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \
# Install Node packages # Install Node.js packages
yarn workspaces focus --production @mastodon/mastodon; yarn workspaces focus --production @mastodon/mastodon;
# Create temporary assets build layer from build layer # Copy libvips components into layer for precompiler
FROM build AS precompiler
# Copy Mastodon sources into precompiler layer
COPY . /opt/mastodon/
# Copy bundler and node packages from build layer to container
COPY --from=yarn /opt/mastodon /opt/mastodon/
COPY --from=bundler /opt/mastodon /opt/mastodon/
COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/
# Copy libvips components to layer for precompiler
COPY --from=libvips /usr/local/libvips/bin /usr/local/bin COPY --from=libvips /usr/local/libvips/bin /usr/local/bin
COPY --from=libvips /usr/local/libvips/lib /usr/local/lib COPY --from=libvips /usr/local/libvips/lib /usr/local/lib
# Copy bundler packages into layer for precompiler
ARG TARGETPLATFORM COPY --from=bundler /opt/mastodon /opt/mastodon/
COPY --from=bundler /usr/local/bundle/ /usr/local/bundle/
RUN \ RUN \
ldconfig; \ ldconfig; \
@ -348,7 +337,7 @@ RUN \
# libvips components # libvips components
libcgif0 \ libcgif0 \
libexif12 \ libexif12 \
libheif1 \ libheif1/bookworm-backports \
libimagequant0 \ libimagequant0 \
libjpeg62-turbo \ libjpeg62-turbo \
liblcms2-2 \ liblcms2-2 \

View File

@ -39,7 +39,7 @@ gem 'net-ldap', '~> 0.18'
gem 'omniauth', '~> 2.0' gem 'omniauth', '~> 2.0'
gem 'omniauth-cas', '~> 3.0.0.beta.1' gem 'omniauth-cas', '~> 3.0.0.beta.1'
gem 'omniauth_openid_connect', '~> 0.6.1' gem 'omniauth_openid_connect', '~> 0.8.0'
gem 'omniauth-rails_csrf_protection', '~> 1.0' gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem 'omniauth-saml', '~> 2.0' gem 'omniauth-saml', '~> 2.0'
@ -102,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5'
gem 'prometheus_exporter', '~> 2.2', require: false gem 'prometheus_exporter', '~> 2.2', require: false
gem 'opentelemetry-api', '~> 1.4.0' gem 'opentelemetry-api', '~> 1.5.0'
group :opentelemetry do group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.29.0', require: false gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
@ -165,6 +165,7 @@ group :development do
# Code linting CLI and plugins # Code linting CLI and plugins
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'rubocop-capybara', require: false gem 'rubocop-capybara', require: false
gem 'rubocop-i18n', require: false
gem 'rubocop-performance', require: false gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false gem 'rubocop-rspec', require: false

View File

@ -10,29 +10,29 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.1) actioncable (8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.1) actionmailbox (8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
activejob (= 8.0.1) activejob (= 8.0.2)
activerecord (= 8.0.1) activerecord (= 8.0.2)
activestorage (= 8.0.1) activestorage (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.0.1) actionmailer (8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
actionview (= 8.0.1) actionview (= 8.0.2)
activejob (= 8.0.1) activejob (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.1) actionpack (8.0.2)
actionview (= 8.0.1) actionview (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@ -40,15 +40,15 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.0.1) actiontext (8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
activerecord (= 8.0.1) activerecord (= 8.0.2)
activestorage (= 8.0.1) activestorage (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.1) actionview (8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
@ -58,22 +58,22 @@ GEM
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (8.0.1) activejob (8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.1) activemodel (8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
activerecord (8.0.1) activerecord (8.0.2)
activemodel (= 8.0.1) activemodel (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.1) activestorage (8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
activejob (= 8.0.1) activejob (= 8.0.2)
activerecord (= 8.0.1) activerecord (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.1) activesupport (8.0.2)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@ -217,6 +217,8 @@ GEM
htmlentities (~> 4.3.3) htmlentities (~> 4.3.3)
launchy (>= 2.1, < 4.0) launchy (>= 2.1, < 4.0)
mail (~> 2.7) mail (~> 2.7)
email_validator (2.2.4)
activemodel
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.2.11) et-orbi (1.2.11)
tzinfo tzinfo
@ -228,6 +230,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
json json
logger logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-httpclient (2.0.1) faraday-httpclient (2.0.1)
httpclient (>= 2.2) httpclient (>= 2.2)
faraday-net_http (3.4.0) faraday-net_http (3.4.0)
@ -261,8 +265,8 @@ GEM
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
google-protobuf (3.25.5) google-protobuf (3.25.6)
googleapis-common-protos-types (1.15.0) googleapis-common-protos-types (1.18.0)
google-protobuf (>= 3.18, < 5.a) google-protobuf (>= 3.18, < 5.a)
haml (6.3.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
@ -304,7 +308,7 @@ GEM
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.14.7) i18n (1.14.7)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (1.0.14) i18n-tasks (1.0.15)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
ast (>= 2.1.0) ast (>= 2.1.0)
erubi erubi
@ -313,6 +317,7 @@ GEM
parser (>= 3.2.2.1) parser (>= 3.2.2.1)
rails-i18n rails-i18n
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.8, >= 1.8.1)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.5) idn-ruby (0.1.5)
inline_svg (1.10.0) inline_svg (1.10.0)
@ -328,13 +333,15 @@ GEM
azure-blob (~> 0.5.2) azure-blob (~> 0.5.2)
hashie (~> 5.0) hashie (~> 5.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.10.1) json (2.10.2)
json-canonicalization (1.0.0) json-canonicalization (1.0.0)
json-jwt (1.15.3.1) json-jwt (1.16.7)
activesupport (>= 4.2) activesupport (>= 4.2)
aes_key_wrap aes_key_wrap
base64
bindata bindata
httpclient faraday (~> 2.0)
faraday-follow_redirects
json-ld (3.3.2) json-ld (3.3.2)
htmlentities (~> 4.3) htmlentities (~> 4.3)
json-canonicalization (~> 1.0) json-canonicalization (~> 1.0)
@ -435,41 +442,43 @@ GEM
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.2) omniauth (2.1.3)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-cas (3.0.0) omniauth-cas (3.0.1)
addressable (~> 2.8) addressable (~> 2.8)
nokogiri (~> 1.12) nokogiri (~> 1.12)
omniauth (~> 2.1) omniauth (~> 2.1)
omniauth-rails_csrf_protection (1.0.2) omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-saml (2.2.1) omniauth-saml (2.2.3)
omniauth (~> 2.1) omniauth (~> 2.1)
ruby-saml (~> 1.17) ruby-saml (~> 1.18)
omniauth_openid_connect (0.6.1) omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3) omniauth (>= 1.9, < 3)
openid_connect (~> 1.1) openid_connect (~> 2.2)
openid_connect (1.4.2) openid_connect (2.3.1)
activemodel activemodel
attr_required (>= 1.0.0) attr_required (>= 1.0.0)
json-jwt (>= 1.15.0) email_validator
net-smtp faraday (~> 2.0)
rack-oauth2 (~> 1.21) faraday-follow_redirects
swd (~> 1.3) json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo tzinfo
validate_email
validate_url validate_url
webfinger (~> 1.2) webfinger (~> 2.0)
openssl (3.3.0) openssl (3.3.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
opentelemetry-api (1.4.0) opentelemetry-api (1.5.0)
opentelemetry-common (0.21.0) opentelemetry-common (0.22.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.29.1) opentelemetry-exporter-otlp (0.30.0)
google-protobuf (>= 3.18) google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3) googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
@ -500,8 +509,8 @@ GEM
opentelemetry-instrumentation-active_record (0.9.0) opentelemetry-instrumentation-active_record (0.9.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_storage (0.1.0) opentelemetry-instrumentation-active_storage (0.1.1)
opentelemetry-api (~> 1.4.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7) opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-active_support (0.8.0) opentelemetry-instrumentation-active_support (0.8.0)
@ -553,14 +562,14 @@ GEM
opentelemetry-instrumentation-sidekiq (0.26.0) opentelemetry-instrumentation-sidekiq (0.26.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0) opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-registry (0.3.1) opentelemetry-registry (0.4.0)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
opentelemetry-sdk (1.7.0) opentelemetry-sdk (1.8.0)
opentelemetry-api (~> 1.1) opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20) opentelemetry-common (~> 0.20)
opentelemetry-registry (~> 0.2) opentelemetry-registry (~> 0.2)
opentelemetry-semantic_conventions opentelemetry-semantic_conventions
opentelemetry-semantic_conventions (1.10.1) opentelemetry-semantic_conventions (1.11.0)
opentelemetry-api (~> 1.0) opentelemetry-api (~> 1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ostruct (0.6.1) ostruct (0.6.1)
@ -600,19 +609,20 @@ GEM
public_suffix (6.0.1) public_suffix (6.0.1)
puma (6.6.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.4.0) pundit (2.5.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (2.2.11) rack (2.2.13)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-cors (2.0.2) rack-cors (2.0.2)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-oauth2 (1.21.3) rack-oauth2 (2.2.1)
activesupport activesupport
attr_required attr_required
httpclient faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0) json-jwt (>= 1.11.0)
rack (>= 2.1.0) rack (>= 2.1.0)
rack-protection (3.2.0) rack-protection (3.2.0)
@ -627,20 +637,20 @@ GEM
rackup (1.0.1) rackup (1.0.1)
rack (< 3) rack (< 3)
webrick webrick
rails (8.0.1) rails (8.0.2)
actioncable (= 8.0.1) actioncable (= 8.0.2)
actionmailbox (= 8.0.1) actionmailbox (= 8.0.2)
actionmailer (= 8.0.1) actionmailer (= 8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
actiontext (= 8.0.1) actiontext (= 8.0.2)
actionview (= 8.0.1) actionview (= 8.0.2)
activejob (= 8.0.1) activejob (= 8.0.2)
activemodel (= 8.0.1) activemodel (= 8.0.2)
activerecord (= 8.0.1) activerecord (= 8.0.2)
activestorage (= 8.0.1) activestorage (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.1) railties (= 8.0.2)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@ -651,9 +661,9 @@ GEM
rails-i18n (8.0.1) rails-i18n (8.0.1)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 8.0.0, < 9)
railties (8.0.1) railties (8.0.2)
actionpack (= 8.0.1) actionpack (= 8.0.2)
activesupport (= 8.0.1) activesupport (= 8.0.2)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@ -669,7 +679,7 @@ GEM
rdf (~> 3.3) rdf (~> 3.3)
rdoc (6.12.0) rdoc (6.12.0)
psych (>= 4.0.0) psych (>= 4.0.0)
redcarpet (3.6.0) redcarpet (3.6.1)
redis (4.8.1) redis (4.8.1)
redis-namespace (1.11.0) redis-namespace (1.11.0)
redis (>= 4) redis (>= 4)
@ -713,13 +723,13 @@ GEM
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
rspec-support (~> 3.13) rspec-support (~> 3.13)
rspec-sidekiq (5.0.0) rspec-sidekiq (5.1.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8) sidekiq (>= 5, < 9)
rspec-support (3.13.2) rspec-support (3.13.2)
rubocop (1.72.2) rubocop (1.73.2)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -730,15 +740,19 @@ GEM
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0) rubocop-ast (1.38.1)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
rubocop-capybara (2.21.0) rubocop-capybara (2.22.1)
rubocop (~> 1.41) lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-i18n (3.2.3)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-performance (1.24.0) rubocop-performance (1.24.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0) rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0) rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.2) rubocop-rails (2.30.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rack (>= 1.1) rack (>= 1.1)
@ -747,12 +761,13 @@ GEM
rubocop-rspec (3.5.0) rubocop-rspec (3.5.0)
lint_roller (~> 1.1) lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1) rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.30.0) rubocop-rspec_rails (2.31.0)
rubocop (~> 1.61) lint_roller (~> 1.1)
rubocop-rspec (~> 3, >= 3.0.1) rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
ruby-prof (1.7.1) ruby-prof (1.7.1)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-saml (1.17.0) ruby-saml (1.18.0)
nokogiri (>= 1.13.10) nokogiri (>= 1.13.10)
rexml rexml
ruby-vips (2.2.3) ruby-vips (2.2.3)
@ -810,13 +825,14 @@ GEM
stackprof (0.2.27) stackprof (0.2.27)
stoplight (4.1.1) stoplight (4.1.1)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.4) stringio (3.1.5)
strong_migrations (2.2.0) strong_migrations (2.2.0)
activerecord (>= 7) activerecord (>= 7)
swd (1.3.0) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
httpclient (>= 2.4) faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0) sysexits (1.2.0)
temple (0.10.3) temple (0.10.3)
terminal-table (4.0.0) terminal-table (4.0.0)
@ -854,11 +870,8 @@ GEM
unicode-display_width (3.1.4) unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uri (1.0.2) uri (1.0.3)
useragent (0.16.11) useragent (0.16.11)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
validate_url (1.0.15) validate_url (1.0.15)
activemodel (>= 3.0.0) activemodel (>= 3.0.0)
public_suffix public_suffix
@ -872,10 +885,11 @@ GEM
openssl (>= 2.2) openssl (>= 2.2)
safety_net_attestation (~> 0.4.0) safety_net_attestation (~> 0.4.0)
tpm-key_attestation (~> 0.14.0) tpm-key_attestation (~> 0.14.0)
webfinger (1.2.0) webfinger (2.1.3)
activesupport activesupport
httpclient (>= 2.4) faraday (~> 2.0)
webmock (3.25.0) faraday-follow_redirects
webmock (3.25.1)
addressable (>= 2.8.0) addressable (>= 2.8.0)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -973,9 +987,9 @@ DEPENDENCIES
omniauth-cas (~> 3.0.0.beta.1) omniauth-cas (~> 3.0.0.beta.1)
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0) omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1) omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.4.0) opentelemetry-api (~> 1.5.0)
opentelemetry-exporter-otlp (~> 0.29.0) opentelemetry-exporter-otlp (~> 0.30.0)
opentelemetry-instrumentation-active_job (~> 0.8.0) opentelemetry-instrumentation-active_job (~> 0.8.0)
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
@ -1016,6 +1030,7 @@ DEPENDENCIES
rspec-sidekiq (~> 5.0) rspec-sidekiq (~> 5.0)
rubocop rubocop
rubocop-capybara rubocop-capybara
rubocop-i18n
rubocop-performance rubocop-performance
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Admin::Announcements::DistributionsController < Admin::BaseController
before_action :set_announcement
def create
authorize @announcement, :distribute?
@announcement.touch(:notification_sent_at)
Admin::DistributeAnnouncementNotificationWorker.perform_async(@announcement.id)
redirect_to admin_announcements_path
end
private
def set_announcement
@announcement = Announcement.find(params[:announcement_id])
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Admin::Announcements::PreviewsController < Admin::BaseController
before_action :set_announcement
def show
authorize @announcement, :distribute?
@user_count = @announcement.scope_for_notification.count
end
private
def set_announcement
@announcement = Announcement.find(params[:announcement_id])
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Admin::Announcements::TestsController < Admin::BaseController
before_action :set_announcement
def create
authorize @announcement, :distribute?
UserMailer.announcement_published(current_user, @announcement).deliver_later!
redirect_to admin_announcements_path
end
private
def set_announcement
@announcement = Announcement.find(params[:announcement_id])
end
end

View File

@ -23,7 +23,7 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
private private
def set_terms_of_service def set_terms_of_service
@terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text) @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text, effective_date: 10.days.from_now)
end end
def current_terms_of_service def current_terms_of_service
@ -32,6 +32,6 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
def resource_params def resource_params
params params
.expect(terms_of_service: [:text, :changelog]) .expect(terms_of_service: [:text, :changelog, :effective_date])
end end
end end

View File

@ -3,6 +3,6 @@
class Admin::TermsOfServiceController < Admin::BaseController class Admin::TermsOfServiceController < Admin::BaseController
def index def index
authorize :terms_of_service, :index? authorize :terms_of_service, :index?
@terms_of_service = TermsOfService.live.first @terms_of_service = TermsOfService.published.first
end end
end end

View File

@ -14,7 +14,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, account_params, raise_error: true) UpdateAccountService.new.call(@account, account_params, raise_error: true)
current_user.update(user_params) if user_params current_user.update(user_params) if user_params
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render json: ValidationErrorFormatter.new(e).as_json, status: 422 render json: ValidationErrorFormatter.new(e).as_json, status: 422

View File

@ -5,12 +5,18 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
def show def show
cache_even_if_authenticated! cache_even_if_authenticated!
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer
end end
private private
def set_terms_of_service def set_terms_of_service
@terms_of_service = TermsOfService.live.first! @terms_of_service = begin
if params[:date].present?
TermsOfService.published.find_by!(effective_date: params[:date])
else
TermsOfService.live.first || TermsOfService.published.first! # For the case when none of the published terms have become effective yet
end
end
end end
end end

View File

@ -3,8 +3,8 @@
class Api::V1::MediaController < Api::BaseController class Api::V1::MediaController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:media' } before_action -> { doorkeeper_authorize! :write, :'write:media' }
before_action :require_user! before_action :require_user!
before_action :set_media_attachment, except: [:create] before_action :set_media_attachment, except: [:create, :destroy]
before_action :check_processing, except: [:create] before_action :check_processing, except: [:create, :destroy]
def show def show
render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment
@ -25,6 +25,15 @@ class Api::V1::MediaController < Api::BaseController
render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment
end end
def destroy
@media_attachment = current_account.media_attachments.find(params[:id])
return render json: in_usage_error, status: 422 unless @media_attachment.status_id.nil?
@media_attachment.destroy
render_empty
end
private private
def status_code_for_media_attachment def status_code_for_media_attachment
@ -54,4 +63,8 @@ class Api::V1::MediaController < Api::BaseController
def processing_error def processing_error
{ error: 'Error processing thumbnail for uploaded media' } { error: 'Error processing thumbnail for uploaded media' }
end end
def in_usage_error
{ error: 'Media attachment is currently used by a status' }
end
end end

View File

@ -7,7 +7,7 @@ class Api::V1::Profile::AvatarsController < Api::BaseController
def destroy def destroy
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true) UpdateAccountService.new.call(@account, { avatar: nil }, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
end end
end end

View File

@ -7,7 +7,7 @@ class Api::V1::Profile::HeadersController < Api::BaseController
def destroy def destroy
@account = current_account @account = current_account
UpdateAccountService.new.call(@account, { header: nil }, raise_error: true) UpdateAccountService.new.call(@account, { header: nil }, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
render json: @account, serializer: REST::CredentialAccountSerializer render json: @account, serializer: REST::CredentialAccountSerializer
end end
end end

View File

@ -58,6 +58,8 @@ class Api::V1::StatusesController < Api::BaseController
statuses = [@status] + @context.ancestors + @context.descendants statuses = [@status] + @context.ancestors + @context.descendants
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
end end
def create def create
@ -113,7 +115,7 @@ class Api::V1::StatusesController < Api::BaseController
@status.account.statuses_count = @status.account.statuses_count - 1 @status.account.statuses_count = @status.account.statuses_count - 1
json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true
RemovalWorker.perform_async(@status.id, { 'redraft' => true }) RemovalWorker.perform_async(@status.id, { 'redraft' => !truthy_param?(:delete_media) })
render json: json render json: json
end end

View File

@ -8,7 +8,7 @@ module Settings
def destroy def destroy
if valid_picture? if valid_picture?
if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' }) if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303 redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303
else else
redirect_to settings_profile_path redirect_to settings_profile_path

View File

@ -8,7 +8,7 @@ class Settings::PrivacyController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params.except(:settings)) if UpdateAccountService.new.call(@account, account_params.except(:settings))
current_user.update!(settings_attributes: account_params[:settings]) current_user.update!(settings_attributes: account_params[:settings])
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_privacy_path, notice: I18n.t('generic.changes_saved_msg')
else else
render :show render :show

View File

@ -9,7 +9,7 @@ class Settings::ProfilesController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params) if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
else else
@account.build_fields @account.build_fields

View File

@ -8,7 +8,7 @@ class Settings::VerificationsController < Settings::BaseController
def update def update
if UpdateAccountService.new.call(@account, account_params) if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id) ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg') redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg')
else else
render :show render :show

View File

@ -155,24 +155,49 @@ module JsonLdHelper
end end
end end
def fetch_resource(uri, id_is_known, on_behalf_of = nil, request_options: {}) # Fetch the resource given by uri.
# @param uri [String]
# @param id_is_known [Boolean]
# @param on_behalf_of [nil, Account]
# @param raise_on_error [Symbol<:all, :temporary, :none>] See {#fetch_resource_without_id_validation} for possible values
def fetch_resource(uri, id_is_known, on_behalf_of = nil, raise_on_error: :none, request_options: {})
unless id_is_known unless id_is_known
json = fetch_resource_without_id_validation(uri, on_behalf_of) json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error)
return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id']) return if !json.is_a?(Hash) || unsupported_uri_scheme?(json['id'])
uri = json['id'] uri = json['id']
end end
json = fetch_resource_without_id_validation(uri, on_behalf_of, request_options: request_options) json = fetch_resource_without_id_validation(uri, on_behalf_of, raise_on_error: raise_on_error, request_options: request_options)
json.present? && json['id'] == uri ? json : nil json.present? && json['id'] == uri ? json : nil
end end
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false, request_options: {}) # Fetch the resource given by uri
#
# If an error is raised, it contains the response and can be captured for handling like
#
# begin
# fetch_resource_without_id_validation(uri, nil, true)
# rescue Mastodon::UnexpectedResponseError => e
# e.response
# end
#
# @param uri [String]
# @param on_behalf_of [nil, Account]
# @param raise_on_error [Symbol<:all, :temporary, :none>]
# - +:all+ - raise if response code is not in the 2xx range
# - +:temporary+ - raise if the response code is not an "unsalvageable error" like a 404
# (see {#response_error_unsalvageable} )
# - +:none+ - do not raise, return +nil+
def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_error: :none, request_options: {})
on_behalf_of ||= Account.representative on_behalf_of ||= Account.representative
build_request(uri, on_behalf_of, options: request_options).perform do |response| build_request(uri, on_behalf_of, options: request_options).perform do |response|
raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error raise Mastodon::UnexpectedResponseError, response if !response_successful?(response) && (
raise_on_error == :all ||
(!response_error_unsalvageable?(response) && raise_on_error == :temporary)
)
body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response) body_to_json(response.body_with_limit) if response.code == 200 && valid_activitypub_content_type?(response)
end end

View File

@ -1,7 +1,7 @@
import './public-path'; import './public-path';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal'; import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';
import { start } from '../mastodon/common'; import { start } from '../mastodon/common';
import { Status } from '../mastodon/features/standalone/status'; import { Status } from '../mastodon/features/standalone/status';

View File

@ -142,6 +142,13 @@ export function fetchAccountFail(id, error) {
}; };
} }
/**
* @param {string} id
* @param {Object} options
* @param {boolean} [options.reblogs]
* @param {boolean} [options.notify]
* @returns {function(): void}
*/
export function followAccount(id, options = { reblogs: true }) { export function followAccount(id, options = { reblogs: true }) {
return (dispatch, getState) => { return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']); const alreadyFollowing = getState().getIn(['relationships', id, 'following']);

View File

@ -29,7 +29,7 @@ const debouncedSave = debounce((dispatch, getState) => {
api().put('/api/web/settings', { data }) api().put('/api/web/settings', { data })
.then(() => dispatch({ type: SETTING_SAVE })) .then(() => dispatch({ type: SETTING_SAVE }))
.catch(error => dispatch(showAlertForError(error))); .catch(error => dispatch(showAlertForError(error)));
}, 5000, { trailing: true }); }, 2000, { leading: true, trailing: true });
export function saveSettings() { export function saveSettings() {
return (dispatch, getState) => debouncedSave(dispatch, getState); return (dispatch, getState) => debouncedSave(dispatch, getState);

View File

@ -139,7 +139,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id)); dispatch(deleteStatusRequest(id));
api().delete(`/api/v1/statuses/${id}`).then(response => { api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
dispatch(deleteStatusSuccess(id)); dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account)); dispatch(importFetchedAccount(response.data.account));

View File

@ -4,8 +4,12 @@ import type {
ApiPrivacyPolicyJSON, ApiPrivacyPolicyJSON,
} from 'flavours/glitch/api_types/instance'; } from 'flavours/glitch/api_types/instance';
export const apiGetTermsOfService = () => export const apiGetTermsOfService = (version?: string) =>
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service'); apiRequestGet<ApiTermsOfServiceJSON>(
version
? `v1/instance/terms_of_service/${version}`
: 'v1/instance/terms_of_service',
);
export const apiGetPrivacyPolicy = () => export const apiGetPrivacyPolicy = () =>
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy'); apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');

View File

@ -1,5 +1,7 @@
export interface ApiTermsOfServiceJSON { export interface ApiTermsOfServiceJSON {
updated_at: string; effective_date: string;
effective: boolean;
succeeded_by: string | null;
content: string; content: string;
} }

View File

@ -8,7 +8,7 @@ import type {
UsePopperOptions, UsePopperOptions,
} from 'react-overlays/esm/usePopper'; } from 'react-overlays/esm/usePopper';
import { useSelectableClick } from '@/hooks/useSelectableClick'; import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue; const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const popperConfig = { strategy: 'fixed' } as UsePopperOptions;

View File

@ -1,8 +1,7 @@
import { useHovering } from 'flavours/glitch/hooks/useHovering';
import { autoPlayGif } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account'; import type { Account } from 'flavours/glitch/models/account';
import { useHovering } from '../hooks/useHovering';
import { autoPlayGif } from '../initial_state';
interface Props { interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there

View File

@ -57,7 +57,7 @@ export const FollowButton: React.FC<{
); );
} }
if (!relationship) return; if (!relationship || !accountId) return;
if (accountId === me) { if (accountId === me) {
return; return;

View File

@ -1,4 +1,4 @@
import { useHovering } from '@/hooks/useHovering'; import { useHovering } from 'flavours/glitch/hooks/useHovering';
import { autoPlayGif } from 'flavours/glitch/initial_state'; import { autoPlayGif } from 'flavours/glitch/initial_state';
export const GIF: React.FC<{ export const GIF: React.FC<{

View File

@ -151,7 +151,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
<Sparklines <Sparklines
width={50} width={50}
height={28} height={28}
data={history ? history : Array.from(Array(7)).map(() => 0)} data={history ?? Array.from(Array(7)).map(() => 0)}
> >
<SparklinesCurve style={{ fill: 'none' }} /> <SparklinesCurve style={{ fill: 'none' }} />
</Sparklines> </Sparklines>

View File

@ -153,6 +153,7 @@ export class IconButton extends PureComponent<Props, States> {
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}

View File

@ -81,6 +81,7 @@ class ScrollableList extends PureComponent {
bindToDocument: PropTypes.bool, bindToDocument: PropTypes.bool,
preventScroll: PropTypes.bool, preventScroll: PropTypes.bool,
footer: PropTypes.node, footer: PropTypes.node,
className: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -325,7 +326,7 @@ class ScrollableList extends PureComponent {
}; };
render () { render () {
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
const childrenCount = Children.count(children); const childrenCount = Children.count(children);
@ -336,9 +337,9 @@ class ScrollableList extends PureComponent {
if (showLoading) { if (showLoading) {
scrollableArea = ( scrollableArea = (
<div className='scrollable scrollable--flex' ref={this.setRef}> <div className='scrollable scrollable--flex' ref={this.setRef}>
<div role='feed' className='item-list'>
{prepend} {prepend}
</div>
<div role='feed' className='item-list' />
<div className='scrollable__append'> <div className='scrollable__append'>
<LoadingIndicator /> <LoadingIndicator />
@ -350,9 +351,9 @@ class ScrollableList extends PureComponent {
} else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) { } else if (isLoading || childrenCount > 0 || hasMore || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend} {prepend}
<div role='feed' className={classNames('item-list', className)}>
{loadPending} {loadPending}
{Children.map(this.props.children, (child, index) => ( {Children.map(this.props.children, (child, index) => (

View File

@ -1,91 +0,0 @@
import { PureComponent } from 'react';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { NavLink } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
class ActionBar extends PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
isStatusesPageActive = (match, location) => {
if (!match) {
return false;
}
return !location.pathname.match(/\/(followers|following)\/?$/);
};
render () {
const { account } = this.props;
if (account.get('suspended')) {
return (
<div>
<div className='account__disclaimer'>
<Icon id='info-circle' icon={InfoIcon} />
<FormattedMessage
id='account.suspended_disclaimer_full'
defaultMessage='This user has been suspended by a moderator.'
/>
</div>
</div>
);
}
let extraInfo = '';
if (account.get('acct') !== account.get('username')) {
extraInfo = (
<div className='account__disclaimer'>
<Icon id='info-circle' icon={InfoIcon} />
<div>
<FormattedMessage
id='account.disclaimer_full'
defaultMessage="Information below may reflect the user's profile incompletely."
/>
{' '}
<a target='_blank' rel='noopener' href={account.get('url')}>
<FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' />
</a>
</div>
</div>
);
}
return (
<div>
{extraInfo}
<div className='account__action-bar'>
<div className='account__action-bar-links'>
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}`}>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
<strong><FormattedNumber value={account.get('statuses_count')} /></strong>
</NavLink>
<NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/following`}>
<FormattedMessage id='account.follows' defaultMessage='Follows' />
<strong><FormattedNumber value={account.get('following_count')} /></strong>
</NavLink>
<NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/@${account.get('acct')}/followers`}>
<FormattedMessage id='account.followers' defaultMessage='Followers' />
<strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong>
</NavLink>
</div>
</div>
</div>
);
}
}
export default ActionBar;

View File

@ -0,0 +1,106 @@
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { NavLink } from 'react-router-dom';
import type { NavLinkProps } from 'react-router-dom';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
import type { Account } from 'flavours/glitch/models/account';
const isStatusesPageActive: NavLinkProps['isActive'] = (match, location) => {
if (!match) {
return false;
}
return !/\/(followers|following)\/?$/.exec(location.pathname);
};
export const ActionBar: React.FC<{ account: Account }> = ({ account }) => {
if (account.suspended) {
return (
<div>
<div className='account__disclaimer'>
<Icon id='info-circle' icon={InfoIcon} />
<FormattedMessage
id='account.suspended_disclaimer_full'
defaultMessage='This user has been suspended by a moderator.'
/>
</div>
</div>
);
}
let extraInfo = null;
if (account.get('acct') !== account.get('username')) {
extraInfo = (
<div className='account__disclaimer'>
<Icon id='info-circle' icon={InfoIcon} />
<div>
<FormattedMessage
id='account.disclaimer_full'
defaultMessage="Information below may reflect the user's profile incompletely."
/>{' '}
<a target='_blank' rel='noopener' href={account.get('url')}>
<FormattedMessage
id='account.view_full_profile'
defaultMessage='View full profile'
/>
</a>
</div>
</div>
);
}
return (
<div>
{extraInfo}
<div className='account__action-bar'>
<div className='account__action-bar-links'>
<NavLink
isActive={isStatusesPageActive}
activeClassName='active'
className='account__action-bar__tab'
to={`/@${account.get('acct')}`}
>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
<strong>
<FormattedNumber value={account.get('statuses_count')} />
</strong>
</NavLink>
<NavLink
exact
activeClassName='active'
className='account__action-bar__tab'
to={`/@${account.get('acct')}/following`}
>
<FormattedMessage id='account.follows' defaultMessage='Follows' />
<strong>
<FormattedNumber value={account.get('following_count')} />
</strong>
</NavLink>
<NavLink
exact
activeClassName='active'
className='account__action-bar__tab'
to={`/@${account.get('acct')}/followers`}
>
<FormattedMessage
id='account.followers'
defaultMessage='Followers'
/>
<strong>
{account.get('followers_count') < 0 ? (
'-'
) : (
<FormattedNumber value={account.get('followers_count')} />
)}
</strong>
</NavLink>
</div>
</div>
</div>
);
};

View File

@ -1,415 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
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 { Avatar } from 'flavours/glitch/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
import { Icon } from 'flavours/glitch/components/icon';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'flavours/glitch/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import AccountNoteContainer from '../containers/account_note_container';
import FollowRequestNoteContainer from '../containers/follow_request_note_container';
import { DomainPill } from './domain_pill';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
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' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned 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' },
});
const titleFromAccount = account => {
const displayName = account.get('display_name');
const acct = account.get('acct') === account.get('username') ? `${account.get('username')}@${localDomain}` : account.get('acct');
const prefix = displayName.trim().length === 0 ? account.get('username') : displayName;
return `${prefix} (@${acct})`;
};
const dateFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
class Header extends ImmutablePureComponent {
static propTypes = {
identity: identityContextPropShape,
account: ImmutablePropTypes.record,
identity_props: ImmutablePropTypes.list,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onNotifyToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
...WithRouterPropTypes,
};
openEditProfile = () => {
window.open(profileLink, '_blank');
};
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
handleAvatarClick = e => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onOpenAvatar();
}
};
handleShare = () => {
const { account } = this.props;
navigator.share({
url: account.get('url'),
}).catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
};
render () {
const { account, hidden, intl } = this.props;
const { signedIn, permissions } = this.props.identity;
if (!account) {
return null;
}
const suspended = account.get('suspended');
const isRemote = account.get('acct') !== account.get('username');
const remoteDomain = isRemote ? account.get('acct').split('@')[1] : null;
let actionBtn, bellBtn, lockedIcon, shareBtn;
let info = [];
let menu = [];
if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>);
} else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
info.push(<span className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>);
}
if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
info.push(<span className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>);
} else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
info.push(<span className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>);
}
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsActiveIcon : NotificationsIcon} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if ('share' in navigator) {
shareBtn = <IconButton className='optional' iconComponent={ShareIcon} title={intl.formatMessage(messages.share, { name: account.get('username') })} onClick={this.handleShare} />;
} else {
shareBtn = <CopyIconButton className='optional' title={intl.formatMessage(messages.copy)} value={account.get('url')} />;
}
if (me !== account.get('id')) {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = <Button disabled><LoadingIndicator /></Button>;
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
}
} else if (profileLink) {
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
}
if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
actionBtn = '';
}
if (account.get('suspended') && !account.getIn(['relationship', 'following'])) {
actionBtn = '';
}
if (account.get('locked')) {
lockedIcon = <Icon id='lock' icon={LockIcon} title={intl.formatMessage(messages.account_locked)} />;
}
if (signedIn && account.get('id') !== me && !account.get('suspended')) {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
menu.push(null);
}
if (isRemote) {
menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: account.get('url') });
menu.push(null);
}
if (account.get('id') === me) {
if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else if (signedIn) {
if (account.getIn(['relationship', 'following'])) {
if (!account.getIn(['relationship', 'muting'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
} else {
menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
}
menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages });
menu.push(null);
}
menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
menu.push(null);
}
if (account.getIn(['relationship', 'muting'])) {
menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
} else {
menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute, dangerous: true });
}
if (account.getIn(['relationship', 'blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
} else {
menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock, dangerous: true });
}
if (!account.get('suspended')) {
menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport, dangerous: true });
}
}
if (signedIn && isRemote) {
menu.push(null);
if (account.getIn(['relationship', 'domain_blocking'])) {
menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain: remoteDomain }), action: this.props.onUnblockDomain });
} else {
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain: remoteDomain }), action: this.props.onBlockDomain, dangerous: true });
}
}
if (account.get('id') !== me && ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) });
}
if (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION) {
menu.push({ text: intl.formatMessage(messages.admin_domain, { domain: remoteDomain }), href: `/admin/instances/${remoteDomain}` });
}
}
const content = { __html: account.get('note_emojified') };
const displayNameHtml = { __html: account.get('display_name_html') };
const fields = account.get('fields');
const isLocal = account.get('acct').indexOf('@') === -1;
const username = account.get('acct').split('@')[0];
const domain = isLocal ? localDomain : account.get('acct').split('@')[1];
const isIndexable = !account.get('noindex');
const badges = [];
if (account.get('bot')) {
badges.push(<AutomatedBadge key='bot-badge' />);
} else if (account.get('group')) {
badges.push(<GroupBadge key='group-badge' />);
}
account.get('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={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
{!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
<div className='account__header__image'>
<div className='account__header__info'>
{info}
</div>
{!(suspended || hidden) && <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />}
</div>
<div className='account__header__bar'>
<div className='account__header__tabs'>
<a className='avatar' href={account.get('avatar')} rel='noopener' target='_blank' onClick={this.handleAvatarClick}>
<Avatar account={suspended || hidden ? undefined : account} size={90} />
</a>
<div className='account__header__tabs__buttons'>
{!hidden && bellBtn}
{!hidden && shareBtn}
<DropdownMenuContainer disabled={menu.length === 0} items={menu} icon='ellipsis-v' iconComponent={MoreHorizIcon} size={24} direction='right' />
{!hidden && actionBtn}
</div>
</div>
<div className='account__header__tabs__name'>
<h1>
<span dangerouslySetInnerHTML={displayNameHtml} />
<small>
<span>@{username}<span className='invisible'>@{domain}</span></span>
<DomainPill username={username} domain={domain} isSelf={me === account.get('id')} />
{lockedIcon}
</small>
</h1>
</div>
{badges.length > 0 && (
<div className='account__header__badges'>
{badges}
</div>
)}
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{(account.get('id') !== me && signedIn) && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content translate' dangerouslySetInnerHTML={content} />}
<div className='account__header__fields'>
<dl>
<dt><FormattedMessage id='account.joined_short' defaultMessage='Joined' /></dt>
<dd>{intl.formatDate(account.get('created_at'), { year: 'numeric', month: 'short', day: '2-digit' })}</dd>
</dl>
{fields.map((pair, i) => (
<dl key={i} className={classNames({ verified: pair.get('verified_at') })}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} className='translate' />
<dd className='translate' title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' icon={CheckIcon} className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd>
</dl>
))}
</div>
</div>
</div>
)}
</div>
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
<link rel='canonical' href={account.get('url')} />
</Helmet>
</div>
);
}
}
export default withRouter(withIdentity(injectIntl(Header)));

View File

@ -15,11 +15,15 @@ import {
useBlurhash, useBlurhash,
} from 'flavours/glitch/initial_state'; } from 'flavours/glitch/initial_state';
import type { Status, MediaAttachment } from 'flavours/glitch/models/status'; import type { Status, MediaAttachment } from 'flavours/glitch/models/status';
import { useAppSelector } from 'flavours/glitch/store';
export const MediaItem: React.FC<{ export const MediaItem: React.FC<{
attachment: MediaAttachment; attachment: MediaAttachment;
onOpenMedia: (arg0: MediaAttachment) => void; onOpenMedia: (arg0: MediaAttachment) => void;
}> = ({ attachment, onOpenMedia }) => { }> = ({ attachment, onOpenMedia }) => {
const account = useAppSelector((state) =>
state.accounts.get(attachment.getIn(['status', 'account']) as string),
);
const [visible, setVisible] = useState( const [visible, setVisible] = useState(
(displayMedia !== 'hide_all' && (displayMedia !== 'hide_all' &&
!attachment.getIn(['status', 'sensitive'])) || !attachment.getIn(['status', 'sensitive'])) ||
@ -70,7 +74,7 @@ export const MediaItem: React.FC<{
attachment.get('description')) as string | undefined; attachment.get('description')) as string | undefined;
const previewUrl = attachment.get('preview_url') as string; const previewUrl = attachment.get('preview_url') as string;
const fullUrl = attachment.get('url') as string; const fullUrl = attachment.get('url') as string;
const avatarUrl = status.getIn(['account', 'avatar_static']) as string; const avatarUrl = account?.avatar_static;
const lang = status.get('language') as string; const lang = status.get('language') as string;
const blurhash = attachment.get('blurhash') as string; const blurhash = attachment.get('blurhash') as string;
const statusUrl = status.get('url') as string; const statusUrl = status.get('url') as string;

View File

@ -1,239 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal';
import { LoadMore } from 'flavours/glitch/components/load_more';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountGallery } from 'flavours/glitch/selectors';
import { expandAccountMediaTimeline } from '../../actions/timelines';
import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column';
import { MediaItem } from './components/media_item';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
isAccount: !!state.getIn(['accounts', accountId]),
attachments: getAccountGallery(state, accountId),
isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
};
};
class LoadMoreMedia extends ImmutablePureComponent {
static propTypes = {
maxId: PropTypes.string,
onLoadMore: PropTypes.func.isRequired,
};
handleLoadMore = () => {
this.props.onLoadMore(this.props.maxId);
};
render () {
return (
<LoadMore
disabled={this.props.disabled}
onClick={this.handleLoadMore}
/>
);
}
}
class AccountGallery extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
suspended: PropTypes.bool,
multiColumn: PropTypes.bool,
};
state = {
width: 323,
};
_load () {
const { accountId, isAccount, dispatch } = this.props;
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(expandAccountMediaTimeline(accountId));
}
componentDidMount () {
const { params: { acct }, accountId, dispatch } = this.props;
if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
}
}
handleHeaderClick = () => {
this.column.scrollTop();
};
handleScrollToBottom = () => {
if (this.props.hasMore) {
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
}
};
handleScroll = e => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
if (150 > offset && !this.props.isLoading) {
this.handleScrollToBottom();
}
};
handleLoadMore = maxId => {
this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
};
handleLoadOlder = e => {
e.preventDefault();
this.handleScrollToBottom();
};
setColumnRef = c => {
this.column = c;
};
handleOpenMedia = attachment => {
const { dispatch } = this.props;
const statusId = attachment.getIn(['status', 'id']);
const lang = attachment.getIn(['status', 'language']);
if (attachment.get('type') === 'video') {
dispatch(openModal({
modalType: 'VIDEO',
modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
}));
} else if (attachment.get('type') === 'audio') {
dispatch(openModal({
modalType: 'AUDIO',
modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
}));
} else {
const media = attachment.getIn(['status', 'media_attachments']);
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
dispatch(openModal({
modalType: 'MEDIA',
modalProps: { media, index, statusId, lang },
}));
}
};
handleRef = c => {
if (c) {
this.setState({ width: c.offsetWidth });
}
};
render () {
const { attachments, isLoading, hasMore, isAccount, multiColumn, suspended } = this.props;
const { width } = this.state;
if (!isAccount) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!attachments && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
return (
<Column ref={this.setColumnRef}>
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<ScrollContainer scrollKey='account_gallery'>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<HeaderContainer accountId={this.props.accountId} />
{suspended ? (
<div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />
</div>
) : (
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder}
</div>
)}
{isLoading && attachments.size === 0 && (
<div className='scrollable__append'>
<LoadingIndicator />
</div>
)}
</div>
</ScrollContainer>
</Column>
);
}
}
export default connect(mapStateToProps)(AccountGallery);

View File

@ -0,0 +1,291 @@
import { useEffect, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { useParams } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
import { lookupAccount, fetchAccount } from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal';
import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
import { LimitedAccountHint } from 'flavours/glitch/features/account_timeline/components/limited_account_hint';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import Column from 'flavours/glitch/features/ui/components/column';
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import type { RootState } from 'flavours/glitch/store';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { MediaItem } from './components/media_item';
const messages = defineMessages({
profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
});
const getAccountGallery = createSelector(
[
(state: RootState, accountId: string) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:media`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
(state: RootState) => state.statuses,
],
(statusIds, statuses) => {
let items = ImmutableList<MediaAttachment>();
statusIds.forEach((statusId) => {
const status = statuses.get(statusId) as
| ImmutableMap<string, unknown>
| undefined;
if (status) {
items = items.concat(
(
status.get('media_attachments') as ImmutableList<MediaAttachment>
).map((media) => media.set('status', status)),
);
}
});
return items;
},
);
interface Params {
acct?: string;
id?: string;
}
const RemoteHint: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
const acct = account?.acct;
const url = account?.url;
const domain = acct ? acct.split('@')[1] : undefined;
if (!url) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};
export const AccountGallery: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const intl = useIntl();
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch();
const accountId = useAppSelector(
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
);
const attachments = useAppSelector((state) =>
accountId
? getAccountGallery(state, accountId)
: ImmutableList<MediaAttachment>(),
);
const isLoading = useAppSelector((state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:media`,
'isLoading',
]),
);
const hasMore = useAppSelector((state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:media`,
'hasMore',
]),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const blockedBy = useAppSelector(
(state) =>
state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
);
const suspended = useAppSelector(
(state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
);
const isAccount = !!account;
const remote = account?.acct !== account?.username;
const hidden = useAppSelector((state) =>
accountId ? getAccountHidden(state, accountId) : false,
);
const maxId = attachments.last()?.getIn(['status', 'id']) as
| string
| undefined;
useEffect(() => {
if (!accountId) {
dispatch(lookupAccount(acct));
}
}, [dispatch, accountId, acct]);
useEffect(() => {
if (accountId && !isAccount) {
dispatch(fetchAccount(accountId));
}
if (accountId && isAccount) {
void dispatch(expandAccountMediaTimeline(accountId));
}
}, [dispatch, accountId, isAccount]);
const handleLoadMore = useCallback(() => {
if (maxId) {
void dispatch(expandAccountMediaTimeline(accountId, { maxId }));
}
}, [dispatch, accountId, maxId]);
const handleOpenMedia = useCallback(
(attachment: MediaAttachment) => {
const statusId = attachment.getIn(['status', 'id']);
const lang = attachment.getIn(['status', 'language']);
if (attachment.get('type') === 'video') {
dispatch(
openModal({
modalType: 'VIDEO',
modalProps: {
media: attachment,
statusId,
lang,
options: { autoPlay: true },
},
}),
);
} else if (attachment.get('type') === 'audio') {
dispatch(
openModal({
modalType: 'AUDIO',
modalProps: {
media: attachment,
statusId,
lang,
options: { autoPlay: true },
},
}),
);
} else {
const media = attachment.getIn([
'status',
'media_attachments',
]) as ImmutableList<MediaAttachment>;
const index = media.findIndex(
(x) => x.get('id') === attachment.get('id'),
);
dispatch(
openModal({
modalType: 'MEDIA',
modalProps: { media, index, statusId, lang },
}),
);
}
},
[dispatch],
);
if (accountId && !isAccount) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
let emptyMessage;
if (accountId) {
if (suspended) {
emptyMessage = (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
emptyMessage = (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (remote && attachments.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} />;
} else {
emptyMessage = (
<FormattedMessage
id='empty_column.account_timeline'
defaultMessage='No posts found'
/>
);
}
}
const forceEmptyState = suspended || blockedBy || hidden;
return (
<Column
icon='user-circle'
iconComponent={PersonIcon}
heading={intl.formatMessage(messages.profile)}
alwaysShowBackButton
>
<ScrollableList
className='account-gallery__container'
prepend={
accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)
}
alwaysPrepend
append={remote && accountId && <RemoteHint accountId={accountId} />}
scrollKey='account_gallery'
isLoading={isLoading}
hasMore={!forceEmptyState && hasMore}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{attachments.map((attachment) => (
<MediaItem
key={attachment.get('id') as string}
attachment={attachment}
onOpenMedia={handleOpenMedia}
/>
))}
</ScrollableList>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountGallery;

View File

@ -1,158 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ActionBar from '../../account/components/action_bar';
import InnerHeader from '../../account/components/header';
import MemorialNote from './memorial_note';
import MovedNote from './moved_note';
class Header extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired,
onEndorseToggle: PropTypes.func.isRequired,
onAddToList: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
hideTabs: PropTypes.bool,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMention = () => {
this.props.onMention(this.props.account);
};
handleDirect = () => {
this.props.onDirect(this.props.account);
};
handleReport = () => {
this.props.onReport(this.props.account);
};
handleReblogToggle = () => {
this.props.onReblogToggle(this.props.account);
};
handleNotifyToggle = () => {
this.props.onNotifyToggle(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
handleBlockDomain = () => {
this.props.onBlockDomain(this.props.account);
};
handleUnblockDomain = () => {
const domain = this.props.account.get('acct').split('@')[1];
if (!domain) return;
this.props.onUnblockDomain(domain);
};
handleEndorseToggle = () => {
this.props.onEndorseToggle(this.props.account);
};
handleAddToList = () => {
this.props.onAddToList(this.props.account);
};
handleEditAccountNote = () => {
this.props.onEditAccountNote(this.props.account);
};
handleChangeLanguages = () => {
this.props.onChangeLanguages(this.props.account);
};
handleInteractionModal = () => {
this.props.onInteractionModal(this.props.account);
};
handleOpenAvatar = () => {
this.props.onOpenAvatar(this.props.account);
};
render () {
const { account, hidden, hideTabs } = this.props;
if (account === null) {
return null;
}
return (
<div className='account-timeline__header'>
{(!hidden && account.get('memorial')) && <MemorialNote />}
{(!hidden && account.get('moved')) && <MovedNote from={account} to={account.get('moved')} />}
<InnerHeader
account={account}
onFollow={this.handleFollow}
onBlock={this.handleBlock}
onMention={this.handleMention}
onDirect={this.handleDirect}
onReblogToggle={this.handleReblogToggle}
onNotifyToggle={this.handleNotifyToggle}
onReport={this.handleReport}
onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain}
onUnblockDomain={this.handleUnblockDomain}
onEndorseToggle={this.handleEndorseToggle}
onAddToList={this.handleAddToList}
onEditAccountNote={this.handleEditAccountNote}
onChangeLanguages={this.handleChangeLanguages}
onInteractionModal={this.handleInteractionModal}
onOpenAvatar={this.handleOpenAvatar}
domain={this.props.domain}
hidden={hidden}
/>
<ActionBar
account={account}
/>
{!(hideTabs || hidden) && (
<div className='account__section-headline'>
<NavLink exact to={`/@${account.get('acct')}`}><FormattedMessage id='account.posts' defaultMessage='Posts' /></NavLink>
<NavLink exact to={`/@${account.get('acct')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' /></NavLink>
<NavLink exact to={`/@${account.get('acct')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
</div>
)}
</div>
);
}
}
export default Header;

View File

@ -1,11 +1,12 @@
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
const MemorialNote = () => ( export const MemorialNote: React.FC = () => (
<div className='account-memorial-banner'> <div className='account-memorial-banner'>
<div className='account-memorial-banner__message'> <div className='account-memorial-banner__message'>
<FormattedMessage id='account.in_memoriam' defaultMessage='In Memoriam.' /> <FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</div> </div>
</div> </div>
); );
export default MemorialNote;

View File

@ -1,38 +0,0 @@
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { AvatarOverlay } from '../../../components/avatar_overlay';
import { DisplayName } from '../../../components/display_name';
import { Permalink } from '../../../components/permalink';
export default class MovedNote extends ImmutablePureComponent {
static propTypes = {
from: ImmutablePropTypes.map.isRequired,
to: ImmutablePropTypes.map.isRequired,
};
render () {
const { from, to } = this.props;
return (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage id='account.moved_to' defaultMessage='{name} has indicated that their new account is now:' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: from.get('display_name_html') }} /></bdi> }} />
</div>
<div className='moved-account-banner__action'>
<Permalink to={`/@${to.get('acct')}`} href={to.get('url')} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</Permalink>
<Permalink to={`/@${to.get('acct')}`} href={to.get('url')} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Permalink>
</div>
</div>
);
}
}

View File

@ -0,0 +1,56 @@
import { FormattedMessage } from 'react-intl';
import { AvatarOverlay } from 'flavours/glitch/components/avatar_overlay';
import { DisplayName } from 'flavours/glitch/components/display_name';
import { Permalink } from 'flavours/glitch/components/permalink';
import { useAppSelector } from 'flavours/glitch/store';
export const MovedNote: React.FC<{
accountId: string;
targetAccountId: string;
}> = ({ accountId, targetAccountId }) => {
const from = useAppSelector((state) => state.accounts.get(accountId));
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
return (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: (
<bdi>
<strong
dangerouslySetInnerHTML={{
__html: from?.display_name_html ?? '',
}}
/>
</bdi>
),
}}
/>
</div>
<div className='moved-account-banner__action'>
<Permalink
to={`/@${to?.acct}`}
href={to?.url}
className='detailed-status__display-name'
>
<div className='detailed-status__display-avatar'>
<AvatarOverlay account={to} friend={from} />
</div>
<DisplayName account={to} />
</Permalink>
<Permalink to={`/@${to?.acct}`} href={to?.url} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Permalink>
</div>
</div>
);
};

View File

@ -1,147 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import {
followAccount,
unblockAccount,
unmuteAccount,
pinAccount,
unpinAccount,
} from '../../../actions/accounts';
import { initBlockModal } from '../../../actions/blocks';
import {
mentionCompose,
directCompose,
} from '../../../actions/compose';
import { initDomainBlockModal, unblockDomain } from '../../../actions/domain_blocks';
import { openModal } from '../../../actions/modal';
import { initMuteModal } from '../../../actions/mutes';
import { initReport } from '../../../actions/reports';
import { makeGetAccount, getAccountHidden } from '../../../selectors';
import Header from '../components/header';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
domain: state.getIn(['meta', 'domain']),
hidden: getAccountHidden(state, accountId),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }));
} else {
dispatch(followAccount(account.get('id')));
}
},
onInteractionModal (account) {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'follow',
accountId: account.get('id'),
url: account.get('uri'),
},
}));
},
onBlock (account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(initBlockModal(account));
}
},
onMention (account) {
dispatch(mentionCompose(account));
},
onDirect (account) {
dispatch(directCompose(account));
},
onReblogToggle (account) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
dispatch(followAccount(account.get('id'), { reblogs: false }));
} else {
dispatch(followAccount(account.get('id'), { reblogs: true }));
}
},
onEndorseToggle (account) {
if (account.getIn(['relationship', 'endorsed'])) {
dispatch(unpinAccount(account.get('id')));
} else {
dispatch(pinAccount(account.get('id')));
}
},
onNotifyToggle (account) {
if (account.getIn(['relationship', 'notifying'])) {
dispatch(followAccount(account.get('id'), { notify: false }));
} else {
dispatch(followAccount(account.get('id'), { notify: true }));
}
},
onReport (account) {
dispatch(initReport(account));
},
onMute (account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
onBlockDomain (account) {
dispatch(initDomainBlockModal(account));
},
onUnblockDomain (domain) {
dispatch(unblockDomain(domain));
},
onAddToList (account) {
dispatch(openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.get('id'),
},
}));
},
onChangeLanguages (account) {
dispatch(openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.get('id'),
},
}));
},
onOpenAvatar (account) {
dispatch(openModal({
modalType: 'IMAGE',
modalProps: {
src: account.get('avatar'),
alt: account.get('acct'),
},
}));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

View File

@ -11,7 +11,7 @@ import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors'; import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector } from 'flavours/glitch/store';
import { lookupAccount, fetchAccount } from '../../actions/accounts'; import { lookupAccount, fetchAccount } from '../../actions/accounts';
@ -21,8 +21,8 @@ import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
import { AccountHeader } from './components/account_header';
import { LimitedAccountHint } from './components/limited_account_hint'; import { LimitedAccountHint } from './components/limited_account_hint';
import HeaderContainer from './containers/header_container';
const emptyList = ImmutableList(); const emptyList = ImmutableList();
@ -199,7 +199,7 @@ class AccountTimeline extends ImmutablePureComponent {
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} /> <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
<StatusList <StatusList
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />} prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'

View File

@ -6,9 +6,9 @@ import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import { useSelectableClick } from '@/hooks/useSelectableClick';
import QuestionMarkIcon from '@/material-icons/400-24px/question_mark.svg?react'; import QuestionMarkIcon from '@/material-icons/400-24px/question_mark.svg?react';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick';
const messages = defineMessages({ const messages = defineMessages({
help: { id: 'info_button.label', defaultMessage: 'Help' }, help: { id: 'info_button.label', defaultMessage: 'Help' },

View File

@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -28,28 +28,30 @@ export const ActionBar = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl(); const intl = useIntl();
const handleLogoutClick = useCallback(() => { const menu = useMemo(() => {
const handleLogoutClick = () => {
dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' }));
}, [dispatch]); };
let menu = []; return ([
{ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' },
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); { text: intl.formatMessage(messages.preferences), href: '/settings/preferences' },
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); { text: intl.formatMessage(messages.pins), to: '/pinned' },
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' }); null,
menu.push(null); { text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' },
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); { text: intl.formatMessage(messages.favourites), to: '/favourites' },
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); { text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' },
menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); { text: intl.formatMessage(messages.lists), to: '/lists' },
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); { text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' },
menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' }); null,
menu.push(null); { text: intl.formatMessage(messages.mutes), to: '/mutes' },
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); { text: intl.formatMessage(messages.blocks), to: '/blocks' },
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); { text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' },
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); { text: intl.formatMessage(messages.filters), href: '/filters' },
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); null,
menu.push(null); { text: intl.formatMessage(messages.logout), action: handleLogoutClick },
menu.push({ text: intl.formatMessage(messages.logout), action: handleLogoutClick }); ]);
}, [intl, dispatch]);
return ( return (
<DropdownMenuContainer <DropdownMenuContainer

View File

@ -132,7 +132,7 @@ class ComposeForm extends ImmutablePureComponent {
return; return;
} }
this.props.onSubmit(missingAltTextModal && this.props.missingAltText, overridePrivacy); this.props.onSubmit(missingAltTextModal && this.props.missingAltText && this.props.privacy !== 'direct', overridePrivacy);
if (e) { if (e) {
e.preventDefault(); e.preventDefault();

View File

@ -12,12 +12,15 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react'; import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import emojiCompressed from 'flavours/glitch/features/emoji/emoji_compressed';
import { useSystemEmojiFont } from 'flavours/glitch/initial_state'; import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
import { assetHost } from 'flavours/glitch/utils/config'; import { assetHost } from 'flavours/glitch/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const nimblePickerData = emojiCompressed[5];
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
@ -38,15 +41,18 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`; const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const notFoundFn = () => ( const notFoundFn = () => (
<div className='emoji-mart-no-results'> <div className='emoji-mart-no-results'>
<Emoji <Emoji
data={nimblePickerData}
emoji='sleuth_or_spy' emoji='sleuth_or_spy'
set='twitter' set='twitter'
size={32} size={32}
sheetSize={32} sheetSize={32}
sheetColumns={62}
sheetRows={62}
backgroundImageFn={backgroundImageFn} backgroundImageFn={backgroundImageFn}
/> />
@ -105,12 +111,12 @@ class ModifierPickerMenu extends PureComponent {
return ( return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}> <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> <button type='button' onClick={this.handleClick} data-index={1}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> <button type='button' onClick={this.handleClick} data-index={2}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> <button type='button' onClick={this.handleClick} data-index={3}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> <button type='button' onClick={this.handleClick} data-index={4}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> <button type='button' onClick={this.handleClick} data-index={5}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button> <button type='button' onClick={this.handleClick} data-index={6}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
</div> </div>
); );
} }
@ -145,7 +151,7 @@ class ModifierPicker extends PureComponent {
return ( return (
<div className='emoji-picker-dropdown__modifiers'> <div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /> <Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} /> <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div> </div>
); );
@ -281,6 +287,9 @@ class EmojiPickerMenuImpl extends PureComponent {
return ( return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker <EmojiPicker
data={nimblePickerData}
sheetColumns={62}
sheetRows={62}
perLine={8} perLine={8}
emojiSize={22} emojiSize={22}
sheetSize={32} sheetSize={32}

View File

@ -25,10 +25,9 @@ import { LoadMore } from 'flavours/glitch/components/load_more';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { RadioButton } from 'flavours/glitch/components/radio_button'; import { RadioButton } from 'flavours/glitch/components/radio_button';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { useSearchParam } from '../../hooks/useSearchParam';
import { AccountCard } from './components/account_card'; import { AccountCard } from './components/account_card';
const messages = defineMessages({ const messages = defineMessages({

View File

@ -45,6 +45,7 @@ type EmojiCompressed = [
Category[], Category[],
Data['aliases'], Data['aliases'],
EmojisWithoutShortCodes, EmojisWithoutShortCodes,
Data,
]; ];
/* /*

View File

@ -9,18 +9,91 @@
// This version comment should be bumped each time the emoji data is changed // This version comment should be bumped each time the emoji data is changed
// to ensure that the prevaled file is regenerated by Babel // to ensure that the prevaled file is regenerated by Babel
// version: 2 // version: 3
const { emojiIndex } = require('emoji-mart'); // This json file contains the names of the categories.
let data = require('emoji-mart/data/all.json'); const emojiMart5LocalesData = require('@emoji-mart/data/i18n/en.json');
const emojiMart5Data = require('@emoji-mart/data/sets/15/all.json');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data'); const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
const _ = require('lodash');
const emojiMap = require('./emoji_map.json'); const emojiMap = require('./emoji_map.json');
// This json file is downloaded from https://github.com/iamcal/emoji-data/
// and is used to correct the sheet coordinates since we're using that repo's sheet
const emojiSheetData = require('./emoji_sheet.json');
const { unicodeToFilename } = require('./unicode_to_filename'); const { unicodeToFilename } = require('./unicode_to_filename');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name'); const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
// Grabbed from `emoji_utils` to avoid circular dependency
function unifiedToNative(unified) {
let unicodes = unified.split('-'),
codePoints = unicodes.map((u) => `0x${u}`);
return String.fromCodePoint(...codePoints);
}
let data = {
compressed: true,
categories: emojiMart5Data.categories.map(cat => {
return {
...cat,
name: emojiMart5LocalesData.categories[cat.id]
};
}),
aliases: emojiMart5Data.aliases,
emojis: _(emojiMart5Data.emojis).values().map(emoji => {
let skin_variations = {};
const unified = emoji.skins[0].unified.toUpperCase();
const emojiFromRawData = emojiSheetData.find(e => e.unified === unified);
if (!emojiFromRawData) {
return undefined;
}
if (emoji.skins.length > 1) {
const [, ...nonDefaultSkins] = emoji.skins;
nonDefaultSkins.forEach(skin => {
const [matchingRawCodePoints,matchingRawEmoji] = Object.entries(emojiFromRawData.skin_variations).find((pair) => {
const [, value] = pair;
return value.unified.toLowerCase() === skin.unified;
});
if (matchingRawEmoji && matchingRawCodePoints) {
// At the time of writing, the json from `@emoji-mart/data` doesn't have data
// for emoji like `woman-heart-woman` with two different skin tones.
const skinToneCode = matchingRawCodePoints.split('-')[0];
skin_variations[skinToneCode] = {
unified: matchingRawEmoji.unified.toUpperCase(),
non_qualified: null,
sheet_x: matchingRawEmoji.sheet_x,
sheet_y: matchingRawEmoji.sheet_y,
has_img_twitter: true,
native: unifiedToNative(matchingRawEmoji.unified.toUpperCase())
};
}
});
}
return {
a: emoji.name,
b: unified,
c: undefined,
f: true,
j: [emoji.id, ...emoji.keywords],
k: [emojiFromRawData.sheet_x, emojiFromRawData.sheet_y],
m: emoji.emoticons?.[0],
l: emoji.emoticons,
o: emoji.version,
id: emoji.id,
skin_variations,
native: unifiedToNative(unified.toUpperCase())
};
}).compact().keyBy(e => e.id).mapValues(e => _.omit(e, 'id')).value()
};
if (data.compressed) { if (data.compressed) {
data = emojiMartUncompress(data); emojiMartUncompress(data);
} }
const emojiMartData = data; const emojiMartData = data;
@ -32,15 +105,10 @@ const shortcodeMap = {};
const shortCodesToEmojiData = {}; const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = []; const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiMart5Data.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key]; let emoji = emojiMart5Data.emojis[key];
// Emojis with skin tone modifiers are stored like this shortcodeMap[emoji.skins[0].native] = emoji.id;
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
}); });
const stripModifiers = unicode => { const stripModifiers = unicode => {
@ -84,13 +152,9 @@ Object.keys(emojiMap).forEach(key => {
} }
}); });
Object.keys(emojiIndex.emojis).forEach(key => { Object.keys(emojiMartData.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key]; let emoji = emojiMartData.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
const { native } = emoji; const { native } = emoji;
let { short_names, search, unified } = emojiMartData.emojis[key]; let { short_names, search, unified } = emojiMartData.emojis[key];
@ -135,4 +199,5 @@ module.exports = JSON.parse(JSON.stringify([
emojiMartData.categories, emojiMartData.categories,
emojiMartData.aliases, emojiMartData.aliases,
emojisWithoutShortCodes, emojisWithoutShortCodes,
emojiMartData
])); ]));

View File

@ -8,14 +8,15 @@ import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed'; import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name'; import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = { type Emojis = Record<
[key in NonNullable<keyof ShortCodesToEmojiData>]: { NonNullable<keyof ShortCodesToEmojiData>,
{
native: BaseEmoji['native']; native: BaseEmoji['native'];
search: Search; search: Search;
short_names: Emoji['short_names']; short_names: Emoji['short_names'];
unified: Emoji['unified']; unified: Emoji['unified'];
}; }
}; >;
const [ const [
shortCodesToEmojiData, shortCodesToEmojiData,

View File

@ -1,5 +1,5 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji'; import Emoji from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import Picker from 'emoji-mart/dist-es/components/picker/picker'; import Picker from 'emoji-mart/dist-es/components/picker/nimble-picker';
export { export {
Picker, Picker,

File diff suppressed because one or more lines are too long

View File

@ -9,12 +9,13 @@ import type {
import emojiCompressed from './emoji_compressed'; import emojiCompressed from './emoji_compressed';
import { unicodeToFilename } from './unicode_to_filename'; import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = { type UnicodeMapping = Record<
[key in FilenameData[number][0]]: { FilenameData[number][0],
{
shortCode: ShortCodesToEmojiDataKey; shortCode: ShortCodesToEmojiDataKey;
filename: FilenameData[number][number]; filename: FilenameData[number][number];
}; }
}; >;
const [ const [
shortCodesToEmojiData, shortCodesToEmojiData,

View File

@ -10,9 +10,10 @@ import { debounce } from 'lodash';
import { Account } from 'flavours/glitch/components/account'; import { Account } from 'flavours/glitch/components/account';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors'; import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector } from 'flavours/glitch/store';
import { import {
@ -25,7 +26,6 @@ import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import ProfileColumnHeader from '../account/components/profile_column_header'; import ProfileColumnHeader from '../account/components/profile_column_header';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => { const mapStateToProps = (state, { params: { acct, id } }) => {
@ -172,7 +172,7 @@ class Followers extends ImmutablePureComponent {
hasMore={!forceEmptyState && hasMore} hasMore={!forceEmptyState && hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}

View File

@ -10,9 +10,10 @@ import { debounce } from 'lodash';
import { Account } from 'flavours/glitch/components/account'; import { Account } from 'flavours/glitch/components/account';
import { TimelineHint } from 'flavours/glitch/components/timeline_hint'; import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map'; import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors'; import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector } from 'flavours/glitch/store';
import { import {
@ -25,7 +26,6 @@ import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import ProfileColumnHeader from '../account/components/profile_column_header'; import ProfileColumnHeader from '../account/components/profile_column_header';
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
import HeaderContainer from '../account_timeline/containers/header_container';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => { const mapStateToProps = (state, { params: { acct, id } }) => {
@ -172,7 +172,7 @@ class Following extends ImmutablePureComponent {
hasMore={!forceEmptyState && hasMore} hasMore={!forceEmptyState && hasMore}
isLoading={isLoading} isLoading={isLoading}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs />} prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}

View File

@ -28,7 +28,7 @@ export const ColumnSettings: React.FC = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const onChange = useCallback( const onChange = useCallback(
(key: string, checked: boolean) => { (key: string[], checked: boolean) => {
dispatch(changeSetting(['home', ...key], checked)); dispatch(changeSetting(['home', ...key], checked));
}, },
[dispatch], [dispatch],

View File

@ -4,7 +4,6 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useSearchParam } from '@/hooks/useSearchParam';
import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react'; import FindInPageIcon from '@/material-icons/400-24px/find_in_page.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react'; import SearchIcon from '@/material-icons/400-24px/search.svg?react';
@ -20,6 +19,7 @@ import { Icon } from 'flavours/glitch/components/icon';
import ScrollableList from 'flavours/glitch/components/scrollable_list'; import ScrollableList from 'flavours/glitch/components/scrollable_list';
import Status from 'flavours/glitch/containers/status_container'; import Status from 'flavours/glitch/containers/status_container';
import { Search } from 'flavours/glitch/features/compose/components/search'; import { Search } from 'flavours/glitch/features/compose/components/search';
import { useSearchParam } from 'flavours/glitch/hooks/useSearchParam';
import type { Hashtag as HashtagType } from 'flavours/glitch/models/tags'; import type { Hashtag as HashtagType } from 'flavours/glitch/models/tags';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';

View File

@ -243,12 +243,12 @@ export const DetailedStatus: React.FC<{
); );
mediaIcons.push('video-camera'); mediaIcons.push('video-camera');
} }
} else if (status.get('spoiler_text').length === 0) { } else if (status.get('card')) {
media = ( media = (
<Card <Card
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenMedia={onOpenMedia} onOpenMedia={onOpenMedia}
card={status.get('card', null)} card={status.get('card')}
/> />
); );
mediaIcons.push('link'); mediaIcons.push('link');

View File

@ -8,26 +8,31 @@ import {
} from 'react-intl'; } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Link, useParams } from 'react-router-dom';
import { apiGetTermsOfService } from 'flavours/glitch/api/instance'; import { apiGetTermsOfService } from 'flavours/glitch/api/instance';
import type { ApiTermsOfServiceJSON } from 'flavours/glitch/api_types/instance'; import type { ApiTermsOfServiceJSON } from 'flavours/glitch/api_types/instance';
import { Column } from 'flavours/glitch/components/column'; import { Column } from 'flavours/glitch/components/column';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error'; import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' }, title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' },
}); });
interface Params {
date?: string;
}
const TermsOfService: React.FC<{ const TermsOfService: React.FC<{
multiColumn: boolean; multiColumn: boolean;
}> = ({ multiColumn }) => { }> = ({ multiColumn }) => {
const intl = useIntl(); const intl = useIntl();
const { date } = useParams<Params>();
const [response, setResponse] = useState<ApiTermsOfServiceJSON>(); const [response, setResponse] = useState<ApiTermsOfServiceJSON>();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
apiGetTermsOfService() apiGetTermsOfService(date)
.then((data) => { .then((data) => {
setResponse(data); setResponse(data);
setLoading(false); setLoading(false);
@ -36,7 +41,7 @@ const TermsOfService: React.FC<{
.catch(() => { .catch(() => {
setLoading(false); setLoading(false);
}); });
}, []); }, [date]);
if (!loading && !response) { if (!loading && !response) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />; return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
@ -55,16 +60,15 @@ const TermsOfService: React.FC<{
defaultMessage='Terms of Service' defaultMessage='Terms of Service'
/> />
</h3> </h3>
<p> <p className='prose'>
{response?.effective ? (
<FormattedMessage <FormattedMessage
id='privacy_policy.last_updated' id='privacy_policy.last_updated'
defaultMessage='Last updated {date}' defaultMessage='Last updated {date}'
values={{ values={{
date: loading ? ( date: (
<Skeleton width='10ch' />
) : (
<FormattedDate <FormattedDate
value={response?.updated_at} value={response.effective_date}
year='numeric' year='numeric'
month='short' month='short'
day='2-digit' day='2-digit'
@ -72,6 +76,44 @@ const TermsOfService: React.FC<{
), ),
}} }}
/> />
) : (
<FormattedMessage
id='terms_of_service.effective_as_of'
defaultMessage='Effective as of {date}'
values={{
date: (
<FormattedDate
value={response?.effective_date}
year='numeric'
month='short'
day='2-digit'
/>
),
}}
/>
)}
{response?.succeeded_by && (
<>
{' · '}
<Link to={`/terms-of-service/${response.succeeded_by}`}>
<FormattedMessage
id='terms_of_service.upcoming_changes_on'
defaultMessage='Upcoming changes on {date}'
values={{
date: (
<FormattedDate
value={response.succeeded_by}
year='numeric'
month='short'
day='2-digit'
/>
),
}}
/>
</Link>
</>
)}
</p> </p>
</div> </div>

View File

@ -101,6 +101,7 @@ const EmbedModal: React.FC<{
/> />
<iframe <iframe
// eslint-disable-next-line @typescript-eslint/no-deprecated
frameBorder='0' frameBorder='0'
ref={iframeRef} ref={iframeRef}
sandbox='allow-scripts allow-same-origin' sandbox='allow-scripts allow-same-origin'

View File

@ -214,7 +214,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} /> <WrappedRoute path='/about' component={About} content={children} />
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} /> <WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path='/terms-of-service' component={TermsOfService} content={children} /> <WrappedRoute path='/terms-of-service/:date?' component={TermsOfService} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} /> <WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact /> <Redirect from='/timelines/public' to='/public' exact />

View File

@ -0,0 +1,24 @@
interface BaseMenuItem {
text: string;
dangerous?: boolean;
}
interface ActionMenuItem extends BaseMenuItem {
action: () => void;
}
interface LinkMenuItem extends BaseMenuItem {
to: string;
}
interface ExternalLinkMenuItem extends BaseMenuItem {
href: string;
}
export type MenuItem =
| ActionMenuItem
| LinkMenuItem
| ExternalLinkMenuItem
| null;
export type DropdownMenu = MenuItem[];

View File

@ -318,12 +318,26 @@ const expiresInFromExpiresAt = expires_at => {
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase(); prefix = prefix.toLowerCase();
if (suggestions.length < 4) { if (suggestions.length < 4) {
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); suggestions = suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
} else {
return suggestions;
} }
// Prefer capitalization from personal history, unless personal history is all lower-case
const fixSuggestionCapitalization = (suggestion) => {
if (suggestion.type !== 'hashtag')
return suggestion;
const tagFromHistory = tagHistory.find((tag) => tag.localeCompare(suggestion.name, undefined, { sensitivity: 'accent' }) === 0);
if (!tagFromHistory || tagFromHistory.toLowerCase() === tagFromHistory)
return suggestion;
return { ...suggestion, name: tagFromHistory };
};
return suggestions.map(fixSuggestionCapitalization);
}; };
const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {

View File

@ -1,6 +1,7 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import { Record as ImmutableRecord } from 'immutable'; import { Record as ImmutableRecord } from 'immutable';
import { me } from 'flavours/glitch/initial_state';
import { accountDefaultValues } from 'flavours/glitch/models/account'; import { accountDefaultValues } from 'flavours/glitch/models/account';
import type { Account, AccountShape } from 'flavours/glitch/models/account'; import type { Account, AccountShape } from 'flavours/glitch/models/account';
import type { Relationship } from 'flavours/glitch/models/relationship'; import type { Relationship } from 'flavours/glitch/models/relationship';
@ -45,3 +46,16 @@ export function makeGetAccount() {
}, },
); );
} }
export const getAccountHidden = createSelector(
[
(state: RootState, id: string) => state.accounts.get(id)?.hidden,
(state: RootState, id: string) =>
state.relationships.get(id)?.following ||
state.relationships.get(id)?.requested,
(state: RootState, id: string) => id === me,
],
(hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested);
},
);

View File

@ -92,29 +92,6 @@ export const makeGetReport = () => createSelector([
(state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]), (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
], (base, targetAccount) => base.set('target_account', targetAccount)); ], (base, targetAccount) => base.set('target_account', targetAccount));
export const getAccountGallery = createSelector([
(state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
state => state.get('statuses'),
(state, id) => state.getIn(['accounts', id]),
], (statusIds, statuses, account) => {
let medias = ImmutableList();
statusIds.forEach(statusId => {
const status = statuses.get(statusId);
medias = medias.concat(status.get('media_attachments').map(media => media.set('status', status).set('account', account)));
});
return medias;
});
export const getAccountHidden = createSelector([
(state, id) => state.getIn(['accounts', id, 'hidden']),
(state, id) => state.getIn(['relationships', id, 'following']) || state.getIn(['relationships', id, 'requested']),
(state, id) => id === me,
], (hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested);
});
export const getStatusList = createSelector([ export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']), (state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList()); ], (items) => items.toList());

View File

@ -2010,6 +2010,11 @@ a.sparkline {
line-height: 20px; line-height: 20px;
font-weight: 600; font-weight: 600;
margin-bottom: 16px; margin-bottom: 16px;
a {
color: inherit;
text-decoration: none;
}
} }
} }
} }

View File

@ -7181,6 +7181,8 @@ img.modal-warning {
} }
&--layout-3 { &--layout-3 {
min-height: calc(64px * 2 + 8px);
& > .media-gallery__item:nth-child(1) { & > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0; border-end-end-radius: 0;
border-start-end-radius: 0; border-start-end-radius: 0;
@ -7200,6 +7202,8 @@ img.modal-warning {
} }
&--layout-4 { &--layout-4 {
min-height: calc(64px * 2 + 8px);
& > .media-gallery__item:nth-child(1) { & > .media-gallery__item:nth-child(1) {
border-end-end-radius: 0; border-end-end-radius: 0;
border-start-end-radius: 0; border-start-end-radius: 0;
@ -7805,7 +7809,8 @@ img.modal-warning {
border-radius: 0; border-radius: 0;
} }
.load-more { .load-more,
.timeline-hint {
grid-column: span 3; grid-column: span 3;
} }
} }

View File

@ -341,11 +341,18 @@ code {
columns: unset; columns: unset;
} }
.input.datetime .label_input select { .input.datetime .label_input,
.input.date .label_input {
display: flex;
gap: 4px;
align-items: center;
select {
display: inline-block; display: inline-block;
width: auto; width: auto;
flex: 0; flex: 0;
} }
}
.input.select.select--languages { .input.select.select--languages {
min-width: 32ch; min-width: 32ch;

View File

@ -29,7 +29,7 @@ const debouncedSave = debounce((dispatch, getState) => {
api().put('/api/web/settings', { data }) api().put('/api/web/settings', { data })
.then(() => dispatch({ type: SETTING_SAVE })) .then(() => dispatch({ type: SETTING_SAVE }))
.catch(error => dispatch(showAlertForError(error))); .catch(error => dispatch(showAlertForError(error)));
}, 5000, { trailing: true }); }, 2000, { leading: true, trailing: true });
export function saveSettings() { export function saveSettings() {
return (dispatch, getState) => debouncedSave(dispatch, getState); return (dispatch, getState) => debouncedSave(dispatch, getState);

View File

@ -138,7 +138,7 @@ export function deleteStatus(id, withRedraft = false) {
dispatch(deleteStatusRequest(id)); dispatch(deleteStatusRequest(id));
api().delete(`/api/v1/statuses/${id}`).then(response => { api().delete(`/api/v1/statuses/${id}`, { params: { delete_media: !withRedraft } }).then(response => {
dispatch(deleteStatusSuccess(id)); dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account)); dispatch(importFetchedAccount(response.data.account));

View File

@ -4,8 +4,12 @@ import type {
ApiPrivacyPolicyJSON, ApiPrivacyPolicyJSON,
} from 'mastodon/api_types/instance'; } from 'mastodon/api_types/instance';
export const apiGetTermsOfService = () => export const apiGetTermsOfService = (version?: string) =>
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service'); apiRequestGet<ApiTermsOfServiceJSON>(
version
? `v1/instance/terms_of_service/${version}`
: 'v1/instance/terms_of_service',
);
export const apiGetPrivacyPolicy = () => export const apiGetPrivacyPolicy = () =>
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy'); apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');

View File

@ -1,5 +1,7 @@
export interface ApiTermsOfServiceJSON { export interface ApiTermsOfServiceJSON {
updated_at: string; effective_date: string;
effective: boolean;
succeeded_by: string | null;
content: string; content: string;
} }

View File

@ -1,4 +1,4 @@
import { useLinks } from 'mastodon/../hooks/useLinks'; import { useLinks } from 'mastodon/hooks/useLinks';
export const AccountBio: React.FC<{ export const AccountBio: React.FC<{
note: string; note: string;

View File

@ -1,8 +1,8 @@
import classNames from 'classnames'; import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { useLinks } from 'mastodon/../hooks/useLinks';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { useLinks } from 'mastodon/hooks/useLinks';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
export const AccountFields: React.FC<{ export const AccountFields: React.FC<{

View File

@ -8,7 +8,7 @@ import type {
UsePopperOptions, UsePopperOptions,
} from 'react-overlays/esm/usePopper'; } from 'react-overlays/esm/usePopper';
import { useSelectableClick } from '@/hooks/useSelectableClick'; import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue; const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const popperConfig = { strategy: 'fixed' } as UsePopperOptions;

View File

@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useHovering } from 'mastodon/../hooks/useHovering'; import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';

View File

@ -1,8 +1,7 @@
import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
import { useHovering } from '../../hooks/useHovering';
import { autoPlayGif } from '../initial_state';
interface Props { interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there friend: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there

View File

@ -5,8 +5,8 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react'; import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import { useTimeout } from 'mastodon/../hooks/useTimeout';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { useTimeout } from 'mastodon/hooks/useTimeout';
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => { export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);

View File

@ -1,4 +1,4 @@
import { useHovering } from '@/hooks/useHovering'; import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
export const GIF: React.FC<{ export const GIF: React.FC<{

View File

@ -151,7 +151,7 @@ export const Hashtag: React.FC<HashtagProps> = ({
<Sparklines <Sparklines
width={50} width={50}
height={28} height={28}
data={history ? history : Array.from(Array(7)).map(() => 0)} data={history ?? Array.from(Array(7)).map(() => 0)}
> >
<SparklinesCurve style={{ fill: 'none' }} /> <SparklinesCurve style={{ fill: 'none' }} />
</Sparklines> </Sparklines>

View File

@ -8,8 +8,8 @@ import type {
UsePopperOptions, UsePopperOptions,
} from 'react-overlays/esm/usePopper'; } from 'react-overlays/esm/usePopper';
import { useTimeout } from 'mastodon/../hooks/useTimeout';
import { HoverCardAccount } from 'mastodon/components/hover_card_account'; import { HoverCardAccount } from 'mastodon/components/hover_card_account';
import { useTimeout } from 'mastodon/hooks/useTimeout';
const offset = [-12, 4] as OffsetValue; const offset = [-12, 4] as OffsetValue;
const enterDelay = 750; const enterDelay = 750;

View File

@ -149,6 +149,7 @@ export class IconButton extends PureComponent<Props, States> {
onClick={this.handleClick} onClick={this.handleClick}
onMouseDown={this.handleMouseDown} onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown} onKeyDown={this.handleKeyDown}
// eslint-disable-next-line @typescript-eslint/no-deprecated
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
style={style} style={style}
tabIndex={tabIndex} tabIndex={tabIndex}

View File

@ -81,6 +81,7 @@ class ScrollableList extends PureComponent {
bindToDocument: PropTypes.bool, bindToDocument: PropTypes.bool,
preventScroll: PropTypes.bool, preventScroll: PropTypes.bool,
footer: PropTypes.node, footer: PropTypes.node,
className: PropTypes.string,
}; };
static defaultProps = { static defaultProps = {
@ -325,7 +326,7 @@ class ScrollableList extends PureComponent {
}; };
render () { render () {
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props; const { children, scrollKey, className, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
const childrenCount = Children.count(children); const childrenCount = Children.count(children);
@ -336,9 +337,9 @@ class ScrollableList extends PureComponent {
if (showLoading) { if (showLoading) {
scrollableArea = ( scrollableArea = (
<div className='scrollable scrollable--flex' ref={this.setRef}> <div className='scrollable scrollable--flex' ref={this.setRef}>
<div role='feed' className='item-list'>
{prepend} {prepend}
</div>
<div role='feed' className='item-list' />
<div className='scrollable__append'> <div className='scrollable__append'>
<LoadingIndicator /> <LoadingIndicator />
@ -350,9 +351,9 @@ class ScrollableList extends PureComponent {
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
scrollableArea = ( scrollableArea = (
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}> <div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend} {prepend}
<div role='feed' className={classNames('item-list', className)}>
{loadPending} {loadPending}
{Children.map(this.props.children, (child, index) => ( {Children.map(this.props.children, (child, index) => (

View File

@ -11,11 +11,15 @@ import { Icon } from 'mastodon/components/icon';
import { formatTime } from 'mastodon/features/video'; import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state'; import { autoPlayGif, displayMedia, useBlurhash } from 'mastodon/initial_state';
import type { Status, MediaAttachment } from 'mastodon/models/status'; import type { Status, MediaAttachment } from 'mastodon/models/status';
import { useAppSelector } from 'mastodon/store';
export const MediaItem: React.FC<{ export const MediaItem: React.FC<{
attachment: MediaAttachment; attachment: MediaAttachment;
onOpenMedia: (arg0: MediaAttachment) => void; onOpenMedia: (arg0: MediaAttachment) => void;
}> = ({ attachment, onOpenMedia }) => { }> = ({ attachment, onOpenMedia }) => {
const account = useAppSelector((state) =>
state.accounts.get(attachment.getIn(['status', 'account']) as string),
);
const [visible, setVisible] = useState( const [visible, setVisible] = useState(
(displayMedia !== 'hide_all' && (displayMedia !== 'hide_all' &&
!attachment.getIn(['status', 'sensitive'])) || !attachment.getIn(['status', 'sensitive'])) ||
@ -66,11 +70,10 @@ export const MediaItem: React.FC<{
attachment.get('description')) as string | undefined; attachment.get('description')) as string | undefined;
const previewUrl = attachment.get('preview_url') as string; const previewUrl = attachment.get('preview_url') as string;
const fullUrl = attachment.get('url') as string; const fullUrl = attachment.get('url') as string;
const avatarUrl = status.getIn(['account', 'avatar_static']) as string; const avatarUrl = account?.avatar_static;
const lang = status.get('language') as string; const lang = status.get('language') as string;
const blurhash = attachment.get('blurhash') as string; const blurhash = attachment.get('blurhash') as string;
const statusId = status.get('id') as string; const statusId = status.get('id') as string;
const acct = status.getIn(['account', 'acct']) as string;
const type = attachment.get('type') as string; const type = attachment.get('type') as string;
let thumbnail; let thumbnail;
@ -181,7 +184,7 @@ export const MediaItem: React.FC<{
<a <a
className='media-gallery__item-thumbnail' className='media-gallery__item-thumbnail'
href={`/@${acct}/${statusId}`} href={`/@${account?.acct}/${statusId}`}
onClick={handleClick} onClick={handleClick}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'

View File

@ -1,241 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountGallery } from 'mastodon/selectors';
import { expandAccountMediaTimeline } from '../../actions/timelines';
import { AccountHeader } from '../account_timeline/components/account_header';
import Column from '../ui/components/column';
import { MediaItem } from './components/media_item';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
if (!accountId) {
return {
isLoading: true,
};
}
return {
accountId,
isAccount: !!state.getIn(['accounts', accountId]),
attachments: getAccountGallery(state, accountId),
isLoading: state.getIn(['timelines', `account:${accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${accountId}:media`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
};
};
class LoadMoreMedia extends ImmutablePureComponent {
static propTypes = {
maxId: PropTypes.string,
onLoadMore: PropTypes.func.isRequired,
};
handleLoadMore = () => {
this.props.onLoadMore(this.props.maxId);
};
render () {
return (
<LoadMore
disabled={this.props.disabled}
onClick={this.handleLoadMore}
/>
);
}
}
class AccountGallery extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.shape({
acct: PropTypes.string,
id: PropTypes.string,
}).isRequired,
accountId: PropTypes.string,
dispatch: PropTypes.func.isRequired,
attachments: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
blockedBy: PropTypes.bool,
suspended: PropTypes.bool,
multiColumn: PropTypes.bool,
};
state = {
width: 323,
};
_load () {
const { accountId, isAccount, dispatch } = this.props;
if (!isAccount) dispatch(fetchAccount(accountId));
dispatch(expandAccountMediaTimeline(accountId));
}
componentDidMount () {
const { params: { acct }, accountId, dispatch } = this.props;
if (accountId) {
this._load();
} else {
dispatch(lookupAccount(acct));
}
}
componentDidUpdate (prevProps) {
const { params: { acct }, accountId, dispatch } = this.props;
if (prevProps.accountId !== accountId && accountId) {
this._load();
} else if (prevProps.params.acct !== acct) {
dispatch(lookupAccount(acct));
}
}
handleScrollToBottom = () => {
if (this.props.hasMore) {
this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
}
};
handleScroll = e => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
const offset = scrollHeight - scrollTop - clientHeight;
if (150 > offset && !this.props.isLoading) {
this.handleScrollToBottom();
}
};
handleLoadMore = maxId => {
this.props.dispatch(expandAccountMediaTimeline(this.props.accountId, { maxId }));
};
handleLoadOlder = e => {
e.preventDefault();
this.handleScrollToBottom();
};
handleOpenMedia = attachment => {
const { dispatch } = this.props;
const statusId = attachment.getIn(['status', 'id']);
const lang = attachment.getIn(['status', 'language']);
if (attachment.get('type') === 'video') {
dispatch(openModal({
modalType: 'VIDEO',
modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
}));
} else if (attachment.get('type') === 'audio') {
dispatch(openModal({
modalType: 'AUDIO',
modalProps: { media: attachment, statusId, lang, options: { autoPlay: true } },
}));
} else {
const media = attachment.getIn(['status', 'media_attachments']);
const index = media.findIndex(x => x.get('id') === attachment.get('id'));
dispatch(openModal({
modalType: 'MEDIA',
modalProps: { media, index, statusId, lang },
}));
}
};
handleRef = c => {
if (c) {
this.setState({ width: c.offsetWidth });
}
};
render () {
const { attachments, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
const { width } = this.state;
if (!isAccount) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
if (!attachments && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let loadOlder = null;
if (hasMore && !(isLoading && attachments.size === 0)) {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
}
let emptyMessage;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
}
return (
<Column>
<ColumnBackButton />
<ScrollContainer scrollKey='account_gallery'>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
<AccountHeader accountId={this.props.accountId} />
{(suspended || blockedBy) ? (
<div className='empty-column-indicator'>
{emptyMessage}
</div>
) : (
<div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder}
</div>
)}
{isLoading && attachments.size === 0 && (
<div className='scrollable__append'>
<LoadingIndicator />
</div>
)}
</div>
</ScrollContainer>
</Column>
);
}
}
export default connect(mapStateToProps)(AccountGallery);

View File

@ -0,0 +1,283 @@
import { useEffect, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import ScrollableList from 'mastodon/components/scrollable_list';
import { TimelineHint } from 'mastodon/components/timeline_hint';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import Column from 'mastodon/features/ui/components/column';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import type { RootState } from 'mastodon/store';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { MediaItem } from './components/media_item';
const getAccountGallery = createSelector(
[
(state: RootState, accountId: string) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:media`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
(state: RootState) => state.statuses,
],
(statusIds, statuses) => {
let items = ImmutableList<MediaAttachment>();
statusIds.forEach((statusId) => {
const status = statuses.get(statusId) as
| ImmutableMap<string, unknown>
| undefined;
if (status) {
items = items.concat(
(
status.get('media_attachments') as ImmutableList<MediaAttachment>
).map((media) => media.set('status', status)),
);
}
});
return items;
},
);
interface Params {
acct?: string;
id?: string;
}
const RemoteHint: React.FC<{
accountId: string;
}> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
const acct = account?.acct;
const url = account?.url;
const domain = acct ? acct.split('@')[1] : undefined;
if (!url) {
return null;
}
return (
<TimelineHint
url={url}
message={
<FormattedMessage
id='hints.profiles.posts_may_be_missing'
defaultMessage='Some posts from this profile may be missing.'
/>
}
label={
<FormattedMessage
id='hints.profiles.see_more_posts'
defaultMessage='See more posts on {domain}'
values={{ domain: <strong>{domain}</strong> }}
/>
}
/>
);
};
export const AccountGallery: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch();
const accountId = useAppSelector(
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
);
const attachments = useAppSelector((state) =>
accountId
? getAccountGallery(state, accountId)
: ImmutableList<MediaAttachment>(),
);
const isLoading = useAppSelector((state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:media`,
'isLoading',
]),
);
const hasMore = useAppSelector((state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:media`,
'hasMore',
]),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const blockedBy = useAppSelector(
(state) =>
state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
);
const suspended = useAppSelector(
(state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
);
const isAccount = !!account;
const remote = account?.acct !== account?.username;
const hidden = useAppSelector((state) =>
accountId ? getAccountHidden(state, accountId) : false,
);
const maxId = attachments.last()?.getIn(['status', 'id']) as
| string
| undefined;
useEffect(() => {
if (!accountId) {
dispatch(lookupAccount(acct));
}
}, [dispatch, accountId, acct]);
useEffect(() => {
if (accountId && !isAccount) {
dispatch(fetchAccount(accountId));
}
if (accountId && isAccount) {
void dispatch(expandAccountMediaTimeline(accountId));
}
}, [dispatch, accountId, isAccount]);
const handleLoadMore = useCallback(() => {
if (maxId) {
void dispatch(expandAccountMediaTimeline(accountId, { maxId }));
}
}, [dispatch, accountId, maxId]);
const handleOpenMedia = useCallback(
(attachment: MediaAttachment) => {
const statusId = attachment.getIn(['status', 'id']);
const lang = attachment.getIn(['status', 'language']);
if (attachment.get('type') === 'video') {
dispatch(
openModal({
modalType: 'VIDEO',
modalProps: {
media: attachment,
statusId,
lang,
options: { autoPlay: true },
},
}),
);
} else if (attachment.get('type') === 'audio') {
dispatch(
openModal({
modalType: 'AUDIO',
modalProps: {
media: attachment,
statusId,
lang,
options: { autoPlay: true },
},
}),
);
} else {
const media = attachment.getIn([
'status',
'media_attachments',
]) as ImmutableList<MediaAttachment>;
const index = media.findIndex(
(x) => x.get('id') === attachment.get('id'),
);
dispatch(
openModal({
modalType: 'MEDIA',
modalProps: { media, index, statusId, lang },
}),
);
}
},
[dispatch],
);
if (accountId && !isAccount) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
let emptyMessage;
if (accountId) {
if (suspended) {
emptyMessage = (
<FormattedMessage
id='empty_column.account_suspended'
defaultMessage='Account suspended'
/>
);
} else if (hidden) {
emptyMessage = <LimitedAccountHint accountId={accountId} />;
} else if (blockedBy) {
emptyMessage = (
<FormattedMessage
id='empty_column.account_unavailable'
defaultMessage='Profile unavailable'
/>
);
} else if (remote && attachments.isEmpty()) {
emptyMessage = <RemoteHint accountId={accountId} />;
} else {
emptyMessage = (
<FormattedMessage
id='empty_column.account_timeline'
defaultMessage='No posts found'
/>
);
}
}
const forceEmptyState = suspended || blockedBy || hidden;
return (
<Column>
<ColumnBackButton />
<ScrollableList
className='account-gallery__container'
prepend={
accountId && (
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
)
}
alwaysPrepend
append={remote && accountId && <RemoteHint accountId={accountId} />}
scrollKey='account_gallery'
isLoading={isLoading}
hasMore={!forceEmptyState && hasMore}
onLoadMore={handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{attachments.map((attachment) => (
<MediaItem
key={attachment.get('id') as string}
attachment={attachment}
onOpenMedia={handleOpenMedia}
/>
))}
</ScrollableList>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AccountGallery;

View File

@ -6,7 +6,6 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useLinks } from '@/hooks/useLinks';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -46,6 +45,7 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { DomainPill } from 'mastodon/features/account/components/domain_pill'; import { DomainPill } from 'mastodon/features/account/components/domain_pill';
import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container'; import AccountNoteContainer from 'mastodon/features/account/containers/account_note_container';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container'; import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { useLinks } from 'mastodon/hooks/useLinks';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state'; import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
@ -58,8 +58,8 @@ import {
import { getAccountHidden } from 'mastodon/selectors/accounts'; import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import MemorialNote from './memorial_note'; import { MemorialNote } from './memorial_note';
import MovedNote from './moved_note'; import { MovedNote } from './moved_note';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -833,7 +833,7 @@ export const AccountHeader: React.FC<{
<div className='account-timeline__header'> <div className='account-timeline__header'>
{!hidden && account.memorial && <MemorialNote />} {!hidden && account.memorial && <MemorialNote />}
{!hidden && account.moved && ( {!hidden && account.moved && (
<MovedNote from={account} to={account.moved} /> <MovedNote accountId={account.id} targetAccountId={account.moved} />
)} )}
<div <div

Some files were not shown because too many files have changed in this diff Show More