Merge branch 'main' into main

This commit is contained in:
kechpaja 2025-03-06 21:29:58 +02:00 committed by GitHub
commit a6d2ed4f3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
331 changed files with 4542 additions and 3081 deletions

View File

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

View File

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

View File

@ -18,6 +18,7 @@ inherit_from:
- .rubocop/rspec_rails.yml
- .rubocop/rspec.yml
- .rubocop/style.yml
- .rubocop/i18n.yml
- .rubocop/custom.yml
- .rubocop_todo.yml
- .rubocop/strict.yml
@ -30,6 +31,7 @@ plugins:
- rubocop-rails
- rubocop-rspec
- rubocop-performance
- rubocop-i18n
require:
- rubocop-rspec_rails

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
# `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.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new

View File

@ -2,6 +2,41 @@
All notable changes to this project will be documented in this file.
## [4.3.4] - 2025-02-27
### Security
- Update dependencies
- Change HTML sanitization to remove unusable and unused `embed` tag (#34021 by @ClearlyClaire, [GHSA-mq2m-hr29-8gqf](https://github.com/mastodon/mastodon/security/advisories/GHSA-mq2m-hr29-8gqf))
- Fix rate-limit on sign-up email verification ([GHSA-v39f-c9jj-8w7h](https://github.com/mastodon/mastodon/security/advisories/GHSA-v39f-c9jj-8w7h))
- Fix improper disclosure of domain blocks to unverified users ([GHSA-94h4-fj37-c825](https://github.com/mastodon/mastodon/security/advisories/GHSA-94h4-fj37-c825))
### Changed
- Change preview cards to be shown when Content Warnings are expanded (#33827 by @ClearlyClaire)
- Change warnings against changing encryption secrets to be even more noticeable (#33631 by @ClearlyClaire)
- Change `mastodon:setup` to prevent overwriting already-configured servers (#33603, #33616, and #33684 by @ClearlyClaire and @mjankowski)
- Change notifications from moderators to not be filtered (#32974 and #33654 by @ClearlyClaire and @mjankowski)
### Fixed
- Fix `GET /api/v2/notifications/:id` and `POST /api/v2/notifications/:id/dismiss` for ungrouped notifications (#33990 by @ClearlyClaire)
- Fix issue with some versions of libvips on some systems (#33853 by @kleisauke)
- Fix handling of duplicate mentions in incoming status `Update` (#33911 by @ClearlyClaire)
- Fix inefficiencies in timeline generation (#33839 and #33842 by @ClearlyClaire)
- Fix emoji rewrite adding unnecessary curft to the DOM for most emoji (#33818 by @ClearlyClaire)
- Fix `tootctl feeds build` not building list timelines (#33783 by @ClearlyClaire)
- Fix flaky test in `/api/v2/notifications` tests (#33773 by @ClearlyClaire)
- Fix incorrect signature after HTTP redirect (#33757 and #33769 by @ClearlyClaire)
- Fix polls not being validated on edition (#33755 by @ClearlyClaire)
- Fix media preview height in compose form when 3 or more images are attached (#33571 by @ClearlyClaire)
- Fix preview card sizing in “Author attribution” in profile settings (#33482 by @ClearlyClaire)
- Fix processing of incoming notifications for unfilterable types (#33429 by @ClearlyClaire)
- Fix featured tags for remote accounts not being kept up to date (#33372, #33406, and #33425 by @ClearlyClaire and @mjankowski)
- Fix notification polling showing a loading bar in web UI (#32960 by @Gargron)
- Fix accounts table long display name (#29316 by @WebCoder49)
- Fix exclusive lists interfering with notifications (#28162 by @ShadowJonathan)
## [4.3.3] - 2025-01-16
### Security

View File

@ -96,6 +96,9 @@ RUN \
# Set /opt/mastodon as working directory
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
RUN \
# Mount Apt cache and lib directories from Docker buildx caches
@ -165,7 +168,7 @@ RUN \
libexif-dev \
libexpat1-dev \
libgirepository1.0-dev \
libheif-dev \
libheif-dev/bookworm-backports \
libimagequant-dev \
libjpeg62-turbo-dev \
liblcms2-dev \
@ -348,7 +351,7 @@ RUN \
# libvips components
libcgif0 \
libexif12 \
libheif1 \
libheif1/bookworm-backports \
libimagequant0 \
libjpeg62-turbo \
liblcms2-2 \

View File

@ -39,7 +39,7 @@ gem 'net-ldap', '~> 0.18'
gem 'omniauth', '~> 2.0'
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-saml', '~> 2.0'
@ -145,9 +145,6 @@ group :test do
# Used to mock environment variables
gem 'climate_control'
# Add back helpers functions removed in Rails 5.1
gem 'rails-controller-testing', '~> 1.0'
# Validate schemas in specs
gem 'json-schema', '~> 5.0'
@ -168,6 +165,7 @@ group :development do
# Code linting CLI and plugins
gem 'rubocop', require: false
gem 'rubocop-capybara', require: false
gem 'rubocop-i18n', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false

View File

@ -194,7 +194,7 @@ GEM
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.5.1)
diff-lcs (1.6.0)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
@ -217,6 +217,8 @@ GEM
htmlentities (~> 4.3.3)
launchy (>= 2.1, < 4.0)
mail (~> 2.7)
email_validator (2.2.4)
activemodel
erubi (1.13.1)
et-orbi (1.2.11)
tzinfo
@ -228,6 +230,8 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-httpclient (2.0.1)
httpclient (>= 2.2)
faraday-net_http (3.4.0)
@ -273,7 +277,7 @@ GEM
activesupport (>= 5.1)
haml (>= 4.0.6)
railties (>= 5.1)
haml_lint (0.60.0)
haml_lint (0.61.0)
haml (>= 5.0)
parallel (~> 1.10)
rainbow
@ -330,11 +334,13 @@ GEM
jmespath (1.6.2)
json (2.10.1)
json-canonicalization (1.0.0)
json-jwt (1.15.3.1)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
httpclient
faraday (~> 2.0)
faraday-follow_redirects
json-ld (3.3.2)
htmlentities (~> 4.3)
json-canonicalization (~> 1.0)
@ -409,11 +415,11 @@ GEM
mime-types (3.6.0)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2025.0204)
mime-types-data (3.2025.0220)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
msgpack (1.7.5)
msgpack (1.8.0)
multi_json (1.15.0)
mutex_m (0.3.0)
net-http (0.6.0)
@ -432,37 +438,39 @@ GEM
nokogiri (1.18.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.9)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.2)
omniauth (2.1.3)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-cas (3.0.0)
omniauth-cas (3.0.1)
addressable (~> 2.8)
nokogiri (~> 1.12)
omniauth (~> 2.1)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.2.1)
omniauth-saml (2.2.2)
omniauth (~> 2.1)
ruby-saml (~> 1.17)
omniauth_openid_connect (0.6.1)
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 1.1)
openid_connect (1.4.2)
openid_connect (~> 2.2)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
json-jwt (>= 1.15.0)
net-smtp
rack-oauth2 (~> 1.21)
swd (~> 1.3)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_email
validate_url
webfinger (~> 1.2)
webfinger (~> 2.0)
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
@ -600,19 +608,20 @@ GEM
public_suffix (6.0.1)
puma (6.6.0)
nio4r (~> 2.0)
pundit (2.4.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.11)
rack (2.2.12)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-oauth2 (1.21.3)
rack-oauth2 (2.2.1)
activesupport
attr_required
httpclient
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (3.2.0)
@ -641,10 +650,6 @@ GEM
activesupport (= 8.0.1)
bundler (>= 1.15.0)
railties (= 8.0.1)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@ -673,7 +678,7 @@ GEM
rdf (~> 3.3)
rdoc (6.12.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redcarpet (3.6.1)
redis (4.8.1)
redis-namespace (1.11.0)
redis (>= 4)
@ -687,7 +692,7 @@ GEM
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.4.0)
rexml (3.4.1)
rotp (6.3.0)
rouge (4.5.1)
rpam2 (4.0.2)
@ -723,7 +728,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 8)
rspec-support (3.13.2)
rubocop (1.72.2)
rubocop (1.73.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -734,15 +739,18 @@ GEM
rubocop-ast (>= 1.38.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.38.0)
rubocop-ast (1.38.1)
parser (>= 3.3.1.0)
rubocop-capybara (2.21.0)
rubocop (~> 1.41)
rubocop-i18n (3.2.3)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-performance (1.24.0)
lint_roller (~> 1.1)
rubocop (>= 1.72.1, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.30.1)
rubocop-rails (2.30.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@ -774,7 +782,7 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
selenium-webdriver (4.28.0)
selenium-webdriver (4.29.1)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@ -814,13 +822,14 @@ GEM
stackprof (0.2.27)
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.2)
stringio (3.1.4)
strong_migrations (2.2.0)
activerecord (>= 7)
swd (1.3.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
faraday (~> 2.0)
faraday-follow_redirects
sysexits (1.2.0)
temple (0.10.3)
terminal-table (4.0.0)
@ -858,11 +867,8 @@ GEM
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
uri (1.0.3)
useragent (0.16.11)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
@ -876,9 +882,10 @@ GEM
openssl (>= 2.2)
safety_net_attestation (~> 0.4.0)
tpm-key_attestation (~> 0.14.0)
webfinger (1.2.0)
webfinger (2.1.3)
activesupport
httpclient (>= 2.4)
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.25.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@ -898,7 +905,7 @@ GEM
xorcist (1.1.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.1)
zeitwerk (2.7.2)
PLATFORMS
ruby
@ -977,7 +984,7 @@ DEPENDENCIES
omniauth-cas (~> 3.0.0.beta.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1)
omniauth_openid_connect (~> 0.8.0)
opentelemetry-api (~> 1.4.0)
opentelemetry-exporter-otlp (~> 0.29.0)
opentelemetry-instrumentation-active_job (~> 0.8.0)
@ -1009,7 +1016,6 @@ DEPENDENCIES
rack-cors (~> 2.0)
rack-test (~> 2.1)
rails (~> 8.0)
rails-controller-testing (~> 1.0)
rails-i18n (~> 8.0)
rdf-normalize (~> 0.5)
redcarpet (~> 3.6)
@ -1021,6 +1027,7 @@ DEPENDENCIES
rspec-sidekiq (~> 5.0)
rubocop
rubocop-capybara
rubocop-i18n
rubocop-performance
rubocop-rails
rubocop-rspec
@ -1059,4 +1066,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.6.3
2.6.5

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
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
def current_terms_of_service
@ -32,6 +32,6 @@ class Admin::TermsOfService::DraftsController < Admin::BaseController
def resource_params
params
.expect(terms_of_service: [:text, :changelog])
.expect(terms_of_service: [:text, :changelog, :effective_date])
end
end

View File

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

View File

@ -31,7 +31,7 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr
end
def show_domain_blocks_to_user?
Setting.show_domain_blocks == 'users' && user_signed_in?
Setting.show_domain_blocks == 'users' && user_signed_in? && current_user.functional_or_moved?
end
def set_domain_blocks
@ -47,6 +47,6 @@ class Api::V1::Instances::DomainBlocksController < Api::V1::Instances::BaseContr
end
def show_rationale_for_user?
Setting.show_domain_blocks_rationale == 'users' && user_signed_in?
Setting.show_domain_blocks_rationale == 'users' && user_signed_in? && current_user.functional_or_moved?
end
end

View File

@ -5,12 +5,18 @@ class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseCo
def show
cache_even_if_authenticated!
render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer
render json: @terms_of_service, serializer: REST::TermsOfServiceSerializer
end
private
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

View File

@ -3,8 +3,8 @@
class Api::V1::MediaController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:media' }
before_action :require_user!
before_action :set_media_attachment, except: [:create]
before_action :check_processing, except: [:create]
before_action :set_media_attachment, except: [:create, :destroy]
before_action :check_processing, except: [:create, :destroy]
def show
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
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
def status_code_for_media_attachment
@ -54,4 +63,8 @@ class Api::V1::MediaController < Api::BaseController
def processing_error
{ error: 'Error processing thumbnail for uploaded media' }
end
def in_usage_error
{ error: 'Media attachment is currently used by a status' }
end
end

View File

@ -111,7 +111,7 @@ class Api::V1::StatusesController < Api::BaseController
@status.account.statuses_count = @status.account.statuses_count - 1
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
end

View File

@ -46,7 +46,7 @@ class Api::V2::NotificationsController < Api::BaseController
end
def show
@notification = current_account.notifications.without_suspended.find_by!(group_key: params[:group_key])
@notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take!
presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification]))
render json: presenter, serializer: REST::DedupNotificationGroupSerializer
end
@ -57,7 +57,7 @@ class Api::V2::NotificationsController < Api::BaseController
end
def dismiss
current_account.notifications.where(group_key: params[:group_key]).destroy_all
current_account.notifications.by_group_key(params[:group_key]).destroy_all
render_empty
end

View File

@ -1,7 +1,7 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/../hooks/useRenderSignal';
import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';
import { start } from '../mastodon/common';
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 }) {
return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);

View File

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

View File

@ -138,7 +138,7 @@ export function deleteStatus(id, withRedraft = false) {
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(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));

View File

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

View File

@ -1,5 +1,7 @@
export interface ApiTermsOfServiceJSON {
updated_at: string;
effective_date: string;
effective: boolean;
succeeded_by: string | null;
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<{
note: string;

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
import classNames from 'classnames';
import { useHovering } from 'mastodon/../hooks/useHovering';
import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state';
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 { useHovering } from '../../hooks/useHovering';
import { autoPlayGif } from '../initial_state';
interface Props {
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,528 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { NavLink, withRouter } from 'react-router-dom';
import { isFulfilled, isRejected } from '@reduxjs/toolkit';
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 'mastodon/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
import { Button } from 'mastodon/components/button';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import { FollowersCounter, FollowingCounter, StatusesCounter } from 'mastodon/components/counters';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ShortNumber } from 'mastodon/components/short_number';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions';
import { WithRouterPropTypes } from 'mastodon/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' },
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
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 messageForFollowButton = relationship => {
if(!relationship) return messages.follow;
if (relationship.get('following') && relationship.get('followed_by')) {
return messages.mutual;
} else if (relationship.get('following') || relationship.get('requested')) {
return messages.unfollow;
} else if (relationship.get('followed_by')) {
return messages.followBack;
} else {
return messages.follow;
}
};
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,
onEditAccountNote: PropTypes.func.isRequired,
onChangeLanguages: PropTypes.func.isRequired,
onInteractionModal: PropTypes.func.isRequired,
onOpenAvatar: PropTypes.func.isRequired,
onOpenURL: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
domain: PropTypes.string.isRequired,
hidden: PropTypes.bool,
...WithRouterPropTypes,
};
setRef = c => {
this.node = c;
};
openEditProfile = () => {
window.open('/settings/profile', '_blank');
};
isStatusesPageActive = (match, location) => {
if (!match) {
return false;
}
return !location.pathname.match(/\/(followers|following)\/?$/);
};
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);
});
};
handleHashtagClick = e => {
const { history } = this.props;
const value = e.currentTarget.textContent.replace(/^#/, '');
if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
history.push(`/tags/${value}`);
}
};
handleMentionClick = e => {
const { history, onOpenURL } = this.props;
if (history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
const link = e.currentTarget;
onOpenURL(link.href).then((result) => {
if (isFulfilled(result)) {
if (result.payload.accounts[0]) {
history.push(`/@${result.payload.accounts[0].acct}`);
} else if (result.payload.statuses[0]) {
history.push(`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`);
} else {
window.location = link.href;
}
} else if (isRejected(result)) {
window.location = link.href;
}
}).catch(() => {
// Nothing
});
}
};
_attachLinkEvents () {
const node = this.node;
if (!node) {
return;
}
const links = node.querySelectorAll('a');
let link;
for (var i = 0; i < links.length; ++i) {
link = links[i];
if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.handleHashtagClick, false);
} else if (link.classList.contains('mention')) {
link.addEventListener('click', this.handleMentionClick, false);
}
}
}
componentDidMount () {
this._attachLinkEvents();
}
componentDidUpdate () {
this._attachLinkEvents();
}
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', 'blocking'])) {
info.push(<span key='blocked' className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>);
}
if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
info.push(<span key='muted' className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>);
} else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
info.push(<span key='domain_blocked' 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', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) })} text={intl.formatMessage(messageForFollowButton(account.get('relationship')))} 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 {
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
}
if (account.get('moved') && !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) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
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) || (isRemote && (permissions & PERMISSION_MANAGE_FEDERATION) === PERMISSION_MANAGE_FEDERATION)) {
menu.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${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' ref={this.setRef}>
{(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 className='account__header__extra__links'>
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/@${account.get('acct')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<ShortNumber
value={account.get('statuses_count')}
renderer={StatusesCounter}
/>
</NavLink>
<NavLink exact activeClassName='active' to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<ShortNumber
value={account.get('following_count')}
renderer={FollowingCounter}
/>
</NavLink>
<NavLink exact activeClassName='active' to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<ShortNumber
value={account.get('followers_count')}
renderer={FollowersCounter}
/>
</NavLink>
</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

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

File diff suppressed because it is too large Load Diff

View File

@ -1,155 +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 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,
onOpenURL: 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}
onOpenURL={this.props.onOpenURL}
domain={this.props.domain}
hidden={hidden}
/>
{!(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';
const MemorialNote = () => (
export const MemorialNote: React.FC = () => (
<div className='account-memorial-banner'>
<div className='account-memorial-banner__message'>
<FormattedMessage id='account.in_memoriam' defaultMessage='In Memoriam.' />
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</div>
</div>
);
export default MemorialNote;

View File

@ -1,39 +0,0 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
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';
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'>
<Link to={`/@${to.get('acct')}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to.get('acct')}`} className='button'><FormattedMessage id='account.go_to_profile' defaultMessage='Go to profile' /></Link>
</div>
</div>
);
}
}

View File

@ -0,0 +1,53 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
import { DisplayName } from 'mastodon/components/display_name';
import { useAppSelector } from 'mastodon/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'>
<Link to={`/@${to?.acct}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'>
<AvatarOverlay account={to} friend={from} />
</div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</div>
);
};

View File

@ -1,153 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openURL } from 'mastodon/actions/search';
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: '',
},
}));
},
onOpenURL (url) {
return dispatch(openURL({ url }));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));

View File

@ -11,7 +11,7 @@ import { TimelineHint } from 'mastodon/components/timeline_hint';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { me } from 'mastodon/initial_state';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector } from 'mastodon/store';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
@ -22,8 +22,8 @@ import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';
import { AccountHeader } from './components/account_header';
import { LimitedAccountHint } from './components/limited_account_hint';
import HeaderContainer from './containers/header_container';
const emptyList = ImmutableList();
@ -198,7 +198,7 @@ class AccountTimeline extends ImmutablePureComponent {
<ColumnBackButton />
<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
append={remoteMessage}
scrollKey='account_timeline'

View File

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

View File

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

View File

@ -12,11 +12,14 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import emojiCompressed from 'mastodon/features/emoji/emoji_compressed';
import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const nimblePickerData = emojiCompressed[5];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
@ -37,15 +40,18 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>
<Emoji
data={nimblePickerData}
emoji='sleuth_or_spy'
set='twitter'
size={32}
sheetSize={32}
sheetColumns={62}
sheetRows={62}
backgroundImageFn={backgroundImageFn}
/>
@ -67,7 +73,7 @@ class ModifierPickerMenu extends PureComponent {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
};
UNSAFE_componentWillReceiveProps (nextProps) {
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.active) {
this.attachListeners();
} else {
@ -75,7 +81,7 @@ class ModifierPickerMenu extends PureComponent {
}
}
componentWillUnmount () {
componentWillUnmount() {
this.removeListeners();
}
@ -85,12 +91,12 @@ class ModifierPickerMenu extends PureComponent {
}
};
attachListeners () {
attachListeners() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners () {
removeListeners() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -99,17 +105,17 @@ class ModifierPickerMenu extends PureComponent {
this.node = c;
};
render () {
render() {
const { active } = this.props;
return (
<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} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></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} /></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} /></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} /></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} /></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} /></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} /></button>
</div>
);
}
@ -139,12 +145,12 @@ class ModifierPicker extends PureComponent {
this.props.onClose();
};
render () {
render() {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
@ -184,7 +190,7 @@ class EmojiPickerMenuImpl extends PureComponent {
}
};
componentDidMount () {
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, { capture: true });
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
@ -199,7 +205,7 @@ class EmojiPickerMenuImpl extends PureComponent {
});
}
componentWillUnmount () {
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, { capture: true });
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
@ -252,7 +258,7 @@ class EmojiPickerMenuImpl extends PureComponent {
this.props.onSkinTone(modifier);
};
render () {
render() {
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
@ -280,6 +286,9 @@ class EmojiPickerMenuImpl extends PureComponent {
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
data={nimblePickerData}
sheetColumns={62}
sheetRows={62}
perLine={8}
emojiSize={22}
sheetSize={32}
@ -345,7 +354,7 @@ class EmojiPickerDropdown extends PureComponent {
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
@ -386,7 +395,7 @@ class EmojiPickerDropdown extends PureComponent {
this.setState({ placement: state.placement });
};
render () {
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
@ -403,7 +412,7 @@ class EmojiPickerDropdown extends PureComponent {
/>
<Overlay show={active} placement={placement} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
{({ props, placement })=> (
{({ props, placement }) => (
<div {...props} style={{ ...props.style }}>
<div className={`dropdown-animation ${placement}`}>
<EmojiPickerMenu

View File

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

View File

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

View File

@ -9,18 +9,91 @@
// This version comment should be bumped each time the emoji data is changed
// to ensure that the prevaled file is regenerated by Babel
// version: 2
// version: 3
const { emojiIndex } = require('emoji-mart');
let data = require('emoji-mart/data/all.json');
// This json file contains the names of the categories.
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 _ = require('lodash');
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 { unicodeToUnifiedName } = require('./unicode_to_unified_name');
if(data.compressed) {
data = emojiMartUncompress(data);
// 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) {
emojiMartUncompress(data);
}
const emojiMartData = data;
@ -32,15 +105,10 @@ const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
Object.keys(emojiMart5Data.emojis).forEach(key => {
let emoji = emojiMart5Data.emojis[key];
// Emojis with skin tone modifiers are stored like this
if (Object.hasOwn(emoji, '1')) {
emoji = emoji['1'];
}
shortcodeMap[emoji.native] = emoji.id;
shortcodeMap[emoji.skins[0].native] = emoji.id;
});
const stripModifiers = unicode => {
@ -84,13 +152,9 @@ Object.keys(emojiMap).forEach(key => {
}
});
Object.keys(emojiIndex.emojis).forEach(key => {
let emoji = emojiIndex.emojis[key];
Object.keys(emojiMartData.emojis).forEach(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;
let { short_names, search, unified } = emojiMartData.emojis[key];
@ -135,4 +199,5 @@ module.exports = JSON.parse(JSON.stringify([
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
emojiMartData
]));

View File

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

View File

@ -1,5 +1,5 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/emoji';
import Picker from 'emoji-mart/dist-es/components/picker/picker';
import Emoji from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import Picker from 'emoji-mart/dist-es/components/picker/nimble-picker';
export {
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 { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = {
[key in FilenameData[number][0]]: {
type UnicodeMapping = Record<
FilenameData[number][0],
{
shortCode: ShortCodesToEmojiDataKey;
filename: FilenameData[number][number];
};
};
}
>;
const [
shortCodesToEmojiData,

View File

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

View File

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

View File

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

View File

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

View File

@ -6,11 +6,11 @@ import { useEffect, useCallback } from 'react';
import { Provider } from 'react-redux';
import { useRenderSignal } from 'mastodon/../hooks/useRenderSignal';
import { fetchStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
import initialState from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';

View File

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

View File

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

View File

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

View File

@ -205,7 +205,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} 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} />
<Redirect from='/timelines/public' to='/public' exact />

View File

@ -592,7 +592,6 @@
"poll_button.remove_poll": "إزالة استطلاع الرأي",
"privacy.change": "اضبط خصوصية المنشور",
"privacy.direct.long": "كل من ذُكر في المنشور",
"privacy.direct.short": "أشخاص محددون",
"privacy.private.long": "متابعيك فقط",
"privacy.private.short": "للمتابِعين",
"privacy.public.long": "أي شخص على أو خارج ماستدون",

View File

@ -4,15 +4,16 @@
"about.disclaimer": "Mastodon ye software gratuito y de códigu llibre, y una marca rexistrada de Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "El motivu nun ta disponible",
"about.domain_blocks.preamble": "Polo xeneral, Mastodon permítete ver el conteníu ya interactuar colos perfiles d'otros sirvidores nel fediversu. Estes son les esceiciones que se ficieron nesti sirvidor.",
"about.domain_blocks.silenced.explanation": "Polo xeneral, nun ves los perfiles ya'l conteníu d'esti sirvidor sacante que los busques o decidas siguilos.",
"about.domain_blocks.silenced.explanation": "Polo xeneral, nun ves los perfiles y el conteníu d'esti sirvidor sacante que los busques o decidas siguilos.",
"about.domain_blocks.silenced.title": "Llendóse",
"about.domain_blocks.suspended.explanation": "Nun se procesa, atroxa nin intercambia nengún datu d'esti sirvidor, lo que fai que cualesquier interaición o comunicación colos sos perfiles seya imposible.",
"about.domain_blocks.suspended.explanation": "Nun se procesa, atroxa nin intercambia nengún datu d'esti sirvidor, lo que fai imposible cualesquier interaición o comunicación colos sos perfiles.",
"about.domain_blocks.suspended.title": "Suspendióse",
"about.not_available": "Esta información nun ta disponible nesti sirvidor.",
"about.powered_by": "Una rede social descentralizada que tien la teunoloxía de {mastodon}",
"about.rules": "Normes del sirvidor",
"account.account_note_header": "Nota personal",
"account.add_or_remove_from_list": "Amestar o quitar de les llistes",
"account.badges.bot": "Automatizóse",
"account.badges.group": "Grupu",
"account.block": "Bloquiar a @{name}",
"account.block_domain": "Bloquiar el dominiu {domain}",
@ -25,7 +26,7 @@
"account.edit_profile": "Editar el perfil",
"account.enable_notifications": "Avisame cuando @{name} espublice artículos",
"account.endorse": "Destacar nel perfil",
"account.featured_tags.last_status_never": "Nun hai nengún artículu",
"account.featured_tags.last_status_never": "Nun hai nenguna publicación",
"account.featured_tags.title": "Etiquetes destacaes de: {name}",
"account.follow": "Siguir",
"account.follow_back": "Siguir tamién",
@ -107,6 +108,7 @@
"column.domain_blocks": "Dominios bloquiaos",
"column.edit_list": "Editar la llista",
"column.favourites": "Favoritos",
"column.firehose": "Feed en direuto",
"column.follow_requests": "Solicitúes de siguimientu",
"column.home": "Aniciu",
"column.lists": "Llistes",
@ -126,9 +128,9 @@
"community.column_settings.remote_only": "Namás lo remoto",
"compose.language.change": "Camudar la llingua",
"compose.language.search": "Buscar llingües…",
"compose.published.body": "Espublizóse l'artículu.",
"compose.published.body": "Publicóse la publicación.",
"compose.published.open": "Abrir",
"compose.saved.body": "Post guardáu.",
"compose.saved.body": "Guardóse la publicación.",
"compose_form.direct_message_warning_learn_more": "Saber más",
"compose_form.encryption_warning": "Los artículos de Mastodon nun tán cifraos de puntu a puntu. Nun compartas nengún tipu d'información sensible per Mastodon.",
"compose_form.lock_disclaimer": "La to cuenta nun ye {locked}. Cualesquier perfil pue siguite pa ver los artículos que son namás pa siguidores.",
@ -137,34 +139,33 @@
"compose_form.poll.option_placeholder": "Opción {number}",
"compose_form.poll.type": "Tipu",
"compose_form.publish": "Espublizar",
"compose_form.publish_form": "Artículu nuevu",
"compose_form.publish_form": "Publicación nueva",
"compose_form.reply": "Responder",
"confirmation_modal.cancel": "Encaboxar",
"confirmations.block.confirm": "Bloquiar",
"confirmations.delete.confirm": "Desaniciar",
"confirmations.delete.message": "¿De xuru que quies desaniciar esti artículu?",
"confirmations.delete.title": "¿Desaniciar l'artículu?",
"confirmations.delete.message": "¿De xuru que quies desaniciar esta publicación?",
"confirmations.delete.title": "¿Quies desaniciar esta publicación?",
"confirmations.delete_list.confirm": "Desaniciar",
"confirmations.delete_list.message": "¿De xuru que quies desaniciar permanentemente esta llista?",
"confirmations.delete_list.title": "¿Desaniciar la llista?",
"confirmations.delete_list.title": "¿Quies desaniciar la llista?",
"confirmations.discard_edit_media.confirm": "Escartar",
"confirmations.edit.confirm": "Editar",
"confirmations.edit.message": "La edición va sobrescribir el mensaxe que tas escribiendo. ¿De xuru que quies siguir?",
"confirmations.follow_to_list.title": "¿Siguir al usuariu?",
"confirmations.logout.confirm": "Zarrar la sesión",
"confirmations.logout.message": "¿De xuru que quies zarrar la sesión?",
"confirmations.logout.title": "¿Zarrar la sesión?",
"confirmations.logout.title": "¿Quies zarrar la sesión?",
"confirmations.missing_alt_text.confirm": "Amestar testu alternativu",
"confirmations.missing_alt_text.title": "¿Quies amestar testu alternativu?",
"confirmations.redraft.confirm": "Desaniciar y reeditar",
"confirmations.redraft.message": "¿De xuru que quies desaniciar esti artículu y reeditalu? Van perdese los favoritos y comparticiones, y les rempuestes al artículu orixinal van quedar güérfanes.",
"confirmations.redraft.title": "¿Desaniciar ya reeditar l'artículu?",
"confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?",
"confirmations.reply.confirm": "Responder",
"confirmations.reply.message": "Responder agora va sobrescribir el mensaxe que tas componiendo anguaño. ¿De xuru que quies siguir?",
"confirmations.unfollow.confirm": "Dexar de siguir",
"confirmations.unfollow.message": "¿De xuru que quies dexar de siguir a {name}?",
"confirmations.unfollow.title": "¿Dexar de siguir al usuariu?",
"content_warning.hide": "Anubrir l'artículu",
"content_warning.hide": "Esconder la publicación",
"content_warning.show": "Amosar de toes toes",
"content_warning.show_more": "Amosar más",
"conversation.delete": "Desaniciar la conversación",
@ -186,7 +187,7 @@
"domain_block_modal.title": "Bloquiar el dominiu?",
"domain_pill.server": "Sirvidor",
"domain_pill.username": "Nome d'usuariu",
"embed.instructions": "Empotra esti artículu nel to sitiu web copiando'l códigu d'abaxo.",
"embed.instructions": "Empotra esta publicación nel to sitiu web copiando'l códigu d'abaxo.",
"embed.preview": "Va apaecer asina:",
"emoji_button.activity": "Actividá",
"emoji_button.flags": "Banderes",
@ -201,9 +202,9 @@
"emoji_button.symbols": "Símbolos",
"emoji_button.travel": "Viaxes y llugares",
"empty_column.account_suspended": "Cuenta suspendida",
"empty_column.account_timeline": "¡Equí nun hai nengún artículu!",
"empty_column.account_timeline": "¡Equí nun hai nenguna publicación!",
"empty_column.blocks": "Nun bloquiesti a nengún perfil.",
"empty_column.bookmarked_statuses": "Nun tienes nengún artículu en Marcadores. Cuando amiestes dalgún, apaez equí.",
"empty_column.bookmarked_statuses": "Nun tienes nenguna publicación en Marcadores. Cuando amiestes dalguna, va apaecer equí.",
"empty_column.direct": "Nun tienes nenguna mención privada. Cuando unvies o recibas dalguna, apaez equí.",
"empty_column.domain_blocks": "Nun hai nengún dominiu bloquiáu.",
"empty_column.explore_statuses": "Agora nun hai nada en tendencia. ¡Volvi equí dempués!",
@ -223,21 +224,21 @@
"explore.trending_links": "Noticies",
"explore.trending_statuses": "Artículos",
"explore.trending_tags": "Etiquetes",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de peñera nun s'aplica al contestu nel qu'accediesti a esti artículu. Si tamién quies que se peñere l'artículu nesti contestu, tienes d'editar la peñera.",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de peñera nun s'aplica al contestu nel qu'accediesti a esta publicación. Si tamién quies que se peñere la publicación nesti contestu, tienes d'editar la peñera.",
"filter_modal.added.context_mismatch_title": "¡El contestu nun coincide!",
"filter_modal.added.expired_explanation": "Esta categoría de peñera caducó, tienes de camudar la so data de caducidá p'aplicala.",
"filter_modal.added.expired_title": "¡La peñera caducó!",
"filter_modal.added.review_and_configure": "Pa revisar y configurar a fondu esta categoría de peñera, vete a la {settings_link}.",
"filter_modal.added.review_and_configure_title": "Configuración de la peñera",
"filter_modal.added.settings_link": "páxina de configuración",
"filter_modal.added.short_explanation": "Esti artículu amestóse a la categoría de peñera siguiente: {title}.",
"filter_modal.added.short_explanation": "Esta publicación amestóse a la categoría de peñera siguiente: {title}.",
"filter_modal.added.title": "¡Amestóse la peñera!",
"filter_modal.select_filter.expired": "caducó",
"filter_modal.select_filter.prompt_new": "Categoría nueva: {name}",
"filter_modal.select_filter.search": "Buscar o crear",
"filter_modal.select_filter.subtitle": "Usa una categoría esistente o créala",
"filter_modal.select_filter.title": "Peñerar esti artículu",
"filter_modal.title.status": "Peñera d'un artículu",
"filter_modal.select_filter.title": "Peñerar esta publicación",
"filter_modal.title.status": "Peñera d'una publicación",
"firehose.all": "Tolos sirvidores",
"firehose.local": "Esti sirvidor",
"firehose.remote": "Otros sirvidores",
@ -285,20 +286,20 @@
"interaction_modal.on_another_server": "N'otru sirvidor",
"interaction_modal.on_this_server": "Nesti sirvidor",
"interaction_modal.title.follow": "Siguir a {name}",
"interaction_modal.title.reply": "Rempuesta al artículu de: {name}",
"interaction_modal.title.reply": "Rempuesta a la publicación de: {name}",
"interaction_modal.title.vote": "Vota na encuesta de {name}",
"intervals.full.days": "{number, plural, one {# día} other {# díes}}",
"intervals.full.hours": "{number, plural, one {# hora} other {# hores}}",
"intervals.full.minutes": "{number, plural, one {# minutu} other {# minutos}}",
"keyboard_shortcuts.back": "Dir p'atrás",
"keyboard_shortcuts.blocked": "Abrir la llista de perfiles bloquiaos",
"keyboard_shortcuts.boost": "Compartir un artículu",
"keyboard_shortcuts.boost": "Compartir una publicación",
"keyboard_shortcuts.column": "Enfocar una columna",
"keyboard_shortcuts.compose": "Enfocar l'área de composición",
"keyboard_shortcuts.description": "Descripción",
"keyboard_shortcuts.direct": "p'abrir la columna de les menciones privaes",
"keyboard_shortcuts.down": "Baxar na llista",
"keyboard_shortcuts.enter": "Abrir un artículu",
"keyboard_shortcuts.enter": "Abrir una publicación",
"keyboard_shortcuts.federated": "Abrir la llinia de tiempu federada",
"keyboard_shortcuts.heading": "Atayos del tecláu",
"keyboard_shortcuts.home": "Abrir la llinia de tiempu del aniciu",
@ -312,12 +313,12 @@
"keyboard_shortcuts.open_media": "Abrir el conteníu mutimedia",
"keyboard_shortcuts.pinned": "Abrir la llista d'artículos fixaos",
"keyboard_shortcuts.profile": "Abrir el perfil del autor/a",
"keyboard_shortcuts.reply": "Responder a un artículu",
"keyboard_shortcuts.reply": "Responder a una publicación",
"keyboard_shortcuts.requests": "Abrir la llista de solicitúes de siguimientu",
"keyboard_shortcuts.search": "Enfocar la barra de busca",
"keyboard_shortcuts.start": "Abrir la columna «Entamar»",
"keyboard_shortcuts.toggle_sensitivity": "Amosar/esconder el conteníu multimedia",
"keyboard_shortcuts.toot": "Comenzar un artículu nuevu",
"keyboard_shortcuts.toot": "Escribir una publicación nueva",
"keyboard_shortcuts.unfocus": "Desenfocar l'área de composición/busca",
"keyboard_shortcuts.up": "Xubir na llista",
"lightbox.close": "Zarrar",
@ -377,9 +378,9 @@
"notification.mentioned_you": "{name} mentóte",
"notification.moderation-warning.learn_more": "Deprender más",
"notification.poll": "Finó una encuesta na que votesti",
"notification.reblog": "{name} compartió'l to artículu",
"notification.reblog": "{name} compartió la to publicación",
"notification.status": "{name} ta acabante d'espublizar",
"notification.update": "{name} editó un artículu",
"notification.update": "{name} editó una publicación",
"notification_requests.edit_selection": "Editar",
"notification_requests.exit_selection": "Fecho",
"notifications.clear": "Borrar los avisos",
@ -421,10 +422,10 @@
"poll.votes": "{votes, plural, one {# votu} other {# votos}}",
"poll_button.add_poll": "Amestar una encuesta",
"poll_button.remove_poll": "Quitar la encuesta",
"privacy.change": "Configurar la privacidá del artículu",
"privacy.direct.short": "Perfiles específicos",
"privacy.change": "Configurar la privacidá de la publicación",
"privacy.direct.short": "Mención privada",
"privacy.private.short": "Siguidores",
"privacy.public.short": "Artículu públicu",
"privacy.public.short": "Publicación pública",
"privacy_policy.last_updated": "Data del últimu anovamientu: {date}",
"privacy_policy.title": "Política de privacidá",
"refresh": "Anovar",
@ -448,7 +449,7 @@
"report.category.subtitle": "Escueyi la meyor opción",
"report.category.title": "Dinos qué pasa con esti {type}",
"report.category.title_account": "perfil",
"report.category.title_status": "artículu",
"report.category.title_status": "publicación",
"report.close": "Fecho",
"report.comment.title": "¿Hai daqué más qu'habríemos saber?",
"report.forward": "Reunviar a {target}",
@ -468,7 +469,7 @@
"report.rules.subtitle": "Seleiciona tolo que s'axuste",
"report.rules.title": "¿Qué normes s'incumplen?",
"report.statuses.subtitle": "Seleiciona tolo que s'axuste",
"report.statuses.title": "¿Hai dalgún artículu qu'apoye esti informe?",
"report.statuses.title": "¿Hai dalguna publicación qu'apoye esti informe?",
"report.submit": "Unviar",
"report.target": "Informe de: {target}",
"report.thanks.take_action": "Equí tienes les opciones pa controlar qué ves en Mastodon:",
@ -477,7 +478,7 @@
"report.thanks.title_actionable": "Gracies pol informe, el casu yá ta n'investigación.",
"report.unfollow": "Dexar de siguir a @{name}",
"report.unfollow_explanation": "Sigues a esta cuenta. Pa dexar de ver los sos artículos nel to feed d'aniciu, dexa de siguila.",
"report_notification.attached_statuses": "{count, plural, one {Axuntóse {count} artículu} other {Axuntáronse {count} artículos}}",
"report_notification.attached_statuses": "{count, plural, one {Axuntóse {count} publicación} other {Axuntáronse {count} publicaciones}}",
"report_notification.categories.legal": "Llegal",
"report_notification.categories.legal_sentence": "conteníu illegal",
"report_notification.categories.spam": "Spam",
@ -490,6 +491,7 @@
"search.quick_action.go_to_hashtag": "Dir a la etiqueta {x}",
"search.quick_action.status_search": "Artículos que concasen con {x}",
"search.search_or_paste": "Busca o apiega una URL",
"search_popout.full_text_search_disabled_message": "Nun ta disponible nel dominiu {domain}.",
"search_popout.language_code": "códigu de llingua ISO",
"search_popout.options": "Opciones de busca",
"search_popout.quick_actions": "Aiciones rápides",
@ -501,22 +503,25 @@
"search_results.hashtags": "Etiquetes",
"search_results.see_all": "Ver too",
"search_results.statuses": "Artículos",
"server_banner.is_one_of_many": "{domain} ye unu de los munchos sirvidores independientes de Mastodon que pues usar pa participar nel fediversu.",
"server_banner.server_stats": "Estadístiques del sirvidor:",
"sign_in_banner.create_account": "Crear una cuenta",
"sign_in_banner.mastodon_is": "Mastodon ye la meyor manera de siguir al momentu qué pasa.",
"sign_in_banner.sign_in": "Aniciar la sesión",
"sign_in_banner.sso_redirect": "Aniciar la sesión o rexistrase",
"status.admin_account": "Abrir la interfaz de moderación pa @{name}",
"status.admin_domain": "Abrir la interfaz de moderación pa «{domain}»",
"status.admin_status": "Abrir esti artículu na interfaz de moderación",
"status.admin_status": "Abrir esta publicación na interfaz de moderación",
"status.block": "Bloquiar a @{name}",
"status.bookmark": "Meter en Marcadores",
"status.cannot_reblog": "Esti artículu nun se pue compartir",
"status.copy": "Copiar l'enllaz al artículu",
"status.cannot_reblog": "Esta publicación nun se pue compartir",
"status.copy": "Copiar l'enllaz a la publicación",
"status.delete": "Desaniciar",
"status.direct": "Mentar a @{name} per privao",
"status.direct_indicator": "Mención privada",
"status.edited_x_times": "Editóse {count, plural, one {{count} vegada} other {{count} vegaes}}",
"status.embed": "Consiguir el códigu pa empotrar",
"status.filter": "Peñerar esti artículu",
"status.filter": "Peñerar esta publicación",
"status.history.created": "{name} creó {date}",
"status.history.edited": "{name} editó {date}",
"status.load_more": "Cargar más",
@ -525,13 +530,13 @@
"status.more": "Más",
"status.mute": "Desactivar los avisos de @{name}",
"status.mute_conversation": "Desactivar los avisos de la conversación",
"status.open": "Espander esti artículu",
"status.open": "Espander esta publicación",
"status.pin": "Fixar nel perfil",
"status.pinned": "Artículu fixáu",
"status.pinned": "Publicación fixada",
"status.read_more": "Lleer más",
"status.reblog": "Compartir",
"status.reblogged_by": "{name} compartió",
"status.reblogs.empty": "Naide nun compartió esti artículu. Cuando daquién lo faiga, apaez equí.",
"status.reblogs.empty": "Naide nun compartió esta publicación. Cuando daquién lo faiga, va apaecer equí.",
"status.redraft": "Desaniciar y reeditar",
"status.remove_bookmark": "Desaniciar el marcador",
"status.replied_to": "En rempuesta a {name}",

View File

@ -642,7 +642,6 @@
"poll_button.remove_poll": "Выдаліць апытанне",
"privacy.change": "Змяніць прыватнасць допісу",
"privacy.direct.long": "Усе згаданыя ў допісе",
"privacy.direct.short": "Канкрэтныя людзі",
"privacy.private.long": "Толькі вашыя падпісчыкі",
"privacy.private.short": "Падпісчыкі",
"privacy.public.long": "Усе, хто ёсць і каго няма ў Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Премахване на анкета",
"privacy.change": "Промяна на поверителността на публикация",
"privacy.direct.long": "Споменатите в публикацията",
"privacy.direct.short": "Определени хора",
"privacy.direct.short": "Частно споменаване",
"privacy.private.long": "Само последователите ви",
"privacy.private.short": "Последователи",
"privacy.public.long": "Всеки във и извън Mastodon",

View File

@ -436,7 +436,6 @@
"poll_button.add_poll": "Ouzhpennañ ur sontadeg",
"poll_button.remove_poll": "Dilemel ar sontadeg",
"privacy.change": "Cheñch prevezded an embannadur",
"privacy.direct.short": "Tud resis",
"privacy.private.short": "Heulierien",
"privacy.public.short": "Publik",
"privacy_policy.last_updated": "Hizivadenn ziwezhañ {date}",

View File

@ -696,7 +696,7 @@
"poll_button.remove_poll": "Elimina l'enquesta",
"privacy.change": "Canvia la privacitat del tut",
"privacy.direct.long": "Tothom mencionat a la publicació",
"privacy.direct.short": "Persones concretes",
"privacy.direct.short": "Menció privada",
"privacy.private.long": "Només els vostres seguidors",
"privacy.private.short": "Seguidors",
"privacy.public.long": "Tothom dins o fora Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Odebrat anketu",
"privacy.change": "Změnit soukromí příspěvku",
"privacy.direct.long": "Všichni zmínění v příspěvku",
"privacy.direct.short": "Vybraní lidé",
"privacy.direct.short": "Soukromá zmínka",
"privacy.private.long": "Jen vaši sledující",
"privacy.private.short": "Sledující",
"privacy.public.long": "Kdokoliv na Mastodonu i mimo něj",

View File

@ -696,7 +696,6 @@
"poll_button.remove_poll": "Tynnu pleidlais",
"privacy.change": "Addasu preifatrwdd y post",
"privacy.direct.long": "Pawb sydd â sôn amdanyn nhw yn y postiad",
"privacy.direct.short": "Pobl benodol",
"privacy.private.long": "Eich dilynwyr yn unig",
"privacy.private.short": "Dilynwyr",
"privacy.public.long": "Unrhyw ar ac oddi ar Mastodon",

View File

@ -697,12 +697,12 @@
"poll_button.remove_poll": "Fjern afstemning",
"privacy.change": "Tilpas indlægsfortrolighed",
"privacy.direct.long": "Alle omtalt i indlægget",
"privacy.direct.short": "Bestemte personer",
"privacy.direct.short": "Privat omtale",
"privacy.private.long": "Kun dine følgere",
"privacy.private.short": "Følgere",
"privacy.public.long": "Alle på og udenfor Mastodon",
"privacy.public.short": "Offentlig",
"privacy.unlisted.additional": "Dette er præcis som offentlig adfærd, dog vises indlægget ikke i realtids-strømme/etiketter, udforsk eller Mastodon-søgning, selv hvis valget gælder hele kontoen.",
"privacy.unlisted.additional": "Dette er præcis som offentlig adfærd, dog vises indlægget ikke i tidslinjer, under etiketter, udforsk eller Mastodon-søgning, selv hvis du ellers har sagt at dine opslag godt må være søgbare.",
"privacy.unlisted.long": "Færre algoritmiske fanfarer",
"privacy.unlisted.short": "Stille offentligt",
"privacy_policy.last_updated": "Senest opdateret {date}",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Umfrage entfernen",
"privacy.change": "Sichtbarkeit anpassen",
"privacy.direct.long": "Alle in diesem Beitrag erwähnten Profile",
"privacy.direct.short": "Ausgewählte Profile",
"privacy.direct.short": "Private Erwähnung",
"privacy.private.long": "Nur deine Follower",
"privacy.private.short": "Follower",
"privacy.public.long": "Alle in und außerhalb von Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Αφαίρεση δημοσκόπησης",
"privacy.change": "Προσαρμογή ιδιωτικότητας ανάρτησης",
"privacy.direct.long": "Όλοι όσοι αναφέρθηκαν στη δημοσίευση",
"privacy.direct.short": "Συγκεκριμένα άτομα",
"privacy.direct.short": "Ιδιωτική επισήμανση",
"privacy.private.long": "Μόνο οι ακόλουθοί σας",
"privacy.private.short": "Ακόλουθοι",
"privacy.public.long": "Όλοι εντός και εκτός του Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Remove poll",
"privacy.change": "Change post privacy",
"privacy.direct.long": "Everyone mentioned in the post",
"privacy.direct.short": "Specific people",
"privacy.direct.short": "Private mention",
"privacy.private.long": "Only your followers",
"privacy.private.short": "Followers",
"privacy.public.long": "Anyone on and off Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Remove poll",
"privacy.change": "Change post privacy",
"privacy.direct.long": "Everyone mentioned in the post",
"privacy.direct.short": "Specific people",
"privacy.direct.short": "Private mention",
"privacy.private.long": "Only your followers",
"privacy.private.short": "Followers",
"privacy.public.long": "Anyone on and off Mastodon",
@ -872,7 +872,9 @@
"subscribed_languages.target": "Change subscribed languages for {target}",
"tabs_bar.home": "Home",
"tabs_bar.notifications": "Notifications",
"terms_of_service.effective_as_of": "Effective as of {date}",
"terms_of_service.title": "Terms of Service",
"terms_of_service.upcoming_changes_on": "Upcoming changes on {date}",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Forigi balotenketon",
"privacy.change": "Ŝanĝu afiŝan privatecon",
"privacy.direct.long": "Ĉiuj menciitaj en la afiŝo",
"privacy.direct.short": "Specifaj homoj",
"privacy.direct.short": "Privata mencio",
"privacy.private.long": "Nur viaj sekvantoj",
"privacy.private.short": "Sekvantoj",
"privacy.public.long": "Ĉiujn ajn ĉe kaj ekster Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Quitar encuesta",
"privacy.change": "Configurar privacidad del mensaje",
"privacy.direct.long": "Todas las cuentas mencionadas en el mensaje",
"privacy.direct.short": "Cuentas específicas",
"privacy.direct.short": "Mención privada",
"privacy.private.long": "Solo tus seguidores",
"privacy.private.short": "Seguidores",
"privacy.public.long": "Cualquier persona dentro y fuera de Mastodon",

View File

@ -192,10 +192,10 @@
"compose_form.poll.switch_to_multiple": "Cambiar la encuesta para permitir múltiples opciones",
"compose_form.poll.switch_to_single": "Cambiar la encuesta para permitir una única opción",
"compose_form.poll.type": "Estilo",
"compose_form.publish": "Publicación",
"compose_form.publish": "Publicar",
"compose_form.publish_form": "Nueva publicación",
"compose_form.reply": "Respuesta",
"compose_form.save_changes": "Actualización",
"compose_form.save_changes": "Actualizar",
"compose_form.spoiler.marked": "Quitar advertencia de contenido",
"compose_form.spoiler.unmarked": "Añadir advertencia de contenido",
"compose_form.spoiler_placeholder": "Advertencia de contenido (opcional)",
@ -697,7 +697,7 @@
"poll_button.remove_poll": "Eliminar encuesta",
"privacy.change": "Ajustar privacidad",
"privacy.direct.long": "Todos los mencionados en la publicación",
"privacy.direct.short": "Personas específicas",
"privacy.direct.short": "Mención privada",
"privacy.private.long": "Sólo tus seguidores",
"privacy.private.short": "Seguidores",
"privacy.public.long": "Cualquiera dentro y fuera de Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Eliminar encuesta",
"privacy.change": "Ajustar privacidad",
"privacy.direct.long": "Visible únicamente por los mencionados en la publicación",
"privacy.direct.short": "Personas específicas",
"privacy.direct.short": "Mención privada",
"privacy.private.long": "Visible únicamente por tus seguidores",
"privacy.private.short": "Seguidores",
"privacy.public.long": "Visible por todo el mundo, dentro y fuera de Mastodon",

View File

@ -696,7 +696,6 @@
"poll_button.remove_poll": "Eemalda küsitlus",
"privacy.change": "Muuda postituse nähtavust",
"privacy.direct.long": "Kõik postituses mainitud",
"privacy.direct.short": "Määratud kasutajad",
"privacy.private.long": "Ainult jälgijad",
"privacy.private.short": "Jälgijad",
"privacy.public.long": "Nii kasutajad kui mittekasutajad",

View File

@ -644,7 +644,6 @@
"poll_button.remove_poll": "Kendu inkesta",
"privacy.change": "Aldatu bidalketaren pribatutasuna",
"privacy.direct.long": "Argitalpen honetan aipatutako denak",
"privacy.direct.short": "Jende jakina",
"privacy.private.long": "Soilik jarraitzaileak",
"privacy.private.short": "Jarraitzaileak",
"privacy.public.long": "Mastodonen dagoen edo ez dagoen edonor",

View File

@ -218,6 +218,10 @@
"confirmations.logout.confirm": "خروج از حساب",
"confirmations.logout.message": "مطمئنید می‌خواهید خارج شوید؟",
"confirmations.logout.title": "خروج؟",
"confirmations.missing_alt_text.confirm": "متن جایگزین را اضافه کنید",
"confirmations.missing_alt_text.message": "پست شما حاوی رسانه بدون متن جایگزین است. افزودن توضیحات کمک می کند تا محتوای شما برای افراد بیشتری قابل دسترسی باشد.",
"confirmations.missing_alt_text.secondary": "به هر حال پست کن",
"confirmations.missing_alt_text.title": "متن جایگزین اضافه شود؟",
"confirmations.mute.confirm": "خموش",
"confirmations.redraft.confirm": "حذف و بازنویسی",
"confirmations.redraft.message": "مطمئنید که می‌خواهید این فرسته را حذف کنید و از نو بنویسید؟ با این کار تقویت‌ها و پسندهایش از دست رفته و پاسخ‌ها به آن بی‌مرجع می‌شود.",
@ -693,7 +697,7 @@
"poll_button.remove_poll": "برداشتن نظرسنجی",
"privacy.change": "تغییر محرمانگی فرسته",
"privacy.direct.long": "هرکسی که در فرسته نام برده شده",
"privacy.direct.short": "افراد مشخّص",
"privacy.direct.short": "ذکر خصوصی",
"privacy.private.long": "تنها پی‌گیرندگانتان",
"privacy.private.short": "پی‌گیرندگان",
"privacy.public.long": "هرکسی در و بیرون از ماستودون",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Poista äänestys",
"privacy.change": "Muuta julkaisun näkyvyyttä",
"privacy.direct.long": "Kaikki tässä julkaisussa mainitut",
"privacy.direct.short": "Tietyt käyttäjät",
"privacy.direct.short": "Yksityismaininta",
"privacy.private.long": "Vain seuraajasi",
"privacy.private.short": "Seuraajat",
"privacy.public.long": "Kuka tahansa Mastodonissa ja sen ulkopuolella",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Strika atkvøðugreiðslu",
"privacy.change": "Broyt privatverju av posti",
"privacy.direct.long": "Øll, sum eru nevnd í postinum",
"privacy.direct.short": "Ávís fólk",
"privacy.direct.short": "Privat umrøða",
"privacy.private.long": "Einans tey, ið fylgja tær",
"privacy.private.short": "Fylgjarar",
"privacy.public.long": "Øll í og uttanfyri Mastodon",

View File

@ -1,6 +1,6 @@
{
"about.blocks": "Serveurs modérés",
"about.contact": "Contact:",
"about.contact": "Contact :",
"about.disclaimer": "Mastodon est un logiciel open-source gratuit et une marque déposée de Mastodon gGmbH.",
"about.domain_blocks.no_reason_available": "Raison non disponible",
"about.domain_blocks.preamble": "Mastodon vous permet généralement de visualiser le contenu et d'interagir avec des comptes de n'importe quel serveur dans le fediverse. Voici les exceptions qui ont été faites sur ce serveur en particulier.",
@ -697,7 +697,7 @@
"poll_button.remove_poll": "Supprimer le sondage",
"privacy.change": "Changer la confidentialité des messages",
"privacy.direct.long": "Toutes les personnes mentionnées dans le post",
"privacy.direct.short": "Personnes spécifiques",
"privacy.direct.short": "Mention privée",
"privacy.private.long": "Seulement vos abonnés",
"privacy.private.short": "Abonnés",
"privacy.public.long": "Tout le monde sur et en dehors de Mastodon",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Supprimer le sondage",
"privacy.change": "Ajuster la confidentialité du message",
"privacy.direct.long": "Toutes les personnes mentionnées dans le post",
"privacy.direct.short": "Personnes spécifiques",
"privacy.direct.short": "Mention privée",
"privacy.private.long": "Seulement vos abonnés",
"privacy.private.short": "Abonnés",
"privacy.public.long": "Tout le monde sur et en dehors de Mastodon",

View File

@ -682,7 +682,6 @@
"poll_button.remove_poll": "Enkête fuortsmite",
"privacy.change": "Sichtberheid fan berjocht oanpasse",
"privacy.direct.long": "Elkenien dy yn it berjocht fermeld wurdt",
"privacy.direct.short": "Bepaalde minsken",
"privacy.private.long": "Allinnich jo folgers",
"privacy.private.short": "Folgers",
"privacy.public.long": "Elkenien op Mastodon en dêrbûten",

View File

@ -697,7 +697,7 @@
"poll_button.remove_poll": "Bain suirbhé",
"privacy.change": "Athraigh príobháideacht postála",
"privacy.direct.long": "Luaigh gach duine sa phost",
"privacy.direct.short": "Daoine ar leith",
"privacy.direct.short": "Tagairt phríobháideach",
"privacy.private.long": "Do leanúna amháin",
"privacy.private.short": "Leantóirí",
"privacy.public.long": "Duine ar bith ar agus amach Mastodon",

View File

@ -633,7 +633,6 @@
"poll_button.remove_poll": "Thoir air falbh an cunntas-bheachd",
"privacy.change": "Cuir gleus air prìobhaideachd a phuist",
"privacy.direct.long": "A h-uile duine air a bheil iomradh sa phost",
"privacy.direct.short": "Daoine àraidh",
"privacy.private.long": "An luchd-leantainn agad a-mhàin",
"privacy.private.short": "Luchd-leantainn",
"privacy.public.long": "Duine sam bith taobh a-staigh no a-muigh Mhastodon",

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