diff --git a/.prettierignore b/.prettierignore index b487a4c856..e6859a850d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/Gemfile b/Gemfile index b55ec1d730..44c4c9a54d 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 4eb975c6c0..8f5d3b021c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index 59f2f991f2..796584e9c6 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -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), diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 0980e0ebbc..b10c2f5737 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/featured_tags_controller.rb b/app/controllers/api/v1/featured_tags_controller.rb index 516046f009..15c5de67a2 100644 --- a/app/controllers/api/v1/featured_tags_controller.rb +++ b/app/controllers/api/v1/featured_tags_controller.rb @@ -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 diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb index 672535a018..67a4d8ef49 100644 --- a/app/controllers/api/v1/tags_controller.rb +++ b/app/controllers/api/v1/tags_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f9c06cb904..f8ce94a7d2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index 0f352e1913..d2fbd1f0f3 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -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 diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index b36e25c092..22d1964cae 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -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 diff --git a/app/javascript/flavours/glitch/actions/accounts_typed.ts b/app/javascript/flavours/glitch/actions/accounts_typed.ts index d9097591e0..e49c3043fb 100644 --- a/app/javascript/flavours/glitch/actions/accounts_typed.ts +++ b/app/javascript/flavours/glitch/actions/accounts_typed.ts @@ -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: 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; + }, +); diff --git a/app/javascript/flavours/glitch/actions/featured_tags.js b/app/javascript/flavours/glitch/actions/featured_tags.js deleted file mode 100644 index 6ee4dee2bc..0000000000 --- a/app/javascript/flavours/glitch/actions/featured_tags.js +++ /dev/null @@ -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, -}); diff --git a/app/javascript/flavours/glitch/actions/featured_tags.ts b/app/javascript/flavours/glitch/actions/featured_tags.ts new file mode 100644 index 0000000000..c800b8d9c3 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/featured_tags.ts @@ -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), +); diff --git a/app/javascript/flavours/glitch/actions/importer/accounts.ts b/app/javascript/flavours/glitch/actions/importer/accounts.ts new file mode 100644 index 0000000000..02a038af3e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/importer/accounts.ts @@ -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', +); diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js index 0212b3a2f7..c1db557115 100644 --- a/app/javascript/flavours/glitch/actions/importer/index.js +++ b/app/javascript/flavours/glitch/actions/importer/index.js @@ -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'; diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js index 02c8e70df0..fe4a12ed74 100644 --- a/app/javascript/flavours/glitch/actions/statuses.js +++ b/app/javascript/flavours/glitch/actions/statuses.js @@ -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)); diff --git a/app/javascript/flavours/glitch/actions/statuses_typed.ts b/app/javascript/flavours/glitch/actions/statuses_typed.ts new file mode 100644 index 0000000000..840dba5bc1 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/statuses_typed.ts @@ -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, + }; + }, +); diff --git a/app/javascript/flavours/glitch/actions/tags_typed.ts b/app/javascript/flavours/glitch/actions/tags_typed.ts index 81ab4ae437..5d1ab026ee 100644 --- a/app/javascript/flavours/glitch/actions/tags_typed.ts +++ b/app/javascript/flavours/glitch/actions/tags_typed.ts @@ -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), +); diff --git a/app/javascript/flavours/glitch/api/accounts.ts b/app/javascript/flavours/glitch/api/accounts.ts index 8b02ca06d0..fb77f7916f 100644 --- a/app/javascript/flavours/glitch/api/accounts.ts +++ b/app/javascript/flavours/glitch/api/accounts.ts @@ -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(`v1/accounts/${id}/note`, { @@ -23,3 +25,9 @@ export const apiRemoveAccountFromFollowers = (id: string) => apiRequestPost( `v1/accounts/${id}/remove_from_followers`, ); + +export const apiGetFeaturedTags = (id: string) => + apiRequestGet(`v1/accounts/${id}/featured_tags`); + +export const apiGetEndorsedAccounts = (id: string) => + apiRequestGet(`v1/accounts/${id}/endorsements`); diff --git a/app/javascript/flavours/glitch/api/statuses.ts b/app/javascript/flavours/glitch/api/statuses.ts new file mode 100644 index 0000000000..3b6053a858 --- /dev/null +++ b/app/javascript/flavours/glitch/api/statuses.ts @@ -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(`v1/statuses/${statusId}/context`); diff --git a/app/javascript/flavours/glitch/api/tags.ts b/app/javascript/flavours/glitch/api/tags.ts index eb2dba3536..2eb53a98db 100644 --- a/app/javascript/flavours/glitch/api/tags.ts +++ b/app/javascript/flavours/glitch/api/tags.ts @@ -14,6 +14,12 @@ export const apiFollowTag = (tagId: string) => export const apiUnfollowTag = (tagId: string) => apiRequestPost(`v1/tags/${tagId}/unfollow`); +export const apiFeatureTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/feature`); + +export const apiUnfeatureTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/unfeature`); + export const apiGetFollowedTags = async (url?: string) => { const response = await api().request({ method: 'GET', diff --git a/app/javascript/flavours/glitch/api_types/statuses.ts b/app/javascript/flavours/glitch/api_types/statuses.ts index c84ecc7afc..fcdcef1c70 100644 --- a/app/javascript/flavours/glitch/api_types/statuses.ts +++ b/app/javascript/flavours/glitch/api_types/statuses.ts @@ -123,3 +123,8 @@ export interface ApiStatusJSON { local_only?: boolean; content_type?: string; } + +export interface ApiContextJSON { + ancestors: ApiStatusJSON[]; + descendants: ApiStatusJSON[]; +} diff --git a/app/javascript/flavours/glitch/api_types/tags.ts b/app/javascript/flavours/glitch/api_types/tags.ts index 0c16c8bd28..3066b4f1f1 100644 --- a/app/javascript/flavours/glitch/api_types/tags.ts +++ b/app/javascript/flavours/glitch/api_types/tags.ts @@ -10,4 +10,5 @@ export interface ApiHashtagJSON { url: string; history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; following?: boolean; + featuring?: boolean; } diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.tsx b/app/javascript/flavours/glitch/components/dropdown_menu.tsx index 64745ba8c9..cb03204a5f 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.tsx +++ b/app/javascript/flavours/glitch/components/dropdown_menu.tsx @@ -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: Item, index: number, @@ -320,6 +297,7 @@ interface DropdownProps { scrollable?: boolean; scrollKey?: string; status?: ImmutableMap; + forceDropdown?: boolean; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: () => void; @@ -339,6 +317,7 @@ export const Dropdown = ({ disabled, scrollable, status, + forceDropdown = false, renderItem, renderHeader, onOpen, @@ -354,6 +333,9 @@ export const Dropdown = ({ const open = currentId === openDropdownId; const activeElement = useRef(null); const targetRef = useRef(null); + const prefetchAccountId = status + ? status.getIn(['account', 'id']) + : undefined; const handleClose = useCallback(() => { if (activeElement.current) { @@ -402,16 +384,15 @@ export const Dropdown = ({ } 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 = ({ [ dispatch, currentId, + prefetchAccountId, scrollKey, onOpen, handleItemClick, open, - status, items, + forceDropdown, handleClose, ], ); diff --git a/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx b/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx index e094f93bac..2cc9219fb5 100644 --- a/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx +++ b/app/javascript/flavours/glitch/components/edited_timestamp/index.tsx @@ -116,6 +116,7 @@ export const EditedTimestamp: React.FC<{ renderHeader={renderHeader} onOpen={handleOpen} onItemClick={handleItemClick} + forceDropdown > ); }; diff --git a/app/javascript/flavours/glitch/components/status_prepend.jsx b/app/javascript/flavours/glitch/components/status_prepend.jsx index 7ed5416d38..60dd0a4ce7 100644 --- a/app/javascript/flavours/glitch/components/status_prepend.jsx +++ b/app/javascript/flavours/glitch/components/status_prepend.jsx @@ -45,10 +45,6 @@ export default class StatusPrepend extends PureComponent { ); switch (type) { - case 'featured': - return ( - - ); case 'reblogged_by': return ( { 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'; diff --git a/app/javascript/flavours/glitch/features/account_featured/index.tsx b/app/javascript/flavours/glitch/features/account_featured/index.tsx index b07027c50c..1efa7bdd6c 100644 --- a/app/javascript/flavours/glitch/features/account_featured/index.tsx +++ b/app/javascript/flavours/glitch/features/account_featured/index.tsx @@ -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, ); + const featuredAccountIds = useAppSelector( + (state) => + state.user_lists.getIn( + ['featured_accounts', accountId, 'items'], + ImmutableList(), + ) as ImmutableList, + ); + + if (accountId === null) { + return ; + } if (isLoading) { return ( @@ -78,7 +94,11 @@ const AccountFeatured = () => { ); } - if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) { + if ( + featuredStatusIds.isEmpty() && + featuredTags.isEmpty() && + featuredAccountIds.isEmpty() + ) { return ( { ))} )} + {!featuredAccountIds.isEmpty() && ( + <> +

+ +

+ {featuredAccountIds.map((featuredAccountId) => ( + + ))} + + )} diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.tsx b/app/javascript/flavours/glitch/features/account_gallery/index.tsx index 8e48334d87..14c331a9b7 100644 --- a/app/javascript/flavours/glitch/features/account_gallery/index.tsx +++ b/app/javascript/flavours/glitch/features/account_gallery/index.tsx @@ -152,7 +152,7 @@ export const AccountGallery: React.FC<{ [dispatch], ); - if (accountId && !isAccount) { + if (accountId === null) { return ; } diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx index 2af8fab446..cbb91935ef 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx @@ -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), diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.jsx b/app/javascript/flavours/glitch/features/account_timeline/index.jsx index 212ad79dc8..e75f0d0c27 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/account_timeline/index.jsx @@ -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 })); } diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.tsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.tsx index 6312da171d..55d39a0c6e 100644 --- a/app/javascript/flavours/glitch/features/compose/components/action_bar.tsx +++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.tsx @@ -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), diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx index 256146a77a..3a1c5ea384 100644 --- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx @@ -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 = () => (
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx index 4a7cc2e9a9..6b0c6db3ec 100644 --- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx @@ -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, diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js index 6d26abf4f6..6d3eef13aa 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js @@ -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); diff --git a/app/javascript/flavours/glitch/features/directory/index.tsx b/app/javascript/flavours/glitch/features/directory/index.tsx index d7ab68ffe6..689f924b8f 100644 --- a/app/javascript/flavours/glitch/features/directory/index.tsx +++ b/app/javascript/flavours/glitch/features/directory/index.tsx @@ -133,6 +133,7 @@ export const Directory: React.FC<{ }, [dispatch, order, local]); const pinned = !!columnId; + const initialLoad = isLoading && accountIds.size === 0; const scrollableArea = (
@@ -173,7 +174,7 @@ export const Directory: React.FC<{
- {isLoading ? ( + {initialLoad ? ( ) : ( accountIds.map((accountId) => ( @@ -182,7 +183,11 @@ export const Directory: React.FC<{ )}
- +
); diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js b/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js index 58f9bedbb8..adcc978d12 100644 --- a/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js +++ b/app/javascript/flavours/glitch/features/emoji/emoji_compressed.js @@ -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]; diff --git a/app/javascript/flavours/glitch/features/emoji/emoji_data.json b/app/javascript/flavours/glitch/features/emoji/emoji_data.json new file mode 100644 index 0000000000..4d7a48692b --- /dev/null +++ b/app/javascript/flavours/glitch/features/emoji/emoji_data.json @@ -0,0 +1 @@ +{"compressed":true,"categories":[{"id":"people","name":"Smileys & People","emojis":["grinning","smiley","smile","grin","laughing","sweat_smile","rolling_on_the_floor_laughing","joy","slightly_smiling_face","upside_down_face","melting_face","wink","blush","innocent","smiling_face_with_3_hearts","heart_eyes","star-struck","kissing_heart","kissing","relaxed","kissing_closed_eyes","kissing_smiling_eyes","smiling_face_with_tear","yum","stuck_out_tongue","stuck_out_tongue_winking_eye","zany_face","stuck_out_tongue_closed_eyes","money_mouth_face","hugging_face","face_with_hand_over_mouth","face_with_open_eyes_and_hand_over_mouth","face_with_peeking_eye","shushing_face","thinking_face","saluting_face","zipper_mouth_face","face_with_raised_eyebrow","neutral_face","expressionless","no_mouth","dotted_line_face","face_in_clouds","smirk","unamused","face_with_rolling_eyes","grimacing","face_exhaling","lying_face","shaking_face","head_shaking_horizontally","head_shaking_vertically","relieved","pensive","sleepy","drooling_face","sleeping","mask","face_with_thermometer","face_with_head_bandage","nauseated_face","face_vomiting","sneezing_face","hot_face","cold_face","woozy_face","dizzy_face","face_with_spiral_eyes","exploding_head","face_with_cowboy_hat","partying_face","disguised_face","sunglasses","nerd_face","face_with_monocle","confused","face_with_diagonal_mouth","worried","slightly_frowning_face","white_frowning_face","open_mouth","hushed","astonished","flushed","pleading_face","face_holding_back_tears","frowning","anguished","fearful","cold_sweat","disappointed_relieved","cry","sob","scream","confounded","persevere","disappointed","sweat","weary","tired_face","yawning_face","triumph","rage","angry","face_with_symbols_on_mouth","smiling_imp","imp","skull","skull_and_crossbones","hankey","clown_face","japanese_ogre","japanese_goblin","ghost","alien","space_invader","robot_face","smiley_cat","smile_cat","joy_cat","heart_eyes_cat","smirk_cat","kissing_cat","scream_cat","crying_cat_face","pouting_cat","see_no_evil","hear_no_evil","speak_no_evil","wave","raised_back_of_hand","raised_hand_with_fingers_splayed","hand","spock-hand","rightwards_hand","leftwards_hand","palm_down_hand","palm_up_hand","leftwards_pushing_hand","rightwards_pushing_hand","ok_hand","pinched_fingers","pinching_hand","v","crossed_fingers","hand_with_index_finger_and_thumb_crossed","i_love_you_hand_sign","the_horns","call_me_hand","point_left","point_right","point_up_2","middle_finger","point_down","point_up","index_pointing_at_the_viewer","+1","-1","fist","facepunch","left-facing_fist","right-facing_fist","clap","raised_hands","heart_hands","open_hands","palms_up_together","handshake","pray","writing_hand","nail_care","selfie","muscle","mechanical_arm","mechanical_leg","leg","foot","ear","ear_with_hearing_aid","nose","brain","anatomical_heart","lungs","tooth","bone","eyes","eye","tongue","lips","biting_lip","baby","child","boy","girl","adult","person_with_blond_hair","man","bearded_person","man_with_beard","woman_with_beard","red_haired_man","curly_haired_man","white_haired_man","bald_man","woman","red_haired_woman","red_haired_person","curly_haired_woman","curly_haired_person","white_haired_woman","white_haired_person","bald_woman","bald_person","blond-haired-woman","blond-haired-man","older_adult","older_man","older_woman","person_frowning","man-frowning","woman-frowning","person_with_pouting_face","man-pouting","woman-pouting","no_good","man-gesturing-no","woman-gesturing-no","ok_woman","man-gesturing-ok","woman-gesturing-ok","information_desk_person","man-tipping-hand","woman-tipping-hand","raising_hand","man-raising-hand","woman-raising-hand","deaf_person","deaf_man","deaf_woman","bow","man-bowing","woman-bowing","face_palm","man-facepalming","woman-facepalming","shrug","man-shrugging","woman-shrugging","health_worker","male-doctor","female-doctor","student","male-student","female-student","teacher","male-teacher","female-teacher","judge","male-judge","female-judge","farmer","male-farmer","female-farmer","cook","male-cook","female-cook","mechanic","male-mechanic","female-mechanic","factory_worker","male-factory-worker","female-factory-worker","office_worker","male-office-worker","female-office-worker","scientist","male-scientist","female-scientist","technologist","male-technologist","female-technologist","singer","male-singer","female-singer","artist","male-artist","female-artist","pilot","male-pilot","female-pilot","astronaut","male-astronaut","female-astronaut","firefighter","male-firefighter","female-firefighter","cop","male-police-officer","female-police-officer","sleuth_or_spy","male-detective","female-detective","guardsman","male-guard","female-guard","ninja","construction_worker","male-construction-worker","female-construction-worker","person_with_crown","prince","princess","man_with_turban","man-wearing-turban","woman-wearing-turban","man_with_gua_pi_mao","person_with_headscarf","person_in_tuxedo","man_in_tuxedo","woman_in_tuxedo","bride_with_veil","man_with_veil","woman_with_veil","pregnant_woman","pregnant_man","pregnant_person","breast-feeding","woman_feeding_baby","man_feeding_baby","person_feeding_baby","angel","santa","mrs_claus","mx_claus","superhero","male_superhero","female_superhero","supervillain","male_supervillain","female_supervillain","mage","male_mage","female_mage","fairy","male_fairy","female_fairy","vampire","male_vampire","female_vampire","merperson","merman","mermaid","elf","male_elf","female_elf","genie","male_genie","female_genie","zombie","male_zombie","female_zombie","troll","massage","man-getting-massage","woman-getting-massage","haircut","man-getting-haircut","woman-getting-haircut","walking","man-walking","woman-walking","person_walking_facing_right","woman_walking_facing_right","man_walking_facing_right","standing_person","man_standing","woman_standing","kneeling_person","man_kneeling","woman_kneeling","person_kneeling_facing_right","woman_kneeling_facing_right","man_kneeling_facing_right","person_with_probing_cane","person_with_white_cane_facing_right","man_with_probing_cane","man_with_white_cane_facing_right","woman_with_probing_cane","woman_with_white_cane_facing_right","person_in_motorized_wheelchair","person_in_motorized_wheelchair_facing_right","man_in_motorized_wheelchair","man_in_motorized_wheelchair_facing_right","woman_in_motorized_wheelchair","woman_in_motorized_wheelchair_facing_right","person_in_manual_wheelchair","person_in_manual_wheelchair_facing_right","man_in_manual_wheelchair","man_in_manual_wheelchair_facing_right","woman_in_manual_wheelchair","woman_in_manual_wheelchair_facing_right","runner","man-running","woman-running","person_running_facing_right","woman_running_facing_right","man_running_facing_right","dancer","man_dancing","man_in_business_suit_levitating","dancers","men-with-bunny-ears-partying","women-with-bunny-ears-partying","person_in_steamy_room","man_in_steamy_room","woman_in_steamy_room","person_climbing","man_climbing","woman_climbing","fencer","horse_racing","skier","snowboarder","golfer","man-golfing","woman-golfing","surfer","man-surfing","woman-surfing","rowboat","man-rowing-boat","woman-rowing-boat","swimmer","man-swimming","woman-swimming","person_with_ball","man-bouncing-ball","woman-bouncing-ball","weight_lifter","man-lifting-weights","woman-lifting-weights","bicyclist","man-biking","woman-biking","mountain_bicyclist","man-mountain-biking","woman-mountain-biking","person_doing_cartwheel","man-cartwheeling","woman-cartwheeling","wrestlers","man-wrestling","woman-wrestling","water_polo","man-playing-water-polo","woman-playing-water-polo","handball","man-playing-handball","woman-playing-handball","juggling","man-juggling","woman-juggling","person_in_lotus_position","man_in_lotus_position","woman_in_lotus_position","bath","sleeping_accommodation","people_holding_hands","two_women_holding_hands","man_and_woman_holding_hands","two_men_holding_hands","couplekiss","woman-kiss-man","man-kiss-man","woman-kiss-woman","couple_with_heart","woman-heart-man","man-heart-man","woman-heart-woman","man-woman-boy","man-woman-girl","man-woman-girl-boy","man-woman-boy-boy","man-woman-girl-girl","man-man-boy","man-man-girl","man-man-girl-boy","man-man-boy-boy","man-man-girl-girl","woman-woman-boy","woman-woman-girl","woman-woman-girl-boy","woman-woman-boy-boy","woman-woman-girl-girl","man-boy","man-boy-boy","man-girl","man-girl-boy","man-girl-girl","woman-boy","woman-boy-boy","woman-girl","woman-girl-boy","woman-girl-girl","speaking_head_in_silhouette","bust_in_silhouette","busts_in_silhouette","people_hugging","family","family_adult_adult_child","family_adult_adult_child_child","family_adult_child","family_adult_child_child","footprints","love_letter","cupid","gift_heart","sparkling_heart","heartpulse","heartbeat","revolving_hearts","two_hearts","heart_decoration","heavy_heart_exclamation_mark_ornament","broken_heart","heart_on_fire","mending_heart","heart","pink_heart","orange_heart","yellow_heart","green_heart","blue_heart","light_blue_heart","purple_heart","brown_heart","black_heart","grey_heart","white_heart","kiss","100","anger","boom","dizzy","sweat_drops","dash","hole","speech_balloon","eye-in-speech-bubble","left_speech_bubble","right_anger_bubble","thought_balloon","zzz"]},{"id":"nature","name":"Animals & Nature","emojis":["monkey_face","monkey","gorilla","orangutan","dog","dog2","guide_dog","service_dog","poodle","wolf","fox_face","raccoon","cat","cat2","black_cat","lion_face","tiger","tiger2","leopard","horse","moose","donkey","racehorse","unicorn_face","zebra_face","deer","bison","cow","ox","water_buffalo","cow2","pig","pig2","boar","pig_nose","ram","sheep","goat","dromedary_camel","camel","llama","giraffe_face","elephant","mammoth","rhinoceros","hippopotamus","mouse","mouse2","rat","hamster","rabbit","rabbit2","chipmunk","beaver","hedgehog","bat","bear","polar_bear","koala","panda_face","sloth","otter","skunk","kangaroo","badger","feet","turkey","chicken","rooster","hatching_chick","baby_chick","hatched_chick","bird","penguin","dove_of_peace","eagle","duck","swan","owl","dodo","feather","flamingo","peacock","parrot","wing","black_bird","goose","phoenix","frog","crocodile","turtle","lizard","snake","dragon_face","dragon","sauropod","t-rex","whale","whale2","dolphin","seal","fish","tropical_fish","blowfish","shark","octopus","shell","coral","jellyfish","snail","butterfly","bug","ant","bee","beetle","ladybug","cricket","cockroach","spider","spider_web","scorpion","mosquito","fly","worm","microbe","bouquet","cherry_blossom","white_flower","lotus","rosette","rose","wilted_flower","hibiscus","sunflower","blossom","tulip","hyacinth","seedling","potted_plant","evergreen_tree","deciduous_tree","palm_tree","cactus","ear_of_rice","herb","shamrock","four_leaf_clover","maple_leaf","fallen_leaf","leaves","empty_nest","nest_with_eggs","mushroom"]},{"id":"foods","name":"Food & Drink","emojis":["grapes","melon","watermelon","tangerine","lemon","lime","banana","pineapple","mango","apple","green_apple","pear","peach","cherries","strawberry","blueberries","kiwifruit","tomato","olive","coconut","avocado","eggplant","potato","carrot","corn","hot_pepper","bell_pepper","cucumber","leafy_green","broccoli","garlic","onion","peanuts","beans","chestnut","ginger_root","pea_pod","brown_mushroom","bread","croissant","baguette_bread","flatbread","pretzel","bagel","pancakes","waffle","cheese_wedge","meat_on_bone","poultry_leg","cut_of_meat","bacon","hamburger","fries","pizza","hotdog","sandwich","taco","burrito","tamale","stuffed_flatbread","falafel","egg","fried_egg","shallow_pan_of_food","stew","fondue","bowl_with_spoon","green_salad","popcorn","butter","salt","canned_food","bento","rice_cracker","rice_ball","rice","curry","ramen","spaghetti","sweet_potato","oden","sushi","fried_shrimp","fish_cake","moon_cake","dango","dumpling","fortune_cookie","takeout_box","crab","lobster","shrimp","squid","oyster","icecream","shaved_ice","ice_cream","doughnut","cookie","birthday","cake","cupcake","pie","chocolate_bar","candy","lollipop","custard","honey_pot","baby_bottle","glass_of_milk","coffee","teapot","tea","sake","champagne","wine_glass","cocktail","tropical_drink","beer","beers","clinking_glasses","tumbler_glass","pouring_liquid","cup_with_straw","bubble_tea","beverage_box","mate_drink","ice_cube","chopsticks","knife_fork_plate","fork_and_knife","spoon","hocho","jar","amphora"]},{"id":"activity","name":"Activities","emojis":["jack_o_lantern","christmas_tree","fireworks","sparkler","firecracker","sparkles","balloon","tada","confetti_ball","tanabata_tree","bamboo","dolls","flags","wind_chime","rice_scene","red_envelope","ribbon","gift","reminder_ribbon","admission_tickets","ticket","medal","trophy","sports_medal","first_place_medal","second_place_medal","third_place_medal","soccer","baseball","softball","basketball","volleyball","football","rugby_football","tennis","flying_disc","bowling","cricket_bat_and_ball","field_hockey_stick_and_ball","ice_hockey_stick_and_puck","lacrosse","table_tennis_paddle_and_ball","badminton_racquet_and_shuttlecock","boxing_glove","martial_arts_uniform","goal_net","golf","ice_skate","fishing_pole_and_fish","diving_mask","running_shirt_with_sash","ski","sled","curling_stone","dart","yo-yo","kite","gun","8ball","crystal_ball","magic_wand","video_game","joystick","slot_machine","game_die","jigsaw","teddy_bear","pinata","mirror_ball","nesting_dolls","spades","hearts","diamonds","clubs","chess_pawn","black_joker","mahjong","flower_playing_cards","performing_arts","frame_with_picture","art","thread","sewing_needle","yarn","knot"]},{"id":"places","name":"Travel & Places","emojis":["earth_africa","earth_americas","earth_asia","globe_with_meridians","world_map","japan","compass","snow_capped_mountain","mountain","volcano","mount_fuji","camping","beach_with_umbrella","desert","desert_island","national_park","stadium","classical_building","building_construction","bricks","rock","wood","hut","house_buildings","derelict_house_building","house","house_with_garden","office","post_office","european_post_office","hospital","bank","hotel","love_hotel","convenience_store","school","department_store","factory","japanese_castle","european_castle","wedding","tokyo_tower","statue_of_liberty","church","mosque","hindu_temple","synagogue","shinto_shrine","kaaba","fountain","tent","foggy","night_with_stars","cityscape","sunrise_over_mountains","sunrise","city_sunset","city_sunrise","bridge_at_night","hotsprings","carousel_horse","playground_slide","ferris_wheel","roller_coaster","barber","circus_tent","steam_locomotive","railway_car","bullettrain_side","bullettrain_front","train2","metro","light_rail","station","tram","monorail","mountain_railway","train","bus","oncoming_bus","trolleybus","minibus","ambulance","fire_engine","police_car","oncoming_police_car","taxi","oncoming_taxi","car","oncoming_automobile","blue_car","pickup_truck","truck","articulated_lorry","tractor","racing_car","racing_motorcycle","motor_scooter","manual_wheelchair","motorized_wheelchair","auto_rickshaw","bike","scooter","skateboard","roller_skate","busstop","motorway","railway_track","oil_drum","fuelpump","wheel","rotating_light","traffic_light","vertical_traffic_light","octagonal_sign","construction","anchor","ring_buoy","boat","canoe","speedboat","passenger_ship","ferry","motor_boat","ship","airplane","small_airplane","airplane_departure","airplane_arriving","parachute","seat","helicopter","suspension_railway","mountain_cableway","aerial_tramway","satellite","rocket","flying_saucer","bellhop_bell","luggage","hourglass","hourglass_flowing_sand","watch","alarm_clock","stopwatch","timer_clock","mantelpiece_clock","clock12","clock1230","clock1","clock130","clock2","clock230","clock3","clock330","clock4","clock430","clock5","clock530","clock6","clock630","clock7","clock730","clock8","clock830","clock9","clock930","clock10","clock1030","clock11","clock1130","new_moon","waxing_crescent_moon","first_quarter_moon","moon","full_moon","waning_gibbous_moon","last_quarter_moon","waning_crescent_moon","crescent_moon","new_moon_with_face","first_quarter_moon_with_face","last_quarter_moon_with_face","thermometer","sunny","full_moon_with_face","sun_with_face","ringed_planet","star","star2","stars","milky_way","cloud","partly_sunny","thunder_cloud_and_rain","mostly_sunny","barely_sunny","partly_sunny_rain","rain_cloud","snow_cloud","lightning","tornado","fog","wind_blowing_face","cyclone","rainbow","closed_umbrella","umbrella","umbrella_with_rain_drops","umbrella_on_ground","zap","snowflake","snowman","snowman_without_snow","comet","fire","droplet","ocean"]},{"id":"objects","name":"Objects","emojis":["eyeglasses","dark_sunglasses","goggles","lab_coat","safety_vest","necktie","shirt","jeans","scarf","gloves","coat","socks","dress","kimono","sari","one-piece_swimsuit","briefs","shorts","bikini","womans_clothes","folding_hand_fan","purse","handbag","pouch","shopping_bags","school_satchel","thong_sandal","mans_shoe","athletic_shoe","hiking_boot","womans_flat_shoe","high_heel","sandal","ballet_shoes","boot","hair_pick","crown","womans_hat","tophat","mortar_board","billed_cap","military_helmet","helmet_with_white_cross","prayer_beads","lipstick","ring","gem","mute","speaker","sound","loud_sound","loudspeaker","mega","postal_horn","bell","no_bell","musical_score","musical_note","notes","studio_microphone","level_slider","control_knobs","microphone","headphones","radio","saxophone","accordion","guitar","musical_keyboard","trumpet","violin","banjo","drum_with_drumsticks","long_drum","maracas","flute","iphone","calling","phone","telephone_receiver","pager","fax","battery","low_battery","electric_plug","computer","desktop_computer","printer","keyboard","three_button_mouse","trackball","minidisc","floppy_disk","cd","dvd","abacus","movie_camera","film_frames","film_projector","clapper","tv","camera","camera_with_flash","video_camera","vhs","mag","mag_right","candle","bulb","flashlight","izakaya_lantern","diya_lamp","notebook_with_decorative_cover","closed_book","book","green_book","blue_book","orange_book","books","notebook","ledger","page_with_curl","scroll","page_facing_up","newspaper","rolled_up_newspaper","bookmark_tabs","bookmark","label","moneybag","coin","yen","dollar","euro","pound","money_with_wings","credit_card","receipt","chart","email","e-mail","incoming_envelope","envelope_with_arrow","outbox_tray","inbox_tray","package","mailbox","mailbox_closed","mailbox_with_mail","mailbox_with_no_mail","postbox","ballot_box_with_ballot","pencil2","black_nib","lower_left_fountain_pen","lower_left_ballpoint_pen","lower_left_paintbrush","lower_left_crayon","memo","briefcase","file_folder","open_file_folder","card_index_dividers","date","calendar","spiral_note_pad","spiral_calendar_pad","card_index","chart_with_upwards_trend","chart_with_downwards_trend","bar_chart","clipboard","pushpin","round_pushpin","paperclip","linked_paperclips","straight_ruler","triangular_ruler","scissors","card_file_box","file_cabinet","wastebasket","lock","unlock","lock_with_ink_pen","closed_lock_with_key","key","old_key","hammer","axe","pick","hammer_and_pick","hammer_and_wrench","dagger_knife","crossed_swords","bomb","boomerang","bow_and_arrow","shield","carpentry_saw","wrench","screwdriver","nut_and_bolt","gear","compression","scales","probing_cane","link","broken_chain","chains","hook","toolbox","magnet","ladder","alembic","test_tube","petri_dish","dna","microscope","telescope","satellite_antenna","syringe","drop_of_blood","pill","adhesive_bandage","crutch","stethoscope","x-ray","door","elevator","mirror","window","bed","couch_and_lamp","chair","toilet","plunger","shower","bathtub","mouse_trap","razor","lotion_bottle","safety_pin","broom","basket","roll_of_paper","bucket","soap","bubbles","toothbrush","sponge","fire_extinguisher","shopping_trolley","smoking","coffin","headstone","funeral_urn","nazar_amulet","hamsa","moyai","placard","identification_card"]},{"id":"symbols","name":"Symbols","emojis":["atm","put_litter_in_its_place","potable_water","wheelchair","mens","womens","restroom","baby_symbol","wc","passport_control","customs","baggage_claim","left_luggage","warning","children_crossing","no_entry","no_entry_sign","no_bicycles","no_smoking","do_not_litter","non-potable_water","no_pedestrians","no_mobile_phones","underage","radioactive_sign","biohazard_sign","arrow_up","arrow_upper_right","arrow_right","arrow_lower_right","arrow_down","arrow_lower_left","arrow_left","arrow_upper_left","arrow_up_down","left_right_arrow","leftwards_arrow_with_hook","arrow_right_hook","arrow_heading_up","arrow_heading_down","arrows_clockwise","arrows_counterclockwise","back","end","on","soon","top","place_of_worship","atom_symbol","om_symbol","star_of_david","wheel_of_dharma","yin_yang","latin_cross","orthodox_cross","star_and_crescent","peace_symbol","menorah_with_nine_branches","six_pointed_star","khanda","aries","taurus","gemini","cancer","leo","virgo","libra","scorpius","sagittarius","capricorn","aquarius","pisces","ophiuchus","twisted_rightwards_arrows","repeat","repeat_one","arrow_forward","fast_forward","black_right_pointing_double_triangle_with_vertical_bar","black_right_pointing_triangle_with_double_vertical_bar","arrow_backward","rewind","black_left_pointing_double_triangle_with_vertical_bar","arrow_up_small","arrow_double_up","arrow_down_small","arrow_double_down","double_vertical_bar","black_square_for_stop","black_circle_for_record","eject","cinema","low_brightness","high_brightness","signal_strength","wireless","vibration_mode","mobile_phone_off","female_sign","male_sign","transgender_symbol","heavy_multiplication_x","heavy_plus_sign","heavy_minus_sign","heavy_division_sign","heavy_equals_sign","infinity","bangbang","interrobang","question","grey_question","grey_exclamation","exclamation","wavy_dash","currency_exchange","heavy_dollar_sign","medical_symbol","recycle","fleur_de_lis","trident","name_badge","beginner","o","white_check_mark","ballot_box_with_check","heavy_check_mark","x","negative_squared_cross_mark","curly_loop","loop","part_alternation_mark","eight_spoked_asterisk","eight_pointed_black_star","sparkle","copyright","registered","tm","hash","keycap_star","zero","one","two","three","four","five","six","seven","eight","nine","keycap_ten","capital_abcd","abcd","1234","symbols","abc","a","ab","b","cl","cool","free","information_source","id","m","new","ng","o2","ok","parking","sos","up","vs","koko","sa","u6708","u6709","u6307","ideograph_advantage","u5272","u7121","u7981","accept","u7533","u5408","u7a7a","congratulations","secret","u55b6","u6e80","red_circle","large_orange_circle","large_yellow_circle","large_green_circle","large_blue_circle","large_purple_circle","large_brown_circle","black_circle","white_circle","large_red_square","large_orange_square","large_yellow_square","large_green_square","large_blue_square","large_purple_square","large_brown_square","black_large_square","white_large_square","black_medium_square","white_medium_square","black_medium_small_square","white_medium_small_square","black_small_square","white_small_square","large_orange_diamond","large_blue_diamond","small_orange_diamond","small_blue_diamond","small_red_triangle","small_red_triangle_down","diamond_shape_with_a_dot_inside","radio_button","white_square_button","black_square_button"]},{"id":"flags","name":"Flags","emojis":["checkered_flag","triangular_flag_on_post","crossed_flags","waving_black_flag","waving_white_flag","rainbow-flag","transgender_flag","pirate_flag","flag-ac","flag-ad","flag-ae","flag-af","flag-ag","flag-ai","flag-al","flag-am","flag-ao","flag-aq","flag-ar","flag-as","flag-at","flag-au","flag-aw","flag-ax","flag-az","flag-ba","flag-bb","flag-bd","flag-be","flag-bf","flag-bg","flag-bh","flag-bi","flag-bj","flag-bl","flag-bm","flag-bn","flag-bo","flag-bq","flag-br","flag-bs","flag-bt","flag-bv","flag-bw","flag-by","flag-bz","flag-ca","flag-cc","flag-cd","flag-cf","flag-cg","flag-ch","flag-ci","flag-ck","flag-cl","flag-cm","cn","flag-co","flag-cp","flag-cr","flag-cu","flag-cv","flag-cw","flag-cx","flag-cy","flag-cz","de","flag-dg","flag-dj","flag-dk","flag-dm","flag-do","flag-dz","flag-ea","flag-ec","flag-ee","flag-eg","flag-eh","flag-er","es","flag-et","flag-eu","flag-fi","flag-fj","flag-fk","flag-fm","flag-fo","fr","flag-ga","gb","flag-gd","flag-ge","flag-gf","flag-gg","flag-gh","flag-gi","flag-gl","flag-gm","flag-gn","flag-gp","flag-gq","flag-gr","flag-gs","flag-gt","flag-gu","flag-gw","flag-gy","flag-hk","flag-hm","flag-hn","flag-hr","flag-ht","flag-hu","flag-ic","flag-id","flag-ie","flag-il","flag-im","flag-in","flag-io","flag-iq","flag-ir","flag-is","it","flag-je","flag-jm","flag-jo","jp","flag-ke","flag-kg","flag-kh","flag-ki","flag-km","flag-kn","flag-kp","kr","flag-kw","flag-ky","flag-kz","flag-la","flag-lb","flag-lc","flag-li","flag-lk","flag-lr","flag-ls","flag-lt","flag-lu","flag-lv","flag-ly","flag-ma","flag-mc","flag-md","flag-me","flag-mf","flag-mg","flag-mh","flag-mk","flag-ml","flag-mm","flag-mn","flag-mo","flag-mp","flag-mq","flag-mr","flag-ms","flag-mt","flag-mu","flag-mv","flag-mw","flag-mx","flag-my","flag-mz","flag-na","flag-nc","flag-ne","flag-nf","flag-ng","flag-ni","flag-nl","flag-no","flag-np","flag-nr","flag-nu","flag-nz","flag-om","flag-pa","flag-pe","flag-pf","flag-pg","flag-ph","flag-pk","flag-pl","flag-pm","flag-pn","flag-pr","flag-ps","flag-pt","flag-pw","flag-py","flag-qa","flag-re","flag-ro","flag-rs","ru","flag-rw","flag-sa","flag-sb","flag-sc","flag-sd","flag-se","flag-sg","flag-sh","flag-si","flag-sj","flag-sk","flag-sl","flag-sm","flag-sn","flag-so","flag-sr","flag-ss","flag-st","flag-sv","flag-sx","flag-sy","flag-sz","flag-ta","flag-tc","flag-td","flag-tf","flag-tg","flag-th","flag-tj","flag-tk","flag-tl","flag-tm","flag-tn","flag-to","flag-tr","flag-tt","flag-tv","flag-tw","flag-tz","flag-ua","flag-ug","flag-um","flag-un","us","flag-uy","flag-uz","flag-va","flag-vc","flag-ve","flag-vg","flag-vi","flag-vn","flag-vu","flag-wf","flag-ws","flag-xk","flag-ye","flag-yt","flag-za","flag-zm","flag-zw","flag-england","flag-scotland","flag-wales"]}],"emojis":{"grinning":{"a":"Grinning Face","b":"1F600","f":true,"k":[32,46],"j":["grinning_face","face","smile","happy","joy",":D","grin"],"m":":D"},"smiley":{"a":"Smiling Face with Open Mouth","b":"1F603","f":true,"k":[32,49],"j":["grinning_face_with_big_eyes","face","happy","joy","haha",":D",":)","smile","funny"],"l":["=)","=-)"],"m":":)"},"smile":{"a":"Smiling Face with Open Mouth and Smiling Eyes","b":"1F604","f":true,"k":[32,50],"j":["grinning_face_with_smiling_eyes","face","happy","joy","funny","haha","laugh","like",":D",":)"],"l":["C:","c:",":D",":-D"],"m":":)"},"grin":{"a":"Grinning Face with Smiling Eyes","b":"1F601","f":true,"k":[32,47],"j":["beaming_face_with_smiling_eyes","face","happy","smile","joy","kawaii"]},"laughing":{"a":"Smiling Face with Open Mouth and Tightly-Closed Eyes","b":"1F606","f":true,"k":[32,52],"j":["grinning_squinting_face","happy","joy","lol","satisfied","haha","face","glad","XD","laugh"],"l":[":>",":->"]},"sweat_smile":{"a":"Smiling Face with Open Mouth and Cold Sweat","b":"1F605","f":true,"k":[32,51],"j":["grinning_face_with_sweat","face","hot","happy","laugh","sweat","smile","relief"]},"rolling_on_the_floor_laughing":{"a":"Rolling On the Floor Laughing","b":"1F923","f":true,"k":[40,54],"j":["face","rolling","floor","laughing","lol","haha","rofl"]},"joy":{"a":"Face with Tears Of Joy","b":"1F602","f":true,"k":[32,48],"j":["face_with_tears_of_joy","face","cry","tears","weep","happy","happytears","haha"]},"slightly_smiling_face":{"a":"Slightly Smiling Face","b":"1F642","f":true,"k":[33,55],"j":["face","smile"],"l":[":)","(:",":-)"]},"upside_down_face":{"a":"Upside-Down Face","b":"1F643","f":true,"k":[33,56],"j":["face","flipped","silly","smile"]},"melting_face":{"a":"Melting Face","b":"1FAE0","f":true,"k":[56,30],"j":["melting face","hot","heat"]},"wink":{"a":"Winking Face","b":"1F609","f":true,"k":[32,55],"j":["winking_face","face","happy","mischievous","secret",";)","smile","eye"],"l":[";)",";-)"],"m":";)"},"blush":{"a":"Smiling Face with Smiling Eyes","b":"1F60A","f":true,"k":[32,56],"j":["smiling_face_with_smiling_eyes","face","smile","happy","flushed","crush","embarrassed","shy","joy"],"m":":)"},"innocent":{"a":"Smiling Face with Halo","b":"1F607","f":true,"k":[32,53],"j":["smiling_face_with_halo","face","angel","heaven","halo"]},"smiling_face_with_3_hearts":{"a":"Smiling Face with Smiling Eyes and Three Hearts","b":"1F970","f":true,"k":[44,32],"j":["smiling_face_with_hearts","face","love","like","affection","valentines","infatuation","crush","hearts","adore"]},"heart_eyes":{"a":"Smiling Face with Heart-Shaped Eyes","b":"1F60D","f":true,"k":[32,59],"j":["smiling_face_with_heart_eyes","face","love","like","affection","valentines","infatuation","crush","heart"]},"star-struck":{"a":"Grinning Face with Star Eyes","b":"1F929","f":true,"k":[41,15],"j":["star_struck","face","smile","starry","eyes","grinning"]},"kissing_heart":{"a":"Face Throwing a Kiss","b":"1F618","f":true,"k":[33,8],"j":["face_blowing_a_kiss","face","love","like","affection","valentines","infatuation","kiss"],"l":[":*",":-*"]},"kissing":{"a":"Kissing Face","b":"1F617","f":true,"k":[33,7],"j":["kissing_face","love","like","face","3","valentines","infatuation","kiss"]},"relaxed":{"a":"White Smiling Face","b":"263A-FE0F","f":true,"k":[58,33],"c":"263A"},"kissing_closed_eyes":{"a":"Kissing Face with Closed Eyes","b":"1F61A","f":true,"k":[33,10],"j":["kissing_face_with_closed_eyes","face","love","like","affection","valentines","infatuation","kiss"]},"kissing_smiling_eyes":{"a":"Kissing Face with Smiling Eyes","b":"1F619","f":true,"k":[33,9],"j":["kissing_face_with_smiling_eyes","face","affection","valentines","infatuation","kiss"]},"smiling_face_with_tear":{"a":"Smiling Face with Tear","b":"1F972","f":true,"k":[44,34],"j":["smiling face with tear","sad","cry","pretend"]},"yum":{"a":"Face Savouring Delicious Food","b":"1F60B","f":true,"k":[32,57],"j":["face_savoring_food","happy","joy","tongue","smile","face","silly","yummy","nom","delicious","savouring"]},"stuck_out_tongue":{"a":"Face with Stuck-Out Tongue","b":"1F61B","f":true,"k":[33,11],"j":["face_with_tongue","face","prank","childish","playful","mischievous","smile","tongue"],"l":[":p",":-p",":P",":-P",":b",":-b"],"m":":p"},"stuck_out_tongue_winking_eye":{"a":"Face with Stuck-Out Tongue and Winking Eye","b":"1F61C","f":true,"k":[33,12],"j":["winking_face_with_tongue","face","prank","childish","playful","mischievous","smile","wink","tongue"],"l":[";p",";-p",";b",";-b",";P",";-P"],"m":";p"},"zany_face":{"a":"Grinning Face with One Large and One Small Eye","b":"1F92A","f":true,"k":[41,16],"j":["face","goofy","crazy"]},"stuck_out_tongue_closed_eyes":{"a":"Face with Stuck-Out Tongue and Tightly-Closed Eyes","b":"1F61D","f":true,"k":[33,13],"j":["squinting_face_with_tongue","face","prank","playful","mischievous","smile","tongue"]},"money_mouth_face":{"a":"Money-Mouth Face","b":"1F911","f":true,"k":[39,38],"j":["face","rich","dollar","money"]},"hugging_face":{"a":"Hugging Face","b":"1F917","f":true,"k":[39,44],"j":["face","smile","hug"]},"face_with_hand_over_mouth":{"a":"Smiling Face with Smiling Eyes and Hand Covering Mouth","b":"1F92D","f":true,"k":[41,19],"j":["face","whoops","shock","surprise"]},"face_with_open_eyes_and_hand_over_mouth":{"a":"Face with Open Eyes and Hand Over Mouth","b":"1FAE2","f":true,"k":[56,32],"j":["face with open eyes and hand over mouth","silence","secret","shock","surprise"]},"face_with_peeking_eye":{"a":"Face with Peeking Eye","b":"1FAE3","f":true,"k":[56,33],"j":["face with peeking eye","scared","frightening","embarrassing","shy"]},"shushing_face":{"a":"Face with Finger Covering Closed Lips","b":"1F92B","f":true,"k":[41,17],"j":["face","quiet","shhh"]},"thinking_face":{"a":"Thinking Face","b":"1F914","f":true,"k":[39,41],"j":["face","hmmm","think","consider"]},"saluting_face":{"a":"Saluting Face","b":"1FAE1","f":true,"k":[56,31],"j":["saluting face","respect","salute"]},"zipper_mouth_face":{"a":"Zipper-Mouth Face","b":"1F910","f":true,"k":[39,37],"j":["face","sealed","zipper","secret"]},"face_with_raised_eyebrow":{"a":"Face with One Eyebrow Raised","b":"1F928","f":true,"k":[41,14],"j":["face","distrust","scepticism","disapproval","disbelief","surprise","suspicious"]},"neutral_face":{"a":"Neutral Face","b":"1F610","f":true,"k":[33,0],"j":["indifference","meh",":|","neutral"],"l":[":|",":-|"]},"expressionless":{"a":"Expressionless Face","b":"1F611","f":true,"k":[33,1],"j":["expressionless_face","face","indifferent","-_-","meh","deadpan"]},"no_mouth":{"a":"Face Without Mouth","b":"1F636","f":true,"k":[33,41],"j":["face_without_mouth","face"]},"dotted_line_face":{"a":"Dotted Line Face","b":"1FAE5","f":true,"k":[56,35],"j":["dotted line face","invisible","lonely","isolation","depression"]},"face_in_clouds":{"a":"Face In Clouds","b":"1F636-200D-1F32B-FE0F","f":true,"k":[33,40],"c":"1F636-200D-1F32B","j":["face_without_mouth","face"]},"smirk":{"a":"Smirking Face","b":"1F60F","f":true,"k":[32,61],"j":["smirking_face","face","smile","mean","prank","smug","sarcasm"]},"unamused":{"a":"Unamused Face","b":"1F612","f":true,"k":[33,2],"j":["unamused_face","indifference","bored","straight face","serious","sarcasm","unimpressed","skeptical","dubious","ugh","side_eye"],"m":":("},"face_with_rolling_eyes":{"a":"Face with Rolling Eyes","b":"1F644","f":true,"k":[33,57],"j":["face","eyeroll","frustrated"]},"grimacing":{"a":"Grimacing Face","b":"1F62C","f":true,"k":[33,28],"j":["grimacing_face","face","grimace","teeth"]},"face_exhaling":{"a":"Face Exhaling","b":"1F62E-200D-1F4A8","f":true,"k":[33,30],"j":["face_with_open_mouth","face","surprise","impressed","wow","whoa",":O"]},"lying_face":{"a":"Lying Face","b":"1F925","f":true,"k":[40,56],"j":["face","lie","pinocchio"]},"shaking_face":{"a":"Shaking Face","b":"1FAE8","f":true,"k":[56,38],"j":["shaking face","dizzy","shock","blurry","earthquake"]},"head_shaking_horizontally":{"a":"Head Shaking Horizontally","b":"1F642-200D-2194-FE0F","f":true,"k":[33,53],"c":"1F642-200D-2194","j":["slightly_smiling_face","face","smile"]},"head_shaking_vertically":{"a":"Head Shaking Vertically","b":"1F642-200D-2195-FE0F","f":true,"k":[33,54],"c":"1F642-200D-2195","j":["slightly_smiling_face","face","smile"]},"relieved":{"a":"Relieved Face","b":"1F60C","f":true,"k":[32,58],"j":["relieved_face","face","relaxed","phew","massage","happiness"]},"pensive":{"a":"Pensive Face","b":"1F614","f":true,"k":[33,4],"j":["pensive_face","face","sad","depressed","upset"]},"sleepy":{"a":"Sleepy Face","b":"1F62A","f":true,"k":[33,26],"j":["sleepy_face","face","tired","rest","nap"]},"drooling_face":{"a":"Drooling Face","b":"1F924","f":true,"k":[40,55],"j":["face"]},"sleeping":{"a":"Sleeping Face","b":"1F634","f":true,"k":[33,37],"j":["sleeping_face","face","tired","sleepy","night","zzz"]},"mask":{"a":"Face with Medical Mask","b":"1F637","f":true,"k":[33,42],"j":["face_with_medical_mask","face","sick","ill","disease","covid"]},"face_with_thermometer":{"a":"Face with Thermometer","b":"1F912","f":true,"k":[39,39],"j":["sick","temperature","thermometer","cold","fever","covid"]},"face_with_head_bandage":{"a":"Face with Head-Bandage","b":"1F915","f":true,"k":[39,42],"j":["injured","clumsy","bandage","hurt"]},"nauseated_face":{"a":"Nauseated Face","b":"1F922","f":true,"k":[40,53],"j":["face","vomit","gross","green","sick","throw up","ill"]},"face_vomiting":{"a":"Face with Open Mouth Vomiting","b":"1F92E","f":true,"k":[41,20],"j":["face","sick"]},"sneezing_face":{"a":"Sneezing Face","b":"1F927","f":true,"k":[41,13],"j":["face","gesundheit","sneeze","sick","allergy"]},"hot_face":{"a":"Overheated Face","b":"1F975","f":true,"k":[44,37],"j":["face","feverish","heat","red","sweating"]},"cold_face":{"a":"Freezing Face","b":"1F976","f":true,"k":[44,38],"j":["face","blue","freezing","frozen","frostbite","icicles"]},"woozy_face":{"a":"Face with Uneven Eyes and Wavy Mouth","b":"1F974","f":true,"k":[44,36],"j":["face","dizzy","intoxicated","tipsy","wavy"]},"dizzy_face":{"a":"Dizzy Face","b":"1F635","f":true,"k":[33,39],"j":["spent","unconscious","xox","dizzy"]},"face_with_spiral_eyes":{"a":"Face with Spiral Eyes","b":"1F635-200D-1F4AB","f":true,"k":[33,38],"j":["dizzy_face","spent","unconscious","xox","dizzy"]},"exploding_head":{"a":"Shocked Face with Exploding Head","b":"1F92F","f":true,"k":[41,21],"j":["face","shocked","mind","blown"]},"face_with_cowboy_hat":{"a":"Face with Cowboy Hat","b":"1F920","f":true,"k":[40,51],"j":["cowboy_hat_face","face","cowgirl","hat"]},"partying_face":{"a":"Face with Party Horn and Party Hat","b":"1F973","f":true,"k":[44,35],"j":["face","celebration","woohoo"]},"disguised_face":{"a":"Disguised Face","b":"1F978","f":true,"k":[44,45],"j":["disguised face","pretent","brows","glasses","moustache"]},"sunglasses":{"a":"Smiling Face with Sunglasses","b":"1F60E","f":true,"k":[32,60],"j":["smiling_face_with_sunglasses","face","cool","smile","summer","beach","sunglass"],"l":["8)"]},"nerd_face":{"a":"Nerd Face","b":"1F913","f":true,"k":[39,40],"j":["face","nerdy","geek","dork"]},"face_with_monocle":{"a":"Face with Monocle","b":"1F9D0","f":true,"k":[47,61],"j":["face","stuffy","wealthy"]},"confused":{"a":"Confused Face","b":"1F615","f":true,"k":[33,5],"j":["confused_face","face","indifference","huh","weird","hmmm",":/"],"l":[":\\",":-\\",":/",":-/"]},"face_with_diagonal_mouth":{"a":"Face with Diagonal Mouth","b":"1FAE4","f":true,"k":[56,34],"j":["face with diagonal mouth","skeptic","confuse","frustrated","indifferent"]},"worried":{"a":"Worried Face","b":"1F61F","f":true,"k":[33,15],"j":["worried_face","face","concern","nervous",":("]},"slightly_frowning_face":{"a":"Slightly Frowning Face","b":"1F641","f":true,"k":[33,52],"j":["face","frowning","disappointed","sad","upset"]},"white_frowning_face":{"a":"Frowning Face","b":"2639-FE0F","f":true,"k":[58,32],"c":"2639"},"open_mouth":{"a":"Face with Open Mouth","b":"1F62E","f":true,"k":[33,31],"j":["face_with_open_mouth","face","surprise","impressed","wow","whoa",":O"],"l":[":o",":-o",":O",":-O"]},"hushed":{"a":"Hushed Face","b":"1F62F","f":true,"k":[33,32],"j":["hushed_face","face","woo","shh"]},"astonished":{"a":"Astonished Face","b":"1F632","f":true,"k":[33,35],"j":["astonished_face","face","xox","surprised","poisoned"]},"flushed":{"a":"Flushed Face","b":"1F633","f":true,"k":[33,36],"j":["flushed_face","face","blush","shy","flattered"]},"pleading_face":{"a":"Face with Pleading Eyes","b":"1F97A","f":true,"k":[44,47],"j":["face","begging","mercy","cry","tears","sad","grievance"]},"face_holding_back_tears":{"a":"Face Holding Back Tears","b":"1F979","f":true,"k":[44,46],"j":["face holding back tears","touched","gratitude","cry"]},"frowning":{"a":"Frowning Face with Open Mouth","b":"1F626","f":true,"k":[33,22],"j":["frowning_face_with_open_mouth","face","aw","what"]},"anguished":{"a":"Anguished Face","b":"1F627","f":true,"k":[33,23],"j":["anguished_face","face","stunned","nervous"],"l":["D:"]},"fearful":{"a":"Fearful Face","b":"1F628","f":true,"k":[33,24],"j":["fearful_face","face","scared","terrified","nervous"]},"cold_sweat":{"a":"Face with Open Mouth and Cold Sweat","b":"1F630","f":true,"k":[33,33],"j":["anxious_face_with_sweat","face","nervous","sweat"]},"disappointed_relieved":{"a":"Disappointed But Relieved Face","b":"1F625","f":true,"k":[33,21],"j":["sad_but_relieved_face","face","phew","sweat","nervous"]},"cry":{"a":"Crying Face","b":"1F622","f":true,"k":[33,18],"j":["crying_face","face","tears","sad","depressed","upset",":'("],"l":[":'("],"m":":'("},"sob":{"a":"Loudly Crying Face","b":"1F62D","f":true,"k":[33,29],"j":["loudly_crying_face","sobbing","face","cry","tears","sad","upset","depressed"],"m":":'("},"scream":{"a":"Face Screaming In Fear","b":"1F631","f":true,"k":[33,34],"j":["face_screaming_in_fear","face","munch","scared","omg"]},"confounded":{"a":"Confounded Face","b":"1F616","f":true,"k":[33,6],"j":["confounded_face","face","confused","sick","unwell","oops",":S"]},"persevere":{"a":"Persevering Face","b":"1F623","f":true,"k":[33,19],"j":["persevering_face","face","sick","no","upset","oops"]},"disappointed":{"a":"Disappointed Face","b":"1F61E","f":true,"k":[33,14],"j":["disappointed_face","face","sad","upset","depressed",":("],"l":["):",":(",":-("],"m":":("},"sweat":{"a":"Face with Cold Sweat","b":"1F613","f":true,"k":[33,3],"j":["downcast_face_with_sweat","face","hot","sad","tired","exercise"]},"weary":{"a":"Weary Face","b":"1F629","f":true,"k":[33,25],"j":["weary_face","face","tired","sleepy","sad","frustrated","upset"]},"tired_face":{"a":"Tired Face","b":"1F62B","f":true,"k":[33,27],"j":["sick","whine","upset","frustrated"]},"yawning_face":{"a":"Yawning Face","b":"1F971","f":true,"k":[44,33],"j":["tired","sleepy"]},"triumph":{"a":"Face with Look Of Triumph","b":"1F624","f":true,"k":[33,20],"j":["face_with_steam_from_nose","face","gas","phew","proud","pride"]},"rage":{"a":"Pouting Face","b":"1F621","f":true,"k":[33,17],"j":["pouting_face","angry","mad","hate","despise"]},"angry":{"a":"Angry Face","b":"1F620","f":true,"k":[33,16],"j":["angry_face","mad","face","annoyed","frustrated"],"l":[">:(",">:-("]},"face_with_symbols_on_mouth":{"a":"Serious Face with Symbols Covering Mouth","b":"1F92C","f":true,"k":[41,18],"j":["face","swearing","cursing","cussing","profanity","expletive"]},"smiling_imp":{"a":"Smiling Face with Horns","b":"1F608","f":true,"k":[32,54],"j":["smiling_face_with_horns","devil","horns"]},"imp":{"a":"Imp","b":"1F47F","f":true,"k":[25,41],"j":["angry_face_with_horns","devil","angry","horns"]},"skull":{"a":"Skull","b":"1F480","f":true,"k":[25,42],"j":["dead","skeleton","creepy","death","dead"]},"skull_and_crossbones":{"a":"Skull and Crossbones","b":"2620-FE0F","f":true,"k":[58,24],"c":"2620"},"hankey":{"a":"Pile Of Poo","b":"1F4A9","f":true,"k":[28,25],"j":["pile_of_poo","shitface","fail","turd","shit"]},"clown_face":{"a":"Clown Face","b":"1F921","f":true,"k":[40,52],"j":["face"]},"japanese_ogre":{"a":"Japanese Ogre","b":"1F479","f":true,"k":[25,30],"j":["ogre","monster","red","mask","halloween","scary","creepy","devil","demon"]},"japanese_goblin":{"a":"Japanese Goblin","b":"1F47A","f":true,"k":[25,31],"j":["goblin","red","evil","mask","monster","scary","creepy"]},"ghost":{"a":"Ghost","b":"1F47B","f":true,"k":[25,32],"j":["halloween","spooky","scary"]},"alien":{"a":"Extraterrestrial Alien","b":"1F47D","f":true,"k":[25,39],"j":["UFO","paul","weird","outer_space"]},"space_invader":{"a":"Alien Monster","b":"1F47E","f":true,"k":[25,40],"j":["alien_monster","game","arcade","play"]},"robot_face":{"a":"Robot Face","b":"1F916","f":true,"k":[39,43],"j":["robot","computer","machine","bot"]},"smiley_cat":{"a":"Smiling Cat Face with Open Mouth","b":"1F63A","f":true,"k":[33,45],"j":["grinning_cat","animal","cats","happy","smile"]},"smile_cat":{"a":"Grinning Cat Face with Smiling Eyes","b":"1F638","f":true,"k":[33,43],"j":["grinning_cat_with_smiling_eyes","animal","cats","smile"]},"joy_cat":{"a":"Cat Face with Tears Of Joy","b":"1F639","f":true,"k":[33,44],"j":["cat_with_tears_of_joy","animal","cats","haha","happy","tears"]},"heart_eyes_cat":{"a":"Smiling Cat Face with Heart-Shaped Eyes","b":"1F63B","f":true,"k":[33,46],"j":["smiling_cat_with_heart_eyes","animal","love","like","affection","cats","valentines","heart"]},"smirk_cat":{"a":"Cat Face with Wry Smile","b":"1F63C","f":true,"k":[33,47],"j":["cat_with_wry_smile","animal","cats","smirk"]},"kissing_cat":{"a":"Kissing Cat Face with Closed Eyes","b":"1F63D","f":true,"k":[33,48],"j":["animal","cats","kiss"]},"scream_cat":{"a":"Weary Cat Face","b":"1F640","f":true,"k":[33,51],"j":["weary_cat","animal","cats","munch","scared","scream"]},"crying_cat_face":{"a":"Crying Cat Face","b":"1F63F","f":true,"k":[33,50],"j":["crying_cat","animal","tears","weep","sad","cats","upset","cry"]},"pouting_cat":{"a":"Pouting Cat Face","b":"1F63E","f":true,"k":[33,49],"j":["animal","cats"]},"see_no_evil":{"a":"See-No-Evil Monkey","b":"1F648","f":true,"k":[34,50],"j":["see_no_evil_monkey","monkey","animal","nature","haha"]},"hear_no_evil":{"a":"Hear-No-Evil Monkey","b":"1F649","f":true,"k":[34,51],"j":["hear_no_evil_monkey","animal","monkey","nature"]},"speak_no_evil":{"a":"Speak-No-Evil Monkey","b":"1F64A","f":true,"k":[34,52],"j":["speak_no_evil_monkey","monkey","animal","nature","omg"]},"love_letter":{"a":"Love Letter","b":"1F48C","f":true,"k":[27,8],"j":["email","like","affection","envelope","valentines"]},"cupid":{"a":"Heart with Arrow","b":"1F498","f":true,"k":[28,8],"j":["heart_with_arrow","love","like","heart","affection","valentines"]},"gift_heart":{"a":"Heart with Ribbon","b":"1F49D","f":true,"k":[28,13],"j":["heart_with_ribbon","love","valentines"]},"sparkling_heart":{"a":"Sparkling Heart","b":"1F496","f":true,"k":[28,6],"j":["love","like","affection","valentines"]},"heartpulse":{"a":"Growing Heart","b":"1F497","f":true,"k":[28,7],"j":["growing_heart","like","love","affection","valentines","pink"]},"heartbeat":{"a":"Beating Heart","b":"1F493","f":true,"k":[28,3],"j":["beating_heart","love","like","affection","valentines","pink","heart"]},"revolving_hearts":{"a":"Revolving Hearts","b":"1F49E","f":true,"k":[28,14],"j":["love","like","affection","valentines"]},"two_hearts":{"a":"Two Hearts","b":"1F495","f":true,"k":[28,5],"j":["love","like","affection","valentines","heart"]},"heart_decoration":{"a":"Heart Decoration","b":"1F49F","f":true,"k":[28,15],"j":["purple-square","love","like"]},"heavy_heart_exclamation_mark_ornament":{"a":"Heart Exclamation","b":"2763-FE0F","f":true,"k":[60,35],"c":"2763"},"broken_heart":{"a":"Broken Heart","b":"1F494","f":true,"k":[28,4],"j":["sad","sorry","break","heart","heartbreak"],"l":[" { - const accountId = id || state.getIn(['accounts_map', normalizeForLookup(acct)]); + const accountId = id || state.accounts_map[normalizeForLookup(acct)]; if (!accountId) { return { diff --git a/app/javascript/flavours/glitch/features/following/index.jsx b/app/javascript/flavours/glitch/features/following/index.jsx index 23cd8ec2e4..d673e3ffc4 100644 --- a/app/javascript/flavours/glitch/features/following/index.jsx +++ b/app/javascript/flavours/glitch/features/following/index.jsx @@ -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 { diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/hashtag_header.tsx b/app/javascript/flavours/glitch/features/hashtag_timeline/components/hashtag_header.tsx index 35590c4a52..a9013ea0fa 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/components/hashtag_header.tsx +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/hashtag_header.tsx @@ -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) { diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx index 276e2d3cc1..94318773cb 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/notification_mention.tsx @@ -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; diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index 5a9df4c731..af3bc8b7f4 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -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)}; } diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx deleted file mode 100644 index 4b519e0d75..0000000000 --- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.jsx +++ /dev/null @@ -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
  • ; - } - - 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 && } -
    -
    {text}
    -
    {meta}
    -
    - - ); - } - - return ( -
  • - - {contents} - -
  • - ); - }; - - render () { - return ( -
    -
      - {this.props.actions.map(this.renderAction)} -
    -
    - ); - } - -} diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx new file mode 100644 index 0000000000..45005bff1c --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx @@ -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 }) => ( +
    +
      + {actions.map((option, i: number) => { + if (option === null) { + return
    • ; + } + + const { text, dangerous } = option; + + let element: React.ReactElement; + + if (isActionItem(option)) { + element = ( + + ); + } else if (isExternalLinkItem(option)) { + element = ( + + {text} + + ); + } else { + element = ( + + {text} + + ); + } + + return ( +
    • + {element} +
    • + ); + })} +
    +
    +); diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx index 9ff577c8ce..2c85f46241 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.jsx @@ -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 { diff --git a/app/javascript/flavours/glitch/hooks/useAccountId.ts b/app/javascript/flavours/glitch/hooks/useAccountId.ts index 406a062c9e..3b48173795 100644 --- a/app/javascript/flavours/glitch/hooks/useAccountId.ts +++ b/app/javascript/flavours/glitch/hooks/useAccountId.ts @@ -11,27 +11,25 @@ interface Params { id?: string; } -export function useAccountId() { +export const useAccountId = () => { const { acct, id } = useParams(); + 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; -} +}; diff --git a/app/javascript/flavours/glitch/hooks/useAccountVisibility.ts b/app/javascript/flavours/glitch/hooks/useAccountVisibility.ts index e5fd40adba..1752de75e9 100644 --- a/app/javascript/flavours/glitch/hooks/useAccountVisibility.ts +++ b/app/javascript/flavours/glitch/hooks/useAccountVisibility.ts @@ -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, diff --git a/app/javascript/flavours/glitch/models/dropdown_menu.ts b/app/javascript/flavours/glitch/models/dropdown_menu.ts index ceea9ad4dd..c02f205023 100644 --- a/app/javascript/flavours/glitch/models/dropdown_menu.ts +++ b/app/javascript/flavours/glitch/models/dropdown_menu.ts @@ -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; +}; diff --git a/app/javascript/flavours/glitch/reducers/accounts.ts b/app/javascript/flavours/glitch/reducers/accounts.ts index cbdfbcd832..c9d47184d4 100644 --- a/app/javascript/flavours/glitch/reducers/accounts.ts +++ b/app/javascript/flavours/glitch/reducers/accounts.ts @@ -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'; diff --git a/app/javascript/flavours/glitch/reducers/accounts_map.js b/app/javascript/flavours/glitch/reducers/accounts_map.js deleted file mode 100644 index 9053dcc9c0..0000000000 --- a/app/javascript/flavours/glitch/reducers/accounts_map.js +++ /dev/null @@ -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; - } -} diff --git a/app/javascript/flavours/glitch/reducers/accounts_map.ts b/app/javascript/flavours/glitch/reducers/accounts_map.ts new file mode 100644 index 0000000000..d8847a3bba --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/accounts_map.ts @@ -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 = {}; + +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; + } + }, + ); +}); diff --git a/app/javascript/flavours/glitch/reducers/contexts.js b/app/javascript/flavours/glitch/reducers/contexts.js deleted file mode 100644 index 07fe8ffb8a..0000000000 --- a/app/javascript/flavours/glitch/reducers/contexts.js +++ /dev/null @@ -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; - } -} diff --git a/app/javascript/flavours/glitch/reducers/contexts.ts b/app/javascript/flavours/glitch/reducers/contexts.ts new file mode 100644 index 0000000000..9c849a967b --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/contexts.ts @@ -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; + replies: Record; +} + +const initialState: State = { + inReplyTos: {}, + replies: {}, +}; + +const normalizeContext = ( + state: Draft, + 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, 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, + relationship: ApiRelationshipJSON, + statuses: ImmutableList, +): 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, 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, + ); + }) + .addCase(muteAccountSuccess, (state, action) => { + filterContexts( + state, + action.payload.relationship, + action.payload.statuses as ImmutableList, + ); + }) + .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); + }, + ); +}); diff --git a/app/javascript/flavours/glitch/reducers/index.ts b/app/javascript/flavours/glitch/reducers/index.ts index 33ed4ec967..e57eedd67e 100644 --- a/app/javascript/flavours/glitch/reducers/index.ts +++ b/app/javascript/flavours/glitch/reducers/index.ts @@ -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, diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index 39df0e956d..8c54af6393 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -64,6 +64,7 @@ const statusTranslateUndo = (state, id) => { }); }; +/** @type {ImmutableMap>} */ const initialState = ImmutableMap(); /** @type {import('@reduxjs/toolkit').Reducer} */ diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js index 0ea3debc6c..77d2f3ea61 100644 --- a/app/javascript/flavours/glitch/reducers/user_lists.js +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -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 diff --git a/app/javascript/flavours/glitch/selectors/contexts.ts b/app/javascript/flavours/glitch/selectors/contexts.ts new file mode 100644 index 0000000000..a9662bcc1d --- /dev/null +++ b/app/javascript/flavours/glitch/selectors/contexts.ts @@ -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; + }, +); diff --git a/app/javascript/flavours/glitch/store/index.ts b/app/javascript/flavours/glitch/store/index.ts index c2629b0ed7..0b9564c909 100644 --- a/app/javascript/flavours/glitch/store/index.ts +++ b/app/javascript/flavours/glitch/store/index.ts @@ -3,6 +3,7 @@ export type { GetState, AppDispatch, RootState } from './store'; export { createAppAsyncThunk, + createAppSelector, useAppDispatch, useAppSelector, } from './typed_functions'; diff --git a/app/javascript/flavours/glitch/store/typed_functions.ts b/app/javascript/flavours/glitch/store/typed_functions.ts index 9fcc90c61b..f0a18a0681 100644 --- a/app/javascript/flavours/glitch/store/typed_functions.ts +++ b/app/javascript/flavours/glitch/store/typed_functions.ts @@ -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(); + interface AppThunkConfig { state: RootState; dispatch: AppDispatch; diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index b0c97cc6ec..37c31cae6c 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -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); } } } diff --git a/app/javascript/mastodon/actions/accounts_typed.ts b/app/javascript/mastodon/actions/accounts_typed.ts index fcdec97e08..fe7c7327ce 100644 --- a/app/javascript/mastodon/actions/accounts_typed.ts +++ b/app/javascript/mastodon/actions/accounts_typed.ts @@ -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: 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; + }, +); diff --git a/app/javascript/mastodon/actions/featured_tags.js b/app/javascript/mastodon/actions/featured_tags.js deleted file mode 100644 index 6ee4dee2bc..0000000000 --- a/app/javascript/mastodon/actions/featured_tags.js +++ /dev/null @@ -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, -}); diff --git a/app/javascript/mastodon/actions/featured_tags.ts b/app/javascript/mastodon/actions/featured_tags.ts new file mode 100644 index 0000000000..12ed6282af --- /dev/null +++ b/app/javascript/mastodon/actions/featured_tags.ts @@ -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), +); diff --git a/app/javascript/mastodon/actions/importer/accounts.ts b/app/javascript/mastodon/actions/importer/accounts.ts new file mode 100644 index 0000000000..9eedad6da5 --- /dev/null +++ b/app/javascript/mastodon/actions/importer/accounts.ts @@ -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', +); diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index a527043940..becbdb88c3 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -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'; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 1e5b53c687..42d0c1c0f1 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -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)); diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts new file mode 100644 index 0000000000..b98abbe122 --- /dev/null +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -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, + }; + }, +); diff --git a/app/javascript/mastodon/actions/tags_typed.ts b/app/javascript/mastodon/actions/tags_typed.ts index 6dca32fd84..a3e5cfd125 100644 --- a/app/javascript/mastodon/actions/tags_typed.ts +++ b/app/javascript/mastodon/actions/tags_typed.ts @@ -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), +); diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index c574a47459..074bcffaa1 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -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(`v1/accounts/${id}/note`, { @@ -23,3 +25,9 @@ export const apiRemoveAccountFromFollowers = (id: string) => apiRequestPost( `v1/accounts/${id}/remove_from_followers`, ); + +export const apiGetFeaturedTags = (id: string) => + apiRequestGet(`v1/accounts/${id}/featured_tags`); + +export const apiGetEndorsedAccounts = (id: string) => + apiRequestGet(`v1/accounts/${id}/endorsements`); diff --git a/app/javascript/mastodon/api/statuses.ts b/app/javascript/mastodon/api/statuses.ts new file mode 100644 index 0000000000..921a7bfe63 --- /dev/null +++ b/app/javascript/mastodon/api/statuses.ts @@ -0,0 +1,5 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { ApiContextJSON } from 'mastodon/api_types/statuses'; + +export const apiGetContext = (statusId: string) => + apiRequestGet(`v1/statuses/${statusId}/context`); diff --git a/app/javascript/mastodon/api/tags.ts b/app/javascript/mastodon/api/tags.ts index 4b111def81..cb84ccb1c4 100644 --- a/app/javascript/mastodon/api/tags.ts +++ b/app/javascript/mastodon/api/tags.ts @@ -10,6 +10,12 @@ export const apiFollowTag = (tagId: string) => export const apiUnfollowTag = (tagId: string) => apiRequestPost(`v1/tags/${tagId}/unfollow`); +export const apiFeatureTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/feature`); + +export const apiUnfeatureTag = (tagId: string) => + apiRequestPost(`v1/tags/${tagId}/unfeature`); + export const apiGetFollowedTags = async (url?: string) => { const response = await api().request({ method: 'GET', diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 2c59645ea7..09bd2349b3 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -119,3 +119,8 @@ export interface ApiStatusJSON { card?: ApiPreviewCardJSON; poll?: ApiPollJSON; } + +export interface ApiContextJSON { + ancestors: ApiStatusJSON[]; + descendants: ApiStatusJSON[]; +} diff --git a/app/javascript/mastodon/api_types/tags.ts b/app/javascript/mastodon/api_types/tags.ts index 0c16c8bd28..3066b4f1f1 100644 --- a/app/javascript/mastodon/api_types/tags.ts +++ b/app/javascript/mastodon/api_types/tags.ts @@ -10,4 +10,5 @@ export interface ApiHashtagJSON { url: string; history: [ApiHistoryJSON, ...ApiHistoryJSON[]]; following?: boolean; + featuring?: boolean; } diff --git a/app/javascript/mastodon/blurhash.ts b/app/javascript/mastodon/blurhash.ts index cafe7b12dc..a1d1a0f4e1 100644 --- a/app/javascript/mastodon/blurhash.ts +++ b/app/javascript/mastodon/blurhash.ts @@ -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; } diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index 886d517fa9..23d77f0dda 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -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: Item, index: number, @@ -320,6 +297,7 @@ interface DropdownProps { scrollable?: boolean; scrollKey?: string; status?: ImmutableMap; + forceDropdown?: boolean; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; onOpen?: () => void; @@ -339,6 +317,7 @@ export const Dropdown = ({ disabled, scrollable, status, + forceDropdown = false, renderItem, renderHeader, onOpen, @@ -354,6 +333,9 @@ export const Dropdown = ({ const open = currentId === openDropdownId; const activeElement = useRef(null); const targetRef = useRef(null); + const prefetchAccountId = status + ? status.getIn(['account', 'id']) + : undefined; const handleClose = useCallback(() => { if (activeElement.current) { @@ -402,16 +384,15 @@ export const Dropdown = ({ } 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 = ({ [ dispatch, currentId, + prefetchAccountId, scrollKey, onOpen, handleItemClick, open, - status, items, + forceDropdown, handleClose, ], ); diff --git a/app/javascript/mastodon/components/edited_timestamp/index.tsx b/app/javascript/mastodon/components/edited_timestamp/index.tsx index 4a33210199..63b21cf5bd 100644 --- a/app/javascript/mastodon/components/edited_timestamp/index.tsx +++ b/app/javascript/mastodon/components/edited_timestamp/index.tsx @@ -116,6 +116,7 @@ export const EditedTimestamp: React.FC<{ renderHeader={renderHeader} onOpen={handleOpen} onItemClick={handleItemClick} + forceDropdown > ); }; diff --git a/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx b/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx deleted file mode 100644 index 50f91a9275..0000000000 --- a/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx +++ /dev/null @@ -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 ( -
    - - -
    - ); - } - -} - -export default connect()(PictureInPicturePlaceholder); diff --git a/app/javascript/mastodon/components/picture_in_picture_placeholder.tsx b/app/javascript/mastodon/components/picture_in_picture_placeholder.tsx new file mode 100644 index 0000000000..829ab5febd --- /dev/null +++ b/app/javascript/mastodon/components/picture_in_picture_placeholder.tsx @@ -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 ( +
    + + +
    + ); +}; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 1fe9b096e1..21d596a58c 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -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 = ( -
    -
    - -
    - ); - } 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')} diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx index 9c07341faa..e826dbfa96 100644 --- a/app/javascript/mastodon/containers/media_container.jsx +++ b/app/javascript/mastodon/containers/media_container.jsx @@ -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'; diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 70e411f61a..d516bc3411 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -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, ); + const featuredAccountIds = useAppSelector( + (state) => + state.user_lists.getIn( + ['featured_accounts', accountId, 'items'], + ImmutableList(), + ) as ImmutableList, + ); + + if (accountId === null) { + return ; + } if (isLoading) { return ( @@ -78,7 +94,11 @@ const AccountFeatured = () => { ); } - if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) { + if ( + featuredStatusIds.isEmpty() && + featuredTags.isEmpty() && + featuredAccountIds.isEmpty() + ) { return ( { ))} )} + {!featuredAccountIds.isEmpty() && ( + <> +

    + +

    + {featuredAccountIds.map((featuredAccountId) => ( + + ))} + + )} diff --git a/app/javascript/mastodon/features/account_gallery/index.tsx b/app/javascript/mastodon/features/account_gallery/index.tsx index 0027329c93..594f71cb23 100644 --- a/app/javascript/mastodon/features/account_gallery/index.tsx +++ b/app/javascript/mastodon/features/account_gallery/index.tsx @@ -147,7 +147,7 @@ export const AccountGallery: React.FC<{ [dispatch], ); - if (accountId && !isAccount) { + if (accountId === null) { return ; } diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index 9d4825d302..b7908cc8d3 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -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), diff --git a/app/javascript/mastodon/features/account_timeline/index.jsx b/app/javascript/mastodon/features/account_timeline/index.jsx index a5223275b3..6fc7d0a4ef 100644 --- a/app/javascript/mastodon/features/account_timeline/index.jsx +++ b/app/javascript/mastodon/features/account_timeline/index.jsx @@ -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) { diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx index e2d05a99ca..08e4a8917c 100644 --- a/app/javascript/mastodon/features/alt_text_modal/index.tsx +++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx @@ -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 (