Merge pull request #3057 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 8b34daf2541c6afdb5ade19e4531373ef7b05bc0
This commit is contained in:
Claire 2025-05-04 11:25:28 +02:00 committed by GitHub
commit e05e81c5b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
456 changed files with 5024 additions and 3757 deletions

View File

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

View File

@ -212,7 +212,7 @@ group :development, :test do
gem 'test-prof', require: false
# RSpec runner for rails
gem 'rspec-rails', '~> 7.0'
gem 'rspec-rails', '~> 8.0'
end
group :production do

View File

@ -160,7 +160,7 @@ GEM
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.2)
connection_pool (2.5.3)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
@ -435,7 +435,7 @@ GEM
mutex_m (0.3.0)
net-http (0.6.0)
uri
net-imap (0.5.6)
net-imap (0.5.8)
date
net-protocol
net-ldap (0.19.0)
@ -711,7 +711,7 @@ GEM
rotp (6.3.0)
rouge (4.5.1)
rpam2 (4.0.2)
rqrcode (3.0.0)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
@ -721,18 +721,18 @@ GEM
rspec-mocks (~> 3.13.0)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
rspec-expectations (3.13.4)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-github (3.0.0)
rspec-core (~> 3.0)
rspec-mocks (3.13.2)
rspec-mocks (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-rails (8.0.0)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
@ -742,8 +742,8 @@ GEM
rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.2)
rubocop (1.75.3)
rspec-support (3.13.3)
rubocop (1.75.4)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -807,7 +807,7 @@ GEM
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.1.0)
shoulda-matchers (6.4.0)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3)
@ -842,7 +842,7 @@ GEM
base64
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.6)
stringio (3.1.7)
strong_migrations (2.3.0)
activerecord (>= 7)
swd (2.0.3)
@ -1045,7 +1045,7 @@ DEPENDENCIES
redis-namespace (~> 1.10)
rqrcode (~> 3.0)
rspec-github (~> 3.0)
rspec-rails (~> 7.0)
rspec-rails (~> 8.0)
rspec-sidekiq (~> 5.0)
rubocop
rubocop-capybara

View File

@ -19,9 +19,16 @@ class AccountsIndex < Chewy::Index
type: 'stemmer',
language: 'possessive_english',
},
word_joiner: {
type: 'shingle',
output_unigrams: true,
token_separator: '',
},
},
analyzer: {
# "The FOOING's bar" becomes "foo bar"
natural: {
tokenizer: 'standard',
filter: %w(
@ -35,11 +42,20 @@ class AccountsIndex < Chewy::Index
),
},
# "FOO bar" becomes "foo bar"
verbatim: {
tokenizer: 'standard',
filter: %w(lowercase asciifolding cjk_width),
},
# "Foo bar" becomes "foo bar foobar"
word_join_analyzer: {
type: 'custom',
tokenizer: 'standard',
filter: %w(lowercase asciifolding cjk_width word_joiner),
},
# "Foo bar" becomes "f fo foo b ba bar"
edge_ngram: {
tokenizer: 'edge_ngram',
filter: %w(lowercase asciifolding cjk_width),

View File

@ -72,6 +72,13 @@ class Api::BaseController < ApplicationController
end
end
# Redefine `require_functional!` to properly output JSON instead of HTML redirects
def require_functional!
return if current_user.functional?
require_user!
end
def render_empty
render json: {}, status: 200
end

View File

@ -18,7 +18,7 @@ class Api::V1::FeaturedTagsController < Api::BaseController
end
def destroy
RemoveFeaturedTagWorker.perform_async(current_account.id, @featured_tag.id)
RemoveFeaturedTagService.new.call(current_account, @featured_tag)
render_empty
end

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true
class Api::V1::TagsController < Api::BaseController
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, except: :show
before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, only: [:follow, :unfollow]
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:feature, :unfeature]
before_action :require_user!, except: :show
before_action :set_or_create_tag
@ -23,6 +24,16 @@ class Api::V1::TagsController < Api::BaseController
render json: @tag, serializer: REST::TagSerializer
end
def feature
CreateFeaturedTagService.new.call(current_account, @tag)
render json: @tag, serializer: REST::TagSerializer
end
def unfeature
RemoveFeaturedTagService.new.call(current_account, @tag)
render json: @tag, serializer: REST::TagSerializer
end
private
def set_or_create_tag

View File

@ -75,10 +75,24 @@ class ApplicationController < ActionController::Base
def require_functional!
return if current_user.functional?
if current_user.confirmed?
redirect_to edit_user_registration_path
else
redirect_to auth_setup_path
respond_to do |format|
format.any do
if current_user.confirmed?
redirect_to edit_user_registration_path
else
redirect_to auth_setup_path
end
end
format.json do
if !current_user.confirmed?
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403
elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403
end
end
end
end

View File

@ -12,7 +12,7 @@ class Settings::FeaturedTagsController < Settings::BaseController
end
def create
@featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name], force: false)
@featured_tag = CreateFeaturedTagService.new.call(current_account, featured_tag_params[:name], raise_error: false)
if @featured_tag.valid?
redirect_to settings_featured_tags_path

View File

@ -26,6 +26,14 @@ module ContextHelper
voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
quote_requests: { 'QuoteRequest' => 'https://w3id.org/fep/044f#QuoteRequest' },
interaction_policies: {
'gts' => 'https://gotosocial.org/ns#',
'interactionPolicy' => { '@id' => 'gts:interactionPolicy', '@type' => '@id' },
'canQuote' => { '@id' => 'gts:canQuote', '@type' => '@id' },
'automaticApproval' => { '@id' => 'gts:automaticApproval', '@type' => '@id' },
'manualApproval' => { '@id' => 'gts:manualApproval', '@type' => '@id' },
},
}.freeze
def full_context

View File

@ -1,18 +1,18 @@
import { createAction } from '@reduxjs/toolkit';
import { apiRemoveAccountFromFollowers } from 'flavours/glitch/api/accounts';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import {
apiRemoveAccountFromFollowers,
apiGetEndorsedAccounts,
} from 'flavours/glitch/api/accounts';
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
import { importFetchedAccounts } from './importer';
export const revealAccount = createAction<{
id: string;
}>('accounts/revealAccount');
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
'accounts/importAccounts',
);
function actionWithSkipLoadingTrue<Args extends object>(args: Args) {
return {
payload: {
@ -104,3 +104,12 @@ export const removeAccountFromFollowers = createDataLoadingThunk(
apiRemoveAccountFromFollowers(accountId),
(relationship) => ({ relationship }),
);
export const fetchEndorsedAccounts = createDataLoadingThunk(
'accounts/endorsements',
({ accountId }: { accountId: string }) => apiGetEndorsedAccounts(accountId),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data));
return data;
},
);

View File

@ -1,34 +0,0 @@
import api from '../api';
export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
export const FEATURED_TAGS_FETCH_FAIL = 'FEATURED_TAGS_FETCH_FAIL';
export const fetchFeaturedTags = (id) => (dispatch, getState) => {
if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
return;
}
dispatch(fetchFeaturedTagsRequest(id));
api().get(`/api/v1/accounts/${id}/featured_tags`)
.then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
.catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
};
export const fetchFeaturedTagsRequest = (id) => ({
type: FEATURED_TAGS_FETCH_REQUEST,
id,
});
export const fetchFeaturedTagsSuccess = (id, tags) => ({
type: FEATURED_TAGS_FETCH_SUCCESS,
id,
tags,
});
export const fetchFeaturedTagsFail = (id, error) => ({
type: FEATURED_TAGS_FETCH_FAIL,
id,
error,
});

View File

@ -0,0 +1,7 @@
import { apiGetFeaturedTags } from 'flavours/glitch/api/accounts';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
export const fetchFeaturedTags = createDataLoadingThunk(
'accounts/featured_tags',
({ accountId }: { accountId: string }) => apiGetFeaturedTags(accountId),
);

View File

@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
'accounts/importAccounts',
);

View File

@ -1,7 +1,6 @@
import { createPollFromServerJSON } from 'flavours/glitch/models/poll';
import { importAccounts } from '../accounts_typed';
import { importAccounts } from './accounts';
import { normalizeStatus } from './normalizer';
import { importPolls } from './polls';

View File

@ -4,8 +4,11 @@ import api from '../api';
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines';
export * from './statuses_typed';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
@ -14,10 +17,6 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
@ -54,7 +53,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
if (alsoFetchContext) {
dispatch(fetchContext(id));
dispatch(fetchContext({ statusId: id }));
}
if (skipLoading) {
@ -179,50 +178,6 @@ export function deleteStatusFail(id, error) {
export const updateStatus = status => dispatch =>
dispatch(importFetchedStatus(status));
export function fetchContext(id) {
return (dispatch) => {
dispatch(fetchContextRequest(id));
api().get(`/api/v1/statuses/${id}/context`).then(response => {
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
}).catch(error => {
if (error.response && error.response.status === 404) {
dispatch(deleteFromTimelines(id));
}
dispatch(fetchContextFail(id, error));
});
};
}
export function fetchContextRequest(id) {
return {
type: CONTEXT_FETCH_REQUEST,
id,
};
}
export function fetchContextSuccess(id, ancestors, descendants) {
return {
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors,
descendants,
statuses: ancestors.concat(descendants),
};
}
export function fetchContextFail(id, error) {
return {
type: CONTEXT_FETCH_FAIL,
id,
error,
skipAlert: true,
};
}
export function muteStatus(id) {
return (dispatch) => {
dispatch(muteStatusRequest(id));

View File

@ -0,0 +1,18 @@
import { apiGetContext } from 'flavours/glitch/api/statuses';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk(
'status/context',
({ statusId }: { statusId: string }) => apiGetContext(statusId),
(context, { dispatch }) => {
const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses));
return {
context,
};
},
);

View File

@ -2,6 +2,8 @@ import {
apiGetTag,
apiFollowTag,
apiUnfollowTag,
apiFeatureTag,
apiUnfeatureTag,
} from 'flavours/glitch/api/tags';
import { createDataLoadingThunk } from 'flavours/glitch/store/typed_functions';
@ -19,3 +21,13 @@ export const unfollowHashtag = createDataLoadingThunk(
'tags/unfollow',
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
);
export const featureHashtag = createDataLoadingThunk(
'tags/feature',
({ tagId }: { tagId: string }) => apiFeatureTag(tagId),
);
export const unfeatureHashtag = createDataLoadingThunk(
'tags/unfeature',
({ tagId }: { tagId: string }) => apiUnfeatureTag(tagId),
);

View File

@ -1,5 +1,7 @@
import { apiRequestPost } from 'flavours/glitch/api';
import { apiRequestPost, apiRequestGet } from 'flavours/glitch/api';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
@ -23,3 +25,9 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
apiRequestPost<ApiRelationshipJSON>(
`v1/accounts/${id}/remove_from_followers`,
);
export const apiGetFeaturedTags = (id: string) =>
apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
export const apiGetEndorsedAccounts = (id: string) =>
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);

View File

@ -0,0 +1,5 @@
import { apiRequestGet } from 'flavours/glitch/api';
import type { ApiContextJSON } from 'flavours/glitch/api_types/statuses';
export const apiGetContext = (statusId: string) =>
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);

View File

