mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-14 15:25:51 -05:00
Merge branch 'main' into improve-devcontainer-config
This commit is contained in:
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -61,6 +61,6 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
2
.github/workflows/test-ruby.yml
vendored
2
.github/workflows/test-ruby.yml
vendored
@@ -167,7 +167,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.ruby-version == '.ruby-version'
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
|
||||
with:
|
||||
files: coverage/lcov/*.lcov
|
||||
env:
|
||||
|
||||
@@ -24,3 +24,6 @@ Rails/RakeEnvironment:
|
||||
|
||||
Rails/SkipsModelValidations:
|
||||
Enabled: false
|
||||
|
||||
Rails/StrongParametersExpect:
|
||||
Enabled: false
|
||||
|
||||
@@ -33,6 +33,9 @@ Style/NumericLiterals:
|
||||
AllowedPatterns:
|
||||
- \d{4}_\d{2}_\d{2}_\d{6}
|
||||
|
||||
Style/OneClassPerFile:
|
||||
Enabled: false
|
||||
|
||||
Style/PercentLiteralDelimiters:
|
||||
PreferredDelimiters:
|
||||
'%i': ()
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -135,7 +135,7 @@ group :test do
|
||||
# Browser integration testing
|
||||
gem 'capybara', '~> 3.39'
|
||||
gem 'capybara-playwright-driver'
|
||||
gem 'playwright-ruby-client', '1.59.1', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
|
||||
gem 'playwright-ruby-client', '1.60.0', require: false # Pinning the exact version as it needs to be kept in sync with the installed npm package
|
||||
|
||||
# Used to reset the database between system tests
|
||||
gem 'database_cleaner-active_record'
|
||||
@@ -223,7 +223,7 @@ gem 'concurrent-ruby', require: false
|
||||
gem 'connection_pool', require: false
|
||||
gem 'xorcist', '~> 1.1'
|
||||
|
||||
gem 'net-http', '~> 0.6.0'
|
||||
gem 'net-http', '~> 0.9.0'
|
||||
gem 'rubyzip', '~> 3.0'
|
||||
|
||||
gem 'hcaptcha', '~> 7.1'
|
||||
|
||||
36
Gemfile.lock
36
Gemfile.lock
@@ -99,8 +99,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1249.0)
|
||||
aws-sdk-core (3.247.0)
|
||||
aws-partitions (1.1254.0)
|
||||
aws-sdk-core (3.250.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -235,7 +235,7 @@ GEM
|
||||
fabrication (3.0.0)
|
||||
faker (3.8.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.1)
|
||||
faraday (2.14.2)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -243,7 +243,7 @@ GEM
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (2.0.2)
|
||||
httpclient (>= 2.2)
|
||||
faraday-net_http (3.4.2)
|
||||
faraday-net_http (3.4.3)
|
||||
net-http (~> 0.5)
|
||||
fast_blank (1.0.1)
|
||||
fastimage (2.4.1)
|
||||
@@ -378,7 +378,7 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
jsonapi-renderer (0.2.2)
|
||||
jwt (2.10.2)
|
||||
jwt (2.10.3)
|
||||
base64
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
@@ -455,8 +455,8 @@ GEM
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.20.1)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.4)
|
||||
date
|
||||
net-protocol
|
||||
@@ -539,7 +539,7 @@ GEM
|
||||
opentelemetry-instrumentation-active_support (~> 0.10)
|
||||
opentelemetry-instrumentation-active_support (0.12.0)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-base (0.26.0)
|
||||
opentelemetry-instrumentation-base (0.26.1)
|
||||
opentelemetry-api (~> 1.7)
|
||||
opentelemetry-common (~> 0.21)
|
||||
opentelemetry-registry (~> 0.1)
|
||||
@@ -559,7 +559,7 @@ GEM
|
||||
opentelemetry-helpers-sql
|
||||
opentelemetry-helpers-sql-processor
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-rack (0.31.0)
|
||||
opentelemetry-instrumentation-rack (0.31.1)
|
||||
opentelemetry-instrumentation-base (~> 0.25)
|
||||
opentelemetry-instrumentation-rails (0.42.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.7)
|
||||
@@ -588,7 +588,7 @@ GEM
|
||||
ostruct (0.6.3)
|
||||
ox (2.14.26)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.28.0)
|
||||
parallel (2.1.0)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
@@ -598,7 +598,7 @@ GEM
|
||||
pg (1.6.3)
|
||||
pghero (3.8.0)
|
||||
activerecord (>= 7.2)
|
||||
playwright-ruby-client (1.59.1)
|
||||
playwright-ruby-client (1.60.0)
|
||||
base64
|
||||
concurrent-ruby (>= 1.1.6)
|
||||
mime-types (>= 3.0)
|
||||
@@ -755,11 +755,11 @@ GEM
|
||||
rspec-mocks (~> 3.0)
|
||||
sidekiq (>= 5, < 9)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.84.2)
|
||||
rubocop (1.86.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
@@ -779,7 +779,7 @@ GEM
|
||||
lint_roller (~> 1.1)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.47.1, < 2.0)
|
||||
rubocop-rails (2.34.3)
|
||||
rubocop-rails (2.35.2)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
@@ -816,7 +816,7 @@ GEM
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (7.0.1)
|
||||
activesupport (>= 7.1)
|
||||
sidekiq (8.1.5)
|
||||
sidekiq (8.1.6)
|
||||
connection_pool (>= 3.0.0)
|
||||
json (>= 2.16.0)
|
||||
logger (>= 1.7.0)
|
||||
@@ -827,7 +827,7 @@ GEM
|
||||
sidekiq-scheduler (6.0.2)
|
||||
rufus-scheduler (~> 3.2)
|
||||
sidekiq (>= 7.3, < 9)
|
||||
sidekiq-unique-jobs (8.0.13)
|
||||
sidekiq-unique-jobs (8.1.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.5)
|
||||
sidekiq (>= 7.0.0, < 9.0.0)
|
||||
thor (>= 1.0, < 3.0)
|
||||
@@ -1012,7 +1012,7 @@ DEPENDENCIES
|
||||
memory_profiler
|
||||
mime-types (~> 3.7.0)
|
||||
mutex_m
|
||||
net-http (~> 0.6.0)
|
||||
net-http (~> 0.9.0)
|
||||
net-ldap (~> 0.18)
|
||||
nokogiri (~> 1.15)
|
||||
omniauth (~> 2.0)
|
||||
@@ -1040,7 +1040,7 @@ DEPENDENCIES
|
||||
parslet
|
||||
pg (~> 1.5)
|
||||
pghero
|
||||
playwright-ruby-client (= 1.59.1)
|
||||
playwright-ruby-client (= 1.60.0)
|
||||
premailer-rails
|
||||
prometheus_exporter (~> 2.2)
|
||||
propshaft
|
||||
|
||||
@@ -9,7 +9,6 @@ class ActivityPub::FeaturedCollectionsController < ApplicationController
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, if: -> { authorized_fetch_mode? }
|
||||
before_action :set_collections
|
||||
|
||||
@@ -72,8 +71,4 @@ class ActivityPub::FeaturedCollectionsController < ApplicationController
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,6 +25,8 @@ module Admin
|
||||
@action_logs = @report.history.includes(:target)
|
||||
@form = Admin::StatusBatchAction.new
|
||||
@statuses = @report.statuses.with_includes
|
||||
@collections = @report.collections
|
||||
@collection_form = Admin::CollectionBatchAction.new
|
||||
|
||||
render 'admin/reports/show'
|
||||
end
|
||||
|
||||
@@ -16,6 +16,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController
|
||||
|
||||
FILTER_PARAMS = %i(
|
||||
resolved
|
||||
unresolved
|
||||
account_id
|
||||
target_account_id
|
||||
).freeze
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1Alpha::CollectionItemsController < Api::BaseController
|
||||
class Api::V1::CollectionItemsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action :check_feature_enabled
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:collections' }
|
||||
|
||||
before_action :require_user!
|
||||
@@ -55,8 +53,4 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
|
||||
def set_collection_item
|
||||
@collection_item = @collection.collection_items.find(params[:id])
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
class Api::V1::CollectionsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
DEFAULT_COLLECTIONS_LIMIT = 40
|
||||
@@ -9,8 +9,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
render json: { error: ValidationErrorFormatter.new(e).as_json }, status: 422
|
||||
end
|
||||
|
||||
before_action :check_feature_enabled
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index, :show]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:collections' }, only: [:create, :update, :destroy]
|
||||
|
||||
@@ -28,10 +26,9 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
cache_if_unauthenticated!
|
||||
authorize @account, :index_collections?
|
||||
|
||||
presenter = CollectionsPresenter.new(collections: @collections)
|
||||
render json: presenter, serializer: REST::CollectionsWithAccountPreviewsSerializer
|
||||
render json: @collections, each_serializer: REST::CollectionSerializer, adapter: :json
|
||||
rescue Mastodon::NotPermittedError
|
||||
render json: { collections: [], partial_accounts: [] }
|
||||
render json: { collections: [] }
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -74,7 +71,6 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
def set_collections
|
||||
@collections = @account.collections
|
||||
.with_tag
|
||||
.preload(top_items: :account)
|
||||
.order(created_at: :desc)
|
||||
.offset(offset_param)
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
@@ -93,20 +89,16 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
|
||||
params.permit(:name, :description, :language, :sensitive, :discoverable, :tag_name)
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
|
||||
def next_path
|
||||
return unless records_continue?
|
||||
|
||||
api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
api_v1_account_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
end
|
||||
|
||||
def prev_path
|
||||
return if offset_param.zero?
|
||||
|
||||
api_v1_alpha_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
api_v1_account_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@@ -1,12 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1Alpha::InCollectionsController < Api::BaseController
|
||||
class Api::V1::InCollectionsController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
DEFAULT_COLLECTIONS_LIMIT = 40
|
||||
|
||||
before_action :check_feature_enabled
|
||||
|
||||
before_action -> { authorize_if_got_token! :read, :'read:collections' }, only: [:index]
|
||||
|
||||
before_action :require_user!
|
||||
@@ -37,20 +35,16 @@ class Api::V1Alpha::InCollectionsController < Api::BaseController
|
||||
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
|
||||
def next_path
|
||||
return unless records_continue?
|
||||
|
||||
api_v1_alpha_account_in_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
api_v1_account_in_collections_url(@account, pagination_params(offset: offset_param + limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
end
|
||||
|
||||
def prev_path
|
||||
return if offset_param.zero?
|
||||
|
||||
api_v1_alpha_account_in_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
api_v1_account_in_collections_url(@account, pagination_params(offset: offset_param - limit_param(DEFAULT_COLLECTIONS_LIMIT)))
|
||||
end
|
||||
|
||||
def records_continue?
|
||||
@@ -23,10 +23,6 @@ class Api::V1::ReportsController < Api::BaseController
|
||||
end
|
||||
|
||||
def report_params
|
||||
if Mastodon::Feature.collections_enabled?
|
||||
params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], collection_ids: [], rule_ids: [])
|
||||
else
|
||||
params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], rule_ids: [])
|
||||
end
|
||||
params.permit(:account_id, :comment, :category, :forward, forward_to_domains: [], status_ids: [], collection_ids: [], rule_ids: [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -170,6 +170,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||
end
|
||||
|
||||
def serialized_accounts(accounts)
|
||||
ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer)
|
||||
ActiveModel::Serializer::CollectionSerializer.new(accounts, serializer: REST::AccountSerializer, scope_name: :current_user, scope: current_user)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,7 +7,6 @@ class CollectionItemsController < ApplicationController
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, if: -> { authorized_fetch_mode? }
|
||||
before_action :set_collection_item
|
||||
|
||||
@@ -35,8 +34,4 @@ class CollectionItemsController < ApplicationController
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,6 @@ class CollectionsController < ApplicationController
|
||||
|
||||
vary_by -> { public_fetch_mode? ? 'Accept, Accept-Language, Cookie' : 'Accept, Accept-Language, Cookie, Signature' }
|
||||
|
||||
before_action :check_feature_enabled
|
||||
before_action :require_account_signature!, only: :show, if: -> { request.format == :json && authorized_fetch_mode? }
|
||||
before_action :set_collection
|
||||
|
||||
@@ -51,8 +50,4 @@ class CollectionsController < ApplicationController
|
||||
recently_updated = @collection.updated_at > 15.minutes.ago
|
||||
recently_updated ? 30.seconds : 5.minutes
|
||||
end
|
||||
|
||||
def check_feature_enabled
|
||||
raise ActionController::RoutingError unless Mastodon::Feature.collections_enabled?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,8 +20,9 @@ module Admin::FilterHelper
|
||||
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
|
||||
new_url = filtered_url_for(link_to_params)
|
||||
new_class = filtered_url_for(link_class_params)
|
||||
is_selected = selected?(link_class_params)
|
||||
|
||||
link_to text, new_url, class: filter_link_class(new_class)
|
||||
link_to text, new_url, class: filter_link_class(new_class), 'aria-current': (is_selected ? 'true' : nil)
|
||||
end
|
||||
|
||||
def table_link_to(icon, text, path, **options)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createAction } from '@reduxjs/toolkit';
|
||||
import {
|
||||
apiRemoveAccountFromFollowers,
|
||||
apiGetEndorsedAccounts,
|
||||
apiGetAccounts,
|
||||
} from 'mastodon/api/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
|
||||
@@ -113,3 +114,12 @@ export const fetchEndorsedAccounts = createDataLoadingThunk(
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchAccounts = createDataLoadingThunk(
|
||||
'accounts/multi_accounts',
|
||||
({ accountIds }: { accountIds: string[] }) => apiGetAccounts(accountIds),
|
||||
(data, { dispatch }) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
return data;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -327,7 +327,7 @@ export function uploadCompose(files) {
|
||||
dispatch(showAlert({ message: messages.uploadQuote }));
|
||||
return;
|
||||
}
|
||||
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']);
|
||||
const uploadLimit = getState().getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments']);
|
||||
const media = getState().getIn(['compose', 'media_attachments']);
|
||||
const pending = getState().getIn(['compose', 'pending_media_attachments']);
|
||||
const progress = new Array(files.length).fill(0);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
||||
import { loadCustomEmoji } from '@/mastodon/features/emoji';
|
||||
import { emojiLogger } from '@/mastodon/features/emoji/utils';
|
||||
|
||||
const log = emojiLogger('actions');
|
||||
|
||||
export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
|
||||
if (emojis.length === 0) {
|
||||
@@ -18,5 +21,11 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
|
||||
if (existingEmojis.length < emojis.length) {
|
||||
await clearCache('custom');
|
||||
await loadCustomEmoji();
|
||||
|
||||
const { reloadCustomEmojis } =
|
||||
await import('@/mastodon/features/emoji/picker');
|
||||
await reloadCustomEmojis();
|
||||
|
||||
log('Custom emojis updated, reloaded cache and picker data.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,18 @@ function notificationTypeForFilter(type: NotificationType) {
|
||||
}
|
||||
|
||||
function notificationTypeForQuickFilter(type: NotificationType) {
|
||||
if (type === 'quoted_update') return 'update';
|
||||
else if (type === 'quote') return 'mention';
|
||||
else return type;
|
||||
switch (type) {
|
||||
case 'quoted_update':
|
||||
return 'update';
|
||||
case 'quote':
|
||||
return 'mention';
|
||||
case 'collection_update':
|
||||
return 'collection';
|
||||
case 'added_to_collection':
|
||||
return 'collection';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function excludeAllTypesExcept(filter: string) {
|
||||
|
||||
@@ -86,6 +86,9 @@ export function setupBrowserNotifications() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(NotificationPermission) => void} callback
|
||||
*/
|
||||
export function requestBrowserPermission(callback = noOp) {
|
||||
return dispatch => {
|
||||
requestNotificationPermission((permission) => {
|
||||
|
||||
@@ -16,19 +16,33 @@ export const fetchServer = createDataLoadingThunk(
|
||||
dispatch(importFetchedAccount(instance.contact.account));
|
||||
}
|
||||
},
|
||||
{
|
||||
condition: (_, { getState }) => !getState().server.server.isLoading,
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchExtendedDescription = createDataLoadingThunk(
|
||||
'server/extended_description',
|
||||
() => apiGetExtendedDescription(),
|
||||
{
|
||||
condition: (_, { getState }) =>
|
||||
!getState().server.extendedDescription.isLoading,
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchServerTranslationLanguages = createDataLoadingThunk(
|
||||
'server/translation_languages',
|
||||
() => apiGetTranslationLanguages(),
|
||||
{
|
||||
condition: (_, { getState }) =>
|
||||
!getState().server.translationLanguages.isLoading,
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchDomainBlocks = createDataLoadingThunk(
|
||||
'server/domain_blocks',
|
||||
() => apiGetDomainBlocks(),
|
||||
{
|
||||
condition: (_, { getState }) => !getState().server.domainBlocks.isLoading,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -19,6 +19,11 @@ import type {
|
||||
ApiProfileUpdateParams,
|
||||
} from '../api_types/profile';
|
||||
|
||||
export const apiGetAccounts = (ids: string[]) =>
|
||||
apiRequestGet<ApiAccountJSON[]>('v1/accounts', {
|
||||
id: ids,
|
||||
});
|
||||
|
||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
comment: value,
|
||||
|
||||
@@ -15,48 +15,40 @@ import type {
|
||||
} from '../api_types/collections';
|
||||
|
||||
export const apiCreateCollection = (collection: ApiCreateCollectionPayload) =>
|
||||
apiRequestPost<ApiWrappedCollectionJSON>('v1_alpha/collections', collection);
|
||||
apiRequestPost<ApiWrappedCollectionJSON>('v1/collections', collection);
|
||||
|
||||
export const apiUpdateCollection = ({
|
||||
id,
|
||||
...collection
|
||||
}: ApiUpdateCollectionPayload) =>
|
||||
apiRequestPut<ApiWrappedCollectionJSON>(
|
||||
`v1_alpha/collections/${id}`,
|
||||
collection,
|
||||
);
|
||||
apiRequestPut<ApiWrappedCollectionJSON>(`v1/collections/${id}`, collection);
|
||||
|
||||
export const apiDeleteCollection = (collectionId: string) =>
|
||||
apiRequestDelete(`v1_alpha/collections/${collectionId}`);
|
||||
apiRequestDelete(`v1/collections/${collectionId}`);
|
||||
|
||||
export const apiGetCollection = (collectionId: string) =>
|
||||
apiRequestGet<ApiCollectionWithAccountsJSON>(
|
||||
`v1_alpha/collections/${collectionId}`,
|
||||
`v1/collections/${collectionId}`,
|
||||
);
|
||||
|
||||
export const apiGetCollectionsCreatedByAccount = (accountId: string) =>
|
||||
apiRequestGet<ApiCollectionsJSON>(
|
||||
`v1_alpha/accounts/${accountId}/collections`,
|
||||
);
|
||||
apiRequestGet<ApiCollectionsJSON>(`v1/accounts/${accountId}/collections`);
|
||||
|
||||
export const apiGetCollectionsFeaturingAccount = (accountId: string) =>
|
||||
apiRequestGet<ApiCollectionsJSON>(
|
||||
`v1_alpha/accounts/${accountId}/in_collections`,
|
||||
);
|
||||
apiRequestGet<ApiCollectionsJSON>(`v1/accounts/${accountId}/in_collections`);
|
||||
|
||||
export const apiAddCollectionItem = (collectionId: string, accountId: string) =>
|
||||
apiRequestPost<WrappedCollectionAccountItem>(
|
||||
`v1_alpha/collections/${collectionId}/items`,
|
||||
`v1/collections/${collectionId}/items`,
|
||||
{ account_id: accountId },
|
||||
);
|
||||
|
||||
export const apiRemoveCollectionItem = (collectionId: string, itemId: string) =>
|
||||
apiRequestDelete<WrappedCollectionAccountItem>(
|
||||
`v1_alpha/collections/${collectionId}/items/${itemId}`,
|
||||
`v1/collections/${collectionId}/items/${itemId}`,
|
||||
);
|
||||
|
||||
export const apiRevokeCollectionInclusion = (
|
||||
collectionId: string,
|
||||
itemId: string,
|
||||
) =>
|
||||
apiRequestPost(`v1_alpha/collections/${collectionId}/items/${itemId}/revoke`);
|
||||
) => apiRequestPost(`v1/collections/${collectionId}/items/${itemId}/revoke`);
|
||||
|
||||
@@ -15,7 +15,7 @@ export const apiUpdate = (list: Partial<ApiListJSON>) =>
|
||||
|
||||
export const apiGetLists = () => apiRequestGet<ApiListJSON[]>('v1/lists');
|
||||
|
||||
export const apiGetAccounts = (listId: string) =>
|
||||
export const apiGetListAccounts = (listId: string) =>
|
||||
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ApiAccountJSON } from './accounts';
|
||||
import type { ApiCollectionJSON } from './collections';
|
||||
import type { ApiStatusJSON } from './statuses';
|
||||
import type { ApiHashtagJSON } from './tags';
|
||||
|
||||
@@ -8,4 +9,5 @@ export interface ApiSearchResultsJSON {
|
||||
accounts: ApiAccountJSON[];
|
||||
statuses: ApiStatusJSON[];
|
||||
hashtags: ApiHashtagJSON[];
|
||||
collections: ApiCollectionJSON[];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { CopyIconButton } from '../copy_icon_button';
|
||||
import { CopyIconButton } from '../copy_button';
|
||||
import { FollowButton } from '../follow_button';
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
|
||||
@@ -7,17 +7,16 @@ import classNames from 'classnames';
|
||||
|
||||
import Overlay from 'react-overlays/esm/Overlay';
|
||||
|
||||
import { showAlert } from '@/mastodon/actions/alerts';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useRelationship } from '@/mastodon/hooks/useRelationship';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import AtIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
|
||||
import DomainIcon from '@/material-icons/400-24px/language.svg?react';
|
||||
|
||||
import { FollowsYouBadge } from '../badge';
|
||||
import { Button } from '../button';
|
||||
import { CopyButton } from '../copy_button';
|
||||
import { DisplayName } from '../display_name';
|
||||
import { Icon } from '../icon';
|
||||
|
||||
@@ -90,17 +89,6 @@ const AccountNameHelp: FC<{
|
||||
|
||||
const handle = `@${username}@${domain}`;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleCopy = useCallback(() => {
|
||||
void navigator.clipboard.writeText(handle);
|
||||
setCopied(true);
|
||||
dispatch(showAlert({ message: messages.copied }));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
}, [handle, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -182,21 +170,25 @@ const AccountNameHelp: FC<{
|
||||
tagName='p'
|
||||
/>
|
||||
|
||||
<Button onClick={handleCopy} className={classes.handleCopy}>
|
||||
<Icon id='copy' icon={ContentCopyIcon} />
|
||||
{!copied && (
|
||||
<FormattedMessage
|
||||
id='account.name.copy'
|
||||
defaultMessage='Copy handle'
|
||||
/>
|
||||
<CopyButton value={handle} className={classes.handleCopy}>
|
||||
{(wasCopied) => (
|
||||
<>
|
||||
<Icon id='copy' icon={ContentCopyIcon} />
|
||||
{!wasCopied && (
|
||||
<FormattedMessage
|
||||
id='account.name.copy'
|
||||
defaultMessage='Copy handle'
|
||||
/>
|
||||
)}
|
||||
{wasCopied && (
|
||||
<FormattedMessage
|
||||
id='copypaste.copied'
|
||||
defaultMessage='Copied'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{copied && (
|
||||
<FormattedMessage
|
||||
id='copypaste.copied'
|
||||
defaultMessage='Copied'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</CopyButton>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
word-break: break-all;
|
||||
text-align: left;
|
||||
|
||||
/* Allow the handle text to be selected */
|
||||
user-select: text;
|
||||
|
||||
> svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -480,6 +483,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bannerBaseCentered {
|
||||
|
||||
75
app/javascript/mastodon/components/copy_button.tsx
Normal file
75
app/javascript/mastodon/components/copy_button.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { showAlert } from 'mastodon/actions/alerts';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { Button } from './button';
|
||||
|
||||
const messages = defineMessages({
|
||||
copied: {
|
||||
id: 'copy_icon_button.copied',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
},
|
||||
});
|
||||
|
||||
export function useCopyToClipboard({ text }: { text: string }) {
|
||||
const [wasCopied, setWasCopied] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const copyText = useCallback(() => {
|
||||
void navigator.clipboard.writeText(text);
|
||||
setWasCopied(true);
|
||||
dispatch(showAlert({ message: messages.copied }));
|
||||
setTimeout(() => {
|
||||
setWasCopied(false);
|
||||
}, 700);
|
||||
}, [setWasCopied, text, dispatch]);
|
||||
|
||||
return { copyText, wasCopied };
|
||||
}
|
||||
|
||||
export const CopyButton: React.FC<
|
||||
Omit<
|
||||
React.ComponentPropsWithoutRef<typeof Button>,
|
||||
'onClick' | 'text' | 'children'
|
||||
> & {
|
||||
value: string;
|
||||
children: React.ReactNode | ((wasCopied: boolean) => React.ReactNode);
|
||||
}
|
||||
> = ({ value, children, ...otherProps }) => {
|
||||
const { copyText, wasCopied } = useCopyToClipboard({ text: value });
|
||||
|
||||
const label = typeof children === 'function' ? children(wasCopied) : children;
|
||||
|
||||
return (
|
||||
<Button {...otherProps} onClick={copyText}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const CopyIconButton: React.FC<{
|
||||
title: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
'aria-describedby'?: string;
|
||||
}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => {
|
||||
const { copyText, wasCopied } = useCopyToClipboard({ text: value });
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
className={classNames(className, wasCopied ? 'copied' : 'copyable')}
|
||||
title={title}
|
||||
onClick={copyText}
|
||||
icon='copy-icon'
|
||||
iconComponent={ContentCopyIcon}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { showAlert } from 'mastodon/actions/alerts';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
copied: {
|
||||
id: 'copy_icon_button.copied',
|
||||
defaultMessage: 'Copied to clipboard',
|
||||
},
|
||||
});
|
||||
|
||||
export const CopyIconButton: React.FC<{
|
||||
title: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
'aria-describedby'?: string;
|
||||
}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
void navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
dispatch(showAlert({ message: messages.copied }));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
}, [setCopied, value, dispatch]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
icon='copy-icon'
|
||||
iconComponent={ContentCopyIcon}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -13,8 +13,7 @@
|
||||
.content {
|
||||
max-width: 370px;
|
||||
|
||||
svg,
|
||||
img {
|
||||
:where(svg, img) {
|
||||
width: 200px;
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
@@ -22,16 +21,10 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
text-wrap: balance;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
@@ -41,6 +34,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
text-wrap: balance;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.errorImage {
|
||||
width: 280px;
|
||||
margin: -10% 0;
|
||||
}
|
||||
|
||||
[data-color-scheme='dark'] .defaultImage {
|
||||
--color-skin-1: #3a3a50;
|
||||
--color-skin-2: #67678e;
|
||||
|
||||
@@ -29,6 +29,14 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
image: 'error',
|
||||
title: 'Error',
|
||||
message: 'Something went wrong loading the page.',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
|
||||
@@ -4,10 +4,15 @@ import classNames from 'classnames';
|
||||
|
||||
import ElephantImage from '@/images/elephant_ui.svg?react';
|
||||
|
||||
import { GIF } from '../gif';
|
||||
|
||||
import classes from './empty_state.module.scss';
|
||||
|
||||
const images = {
|
||||
default: <ElephantImage className={classes.defaultImage} />,
|
||||
error: (
|
||||
<GIF src='/oops.gif' staticSrc='/oops.png' className={classes.errorImage} />
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -21,6 +26,7 @@ export const EmptyState: React.FC<{
|
||||
title?: React.ReactNode;
|
||||
message?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
headingLevel?: 'h2' | 'h3' | 'h4';
|
||||
className?: string;
|
||||
}> = ({
|
||||
image = 'default',
|
||||
@@ -29,6 +35,7 @@ export const EmptyState: React.FC<{
|
||||
),
|
||||
message,
|
||||
children,
|
||||
headingLevel: Heading = 'h2',
|
||||
className,
|
||||
}) => {
|
||||
const imageToRender = typeof image === 'string' ? images[image] : image;
|
||||
@@ -38,7 +45,7 @@ export const EmptyState: React.FC<{
|
||||
{(title || message || imageToRender) && (
|
||||
<div className={classes.content}>
|
||||
{imageToRender}
|
||||
{!!title && <h3>{title}</h3>}
|
||||
{!!title && <Heading className={classes.heading}>{title}</Heading>}
|
||||
{!!message && <p>{message}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -100,6 +100,10 @@ interface ComboboxProps<
|
||||
* Icon to be displayed in the text input
|
||||
*/
|
||||
icon?: TextInputProps['icon'] | null;
|
||||
/**
|
||||
* Set to true to open as soon as there is focus
|
||||
*/
|
||||
openOnFocus?: boolean;
|
||||
/**
|
||||
* Set to false to keep the menu open when an item is selected
|
||||
*/
|
||||
@@ -217,8 +221,10 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
|
||||
renderGroupTitle,
|
||||
renderItem,
|
||||
onSelectItem,
|
||||
onFocus,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
openOnFocus = false,
|
||||
closeOnSelect = true,
|
||||
suppressMenu = false,
|
||||
icon = SearchIcon,
|
||||
@@ -288,6 +294,16 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFocus: React.FocusEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
if (openOnFocus) {
|
||||
setShouldMenuOpen(true);
|
||||
}
|
||||
onFocus?.(e);
|
||||
},
|
||||
[onFocus, openOnFocus],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e);
|
||||
@@ -487,6 +503,7 @@ const ComboboxWithRef = <Item extends ComboboxItem, GroupKey extends string>(
|
||||
autoComplete='off'
|
||||
spellCheck='false'
|
||||
value={value}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
icon={icon ?? undefined}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
||||
import { CopyIconButton } from '@/mastodon/components/copy_button';
|
||||
|
||||
import classes from './copy_link_field.module.scss';
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { autoPlayGif } from 'mastodon/initial_state';
|
||||
export const GIF: React.FC<{
|
||||
src: string;
|
||||
staticSrc: string;
|
||||
className: string;
|
||||
className?: string;
|
||||
animate?: boolean;
|
||||
}> = ({ src, staticSrc, className, animate = autoPlayGif }) => {
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
||||
|
||||
@@ -144,11 +144,16 @@ export const HoverCardController: React.FC = () => {
|
||||
setScrollTimeout(handleScrollEnd, 100);
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isUsingTouch) {
|
||||
isUsingTouch = false;
|
||||
}
|
||||
|
||||
const hasMoved = Math.max(e.movementX, e.movementY) > 0;
|
||||
if (!hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
delayEnterTimeout(enterDelay);
|
||||
|
||||
cancelMoveTimeout();
|
||||
|
||||
@@ -285,7 +285,7 @@ class ScrollableList extends PureComponent {
|
||||
if (this.props.bindToDocument) {
|
||||
document.removeEventListener('scroll', this.handleScroll);
|
||||
document.removeEventListener('wheel', this.handleWheel, listenerOptions);
|
||||
} else {
|
||||
} else if (this.node) {
|
||||
this.node.removeEventListener('scroll', this.handleScroll);
|
||||
this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@ class Status extends ImmutablePureComponent {
|
||||
).find((item) => compareUrls(item.get('url'), cardUrl));
|
||||
|
||||
if (taggedCollection) {
|
||||
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
|
||||
media = <CollectionPreviewCard collection={taggedCollection.toJS()} headingLevel='h2' />;
|
||||
} else {
|
||||
media = (
|
||||
<Card
|
||||
@@ -570,7 +570,7 @@ class Status extends ImmutablePureComponent {
|
||||
const firstLinkedCollection = status.get('tagged_collections').first();
|
||||
if (firstLinkedCollection) {
|
||||
media = (
|
||||
<CollectionPreviewCard collection={firstLinkedCollection.toJS()} />
|
||||
<CollectionPreviewCard collection={firstLinkedCollection.toJS()} headingLevel='h2' />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export interface StatusHeaderProps {
|
||||
status: Status;
|
||||
account?: Account;
|
||||
avatarSize?: number;
|
||||
children?: ReactNode;
|
||||
contentBeforeDate?: ReactNode;
|
||||
contentAfterDate?: ReactNode;
|
||||
wrapperProps?: HTMLAttributes<HTMLDivElement>;
|
||||
displayNameProps?: DisplayNameProps;
|
||||
onHeaderClick?: MouseEventHandler<HTMLDivElement>;
|
||||
@@ -33,10 +34,11 @@ export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode;
|
||||
export const StatusHeader: FC<StatusHeaderProps> = ({
|
||||
status,
|
||||
account,
|
||||
children,
|
||||
className,
|
||||
avatarSize = 48,
|
||||
wrapperProps,
|
||||
contentBeforeDate,
|
||||
contentAfterDate,
|
||||
onHeaderClick,
|
||||
}) => {
|
||||
const statusAccount = status.get('account') as Account | undefined;
|
||||
@@ -51,6 +53,14 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
|
||||
className={classNames('status__info', className)}
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
|
||||
>
|
||||
<StatusDisplayName
|
||||
statusAccount={statusAccount}
|
||||
friendAccount={account}
|
||||
avatarSize={avatarSize}
|
||||
/>
|
||||
|
||||
{contentBeforeDate}
|
||||
|
||||
<Link
|
||||
to={`/@${statusAccount?.acct}/${status.get('id') as string}`}
|
||||
className='status__relative-time'
|
||||
@@ -60,13 +70,7 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
|
||||
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
||||
</Link>
|
||||
|
||||
<StatusDisplayName
|
||||
statusAccount={statusAccount}
|
||||
friendAccount={account}
|
||||
avatarSize={avatarSize}
|
||||
/>
|
||||
|
||||
{children}
|
||||
{contentAfterDate}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ class TranslateButton extends PureComponent {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
languages: state.server.translationLanguages.items,
|
||||
languages: state.server.translationLanguages.item,
|
||||
});
|
||||
|
||||
class StatusContent extends PureComponent {
|
||||
@@ -187,7 +187,7 @@ class StatusContent extends PureComponent {
|
||||
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
const contentLocale = intl.locale.replace(/[_-].*/, '');
|
||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||
const targetLanguages = this.props.languages?.[status.get('language') || 'und'];
|
||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = statusContent ?? getStatusContent(status);
|
||||
|
||||
@@ -225,17 +225,20 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
||||
const intl = useIntl();
|
||||
const headerRenderFn: StatusHeaderRenderFn = useCallback(
|
||||
(props) => (
|
||||
<StatusHeader {...props}>
|
||||
{onQuoteCancel && (
|
||||
<IconButton
|
||||
onClick={onQuoteCancel}
|
||||
className='status__quote-cancel'
|
||||
title={intl.formatMessage(quoteCancelMessage)}
|
||||
icon='cancel-fill'
|
||||
iconComponent={CancelFillIcon}
|
||||
/>
|
||||
)}
|
||||
</StatusHeader>
|
||||
<StatusHeader
|
||||
{...props}
|
||||
contentAfterDate={
|
||||
onQuoteCancel && (
|
||||
<IconButton
|
||||
onClick={onQuoteCancel}
|
||||
className='status__quote-cancel'
|
||||
title={intl.formatMessage(quoteCancelMessage)}
|
||||
icon='cancel-fill'
|
||||
iconComponent={CancelFillIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
[intl, onQuoteCancel],
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
||||
|
||||
import { Section } from './components/section';
|
||||
import { RulesSection } from './components/rules';
|
||||
import { getColumnSkipLinkId } from '../ui/components/skip_links';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'column.about', defaultMessage: 'About' },
|
||||
@@ -80,7 +81,7 @@ class About extends PureComponent {
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable about'>
|
||||
<div className='scrollable about' id={getColumnSkipLinkId(1)}>
|
||||
<div className='about__header'>
|
||||
<ServerHeroImage
|
||||
withAltBadge
|
||||
@@ -164,7 +165,7 @@ class About extends PureComponent {
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<LinkFooter />
|
||||
<LinkFooter context='about' />
|
||||
|
||||
<div className='about__footer'>
|
||||
<p><FormattedMessage id='about.disclaimer' defaultMessage='Mastodon is free, open-source software, and a trademark of Mastodon gGmbH.' /></p>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Helmet } from '@unhead/react/helmet';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnHeader } from '@/mastodon/components/column_header';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
|
||||
import { useColumnsContext } from '../../ui/util/columns_context';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
@@ -2,14 +2,14 @@ import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DisplayName } from '@/mastodon/components/display_name';
|
||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
@@ -28,8 +28,8 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
blockedBy,
|
||||
withoutAddCollectionButton,
|
||||
}) => {
|
||||
const { acct } = useParams<{ acct?: string }>();
|
||||
const me = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -49,56 +49,39 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
let title: React.ReactNode = null;
|
||||
let message: React.ReactNode = null;
|
||||
|
||||
const hasCollections = areCollectionsEnabled();
|
||||
|
||||
if (me === accountId) {
|
||||
if (hasCollections) {
|
||||
// Return only here to insert the "Create a collection" button as the action for the empty state.
|
||||
return (
|
||||
<EmptyState
|
||||
title={
|
||||
// Return only here to insert the "Create a collection" button as the action for the empty state.
|
||||
return (
|
||||
<EmptyState
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts'
|
||||
defaultMessage='Showcase your favorite accounts'
|
||||
/>
|
||||
}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts_desc'
|
||||
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!withoutAddCollectionButton && (
|
||||
<Link to='/collections/new' className='button'>
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts'
|
||||
defaultMessage='Showcase your favorite accounts'
|
||||
id='empty_column.account_featured_self.no_collections_button'
|
||||
defaultMessage='Create a collection'
|
||||
/>
|
||||
}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.showcase_accounts_desc'
|
||||
defaultMessage='Collections are curated lists of accounts to help others discover more of the Fediverse.'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!withoutAddCollectionButton && (
|
||||
<Link to='/collections/new' className='button'>
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.no_collections_button'
|
||||
defaultMessage='Create a collection'
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
<Button secondary onClick={confirmHideFeaturedTab}>
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.no_collections_hide_tab'
|
||||
defaultMessage='Hide this tab instead'
|
||||
/>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
);
|
||||
} else {
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.pre_collections'
|
||||
defaultMessage='Stay tuned for Collections'
|
||||
/>
|
||||
);
|
||||
message = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.pre_collections_desc'
|
||||
defaultMessage='Collections (coming in Mastodon 4.6) allow you to create your own curated lists of accounts to recommend to others.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
</Link>
|
||||
)}
|
||||
<Button secondary onClick={confirmHideFeaturedTab}>
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured_self.no_collections_hide_tab'
|
||||
defaultMessage='Hide this tab instead'
|
||||
/>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
);
|
||||
} else if (suspended) {
|
||||
title = (
|
||||
<FormattedMessage
|
||||
@@ -116,12 +99,12 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
if (acct) {
|
||||
if (account) {
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_featured.other'
|
||||
defaultMessage='{acct} has not featured anything yet.'
|
||||
values={{ acct }}
|
||||
values={{ acct: <DisplayName variant='simple' account={account} /> }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@/mastodon/components/scrollable_list/components';
|
||||
import type { TruncatedListItemInfo } from '@/mastodon/components/truncated_list';
|
||||
import { TruncatedListItems } from '@/mastodon/components/truncated_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from '@/mastodon/features/ui/components/column';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
@@ -30,13 +30,10 @@ import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
|
||||
import { CollectionListItem } from '../collections/components/collection_list_item';
|
||||
import { useCollectionsCreatedBy } from '../collections/overview/created_by_you';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
import { Subheading, SubheadingLink } from './components/subheading';
|
||||
|
||||
const collectionsEnabled = areCollectionsEnabled();
|
||||
|
||||
const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
multiColumn,
|
||||
}) => {
|
||||
@@ -98,14 +95,11 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
);
|
||||
|
||||
const hasCollections =
|
||||
collectionsEnabled &&
|
||||
collectionsLoadStatus === 'idle' &&
|
||||
listedCollections.length > 0;
|
||||
collectionsLoadStatus === 'idle' && listedCollections.length > 0;
|
||||
|
||||
const hasFeaturedAccounts = !featuredAccountIds.isEmpty();
|
||||
|
||||
const isLoading =
|
||||
!accountId || (collectionsEnabled && collectionsLoadStatus !== 'idle');
|
||||
const isLoading = !accountId || collectionsLoadStatus !== 'idle';
|
||||
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
@@ -165,57 +159,53 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
</ItemList>
|
||||
</>
|
||||
)}
|
||||
{collectionsEnabled && (
|
||||
<>
|
||||
<Subheading as='header'>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='account.featured.collections'
|
||||
defaultMessage='Collections'
|
||||
/>
|
||||
</h2>
|
||||
{accountId === me && (
|
||||
<SubheadingLink to='/collections/new' icon={AddIcon}>
|
||||
<FormattedMessage
|
||||
id='account.featured.new_collection'
|
||||
defaultMessage='New collection'
|
||||
/>
|
||||
</SubheadingLink>
|
||||
)}
|
||||
</Subheading>
|
||||
{hasCollections ? (
|
||||
<ItemList>
|
||||
<TruncatedListItems
|
||||
visibleItems={listedCollections}
|
||||
truncatedItems={unlistedCollections}
|
||||
toggleButton={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.unlisted_collections_with_count'
|
||||
defaultMessage='Unlisted collections ({count})'
|
||||
values={{ count: unlistedCollections.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.unlisted_collections_description'
|
||||
defaultMessage='These don’t appear on your profile to others. Anyone with the link can discover them.'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
) : (
|
||||
<EmptyMessage
|
||||
withoutAddCollectionButton
|
||||
blockedBy={blockedBy}
|
||||
hidden={hidden}
|
||||
suspended={suspended}
|
||||
accountId={accountId}
|
||||
<Subheading as='header'>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='account.featured.collections'
|
||||
defaultMessage='Collections'
|
||||
/>
|
||||
</h2>
|
||||
{accountId === me && (
|
||||
<SubheadingLink to='/collections/new' icon={AddIcon}>
|
||||
<FormattedMessage
|
||||
id='account.featured.new_collection'
|
||||
defaultMessage='New collection'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</SubheadingLink>
|
||||
)}
|
||||
</Subheading>
|
||||
{hasCollections ? (
|
||||
<ItemList>
|
||||
<TruncatedListItems
|
||||
visibleItems={listedCollections}
|
||||
truncatedItems={unlistedCollections}
|
||||
toggleButton={{
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id='collections.unlisted_collections_with_count'
|
||||
defaultMessage='Unlisted collections ({count})'
|
||||
values={{ count: unlistedCollections.length }}
|
||||
/>
|
||||
),
|
||||
subtitle: (
|
||||
<FormattedMessage
|
||||
id='collections.unlisted_collections_description'
|
||||
defaultMessage='These don’t appear on your profile to others. Anyone with the link can discover them.'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
renderListItem={renderListItem}
|
||||
/>
|
||||
</ItemList>
|
||||
) : (
|
||||
<EmptyMessage
|
||||
withoutAddCollectionButton
|
||||
blockedBy={blockedBy}
|
||||
hidden={hidden}
|
||||
suspended={suspended}
|
||||
accountId={accountId}
|
||||
/>
|
||||
)}
|
||||
<RemoteHint accountId={accountId} />
|
||||
</Scrollable>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import ScrollableList from '@/mastodon/components/scrollable_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from '@/mastodon/features/ui/components/column';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
|
||||
@@ -22,18 +22,22 @@ export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({
|
||||
return <StatusHeader {...args} />;
|
||||
}
|
||||
return (
|
||||
<StatusHeader {...args} className={classes.pinnedStatusHeader}>
|
||||
<Badge
|
||||
className={classes.pinnedBadge}
|
||||
icon={<Icon id='pinned' icon={IconPinned} />}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.timeline.pinned'
|
||||
defaultMessage='Pinned'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StatusHeader>
|
||||
<StatusHeader
|
||||
{...args}
|
||||
className={classes.pinnedStatusHeader}
|
||||
contentBeforeDate={
|
||||
<Badge
|
||||
className={classes.pinnedBadge}
|
||||
icon={<Icon id='pinned' icon={IconPinned} />}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account.timeline.pinned'
|
||||
defaultMessage='Pinned'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { LimitedAccountHint } from '@/mastodon/components/limited_account_hint';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from '@/mastodon/components/remote_hint';
|
||||
import StatusList from '@/mastodon/components/status_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import {
|
||||
useAccountId,
|
||||
useCurrentAccountId,
|
||||
|
||||
@@ -126,4 +126,7 @@
|
||||
|
||||
.pinnedBadge {
|
||||
justify-self: end;
|
||||
|
||||
// Allow "click to open post" event to pass through
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ export const CollectionListItem: React.FC<CollectionListItemProps> = ({
|
||||
...otherProps
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const linkId = `${uniqueId}-link`;
|
||||
const infoId = `${uniqueId}-info`;
|
||||
const titleId = `${uniqueId}-title`;
|
||||
const subtitleId = `${uniqueId}-info`;
|
||||
|
||||
return (
|
||||
<Article
|
||||
focusable
|
||||
aria-labelledby={linkId}
|
||||
aria-describedby={infoId}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={subtitleId}
|
||||
aria-posinset={positionInList}
|
||||
aria-setsize={listSize}
|
||||
>
|
||||
@@ -52,6 +52,8 @@ export const CollectionListItem: React.FC<CollectionListItemProps> = ({
|
||||
className={classes.menuButton}
|
||||
/>
|
||||
}
|
||||
titleId={titleId}
|
||||
subtitleId={subtitleId}
|
||||
{...otherProps}
|
||||
/>
|
||||
</Article>
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface CollectionLockupProps {
|
||||
withTimestamp?: boolean;
|
||||
sideContent?: React.ReactNode;
|
||||
className?: string;
|
||||
headingLevel?: 'h2' | 'h3' | 'h4';
|
||||
titleId?: string;
|
||||
subtitleId?: string;
|
||||
}
|
||||
|
||||
export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
@@ -54,6 +57,9 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
withAuthorHandle = true,
|
||||
withTimestamp,
|
||||
sideContent,
|
||||
headingLevel = 'h3',
|
||||
titleId,
|
||||
subtitleId,
|
||||
className,
|
||||
}) => {
|
||||
const { id, name } = collection;
|
||||
@@ -70,8 +76,9 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
sideContent={sideContent}
|
||||
>
|
||||
<ListItemLink
|
||||
as='h3'
|
||||
as={headingLevel}
|
||||
to={getCollectionPath(id)}
|
||||
id={titleId}
|
||||
subtitle={
|
||||
<CollectionInfo
|
||||
collection={collection}
|
||||
@@ -79,6 +86,7 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
withTimestamp={withTimestamp}
|
||||
/>
|
||||
}
|
||||
subtitleId={subtitleId}
|
||||
>
|
||||
{name}
|
||||
</ListItemLink>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const CollectionShareModal: React.FC<{
|
||||
/>
|
||||
|
||||
<div className={classes.preview}>
|
||||
<CollectionPreviewCard collection={collection} />
|
||||
<CollectionPreviewCard collection={collection} headingLevel='h2' />
|
||||
</div>
|
||||
|
||||
<CopyLinkField
|
||||
|
||||
@@ -215,6 +215,7 @@ export const CollectionAccounts: React.FC<{
|
||||
resetAccounts,
|
||||
} = useSearchAccounts({
|
||||
withRelationships: true,
|
||||
withDefaultFollows: searchValue === '',
|
||||
// Don't suggest accounts that were already added
|
||||
filterResults: (account) =>
|
||||
!editorItems.find((item) => item.account_id === account.id),
|
||||
@@ -363,6 +364,7 @@ export const CollectionAccounts: React.FC<{
|
||||
)}
|
||||
{hasPendingItems && <PendingNote />}
|
||||
<ComboboxField
|
||||
openOnFocus
|
||||
id={inputId}
|
||||
label={intl.formatMessage({
|
||||
id: 'collections.search_accounts_label',
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
userCollectionLimit,
|
||||
} from '../editor';
|
||||
import classes from '../styles.module.scss';
|
||||
import { areCollectionsEnabled } from '../utils';
|
||||
|
||||
const CreateButton: React.FC = () => (
|
||||
<Link to='/collections/new' className='button button--compact'>
|
||||
@@ -49,7 +48,7 @@ export function useCollectionsCreatedBy(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && areCollectionsEnabled()) {
|
||||
if (accountId) {
|
||||
void dispatch(fetchCollectionsCreatedByAccount({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CollectionListItem } from '../components/collection_list_item';
|
||||
import classes from '../styles.module.scss';
|
||||
import { areCollectionsEnabled } from '../utils';
|
||||
|
||||
import { CollectionListError } from './created_by_you';
|
||||
|
||||
@@ -23,7 +22,7 @@ function useCollectionsFeaturing(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && areCollectionsEnabled()) {
|
||||
if (accountId) {
|
||||
void dispatch(fetchCollectionsFeaturingAccount({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { ApiMutedAccountJSON } from '@/mastodon/api_types/accounts';
|
||||
import type { ApiAccountJSON } from '@/mastodon/api_types/accounts';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function areCollectionsEnabled() {
|
||||
return isServerFeatureEnabled('collections');
|
||||
}
|
||||
|
||||
export const getCollectionPath = (id: string) => `/collections/${id}`;
|
||||
|
||||
export const canAccountBeAdded = (account: ApiMutedAccountJSON | Account) =>
|
||||
export const canAccountBeAdded = (account: ApiAccountJSON | Account) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
export const canAccountBeAddedByFollowers = (
|
||||
account: ApiMutedAccountJSON | Account,
|
||||
account: ApiAccountJSON | Account,
|
||||
) =>
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
@@ -248,7 +248,15 @@ class ComposeForm extends ImmutablePureComponent {
|
||||
const { intl, onPaste, onDrop, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
|
||||
|
||||
return (
|
||||
<form className='compose-form' onSubmit={this.handleSubmit}>
|
||||
<form
|
||||
className='compose-form'
|
||||
role='region'
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'tabs_bar.publish',
|
||||
defaultMessage: 'New Post'
|
||||
})}
|
||||
onSubmit={this.handleSubmit}
|
||||
>
|
||||
<ReplyIndicator />
|
||||
{!withoutNavigation && <NavigationBar />}
|
||||
<Warning />
|
||||
|
||||
@@ -58,7 +58,7 @@ const Option = ({ multipleChoice, index, title, autoFocus }) => {
|
||||
const dispatch = useDispatch();
|
||||
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
|
||||
const lang = useSelector(state => state.getIn(['compose', 'language']));
|
||||
const maxOptions = useSelector(state => state.getIn(['server', 'server', 'configuration', 'polls', 'max_options']));
|
||||
const maxOptions = useSelector(state => state.getIn(['server', 'server', 'item', 'configuration', 'polls', 'max_options']));
|
||||
|
||||
const handleChange = useCallback(({ target: { value } }) => {
|
||||
dispatch(changePollOption(index, value, maxOptions));
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { getCollectionPath } from '@/mastodon/features/collections/utils';
|
||||
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
@@ -337,6 +338,10 @@ export const Search: React.FC<{
|
||||
history.push(
|
||||
`/@${result.payload.statuses[0].account.acct}/${result.payload.statuses[0].id}`,
|
||||
);
|
||||
} else if (result.payload.collections[0]) {
|
||||
history.push(
|
||||
getCollectionPath(result.payload.collections[0].id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,11 +552,15 @@ export const Search: React.FC<{
|
||||
const searchOptionsHeading = useId();
|
||||
|
||||
return (
|
||||
<form ref={formRef} className={classNames('search', { active: expanded })}>
|
||||
<form
|
||||
role='search'
|
||||
ref={formRef}
|
||||
className={classNames('search', { active: expanded })}
|
||||
>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className='search__input'
|
||||
type='text'
|
||||
type='search'
|
||||
placeholder={intl.formatMessage(
|
||||
signedIn ? messages.placeholderSignedIn : messages.placeholder,
|
||||
)}
|
||||
|
||||
@@ -58,7 +58,7 @@ const mapStateToProps = state => ({
|
||||
&& !state.getIn(['settings', 'dismissed_banners', PRIVATE_QUOTE_MODAL_ID]),
|
||||
isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
|
||||
lang: state.getIn(['compose', 'language']),
|
||||
maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),
|
||||
maxChars: state.getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_characters'], 500),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch, props) => ({
|
||||
|
||||
@@ -9,7 +9,7 @@ const mapStateToProps = state => {
|
||||
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
|
||||
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
|
||||
const attachmentsSize = readyAttachmentsSize + pendingAttachmentsSize;
|
||||
const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments'])-1;
|
||||
const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments'])-1;
|
||||
const hasVideoOrAudio = state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')));
|
||||
const hasQuote = !!state.compose.get('quoted_status_id');
|
||||
|
||||
|
||||
@@ -87,12 +87,11 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
|
||||
if (multiColumn) {
|
||||
return (
|
||||
<div
|
||||
className='drawer'
|
||||
role='region'
|
||||
aria-label={intl.formatMessage(navbarMessages.publish)}
|
||||
>
|
||||
<nav className='drawer__header'>
|
||||
<div className='drawer'>
|
||||
<nav
|
||||
className='drawer__header'
|
||||
aria-label={intl.formatMessage(navbarMessages.advancedUiQuickLinks)}
|
||||
>
|
||||
<Link
|
||||
to='/getting-started'
|
||||
className='drawer__tab'
|
||||
@@ -163,7 +162,11 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
|
||||
<Search singleColumn={false} />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div
|
||||
className='drawer__pager'
|
||||
role='region'
|
||||
aria-label={intl.formatMessage(navbarMessages.publish)}
|
||||
>
|
||||
<div className='drawer__inner'>
|
||||
<ComposeFormContainer />
|
||||
|
||||
|
||||
75
app/javascript/mastodon/features/custom_homepage/about.tsx
Normal file
75
app/javascript/mastodon/features/custom_homepage/about.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { fetchExtendedDescription } from 'mastodon/actions/server';
|
||||
import { Account } from 'mastodon/components/account';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const Placeholder = () => (
|
||||
<div className={classes.placeholder}>
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width='100%' />
|
||||
<Skeleton width='100%' />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const About = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const server = useAppSelector((state) => state.server.server);
|
||||
const extendedDescription = useAppSelector(
|
||||
(state) => state.server.extendedDescription,
|
||||
);
|
||||
|
||||
const accountId = server.item?.contact.account?.id ?? '';
|
||||
const isLoading = extendedDescription.isLoading;
|
||||
const hasContent = (extendedDescription.item?.content.length ?? 0) > 0;
|
||||
const content = extendedDescription.item?.content ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchExtendedDescription());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.block}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='custom_homepage.administered_by'
|
||||
defaultMessage='Administered by'
|
||||
/>
|
||||
</h2>
|
||||
<Account id={accountId} size={36} minimal />
|
||||
</div>
|
||||
|
||||
<div className={classes.block}>
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id='custom_homepage.about_this_server'
|
||||
defaultMessage='About this server'
|
||||
/>
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<Placeholder />
|
||||
) : hasContent ? (
|
||||
<div
|
||||
className='prose'
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
) : (
|
||||
<div className='prose'>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='about.not_available'
|
||||
defaultMessage='This information has not been made available on this server.'
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const Footer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const server = useAppSelector((state) => state.server.server);
|
||||
const email = server.item?.contact.email ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchServer());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<footer className={classes.minimalFooter}>
|
||||
<div className={classes.contact}>
|
||||
<FormattedMessage
|
||||
id='custom_homepage.contact'
|
||||
defaultMessage='Contact:'
|
||||
/>
|
||||
<a href={`mailto:${email}`}>{email}</a>
|
||||
</div>
|
||||
|
||||
<Link to='/privacy-policy' rel='privacy-policy'>
|
||||
<FormattedMessage
|
||||
id='footer.privacy_policy'
|
||||
defaultMessage='Privacy policy'
|
||||
/>
|
||||
</Link>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { domain, sso_redirect } from 'mastodon/initial_state';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const Header = () => (
|
||||
<div className={classes.minimalHeader}>
|
||||
<div className={classes.leftSide}>
|
||||
<Link to='/overview'>{domain}</Link>
|
||||
</div>
|
||||
|
||||
<div className={classes.rightSide}>
|
||||
<a
|
||||
href={sso_redirect ?? '/auth/sign_in'}
|
||||
data-method={sso_redirect ? 'post' : undefined}
|
||||
className='button button-secondary'
|
||||
>
|
||||
<FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
71
app/javascript/mastodon/features/custom_homepage/index.tsx
Normal file
71
app/javascript/mastodon/features/custom_homepage/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||
import { TabLink, TabList } from 'mastodon/components/tab_list';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { About } from './about';
|
||||
import { LatestActivity } from './latest_activity';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const CustomHomepage: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const server = useAppSelector((state) => state.server.server);
|
||||
const { path } = useRouteMatch();
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchServer());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className={classes.page}>
|
||||
<ServerHeroImage
|
||||
alt={server.item?.thumbnail.description ?? ''}
|
||||
blurhash={server.item?.thumbnail.blurhash ?? ''}
|
||||
src={server.item?.thumbnail.url ?? ''}
|
||||
srcSet={Object.keys(server.item?.thumbnail.versions ?? {})
|
||||
.map(
|
||||
(key) =>
|
||||
`${server.item?.thumbnail.versions?.[key]} ${key.replace('@', '')}`,
|
||||
)
|
||||
.join(', ')}
|
||||
className={classes.header}
|
||||
/>
|
||||
|
||||
<div className={classes.topSection}>
|
||||
<h1>{server.item?.domain}</h1>
|
||||
<p>{server.item?.description}</p>
|
||||
</div>
|
||||
|
||||
<TabList>
|
||||
<TabLink to={path} exact>
|
||||
<FormattedMessage
|
||||
id='custom_homepage.latest_activity'
|
||||
defaultMessage='Latest activity'
|
||||
/>
|
||||
</TabLink>
|
||||
|
||||
<TabLink to={`${path}/about`} exact>
|
||||
<FormattedMessage id='custom_homepage.about' defaultMessage='About' />
|
||||
</TabLink>
|
||||
</TabList>
|
||||
|
||||
<Switch>
|
||||
<Route path={path} exact component={LatestActivity} />
|
||||
<Route path={`${path}/about`} exact component={About} />
|
||||
</Switch>
|
||||
|
||||
<Helmet>
|
||||
<title>{server.item?.domain}</title>
|
||||
<meta name='robots' content='all' />
|
||||
</Helmet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { expandCommunityTimeline } from 'mastodon/actions/timelines';
|
||||
import { Callout } from 'mastodon/components/callout';
|
||||
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const LatestActivity = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(expandCommunityTimeline());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<StatusListContainer
|
||||
prepend={
|
||||
<Callout className={classes.banner}>
|
||||
<FormattedMessage
|
||||
id='custom_homepage.these_are_the_latest_posts'
|
||||
defaultMessage='These are the latest 40 posts from accounts on this server.'
|
||||
/>
|
||||
</Callout>
|
||||
}
|
||||
scrollKey='custom_homepage'
|
||||
timelineId='community'
|
||||
maxItems={40}
|
||||
bindToDocument
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
.page {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
background: var(--color-bg-primary);
|
||||
min-height: 100%;
|
||||
|
||||
:global(.item-list) article:last-child :global(.status) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@media screen and (width <= 1175px) {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
aspect-ratio: 40/21;
|
||||
border-radius: 16px 16px 0 0;
|
||||
|
||||
@media screen and (width <= 1175px) {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
margin: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.topSection {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: var(--color-text-primary);
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 30px;
|
||||
letter-spacing: -0.12px;
|
||||
}
|
||||
|
||||
p {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
padding: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 22.4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:global(.account) {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
padding: 18px 16px;
|
||||
border-radius: 12px;
|
||||
|
||||
--avatar-border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
:global(.skeleton) {
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-bg-overlay-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
.minimalHeader {
|
||||
padding-top: 24px;
|
||||
padding-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media screen and (width <= 1175px) {
|
||||
padding-inline-start: 12px;
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.leftSide {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
max-width: 300px;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.rightSide {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.minimalFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 22.4px;
|
||||
padding-top: 28px;
|
||||
padding-bottom: 54px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media screen and (width <= 1175px) {
|
||||
justify-content: center;
|
||||
padding-inline-start: 12px;
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.contact {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -85,13 +85,16 @@ export async function search({
|
||||
// Only query the range for the last token to allow partial matches.
|
||||
const range =
|
||||
i === queryTokens.length - 1
|
||||
? IDBKeyRange.bound(token, token + '\uffff')
|
||||
? IDBKeyRange.lowerBound(token)
|
||||
: IDBKeyRange.only(token);
|
||||
|
||||
const [unicodeResults, customResults] = await Promise.all([
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
]);
|
||||
const [unicodeResults, customResults, shortcodeResults] = await Promise.all(
|
||||
[
|
||||
db.getAllFromIndex(locale, 'tokens', range),
|
||||
db.getAllFromIndex('custom', 'tokens', range),
|
||||
db.getAllFromIndex('shortcodes', 'shortcodes', range),
|
||||
],
|
||||
);
|
||||
const resultMap: ScoreMap = new Map();
|
||||
for (const emoji of unicodeResults) {
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
@@ -107,6 +110,22 @@ export async function search({
|
||||
}
|
||||
resultMap.set(emoji.shortcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
for (const shortcodeResult of shortcodeResults) {
|
||||
if (resultMap.has(shortcodeResult.hexcode)) {
|
||||
continue;
|
||||
}
|
||||
const emoji = await db.get(locale, shortcodeResult.hexcode);
|
||||
if (!emoji) {
|
||||
continue;
|
||||
}
|
||||
const score = getScoreForEmoji(emoji, token);
|
||||
if (score === null) {
|
||||
continue;
|
||||
}
|
||||
resultMap.set(emoji.hexcode, { ...emoji, score });
|
||||
}
|
||||
|
||||
log('found %d results for token "%s"', resultMap.size, token);
|
||||
resultArrays.push(resultMap);
|
||||
}
|
||||
@@ -147,7 +166,7 @@ function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
|
||||
}
|
||||
|
||||
let index = 1;
|
||||
for (const token of [id, emoji.tokens]) {
|
||||
for (const token of [id, ...emoji.tokens]) {
|
||||
const tokenIndex = token.indexOf(query);
|
||||
if (tokenIndex !== -1) {
|
||||
return index + tokenIndex / token.length;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { initialState } from '@/mastodon/initial_state';
|
||||
|
||||
import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
|
||||
import { toSupportedLocale } from './locale';
|
||||
import { reloadCustomEmojis } from './picker';
|
||||
import type { LocaleOrCustom } from './types';
|
||||
import { emojiLogger } from './utils';
|
||||
|
||||
@@ -90,6 +91,7 @@ export async function loadCustomEmoji() {
|
||||
const emojis = await importCustomEmojiData();
|
||||
if (emojis && emojis.length > 0) {
|
||||
log('loaded %d custom emojis', emojis.length);
|
||||
await reloadCustomEmojis();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE,
|
||||
EMOJI_MIN_TOKEN_LENGTH,
|
||||
} from './constants';
|
||||
import { localeToSegmenter } from './locale';
|
||||
import type {
|
||||
CustomEmojiData,
|
||||
CustomEmojiMapArg,
|
||||
@@ -92,10 +93,11 @@ export function transformEmojiData(
|
||||
export function transformCustomEmojiData(
|
||||
emoji: ApiCustomEmojiJSON,
|
||||
): CustomEmojiData {
|
||||
const tokens = emoji.shortcode
|
||||
.split('_')
|
||||
.filter((word) => word.length >= EMOJI_MIN_TOKEN_LENGTH)
|
||||
.map((word) => word.toLowerCase());
|
||||
const tokens = extractTokens(emoji.shortcode, localeToSegmenter('en'));
|
||||
if (!tokens.includes(emoji.shortcode)) {
|
||||
tokens.unshift(emoji.shortcode);
|
||||
}
|
||||
|
||||
return {
|
||||
...emoji,
|
||||
tokens,
|
||||
@@ -215,7 +217,9 @@ export function extractTokens(
|
||||
// Prefer to use Intl.Segmenter if available for better locale support.
|
||||
if (segmenter) {
|
||||
for (const { isWordLike, segment } of segmenter.segment(
|
||||
input.replaceAll('_', ' '), // Handle underscores from shortcodes.
|
||||
input
|
||||
.replaceAll(/[_-]+/g, ' ') // Handle underscores from shortcodes.
|
||||
.replaceAll(/([a-z])([A-Z])/g, '$1 $2'), // Handle camelCase.
|
||||
)) {
|
||||
if (isWordLike && segment.length >= EMOJI_MIN_TOKEN_LENGTH) {
|
||||
tokens.push(segment.toLowerCase());
|
||||
|
||||
@@ -82,13 +82,22 @@ type LegacyEmoji =
|
||||
custom: true;
|
||||
};
|
||||
|
||||
export async function reloadCustomEmojis() {
|
||||
customEmojis = null;
|
||||
|
||||
const { loadEmojisIntoCache } =
|
||||
await import('@/mastodon/hooks/useCustomEmojis');
|
||||
|
||||
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
|
||||
}
|
||||
|
||||
// Replicates the old legacy search function.
|
||||
export async function emojiMartSearch(
|
||||
token: string,
|
||||
locale: string,
|
||||
limit = 5,
|
||||
): Promise<LegacyEmoji[]> {
|
||||
const query = token.replace(':', '').toLowerCase().trim();
|
||||
const query = token.replace(':', '').trim();
|
||||
if (!query.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import ScrollableList from '@/mastodon/components/scrollable_list';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { BundleColumnError } from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||
|
||||
@@ -13,7 +13,7 @@ const GettingStarted: React.FC = () => {
|
||||
<Column>
|
||||
<NavigationPanel multiColumn />
|
||||
|
||||
<LinkFooter multiColumn />
|
||||
<LinkFooter context='multi-column' />
|
||||
|
||||
<Helmet>
|
||||
<title>
|
||||
|
||||
@@ -212,12 +212,12 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className='inline-follow-suggestions__header'>
|
||||
<h3 id={uniqueId}>
|
||||
<h2 id={uniqueId} className='inline-follow-suggestions__title'>
|
||||
<FormattedMessage
|
||||
id='follow_suggestions.who_to_follow'
|
||||
defaultMessage='Who to follow'
|
||||
/>
|
||||
</h3>
|
||||
</h2>
|
||||
|
||||
<div className='inline-follow-suggestions__header__actions'>
|
||||
<button className='link-button' onClick={handleDismiss} type='button'>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fetchList } from 'mastodon/actions/lists';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { apiFollowAccount } from 'mastodon/api/accounts';
|
||||
import {
|
||||
apiGetAccounts,
|
||||
apiGetListAccounts,
|
||||
apiAddAccountToList,
|
||||
apiRemoveAccountFromList,
|
||||
} from 'mastodon/api/lists';
|
||||
@@ -184,7 +184,7 @@ const ListMembers: React.FC<{
|
||||
if (id) {
|
||||
dispatch(fetchList(id));
|
||||
|
||||
void apiGetAccounts(id)
|
||||
void apiGetListAccounts(id)
|
||||
.then((data) => {
|
||||
dispatch(importFetchedAccounts(data));
|
||||
setAccountIds(data.map((a) => a.id));
|
||||
|
||||
@@ -12,7 +12,7 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { fetchList } from 'mastodon/actions/lists';
|
||||
import { createList, updateList } from 'mastodon/actions/lists_typed';
|
||||
import { apiGetAccounts } from 'mastodon/api/lists';
|
||||
import { apiGetListAccounts } from 'mastodon/api/lists';
|
||||
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { RepliesPolicyType } from 'mastodon/api_types/lists';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
@@ -44,7 +44,7 @@ const MembersLink: React.FC<{
|
||||
const [avatarAccounts, setAvatarAccounts] = useState<ApiAccountJSON[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void apiGetAccounts(id)
|
||||
void apiGetListAccounts(id)
|
||||
.then((data) => {
|
||||
setAvatarCount(data.length);
|
||||
setAvatarAccounts(data.slice(0, 3));
|
||||
|
||||
@@ -36,7 +36,7 @@ export const CollapsiblePanel: React.FC<{
|
||||
}, [setExpanded]);
|
||||
|
||||
return (
|
||||
<div className='navigation-panel__list-panel'>
|
||||
<li className='navigation-panel__list-panel'>
|
||||
<div className='navigation-panel__list-panel__header'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
@@ -64,7 +64,7 @@ export const CollapsiblePanel: React.FC<{
|
||||
}
|
||||
title={expanded ? collapseTitle : expandTitle}
|
||||
onClick={handleClick}
|
||||
aria-controls={`${accessibilityId}-content`}
|
||||
ariaControls={`${accessibilityId}-content`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -80,6 +80,6 @@ export const CollapsiblePanel: React.FC<{
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,21 +37,21 @@ export const Trends: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-panel__portal'>
|
||||
<aside className='navigation-panel__portal'>
|
||||
<div className='getting-started__trends'>
|
||||
<h4>
|
||||
<h2 className='getting-started__trends-heading'>
|
||||
<Link to={'/explore/tags'}>
|
||||
<FormattedMessage
|
||||
id='trends.trending_now'
|
||||
defaultMessage='Trending now'
|
||||
/>
|
||||
</Link>
|
||||
</h4>
|
||||
</h2>
|
||||
|
||||
{trends.take(4).map((hashtag) => (
|
||||
<Hashtag key={hashtag.get('name') as string} hashtag={hashtag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,6 @@ import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifica
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { AnnualReportNavItem } from '../annual_report/nav_item';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
|
||||
import { DisabledAccountBanner } from './components/disabled_account_banner';
|
||||
import { FollowedTagsPanel } from './components/followed_tags_panel';
|
||||
@@ -72,6 +71,12 @@ const messages = defineMessages({
|
||||
id: 'column.firehose_singular',
|
||||
defaultMessage: 'Live feed',
|
||||
},
|
||||
main: {
|
||||
id: 'navigation_bar.main',
|
||||
defaultMessage: 'Main',
|
||||
description:
|
||||
'Label for the main navigation; should not contain the word "navigation".',
|
||||
},
|
||||
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
|
||||
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
|
||||
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
@@ -232,7 +237,10 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='navigation-panel'>
|
||||
<nav
|
||||
className='navigation-panel'
|
||||
aria-label={intl.formatMessage(messages.main)}
|
||||
>
|
||||
<div className='navigation-panel__logo'>
|
||||
<Link
|
||||
to='/'
|
||||
@@ -249,92 +257,110 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
|
||||
{banner && <div className='navigation-panel__banner'>{banner}</div>}
|
||||
|
||||
<div className='navigation-panel__menu'>
|
||||
<ul className='navigation-panel__menu'>
|
||||
{signedIn && (
|
||||
<>
|
||||
{!multiColumn && (
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
<li>
|
||||
<ColumnLink
|
||||
to='/publish'
|
||||
icon='plus'
|
||||
iconComponent={AddIcon}
|
||||
activeIconComponent={AddIcon}
|
||||
text={intl.formatMessage(messages.compose)}
|
||||
className='button navigation-panel__compose-button'
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/home'
|
||||
icon='home'
|
||||
iconComponent={HomeIcon}
|
||||
activeIconComponent={HomeActiveIcon}
|
||||
text={intl.formatMessage(messages.home)}
|
||||
/>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
{trendsEnabled && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={TrendingUpIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/explore'
|
||||
icon='explore'
|
||||
iconComponent={TrendingUpIcon}
|
||||
text={intl.formatMessage(messages.explore)}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{(canViewFeed(signedIn, permissions, localLiveFeedAccess) ||
|
||||
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)) && (
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={
|
||||
canViewFeed(signedIn, permissions, localLiveFeedAccess)
|
||||
? '/public/local'
|
||||
: '/public/remote'
|
||||
}
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
text={intl.formatMessage(
|
||||
canViewFeed(signedIn, permissions, localLiveFeedAccess) &&
|
||||
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)
|
||||
? messages.firehose
|
||||
: messages.firehose_singular,
|
||||
)}
|
||||
/>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={
|
||||
canViewFeed(signedIn, permissions, localLiveFeedAccess)
|
||||
? '/public/local'
|
||||
: '/public/remote'
|
||||
}
|
||||
icon='globe'
|
||||
iconComponent={PublicIcon}
|
||||
isActive={isFirehoseActive}
|
||||
text={intl.formatMessage(
|
||||
canViewFeed(signedIn, permissions, localLiveFeedAccess) &&
|
||||
canViewFeed(signedIn, permissions, remoteLiveFeedAccess)
|
||||
? messages.firehose
|
||||
: messages.firehose_singular,
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{signedIn && (
|
||||
<>
|
||||
<NotificationsLink />
|
||||
<li>
|
||||
<NotificationsLink />
|
||||
</li>
|
||||
|
||||
<FollowRequestsLink />
|
||||
<li>
|
||||
<FollowRequestsLink />
|
||||
</li>
|
||||
|
||||
<AnnualReportNavItem />
|
||||
<li>
|
||||
<AnnualReportNavItem />
|
||||
</li>
|
||||
|
||||
<hr />
|
||||
<li role='separator' />
|
||||
|
||||
<ListPanel />
|
||||
|
||||
<FollowedTagsPanel />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
{areCollectionsEnabled() && (
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/favourites'
|
||||
icon='star'
|
||||
iconComponent={StarIcon}
|
||||
activeIconComponent={StarActiveIcon}
|
||||
text={intl.formatMessage(messages.favourites)}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/bookmarks'
|
||||
icon='bookmarks'
|
||||
iconComponent={BookmarksIcon}
|
||||
activeIconComponent={BookmarksActiveIcon}
|
||||
text={intl.formatMessage(messages.bookmarks)}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to={`/@${account?.acct}/collections`}
|
||||
@@ -343,30 +369,36 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
activeIconComponent={CollectionsActiveIcon}
|
||||
text={intl.formatMessage(messages.collections)}
|
||||
/>
|
||||
)}
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/conversations'
|
||||
icon='at'
|
||||
iconComponent={AlternateEmailIcon}
|
||||
text={intl.formatMessage(messages.direct)}
|
||||
/>
|
||||
</li>
|
||||
|
||||
<hr />
|
||||
<li role='separator' />
|
||||
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
<li>
|
||||
<ColumnLink
|
||||
transparent
|
||||
href='/settings/preferences'
|
||||
icon='cog'
|
||||
iconComponent={SettingsIcon}
|
||||
text={intl.formatMessage(messages.preferences)}
|
||||
/>
|
||||
</li>
|
||||
|
||||
<MoreLink />
|
||||
<li>
|
||||
<MoreLink />
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className='navigation-panel__legal'>
|
||||
<li className='navigation-panel__legal'>
|
||||
<ColumnLink
|
||||
transparent
|
||||
to='/about'
|
||||
@@ -374,21 +406,21 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
|
||||
iconComponent={InfoIcon}
|
||||
text={intl.formatMessage(messages.about)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{!signedIn && (
|
||||
<div className='navigation-panel__sign-in-banner'>
|
||||
<li className='navigation-panel__sign-in-banner'>
|
||||
<hr />
|
||||
|
||||
{disabledAccountId ? <DisabledAccountBanner /> : <SignInBanner />}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div className='flex-spacer' />
|
||||
|
||||
<Trends />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import ClearColumnButton from './clear_column_button';
|
||||
import GrantPermissionButton from './grant_permission_button';
|
||||
import { PolicyControls } from './policy_controls';
|
||||
import SettingToggle from './setting_toggle';
|
||||
import { ColumnSettingsGroup } from './column_settings_group';
|
||||
|
||||
class ColumnSettings extends PureComponent {
|
||||
static propTypes = {
|
||||
@@ -187,6 +188,11 @@ class ColumnSettings extends PureComponent {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ColumnSettingsGroup
|
||||
type="collections"
|
||||
label={<FormattedMessage id='notifications.column_settings.collections' defaultMessage='Collections:' />}
|
||||
/>
|
||||
|
||||
{((this.props.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) && (
|
||||
<section role='group' aria-labelledby='notifications-admin-sign-up'>
|
||||
<h3 id='notifications-status'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></h3>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { showAlert } from '@/mastodon/actions/alerts';
|
||||
import { requestBrowserPermission } from '@/mastodon/actions/notifications';
|
||||
import { changeAlerts } from '@/mastodon/actions/push_notifications';
|
||||
import { changeSetting } from '@/mastodon/actions/settings';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import SettingToggle from './setting_toggle';
|
||||
|
||||
const selectNotificationSettings = createAppSelector(
|
||||
[
|
||||
(state) =>
|
||||
(state.settings as Immutable.Map<string, unknown>).get(
|
||||
'notifications',
|
||||
) as Immutable.Map<string, Immutable.Map<string, unknown> | boolean>,
|
||||
(state) => state.notifications.get('browserPermission') as string,
|
||||
(state) => state.push_notifications,
|
||||
],
|
||||
(settings, browserPermission, pushSettings) => ({
|
||||
settings,
|
||||
browserPermission: browserPermission !== 'denied',
|
||||
pushSettings,
|
||||
showPushSettings:
|
||||
pushSettings.get('supported') && pushSettings.get('enabled'),
|
||||
}),
|
||||
);
|
||||
|
||||
const messages = defineMessages({
|
||||
permissionDenied: {
|
||||
id: 'notifications.permission_denied_alert',
|
||||
defaultMessage:
|
||||
"Desktop notifications can't be enabled, as browser permission has been denied before",
|
||||
},
|
||||
});
|
||||
|
||||
type SettingsType = 'alerts' | 'shows' | 'sounds';
|
||||
|
||||
export const ColumnSettingsGroup: FC<{ label: ReactNode; type: string }> = ({
|
||||
label,
|
||||
type,
|
||||
}) => {
|
||||
const { settings, browserPermission, pushSettings, showPushSettings } =
|
||||
useAppSelector(selectNotificationSettings);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleChange = useCallback(
|
||||
(path: [SettingsType, string], checked: boolean) => {
|
||||
if (
|
||||
path[0] === 'alerts' &&
|
||||
checked &&
|
||||
typeof window.Notification !== 'undefined' &&
|
||||
Notification.permission !== 'granted'
|
||||
) {
|
||||
dispatch(
|
||||
requestBrowserPermission((permission) => {
|
||||
if (permission === 'granted') {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
} else {
|
||||
dispatch(showAlert({ message: messages.permissionDenied }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(changeSetting(['notifications', ...path], checked));
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const handlePushChange = useCallback(
|
||||
(path: string[], checked: boolean) => {
|
||||
if (
|
||||
checked &&
|
||||
typeof window.Notification !== 'undefined' &&
|
||||
Notification.permission !== 'granted'
|
||||
) {
|
||||
dispatch(
|
||||
requestBrowserPermission((permission: NotificationPermission) => {
|
||||
if (permission === 'granted') {
|
||||
dispatch(changeAlerts(path, checked));
|
||||
} else {
|
||||
dispatch(showAlert({ message: messages.permissionDenied }));
|
||||
}
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(changeAlerts(path, checked));
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<section role='group' aria-labelledby={`notifications-${type}`}>
|
||||
<h3 id={`notifications-${type}`}>{label}</h3>
|
||||
|
||||
<div className='column-settings__row'>
|
||||
<SettingToggle
|
||||
disabled={!browserPermission}
|
||||
prefix='notifications_desktop'
|
||||
settings={settings}
|
||||
settingPath={['alerts', type]}
|
||||
onChange={handleChange}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.column_settings.alert'
|
||||
defaultMessage='Desktop notifications'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{showPushSettings && (
|
||||
<SettingToggle
|
||||
prefix='notifications_push'
|
||||
settings={pushSettings}
|
||||
settingPath={['alerts', type]}
|
||||
onChange={handlePushChange}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.column_settings.push'
|
||||
defaultMessage='Push notifications'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingToggle
|
||||
prefix='notifications'
|
||||
settings={settings}
|
||||
settingPath={['shows', type]}
|
||||
onChange={handleChange}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.column_settings.show'
|
||||
defaultMessage='Show in column'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingToggle
|
||||
prefix='notifications'
|
||||
settings={settings}
|
||||
settingPath={['sounds', type]}
|
||||
onChange={handleChange}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='notifications.column_settings.sound'
|
||||
defaultMessage='Play sound'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -106,10 +106,10 @@ export const NotificationAdminReport: React.FC<{
|
||||
|
||||
<div className='notification-group__main'>
|
||||
<div className='notification-group__main__header'>
|
||||
<div className='notification-group__main__header__label'>
|
||||
<h2 className='notification-group__main__header__label'>
|
||||
{message}
|
||||
<RelativeTimestamp timestamp={report.created_at} />
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{report.comment.length > 0 && (
|
||||
|
||||
@@ -46,7 +46,7 @@ export const NotificationCollection: React.FC<{
|
||||
|
||||
<div className='notification-group__main'>
|
||||
<div className='notification-group__main__header'>
|
||||
<div className='notification-group__main__header__label'>
|
||||
<h2 className='notification-group__main__header__label'>
|
||||
{type === 'added_to_collection' && (
|
||||
<FormattedMessage
|
||||
id='notification.added_to_collection'
|
||||
@@ -79,7 +79,7 @@ export const NotificationCollection: React.FC<{
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<CollectionPreviewCard collection={collection} />
|
||||
|
||||
@@ -125,7 +125,7 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='notification-group__main__header__label'>
|
||||
<h2 className='notification-group__main__header__label'>
|
||||
<span>{label}</span>
|
||||
{timestamp && (
|
||||
<>
|
||||
@@ -135,7 +135,7 @@ export const NotificationGroupWithStatus: React.FC<{
|
||||
<RelativeTimestamp timestamp={timestamp} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{statusId && (
|
||||
|
||||
@@ -101,12 +101,12 @@ export const NotificationWithStatus: React.FC<{
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className='notification-ungrouped__header'>
|
||||
<h2 className='notification-ungrouped__header'>
|
||||
<div className='notification-ungrouped__header__icon'>
|
||||
<Icon icon={icon} id={iconId} />
|
||||
</div>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<StatusQuoteManager
|
||||
id={statusId}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import CollectionsIcon from '@/material-icons/400-24px/category.svg?react';
|
||||
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
|
||||
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
|
||||
import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react';
|
||||
@@ -26,6 +27,10 @@ const tooltips = defineMessages({
|
||||
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
|
||||
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
|
||||
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
|
||||
collections: {
|
||||
id: 'notifications.filter.collections',
|
||||
defaultMessage: 'Collections',
|
||||
},
|
||||
statuses: {
|
||||
id: 'notifications.filter.statuses',
|
||||
defaultMessage: 'Updates from people you follow',
|
||||
@@ -124,6 +129,14 @@ export const FilterBar: React.FC = () => {
|
||||
>
|
||||
<Icon id='user-plus' icon={PersonAddIcon} />
|
||||
</BarButton>
|
||||
<BarButton
|
||||
selectedFilter={selectedFilter}
|
||||
type='collection'
|
||||
key='collection'
|
||||
title={intl.formatMessage(tooltips.collections)}
|
||||
>
|
||||
<Icon id='collections' icon={CollectionsIcon} />
|
||||
</BarButton>
|
||||
</div>
|
||||
);
|
||||
else
|
||||
|
||||
@@ -272,7 +272,12 @@ export const DetailedStatus: React.FC<{
|
||||
);
|
||||
|
||||
if (taggedCollection) {
|
||||
media = <CollectionPreviewCard collection={taggedCollection.toJS()} />;
|
||||
media = (
|
||||
<CollectionPreviewCard
|
||||
collection={taggedCollection.toJS()}
|
||||
headingLevel='h2'
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Card
|
||||
@@ -286,7 +291,10 @@ export const DetailedStatus: React.FC<{
|
||||
const firstLinkedCollection = status.get('tagged_collections').first();
|
||||
if (firstLinkedCollection) {
|
||||
media = (
|
||||
<CollectionPreviewCard collection={firstLinkedCollection.toJS()} />
|
||||
<CollectionPreviewCard
|
||||
collection={firstLinkedCollection.toJS()}
|
||||
headingLevel='h2'
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Helmet } from '@unhead/react/helmet';
|
||||
import { apiGetTermsOfService } from 'mastodon/api/instance';
|
||||
import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import { BundleColumnError } from 'mastodon/features/ui/components/bundle_column_error';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'terms_of_service.title', defaultMessage: 'Terms of Service' },
|
||||
|
||||
@@ -17,7 +17,6 @@ import { blockAccount } from 'mastodon/actions/accounts';
|
||||
import { closeModal } from 'mastodon/actions/modal';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { areCollectionsEnabled } from '../../collections/utils';
|
||||
|
||||
export const BlockModal = ({ accountId, acct }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -73,12 +72,10 @@ export const BlockModal = ({ accountId, acct }) => {
|
||||
<div><FormattedMessage id='block_modal.they_cant_mention' defaultMessage="You can't mention, follow, or quote each other." /></div>
|
||||
</li>
|
||||
|
||||
{areCollectionsEnabled() &&
|
||||
<li>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={CollectionsIcon} /></div>
|
||||
<div><FormattedMessage id='block_modal.no_collections' defaultMessage="Neither of you can add each other to collections. You'll be automatically removed from each others' existing collections, if applicable." /></div>
|
||||
</li>
|
||||
}
|
||||
<li>
|
||||
<div className='safety-action-modal__bullet-points__icon'><Icon icon={CollectionsIcon} /></div>
|
||||
<div><FormattedMessage id='block_modal.no_collections' defaultMessage="Neither of you can add each other to collections. You'll be automatically removed from each others' existing collections, if applicable." /></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import Column from 'mastodon/components/column';
|
||||
import { injectIntl } from '@/mastodon/components/intl';
|
||||
import { GIF } from 'mastodon/components/gif';
|
||||
|
||||
class CopyButton extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
copied: false,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { value } = this.props;
|
||||
navigator.clipboard.writeText(value);
|
||||
this.setState({ copied: true });
|
||||
this.timeout = setTimeout(() => this.setState({ copied: false }), 700);
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children } = this.props;
|
||||
const { copied } = this.state;
|
||||
|
||||
return (
|
||||
<Button onClick={this.handleClick} className={copied ? 'copied' : 'copyable'}>{copied ? <FormattedMessage id='copypaste.copied' defaultMessage='Copied' /> : children}</Button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BundleColumnError extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
errorType: PropTypes.oneOf(['routing', 'network', 'error']),
|
||||
onRetry: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
multiColumn: PropTypes.bool,
|
||||
stacktrace: PropTypes.string,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
errorType: 'routing',
|
||||
};
|
||||
|
||||
handleRetry = () => {
|
||||
const { onRetry } = this.props;
|
||||
|
||||
if (onRetry) {
|
||||
onRetry();
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { errorType, multiColumn, stacktrace } = this.props;
|
||||
|
||||
let title, body;
|
||||
|
||||
switch(errorType) {
|
||||
case 'routing':
|
||||
title = <FormattedMessage id='bundle_column_error.routing.title' defaultMessage='404' />;
|
||||
body = <FormattedMessage id='bundle_column_error.routing.body' defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?' />;
|
||||
break;
|
||||
case 'network':
|
||||
title = <FormattedMessage id='bundle_column_error.network.title' defaultMessage='Network error' />;
|
||||
body = <FormattedMessage id='bundle_column_error.network.body' defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.' />;
|
||||
break;
|
||||
case 'error':
|
||||
title = <FormattedMessage id='bundle_column_error.error.title' defaultMessage='Oh, no!' />;
|
||||
body = <FormattedMessage id='bundle_column_error.error.body' defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.' />;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<div className='error-column'>
|
||||
<GIF src='/oops.gif' staticSrc='/oops.png' className='error-column__image' />
|
||||
|
||||
<div className='error-column__message'>
|
||||
<h1>{title}</h1>
|
||||
<p>{body}</p>
|
||||
|
||||
<div className='error-column__message__actions'>
|
||||
{errorType === 'network' && <Button onClick={this.handleRetry}><FormattedMessage id='bundle_column_error.retry' defaultMessage='Try again' /></Button>}
|
||||
{errorType === 'error' && <CopyButton value={stacktrace}><FormattedMessage id='bundle_column_error.copy_stacktrace' defaultMessage='Copy error report' /></CopyButton>}
|
||||
<Link to='/' className={classNames('button', { 'button-secondary': errorType !== 'routing' })}><FormattedMessage id='bundle_column_error.return' defaultMessage='Go back home' /></Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(BundleColumnError);
|
||||
@@ -0,0 +1,122 @@
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { CopyButton } from '@/mastodon/components/copy_button';
|
||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
interface BundleColumnErrorProps {
|
||||
errorType?: 'routing' | 'network' | 'error';
|
||||
onRetry?: () => void;
|
||||
multiColumn?: boolean;
|
||||
stacktrace?: string;
|
||||
}
|
||||
|
||||
export const BundleColumnError: React.FC<BundleColumnErrorProps> = ({
|
||||
errorType = 'routing',
|
||||
onRetry,
|
||||
multiColumn,
|
||||
stacktrace,
|
||||
}) => {
|
||||
let title, body;
|
||||
|
||||
switch (errorType) {
|
||||
case 'routing':
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.routing.title'
|
||||
defaultMessage='404'
|
||||
/>
|
||||
);
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.routing.body'
|
||||
defaultMessage='The requested page could not be found. Are you sure the URL in the address bar is correct?'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'network':
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.network.title'
|
||||
defaultMessage='Network error'
|
||||
/>
|
||||
);
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.network.body'
|
||||
defaultMessage='There was an error when trying to load this page. This could be due to a temporary problem with your internet connection or this server.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'error':
|
||||
title = (
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.error.title'
|
||||
defaultMessage='Oh, no!'
|
||||
/>
|
||||
);
|
||||
body = (
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.error.body'
|
||||
defaultMessage='The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.'
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<EmptyState
|
||||
image='error'
|
||||
title={title}
|
||||
message={body}
|
||||
className={classes.error}
|
||||
>
|
||||
<div className={classes.actions}>
|
||||
{errorType === 'network' && onRetry && (
|
||||
<Button onClick={onRetry}>
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.retry'
|
||||
defaultMessage='Try again'
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{errorType === 'error' && stacktrace && (
|
||||
<CopyButton value={stacktrace}>
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.copy_stacktrace'
|
||||
defaultMessage='Copy error report'
|
||||
/>
|
||||
</CopyButton>
|
||||
)}
|
||||
<Link
|
||||
to='/'
|
||||
className={classNames('button', {
|
||||
'button-secondary': errorType !== 'routing',
|
||||
})}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='bundle_column_error.return'
|
||||
defaultMessage='Go back home'
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</EmptyState>
|
||||
|
||||
<Helmet>
|
||||
<meta name='robots' content='noindex' />
|
||||
</Helmet>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default BundleColumnError;
|
||||
@@ -0,0 +1,12 @@
|
||||
.error {
|
||||
flex-grow: 1;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import classNames from 'classnames';
|
||||
import type { List, Record } from 'immutable';
|
||||
|
||||
import { useAppSelector } from '@/mastodon/store';
|
||||
import { Footer } from 'mastodon/features/custom_homepage/components/footer';
|
||||
import { Header } from 'mastodon/features/custom_homepage/components/header';
|
||||
import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel';
|
||||
|
||||
import { useBreakpoint } from '../hooks/useBreakpoint';
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
import { useColumnsContext } from '../util/columns_context';
|
||||
|
||||
import Bundle from './bundle';
|
||||
import BundleColumnError from './bundle_column_error';
|
||||
import { BundleColumnError } from './bundle_column_error';
|
||||
import { ColumnLoading } from './column_loading';
|
||||
import { ComposePanel, RedirectToMobileComposeIfNeeded } from './compose_panel';
|
||||
import DrawerLoading from './drawer_loading';
|
||||
@@ -85,9 +87,10 @@ export const ColumnsArea = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
singleColumn?: boolean;
|
||||
minimalShell?: boolean;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
}
|
||||
>(({ children, singleColumn }, ref) => {
|
||||
>(({ children, minimalShell, singleColumn }, ref) => {
|
||||
const renderComposePanel = !useBreakpoint('full');
|
||||
const columns = useAppSelector((state) =>
|
||||
(state.settings as Record<{ columns: List<Record<Column>> }>).get(
|
||||
@@ -98,6 +101,24 @@ export const ColumnsArea = forwardRef<
|
||||
(state) => !state.modal.get('stack').isEmpty(),
|
||||
);
|
||||
|
||||
if (minimalShell) {
|
||||
return (
|
||||
<div className='columns-area__panels'>
|
||||
<div className='columns-area__panels__main'>
|
||||
<Header />
|
||||
|
||||
<div className='tabs-bar__wrapper'>
|
||||
<TabsBarPortal />
|
||||
</div>
|
||||
|
||||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (singleColumn) {
|
||||
return (
|
||||
<div className='columns-area__panels'>
|
||||
@@ -108,12 +129,13 @@ export const ColumnsArea = forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='columns-area__panels__main'>
|
||||
<main className='columns-area__panels__main'>
|
||||
<div className='tabs-bar__wrapper'>
|
||||
<TabsBarPortal />
|
||||
</div>
|
||||
|
||||
<div className='columns-area columns-area--mobile'>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CollapsibleNavigationPanel />
|
||||
</div>
|
||||
@@ -121,7 +143,7 @@ export const ColumnsArea = forwardRef<
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
<main
|
||||
className={classNames('columns-area', { unscrollable: isModalOpen })}
|
||||
ref={ref}
|
||||
tabIndex={isModalOpen ? undefined : 0}
|
||||
@@ -160,7 +182,7 @@ export const ColumnsArea = forwardRef<
|
||||
cloneElement(child, { multiColumn: true }),
|
||||
)}
|
||||
</ColumnIndexContext.Provider>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ComposePanel: React.FC = () => {
|
||||
{signedIn && !hideComposer && <ComposeFormContainer singleColumn />}
|
||||
{signedIn && hideComposer && <div className='compose-form' />}
|
||||
|
||||
<LinkFooter multiColumn={!singleColumn} />
|
||||
<LinkFooter context={singleColumn ? 'default' : 'multi-column'} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
.wrapper {
|
||||
z-index: 1;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
&[data-context='default'] {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
&[data-context='multi-column'] {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
&[data-context='about'] {
|
||||
margin-top: 60px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
line-height: 22px;
|
||||
|
||||
@media screen and (width >= 1175px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: inline;
|
||||
margin-inline-end: 0.3em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: inline;
|
||||
|
||||
li {
|
||||
display: inline;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
content: ' · ';
|
||||
|
||||
@supports (content: 'x' / 'y') {
|
||||
content: ' · ' / '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
white-space: nowrap;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user