Merge branch 'main' into improve-devcontainer-config

This commit is contained in:
Liberal Dev
2026-05-31 19:26:18 +09:00
committed by GitHub
272 changed files with 3297 additions and 1874 deletions

View File

@@ -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}}'

View File

@@ -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:

2
.nvmrc
View File

@@ -1 +1 @@
24.15
24.16

View File

@@ -24,3 +24,6 @@ Rails/RakeEnvironment:
Rails/SkipsModelValidations:
Enabled: false
Rails/StrongParametersExpect:
Enabled: false

View File

@@ -33,6 +33,9 @@ Style/NumericLiterals:
AllowedPatterns:
- \d{4}_\d{2}_\d{2}_\d{6}
Style/OneClassPerFile:
Enabled: false
Style/PercentLiteralDelimiters:
PreferredDelimiters:
'%i': ()

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -16,6 +16,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController
FILTER_PARAMS = %i(
resolved
unresolved
account_id
target_account_id
).freeze

View File

@@ -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

View File

@@ -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?

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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;
},
);

View File

@@ -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);

View File

@@ -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.');
}
}

View File

@@ -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) {

View File

@@ -86,6 +86,9 @@ export function setupBrowserNotifications() {
};
}
/**
* @param {(NotificationPermission) => void} callback
*/
export function requestBrowserPermission(callback = noOp) {
return dispatch => {
requestNotificationPermission((permission) => {

View File

@@ -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,
},
);

View File

@@ -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,

View File

@@ -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`);

View File

@@ -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,
});

View File

@@ -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[];
}

View File

@@ -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';

View File

@@ -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>

View File

@@ -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 {

View 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}
/>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -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;

View File

@@ -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,

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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';

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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' />
);
}
}

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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],
);

View File

@@ -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>

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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 dont 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 dont 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>

View File

@@ -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';

View File

@@ -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'
/>
}
/>
}
/>
);
};

View File

@@ -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,

View File

@@ -126,4 +126,7 @@
.pinnedBadge {
justify-self: end;
// Allow "click to open post" event to pass through
pointer-events: none;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -92,7 +92,7 @@ export const CollectionShareModal: React.FC<{
/>
<div className={classes.preview}>
<CollectionPreviewCard collection={collection} />
<CollectionPreviewCard collection={collection} headingLevel='h2' />
</div>
<CopyLinkField

View File

@@ -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',

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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');

View File

@@ -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 />

View File

@@ -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));

View File

@@ -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,
)}

View File

@@ -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) => ({

View File

@@ -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');

View File

@@ -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 />

View 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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);

View 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>
);
};

View File

@@ -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
/>
);
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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();
}
}
}

View File

@@ -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());

View File

@@ -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 [];
}

View File

@@ -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';

View File

@@ -13,7 +13,7 @@ const GettingStarted: React.FC = () => {
<Column>
<NavigationPanel multiColumn />
<LinkFooter multiColumn />
<LinkFooter context='multi-column' />
<Helmet>
<title>

View File

@@ -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'>

View File

@@ -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));

View File

@@ -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));

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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 && (

View File

@@ -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} />

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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

View File

@@ -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'
/>
);
}
}

View File

@@ -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' },

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -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