@ -14,6 +14,12 @@ export const apiFollowTag = (tagId: string) =>
export const apiUnfollowTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
export const apiFeatureTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/feature`);
export const apiUnfeatureTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfeature`);
export const apiGetFollowedTags = async (url?: string) => {
const response = await api().request<ApiHashtagJSON[]>({
method: 'GET',

View File

@ -123,3 +123,8 @@ export interface ApiStatusJSON {
local_only?: boolean;
content_type?: string;
}
export interface ApiContextJSON {
ancestors: ApiStatusJSON[];
descendants: ApiStatusJSON[];
}

View File

@ -10,4 +10,5 @@ export interface ApiHashtagJSON {
url: string;
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
following?: boolean;
featuring?: boolean;
}

View File

@ -26,11 +26,12 @@ import {
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import { CircularProgress } from 'flavours/glitch/components/circular_progress';
import { isUserTouching } from 'flavours/glitch/is_mobile';
import type {
MenuItem,
ActionMenuItem,
ExternalLinkMenuItem,
import {
isMenuItem,
isActionItem,
isExternalLinkItem,
} from 'flavours/glitch/models/dropdown_menu';
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import type { IconProp } from './icon';
@ -38,30 +39,6 @@ import { IconButton } from './icon_button';
let id = 0;
const isMenuItem = (item: unknown): item is MenuItem => {
if (item === null) {
return true;
}
return typeof item === 'object' && 'text' in item;
};
const isActionItem = (item: unknown): item is ActionMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'action' in item;
};
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'href' in item;
};
type RenderItemFn<Item = MenuItem> = (
item: Item,
index: number,
@ -320,6 +297,7 @@ interface DropdownProps<Item = MenuItem> {
scrollable?: boolean;
scrollKey?: string;
status?: ImmutableMap<string, unknown>;
forceDropdown?: boolean;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void;
@ -339,6 +317,7 @@ export const Dropdown = <Item = MenuItem,>({
disabled,
scrollable,
status,
forceDropdown = false,
renderItem,
renderHeader,
onOpen,
@ -354,6 +333,9 @@ export const Dropdown = <Item = MenuItem,>({
const open = currentId === openDropdownId;
const activeElement = useRef<HTMLElement | null>(null);
const targetRef = useRef<HTMLButtonElement | null>(null);
const prefetchAccountId = status
? status.getIn(['account', 'id'])
: undefined;
const handleClose = useCallback(() => {
if (activeElement.current) {
@ -402,16 +384,15 @@ export const Dropdown = <Item = MenuItem,>({
} else {
onOpen?.();
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
if (prefetchAccountId) {
dispatch(fetchRelationships([prefetchAccountId]));
}
if (isUserTouching()) {
if (isUserTouching() && !forceDropdown) {
dispatch(
openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: handleItemClick,
},
@ -431,12 +412,13 @@ export const Dropdown = <Item = MenuItem,>({
[
dispatch,
currentId,
prefetchAccountId,
scrollKey,
onOpen,
handleItemClick,
open,
status,
items,
forceDropdown,
handleClose,
],
);

View File

@ -116,6 +116,7 @@ export const EditedTimestamp: React.FC<{
renderHeader={renderHeader}
onOpen={handleOpen}
onItemClick={handleItemClick}
forceDropdown
>
<button className='dropdown-menu__text-button'>
<FormattedMessage

View File

@ -1,24 +1,32 @@
import { FormattedMessage } from 'react-intl';
import { LoadingIndicator } from './loading_indicator';
interface Props {
onClick: (event: React.MouseEvent) => void;
disabled?: boolean;
visible?: boolean;
loading?: boolean;
}
export const LoadMore: React.FC<Props> = ({
onClick,
disabled,
visible = true,
loading = false,
}) => {
return (
<button
type='button'
className='load-more'
disabled={disabled || !visible}
disabled={disabled || loading || !visible}
style={{ visibility: visible ? 'visible' : 'hidden' }}
onClick={onClick}
>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
{loading ? (
<LoadingIndicator />
) : (
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
)}
</button>
);
};

View File

@ -45,10 +45,6 @@ export default class StatusPrepend extends PureComponent {
</Permalink>
);
switch (type) {
case 'featured':
return (
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
);
case 'reblogged_by':
return (
<FormattedMessage

View File

@ -46,10 +46,7 @@ const makeMapStateToProps = () => {
let account = undefined;
let prepend = undefined;
if (props.featured && status) {
account = status.get('account');
prepend = 'featured';
} else if (reblogStatus !== null && typeof reblogStatus === 'object') {
if (reblogStatus !== null && typeof reblogStatus === 'object') {
account = status.get('account');
status = reblogStatus;
prepend = 'reblogged_by';

View File

@ -7,19 +7,21 @@ import { useParams } from 'react-router';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts';
import { fetchFeaturedTags } from 'flavours/glitch/actions/featured_tags';
import { expandAccountFeaturedTimeline } from 'flavours/glitch/actions/timelines';
import { Account } from 'flavours/glitch/components/account';
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import { RemoteHint } from 'flavours/glitch/components/remote_hint';
import StatusContainer from 'flavours/glitch/containers/status_container';
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
import Column from 'flavours/glitch/features/ui/components/column';
import { useAccountId } from 'flavours/glitch/hooks/useAccountId';
import { useAccountVisibility } from 'flavours/glitch/hooks/useAccountVisibility';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import Column from '../ui/components/column';
import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag';
@ -29,7 +31,9 @@ interface Params {
id?: string;
}
const AccountFeatured = () => {
const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
multiColumn,
}) => {
const accountId = useAccountId();
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
const forceEmptyState = suspended || blockedBy || hidden;
@ -40,7 +44,8 @@ const AccountFeatured = () => {
useEffect(() => {
if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId));
dispatch(fetchFeaturedTags(accountId));
void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId }));
}
}, [accountId, dispatch]);
@ -67,6 +72,17 @@ const AccountFeatured = () => {
ImmutableList(),
) as ImmutableList<string>,
);
const featuredAccountIds = useAppSelector(
(state) =>
state.user_lists.getIn(
['featured_accounts', accountId, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (isLoading) {
return (
@ -78,7 +94,11 @@ const AccountFeatured = () => {
);
}
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
if (
featuredStatusIds.isEmpty() &&
featuredTags.isEmpty() &&
featuredAccountIds.isEmpty()
) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
@ -131,6 +151,19 @@ const AccountFeatured = () => {
))}
</>
)}
{!featuredAccountIds.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.accounts'
defaultMessage='Profiles'
/>
</h4>
{featuredAccountIds.map((featuredAccountId) => (
<Account key={featuredAccountId} id={featuredAccountId} />
))}
</>
)}
<RemoteHint accountId={accountId} />
</div>
</Column>

View File

@ -152,7 +152,7 @@ export const AccountGallery: React.FC<{
[dispatch],
);
if (accountId && !isAccount) {
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}

View File

@ -111,7 +111,6 @@ const messages = defineMessages({
id: 'account.disable_notifications',
defaultMessage: 'Stop notifying me when @{name} posts',
},
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
@ -455,7 +454,6 @@ export const AccountHeader: React.FC<{
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
});
arr.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
arr.push(null);
arr.push({
text: intl.formatMessage(messages.follow_requests),

View File

@ -13,7 +13,6 @@ import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list';
@ -26,7 +25,7 @@ import { LimitedAccountHint } from './components/limited_account_hint';
const emptyList = ImmutableList();
const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (accountId === null) {
return {
@ -83,7 +82,6 @@ class AccountTimeline extends ImmutablePureComponent {
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
}
dispatch(fetchFeaturedTags(accountId));
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
}

View File

@ -9,7 +9,6 @@ import { useAppDispatch } from 'flavours/glitch/store';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
@ -53,7 +52,6 @@ export const ActionBar: React.FC = () => {
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
},
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
null,
{
text: intl.formatMessage(messages.follow_requests),

View File

@ -41,7 +41,7 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15_1.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>

View File

@ -30,9 +30,6 @@ const messages = defineMessages({
class PrivacyDropdown extends PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,

View File

@ -15,16 +15,6 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal({
modalType: 'ACTIONS',
modalProps: props,
})),
onModalClose: () => dispatch(closeModal({
modalType: undefined,
ignoreFocus: false,
})),
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View File

@ -133,6 +133,7 @@ export const Directory: React.FC<{
}, [dispatch, order, local]);
const pinned = !!columnId;
const initialLoad = isLoading && accountIds.size === 0;
const scrollableArea = (
<div className='scrollable'>
@ -173,7 +174,7 @@ export const Directory: React.FC<{
</div>
<div className='directory__list'>
{isLoading ? (
{initialLoad ? (
<LoadingIndicator />
) : (
accountIds.map((accountId) => (
@ -182,7 +183,11 @@ export const Directory: React.FC<{
)}
</div>
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
<LoadMore
onClick={handleLoadMore}
visible={!initialLoad}
loading={isLoading}
/>
</div>
);

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -29,7 +29,7 @@ import { LimitedAccountHint } from '../account_timeline/components/limited_accou
import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (!accountId) {
return {

View File

@ -29,7 +29,7 @@ import { LimitedAccountHint } from '../account_timeline/components/limited_accou
import Column from '../ui/components/column';
const mapStateToProps = (state, { params: { acct, id } }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (!accountId) {
return {

View File

@ -9,6 +9,8 @@ import {
fetchHashtag,
followHashtag,
unfollowHashtag,
featureHashtag,
unfeatureHashtag,
} from 'flavours/glitch/actions/tags_typed';
import type { ApiHashtagJSON } from 'flavours/glitch/api_types/tags';
import { Button } from 'flavours/glitch/components/button';
@ -28,6 +30,11 @@ const messages = defineMessages({
id: 'hashtag.admin_moderation',
defaultMessage: 'Open moderation interface for #{name}',
},
feature: { id: 'hashtag.feature', defaultMessage: 'Feature on profile' },
unfeature: {
id: 'hashtag.unfeature',
defaultMessage: "Don't feature on profile",
},
});
const usesRenderer = (displayNumber: React.ReactNode, pluralReady: number) => (
@ -88,22 +95,51 @@ export const HashtagHeader: React.FC<{
}, [dispatch, tagId, setTag]);
const menu = useMemo(() => {
const tmp = [];
const arr = [];
if (
tag &&
signedIn &&
(permissions & PERMISSION_MANAGE_TAXONOMIES) ===
PERMISSION_MANAGE_TAXONOMIES
) {
tmp.push({
text: intl.formatMessage(messages.adminModeration, { name: tag.id }),
href: `/admin/tags/${tag.id}`,
if (tag && signedIn) {
const handleFeature = () => {
if (tag.featuring) {
void dispatch(unfeatureHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
setTag(result.payload);
}
return '';
});
} else {
void dispatch(featureHashtag({ tagId })).then((result) => {
if (isFulfilled(result)) {
setTag(result.payload);
}
return '';
});
}
};
arr.push({
text: intl.formatMessage(
tag.featuring ? messages.unfeature : messages.feature,
),
action: handleFeature,
});
arr.push(null);
if (
(permissions & PERMISSION_MANAGE_TAXONOMIES) ===
PERMISSION_MANAGE_TAXONOMIES
) {
arr.push({
text: intl.formatMessage(messages.adminModeration, { name: tagId }),
href: `/admin/tags/${tag.id}`,
});
}
}
return tmp;
}, [signedIn, permissions, intl, tag]);
return arr;
}, [setTag, dispatch, tagId, signedIn, permissions, intl, tag]);
const handleFollow = useCallback(() => {
if (!signedIn || !tag) {

View File

@ -39,9 +39,9 @@ export const NotificationMention: React.FC<{
unread: boolean;
}> = ({ notification, unread }) => {
const [isDirect, isReply] = useAppSelector((state) => {
const status = state.statuses.get(notification.statusId) as
| Status
| undefined;
const status = notification.statusId
? (state.statuses.get(notification.statusId) as Status | undefined)
: undefined;
if (!status) return [false, false] as const;

View File

@ -59,6 +59,7 @@ import { textForScreenReader, defaultMediaVisibility } from '../../components/st
import StatusContainer from '../../containers/status_container';
import { deleteModal } from '../../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
import { getAncestorsIds, getDescendantsIds } from 'flavours/glitch/selectors/contexts';
import Column from '../ui/components/column';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen';
@ -78,69 +79,15 @@ const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const getAncestorsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => {
let ancestorsIds = ImmutableList();
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = statusId;
while (id && !mutable.includes(id)) {
mutable.unshift(id);
id = inReplyTos.get(id);
}
});
return ancestorsIds;
});
const getDescendantsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'replies']),
state => state.get('statuses'),
], (statusId, contextReplies, statuses) => {
let descendantsIds = [];
const ids = [statusId];
while (ids.length > 0) {
let id = ids.pop();
const replies = contextReplies.get(id);
if (statusId !== id) {
descendantsIds.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
if (!ids.includes(reply) && !descendantsIds.includes(reply) && statusId !== reply) ids.push(reply);
});
}
}
let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
if (insertAt !== -1) {
descendantsIds.forEach((id, idx) => {
if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
descendantsIds.splice(idx, 1);
descendantsIds.splice(insertAt, 0, id);
insertAt += 1;
}
});
}
return ImmutableList(descendantsIds);
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
let ancestorsIds = ImmutableList();
let descendantsIds = ImmutableList();
let ancestorsIds = [];
let descendantsIds = [];
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
ancestorsIds = getAncestorsIds(state, status.get('in_reply_to_id'));
descendantsIds = getDescendantsIds(state, status.get('id'));
}
return {
@ -185,8 +132,8 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
isLoading: PropTypes.bool,
settings: ImmutablePropTypes.map.isRequired,
ancestorsIds: ImmutablePropTypes.list.isRequired,
descendantsIds: ImmutablePropTypes.list.isRequired,
ancestorsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
descendantsIds: PropTypes.arrayOf(PropTypes.string).isRequired,
intl: PropTypes.object.isRequired,
askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool,
@ -405,7 +352,7 @@ class Status extends ImmutablePureComponent {
handleToggleAll = () => {
const { status, ancestorsIds, descendantsIds, settings } = this.props;
const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
const statusIds = [status.get('id')].concat(ancestorsIds, descendantsIds);
let { isExpanded } = this.state;
if (settings.getIn(['content_warnings', 'shared_state']))
@ -493,13 +440,13 @@ class Status extends ImmutablePureComponent {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size - 1, true);
this._selectChild(ancestorsIds.length - 1, true);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index, true);
this._selectChild(ancestorsIds.length + index, true);
} else {
this._selectChild(index - 1, true);
}
@ -510,13 +457,13 @@ class Status extends ImmutablePureComponent {
const { status, ancestorsIds, descendantsIds } = this.props;
if (id === status.get('id')) {
this._selectChild(ancestorsIds.size + 1, false);
this._selectChild(ancestorsIds.length + 1, false);
} else {
let index = ancestorsIds.indexOf(id);
if (index === -1) {
index = descendantsIds.indexOf(id);
this._selectChild(ancestorsIds.size + index + 2, false);
this._selectChild(ancestorsIds.length + index + 2, false);
} else {
this._selectChild(index + 1, false);
}
@ -552,8 +499,8 @@ class Status extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType='thread'
previousId={i > 0 ? list.get(i - 1) : undefined}
nextId={list.get(i + 1) || (ancestors && statusId)}
previousId={i > 0 ? list[i - 1] : undefined}
nextId={list[i + 1] || (ancestors && statusId)}
rootId={statusId}
/>
));
@ -598,7 +545,7 @@ class Status extends ImmutablePureComponent {
componentDidUpdate (prevProps) {
const { status, ancestorsIds } = this.props;
if (status && (ancestorsIds.size > prevProps.ancestorsIds.size || prevProps.status?.get('id') !== status.get('id'))) {
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
this._scrollStatusIntoView();
}
}
@ -647,11 +594,11 @@ class Status extends ImmutablePureComponent {
const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
if (ancestorsIds && ancestorsIds.size > 0) {
if (ancestorsIds && ancestorsIds.length > 0) {
ancestors = <>{this.renderChildren(ancestorsIds, true)}</>;
}
if (descendantsIds && descendantsIds.size > 0) {
if (descendantsIds && descendantsIds.length > 0) {
descendants = <>{this.renderChildren(descendantsIds)}</>;
}

View File

@ -1,65 +0,0 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { IconButton } from '../../../components/icon_button';
export default class ActionsModal extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
onClick: PropTypes.func,
actions: PropTypes.arrayOf(PropTypes.shape({
active: PropTypes.bool,
href: PropTypes.string,
icon: PropTypes.string,
meta: PropTypes.string,
name: PropTypes.string,
text: PropTypes.string,
})),
renderItemContents: PropTypes.func,
};
renderAction = (action, i) => {
if (action === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { icon = null, iconComponent = null, text, meta = null, active = false, href = '#' } = action;
let contents = this.props.renderItemContents && this.props.renderItemContents(action, i);
if (!contents) {
contents = (
<>
{icon && <IconButton title={text} icon={icon} iconComponent={iconComponent} role='presentation' tabIndex={-1} inverted />}
<div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
<div>{meta}</div>
</div>
</>
);
}
return (
<li key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames('link', { active })}>
{contents}
</a>
</li>
);
};
render () {
return (
<div className='modal-root__modal actions-modal'>
<ul>
{this.props.actions.map(this.renderAction)}
</ul>
</div>
);
}
}

View File

@ -0,0 +1,65 @@
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import {
isActionItem,
isExternalLinkItem,
} from 'flavours/glitch/models/dropdown_menu';
export const ActionsModal: React.FC<{
actions: MenuItem[];
onClick: React.MouseEventHandler;
}> = ({ actions, onClick }) => (
<div className='modal-root__modal actions-modal'>
<ul>
{actions.map((option, i: number) => {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
let element: React.ReactElement;
if (isActionItem(option)) {
element = (
<button onClick={onClick} data-index={i}>
{text}
</button>
);
} else if (isExternalLinkItem(option)) {
element = (
<a
href={option.href}
target={option.target ?? '_target'}
data-method={option.method}
rel='noopener'
onClick={onClick}
data-index={i}
>
{text}
</a>
);
} else {
element = (
<Link to={option.to} onClick={onClick} data-index={i}>
{text}
</Link>
);
}
return (
<li
className={classNames({
'dropdown-menu__item--dangerous': dangerous,
})}
key={`${text}-${i}`}
>
{element}
</li>
);
})}
</ul>
</div>
);

View File

@ -25,7 +25,7 @@ import { getScrollbarWidth } from 'flavours/glitch/utils/scrollbar';
import BundleContainer from '../containers/bundle_container';
import ActionsModal from './actions_modal';
import { ActionsModal } from './actions_modal';
import AudioModal from './audio_modal';
import { BoostModal } from './boost_modal';
import {

View File

@ -11,27 +11,25 @@ interface Params {
id?: string;
}
export function useAccountId() {
export const useAccountId = () => {
const { acct, id } = useParams<Params>();
const dispatch = useAppDispatch();
const accountId = useAppSelector(
(state) =>
id ??
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined),
);
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const isAccount = !!account;
const accountInStore = !!account;
const dispatch = useAppDispatch();
useEffect(() => {
if (!accountId) {
if (typeof accountId === 'undefined' && acct) {
dispatch(lookupAccount(acct));
} else if (!isAccount) {
} else if (accountId && !accountInStore) {
dispatch(fetchAccount(accountId));
}
}, [dispatch, accountId, acct, isAccount]);
}, [dispatch, accountId, acct, accountInStore]);
return accountId;
}
};

View File

@ -1,12 +1,14 @@
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector } from 'flavours/glitch/store';
export function useAccountVisibility(accountId?: string) {
const blockedBy = useAppSelector(
(state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
export function useAccountVisibility(accountId?: string | null) {
const blockedBy = useAppSelector((state) =>
accountId
? !!state.relationships.getIn([accountId, 'blocked_by'], false)
: false,
);
const suspended = useAppSelector(
(state) => !!state.accounts.getIn([accountId, 'suspended'], false),
const suspended = useAppSelector((state) =>
accountId ? !!state.accounts.getIn([accountId, 'suspended'], false) : false,
);
const hidden = useAppSelector((state) =>
accountId ? Boolean(getAccountHidden(state, accountId)) : false,

View File

@ -22,3 +22,29 @@ export type MenuItem =
| LinkMenuItem
| ExternalLinkMenuItem
| null;
export const isMenuItem = (item: unknown): item is MenuItem => {
if (item === null) {
return true;
}
return typeof item === 'object' && 'text' in item;
};
export const isActionItem = (item: unknown): item is ActionMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'action' in item;
};
export const isExternalLinkItem = (
item: unknown,
): item is ExternalLinkMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'href' in item;
};

View File

@ -4,9 +4,9 @@ import { Map as ImmutableMap } from 'immutable';
import {
followAccountSuccess,
unfollowAccountSuccess,
importAccounts,
revealAccount,
} from 'flavours/glitch/actions/accounts_typed';
import { importAccounts } from 'flavours/glitch/actions/importer/accounts';
import type { ApiAccountJSON } from 'flavours/glitch/api_types/accounts';
import { me } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';

View File

@ -1,24 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { ACCOUNT_LOOKUP_FAIL } from '../actions/accounts';
import { importAccounts } from '../actions/accounts_typed';
import { domain } from '../initial_state';
export const normalizeForLookup = str => {
str = str.toLowerCase();
const trailingIndex = str.indexOf(`@${domain.toLowerCase()}`);
return (trailingIndex > 0) ? str.slice(0, trailingIndex) : str;
};
const initialState = ImmutableMap();
export default function accountsMap(state = initialState, action) {
switch(action.type) {
case ACCOUNT_LOOKUP_FAIL:
return action.error?.response?.status === 404 ? state.set(normalizeForLookup(action.acct), null) : state;
case importAccounts.type:
return state.withMutations(map => action.payload.accounts.forEach(account => map.set(normalizeForLookup(account.acct), account.id)));
default:
return state;
}
}

View File

@ -0,0 +1,38 @@
import { createReducer } from '@reduxjs/toolkit';
import type { UnknownAction } from '@reduxjs/toolkit';
import type { AxiosError } from 'axios';
import { ACCOUNT_LOOKUP_FAIL } from 'flavours/glitch/actions/accounts';
import { importAccounts } from 'flavours/glitch/actions/importer/accounts';
import { domain } from 'flavours/glitch/initial_state';
interface AccountLookupFailAction extends UnknownAction {
acct: string;
error?: AxiosError;
}
const pattern = new RegExp(`@${domain}$`, 'gi');
export const normalizeForLookup = (str: string) =>
str.toLowerCase().replace(pattern, '');
const initialState: Record<string, string | null> = {};
export const accountsMapReducer = createReducer(initialState, (builder) => {
builder
.addCase(importAccounts, (state, action) => {
action.payload.accounts.forEach((account) => {
state[normalizeForLookup(account.acct)] = account.id;
});
})
.addMatcher(
(action: UnknownAction): action is AccountLookupFailAction =>
action.type === ACCOUNT_LOOKUP_FAIL,
(state, action) => {
if (action.error?.response?.status === 404) {
state[normalizeForLookup(action.acct)] = null;
}
},
);
});

View File

@ -1,109 +0,0 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
import {
blockAccountSuccess,
muteAccountSuccess,
} from '../actions/accounts';
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_UPDATE } from '../actions/timelines';
import { compareId } from '../compare_id';
const initialState = ImmutableMap({
inReplyTos: ImmutableMap(),
replies: ImmutableMap(),
});
const normalizeContext = (immutableState, id, ancestors, descendants) => immutableState.withMutations(state => {
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id && !inReplyTos.has(id)) {
replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
return siblings.insert(index + 1, id);
});
inReplyTos.set(id, in_reply_to_id);
}
}
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
ancestors.forEach(addReply);
if (ancestors[0]) {
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
}
descendants.forEach(addReply);
}));
}));
});
const deleteFromContexts = (immutableState, ids) => immutableState.withMutations(state => {
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
ids.forEach(id => {
const inReplyToIdOfId = inReplyTos.get(id);
const repliesOfId = replies.get(id);
const siblings = replies.get(inReplyToIdOfId);
if (siblings) {
replies.set(inReplyToIdOfId, siblings.filterNot(sibling => sibling === id));
}
if (repliesOfId) {
repliesOfId.forEach(reply => inReplyTos.delete(reply));
}
inReplyTos.delete(id);
replies.delete(id);
});
}));
}));
});
const filterContexts = (state, relationship, statuses) => {
const ownedStatusIds = statuses
.filter(status => status.get('account') === relationship.id)
.map(status => status.get('id'));
return deleteFromContexts(state, ownedStatusIds);
};
const updateContext = (state, status) => {
if (status.in_reply_to_id) {
return state.withMutations(mutable => {
const replies = mutable.getIn(['replies', status.in_reply_to_id], ImmutableList());
mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
if (!replies.includes(status.id)) {
mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
}
});
}
return state;
};
export default function replies(state = initialState, action) {
switch(action.type) {
case blockAccountSuccess.type:
case muteAccountSuccess.type:
return filterContexts(state, action.payload.relationship, action.payload.statuses);
case CONTEXT_FETCH_SUCCESS:
return normalizeContext(state, action.id, action.ancestors, action.descendants);
case timelineDelete.type:
return deleteFromContexts(state, [action.payload.statusId]);
case TIMELINE_UPDATE:
return updateContext(state, action.status);
default:
return state;
}
}

View File

@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/no-dynamic-delete */
import { createReducer } from '@reduxjs/toolkit';
import type { Draft, UnknownAction } from '@reduxjs/toolkit';
import type { List as ImmutableList } from 'immutable';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
import type { ApiRelationshipJSON } from 'flavours/glitch/api_types/relationships';
import type {
ApiStatusJSON,
ApiContextJSON,
} from 'flavours/glitch/api_types/statuses';
import type { Status } from 'flavours/glitch/models/status';
import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts';
import { fetchContext } from '../actions/statuses';
import { TIMELINE_UPDATE } from '../actions/timelines';
import { compareId } from '../compare_id';
interface TimelineUpdateAction extends UnknownAction {
timeline: string;
status: ApiStatusJSON;
usePendingItems: boolean;
}
interface State {
inReplyTos: Record<string, string>;
replies: Record<string, string[]>;
}
const initialState: State = {
inReplyTos: {},
replies: {},
};
const normalizeContext = (
state: Draft<State>,
id: string,
{ ancestors, descendants }: ApiContextJSON,
): void => {
const addReply = ({
id,
in_reply_to_id,
}: {
id: string;
in_reply_to_id?: string;
}) => {
if (!in_reply_to_id) {
return;
}
if (!state.inReplyTos[id]) {
const siblings = (state.replies[in_reply_to_id] ??= []);
const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0);
siblings.splice(index + 1, 0, id);
state.inReplyTos[id] = in_reply_to_id;
}
};
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
ancestors.forEach(addReply);
if (ancestors[0]) {
addReply({
id,
in_reply_to_id: ancestors[ancestors.length - 1]?.id,
});
}
descendants.forEach(addReply);
};
const deleteFromContexts = (state: Draft<State>, ids: string[]): void => {
ids.forEach((id) => {
const inReplyToIdOfId = state.inReplyTos[id];
const repliesOfId = state.replies[id];
if (inReplyToIdOfId) {
const siblings = state.replies[inReplyToIdOfId];
if (siblings) {
state.replies[inReplyToIdOfId] = siblings.filter(
(sibling) => sibling !== id,
);
}
}
if (repliesOfId) {
repliesOfId.forEach((reply) => {
delete state.inReplyTos[reply];
});
}
delete state.inReplyTos[id];
delete state.replies[id];
});
};
const filterContexts = (
state: Draft<State>,
relationship: ApiRelationshipJSON,
statuses: ImmutableList<Status>,
): void => {
const ownedStatusIds = statuses
.filter((status) => (status.get('account') as string) === relationship.id)
.map((status) => status.get('id') as string);
deleteFromContexts(state, ownedStatusIds.toArray());
};
const updateContext = (state: Draft<State>, status: ApiStatusJSON): void => {
if (!status.in_reply_to_id) {
return;
}
const siblings = (state.replies[status.in_reply_to_id] ??= []);
state.inReplyTos[status.id] = status.in_reply_to_id;
if (!siblings.includes(status.id)) {
siblings.push(status.id);
}
};
export const contextsReducer = createReducer(initialState, (builder) => {
builder
.addCase(fetchContext.fulfilled, (state, action) => {
normalizeContext(state, action.meta.arg.statusId, action.payload.context);
})
.addCase(blockAccountSuccess, (state, action) => {
filterContexts(
state,
action.payload.relationship,
action.payload.statuses as ImmutableList<Status>,
);
})
.addCase(muteAccountSuccess, (state, action) => {
filterContexts(
state,
action.payload.relationship,
action.payload.statuses as ImmutableList<Status>,
);
})
.addCase(timelineDelete, (state, action) => {
deleteFromContexts(state, [action.payload.statusId]);
})
.addMatcher(
(action: UnknownAction): action is TimelineUpdateAction =>
action.type === TIMELINE_UPDATE,
(state, action) => {
updateContext(state, action.status);
},
);
});

View File

@ -4,11 +4,11 @@ import { loadingBarReducer } from 'react-redux-loading-bar';
import { combineReducers } from 'redux-immutable';
import { accountsReducer } from './accounts';
import accounts_map from './accounts_map';
import { accountsMapReducer } from './accounts_map';
import { alertsReducer } from './alerts';
import announcements from './announcements';
import { composeReducer } from './compose';
import contexts from './contexts';
import { contextsReducer } from './contexts';
import conversations from './conversations';
import custom_emojis from './custom_emojis';
import { dropdownMenuReducer } from './dropdown_menu';
@ -50,14 +50,14 @@ const reducers = {
user_lists,
status_lists,
accounts: accountsReducer,
accounts_map,
accounts_map: accountsMapReducer,
statuses,
relationships: relationshipsReducer,
settings,
local_settings,
push_notifications,
server,
contexts,
contexts: contextsReducer,
compose: composeReducer,
search: searchReducer,
media_attachments,

View File

@ -64,6 +64,7 @@ const statusTranslateUndo = (state, id) => {
});
};
/** @type {ImmutableMap<string, ImmutableMap<string, any>>} */
const initialState = ImmutableMap();
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */

View File

@ -5,9 +5,7 @@ import {
fetchDirectory
} from 'flavours/glitch/actions/directory';
import {
FEATURED_TAGS_FETCH_REQUEST,
FEATURED_TAGS_FETCH_SUCCESS,
FEATURED_TAGS_FETCH_FAIL,
fetchFeaturedTags
} from 'flavours/glitch/actions/featured_tags';
import {
@ -31,6 +29,7 @@ import {
FOLLOW_REQUESTS_EXPAND_FAIL,
authorizeFollowRequestSuccess,
rejectFollowRequestSuccess,
fetchEndorsedAccounts,
} from '../actions/accounts';
import {
BLOCKS_FETCH_REQUEST,
@ -191,21 +190,27 @@ export default function userLists(state = initialState, action) {
case MUTES_FETCH_FAIL:
case MUTES_EXPAND_FAIL:
return state.setIn(['mutes', 'isLoading'], false);
case FEATURED_TAGS_FETCH_SUCCESS:
return normalizeFeaturedTags(state, ['featured_tags', action.id], action.tags, action.id);
case FEATURED_TAGS_FETCH_REQUEST:
return state.setIn(['featured_tags', action.id, 'isLoading'], true);
case FEATURED_TAGS_FETCH_FAIL:
return state.setIn(['featured_tags', action.id, 'isLoading'], false);
default:
if(fetchDirectory.fulfilled.match(action))
if (fetchEndorsedAccounts.fulfilled.match(action))
return normalizeList(state, ['featured_accounts', action.meta.arg.accountId], action.payload, undefined);
else if (fetchEndorsedAccounts.pending.match(action))
return state.setIn(['featured_accounts', action.meta.arg.accountId, 'isLoading'], true);
else if (fetchEndorsedAccounts.rejected.match(action))
return state.setIn(['featured_accounts', action.meta.arg.accountId, 'isLoading'], false);
else if (fetchFeaturedTags.fulfilled.match(action))
return normalizeFeaturedTags(state, ['featured_tags', action.meta.arg.accountId], action.payload, action.meta.arg.accountId);
else if (fetchFeaturedTags.pending.match(action))
return state.setIn(['featured_tags', action.meta.arg.accountId, 'isLoading'], true);
else if (fetchFeaturedTags.rejected.match(action))
return state.setIn(['featured_tags', action.meta.arg.accountId, 'isLoading'], false);
else if (fetchDirectory.fulfilled.match(action))
return normalizeList(state, ['directory'], action.payload.accounts, undefined);
else if( expandDirectory.fulfilled.match(action))
else if (expandDirectory.fulfilled.match(action))
return appendToList(state, ['directory'], action.payload.accounts, undefined);
else if(fetchDirectory.pending.match(action) ||
else if (fetchDirectory.pending.match(action) ||
expandDirectory.pending.match(action))
return state.setIn(['directory', 'isLoading'], true);
else if(fetchDirectory.rejected.match(action) ||
else if (fetchDirectory.rejected.match(action) ||
expandDirectory.rejected.match(action))
return state.setIn(['directory', 'isLoading'], false);
else

View File

@ -0,0 +1,94 @@
import { createAppSelector } from 'flavours/glitch/store';
export const getAncestorsIds = createAppSelector(
[(_, id: string) => id, (state) => state.contexts.inReplyTos],
(statusId, inReplyTos) => {
const ancestorsIds: string[] = [];
let currentId: string | undefined = statusId;
while (currentId && !ancestorsIds.includes(currentId)) {
ancestorsIds.unshift(currentId);
currentId = inReplyTos[currentId];
}
return ancestorsIds;
},
);
export const getDescendantsIds = createAppSelector(
[
(_, id: string) => id,
(state) => state.contexts.replies,
(state) => state.statuses,
],
(statusId, contextReplies, statuses) => {
const descendantsIds: string[] = [];
const visitIds = [statusId];
while (visitIds.length > 0) {
const id = visitIds.pop();
if (!id) {
break;
}
const replies = contextReplies[id];
if (statusId !== id) {
descendantsIds.push(id);
}
if (replies) {
replies.toReversed().forEach((replyId) => {
if (
!visitIds.includes(replyId) &&
!descendantsIds.includes(replyId) &&
statusId !== replyId
) {
visitIds.push(replyId);
}
});
}
}
let insertAt = descendantsIds.findIndex((id) => {
const status = statuses.get(id);
if (!status) {
return false;
}
const inReplyToAccountId = status.get('in_reply_to_account_id') as
| string
| null;
const accountId = status.get('account') as string;
return inReplyToAccountId !== accountId;
});
if (insertAt !== -1) {
descendantsIds.forEach((id, idx) => {
const status = statuses.get(id);
if (!status) {
return;
}
const inReplyToAccountId = status.get('in_reply_to_account_id') as
| string
| null;
const accountId = status.get('account') as string;
if (idx > insertAt && inReplyToAccountId === accountId) {
descendantsIds.splice(idx, 1);
descendantsIds.splice(insertAt, 0, id);
insertAt += 1;
}
});
}
return descendantsIds;
},
);

View File

@ -3,6 +3,7 @@ export type { GetState, AppDispatch, RootState } from './store';
export {
createAppAsyncThunk,
createAppSelector,
useAppDispatch,
useAppSelector,
} from './typed_functions';

View File

@ -1,5 +1,5 @@
import type { GetThunkAPI } from '@reduxjs/toolkit';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { createAsyncThunk, createSelector } from '@reduxjs/toolkit';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useDispatch, useSelector } from 'react-redux';
@ -24,6 +24,8 @@ export const createAppAsyncThunk = createAsyncThunk.withTypes<{
rejectValue: AsyncThunkRejectValue;
}>();
export const createAppSelector = createSelector.withTypes<RootState>();
interface AppThunkConfig {
state: RootState;
dispatch: AppDispatch;

View File

@ -6723,55 +6723,38 @@ a.status-card {
}
.actions-modal {
border-radius: 8px 8px 0 0;
background: var(--dropdown-background-color);
backdrop-filter: var(--background-filter);
border-color: var(--dropdown-border-color);
box-shadow: var(--dropdown-shadow);
max-height: 80vh;
max-width: 80vw;
.actions-modal__item-label {
font-weight: 500;
}
ul {
overflow-y: auto;
flex-shrink: 0;
max-height: 80vh;
padding-bottom: 8px;
}
&.with-status {
max-height: calc(80vh - 75px);
}
a,
button {
color: inherit;
display: flex;
padding: 16px;
font-size: 15px;
line-height: 21px;
background: transparent;
border: none;
align-items: center;
text-decoration: none;
width: 100%;
box-sizing: border-box;
li:empty {
margin: 0;
}
li:not(:empty) {
a {
color: $primary-text-color;
display: flex;
padding: 12px 16px;
font-size: 15px;
align-items: center;
text-decoration: none;
&,
button {
transition: none;
}
&.active,
&:hover,
&:active,
&:focus {
&,
button {
background: $ui-highlight-color;
color: $primary-text-color;
}
}
button:first-child {
margin-inline-end: 10px;
}
}
&:hover,
&:active,
&:focus {
background: var(--dropdown-border-color);
}
}
}

View File

@ -1,18 +1,18 @@
import { createAction } from '@reduxjs/toolkit';
import { apiRemoveAccountFromFollowers } from 'mastodon/api/accounts';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import {
apiRemoveAccountFromFollowers,
apiGetEndorsedAccounts,
} from 'mastodon/api/accounts';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedAccounts } from './importer';
export const revealAccount = createAction<{
id: string;
}>('accounts/revealAccount');
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
'accounts/importAccounts',
);
function actionWithSkipLoadingTrue<Args extends object>(args: Args) {
return {
payload: {
@ -104,3 +104,12 @@ export const removeAccountFromFollowers = createDataLoadingThunk(
apiRemoveAccountFromFollowers(accountId),
(relationship) => ({ relationship }),
);
export const fetchEndorsedAccounts = createDataLoadingThunk(
'accounts/endorsements',
({ accountId }: { accountId: string }) => apiGetEndorsedAccounts(accountId),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data));
return data;
},
);

View File

@ -1,34 +0,0 @@
import api from '../api';
export const FEATURED_TAGS_FETCH_REQUEST = 'FEATURED_TAGS_FETCH_REQUEST';
export const FEATURED_TAGS_FETCH_SUCCESS = 'FEATURED_TAGS_FETCH_SUCCESS';
export const FEATURED_TAGS_FETCH_FAIL = 'FEATURED_TAGS_FETCH_FAIL';
export const fetchFeaturedTags = (id) => (dispatch, getState) => {
if (getState().getIn(['user_lists', 'featured_tags', id, 'items'])) {
return;
}
dispatch(fetchFeaturedTagsRequest(id));
api().get(`/api/v1/accounts/${id}/featured_tags`)
.then(({ data }) => dispatch(fetchFeaturedTagsSuccess(id, data)))
.catch(err => dispatch(fetchFeaturedTagsFail(id, err)));
};
export const fetchFeaturedTagsRequest = (id) => ({
type: FEATURED_TAGS_FETCH_REQUEST,
id,
});
export const fetchFeaturedTagsSuccess = (id, tags) => ({
type: FEATURED_TAGS_FETCH_SUCCESS,
id,
tags,
});
export const fetchFeaturedTagsFail = (id, error) => ({
type: FEATURED_TAGS_FETCH_FAIL,
id,
error,
});

View File

@ -0,0 +1,7 @@
import { apiGetFeaturedTags } from 'mastodon/api/accounts';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const fetchFeaturedTags = createDataLoadingThunk(
'accounts/featured_tags',
({ accountId }: { accountId: string }) => apiGetFeaturedTags(accountId),
);

View File

@ -0,0 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export const importAccounts = createAction<{ accounts: ApiAccountJSON[] }>(
'accounts/importAccounts',
);

View File

@ -1,7 +1,6 @@
import { createPollFromServerJSON } from 'mastodon/models/poll';
import { importAccounts } from '../accounts_typed';
import { importAccounts } from './accounts';
import { normalizeStatus } from './normalizer';
import { importPolls } from './polls';

View File

@ -4,8 +4,11 @@ import api from '../api';
import { ensureComposeIsVisible, setComposeToStatus } from './compose';
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
import { fetchContext } from './statuses_typed';
import { deleteFromTimelines } from './timelines';
export * from './statuses_typed';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
export const STATUS_FETCH_FAIL = 'STATUS_FETCH_FAIL';
@ -14,10 +17,6 @@ export const STATUS_DELETE_REQUEST = 'STATUS_DELETE_REQUEST';
export const STATUS_DELETE_SUCCESS = 'STATUS_DELETE_SUCCESS';
export const STATUS_DELETE_FAIL = 'STATUS_DELETE_FAIL';
export const CONTEXT_FETCH_REQUEST = 'CONTEXT_FETCH_REQUEST';
export const CONTEXT_FETCH_SUCCESS = 'CONTEXT_FETCH_SUCCESS';
export const CONTEXT_FETCH_FAIL = 'CONTEXT_FETCH_FAIL';
export const STATUS_MUTE_REQUEST = 'STATUS_MUTE_REQUEST';
export const STATUS_MUTE_SUCCESS = 'STATUS_MUTE_SUCCESS';
export const STATUS_MUTE_FAIL = 'STATUS_MUTE_FAIL';
@ -54,7 +53,7 @@ export function fetchStatus(id, forceFetch = false, alsoFetchContext = true) {
const skipLoading = !forceFetch && getState().getIn(['statuses', id], null) !== null;
if (alsoFetchContext) {
dispatch(fetchContext(id));
dispatch(fetchContext({ statusId: id }));
}
if (skipLoading) {
@ -178,50 +177,6 @@ export function deleteStatusFail(id, error) {
export const updateStatus = status => dispatch =>
dispatch(importFetchedStatus(status));
export function fetchContext(id) {
return (dispatch) => {
dispatch(fetchContextRequest(id));
api().get(`/api/v1/statuses/${id}/context`).then(response => {
dispatch(importFetchedStatuses(response.data.ancestors.concat(response.data.descendants)));
dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants));
}).catch(error => {
if (error.response && error.response.status === 404) {
dispatch(deleteFromTimelines(id));
}
dispatch(fetchContextFail(id, error));
});
};
}
export function fetchContextRequest(id) {
return {
type: CONTEXT_FETCH_REQUEST,
id,
};
}
export function fetchContextSuccess(id, ancestors, descendants) {
return {
type: CONTEXT_FETCH_SUCCESS,
id,
ancestors,
descendants,
statuses: ancestors.concat(descendants),
};
}
export function fetchContextFail(id, error) {
return {
type: CONTEXT_FETCH_FAIL,
id,
error,
skipAlert: true,
};
}
export function muteStatus(id) {
return (dispatch) => {
dispatch(muteStatusRequest(id));

View File

@ -0,0 +1,18 @@
import { apiGetContext } from 'mastodon/api/statuses';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { importFetchedStatuses } from './importer';
export const fetchContext = createDataLoadingThunk(
'status/context',
({ statusId }: { statusId: string }) => apiGetContext(statusId),
(context, { dispatch }) => {
const statuses = context.ancestors.concat(context.descendants);
dispatch(importFetchedStatuses(statuses));
return {
context,
};
},
);

View File

@ -1,4 +1,10 @@
import { apiGetTag, apiFollowTag, apiUnfollowTag } from 'mastodon/api/tags';
import {
apiGetTag,
apiFollowTag,
apiUnfollowTag,
apiFeatureTag,
apiUnfeatureTag,
} from 'mastodon/api/tags';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const fetchHashtag = createDataLoadingThunk(
@ -15,3 +21,13 @@ export const unfollowHashtag = createDataLoadingThunk(
'tags/unfollow',
({ tagId }: { tagId: string }) => apiUnfollowTag(tagId),
);
export const featureHashtag = createDataLoadingThunk(
'tags/feature',
({ tagId }: { tagId: string }) => apiFeatureTag(tagId),
);
export const unfeatureHashtag = createDataLoadingThunk(
'tags/unfeature',
({ tagId }: { tagId: string }) => apiUnfeatureTag(tagId),
);

View File

@ -1,5 +1,7 @@
import { apiRequestPost } from 'mastodon/api';
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
@ -23,3 +25,9 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
apiRequestPost<ApiRelationshipJSON>(
`v1/accounts/${id}/remove_from_followers`,
);
export const apiGetFeaturedTags = (id: string) =>
apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
export const apiGetEndorsedAccounts = (id: string) =>
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);

View File

@ -0,0 +1,5 @@
import { apiRequestGet } from 'mastodon/api';
import type { ApiContextJSON } from 'mastodon/api_types/statuses';
export const apiGetContext = (statusId: string) =>
apiRequestGet<ApiContextJSON>(`v1/statuses/${statusId}/context`);

View File

@ -10,6 +10,12 @@ export const apiFollowTag = (tagId: string) =>
export const apiUnfollowTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfollow`);
export const apiFeatureTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/feature`);
export const apiUnfeatureTag = (tagId: string) =>
apiRequestPost<ApiHashtagJSON>(`v1/tags/${tagId}/unfeature`);
export const apiGetFollowedTags = async (url?: string) => {
const response = await api().request<ApiHashtagJSON[]>({
method: 'GET',

View File

@ -119,3 +119,8 @@ export interface ApiStatusJSON {
card?: ApiPreviewCardJSON;
poll?: ApiPollJSON;
}
export interface ApiContextJSON {
ancestors: ApiStatusJSON[];
descendants: ApiStatusJSON[];
}

View File

@ -10,4 +10,5 @@ export interface ApiHashtagJSON {
url: string;
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
following?: boolean;
featuring?: boolean;
}

View File

@ -96,13 +96,19 @@ export const decode83 = (str: string) => {
return value;
};
export const intToRGB = (int: number) => ({
export interface RGB {
r: number;
g: number;
b: number;
}
export const intToRGB = (int: number): RGB => ({
r: Math.max(0, int >> 16),
g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, int & 255),
});
export const getAverageFromBlurhash = (blurhash: string) => {
export const getAverageFromBlurhash = (blurhash: string | null) => {
if (!blurhash) {
return null;
}

View File

@ -26,11 +26,12 @@ import {
import { openModal, closeModal } from 'mastodon/actions/modal';
import { CircularProgress } from 'mastodon/components/circular_progress';
import { isUserTouching } from 'mastodon/is_mobile';
import type {
MenuItem,
ActionMenuItem,
ExternalLinkMenuItem,
import {
isMenuItem,
isActionItem,
isExternalLinkItem,
} from 'mastodon/models/dropdown_menu';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import type { IconProp } from './icon';
@ -38,30 +39,6 @@ import { IconButton } from './icon_button';
let id = 0;
const isMenuItem = (item: unknown): item is MenuItem => {
if (item === null) {
return true;
}
return typeof item === 'object' && 'text' in item;
};
const isActionItem = (item: unknown): item is ActionMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'action' in item;
};
const isExternalLinkItem = (item: unknown): item is ExternalLinkMenuItem => {
if (!item || !isMenuItem(item)) {
return false;
}
return 'href' in item;
};
type RenderItemFn<Item = MenuItem> = (
item: Item,
index: number,
@ -320,6 +297,7 @@ interface DropdownProps<Item = MenuItem> {
scrollable?: boolean;
scrollKey?: string;
status?: ImmutableMap<string, unknown>;
forceDropdown?: boolean;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: () => void;
@ -339,6 +317,7 @@ export const Dropdown = <Item = MenuItem,>({
disabled,
scrollable,
status,
forceDropdown = false,
renderItem,
renderHeader,
onOpen,
@ -354,6 +333,9 @@ export const Dropdown = <Item = MenuItem,>({
const open = currentId === openDropdownId;
const activeElement = useRef<HTMLElement | null>(null);
const targetRef = useRef<HTMLButtonElement | null>(null);
const prefetchAccountId = status
? status.getIn(['account', 'id'])
: undefined;
const handleClose = useCallback(() => {
if (activeElement.current) {
@ -402,16 +384,15 @@ export const Dropdown = <Item = MenuItem,>({
} else {
onOpen?.();
if (status) {
dispatch(fetchRelationships([status.getIn(['account', 'id'])]));
if (prefetchAccountId) {
dispatch(fetchRelationships([prefetchAccountId]));
}
if (isUserTouching()) {
if (isUserTouching() && !forceDropdown) {
dispatch(
openModal({
modalType: 'ACTIONS',
modalProps: {
status,
actions: items,
onClick: handleItemClick,
},
@ -431,12 +412,13 @@ export const Dropdown = <Item = MenuItem,>({
[
dispatch,
currentId,
prefetchAccountId,
scrollKey,
onOpen,
handleItemClick,
open,
status,
items,
forceDropdown,
handleClose,
],
);

View File

@ -116,6 +116,7 @@ export const EditedTimestamp: React.FC<{
renderHeader={renderHeader}
onOpen={handleOpen}
onItemClick={handleItemClick}
forceDropdown
>
<button className='dropdown-menu__text-button'>
<FormattedMessage

View File

@ -1,24 +1,32 @@
import { FormattedMessage } from 'react-intl';
import { LoadingIndicator } from './loading_indicator';
interface Props {
onClick: (event: React.MouseEvent) => void;
disabled?: boolean;
visible?: boolean;
loading?: boolean;
}
export const LoadMore: React.FC<Props> = ({
onClick,
disabled,
visible = true,
loading = false,
}) => {
return (
<button
type='button'
className='load-more'
disabled={disabled || !visible}
disabled={disabled || loading || !visible}
style={{ visibility: visible ? 'visible' : 'hidden' }}
onClick={onClick}
>
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
{loading ? (
<LoadingIndicator />
) : (
<FormattedMessage id='status.load_more' defaultMessage='Load more' />
)}
</button>
);
};

View File

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import CancelPresentationIcon from '@/material-icons/400-24px/cancel_presentation.svg?react';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { Icon } from 'mastodon/components/icon';
class PictureInPicturePlaceholder extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
aspectRatio: PropTypes.string,
};
handleClick = () => {
const { dispatch } = this.props;
dispatch(removePictureInPicture());
};
render () {
const { aspectRatio } = this.props;
return (
<div className='picture-in-picture-placeholder' style={{ aspectRatio }} role='button' tabIndex={0} onClick={this.handleClick}>
<Icon id='window-restore' icon={CancelPresentationIcon} />
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
</div>
);
}
}
export default connect()(PictureInPicturePlaceholder);

View File

@ -0,0 +1,46 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import PipExitIcon from '@/material-icons/400-24px/pip_exit.svg?react';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { Icon } from 'mastodon/components/icon';
import { useAppDispatch } from 'mastodon/store';
export const PictureInPicturePlaceholder: React.FC<{ aspectRatio: string }> = ({
aspectRatio,
}) => {
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(removePictureInPicture());
}, [dispatch]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleClick();
}
},
[handleClick],
);
return (
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
className='picture-in-picture-placeholder'
style={{ aspectRatio }}
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDownCapture={handleKeyDown}
>
<Icon id='' icon={PipExitIcon} />
<FormattedMessage
id='picture_in_picture.restore'
defaultMessage='Put it back'
/>
</div>
);
};

View File

@ -17,7 +17,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router';
import Card from '../features/status/components/card';
@ -403,14 +403,7 @@ class Status extends ImmutablePureComponent {
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
const matchedFilters = status.get('matched_filters');
if (featured) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend__icon'><Icon id='thumb-tack' icon={PushPinIcon} /></div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</div>
);
} else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = (
@ -491,9 +484,6 @@ class Status extends ImmutablePureComponent {
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')}

View File

@ -8,7 +8,7 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import MediaGallery from 'mastodon/components/media_gallery';
import ModalRoot from 'mastodon/components/modal_root';
import { Poll } from 'mastodon/components/poll';
import Audio from 'mastodon/features/audio';
import { Audio } from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card';
import MediaModal from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video';

View File

@ -7,19 +7,21 @@ import { useParams } from 'react-router';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
import { Account } from 'mastodon/components/account';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RemoteHint } from 'mastodon/components/remote_hint';
import StatusContainer from 'mastodon/containers/status_container';
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
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';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { AccountHeader } from '../account_timeline/components/account_header';
import Column from '../ui/components/column';
import { EmptyMessage } from './components/empty_message';
import { FeaturedTag } from './components/featured_tag';
import type { TagMap } from './components/featured_tag';
@ -29,7 +31,9 @@ interface Params {
id?: string;
}
const AccountFeatured = () => {
const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
multiColumn,
}) => {
const accountId = useAccountId();
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
const forceEmptyState = suspended || blockedBy || hidden;
@ -40,7 +44,8 @@ const AccountFeatured = () => {
useEffect(() => {
if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId));
dispatch(fetchFeaturedTags(accountId));
void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId }));
}
}, [accountId, dispatch]);
@ -67,6 +72,17 @@ const AccountFeatured = () => {
ImmutableList(),
) as ImmutableList<string>,
);
const featuredAccountIds = useAppSelector(
(state) =>
state.user_lists.getIn(
['featured_accounts', accountId, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (isLoading) {
return (
@ -78,7 +94,11 @@ const AccountFeatured = () => {
);
}
if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
if (
featuredStatusIds.isEmpty() &&
featuredTags.isEmpty() &&
featuredAccountIds.isEmpty()
) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
@ -131,6 +151,19 @@ const AccountFeatured = () => {
))}
</>
)}
{!featuredAccountIds.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.accounts'
defaultMessage='Profiles'
/>
</h4>
{featuredAccountIds.map((featuredAccountId) => (
<Account key={featuredAccountId} id={featuredAccountId} />
))}
</>
)}
<RemoteHint accountId={accountId} />
</div>
</Column>

View File

@ -147,7 +147,7 @@ export const AccountGallery: React.FC<{
[dispatch],
);
if (accountId && !isAccount) {
if (accountId === null) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}

View File

@ -107,7 +107,6 @@ const messages = defineMessages({
id: 'account.disable_notifications',
defaultMessage: 'Stop notifying me when @{name} posts',
},
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
@ -451,7 +450,6 @@ export const AccountHeader: React.FC<{
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
});
arr.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
arr.push(null);
arr.push({
text: intl.formatMessage(messages.follow_requests),

View File

@ -13,7 +13,6 @@ import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
@ -27,7 +26,7 @@ import { LimitedAccountHint } from './components/limited_account_hint';
const emptyList = ImmutableList();
const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = false }) => {
const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]);
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
if (accountId === null) {
return {
@ -86,7 +85,6 @@ class AccountTimeline extends ImmutablePureComponent {
dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
}
dispatch(fetchFeaturedTags(accountId));
dispatch(expandAccountTimeline(accountId, { withReplies, tagged }));
if (accountId === me) {

View File

@ -27,7 +27,7 @@ import { Button } from 'mastodon/components/button';
import { GIFV } from 'mastodon/components/gifv';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { Skeleton } from 'mastodon/components/skeleton';
import Audio from 'mastodon/features/audio';
import { Audio } from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import { Video, getPointerPosition } from 'mastodon/features/video';
@ -212,11 +212,11 @@ const Preview: React.FC<{
return (
<Audio
src={media.get('url') as string}
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
poster={
(media.get('preview_url') as string | undefined) ??
account?.avatar_static
}
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string
}

View File

@ -1,588 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { is } from 'immutable';
import { throttle, debounce } from 'lodash';
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
import PauseIcon from '@/material-icons/400-24px/pause.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
import { Blurhash } from '../../components/blurhash';
import { displayMedia, useBlurhash } from '../../initial_state';
import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
download: { id: 'video.download', defaultMessage: 'Download file' },
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
});
const TICK_SIZE = 10;
const PADDING = 180;
class Audio extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
lang: PropTypes.string,
poster: PropTypes.string,
duration: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
editable: PropTypes.bool,
fullscreen: PropTypes.bool,
intl: PropTypes.object.isRequired,
blurhash: PropTypes.string,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
matchedFilters: PropTypes.arrayOf(PropTypes.string),
};
state = {
width: this.props.width,
currentTime: 0,
buffer: 0,
duration: null,
paused: true,
muted: false,
volume: 1,
dragging: false,
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
};
constructor (props) {
super(props);
this.visualizer = new Visualizer(TICK_SIZE);
}
setPlayerRef = c => {
this.player = c;
if (this.player) {
this._setDimensions();
}
};
_pack() {
return {
src: this.props.src,
volume: this.state.volume,
muted: this.state.muted,
currentTime: this.audio.currentTime,
poster: this.props.poster,
backgroundColor: this.props.backgroundColor,
foregroundColor: this.props.foregroundColor,
accentColor: this.props.accentColor,
sensitive: this.props.sensitive,
visible: this.props.visible,
};
}
_setDimensions () {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width, height });
}
setSeekRef = c => {
this.seek = c;
};
setVolumeRef = c => {
this.volume = c;
};
setAudioRef = c => {
this.audio = c;
if (this.audio) {
this.audio.volume = 1;
this.audio.muted = false;
}
};
setCanvasRef = c => {
this.canvas = c;
this.visualizer.setCanvas(c);
};
componentDidMount () {
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentDidUpdate (prevProps, prevState) {
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
this._clear();
this._draw();
}
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ revealed: nextProps.visible });
}
}
componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
}
togglePlay = () => {
if (!this.audioContext) {
this._initAudioContext();
}
if (this.state.paused) {
this.setState({ paused: false }, () => this.audio.play());
} else {
this.setState({ paused: true }, () => this.audio.pause());
}
};
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePlay = () => {
this.setState({ paused: false });
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
this._renderCanvas();
};
handlePause = () => {
this.setState({ paused: true });
if (this.audioContext) {
this.audioContext.suspend();
}
};
handleProgress = () => {
const lastTimeRange = this.audio.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
}
};
toggleMute = () => {
const muted = !(this.state.muted || this.state.volume === 0);
this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
}
});
};
toggleReveal = () => {
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
} else {
this.setState({ revealed: !this.state.revealed });
}
};
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
this.handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
};
handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
};
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('mouseup', this.handleMouseUp, true);
document.addEventListener('touchmove', this.handleMouseMove, true);
document.addEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: true });
this.audio.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
};
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('mouseup', this.handleMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseMove, true);
document.removeEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: false });
this.audio.play();
};
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = this.audio.duration * x;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}, 15);
handleTimeUpdate = () => {
this.setState({
currentTime: this.audio.currentTime,
duration: this.audio.duration,
});
};
handleMouseVolSlide = throttle(e => {
const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) {
this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : x;
}
});
}
}, 15);
handleScroll = throttle(() => {
if (!this.canvas || !this.audio) {
return;
}
const { top, height } = this.canvas.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
this.audio.pause();
if (this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
this.setState({ paused: true });
}
}, 150, { trailing: true });
handleMouseEnter = () => {
this.setState({ hovered: true });
};
handleMouseLeave = () => {
this.setState({ hovered: false });
};
handleLoadedData = () => {
const { autoPlay, currentTime } = this.props;
if (currentTime) {
this.audio.currentTime = currentTime;
}
if (autoPlay) {
this.togglePlay();
}
};
_initAudioContext () {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
const source = context.createMediaElementSource(this.audio);
const gainNode = context.createGain();
gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
this.visualizer.setAudioContext(context, source);
source.connect(gainNode);
gainNode.connect(context.destination);
this.audioContext = context;
this.gainNode = gainNode;
}
handleDownload = () => {
fetch(this.props.src).then(res => res.blob()).then(blob => {
const element = document.createElement('a');
const objectURL = URL.createObjectURL(blob);
element.setAttribute('href', objectURL);
element.setAttribute('download', fileNameFromURL(this.props.src));
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(objectURL);
}).catch(err => {
console.error(err);
});
};
_renderCanvas () {
requestAnimationFrame(() => {
if (!this.audio) return;
this.handleTimeUpdate();
this._clear();
this._draw();
if (!this.state.paused) {
this._renderCanvas();
}
});
}
_clear() {
this.visualizer.clear(this.state.width, this.state.height);
}
_draw() {
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
}
_getRadius () {
return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
}
_getScaleCoefficient () {
return (this.state.height || this.props.height) / 982;
}
_getCX() {
return Math.floor(this.state.width / 2);
}
_getCY() {
return Math.floor((this.state.height || this.props.height) / 2);
}
_getAccentColor () {
return this.props.accentColor || '#ffffff';
}
_getBackgroundColor () {
return this.props.backgroundColor || '#000000';
}
_getForegroundColor () {
return this.props.foregroundColor || '#ffffff';
}
seekBy (time) {
const currentTime = this.audio.currentTime + time;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}
handleAudioKeyDown = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
this.togglePlay();
}
};
handleKeyDown = e => {
switch(e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
this.togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
this.toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
this.seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
this.seekBy(10);
break;
}
};
render () {
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
const muted = this.state.muted || volume === 0;
return (
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
<Blurhash
hash={blurhash}
className={classNames('media-gallery__preview', {
'media-gallery__preview--hidden': revealed,
})}
dummy={!useBlurhash}
/>
{(revealed || editable) && <audio
src={src}
ref={this.setAudioRef}
preload={autoPlay ? 'auto' : 'none'}
onPlay={this.handlePlay}
onPause={this.handlePause}
onProgress={this.handleProgress}
onLoadedData={this.handleLoadedData}
crossOrigin='anonymous'
/>}
<canvas
role='button'
tabIndex={0}
className='audio-player__canvas'
width={this.state.width}
height={this.state.height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={this.setCanvasRef}
onClick={this.togglePlay}
onKeyDown={this.handleAudioKeyDown}
title={alt}
aria-label={alt}
lang={lang}
/>
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
{(revealed || editable) && <img
src={this.props.poster}
alt=''
style={{
position: 'absolute',
left: '50%',
top: '50%',
height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
aspectRatio: '1',
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
pointerEvents: 'none',
}}
/>}
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex={0}
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
onKeyDown={this.handleAudioKeyDown}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} icon={paused ? PlayArrowIcon : PauseIcon} /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} icon={muted ? VolumeOffIcon : VolumeUpIcon} /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span
className='video-player__volume__handle'
tabIndex={0}
style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }}
/>
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
</span>
</div>
<div className='video-player__buttons right'>
{!editable && (
<>
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id='download' icon={DownloadIcon} />
</a>
</>
)}
</div>
</div>
</div>
</div>
);
}
}
export default injectIntl(Audio);

View File

@ -0,0 +1,840 @@
import { useEffect, useRef, useCallback, useState, useId } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useSpring, animated, config } from '@react-spring/web';
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime, getPointerPosition } from 'mastodon/features/video';
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
import {
displayMedia,
useBlurhash,
reduceMotion,
} from 'mastodon/initial_state';
import { playerSettings } from 'mastodon/settings';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
download: { id: 'video.download', defaultMessage: 'Download file' },
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
});
const persistVolume = (volume: number, muted: boolean) => {
playerSettings.set('volume', volume);
playerSettings.set('muted', muted);
};
const restoreVolume = (audio: HTMLAudioElement) => {
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
audio.volume = volume;
audio.muted = muted;
};
const HOVER_FADE_DELAY = 4000;
export const Audio: React.FC<{
src: string;
alt?: string;
lang?: string;
poster?: string;
sensitive?: boolean;
editable?: boolean;
blurhash?: string;
visible?: boolean;
duration?: number;
onToggleVisibility?: () => void;
backgroundColor?: string;
foregroundColor?: string;
accentColor?: string;
startTime?: number;
startPlaying?: boolean;
startVolume?: number;
startMuted?: boolean;
deployPictureInPicture?: (
type: string,
mediaProps: {
src: string;
muted: boolean;
volume: number;
currentTime: number;
poster?: string;
backgroundColor: string;
foregroundColor: string;
accentColor: string;
},
) => void;
matchedFilters?: string[];
}> = ({
src,
alt,
lang,
poster,
duration,
sensitive,
editable,
blurhash,
visible,
onToggleVisibility,
backgroundColor = '#000000',
foregroundColor = '#ffffff',
accentColor = '#ffffff',
startTime,
startPlaying,
startVolume,
startMuted,
deployPictureInPicture,
matchedFilters,
}) => {
const intl = useIntl();
const [currentTime, setCurrentTime] = useState(0);
const [loadedDuration, setDuration] = useState(duration ?? 0);
const [paused, setPaused] = useState(true);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(0.5);
const [hovered, setHovered] = useState(false);
const [dragging, setDragging] = useState(false);
const [revealed, setRevealed] = useState(false);
const playerRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const seekRef = useRef<HTMLDivElement>(null);
const volumeRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
audioRef,
3,
);
const accessibilityId = useId();
const [style, spring] = useSpring(() => ({
progress: '0%',
buffer: '0%',
volume: '0%',
}));
const handleAudioRef = useCallback(
(c: HTMLVideoElement | null) => {
if (audioRef.current && !audioRef.current.paused && c === null) {
deployPictureInPicture?.('audio', {
src,
poster,
backgroundColor,
foregroundColor,
accentColor,
currentTime: audioRef.current.currentTime,
muted: audioRef.current.muted,
volume: audioRef.current.volume,
});
}
audioRef.current = c;
if (audioRef.current) {
restoreVolume(audioRef.current);
setVolume(audioRef.current.volume);
setMuted(audioRef.current.muted);
void spring.start({
volume: `${audioRef.current.volume * 100}%`,
immediate: reduceMotion,
});
}
},
[
spring,
setVolume,
setMuted,
src,
poster,
backgroundColor,
accentColor,
foregroundColor,
deployPictureInPicture,
],
);
useEffect(() => {
if (!audioRef.current) {
return;
}
audioRef.current.volume = volume;
audioRef.current.muted = muted;
}, [volume, muted]);
useEffect(() => {
if (typeof visible !== 'undefined') {
setRevealed(visible);
} else {
setRevealed(
displayMedia === 'show_all' ||
(displayMedia !== 'hide_all' && !sensitive),
);
}
}, [visible, sensitive]);
useEffect(() => {
if (!revealed && audioRef.current) {
audioRef.current.pause();
suspendAudio();
}
}, [suspendAudio, revealed]);
useEffect(() => {
let nextFrame: ReturnType<typeof requestAnimationFrame>;
const updateProgress = () => {
nextFrame = requestAnimationFrame(() => {
if (audioRef.current && audioRef.current.duration > 0) {
void spring.start({
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
immediate: reduceMotion,
config: config.stiff,
});
}
updateProgress();
});
};
updateProgress();
return () => {
cancelAnimationFrame(nextFrame);
};
}, [spring]);
const togglePlay = useCallback(() => {
if (!audioRef.current) {
return;
}
if (audioRef.current.paused) {
resumeAudio();
void audioRef.current.play();
} else {
audioRef.current.pause();
suspendAudio();
}
}, [resumeAudio, suspendAudio]);
const handlePlay = useCallback(() => {
setPaused(false);
}, []);
const handlePause = useCallback(() => {
setPaused(true);
}, []);
const handleProgress = useCallback(() => {
if (!audioRef.current) {
return;
}
const lastTimeRange = audioRef.current.buffered.length - 1;
if (lastTimeRange > -1) {
void spring.start({
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
immediate: reduceMotion,
});
}
}, [spring]);
const handleVolumeChange = useCallback(() => {
if (!audioRef.current) {
return;
}
setVolume(audioRef.current.volume);
setMuted(audioRef.current.muted);
void spring.start({
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
immediate: reduceMotion,
});
persistVolume(audioRef.current.volume, audioRef.current.muted);
}, [spring, setVolume, setMuted]);
const handleTimeUpdate = useCallback(() => {
if (!audioRef.current) {
return;
}
setCurrentTime(audioRef.current.currentTime);
}, [setCurrentTime]);
const toggleMute = useCallback(() => {
if (!audioRef.current) {
return;
}
const effectivelyMuted =
audioRef.current.muted || audioRef.current.volume === 0;
if (effectivelyMuted) {
audioRef.current.muted = false;
if (audioRef.current.volume === 0) {
audioRef.current.volume = 0.05;
}
} else {
audioRef.current.muted = true;
}
}, []);
const toggleReveal = useCallback(() => {
if (onToggleVisibility) {
onToggleVisibility();
} else {
setRevealed((value) => !value);
}
}, [onToggleVisibility, setRevealed]);
const handleVolumeMouseDown = useCallback(
(e: React.MouseEvent) => {
const handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', handleVolumeMouseMove, true);
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
};
const handleVolumeMouseMove = (e: MouseEvent) => {
if (!volumeRef.current || !audioRef.current) {
return;
}
const { x } = getPointerPosition(volumeRef.current, e);
if (!isNaN(x)) {
audioRef.current.volume = x;
audioRef.current.muted = x > 0 ? false : true;
void spring.start({ volume: `${x * 100}%`, immediate: true });
}
};
document.addEventListener('mousemove', handleVolumeMouseMove, true);
document.addEventListener('mouseup', handleVolumeMouseUp, true);
handleVolumeMouseMove(e.nativeEvent);
e.preventDefault();
e.stopPropagation();
},
[spring],
);
const handleSeekMouseDown = useCallback(
(e: React.MouseEvent) => {
const handleSeekMouseUp = () => {
document.removeEventListener('mousemove', handleSeekMouseMove, true);
document.removeEventListener('mouseup', handleSeekMouseUp, true);
setDragging(false);
resumeAudio();
void audioRef.current?.play();
};
const handleSeekMouseMove = (e: MouseEvent) => {
if (!seekRef.current || !audioRef.current) {
return;
}
const { x } = getPointerPosition(seekRef.current, e);
const newTime = audioRef.current.duration * x;
if (!isNaN(newTime)) {
audioRef.current.currentTime = newTime;
void spring.start({ progress: `${x * 100}%`, immediate: true });
}
};
document.addEventListener('mousemove', handleSeekMouseMove, true);
document.addEventListener('mouseup', handleSeekMouseUp, true);
setDragging(true);
audioRef.current?.pause();
handleSeekMouseMove(e.nativeEvent);
e.preventDefault();
e.stopPropagation();
},
[setDragging, spring, resumeAudio],
);
const handleMouseEnter = useCallback(() => {
setHovered(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setHovered(false);
}, HOVER_FADE_DELAY);
}, [setHovered]);
const handleMouseMove = useCallback(() => {
setHovered(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setHovered(false);
}, HOVER_FADE_DELAY);
}, [setHovered]);
const handleMouseLeave = useCallback(() => {
setHovered(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}, [setHovered]);
const handleTouchEnd = useCallback(() => {
setHovered(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setHovered(false);
}, HOVER_FADE_DELAY);
}, [setHovered]);
const handleLoadedData = useCallback(() => {
if (!audioRef.current) {
return;
}
setDuration(audioRef.current.duration);
if (typeof startTime !== 'undefined') {
audioRef.current.currentTime = startTime;
}
if (typeof startVolume !== 'undefined') {
audioRef.current.volume = startVolume;
}
if (typeof startMuted !== 'undefined') {
audioRef.current.muted = startMuted;
}
if (startPlaying) {
void audioRef.current.play();
}
}, [setDuration, startTime, startVolume, startMuted, startPlaying]);
const seekBy = (time: number) => {
if (!audioRef.current) {
return;
}
const newTime = audioRef.current.currentTime + time;
if (!isNaN(newTime)) {
audioRef.current.currentTime = newTime;
}
};
const handleAudioKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
togglePlay();
}
},
[togglePlay],
);
const handleSkipBackward = useCallback(() => {
seekBy(-5);
}, []);
const handleSkipForward = useCallback(() => {
seekBy(5);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const updateVolumeBy = (step: number) => {
if (!audioRef.current) {
return;
}
const newVolume = audioRef.current.volume + step;
if (!isNaN(newVolume)) {
audioRef.current.volume = newVolume;
audioRef.current.muted = newVolume > 0 ? false : true;
}
};
switch (e.key) {
case 'k':
case ' ':
e.preventDefault();
e.stopPropagation();
togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
toggleMute();
break;
case 'j':
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
seekBy(-5);
break;
case 'l':
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
seekBy(5);
break;
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
updateVolumeBy(0.15);
break;
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
updateVolumeBy(-0.15);
break;
}
},
[togglePlay, toggleMute],
);
const springForBand0 = useSpring({
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand1 = useSpring({
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand2 = useSpring({
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
config: config.wobbly,
});
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
const effectivelyMuted = muted || volume === 0;
return (
<div
className={classNames('audio-player', { inactive: !revealed })}
ref={playerRef}
style={
{
'--player-background-color': backgroundColor,
'--player-foreground-color': foregroundColor,
'--player-accent-color': accentColor,
} as React.CSSProperties
}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onTouchEnd={handleTouchEnd}
role='button'
tabIndex={0}
onKeyDownCapture={handleKeyDown}
aria-label={alt}
lang={lang}
>
{blurhash && (
<Blurhash
hash={blurhash}
className={classNames('media-gallery__preview', {
'media-gallery__preview--hidden': revealed,
})}
dummy={!useBlurhash}
/>
)}
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
src={src}
ref={handleAudioRef}
preload={startPlaying ? 'auto' : 'none'}
onPlay={handlePlay}
onPause={handlePause}
onProgress={handleProgress}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onVolumeChange={handleVolumeChange}
crossOrigin='anonymous'
/>
<div
className='video-player__seek'
aria-valuemin={0}
aria-valuenow={progress}
aria-valuemax={100}
onMouseDown={handleSeekMouseDown}
onKeyDownCapture={handleAudioKeyDown}
ref={seekRef}
role='slider'
tabIndex={0}
>
<animated.div
className='video-player__seek__buffer'
style={{ width: style.buffer }}
/>
<animated.div
className='video-player__seek__progress'
style={{ width: style.progress }}
/>
<animated.span
className={classNames('video-player__seek__handle', {
active: dragging,
})}
style={{ left: style.progress }}
/>
</div>
<div className='audio-player__controls'>
<div className='audio-player__controls__play'>
<button
type='button'
title={intl.formatMessage(messages.skipBackward)}
aria-label={intl.formatMessage(messages.skipBackward)}
className='player-button'
onClick={handleSkipBackward}
>
<Icon id='' icon={Replay5Icon} />
</button>
</div>
<div className='audio-player__controls__play'>
<svg
className='audio-player__visualizer'
viewBox='0 0 124 124'
xmlns='http://www.w3.org/2000/svg'
>
<animated.circle
opacity={0.5}
cx={57}
cy={62.5}
r={springForBand0.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={65}
cy={57.5}
r={springForBand1.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={63}
cy={66.5}
r={springForBand2.r}
fill='var(--player-accent-color)'
/>
<g clipPath={`url(#${accessibilityId}-clip)`}>
<rect
x={14}
y={14}
width={96}
height={96}
fill={`url(#${accessibilityId}-pattern)`}
/>
<rect
x={14}
y={14}
width={96}
height={96}
fill='var(--player-background-color'
opacity={0.45}
/>
</g>
<defs>
<pattern
id={`${accessibilityId}-pattern`}
patternContentUnits='objectBoundingBox'
width='1'
height='1'
>
<use href={`#${accessibilityId}-image`} />
</pattern>
<clipPath id={`${accessibilityId}-clip`}>
<rect
x={14}
y={14}
width={96}
height={96}
rx={48}
fill='white'
/>
</clipPath>
<image
id={`${accessibilityId}-image`}
href={poster}
width={1}
height={1}
preserveAspectRatio='none'
/>
</defs>
</svg>
<button
type='button'
title={intl.formatMessage(paused ? messages.play : messages.pause)}
aria-label={intl.formatMessage(
paused ? messages.play : messages.pause,
)}
className='player-button'
onClick={togglePlay}
>
<Icon
id={paused ? 'play' : 'pause'}
icon={paused ? PlayArrowIcon : PauseIcon}
/>
</button>
</div>
<div className='audio-player__controls__play'>
<button
type='button'
title={intl.formatMessage(messages.skipForward)}
aria-label={intl.formatMessage(messages.skipForward)}
className='player-button'
onClick={handleSkipForward}
>
<Icon id='' icon={Forward5Icon} />
</button>
</div>
</div>
<SpoilerButton
hidden={revealed || editable}
sensitive={sensitive ?? false}
onClick={toggleReveal}
matchedFilters={matchedFilters}
/>
<div
className={classNames('video-player__controls', { active: hovered })}
>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button
type='button'
title={intl.formatMessage(
muted ? messages.unmute : messages.mute,
)}
aria-label={intl.formatMessage(
muted ? messages.unmute : messages.mute,
)}
className='player-button'
onClick={toggleMute}
>
<Icon
id={muted ? 'volume-off' : 'volume-up'}
icon={muted ? VolumeOffIcon : VolumeUpIcon}
/>
</button>
<div
className='video-player__volume active'
ref={volumeRef}
onMouseDown={handleVolumeMouseDown}
role='slider'
aria-valuemin={0}
aria-valuenow={effectivelyMuted ? 0 : volume * 100}
aria-valuemax={100}
tabIndex={0}
>
<animated.div
className='video-player__volume__current'
style={{ width: style.volume }}
/>
<animated.span
className={classNames('video-player__volume__handle')}
style={{ left: style.volume }}
/>
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>
{formatTime(Math.floor(currentTime))}
</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>
{formatTime(Math.floor(loadedDuration))}
</span>
</span>
</div>
<div className='video-player__buttons right'>
{!editable && (
<>
<button
type='button'
className='player-button'
onClick={toggleReveal}
>
<FormattedMessage
id='media_gallery.hide'
defaultMessage='Hide'
/>
</button>
<a
title={intl.formatMessage(messages.download)}
aria-label={intl.formatMessage(messages.download)}
className='video-player__download__icon player-button'
href={src}
download
>
<Icon id='download' icon={DownloadIcon} />
</a>
</>
)}
</div>
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default Audio;

View File

@ -1,136 +0,0 @@
/*
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const hex2rgba = (hex, alpha = 1) => {
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export default class Visualizer {
constructor (tickSize) {
this.tickSize = tickSize;
}
setCanvas(canvas) {
this.canvas = canvas;
if (canvas) {
this.context = canvas.getContext('2d');
}
}
setAudioContext(context, source) {
const analyser = context.createAnalyser();
analyser.smoothingTimeConstant = 0.6;
analyser.fftSize = 2048;
source.connect(analyser);
this.analyser = analyser;
}
getTickPoints (count) {
const coords = [];
for(let i = 0; i < count; i++) {
const rad = Math.PI * 2 * i / count;
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
}
return coords;
}
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
const dx1 = Math.ceil(cx + x1);
const dy1 = Math.ceil(cy + y1);
const dx2 = Math.ceil(cx + x2);
const dy2 = Math.ceil(cy + y2);
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
const lastColor = hex2rgba(mainColor, 0);
gradient.addColorStop(0, mainColor);
gradient.addColorStop(0.6, mainColor);
gradient.addColorStop(1, lastColor);
this.context.beginPath();
this.context.strokeStyle = gradient;
this.context.lineWidth = 2;
this.context.moveTo(dx1, dy1);
this.context.lineTo(dx2, dy2);
this.context.stroke();
}
getTicks (count, size, radius, scaleCoefficient) {
const ticks = this.getTickPoints(count);
const lesser = 200;
const m = [];
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
const frequencyData = new Uint8Array(bufferLength);
const allScales = [];
if (this.analyser) {
this.analyser.getByteFrequencyData(frequencyData);
}
ticks.forEach((tick, i) => {
const coef = 1 - i / (ticks.length * 2.5);
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
if (delta < 0) {
delta = 0;
}
const k = radius / (radius - (size + delta));
const x1 = tick.x * (radius - size);
const y1 = tick.y * (radius - size);
const x2 = x1 * k;
const y2 = y1 * k;
m.push({ x1, y1, x2, y2 });
if (i < 20) {
let scale = delta / (200 * scaleCoefficient);
scale = scale < 1 ? 1 : scale;
allScales.push(scale);
}
});
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
return m.map(({ x1, y1, x2, y2 }) => ({
x1: x1,
y1: y1,
x2: x2 * scale,
y2: y2 * scale,
}));
}
clear (width, height) {
this.context.clearRect(0, 0, width, height);
}
draw (cx, cy, color, radius, coefficient) {
this.context.save();
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
ticks.forEach(tick => {
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
});
this.context.restore();
}
}

View File

@ -9,7 +9,6 @@ import { useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
preferences: {
id: 'navigation_bar.preferences',
defaultMessage: 'Preferences',
@ -53,7 +52,6 @@ export const ActionBar: React.FC = () => {
text: intl.formatMessage(messages.preferences),
href: '/settings/preferences',
},
{ text: intl.formatMessage(messages.pins), to: '/pinned' },
null,
{
text: intl.formatMessage(messages.follow_requests),

View File

@ -40,7 +40,7 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15.png`;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15_1.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>

View File

@ -30,9 +30,6 @@ const messages = defineMessages({
class PrivacyDropdown extends PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,

View File

@ -15,16 +15,6 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal({
modalType: 'ACTIONS',
modalProps: props,
})),
onModalClose: () => dispatch(closeModal({
modalType: undefined,
ignoreFocus: false,
})),
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

View File

@ -130,6 +130,7 @@ export const Directory: React.FC<{
}, [dispatch, order, local]);
const pinned = !!columnId;
const initialLoad = isLoading && accountIds.size === 0;
const scrollableArea = (
<div className='scrollable'>
@ -170,7 +171,7 @@ export const Directory: React.FC<{
</div>
<div className='directory__list'>
{isLoading ? (
{initialLoad ? (
<LoadingIndicator />
) : (
accountIds.map((accountId) => (
@ -179,7 +180,11 @@ export const Directory: React.FC<{
)}
</div>
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
<LoadMore
onClick={handleLoadMore}
visible={!initialLoad}
loading={isLoading}
/>
</div>
);

View File

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

File diff suppressed because one or more lines are too long

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