Merge pull request #912 from kmycode/upstream-20241126

Upstream 20241126
This commit is contained in:
KMY(雪あすか) 2024-11-29 12:08:39 +09:00 committed by GitHub
commit 8d94a8dfac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
387 changed files with 9794 additions and 9803 deletions

View file

@ -39,4 +39,4 @@ jobs:
bundler-cache: true
- name: Run bundler-audit
run: bundle exec bundler-audit check --update
run: bin/bundler-audit check --update

View file

@ -41,18 +41,18 @@ jobs:
git diff --exit-code
- name: Check locale file normalization
run: bundle exec i18n-tasks check-normalized
run: bin/i18n-tasks check-normalized
- name: Check for unused strings
run: bundle exec i18n-tasks unused
run: bin/i18n-tasks unused
- name: Check for missing strings in English YML
run: |
bundle exec i18n-tasks add-missing -l en
bin/i18n-tasks add-missing -l en
git diff --exit-code
- name: Check for wrong string interpolations
run: bundle exec i18n-tasks check-consistent-interpolations
run: bin/i18n-tasks check-consistent-interpolations
- name: Check that all required locale files exist
run: bundle exec rake repo:check_locales_files
run: bin/rake repo:check_locales_files

View file

@ -46,7 +46,7 @@ jobs:
uses: ./.github/actions/setup-ruby
- name: Run i18n normalize task
run: bundle exec i18n-tasks normalize
run: bin/i18n-tasks normalize
# Create or update the pull request
- name: Create Pull Request

View file

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

View file

@ -1,4 +1,4 @@
# syntax=docker/dockerfile:1.10
# syntax=docker/dockerfile:1.11
# This file is designed for production server deployment, not local development work
# For a containerized local dev environment, see: https://github.com/mastodon/mastodon/blob/main/README.md#docker

View file

@ -114,7 +114,7 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.29.0', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.25.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.32.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.33.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false

View file

@ -93,18 +93,17 @@ GEM
annotaterb (4.13.0)
ast (2.4.2)
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.1008.0)
aws-sdk-core (3.213.0)
aws-partitions (1.1013.0)
aws-sdk-core (3.214.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.95.0)
aws-sdk-kms (1.96.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.171.0)
aws-sdk-s3 (1.174.0)
aws-sdk-core (~> 3, >= 3.210.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@ -200,7 +199,7 @@ GEM
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
domain_name (0.6.20240107)
doorkeeper (5.7.1)
doorkeeper (5.8.0)
railties (>= 5)
dotenv (3.1.4)
drb (2.2.1)
@ -346,10 +345,11 @@ GEM
json-ld-preloaded (3.3.1)
json-ld (~> 3.3)
rdf (~> 3.3)
json-schema (5.0.1)
json-schema (5.1.0)
addressable (~> 2.8)
jsonapi-renderer (0.2.2)
jwt (2.7.1)
jwt (2.9.3)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -411,7 +411,7 @@ GEM
minitest (5.25.1)
msgpack (1.7.5)
multi_json (1.15.0)
mutex_m (0.2.0)
mutex_m (0.3.0)
net-http (0.5.0)
uri
net-imap (0.5.1)
@ -424,7 +424,7 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nio4r (2.7.4)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
@ -478,13 +478,13 @@ GEM
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-action_pack (0.9.0)
opentelemetry-instrumentation-action_pack (0.10.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (~> 0.21)
opentelemetry-instrumentation-action_view (0.7.2)
opentelemetry-instrumentation-action_view (0.7.3)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-active_support (~> 0.6)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_job (0.7.8)
opentelemetry-api (~> 1.0)
@ -527,10 +527,10 @@ GEM
opentelemetry-instrumentation-rack (0.25.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rails (0.32.0)
opentelemetry-instrumentation-rails (0.33.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.2.0)
opentelemetry-instrumentation-action_pack (~> 0.9.0)
opentelemetry-instrumentation-action_pack (~> 0.10.0)
opentelemetry-instrumentation-action_view (~> 0.7.0)
opentelemetry-instrumentation-active_job (~> 0.7.0)
opentelemetry-instrumentation-active_record (~> 0.8.0)
@ -580,7 +580,7 @@ GEM
psych (5.2.0)
stringio
public_suffix (6.0.1)
puma (6.4.3)
puma (6.5.0)
nio4r (~> 2.0)
pundit (2.4.0)
activesupport (>= 3.0.0)
@ -752,7 +752,7 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.3.2)
selenium-webdriver (4.26.0)
selenium-webdriver (4.27.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@ -844,9 +844,8 @@ GEM
public_suffix
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.1.0)
webauthn (3.2.2)
android_key_attestation (~> 0.3.0)
awrence (~> 1.1)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
@ -966,7 +965,7 @@ DEPENDENCIES
opentelemetry-instrumentation-net_http (~> 0.22.4)
opentelemetry-instrumentation-pg (~> 0.29.0)
opentelemetry-instrumentation-rack (~> 0.25.0)
opentelemetry-instrumentation-rails (~> 0.32.0)
opentelemetry-instrumentation-rails (~> 0.33.0)
opentelemetry-instrumentation-redis (~> 0.25.3)
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
opentelemetry-sdk (~> 1.4)
@ -1031,7 +1030,7 @@ DEPENDENCIES
xorcist (~> 1.1)
RUBY VERSION
ruby 3.3.5p100
ruby 3.3.6p108
BUNDLED WITH
2.5.22
2.5.23

View file

@ -1,51 +0,0 @@
# frozen_string_literal: true
class AntennasController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_antenna, only: [:edit, :update, :destroy]
before_action :set_body_classes
before_action :set_cache_headers
def index
@antennas = current_account.antennas.includes(:antenna_domains).includes(:antenna_tags).includes(:antenna_accounts)
end
def edit; end
def update
if @antenna.update(resource_params)
redirect_to antennas_path
else
render action: :edit
end
end
def destroy
@antenna.destroy
redirect_to antennas_path
end
private
def set_antenna
@antenna = current_account.antennas.find(params[:id])
end
def resource_params
params.require(:antenna).permit(:title, :available, :expires_in)
end
def thin_resource_params
params.require(:antenna).permit(:title)
end
def set_body_classes
@body_classes = 'admin'
end
def set_cache_headers
response.cache_control.replace(private: true, no_store: true)
end
end

View file

@ -15,17 +15,12 @@ class Api::V1::Lists::AccountsController < Api::BaseController
end
def create
ApplicationRecord.transaction do
list_accounts.each do |account|
@list.accounts << account
end
end
AddAccountsToListService.new.call(@list, Account.find(account_ids))
render_empty
end
def destroy
ListAccount.where(list: @list, account_id: account_ids).destroy_all
RemoveAccountsFromListService.new.call(@list, Account.where(id: account_ids))
render_empty
end
@ -43,10 +38,6 @@ class Api::V1::Lists::AccountsController < Api::BaseController
end
end
def list_accounts
Account.find(account_ids)
end
def account_ids
Array(resource_params[:account_ids])
end

View file

@ -22,7 +22,6 @@ class ApplicationController < ActionController::Base
helper_method :use_seamless_external_login?
helper_method :sso_account_settings
helper_method :limited_federation_mode?
helper_method :body_class_string
helper_method :skip_csrf_meta_tags?
rescue_from ActionController::ParameterMissing, Paperclip::AdapterRegistry::NoHandlerError, with: :bad_request
@ -158,10 +157,6 @@ class ApplicationController < ActionController::Base
current_user.setting_theme
end
def body_class_string
@body_classes || ''
end
def respond_with_error(code)
respond_to do |format|
format.any { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }

View file

@ -38,7 +38,7 @@ module CacheConcern
return render(options)
end
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':')
key = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields]&.join(',')].compact.join(':')
expires_in = options.delete(:expires_in) || 3.minutes
body = Rails.cache.read(key, raw: true)

View file

@ -145,7 +145,7 @@ module ApplicationHelper
end
def body_classes
output = body_class_string.split
output = []
output << content_for(:body_classes)
output << "theme-#{current_theme.parameterize}"
output << 'system-font' if current_account&.user&.setting_system_font_ui

View file

@ -1,8 +1,5 @@
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
export const ANTENNA_FETCH_REQUEST = 'ANTENNA_FETCH_REQUEST';
export const ANTENNA_FETCH_SUCCESS = 'ANTENNA_FETCH_SUCCESS';
export const ANTENNA_FETCH_FAIL = 'ANTENNA_FETCH_FAIL';
@ -11,121 +8,10 @@ export const ANTENNAS_FETCH_REQUEST = 'ANTENNAS_FETCH_REQUEST';
export const ANTENNAS_FETCH_SUCCESS = 'ANTENNAS_FETCH_SUCCESS';
export const ANTENNAS_FETCH_FAIL = 'ANTENNAS_FETCH_FAIL';
export const ANTENNA_EDITOR_TITLE_CHANGE = 'ANTENNA_EDITOR_TITLE_CHANGE';
export const ANTENNA_EDITOR_RESET = 'ANTENNA_EDITOR_RESET';
export const ANTENNA_EDITOR_SETUP = 'ANTENNA_EDITOR_SETUP';
export const ANTENNA_CREATE_REQUEST = 'ANTENNA_CREATE_REQUEST';
export const ANTENNA_CREATE_SUCCESS = 'ANTENNA_CREATE_SUCCESS';
export const ANTENNA_CREATE_FAIL = 'ANTENNA_CREATE_FAIL';
export const ANTENNA_UPDATE_REQUEST = 'ANTENNA_UPDATE_REQUEST';
export const ANTENNA_UPDATE_SUCCESS = 'ANTENNA_UPDATE_SUCCESS';
export const ANTENNA_UPDATE_FAIL = 'ANTENNA_UPDATE_FAIL';
export const ANTENNA_DELETE_REQUEST = 'ANTENNA_DELETE_REQUEST';
export const ANTENNA_DELETE_SUCCESS = 'ANTENNA_DELETE_SUCCESS';
export const ANTENNA_DELETE_FAIL = 'ANTENNA_DELETE_FAIL';
export const ANTENNA_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_ACCOUNTS_FETCH_REQUEST';
export const ANTENNA_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_ACCOUNTS_FETCH_SUCCESS';
export const ANTENNA_ACCOUNTS_FETCH_FAIL = 'ANTENNA_ACCOUNTS_FETCH_FAIL';
export const ANTENNA_EDITOR_SUGGESTIONS_CHANGE = 'ANTENNA_EDITOR_SUGGESTIONS_CHANGE';
export const ANTENNA_EDITOR_SUGGESTIONS_READY = 'ANTENNA_EDITOR_SUGGESTIONS_READY';
export const ANTENNA_EDITOR_SUGGESTIONS_CLEAR = 'ANTENNA_EDITOR_SUGGESTIONS_CLEAR';
export const ANTENNA_EDITOR_ADD_REQUEST = 'ANTENNA_EDITOR_ADD_REQUEST';
export const ANTENNA_EDITOR_ADD_SUCCESS = 'ANTENNA_EDITOR_ADD_SUCCESS';
export const ANTENNA_EDITOR_ADD_FAIL = 'ANTENNA_EDITOR_ADD_FAIL';
export const ANTENNA_EDITOR_REMOVE_REQUEST = 'ANTENNA_EDITOR_REMOVE_REQUEST';
export const ANTENNA_EDITOR_REMOVE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_FAIL = 'ANTENNA_EDITOR_REMOVE_FAIL';
export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST';
export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS';
export const ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL = 'ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL';
export const ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST';
export const ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS';
export const ANTENNA_EDITOR_ADD_EXCLUDE_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_FAIL';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL';
export const ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST = 'ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST';
export const ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS = 'ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS';
export const ANTENNA_EDITOR_FETCH_DOMAINS_FAIL = 'ANTENNA_EDITOR_FETCH_DOMAINS_FAIL';
export const ANTENNA_EDITOR_ADD_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_DOMAIN_REQUEST';
export const ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS';
export const ANTENNA_EDITOR_ADD_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_DOMAIN_FAIL';
export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDEDOMAIN_REQUEST';
export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS';
export const ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL';
export const ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST';
export const ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL';
export const ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST = 'ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST';
export const ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS = 'ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS';
export const ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL = 'ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL';
export const ANTENNA_EDITOR_ADD_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_KEYWORD_REQUEST';
export const ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS';
export const ANTENNA_EDITOR_ADD_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_KEYWORD_FAIL';
export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST';
export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS';
export const ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL';
export const ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST';
export const ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL';
export const ANTENNA_EDITOR_FETCH_TAGS_REQUEST = 'ANTENNA_EDITOR_FETCH_TAGS_REQUEST';
export const ANTENNA_EDITOR_FETCH_TAGS_SUCCESS = 'ANTENNA_EDITOR_FETCH_TAGS_SUCCESS';
export const ANTENNA_EDITOR_FETCH_TAGS_FAIL = 'ANTENNA_EDITOR_FETCH_TAGS_FAIL';
export const ANTENNA_EDITOR_ADD_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_TAG_REQUEST';
export const ANTENNA_EDITOR_ADD_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_TAG_SUCCESS';
export const ANTENNA_EDITOR_ADD_TAG_FAIL = 'ANTENNA_EDITOR_ADD_TAG_FAIL';
export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST';
export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS';
export const ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL';
export const ANTENNA_EDITOR_REMOVE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_TAG_REQUEST';
export const ANTENNA_EDITOR_REMOVE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_TAG_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_TAG_FAIL';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL = 'ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL';
export const ANTENNA_ADDER_RESET = 'ANTENNA_ADDER_RESET';
export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP';
export const ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST';
export const ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS';
export const ANTENNA_ADDER_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_ANTENNAS_FETCH_FAIL';
export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST';
export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS';
export const ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL = 'ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL';
export const fetchAntenna = id => (dispatch, getState) => {
if (getState().getIn(['antennas', id])) {
return;
@ -176,98 +62,6 @@ export const fetchAntennasFail = error => ({
error,
});
export const submitAntennaEditor = shouldReset => (dispatch, getState) => {
const antennaId = getState().getIn(['antennaEditor', 'antennaId']);
const title = getState().getIn(['antennaEditor', 'title']);
if (antennaId === null) {
dispatch(createAntenna(title, shouldReset));
} else {
dispatch(updateAntenna(antennaId, title, shouldReset));
}
};
export const setupAntennaEditor = antennaId => (dispatch, getState) => {
dispatch({
type: ANTENNA_EDITOR_SETUP,
antenna: getState().getIn(['antennas', antennaId]),
});
dispatch(fetchAntennaAccounts(antennaId));
};
export const setupExcludeAntennaEditor = antennaId => (dispatch, getState) => {
dispatch({
type: ANTENNA_EDITOR_SETUP,
antenna: getState().getIn(['antennas', antennaId]),
});
dispatch(fetchAntennaExcludeAccounts(antennaId));
};
export const changeAntennaEditorTitle = value => ({
type: ANTENNA_EDITOR_TITLE_CHANGE,
value,
});
export const createAntenna = (title, shouldReset) => (dispatch, getState) => {
dispatch(createAntennaRequest());
api(getState).post('/api/v1/antennas', { title }).then(({ data }) => {
dispatch(createAntennaSuccess(data));
if (shouldReset) {
dispatch(resetAntennaEditor());
}
}).catch(err => dispatch(createAntennaFail(err)));
};
export const createAntennaRequest = () => ({
type: ANTENNA_CREATE_REQUEST,
});
export const createAntennaSuccess = antenna => ({
type: ANTENNA_CREATE_SUCCESS,
antenna,
});
export const createAntennaFail = error => ({
type: ANTENNA_CREATE_FAIL,
error,
});
export const updateAntenna = (id, title, shouldReset, list_id, stl, ltl, with_media_only, ignore_reblog, insert_feeds) => (dispatch, getState) => {
dispatch(updateAntennaRequest(id));
api(getState).put(`/api/v1/antennas/${id}`, { title, list_id, stl, ltl, with_media_only, ignore_reblog, insert_feeds }).then(({ data }) => {
dispatch(updateAntennaSuccess(data));
if (shouldReset) {
dispatch(resetAntennaEditor());
}
}).catch(err => dispatch(updateAntennaFail(id, err)));
};
export const updateAntennaRequest = id => ({
type: ANTENNA_UPDATE_REQUEST,
id,
});
export const updateAntennaSuccess = antenna => ({
type: ANTENNA_UPDATE_SUCCESS,
antenna,
});
export const updateAntennaFail = (id, error) => ({
type: ANTENNA_UPDATE_FAIL,
id,
error,
});
export const resetAntennaEditor = () => ({
type: ANTENNA_EDITOR_RESET,
});
export const deleteAntenna = id => (dispatch, getState) => {
dispatch(deleteAntennaRequest(id));
@ -291,696 +85,3 @@ export const deleteAntennaFail = (id, error) => ({
id,
error,
});
export const fetchAntennaAccounts = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaAccountsRequest(antennaId));
api(getState).get(`/api/v1/antennas/${antennaId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAntennaAccountsSuccess(antennaId, data));
}).catch(err => dispatch(fetchAntennaAccountsFail(antennaId, err)));
};
export const fetchAntennaAccountsRequest = id => ({
type: ANTENNA_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchAntennaAccountsSuccess = (id, accounts, next) => ({
type: ANTENNA_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchAntennaAccountsFail = (id, error) => ({
type: ANTENNA_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const fetchAntennaExcludeAccounts = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaExcludeAccountsRequest(antennaId));
api(getState).get(`/api/v1/antennas/${antennaId}/exclude_accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAntennaExcludeAccountsSuccess(antennaId, data));
}).catch(err => dispatch(fetchAntennaExcludeAccountsFail(antennaId, err)));
};
export const fetchAntennaExcludeAccountsRequest = id => ({
type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchAntennaExcludeAccountsSuccess = (id, accounts, next) => ({
type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchAntennaExcludeAccountsFail = (id, error) => ({
type: ANTENNA_EXCLUDE_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const fetchAntennaSuggestions = q => (dispatch, getState) => {
const params = {
q,
resolve: false,
};
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchAntennaSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchAntennaSuggestionsReady = (query, accounts) => ({
type: ANTENNA_EDITOR_SUGGESTIONS_READY,
query,
accounts,
});
export const clearAntennaSuggestions = () => ({
type: ANTENNA_EDITOR_SUGGESTIONS_CLEAR,
});
export const changeAntennaSuggestions = value => ({
type: ANTENNA_EDITOR_SUGGESTIONS_CHANGE,
value,
});
export const addToAntennaEditor = accountId => (dispatch, getState) => {
dispatch(addToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
};
export const addToAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(addToAntennaRequest(antennaId, accountId));
api(getState).post(`/api/v1/antennas/${antennaId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addToAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(addToAntennaFail(antennaId, accountId, err)));
};
export const addToAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_REQUEST,
antennaId,
accountId,
});
export const addToAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_SUCCESS,
antennaId,
accountId,
});
export const addToAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_ADD_FAIL,
antennaId,
accountId,
error,
});
export const addExcludeToAntennaEditor = accountId => (dispatch, getState) => {
dispatch(addExcludeToAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
};
export const addExcludeToAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(addExcludeToAntennaRequest(antennaId, accountId));
api(getState).post(`/api/v1/antennas/${antennaId}/exclude_accounts`, { account_ids: [accountId] })
.then(() => dispatch(addExcludeToAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(addExcludeToAntennaFail(antennaId, accountId, err)));
};
export const addExcludeToAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_REQUEST,
antennaId,
accountId,
});
export const addExcludeToAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_SUCCESS,
antennaId,
accountId,
});
export const addExcludeToAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_FAIL,
antennaId,
accountId,
error,
});
export const removeFromAntennaEditor = accountId => (dispatch, getState) => {
dispatch(removeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
};
export const removeFromAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(removeFromAntennaRequest(antennaId, accountId));
api(getState).delete(`/api/v1/antennas/${antennaId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeFromAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(removeFromAntennaFail(antennaId, accountId, err)));
};
export const removeFromAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_REQUEST,
antennaId,
accountId,
});
export const removeFromAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_SUCCESS,
antennaId,
accountId,
});
export const removeFromAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_REMOVE_FAIL,
antennaId,
accountId,
error,
});
export const removeExcludeFromAntennaEditor = accountId => (dispatch, getState) => {
dispatch(removeExcludeFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
};
export const removeExcludeFromAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(removeExcludeFromAntennaRequest(antennaId, accountId));
api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeExcludeFromAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(removeExcludeFromAntennaFail(antennaId, accountId, err)));
};
export const removeExcludeFromAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_REQUEST,
antennaId,
accountId,
});
export const removeExcludeFromAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_SUCCESS,
antennaId,
accountId,
});
export const removeExcludeFromAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_FAIL,
antennaId,
accountId,
error,
});
export const fetchAntennaDomains = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaDomainsRequest(antennaId));
api(getState).get(`/api/v1/antennas/${antennaId}/domains`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(fetchAntennaDomainsSuccess(antennaId, data));
}).catch(err => dispatch(fetchAntennaDomainsFail(antennaId, err)));
};
export const fetchAntennaDomainsRequest = id => ({
type: ANTENNA_EDITOR_FETCH_DOMAINS_REQUEST,
id,
});
export const fetchAntennaDomainsSuccess = (id, domains) => ({
type: ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS,
id,
domains,
});
export const fetchAntennaDomainsFail = (id, error) => ({
type: ANTENNA_EDITOR_FETCH_DOMAINS_FAIL,
id,
error,
});
export const addDomainToAntenna = (antennaId, domain) => (dispatch, getState) => {
dispatch(addDomainToAntennaRequest(antennaId, domain));
api(getState).post(`/api/v1/antennas/${antennaId}/domains`, { domains: [domain] })
.then(() => dispatch(addDomainToAntennaSuccess(antennaId, domain)))
.catch(err => dispatch(addDomainToAntennaFail(antennaId, domain, err)));
};
export const addDomainToAntennaRequest = (antennaId, domain) => ({
type: ANTENNA_EDITOR_ADD_DOMAIN_REQUEST,
antennaId,
domain,
});
export const addDomainToAntennaSuccess = (antennaId, domain) => ({
type: ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS,
antennaId,
domain,
});
export const addDomainToAntennaFail = (antennaId, domain, error) => ({
type: ANTENNA_EDITOR_ADD_DOMAIN_FAIL,
antennaId,
domain,
error,
});
export const removeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => {
dispatch(removeDomainFromAntennaRequest(antennaId, domain));
api(getState).delete(`/api/v1/antennas/${antennaId}/domains`, { params: { domains: [domain] } })
.then(() => dispatch(removeDomainFromAntennaSuccess(antennaId, domain)))
.catch(err => dispatch(removeDomainFromAntennaFail(antennaId, domain, err)));
};
export const removeDomainFromAntennaRequest = (antennaId, domain) => ({
type: ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST,
antennaId,
domain,
});
export const removeDomainFromAntennaSuccess = (antennaId, domain) => ({
type: ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS,
antennaId,
domain,
});
export const removeDomainFromAntennaFail = (antennaId, domain, error) => ({
type: ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL,
antennaId,
domain,
error,
});
export const addExcludeDomainToAntenna = (antennaId, domain) => (dispatch, getState) => {
dispatch(addExcludeDomainToAntennaRequest(antennaId, domain));
api(getState).post(`/api/v1/antennas/${antennaId}/exclude_domains`, { domains: [domain] })
.then(() => dispatch(addExcludeDomainToAntennaSuccess(antennaId, domain)))
.catch(err => dispatch(addExcludeDomainToAntennaFail(antennaId, domain, err)));
};
export const addExcludeDomainToAntennaRequest = (antennaId, domain) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_REQUEST,
antennaId,
domain,
});
export const addExcludeDomainToAntennaSuccess = (antennaId, domain) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_SUCCESS,
antennaId,
domain,
});
export const addExcludeDomainToAntennaFail = (antennaId, domain, error) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_DOMAIN_FAIL,
antennaId,
domain,
error,
});
export const removeExcludeDomainFromAntenna = (antennaId, domain) => (dispatch, getState) => {
dispatch(removeExcludeDomainFromAntennaRequest(antennaId, domain));
api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_domains`, { params: { domains: [domain] } })
.then(() => dispatch(removeExcludeDomainFromAntennaSuccess(antennaId, domain)))
.catch(err => dispatch(removeExcludeDomainFromAntennaFail(antennaId, domain, err)));
};
export const removeExcludeDomainFromAntennaRequest = (antennaId, domain) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_REQUEST,
antennaId,
domain,
});
export const removeExcludeDomainFromAntennaSuccess = (antennaId, domain) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_SUCCESS,
antennaId,
domain,
});
export const removeExcludeDomainFromAntennaFail = (antennaId, domain, error) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_DOMAIN_FAIL,
antennaId,
domain,
error,
});
export const fetchAntennaKeywords = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaKeywordsRequest(antennaId));
api(getState).get(`/api/v1/antennas/${antennaId}/keywords`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(fetchAntennaKeywordsSuccess(antennaId, data));
}).catch(err => dispatch(fetchAntennaKeywordsFail(antennaId, err)));
};
export const fetchAntennaKeywordsRequest = id => ({
type: ANTENNA_EDITOR_FETCH_KEYWORDS_REQUEST,
id,
});
export const fetchAntennaKeywordsSuccess = (id, keywords) => ({
type: ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS,
id,
keywords,
});
export const fetchAntennaKeywordsFail = (id, error) => ({
type: ANTENNA_EDITOR_FETCH_KEYWORDS_FAIL,
id,
error,
});
export const addKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => {
dispatch(addKeywordToAntennaRequest(antennaId, keyword));
api(getState).post(`/api/v1/antennas/${antennaId}/keywords`, { keywords: [keyword] })
.then(() => dispatch(addKeywordToAntennaSuccess(antennaId, keyword)))
.catch(err => dispatch(addKeywordToAntennaFail(antennaId, keyword, err)));
};
export const addKeywordToAntennaRequest = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_ADD_KEYWORD_REQUEST,
antennaId,
keyword,
});
export const addKeywordToAntennaSuccess = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS,
antennaId,
keyword,
});
export const addKeywordToAntennaFail = (antennaId, keyword, error) => ({
type: ANTENNA_EDITOR_ADD_KEYWORD_FAIL,
antennaId,
keyword,
error,
});
export const removeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => {
dispatch(removeKeywordFromAntennaRequest(antennaId, keyword));
api(getState).delete(`/api/v1/antennas/${antennaId}/keywords`, { params: { keywords: [keyword] } })
.then(() => dispatch(removeKeywordFromAntennaSuccess(antennaId, keyword)))
.catch(err => dispatch(removeKeywordFromAntennaFail(antennaId, keyword, err)));
};
export const removeKeywordFromAntennaRequest = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_REMOVE_KEYWORD_REQUEST,
antennaId,
keyword,
});
export const removeKeywordFromAntennaSuccess = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS,
antennaId,
keyword,
});
export const removeKeywordFromAntennaFail = (antennaId, keyword, error) => ({
type: ANTENNA_EDITOR_REMOVE_KEYWORD_FAIL,
antennaId,
keyword,
error,
});
export const addExcludeKeywordToAntenna = (antennaId, keyword) => (dispatch, getState) => {
dispatch(addExcludeKeywordToAntennaRequest(antennaId, keyword));
api(getState).post(`/api/v1/antennas/${antennaId}/exclude_keywords`, { keywords: [keyword] })
.then(() => dispatch(addExcludeKeywordToAntennaSuccess(antennaId, keyword)))
.catch(err => dispatch(addExcludeKeywordToAntennaFail(antennaId, keyword, err)));
};
export const addExcludeKeywordToAntennaRequest = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_REQUEST,
antennaId,
keyword,
});
export const addExcludeKeywordToAntennaSuccess = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_SUCCESS,
antennaId,
keyword,
});
export const addExcludeKeywordToAntennaFail = (antennaId, keyword, error) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_KEYWORD_FAIL,
antennaId,
keyword,
error,
});
export const removeExcludeKeywordFromAntenna = (antennaId, keyword) => (dispatch, getState) => {
dispatch(removeExcludeKeywordFromAntennaRequest(antennaId, keyword));
api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_keywords`, { params: { keywords: [keyword] } })
.then(() => dispatch(removeExcludeKeywordFromAntennaSuccess(antennaId, keyword)))
.catch(err => dispatch(removeExcludeKeywordFromAntennaFail(antennaId, keyword, err)));
};
export const removeExcludeKeywordFromAntennaRequest = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_REQUEST,
antennaId,
keyword,
});
export const removeExcludeKeywordFromAntennaSuccess = (antennaId, keyword) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_SUCCESS,
antennaId,
keyword,
});
export const removeExcludeKeywordFromAntennaFail = (antennaId, keyword, error) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_KEYWORD_FAIL,
antennaId,
keyword,
error,
});
export const fetchAntennaTags = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaTagsRequest(antennaId));
api(getState).get(`/api/v1/antennas/${antennaId}/tags`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(fetchAntennaTagsSuccess(antennaId, data));
}).catch(err => dispatch(fetchAntennaTagsFail(antennaId, err)));
};
export const fetchAntennaTagsRequest = id => ({
type: ANTENNA_EDITOR_FETCH_TAGS_REQUEST,
id,
});
export const fetchAntennaTagsSuccess = (id, tags) => ({
type: ANTENNA_EDITOR_FETCH_TAGS_SUCCESS,
id,
tags,
});
export const fetchAntennaTagsFail = (id, error) => ({
type: ANTENNA_EDITOR_FETCH_TAGS_FAIL,
id,
error,
});
export const addTagToAntenna = (antennaId, tag) => (dispatch, getState) => {
dispatch(addTagToAntennaRequest(antennaId, tag));
api(getState).post(`/api/v1/antennas/${antennaId}/tags`, { tags: [tag] })
.then(() => dispatch(addTagToAntennaSuccess(antennaId, tag)))
.catch(err => dispatch(addTagToAntennaFail(antennaId, tag, err)));
};
export const addTagToAntennaRequest = (antennaId, tag) => ({
type: ANTENNA_EDITOR_ADD_TAG_REQUEST,
antennaId,
tag,
});
export const addTagToAntennaSuccess = (antennaId, tag) => ({
type: ANTENNA_EDITOR_ADD_TAG_SUCCESS,
antennaId,
tag,
});
export const addTagToAntennaFail = (antennaId, tag, error) => ({
type: ANTENNA_EDITOR_ADD_TAG_FAIL,
antennaId,
tag,
error,
});
export const removeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => {
dispatch(removeTagFromAntennaRequest(antennaId, tag));
api(getState).delete(`/api/v1/antennas/${antennaId}/tags`, { params: { tags: [tag] } })
.then(() => dispatch(removeTagFromAntennaSuccess(antennaId, tag)))
.catch(err => dispatch(removeTagFromAntennaFail(antennaId, tag, err)));
};
export const removeTagFromAntennaRequest = (antennaId, tag) => ({
type: ANTENNA_EDITOR_REMOVE_TAG_REQUEST,
antennaId,
tag,
});
export const removeTagFromAntennaSuccess = (antennaId, tag) => ({
type: ANTENNA_EDITOR_REMOVE_TAG_SUCCESS,
antennaId,
tag,
});
export const removeTagFromAntennaFail = (antennaId, tag, error) => ({
type: ANTENNA_EDITOR_REMOVE_TAG_FAIL,
antennaId,
tag,
error,
});
export const addExcludeTagToAntenna = (antennaId, tag) => (dispatch, getState) => {
dispatch(addExcludeTagToAntennaRequest(antennaId, tag));
api(getState).post(`/api/v1/antennas/${antennaId}/exclude_tags`, { tags: [tag] })
.then(() => dispatch(addExcludeTagToAntennaSuccess(antennaId, tag)))
.catch(err => dispatch(addExcludeTagToAntennaFail(antennaId, tag, err)));
};
export const addExcludeTagToAntennaRequest = (antennaId, tag) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_REQUEST,
antennaId,
tag,
});
export const addExcludeTagToAntennaSuccess = (antennaId, tag) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_SUCCESS,
antennaId,
tag,
});
export const addExcludeTagToAntennaFail = (antennaId, tag, error) => ({
type: ANTENNA_EDITOR_ADD_EXCLUDE_TAG_FAIL,
antennaId,
tag,
error,
});
export const removeExcludeTagFromAntenna = (antennaId, tag) => (dispatch, getState) => {
dispatch(removeExcludeTagFromAntennaRequest(antennaId, tag));
api(getState).delete(`/api/v1/antennas/${antennaId}/exclude_tags`, { params: { tags: [tag] } })
.then(() => dispatch(removeExcludeTagFromAntennaSuccess(antennaId, tag)))
.catch(err => dispatch(removeExcludeTagFromAntennaFail(antennaId, tag, err)));
};
export const removeExcludeTagFromAntennaRequest = (antennaId, tag) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_REQUEST,
antennaId,
tag,
});
export const removeExcludeTagFromAntennaSuccess = (antennaId, tag) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_SUCCESS,
antennaId,
tag,
});
export const removeExcludeTagFromAntennaFail = (antennaId, tag, error) => ({
type: ANTENNA_EDITOR_REMOVE_EXCLUDE_TAG_FAIL,
antennaId,
tag,
error,
});
export const resetAntennaAdder = () => ({
type: ANTENNA_ADDER_RESET,
});
export const setupAntennaAdder = accountId => (dispatch, getState) => {
dispatch({
type: ANTENNA_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchAntennas());
dispatch(fetchAccountAntennas(accountId));
};
export const setupExcludeAntennaAdder = accountId => (dispatch, getState) => {
dispatch({
type: ANTENNA_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchAntennas());
dispatch(fetchExcludeAccountAntennas(accountId));
};
export const fetchAccountAntennas = accountId => (dispatch, getState) => {
dispatch(fetchAccountAntennasRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/antennas`)
.then(({ data }) => dispatch(fetchAccountAntennasSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountAntennasFail(accountId, err)));
};
export const fetchAccountAntennasRequest = id => ({
type:ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST,
id,
});
export const fetchAccountAntennasSuccess = (id, antennas) => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
id,
antennas,
});
export const fetchAccountAntennasFail = (id, err) => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
id,
err,
});
export const fetchExcludeAccountAntennas = accountId => (dispatch, getState) => {
dispatch(fetchExcludeAccountAntennasRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/exclude_antennas`)
.then(({ data }) => dispatch(fetchExcludeAccountAntennasSuccess(accountId, data)))
.catch(err => dispatch(fetchExcludeAccountAntennasFail(accountId, err)));
};
export const fetchExcludeAccountAntennasRequest = id => ({
type:ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_REQUEST,
id,
});
export const fetchExcludeAccountAntennasSuccess = (id, antennas) => ({
type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_SUCCESS,
id,
antennas,
});
export const fetchExcludeAccountAntennasFail = (id, err) => ({
type: ANTENNA_ADDER_EXCLUDE_ANTENNAS_FETCH_FAIL,
id,
err,
});
export const addToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const removeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const addExcludeToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addExcludeToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const removeExcludeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeExcludeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};

View file

@ -0,0 +1,13 @@
import { apiCreate, apiUpdate } from 'mastodon/api/antennas';
import type { Antenna } from 'mastodon/models/antenna';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const createAntenna = createDataLoadingThunk(
'antenna/create',
(antenna: Partial<Antenna>) => apiCreate(antenna),
);
export const updateAntenna = createDataLoadingThunk(
'antenna/update',
(antenna: Partial<Antenna>) => apiUpdate(antenna),
);

View file

@ -1,10 +1,6 @@
import { bookmarkCategoryNeeded } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer';
import { unbookmark } from './interactions';
export const BOOKMARK_CATEGORY_FETCH_REQUEST = 'BOOKMARK_CATEGORY_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_FETCH_SUCCESS';
@ -14,18 +10,6 @@ export const BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORIES_FETCH_REQU
export const BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORIES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORIES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE = 'BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE';
export const BOOKMARK_CATEGORY_EDITOR_RESET = 'BOOKMARK_CATEGORY_EDITOR_RESET';
export const BOOKMARK_CATEGORY_EDITOR_SETUP = 'BOOKMARK_CATEGORY_EDITOR_SETUP';
export const BOOKMARK_CATEGORY_CREATE_REQUEST = 'BOOKMARK_CATEGORY_CREATE_REQUEST';
export const BOOKMARK_CATEGORY_CREATE_SUCCESS = 'BOOKMARK_CATEGORY_CREATE_SUCCESS';
export const BOOKMARK_CATEGORY_CREATE_FAIL = 'BOOKMARK_CATEGORY_CREATE_FAIL';
export const BOOKMARK_CATEGORY_UPDATE_REQUEST = 'BOOKMARK_CATEGORY_UPDATE_REQUEST';
export const BOOKMARK_CATEGORY_UPDATE_SUCCESS = 'BOOKMARK_CATEGORY_UPDATE_SUCCESS';
export const BOOKMARK_CATEGORY_UPDATE_FAIL = 'BOOKMARK_CATEGORY_UPDATE_FAIL';
export const BOOKMARK_CATEGORY_DELETE_REQUEST = 'BOOKMARK_CATEGORY_DELETE_REQUEST';
export const BOOKMARK_CATEGORY_DELETE_SUCCESS = 'BOOKMARK_CATEGORY_DELETE_SUCCESS';
export const BOOKMARK_CATEGORY_DELETE_FAIL = 'BOOKMARK_CATEGORY_DELETE_FAIL';
@ -34,25 +18,13 @@ export const BOOKMARK_CATEGORY_STATUSES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_STATU
export const BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL = 'BOOKMARK_CATEGORY_STATUSES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST';
export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_ADD_FAIL = 'BOOKMARK_CATEGORY_EDITOR_ADD_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL';
export const BOOKMARK_CATEGORY_ADDER_RESET = 'BOOKMARK_CATEGORY_ADDER_RESET';
export const BOOKMARK_CATEGORY_ADDER_SETUP = 'BOOKMARK_CATEGORY_ADDER_SETUP';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS';
export const BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL = 'BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_REQUEST';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_SUCCESS';
export const BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL = 'BOOKMARK_CATEGORY_STATUSES_EXPAND_FAIL';
export const BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS';
export const BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS = 'BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS';
export const fetchBookmarkCategory = id => (dispatch, getState) => {
if (getState().getIn(['bookmark_categories', id])) {
return;
@ -103,89 +75,6 @@ export const fetchBookmarkCategoriesFail = error => ({
error,
});
export const submitBookmarkCategoryEditor = shouldReset => (dispatch, getState) => {
const bookmarkCategoryId = getState().getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']);
const title = getState().getIn(['bookmarkCategoryEditor', 'title']);
if (bookmarkCategoryId === null) {
dispatch(createBookmarkCategory(title, shouldReset));
} else {
dispatch(updateBookmarkCategory(bookmarkCategoryId, title, shouldReset));
}
};
export const setupBookmarkCategoryEditor = bookmarkCategoryId => (dispatch, getState) => {
dispatch({
type: BOOKMARK_CATEGORY_EDITOR_SETUP,
bookmarkCategory: getState().getIn(['bookmark_categories', bookmarkCategoryId]),
});
dispatch(fetchBookmarkCategoryStatuses(bookmarkCategoryId));
};
export const changeBookmarkCategoryEditorTitle = value => ({
type: BOOKMARK_CATEGORY_EDITOR_TITLE_CHANGE,
value,
});
export const createBookmarkCategory = (title, shouldReset) => (dispatch, getState) => {
dispatch(createBookmarkCategoryRequest());
api(getState).post('/api/v1/bookmark_categories', { title }).then(({ data }) => {
dispatch(createBookmarkCategorySuccess(data));
if (shouldReset) {
dispatch(resetBookmarkCategoryEditor());
}
}).catch(err => dispatch(createBookmarkCategoryFail(err)));
};
export const createBookmarkCategoryRequest = () => ({
type: BOOKMARK_CATEGORY_CREATE_REQUEST,
});
export const createBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_CREATE_SUCCESS,
bookmarkCategory,
});
export const createBookmarkCategoryFail = error => ({
type: BOOKMARK_CATEGORY_CREATE_FAIL,
error,
});
export const updateBookmarkCategory = (id, title, shouldReset) => (dispatch, getState) => {
dispatch(updateBookmarkCategoryRequest(id));
api(getState).put(`/api/v1/bookmark_categories/${id}`, { title }).then(({ data }) => {
dispatch(updateBookmarkCategorySuccess(data));
if (shouldReset) {
dispatch(resetBookmarkCategoryEditor());
}
}).catch(err => dispatch(updateBookmarkCategoryFail(id, err)));
};
export const updateBookmarkCategoryRequest = id => ({
type: BOOKMARK_CATEGORY_UPDATE_REQUEST,
id,
});
export const updateBookmarkCategorySuccess = bookmarkCategory => ({
type: BOOKMARK_CATEGORY_UPDATE_SUCCESS,
bookmarkCategory,
});
export const updateBookmarkCategoryFail = (id, error) => ({
type: BOOKMARK_CATEGORY_UPDATE_FAIL,
id,
error,
});
export const resetBookmarkCategoryEditor = () => ({
type: BOOKMARK_CATEGORY_EDITOR_RESET,
});
export const deleteBookmarkCategory = id => (dispatch, getState) => {
dispatch(deleteBookmarkCategoryRequest(id));
@ -238,116 +127,6 @@ export const fetchBookmarkCategoryStatusesFail = (id, error) => ({
error,
});
export const addToBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
dispatch(addToBookmarkCategoryRequest(bookmarkCategoryId, statusId));
api(getState).post(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { status_ids: [statusId] })
.then(() => dispatch(addToBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
.catch(err => dispatch(addToBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
};
export const addToBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_REQUEST,
bookmarkCategoryId,
statusId,
});
export const addToBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
bookmarkCategoryId,
statusId,
});
export const addToBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
type: BOOKMARK_CATEGORY_EDITOR_ADD_FAIL,
bookmarkCategoryId,
statusId,
error,
});
export const removeFromBookmarkCategory = (bookmarkCategoryId, statusId) => (dispatch, getState) => {
dispatch(removeFromBookmarkCategoryRequest(bookmarkCategoryId, statusId));
api(getState).delete(`/api/v1/bookmark_categories/${bookmarkCategoryId}/statuses`, { params: { status_ids: [statusId] } })
.then(() => dispatch(removeFromBookmarkCategorySuccess(bookmarkCategoryId, statusId)))
.catch(err => dispatch(removeFromBookmarkCategoryFail(bookmarkCategoryId, statusId, err)));
};
export const removeFromBookmarkCategoryRequest = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_REQUEST,
bookmarkCategoryId,
statusId,
});
export const removeFromBookmarkCategorySuccess = (bookmarkCategoryId, statusId) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
bookmarkCategoryId,
statusId,
});
export const removeFromBookmarkCategoryFail = (bookmarkCategoryId, statusId, error) => ({
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_FAIL,
bookmarkCategoryId,
statusId,
error,
});
export const resetBookmarkCategoryAdder = () => ({
type: BOOKMARK_CATEGORY_ADDER_RESET,
});
export const setupBookmarkCategoryAdder = statusId => (dispatch, getState) => {
dispatch({
type: BOOKMARK_CATEGORY_ADDER_SETUP,
status: getState().getIn(['statuses', statusId]),
});
dispatch(fetchBookmarkCategories());
dispatch(fetchStatusBookmarkCategories(statusId));
};
export const fetchStatusBookmarkCategories = statusId => (dispatch, getState) => {
dispatch(fetchStatusBookmarkCategoriesRequest(statusId));
api(getState).get(`/api/v1/statuses/${statusId}/bookmark_categories`)
.then(({ data }) => dispatch(fetchStatusBookmarkCategoriesSuccess(statusId, data)))
.catch(err => dispatch(fetchStatusBookmarkCategoriesFail(statusId, err)));
};
export const fetchStatusBookmarkCategoriesRequest = id => ({
type:BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_REQUEST,
id,
});
export const fetchStatusBookmarkCategoriesSuccess = (id, bookmarkCategories) => ({
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_SUCCESS,
id,
bookmarkCategories,
});
export const fetchStatusBookmarkCategoriesFail = (id, err) => ({
type: BOOKMARK_CATEGORY_ADDER_BOOKMARK_CATEGORIES_FETCH_FAIL,
id,
err,
});
export const addToBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
dispatch(addToBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
};
export const removeFromBookmarkCategoryAdder = bookmarkCategoryId => (dispatch, getState) => {
if (bookmarkCategoryNeeded) {
const categories = getState().getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']);
if (categories && categories.count() <= 1) {
const status = makeGetStatus()(getState(), { id: getState().getIn(['bookmarkCategoryAdder', 'statusId']) });
dispatch(unbookmark(status));
} else {
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
}
} else {
dispatch(removeFromBookmarkCategory(bookmarkCategoryId, getState().getIn(['bookmarkCategoryAdder', 'statusId'])));
}
};
export function expandBookmarkCategoryStatuses(bookmarkCategoryId) {
return (dispatch, getState) => {
const url = getState().getIn(['bookmark_categories', bookmarkCategoryId, 'next'], null);
@ -392,3 +171,19 @@ export function expandBookmarkCategoryStatusesFail(id, error) {
};
}
export function bookmarkCategoryEditorAddSuccess(id, statusId) {
return {
type: BOOKMARK_CATEGORY_EDITOR_ADD_SUCCESS,
id,
statusId,
};
}
export function bookmarkCategoryEditorRemoveSuccess(id, statusId) {
return {
type: BOOKMARK_CATEGORY_EDITOR_REMOVE_SUCCESS,
id,
statusId,
};
}

View file

@ -0,0 +1,13 @@
import { apiCreate, apiUpdate } from 'mastodon/api/bookmark_categories';
import type { BookmarkCategory } from 'mastodon/models/bookmark_category';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const createBookmarkCategory = createDataLoadingThunk(
'bookmark_category/create',
(bookmarkCategory: Partial<BookmarkCategory>) => apiCreate(bookmarkCategory),
);
export const updateBookmarkCategory = createDataLoadingThunk(
'bookmark_category/update',
(bookmarkCategory: Partial<BookmarkCategory>) => apiUpdate(bookmarkCategory),
);

View file

@ -1,7 +1,6 @@
import api, { getLinks } from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { importFetchedStatuses } from './importer';
export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST';
export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS';
@ -11,10 +10,6 @@ export const CIRCLES_FETCH_REQUEST = 'CIRCLES_FETCH_REQUEST';
export const CIRCLES_FETCH_SUCCESS = 'CIRCLES_FETCH_SUCCESS';
export const CIRCLES_FETCH_FAIL = 'CIRCLES_FETCH_FAIL';
export const CIRCLE_EDITOR_TITLE_CHANGE = 'CIRCLE_EDITOR_TITLE_CHANGE';
export const CIRCLE_EDITOR_RESET = 'CIRCLE_EDITOR_RESET';
export const CIRCLE_EDITOR_SETUP = 'CIRCLE_EDITOR_SETUP';
export const CIRCLE_CREATE_REQUEST = 'CIRCLE_CREATE_REQUEST';
export const CIRCLE_CREATE_SUCCESS = 'CIRCLE_CREATE_SUCCESS';
export const CIRCLE_CREATE_FAIL = 'CIRCLE_CREATE_FAIL';
@ -27,29 +22,6 @@ export const CIRCLE_DELETE_REQUEST = 'CIRCLE_DELETE_REQUEST';
export const CIRCLE_DELETE_SUCCESS = 'CIRCLE_DELETE_SUCCESS';
export const CIRCLE_DELETE_FAIL = 'CIRCLE_DELETE_FAIL';
export const CIRCLE_ACCOUNTS_FETCH_REQUEST = 'CIRCLE_ACCOUNTS_FETCH_REQUEST';
export const CIRCLE_ACCOUNTS_FETCH_SUCCESS = 'CIRCLE_ACCOUNTS_FETCH_SUCCESS';
export const CIRCLE_ACCOUNTS_FETCH_FAIL = 'CIRCLE_ACCOUNTS_FETCH_FAIL';
export const CIRCLE_EDITOR_SUGGESTIONS_CHANGE = 'CIRCLE_EDITOR_SUGGESTIONS_CHANGE';
export const CIRCLE_EDITOR_SUGGESTIONS_READY = 'CIRCLE_EDITOR_SUGGESTIONS_READY';
export const CIRCLE_EDITOR_SUGGESTIONS_CLEAR = 'CIRCLE_EDITOR_SUGGESTIONS_CLEAR';
export const CIRCLE_EDITOR_ADD_REQUEST = 'CIRCLE_EDITOR_ADD_REQUEST';
export const CIRCLE_EDITOR_ADD_SUCCESS = 'CIRCLE_EDITOR_ADD_SUCCESS';
export const CIRCLE_EDITOR_ADD_FAIL = 'CIRCLE_EDITOR_ADD_FAIL';
export const CIRCLE_EDITOR_REMOVE_REQUEST = 'CIRCLE_EDITOR_REMOVE_REQUEST';
export const CIRCLE_EDITOR_REMOVE_SUCCESS = 'CIRCLE_EDITOR_REMOVE_SUCCESS';
export const CIRCLE_EDITOR_REMOVE_FAIL = 'CIRCLE_EDITOR_REMOVE_FAIL';
export const CIRCLE_ADDER_RESET = 'CIRCLE_ADDER_RESET';
export const CIRCLE_ADDER_SETUP = 'CIRCLE_ADDER_SETUP';
export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_REQUEST';
export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS';
export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL';
export const CIRCLE_STATUSES_FETCH_REQUEST = 'CIRCLE_STATUSES_FETCH_REQUEST';
export const CIRCLE_STATUSES_FETCH_SUCCESS = 'CIRCLE_STATUSES_FETCH_SUCCESS';
export const CIRCLE_STATUSES_FETCH_FAIL = 'CIRCLE_STATUSES_FETCH_FAIL';
@ -108,89 +80,6 @@ export const fetchCirclesFail = error => ({
error,
});
export const submitCircleEditor = shouldReset => (dispatch, getState) => {
const circleId = getState().getIn(['circleEditor', 'circleId']);
const title = getState().getIn(['circleEditor', 'title']);
if (circleId === null) {
dispatch(createCircle(title, shouldReset));
} else {
dispatch(updateCircle(circleId, title, shouldReset));
}
};
export const setupCircleEditor = circleId => (dispatch, getState) => {
dispatch({
type: CIRCLE_EDITOR_SETUP,
circle: getState().getIn(['circles', circleId]),
});
dispatch(fetchCircleAccounts(circleId));
};
export const changeCircleEditorTitle = value => ({
type: CIRCLE_EDITOR_TITLE_CHANGE,
value,
});
export const createCircle = (title, shouldReset) => (dispatch, getState) => {
dispatch(createCircleRequest());
api(getState).post('/api/v1/circles', { title }).then(({ data }) => {
dispatch(createCircleSuccess(data));
if (shouldReset) {
dispatch(resetCircleEditor());
}
}).catch(err => dispatch(createCircleFail(err)));
};
export const createCircleRequest = () => ({
type: CIRCLE_CREATE_REQUEST,
});
export const createCircleSuccess = circle => ({
type: CIRCLE_CREATE_SUCCESS,
circle,
});
export const createCircleFail = error => ({
type: CIRCLE_CREATE_FAIL,
error,
});
export const updateCircle = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
dispatch(updateCircleRequest(id));
api(getState).put(`/api/v1/circles/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateCircleSuccess(data));
if (shouldReset) {
dispatch(resetCircleEditor());
}
}).catch(err => dispatch(updateCircleFail(id, err)));
};
export const updateCircleRequest = id => ({
type: CIRCLE_UPDATE_REQUEST,
id,
});
export const updateCircleSuccess = circle => ({
type: CIRCLE_UPDATE_SUCCESS,
circle,
});
export const updateCircleFail = (id, error) => ({
type: CIRCLE_UPDATE_FAIL,
id,
error,
});
export const resetCircleEditor = () => ({
type: CIRCLE_EDITOR_RESET,
});
export const deleteCircle = id => (dispatch, getState) => {
dispatch(deleteCircleRequest(id));
@ -215,169 +104,6 @@ export const deleteCircleFail = (id, error) => ({
error,
});
export const fetchCircleAccounts = circleId => (dispatch, getState) => {
dispatch(fetchCircleAccountsRequest(circleId));
api(getState).get(`/api/v1/circles/${circleId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchCircleAccountsSuccess(circleId, data));
}).catch(err => dispatch(fetchCircleAccountsFail(circleId, err)));
};
export const fetchCircleAccountsRequest = id => ({
type: CIRCLE_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchCircleAccountsSuccess = (id, accounts, next) => ({
type: CIRCLE_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchCircleAccountsFail = (id, error) => ({
type: CIRCLE_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const fetchCircleSuggestions = q => (dispatch, getState) => {
const params = {
q,
resolve: false,
follower: true,
};
api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchCircleSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchCircleSuggestionsReady = (query, accounts) => ({
type: CIRCLE_EDITOR_SUGGESTIONS_READY,
query,
accounts,
});
export const clearCircleSuggestions = () => ({
type: CIRCLE_EDITOR_SUGGESTIONS_CLEAR,
});
export const changeCircleSuggestions = value => ({
type: CIRCLE_EDITOR_SUGGESTIONS_CHANGE,
value,
});
export const addToCircleEditor = accountId => (dispatch, getState) => {
dispatch(addToCircle(getState().getIn(['circleEditor', 'circleId']), accountId));
};
export const addToCircle = (circleId, accountId) => (dispatch, getState) => {
dispatch(addToCircleRequest(circleId, accountId));
api(getState).post(`/api/v1/circles/${circleId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addToCircleSuccess(circleId, accountId)))
.catch(err => dispatch(addToCircleFail(circleId, accountId, err)));
};
export const addToCircleRequest = (circleId, accountId) => ({
type: CIRCLE_EDITOR_ADD_REQUEST,
circleId,
accountId,
});
export const addToCircleSuccess = (circleId, accountId) => ({
type: CIRCLE_EDITOR_ADD_SUCCESS,
circleId,
accountId,
});
export const addToCircleFail = (circleId, accountId, error) => ({
type: CIRCLE_EDITOR_ADD_FAIL,
circleId,
accountId,
error,
});
export const removeFromCircleEditor = accountId => (dispatch, getState) => {
dispatch(removeFromCircle(getState().getIn(['circleEditor', 'circleId']), accountId));
};
export const removeFromCircle = (circleId, accountId) => (dispatch, getState) => {
dispatch(removeFromCircleRequest(circleId, accountId));
api(getState).delete(`/api/v1/circles/${circleId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeFromCircleSuccess(circleId, accountId)))
.catch(err => dispatch(removeFromCircleFail(circleId, accountId, err)));
};
export const removeFromCircleRequest = (circleId, accountId) => ({
type: CIRCLE_EDITOR_REMOVE_REQUEST,
circleId,
accountId,
});
export const removeFromCircleSuccess = (circleId, accountId) => ({
type: CIRCLE_EDITOR_REMOVE_SUCCESS,
circleId,
accountId,
});
export const removeFromCircleFail = (circleId, accountId, error) => ({
type: CIRCLE_EDITOR_REMOVE_FAIL,
circleId,
accountId,
error,
});
export const resetCircleAdder = () => ({
type: CIRCLE_ADDER_RESET,
});
export const setupCircleAdder = accountId => (dispatch, getState) => {
dispatch({
type: CIRCLE_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchCircles());
dispatch(fetchAccountCircles(accountId));
};
export const fetchAccountCircles = accountId => (dispatch, getState) => {
dispatch(fetchAccountCirclesRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/circles`)
.then(({ data }) => dispatch(fetchAccountCirclesSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountCirclesFail(accountId, err)));
};
export const fetchAccountCirclesRequest = id => ({
type:CIRCLE_ADDER_CIRCLES_FETCH_REQUEST,
id,
});
export const fetchAccountCirclesSuccess = (id, circles) => ({
type: CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS,
id,
circles,
});
export const fetchAccountCirclesFail = (id, err) => ({
type: CIRCLE_ADDER_CIRCLES_FETCH_FAIL,
id,
err,
});
export const addToCircleAdder = circleId => (dispatch, getState) => {
dispatch(addToCircle(circleId, getState().getIn(['circleAdder', 'accountId'])));
};
export const removeFromCircleAdder = circleId => (dispatch, getState) => {
dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId'])));
};
export function fetchCircleStatuses(circleId) {
return (dispatch, getState) => {
if (getState().getIn(['circles', circleId, 'isLoading'])) {

View file

@ -0,0 +1,13 @@
import { apiCreate, apiUpdate } from 'mastodon/api/circles';
import type { Circle } from 'mastodon/models/circle';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const createCircle = createDataLoadingThunk(
'circle/create',
(circle: Partial<Circle>) => apiCreate(circle),
);
export const updateCircle = createDataLoadingThunk(
'circle/update',
(circle: Partial<Circle>) => apiUpdate(circle),
);

View file

@ -272,14 +272,14 @@ export function submitCompose() {
insertIfOnline('home');
}
if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility_ex === 'public') {
if (statusId === null && response.data.in_reply_to_id === null && ['public', 'public_unlisted', 'login'].includes(response.data.visibility_ex)) {
insertIfOnline('community');
insertIfOnline('public');
insertIfOnline(`account:${response.data.account.id}`);
}
if (statusId === null && privacy === 'circle' && circleId !== null && circleId !== 0) {
dispatch(submitComposeWithCircleSuccess({ ...response.data }, circleId));
dispatch(submitComposeWithCircleSuccess({ ...response.data }, `${circleId}`));
}
dispatch(showAlert({
@ -310,7 +310,7 @@ export function submitComposeSuccess(status) {
export function submitComposeWithCircleSuccess(status, circleId) {
return {
type: COMPOSE_WITH_CIRCLE_SUCCESS,
status,
statusId: status.id,
circleId,
};
}

View file

@ -2,9 +2,6 @@
import api from '../api';
import { showAlertForError } from './alerts';
import { importFetchedAccounts } from './importer';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL';
@ -13,45 +10,10 @@ export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST';
export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS';
export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL';
export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE';
export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET';
export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP';
export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST';
export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS';
export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL';
export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST';
export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS';
export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL';
export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST';
export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS';
export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL';
export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST';
export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS';
export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL';
export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE';
export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY';
export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR';
export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST';
export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS';
export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL';
export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST';
export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS';
export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL';
export const LIST_ADDER_RESET = 'LIST_ADDER_RESET';
export const LIST_ADDER_SETUP = 'LIST_ADDER_SETUP';
export const LIST_ADDER_LISTS_FETCH_REQUEST = 'LIST_ADDER_LISTS_FETCH_REQUEST';
export const LIST_ADDER_LISTS_FETCH_SUCCESS = 'LIST_ADDER_LISTS_FETCH_SUCCESS';
export const LIST_ADDER_LISTS_FETCH_FAIL = 'LIST_ADDER_LISTS_FETCH_FAIL';
export const fetchList = id => (dispatch, getState) => {
if (getState().getIn(['lists', id])) {
return;
@ -102,94 +64,6 @@ export const fetchListsFail = error => ({
error,
});
export const submitListEditor = shouldReset => (dispatch, getState) => {
const listId = getState().getIn(['listEditor', 'listId']);
const title = getState().getIn(['listEditor', 'title']);
if (listId === null) {
dispatch(createList(title, shouldReset));
} else {
dispatch(updateList(listId, title, shouldReset));
}
};
export const setupListEditor = listId => (dispatch, getState) => {
dispatch({
type: LIST_EDITOR_SETUP,
list: getState().getIn(['lists', listId]),
});
dispatch(fetchListAccounts(listId));
};
export const changeListEditorTitle = value => ({
type: LIST_EDITOR_TITLE_CHANGE,
value,
});
export const createList = (title, shouldReset) => (dispatch) => {
dispatch(createListRequest());
api().post('/api/v1/lists', { title }).then(({ data }) => {
dispatch(createListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(createListFail(err)));
};
export const createListRequest = () => ({
type: LIST_CREATE_REQUEST,
});
export const createListSuccess = list => ({
type: LIST_CREATE_SUCCESS,
list,
});
export const createListFail = error => ({
type: LIST_CREATE_FAIL,
error,
});
export const updateList = (id, title, shouldReset, isExclusive, replies_policy, notify) => (dispatch) => {
dispatch(updateListRequest(id));
api().put(`/api/v1/lists/${id}`, {
title,
replies_policy,
exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive,
notify: typeof notify === 'undefined' ? undefined : !!notify,
}).then(({ data }) => {
dispatch(updateListSuccess(data));
if (shouldReset) {
dispatch(resetListEditor());
}
}).catch(err => dispatch(updateListFail(id, err)));
};
export const updateListRequest = id => ({
type: LIST_UPDATE_REQUEST,
id,
});
export const updateListSuccess = list => ({
type: LIST_UPDATE_SUCCESS,
list,
});
export const updateListFail = (id, error) => ({
type: LIST_UPDATE_FAIL,
id,
error,
});
export const resetListEditor = () => ({
type: LIST_EDITOR_RESET,
});
export const deleteList = id => (dispatch) => {
dispatch(deleteListRequest(id));
@ -213,166 +87,3 @@ export const deleteListFail = (id, error) => ({
id,
error,
});
export const fetchListAccounts = listId => (dispatch) => {
dispatch(fetchListAccountsRequest(listId));
api().get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListAccountsSuccess(listId, data));
}).catch(err => dispatch(fetchListAccountsFail(listId, err)));
};
export const fetchListAccountsRequest = id => ({
type: LIST_ACCOUNTS_FETCH_REQUEST,
id,
});
export const fetchListAccountsSuccess = (id, accounts, next) => ({
type: LIST_ACCOUNTS_FETCH_SUCCESS,
id,
accounts,
next,
});
export const fetchListAccountsFail = (id, error) => ({
type: LIST_ACCOUNTS_FETCH_FAIL,
id,
error,
});
export const fetchListSuggestions = q => (dispatch) => {
const params = {
q,
resolve: false,
following: true,
};
api().get('/api/v1/accounts/search', { params }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchListSuggestionsReady(q, data));
}).catch(error => dispatch(showAlertForError(error)));
};
export const fetchListSuggestionsReady = (query, accounts) => ({
type: LIST_EDITOR_SUGGESTIONS_READY,
query,
accounts,
});
export const clearListSuggestions = () => ({
type: LIST_EDITOR_SUGGESTIONS_CLEAR,
});
export const changeListSuggestions = value => ({
type: LIST_EDITOR_SUGGESTIONS_CHANGE,
value,
});
export const addToListEditor = accountId => (dispatch, getState) => {
dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId));
};
export const addToList = (listId, accountId) => (dispatch) => {
dispatch(addToListRequest(listId, accountId));
api().post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] })
.then(() => dispatch(addToListSuccess(listId, accountId)))
.catch(err => dispatch(addToListFail(listId, accountId, err)));
};
export const addToListRequest = (listId, accountId) => ({
type: LIST_EDITOR_ADD_REQUEST,
listId,
accountId,
});
export const addToListSuccess = (listId, accountId) => ({
type: LIST_EDITOR_ADD_SUCCESS,
listId,
accountId,
});
export const addToListFail = (listId, accountId, error) => ({
type: LIST_EDITOR_ADD_FAIL,
listId,
accountId,
error,
});
export const removeFromListEditor = accountId => (dispatch, getState) => {
dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId));
};
export const removeFromList = (listId, accountId) => (dispatch) => {
dispatch(removeFromListRequest(listId, accountId));
api().delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } })
.then(() => dispatch(removeFromListSuccess(listId, accountId)))
.catch(err => dispatch(removeFromListFail(listId, accountId, err)));
};
export const removeFromListRequest = (listId, accountId) => ({
type: LIST_EDITOR_REMOVE_REQUEST,
listId,
accountId,
});
export const removeFromListSuccess = (listId, accountId) => ({
type: LIST_EDITOR_REMOVE_SUCCESS,
listId,
accountId,
});
export const removeFromListFail = (listId, accountId, error) => ({
type: LIST_EDITOR_REMOVE_FAIL,
listId,
accountId,
error,
});
export const resetListAdder = () => ({
type: LIST_ADDER_RESET,
});
export const setupListAdder = accountId => (dispatch, getState) => {
dispatch({
type: LIST_ADDER_SETUP,
account: getState().getIn(['accounts', accountId]),
});
dispatch(fetchLists());
dispatch(fetchAccountLists(accountId));
};
export const fetchAccountLists = accountId => (dispatch) => {
dispatch(fetchAccountListsRequest(accountId));
api().get(`/api/v1/accounts/${accountId}/lists`)
.then(({ data }) => dispatch(fetchAccountListsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountListsFail(accountId, err)));
};
export const fetchAccountListsRequest = id => ({
type:LIST_ADDER_LISTS_FETCH_REQUEST,
id,
});
export const fetchAccountListsSuccess = (id, lists) => ({
type: LIST_ADDER_LISTS_FETCH_SUCCESS,
id,
lists,
});
export const fetchAccountListsFail = (id, err) => ({
type: LIST_ADDER_LISTS_FETCH_FAIL,
id,
err,
});
export const addToListAdder = listId => (dispatch, getState) => {
dispatch(addToList(listId, getState().getIn(['listAdder', 'accountId'])));
};
export const removeFromListAdder = listId => (dispatch, getState) => {
dispatch(removeFromList(listId, getState().getIn(['listAdder', 'accountId'])));
};

View file

@ -0,0 +1,17 @@
import { apiCreate, apiUpdate } from 'mastodon/api/lists';
import type { List } from 'mastodon/models/list';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
export const createList = createDataLoadingThunk(
'list/create',
(list: Partial<List>) => apiCreate(list),
);
// Kmyblue tracking marker: copied antenna, circle, bookmark_category
export const updateList = createDataLoadingThunk(
'list/update',
(list: Partial<List>) => apiUpdate(list),
);
// Kmyblue tracking marker: copied antenna, circle, bookmark_category

View file

@ -141,6 +141,9 @@ export const pollRecentNotifications = createDataLoadingThunk(
return { notifications };
},
{
useLoadingBar: false,
},
);
export const processNewNotificationForGroups = createAppAsyncThunk(

View file

@ -1,58 +0,0 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
export function fetchSuggestions(withRelationships = false) {
return (dispatch) => {
dispatch(fetchSuggestionsRequest());
api().get('/api/v2/suggestions', { params: { limit: 20 } }).then(response => {
dispatch(importFetchedAccounts(response.data.map(x => x.account)));
dispatch(fetchSuggestionsSuccess(response.data));
if (withRelationships) {
dispatch(fetchRelationships(response.data.map(item => item.account.id)));
}
}).catch(error => dispatch(fetchSuggestionsFail(error)));
};
}
export function fetchSuggestionsRequest() {
return {
type: SUGGESTIONS_FETCH_REQUEST,
skipLoading: true,
};
}
export function fetchSuggestionsSuccess(suggestions) {
return {
type: SUGGESTIONS_FETCH_SUCCESS,
suggestions,
skipLoading: true,
};
}
export function fetchSuggestionsFail(error) {
return {
type: SUGGESTIONS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
}
export const dismissSuggestion = accountId => (dispatch) => {
dispatch({
type: SUGGESTIONS_DISMISS,
id: accountId,
});
api().delete(`/api/v1/suggestions/${accountId}`).catch(() => {});
};

View file

@ -0,0 +1,24 @@
import {
apiGetSuggestions,
apiDeleteSuggestion,
} from 'mastodon/api/suggestions';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const fetchSuggestions = createDataLoadingThunk(
'suggestions/fetch',
() => apiGetSuggestions(20),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data.map((x) => x.account)));
dispatch(fetchRelationships(data.map((x) => x.account.id)));
return data;
},
);
export const dismissSuggestion = createDataLoadingThunk(
'suggestions/dismiss',
({ accountId }: { accountId: string }) => apiDeleteSuggestion(accountId),
);

View file

@ -68,6 +68,7 @@ export async function apiRequest<ApiResponse = unknown>(
method: Method,
url: string,
args: {
signal?: AbortSignal;
params?: RequestParamsOrData;
data?: RequestParamsOrData;
timeout?: number;

View file

@ -0,0 +1,143 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiAntennaJSON } from 'mastodon/api_types/antennas';
export const apiCreate = (antenna: Partial<ApiAntennaJSON>) =>
apiRequestPost<ApiAntennaJSON>('v1/antennas', antenna);
export const apiUpdate = (antenna: Partial<ApiAntennaJSON>) =>
apiRequestPut<ApiAntennaJSON>(`v1/antennas/${antenna.id}`, antenna);
export const apiGetAccounts = (antennaId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/antennas/${antennaId}/accounts`, {
limit: 0,
});
export const apiGetExcludeAccounts = (antennaId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/antennas/${antennaId}/exclude_accounts`, {
limit: 0,
});
export const apiGetDomains = (antennaId: string) =>
apiRequestGet<{ domains: string[]; exclude_domains: string[] }>(
`v1/antennas/${antennaId}/domains`,
{
limit: 0,
},
);
export const apiAddDomain = (antennaId: string, domain: string) =>
apiRequestPost(`v1/antennas/${antennaId}/domains`, {
domains: [domain],
});
export const apiRemoveDomain = (antennaId: string, domain: string) =>
apiRequestDelete(`v1/antennas/${antennaId}/domains`, {
domains: [domain],
});
export const apiAddExcludeDomain = (antennaId: string, domain: string) =>
apiRequestPost(`v1/antennas/${antennaId}/exclude_domains`, {
domains: [domain],
});
export const apiRemoveExcludeDomain = (antennaId: string, domain: string) =>
apiRequestDelete(`v1/antennas/${antennaId}/exclude_domains`, {
domains: [domain],
});
export const apiGetTags = (antennaId: string) =>
apiRequestGet<{ tags: string[]; exclude_tags: string[] }>(
`v1/antennas/${antennaId}/tags`,
{
limit: 0,
},
);
export const apiAddTag = (antennaId: string, tag: string) =>
apiRequestPost(`v1/antennas/${antennaId}/tags`, {
tags: [tag],
});
export const apiRemoveTag = (antennaId: string, tag: string) =>
apiRequestDelete(`v1/antennas/${antennaId}/tags`, {
tags: [tag],
});
export const apiAddExcludeTag = (antennaId: string, tag: string) =>
apiRequestPost(`v1/antennas/${antennaId}/exclude_tags`, {
tags: [tag],
});
export const apiRemoveExcludeTag = (antennaId: string, tag: string) =>
apiRequestDelete(`v1/antennas/${antennaId}/exclude_tags`, {
tags: [tag],
});
export const apiGetKeywords = (antennaId: string) =>
apiRequestGet<{ keywords: string[]; exclude_keywords: string[] }>(
`v1/antennas/${antennaId}/keywords`,
{
limit: 0,
},
);
export const apiAddKeyword = (antennaId: string, keyword: string) =>
apiRequestPost(`v1/antennas/${antennaId}/keywords`, {
keywords: [keyword],
});
export const apiRemoveKeyword = (antennaId: string, keyword: string) =>
apiRequestDelete(`v1/antennas/${antennaId}/keywords`, {
keywords: [keyword],
});
export const apiAddExcludeKeyword = (antennaId: string, keyword: string) =>
apiRequestPost(`v1/antennas/${antennaId}/exclude_keywords`, {
keywords: [keyword],
});
export const apiRemoveExcludeKeyword = (antennaId: string, keyword: string) =>
apiRequestDelete(`v1/antennas/${antennaId}/exclude_keywords`, {
keywords: [keyword],
});
export const apiGetAccountAntennas = (accountId: string) =>
apiRequestGet<ApiAntennaJSON[]>(`v1/accounts/${accountId}/antennas`);
export const apiAddAccountToAntenna = (antennaId: string, accountId: string) =>
apiRequestPost(`v1/antennas/${antennaId}/accounts`, {
account_ids: [accountId],
});
export const apiRemoveAccountFromAntenna = (
antennaId: string,
accountId: string,
) =>
apiRequestDelete(`v1/antennas/${antennaId}/accounts`, {
account_ids: [accountId],
});
export const apiGetExcludeAccountAntennas = (accountId: string) =>
apiRequestGet<ApiAntennaJSON[]>(`v1/accounts/${accountId}/exclude_antennas`);
export const apiAddExcludeAccountToAntenna = (
antennaId: string,
accountId: string,
) =>
apiRequestPost(`v1/antennas/${antennaId}/exclude_accounts`, {
account_ids: [accountId],
});
export const apiRemoveExcludeAccountFromAntenna = (
antennaId: string,
accountId: string,
) =>
apiRequestDelete(`v1/antennas/${antennaId}/exclude_accounts`, {
account_ids: [accountId],
});

View file

@ -0,0 +1,49 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiBookmarkCategoryJSON } from 'mastodon/api_types/bookmark_categories';
export const apiCreate = (bookmarkCategory: Partial<ApiBookmarkCategoryJSON>) =>
apiRequestPost<ApiBookmarkCategoryJSON>(
'v1/bookmark_categories',
bookmarkCategory,
);
export const apiUpdate = (bookmarkCategory: Partial<ApiBookmarkCategoryJSON>) =>
apiRequestPut<ApiBookmarkCategoryJSON>(
`v1/bookmark_categories/${bookmarkCategory.id}`,
bookmarkCategory,
);
export const apiGetStatuses = (bookmarkCategoryId: string) =>
apiRequestGet<ApiAccountJSON[]>(
`v1/bookmark_categories/${bookmarkCategoryId}/statuses`,
{
limit: 0,
},
);
export const apiGetStatusBookmarkCategories = (accountId: string) =>
apiRequestGet<ApiBookmarkCategoryJSON[]>(
`v1/statuses/${accountId}/bookmark_categories`,
);
export const apiAddStatusToBookmarkCategory = (
bookmarkCategoryId: string,
statusId: string,
) =>
apiRequestPost(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, {
status_ids: [statusId],
});
export const apiRemoveStatusFromBookmarkCategory = (
bookmarkCategoryId: string,
statusId: string,
) =>
apiRequestDelete(`v1/bookmark_categories/${bookmarkCategoryId}/statuses`, {
status_ids: [statusId],
});

View file

@ -0,0 +1,35 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiCircleJSON } from 'mastodon/api_types/circles';
export const apiCreate = (circle: Partial<ApiCircleJSON>) =>
apiRequestPost<ApiCircleJSON>('v1/circles', circle);
export const apiUpdate = (circle: Partial<ApiCircleJSON>) =>
apiRequestPut<ApiCircleJSON>(`v1/circles/${circle.id}`, circle);
export const apiGetAccounts = (circleId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/circles/${circleId}/accounts`, {
limit: 0,
});
export const apiGetAccountCircles = (accountId: string) =>
apiRequestGet<ApiCircleJSON[]>(`v1/accounts/${accountId}/circles`);
export const apiAddAccountToCircle = (circleId: string, accountId: string) =>
apiRequestPost(`v1/circles/${circleId}/accounts`, {
account_ids: [accountId],
});
export const apiRemoveAccountFromCircle = (
circleId: string,
accountId: string,
) =>
apiRequestDelete(`v1/circles/${circleId}/accounts`, {
account_ids: [accountId],
});

View file

@ -0,0 +1,32 @@
import {
apiRequestPost,
apiRequestPut,
apiRequestGet,
apiRequestDelete,
} from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { ApiListJSON } from 'mastodon/api_types/lists';
export const apiCreate = (list: Partial<ApiListJSON>) =>
apiRequestPost<ApiListJSON>('v1/lists', list);
export const apiUpdate = (list: Partial<ApiListJSON>) =>
apiRequestPut<ApiListJSON>(`v1/lists/${list.id}`, list);
export const apiGetAccounts = (listId: string) =>
apiRequestGet<ApiAccountJSON[]>(`v1/lists/${listId}/accounts`, {
limit: 0,
});
export const apiGetAccountLists = (accountId: string) =>
apiRequestGet<ApiListJSON[]>(`v1/accounts/${accountId}/lists`);
export const apiAddAccountToList = (listId: string, accountId: string) =>
apiRequestPost(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});
export const apiRemoveAccountFromList = (listId: string, accountId: string) =>
apiRequestDelete(`v1/lists/${listId}/accounts`, {
account_ids: [accountId],
});

View file

@ -0,0 +1,8 @@
import { apiRequestGet, apiRequestDelete } from 'mastodon/api';
import type { ApiSuggestionJSON } from 'mastodon/api_types/suggestions';
export const apiGetSuggestions = (limit: number) =>
apiRequestGet<ApiSuggestionJSON[]>('v2/suggestions', { limit });
export const apiDeleteSuggestion = (accountId: string) =>
apiRequestDelete(`v1/suggestions/${accountId}`);

View file

@ -0,0 +1,16 @@
// See app/serializers/rest/antenna_serializer.rb
import type { ApiListJSON } from './lists';
export interface ApiAntennaJSON {
id: string;
title: string;
stl: boolean;
ltl: boolean;
insert_feeds: boolean;
with_media_only: boolean;
ignore_reblog: boolean;
list: ApiListJSON | null;
list_id: string | undefined;
}

View file

@ -0,0 +1,6 @@
// See app/serializers/rest/bookmark_category_serializer.rb
export interface ApiBookmarkCategoryJSON {
id: string;
title: string;
}

View file

@ -0,0 +1,6 @@
// See app/serializers/rest/circle_serializer.rb
export interface ApiCircleJSON {
id: string;
title: string;
}

View file

@ -1,11 +0,0 @@
// A similar definition will eventually be added in the main house. These definitions will replace it.
export interface ApiListJSON_KmyDummy {
id: string;
title: string;
exclusive: boolean;
notify: boolean;
// replies_policy
// antennas
}

View file

@ -0,0 +1,14 @@
// See app/serializers/rest/list_serializer.rb
import type { ApiAntennaJSON } from './antennas';
export type RepliesPolicyType = 'list' | 'followed' | 'none';
export interface ApiListJSON {
id: string;
title: string;
exclusive: boolean;
replies_policy: RepliesPolicyType;
notify: boolean;
antennas?: ApiAntennaJSON[];
}

View file

@ -3,7 +3,7 @@
import type { AccountWarningAction } from 'mastodon/models/notification_group';
import type { ApiAccountJSON } from './accounts';
import type { ApiListJSON_KmyDummy } from './dummy_types';
import type { ApiListJSON } from './lists';
import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
@ -71,7 +71,7 @@ export interface BaseNotificationJSON {
group_key: string;
account: ApiAccountJSON;
emoji_reaction?: NotifyEmojiReactionJSON;
list?: ApiListJSON_KmyDummy;
list?: ApiListJSON;
}
export interface BaseNotificationGroupJSON {
@ -84,7 +84,7 @@ export interface BaseNotificationGroupJSON {
page_min_id?: string;
page_max_id?: string;
emoji_reaction_groups?: NotificationEmojiReactionGroupJSON[];
list?: ApiListJSON_KmyDummy;
list?: ApiListJSON;
}
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {

View file

@ -0,0 +1,13 @@
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export type ApiSuggestionSourceJSON =
| 'featured'
| 'most_followed'
| 'most_interactions'
| 'similar_to_recently_followed'
| 'friends_of_friends';
export interface ApiSuggestionJSON {
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
account: ApiAccountJSON;
}

View file

@ -10,6 +10,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { EmptyAccount } from 'mastodon/components/empty_account';
import { FollowButton } from 'mastodon/components/follow_button';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
@ -23,9 +24,6 @@ import { DisplayName } from './display_name';
import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
@ -35,13 +33,9 @@ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
});
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, hideButtons, minimal, defaultAction, children, withBio }) => {
const Account = ({ size = 46, account, onBlock, onMute, onMuteNotifications, hidden, hideButtons, minimal, defaultAction, children, withBio }) => {
const intl = useIntl();
const handleFollow = useCallback(() => {
onFollow(account);
}, [onFollow, account]);
const handleBlock = useCallback(() => {
onBlock(account);
}, [onBlock, account]);
@ -74,13 +68,12 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
let buttons;
if (!hideButtons && account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
buttons = <FollowButton accountId={account.get('id')} />;
} else if (blocking) {
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
} else if (muting) {
@ -109,9 +102,11 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
} else if (defaultAction === 'block') {
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
} else if (!account.get('suspended') && !account.get('moved') || following) {
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
} else {
buttons = <FollowButton accountId={account.get('id')} />;
}
} else if (!hideButtons) {
buttons = <FollowButton accountId={account.get('id')} />;
}
let muteTimeRemaining;
@ -179,7 +174,6 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica
Account.propTypes = {
size: PropTypes.number,
account: ImmutablePropTypes.record,
onFollow: PropTypes.func,
onBlock: PropTypes.func,
onMute: PropTypes.func,
onMuteNotifications: PropTypes.func,

View file

@ -7,11 +7,11 @@ import { Icon } from './icon';
interface Props {
value: string;
checked: boolean;
indeterminate: boolean;
name: string;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
label: React.ReactNode;
checked?: boolean;
indeterminate?: boolean;
name?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
label?: React.ReactNode;
}
export const CheckBox: React.FC<Props> = ({
@ -30,6 +30,7 @@ export const CheckBox: React.FC<Props> = ({
value={value}
checked={checked}
onChange={onChange}
readOnly={!onChange}
/>
<span
@ -42,7 +43,7 @@ export const CheckBox: React.FC<Props> = ({
)}
</span>
<span>{label}</span>
{label && <span>{label}</span>}
</label>
);
};

View file

@ -24,7 +24,7 @@ function useHandleClick(onClick?: OnClickCallback) {
}, [history, onClick]);
}
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
onClick,
}) => {
const handleClick = useHandleClick(onClick);

View file

@ -0,0 +1,67 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
export const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
onActivate: () => void;
placeholder: string;
active: boolean;
}> = ({ onBack, onActivate, onSubmit, placeholder, active }) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState('');
useEffect(() => {
if (!active) {
setValue('');
}
}, [active]);
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
onBack();
inputRef.current?.blur();
}
},
[onBack],
);
const handleFocus = useCallback(() => {
onActivate();
}, [onActivate]);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<form className='column-search-header' onSubmit={handleSubmit}>
<input
ref={inputRef}
type='search'
value={value}
onChange={handleChange}
onKeyUp={handleKeyUp}
placeholder={placeholder}
onFocus={handleFocus}
/>
{active && (
<button type='button' className='link-button' onClick={onBack}>
<FormattedMessage id='column_search.cancel' defaultMessage='Cancel' />
</button>
)}
</form>
);
};

View file

@ -103,7 +103,12 @@ export const FollowButton: React.FC<{
return (
<Button
onClick={handleClick}
disabled={relationship?.blocked_by || relationship?.blocking}
disabled={
relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following}
className={following ? 'button--destructive' : undefined}
>

View file

@ -80,6 +80,7 @@ class ScrollableList extends PureComponent {
children: PropTypes.node,
bindToDocument: PropTypes.bool,
preventScroll: PropTypes.bool,
footer: PropTypes.node,
};
static defaultProps = {
@ -324,7 +325,7 @@ class ScrollableList extends PureComponent {
};
render () {
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, footer, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = Children.count(children);
@ -342,11 +343,13 @@ class ScrollableList extends PureComponent {
<div className='scrollable__append'>
<LoadingIndicator />
</div>
{footer}
</div>
);
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
scrollableArea = (
<div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
<div role='feed' className='item-list'>
{prepend}
@ -375,6 +378,8 @@ class ScrollableList extends PureComponent {
{!hasMore && append}
</div>
{footer}
</div>
);
} else {
@ -385,6 +390,8 @@ class ScrollableList extends PureComponent {
<div className='empty-column-indicator'>
{emptyMessage}
</div>
{footer}
</div>
);
}

View file

@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -92,7 +94,6 @@ class Status extends ImmutablePureComponent {
account: ImmutablePropTypes.record,
contextType: PropTypes.string,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
onClick: PropTypes.func,
onReply: PropTypes.func,
@ -174,32 +175,18 @@ class Status extends ImmutablePureComponent {
};
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.handleHotkeyOpen(e);
};
handleMouseUp = e => {
// Only handle clicks on the empty space above the content
if (e.target !== e.currentTarget) {
return;
}
if (e) {
e.preventDefault();
}
this.handleHotkeyOpen();
};
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
};
handleAccountClick = (e, proper = true) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
this._openProfile(proper);
this.handleClick(e);
};
handleExpandedToggle = () => {
@ -297,7 +284,7 @@ class Status extends ImmutablePureComponent {
this.props.onMention(this._properStatus().get('account'));
};
handleHotkeyOpen = () => {
handleHotkeyOpen = (e) => {
if (this.props.onClick) {
this.props.onClick();
return;
@ -310,7 +297,13 @@ class Status extends ImmutablePureComponent {
return;
}
history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
const path = `/@${status.getIn(['account', 'acct'])}/${status.get('id')}`;
if (e?.button === 0 && !(e?.ctrlKey || e?.metaKey)) {
history.push(path);
} else if (e?.button === 1 || (e?.button === 0 && (e?.ctrlKey || e?.metaKey))) {
window.open(path, '_blank', 'noreferrer noopener');
}
};
handleHotkeyOpenProfile = () => {
@ -380,7 +373,7 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, withoutQuote, skipPrepend, avatarSize = 46 } = this.props;
const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, rootId, withoutQuote, skipPrepend, avatarSize = 46 } = this.props;
let { status, account, ...other } = this.props;
@ -408,7 +401,6 @@ class Status extends ImmutablePureComponent {
const connectUp = previousId && previousId === status.get('in_reply_to_id');
const connectToRoot = rootId && rootId === status.get('in_reply_to_id');
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
const matchedFilters = status.get('matched_filters');
let visibilityName = status.get('limited_scope') || status.get('visibility_ex') || status.get('visibility');
@ -427,7 +419,7 @@ class Status extends ImmutablePureComponent {
<div className='status__prepend'>
<div className='status__prepend__icon'><Icon id='retweet' icon={RepeatIcon} /></div>
<div className='status__prepend__icon'><VisibilityIcon visibility={visibilityName} /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <Link data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} to={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></Link> }} />
</div>
);
@ -590,31 +582,24 @@ class Status extends ImmutablePureComponent {
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
{(!matchedFilters || this.state.showDespiteFilter || status.get('filter_action_ex') === 'half_warn') && (
<>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<div onMouseUp={this.handleMouseUp} className='status__info'>
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time'>
{withQuote}
{withReference}
{withExpiration}
{withLimited}
<span className='status__visibility-icon'><VisibilityIcon visibility={visibilityName} /></span>
<span className='status__visibility-icon'><VisibilityIcon visibility={status.get('visibility_ex')} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
</Link>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<Link to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} />
</a>
</Link>
</div>
</>
)}
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}

View file

@ -204,8 +204,8 @@ class StatusContent extends PureComponent {
element = element.parentNode;
}
if (deltaX + deltaY < 5 && e.button === 0 && this.props.onClick) {
this.props.onClick();
if (deltaX + deltaY < 5 && (e.button === 0 || e.button === 1) && this.props.onClick) {
this.props.onClick(e);
}
this.startXY = null;

View file

@ -15,6 +15,13 @@ const mapStateToProps = state => ({
openedViaKeyboard: state.dropdownMenu.keyboard,
});
/**
* @param {any} dispatch
* @param {Object} root0
* @param {any} [root0.status]
* @param {any} root0.items
* @param {any} [root0.scrollKey]
*/
const mapDispatchToProps = (dispatch, { status, items, scrollKey }) => ({
onOpen(id, onItemClick, keyboard) {
if (status) {

View file

@ -1,96 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromAntennaAdder, addToAntennaAdder, removeExcludeFromAntennaAdder, addExcludeToAntennaAdder } from '../../../actions/antennas';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'antennas.account.remove', defaultMessage: 'Remove from antenna' },
add: { id: 'antennas.account.add', defaultMessage: 'Add to antenna' },
});
const MapStateToProps = (state, { antennaId, added }) => ({
antenna: state.get('antennas').get(antennaId),
added: typeof added === 'undefined' ? state.getIn(['antennaAdder', 'antennas', 'items']).includes(antennaId) : added,
});
const mapDispatchToProps = (dispatch, { antennaId }) => ({
onRemove: () => dispatch(removeFromAntennaAdder(antennaId)),
onAdd: () => dispatch(addToAntennaAdder(antennaId)),
onExcludeRemove: () => dispatch(removeExcludeFromAntennaAdder(antennaId)),
onExcludeAdd: () => dispatch(addExcludeToAntennaAdder(antennaId)),
});
class Antenna extends ImmutablePureComponent {
static propTypes = {
antenna: ImmutablePropTypes.map.isRequired,
isExclude: PropTypes.bool.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
onExcludeRemove: PropTypes.func.isRequired,
onExcludeAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
handleRemove = () => {
if (this.props.isExclude) {
this.props.onExcludeRemove();
} else {
this.props.onRemove();
}
};
handleAdd = () => {
if (this.props.isExclude) {
this.props.onExcludeAdd();
} else {
this.props.onAdd();
}
};
render () {
const { antenna, intl, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={this.handleRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={this.handleAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='wifi' icon={AntennaIcon} className='column-link__icon' fixedWidth />
{antenna.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(Antenna));

View file

@ -1,84 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { setupAntennaAdder, resetAntennaAdder, setupExcludeAntennaAdder } from '../../actions/antennas';
import NewAntennaForm from '../antennas/components/new_antenna_form';
import Account from '../list_adder/components/account';
import Antenna from './components/antenna';
// hack
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
antennaIds: getOrderedAntennas(state).map(antenna=>antenna.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupAntennaAdder(accountId)),
onExcludeInitialize: accountId => dispatch(setupExcludeAntennaAdder(accountId)),
onReset: () => dispatch(resetAntennaAdder()),
});
class AntennaAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
isExclude: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onExcludeInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
antennaIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { isExclude, onInitialize, onExcludeInitialize, accountId } = this.props;
if (isExclude) {
onExcludeInitialize(accountId);
} else {
onInitialize(accountId);
}
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, antennaIds, isExclude } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewAntennaForm />
<div className='list-adder__lists'>
{antennaIds.map(antennaId => <Antenna key={antennaId} antennaId={antennaId} isExclude={isExclude} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaAdder));

View file

@ -0,0 +1,227 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import { createAntenna } from 'mastodon/actions/antennas_typed';
import {
apiGetAccountAntennas,
apiAddAccountToAntenna,
apiAddExcludeAccountToAntenna,
apiRemoveAccountFromAntenna,
apiRemoveExcludeAccountFromAntenna,
} from 'mastodon/api/antennas';
import type { ApiAntennaJSON } from 'mastodon/api_types/antennas';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newAntenna: {
id: 'antennas.new_antenna_name',
defaultMessage: 'New antenna name',
},
createAntenna: {
id: 'antennas.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const AntennaItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='antennas__item'>
<div className='antennas__item__title'>
<Icon id='antenna-ul' icon={AntennaIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewAntennaItem: React.FC<{
onCreate: (antenna: ApiAntennaJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createAntenna({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='antennas__item' onSubmit={handleSubmit}>
<label className='antennas__item__title'>
<Icon id='antenna-ul' icon={AntennaIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newAntenna)}
/>
</label>
<Button text={intl.formatMessage(messages.createAntenna)} type='submit' />
</form>
);
};
const AntennaAdder: React.FC<{
accountId: string;
isExclude: boolean;
onClose: () => void;
}> = ({ accountId, isExclude, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const antennas = useAppSelector((state) => getOrderedAntennas(state));
const [antennaIds, setAntennaIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchAntennas());
apiGetAccountAntennas(accountId)
.then((data) => {
setAntennaIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setAntennaIds, accountId]);
const handleToggle = useCallback(
(antennaId: string, checked: boolean) => {
if (checked) {
setAntennaIds((currentAntennaIds) => [antennaId, ...currentAntennaIds]);
const func = isExclude
? apiAddExcludeAccountToAntenna
: apiAddAccountToAntenna;
func(antennaId, accountId).catch(() => {
setAntennaIds((currentAntennaIds) =>
currentAntennaIds.filter((id) => id !== antennaId),
);
});
} else {
setAntennaIds((currentAntennaIds) =>
currentAntennaIds.filter((id) => id !== antennaId),
);
const func = isExclude
? apiRemoveExcludeAccountFromAntenna
: apiRemoveAccountFromAntenna;
func(antennaId, accountId).catch(() => {
setAntennaIds((currentAntennaIds) => [
antennaId,
...currentAntennaIds,
]);
});
}
},
[setAntennaIds, accountId, isExclude],
);
const handleCreate = useCallback(
(antenna: ApiAntennaJSON) => {
setAntennaIds((currentAntennaIds) => [antenna.id, ...currentAntennaIds]);
apiAddAccountToAntenna(antenna.id, accountId).catch(() => {
setAntennaIds((currentAntennaIds) =>
currentAntennaIds.filter((id) => id !== antenna.id),
);
});
},
[setAntennaIds, accountId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='antennas.add_to_antennas'
defaultMessage='Add {name} to antennas'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='antennas-scrollable'>
<NewAntennaItem onCreate={handleCreate} />
{antennas.map((antenna) => (
<AntennaItem
key={antenna.id}
id={antenna.id}
title={antenna.title}
checked={antennaIds.includes(antenna.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AntennaAdder;

View file

@ -1,88 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { removeFromAntennaEditor, addToAntennaEditor, removeExcludeFromAntennaEditor, addExcludeToAntennaEditor } from '../../../actions/antennas';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'antennas.account.remove', defaultMessage: 'Remove from antenna' },
add: { id: 'antennas.account.add', defaultMessage: 'Add to antenna' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['antennaEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromAntennaEditor(accountId)),
onAdd: () => dispatch(addToAntennaEditor(accountId)),
onExcludeRemove: () => dispatch(removeExcludeFromAntennaEditor(accountId)),
onExcludeAdd: () => dispatch(addExcludeToAntennaEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
isExclude: PropTypes.bool.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
onExcludeRemove: PropTypes.func.isRequired,
onExcludeAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { account, intl, isExclude, onRemove, onAdd, onExcludeRemove, onExcludeAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={isExclude ? onExcludeRemove : onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={isExclude ? onExcludeAdd : onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View file

@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { changeAntennaEditorTitle, submitAntennaEditor } from '../../../actions/antennas';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'antennas.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'title']),
disabled: !state.getIn(['antennaEditor', 'isChanged']) || !state.getIn(['antennaEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeAntennaEditorTitle(value)),
onSubmit: () => dispatch(submitAntennaEditor(false)),
});
class AntennaForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
iconComponent={CheckIcon}
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaForm));

View file

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { fetchAntennaSuggestions, clearAntennaSuggestions, changeAntennaSuggestions } from '../../../actions/antennas';
const messages = defineMessages({
search: { id: 'antennas.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchAntennaSuggestions(value)),
onClear: () => dispatch(clearAntennaSuggestions()),
onChange: value => dispatch(changeAntennaSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -1,84 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { setupAntennaEditor, setupExcludeAntennaEditor, clearAntennaSuggestions, resetAntennaEditor } from '../../actions/antennas';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditAntennaForm from './components/edit_antenna_form';
import Search from './components/search';
const mapStateToProps = (state) => ({
accountIds: state.getIn(['antennaEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['antennaEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = (dispatch, { isExclude }) => ({
onInitialize: antennaId => dispatch(isExclude ? setupExcludeAntennaEditor(antennaId) : setupAntennaEditor(antennaId)),
onClear: () => dispatch(clearAntennaSuggestions()),
onReset: () => dispatch(resetAntennaEditor()),
});
class AntennaEditor extends ImmutablePureComponent {
static propTypes = {
antennaId: PropTypes.string.isRequired,
isExclude: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, antennaId } = this.props;
onInitialize(antennaId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear, isExclude } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<EditAntennaForm />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} isExclude={isExclude} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} isExclude={isExclude} />)}
</div>
)}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaEditor));

View file

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
class RadioPanel extends PureComponent {
static propTypes = {
values: ImmutablePropTypes.list.isRequired,
value: PropTypes.object.isRequired,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = e => {
const value = e.currentTarget.getAttribute('data-value');
if (value !== this.props.value.get('value')) {
this.props.onChange(value);
}
};
render () {
const { values, value } = this.props;
return (
<div className='setting-radio-panel'>
{values.map((val) => (
<button className={classNames('setting-radio-panel__item', {'setting-radio-panel__item__active': value.get('value') === val.get('value')})}
key={val.get('value')} onClick={this.handleChange} data-value={val.get('value')}>
{val.get('label')}
</button>
))}
</div>
);
}
}
export default connect()(injectIntl(RadioPanel));

View file

@ -1,104 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import { Button } from 'mastodon/components/button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
class TextListItem extends PureComponent {
static propTypes = {
icon: PropTypes.string.isRequired,
iconComponent: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
onRemove: PropTypes.func.isRequired,
};
handleRemove = () => {
this.props.onRemove(this.props.value);
};
render () {
const { icon, iconComponent, value } = this.props;
return (
<div className='setting-text-list-item'>
<Icon id={icon} icon={iconComponent} />
<span className='label'>{value}</span>
<IconButton icon='trash' iconComponent={DeleteIcon} onClick={this.handleRemove} />
</div>
);
}
}
class TextList extends PureComponent {
static propTypes = {
values: ImmutablePropTypes.list.isRequired,
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
icon: PropTypes.string.isRequired,
iconComponent: PropTypes.func.isRequired,
label: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleAdd = () => {
this.props.onAdd();
};
handleSubmit = (e) => {
e.preventDefault();
this.handleAdd();
};
render () {
const { icon, iconComponent, value, values, disabled, label, title } = this.props;
return (
<div className='setting-text-list'>
{values.map((val) => (
<TextListItem key={val} value={val} icon={icon} iconComponent={iconComponent} onRemove={this.props.onRemove} />
))}
<form className='add-text-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleAdd}
/>
</form>
</div>
);
}
}
export default connect()(injectIntl(TextList));

View file

@ -1,587 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import Select, { NonceProvider } from 'react-select';
import Toggle from 'react-toggle';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import DomainIcon from '@/material-icons/400-24px/dns.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import HashtagIcon from '@/material-icons/400-24px/tag.svg?react';
import KeywordIcon from '@/material-icons/400-24px/title.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import {
fetchAntenna,
deleteAntenna,
updateAntenna,
addDomainToAntenna,
removeDomainFromAntenna,
addExcludeDomainToAntenna,
removeExcludeDomainFromAntenna,
fetchAntennaDomains,
fetchAntennaKeywords,
removeKeywordFromAntenna,
addKeywordToAntenna,
removeExcludeKeywordFromAntenna,
addExcludeKeywordToAntenna,
fetchAntennaTags,
removeTagFromAntenna,
addTagToAntenna,
removeExcludeTagFromAntenna,
addExcludeTagToAntenna,
} from 'mastodon/actions/antennas';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchLists } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { enableLocalTimeline } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import RadioPanel from './components/radio_panel';
import TextList from './components/text_list';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_antenna.message', defaultMessage: 'Are you sure you want to permanently delete this antenna?' },
deleteConfirm: { id: 'confirmations.delete_antenna.confirm', defaultMessage: 'Delete' },
editAccounts: { id: 'antennas.edit_accounts', defaultMessage: 'Edit accounts' },
noOptions: { id: 'antennas.select.no_options_message', defaultMessage: 'Empty lists' },
placeholder: { id: 'antennas.select.placeholder', defaultMessage: 'Select list' },
addDomainLabel: { id: 'antennas.add_domain_placeholder', defaultMessage: 'New domain' },
addKeywordLabel: { id: 'antennas.add_keyword_placeholder', defaultMessage: 'New keyword' },
addTagLabel: { id: 'antennas.add_tag_placeholder', defaultMessage: 'New tag' },
addDomainTitle: { id: 'antennas.add_domain', defaultMessage: 'Add domain' },
addKeywordTitle: { id: 'antennas.add_keyword', defaultMessage: 'Add keyword' },
addTagTitle: { id: 'antennas.add_tag', defaultMessage: 'Add tag' },
accounts: { id: 'antennas.accounts', defaultMessage: '{count} accounts' },
domains: { id: 'antennas.domains', defaultMessage: '{count} domains' },
tags: { id: 'antennas.tags', defaultMessage: '{count} tags' },
keywords: { id: 'antennas.keywords', defaultMessage: '{count} keywords' },
setHome: { id: 'antennas.select.set_home', defaultMessage: 'Set home' },
});
const mapStateToProps = (state, props) => ({
antenna: state.getIn(['antennas', props.params.id]),
lists: state.get('lists'),
domains: state.getIn(['antennas', props.params.id, 'domains']) || ImmutableMap(),
keywords: state.getIn(['antennas', props.params.id, 'keywords']) || ImmutableMap(),
tags: state.getIn(['antennas', props.params.id, 'tags']) || ImmutableMap(),
});
class AntennaSetting extends PureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
antenna: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
lists: ImmutablePropTypes.map,
domains: ImmutablePropTypes.map,
keywords: ImmutablePropTypes.map,
tags: ImmutablePropTypes.map,
intl: PropTypes.object.isRequired,
...WithRouterPropTypes,
};
state = {
domainName: '',
excludeDomainName: '',
keywordName: '',
excludeKeywordName: '',
tagName: '',
excludeTagName: '',
rangeRadioValue: null,
contentRadioValue: null,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('ANTENNA', { id: this.props.params.id }));
this.props.history.push('/');
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(fetchAntenna(id));
dispatch(fetchAntennaDomains(id));
dispatch(fetchAntennaKeywords(id));
dispatch(fetchAntennaTags(id));
dispatch(fetchLists());
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchAntenna(id));
dispatch(fetchAntennaKeywords(id));
dispatch(fetchAntennaDomains(id));
dispatch(fetchAntennaKeywords(id));
dispatch(fetchAntennaTags(id));
dispatch(fetchLists());
}
}
setRef = c => {
this.column = c;
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'ANTENNA_EDITOR',
modalProps: { antennaId: this.props.params.id, isExclude: false },
}));
};
handleExcludeEditClick = () => {
this.props.dispatch(openModal({
modalType: 'ANTENNA_EDITOR',
modalProps: { antennaId: this.props.params.id, isExclude: true },
}));
};
handleEditAntennaClick = () => {
window.open(`/antennas/${this.props.params.id}/edit`, '_blank');
};
handleDeleteClick = () => {
const { dispatch, columnId, intl } = this.props;
const { id } = this.props.params;
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteAntenna(id));
if (columnId) {
dispatch(removeColumn(columnId));
} else {
this.props.history.push('/antennasw');
}
},
},
}));
};
handleTimelineClick = () => {
this.props.history.push(`/antennast/${this.props.params.id}`);
};
onStlToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, target.checked, undefined, undefined, undefined, undefined));
};
onLtlToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, undefined, target.checked, undefined, undefined, undefined));
};
onMediaOnlyToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, undefined, undefined, target.checked, undefined, undefined));
};
onIgnoreReblogToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, undefined, undefined, undefined, target.checked, undefined));
};
onNoInsertFeedsToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, undefined, undefined, undefined, undefined, undefined, target.checked));
};
onSelect = value => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, value.value, undefined, undefined, undefined, undefined, undefined));
};
onHomeSelect = () => this.onSelect({ value: '0' });
noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
onRangeRadioChanged = (value) => this.setState({ rangeRadioValue: value });
onContentRadioChanged = (value) => this.setState({ contentRadioValue: value });
onDomainNameChanged = (value) => this.setState({ domainName: value });
onDomainAdd = () => {
this.props.dispatch(addDomainToAntenna(this.props.params.id, this.state.domainName));
this.setState({ domainName: '' });
};
onDomainRemove = (value) => this.props.dispatch(removeDomainFromAntenna(this.props.params.id, value));
onKeywordNameChanged = (value) => this.setState({ keywordName: value });
onKeywordAdd = () => {
this.props.dispatch(addKeywordToAntenna(this.props.params.id, this.state.keywordName));
this.setState({ keywordName: '' });
};
onKeywordRemove = (value) => this.props.dispatch(removeKeywordFromAntenna(this.props.params.id, value));
onTagNameChanged = (value) => this.setState({ tagName: value });
onTagAdd = () => {
this.props.dispatch(addTagToAntenna(this.props.params.id, this.state.tagName));
this.setState({ tagName: '' });
};
onTagRemove = (value) => this.props.dispatch(removeTagFromAntenna(this.props.params.id, value));
onExcludeDomainNameChanged = (value) => this.setState({ excludeDomainName: value });
onExcludeDomainAdd = () => {
this.props.dispatch(addExcludeDomainToAntenna(this.props.params.id, this.state.excludeDomainName));
this.setState({ excludeDomainName: '' });
};
onExcludeDomainRemove = (value) => this.props.dispatch(removeExcludeDomainFromAntenna(this.props.params.id, value));
onExcludeKeywordNameChanged = (value) => this.setState({ excludeKeywordName: value });
onExcludeKeywordAdd = () => {
this.props.dispatch(addExcludeKeywordToAntenna(this.props.params.id, this.state.excludeKeywordName));
this.setState({ excludeKeywordName: '' });
};
onExcludeKeywordRemove = (value) => this.props.dispatch(removeExcludeKeywordFromAntenna(this.props.params.id, value));
onExcludeTagNameChanged = (value) => this.setState({ excludeTagName: value });
onExcludeTagAdd = () => {
this.props.dispatch(addExcludeTagToAntenna(this.props.params.id, this.state.excludeTagName));
this.setState({ excludeTagName: '' });
};
onExcludeTagRemove = (value) => this.props.dispatch(removeExcludeTagFromAntenna(this.props.params.id, value));
render () {
const { columnId, multiColumn, antenna, lists, domains, keywords, tags, intl } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const title = antenna ? antenna.get('title') : id;
const isStl = antenna ? antenna.get('stl') : undefined;
const isLtl = antenna ? antenna.get('ltl') : undefined;
const isMediaOnly = antenna ? antenna.get('with_media_only') : undefined;
const isIgnoreReblog = antenna ? antenna.get('ignore_reblog') : undefined;
const isInsertFeeds = antenna ? antenna.get('insert_feeds') : undefined;
if (typeof antenna === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (antenna === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
let columnSettings;
if (!isStl && !isLtl) {
columnSettings = (
<>
<section className='similar-row'>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-mediaonly`} checked={isMediaOnly} onChange={this.onMediaOnlyToggle} />
<label htmlFor={`antenna-${id}-mediaonly`} className='setting-toggle__label'>
<FormattedMessage id='antennas.media_only' defaultMessage='Media only' />
</label>
</div>
</section>
<section className='similar-row'>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-ignorereblog`} checked={isIgnoreReblog} onChange={this.onIgnoreReblogToggle} />
<label htmlFor={`antenna-${id}-ignorereblog`} className='setting-toggle__label'>
<FormattedMessage id='antennas.ignore_reblog' defaultMessage='Exclude boosts' />
</label>
</div>
</section>
</>
);
}
let stlAlert;
if (isStl) {
stlAlert = (
<div className='antenna-setting'>
<p><FormattedMessage id='antennas.in_stl_mode' defaultMessage='This antenna is in STL mode.' /></p>
</div>
);
} else if (isLtl) {
stlAlert = (
<div className='antenna-setting'>
<p><FormattedMessage id='antennas.in_ltl_mode' defaultMessage='This antenna is in LTL mode.' /></p>
</div>
);
}
const rangeRadioValues = ImmutableList([
ImmutableMap({ value: 'accounts', label: intl.formatMessage(messages.accounts, { count: antenna.get('accounts_count') }) }),
ImmutableMap({ value: 'domains', label: intl.formatMessage(messages.domains, { count: antenna.get('domains_count') }) }),
]);
const rangeRadioValue = ImmutableMap({ value: this.state.rangeRadioValue || (antenna.get('domains_count') > 0 ? 'domains' : 'accounts') });
const rangeRadioAlert = antenna.get(rangeRadioValue.get('value') === 'accounts' ? 'domains_count' : 'accounts_count') > 0;
const contentRadioValues = ImmutableList([
ImmutableMap({ value: 'keywords', label: intl.formatMessage(messages.keywords, { count: antenna.get('keywords_count') }) }),
ImmutableMap({ value: 'tags', label: intl.formatMessage(messages.tags, { count: antenna.get('tags_count') }) }),
]);
const contentRadioValue = ImmutableMap({ value: this.state.contentRadioValue || (antenna.get('tags_count') > 0 ? 'tags' : 'keywords') });
const contentRadioAlert = antenna.get(contentRadioValue.get('value') === 'tags' ? 'keywords_count' : 'tags_count') > 0;
const listOptions = lists.toArray().filter((list) => list.length >= 2 && list[1]).map((list) => {
return { value: list[1].get('id'), label: list[1].get('title') };
});
const isShowStlToggle = !isLtl && (enableLocalTimeline || isStl);
const isShowLtlToggle = !isStl && (enableLocalTimeline || isLtl);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader
icon='wifi'
iconComponent={AntennaIcon}
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='antennas.edit_static' defaultMessage='Edit antenna' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='antennas.delete' defaultMessage='Delete antenna' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleTimelineClick}>
<Icon id='wifi' icon={AntennaIcon} /> <FormattedMessage id='antennas.go_timeline' defaultMessage='Go to antenna timeline' />
</button>
</section>
{isShowStlToggle && (
<section>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-stl`} checked={isStl} onChange={this.onStlToggle} />
<label htmlFor={`antenna-${id}-stl`} className='setting-toggle__label'>
<FormattedMessage id='antennas.stl' defaultMessage='STL mode' />
</label>
</div>
</section>
)}
{isShowLtlToggle && (
<section className={isShowStlToggle && 'similar-row'}>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-ltl`} checked={isLtl} onChange={this.onLtlToggle} />
<label htmlFor={`antenna-${id}-ltl`} className='setting-toggle__label'>
<FormattedMessage id='antennas.ltl' defaultMessage='LTL mode' />
</label>
</div>
</section>
)}
<section className={(isShowStlToggle || isShowLtlToggle) && 'similar-row'}>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-noinsertfeeds`} checked={isInsertFeeds} onChange={this.onNoInsertFeedsToggle} />
<label htmlFor={`antenna-${id}-noinsertfeeds`} className='setting-toggle__label'>
<FormattedMessage id='antennas.insert_feeds' defaultMessage='Insert to feeds' />
</label>
</div>
</section>
{columnSettings}
</div>
</ColumnHeader>
{stlAlert}
<div className='antenna-setting'>
{isInsertFeeds && (
<>
{antenna.get('list') ? (
<p><FormattedMessage id='antennas.related_list' defaultMessage='This antenna is related to {listTitle}.' values={{ listTitle: antenna.getIn(['list', 'title']) }} /></p>
) : (
<p><FormattedMessage id='antennas.not_related_list' defaultMessage='This antenna is not related list. Posts will appear in home timeline. Open edit page to set list.' /></p>
)}
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content} cacheKey='lists'>
<Select
value={{ value: antenna.getIn(['list', 'id']), label: antenna.getIn(['list', 'title']) }}
options={listOptions}
noOptionsMessage={this.noOptionsMessage}
onChange={this.onSelect}
className='column-content-select__container'
classNamePrefix='column-content-select'
name='lists'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
defaultOptions
/>
</NonceProvider>
<Button secondary text={this.props.intl.formatMessage(messages.setHome)} onClick={this.onHomeSelect} />
</>
)}
{!isStl && !isLtl && (
<>
<h2><FormattedMessage id='antennas.filter' defaultMessage='Filter' /></h2>
<RadioPanel values={rangeRadioValues} value={rangeRadioValue} onChange={this.onRangeRadioChanged} />
{rangeRadioValue.get('value') === 'accounts' && <Button text={intl.formatMessage(messages.editAccounts)} onClick={this.handleEditClick} />}
{rangeRadioValue.get('value') === 'domains' && (
<TextList
onChange={this.onDomainNameChanged}
onAdd={this.onDomainAdd}
onRemove={this.onDomainRemove}
value={this.state.domainName}
values={domains.get('domains') || ImmutableList()}
icon='sitemap'
iconComponent={DomainIcon}
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
/>
)}
{rangeRadioAlert && <div className='alert'><FormattedMessage id='antennas.warnings.range_radio' defaultMessage='Simultaneous account and domain designation is not recommended.' /></div>}
<RadioPanel values={contentRadioValues} value={contentRadioValue} onChange={this.onContentRadioChanged} />
{contentRadioValue.get('value') === 'tags' && (
<TextList
onChange={this.onTagNameChanged}
onAdd={this.onTagAdd}
onRemove={this.onTagRemove}
value={this.state.tagName}
values={tags.get('tags') || ImmutableList()}
icon='hashtag'
iconComponent={HashtagIcon}
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
/>
)}
{contentRadioValue.get('value') === 'keywords' && (
<TextList
onChange={this.onKeywordNameChanged}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
value={this.state.keywordName}
values={keywords.get('keywords') || ImmutableList()}
icon='paragraph'
iconComponent={KeywordIcon}
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
/>
)}
{contentRadioAlert && <div className='alert'><FormattedMessage id='antennas.warnings.content_radio' defaultMessage='Simultaneous keyword and tag designation is not recommended.' /></div>}
<h2><FormattedMessage id='antennas.filter_not' defaultMessage='Filter Not' /></h2>
<h3><FormattedMessage id='antennas.exclude_accounts' defaultMessage='Exclude accounts' /></h3>
<Button text={intl.formatMessage(messages.editAccounts)} onClick={this.handleExcludeEditClick} />
<h3><FormattedMessage id='antennas.exclude_domains' defaultMessage='Exclude domains' /></h3>
<TextList
onChange={this.onExcludeDomainNameChanged}
onAdd={this.onExcludeDomainAdd}
onRemove={this.onExcludeDomainRemove}
value={this.state.excludeDomainName}
values={domains.get('exclude_domains') || ImmutableList()}
icon='sitemap'
iconComponent={DomainIcon}
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
/>
<h3><FormattedMessage id='antennas.exclude_keywords' defaultMessage='Exclude keywords' /></h3>
<TextList
onChange={this.onExcludeKeywordNameChanged}
onAdd={this.onExcludeKeywordAdd}
onRemove={this.onExcludeKeywordRemove}
value={this.state.excludeKeywordName}
values={keywords.get('exclude_keywords') || ImmutableList()}
icon='paragraph'
iconComponent={KeywordIcon}
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
/>
<h3><FormattedMessage id='antennas.exclude_tags' defaultMessage='Exclude tags' /></h3>
<TextList
onChange={this.onExcludeTagNameChanged}
onAdd={this.onExcludeTagAdd}
onRemove={this.onExcludeTagRemove}
value={this.state.excludeTagName}
values={tags.get('exclude_tags') || ImmutableList()}
icon='hashtag'
iconComponent={HashtagIcon}
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
/>
</>
)}
</div>
<Helmet>
<title>{title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withRouter(connect(mapStateToProps)(injectIntl(AntennaSetting)));

View file

@ -113,7 +113,7 @@ class AntennaTimeline extends PureComponent {
};
handleEditClick = () => {
this.props.history.push(`/antennasw/${this.props.params.id}`);
this.props.history.push(`/antennas/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {

View file

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeAntennaEditorTitle, submitAntennaEditor } from 'mastodon/actions/antennas';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'antennas.new.title_placeholder', defaultMessage: 'New antenna title' },
title: { id: 'antennas.new.create', defaultMessage: 'Add antenna' },
});
const mapStateToProps = state => ({
value: state.getIn(['antennaEditor', 'title']),
disabled: state.getIn(['antennaEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeAntennaEditorTitle(value)),
onSubmit: () => dispatch(submitAntennaEditor(true)),
});
class NewAntennaForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewAntennaForm));

View file

@ -0,0 +1,719 @@
import { useEffect, useState, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useParams, Link } from 'react-router-dom';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import DomainIcon from '@/material-icons/400-24px/dns.svg?react';
import HashtagIcon from '@/material-icons/400-24px/tag.svg?react';
import KeywordIcon from '@/material-icons/400-24px/title.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntenna } from 'mastodon/actions/antennas';
import {
apiGetAccounts,
apiGetDomains,
apiAddDomain,
apiRemoveDomain,
apiGetTags,
apiAddTag,
apiRemoveTag,
apiGetKeywords,
apiAddKeyword,
apiRemoveKeyword,
apiAddExcludeDomain,
apiRemoveExcludeDomain,
apiAddExcludeTag,
apiRemoveExcludeTag,
apiAddExcludeKeyword,
apiRemoveExcludeKeyword,
apiGetExcludeAccounts,
} from 'mastodon/api/antennas';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
deleteMessage: {
id: 'confirmations.delete_antenna.message',
defaultMessage: 'Are you sure you want to permanently delete this antenna?',
},
deleteConfirm: {
id: 'confirmations.delete_antenna.confirm',
defaultMessage: 'Delete',
},
editAccounts: {
id: 'antennas.edit_accounts',
defaultMessage: 'Edit accounts',
},
noOptions: {
id: 'antennas.select.no_options_message',
defaultMessage: 'Empty lists',
},
placeholder: {
id: 'antennas.select.placeholder',
defaultMessage: 'Select list',
},
addDomainLabel: {
id: 'antennas.add_domain_placeholder',
defaultMessage: 'New domain',
},
addKeywordLabel: {
id: 'antennas.add_keyword_placeholder',
defaultMessage: 'New keyword',
},
addTagLabel: {
id: 'antennas.add_tag_placeholder',
defaultMessage: 'New tag',
},
addDomainTitle: { id: 'antennas.add_domain', defaultMessage: 'Add domain' },
addKeywordTitle: {
id: 'antennas.add_keyword',
defaultMessage: 'Add keyword',
},
addTagTitle: { id: 'antennas.add_tag', defaultMessage: 'Add tag' },
accounts: { id: 'antennas.accounts', defaultMessage: '{count} accounts' },
domains: { id: 'antennas.domains', defaultMessage: '{count} domains' },
tags: { id: 'antennas.tags', defaultMessage: '{count} tags' },
keywords: { id: 'antennas.keywords', defaultMessage: '{count} keywords' },
setHome: { id: 'antennas.select.set_home', defaultMessage: 'Set home' },
});
const TextListItem: React.FC<{
icon: string;
iconComponent: IconProp;
value: string;
onRemove: (value: string) => void;
}> = ({ icon, iconComponent, value, onRemove }) => {
const handleRemove = useCallback(() => {
onRemove(value);
}, [onRemove, value]);
return (
<div className='setting-text-list-item'>
<Icon id={icon} icon={iconComponent} />
<span className='label'>{value}</span>
<IconButton
title='Delete'
icon='trash'
iconComponent={DeleteIcon}
onClick={handleRemove}
/>
</div>
);
};
const TextList: React.FC<{
values: string[];
disabled?: boolean;
icon: string;
iconComponent: IconProp;
label: string;
title: string;
onAdd: (value: string) => void;
onRemove: (value: string) => void;
}> = ({
values,
disabled,
icon,
iconComponent,
label,
title,
onAdd,
onRemove,
}) => {
const [value, setValue] = useState('');
const handleValueChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
},
[setValue],
);
const handleAdd = useCallback(() => {
onAdd(value);
setValue('');
}, [onAdd, value]);
const handleRemove = useCallback(
(removeValue: string) => {
onRemove(removeValue);
},
[onRemove],
);
const handleSubmit = handleAdd;
return (
<div className='setting-text-list'>
<form className='add-text-form' onSubmit={handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={handleValueChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={handleAdd}
/>
</form>
{values.map((val) => (
<TextListItem
key={val}
value={val}
icon={icon}
iconComponent={iconComponent}
onRemove={handleRemove}
/>
))}
</div>
);
};
const RadioPanel: React.FC<{
items: { title: string; value: string }[];
valueLengths: number[];
alertMessage: React.ReactElement;
onChange: (value: string) => void;
}> = ({ items, valueLengths, alertMessage, onChange }) => {
const [error, setError] = useState(false);
const [value, setValue] = useState('');
useEffect(() => {
if (valueLengths.length >= 2) {
setError(valueLengths.filter((v) => v > 0).length > 1);
} else {
setError(false);
}
}, [valueLengths]);
useEffect(() => {
if (items.length > 0) {
for (let i = 0; i < valueLengths.length; i++) {
const length = valueLengths[i] ?? 0;
const item = items[i] ?? { value: '' };
if (length > 0) {
setValue(item.value);
onChange(item.value);
return;
}
}
setValue(items[0]?.value ?? '');
}
}, [items, valueLengths, setValue, onChange]);
const handleChange = useCallback(
({ currentTarget }: React.MouseEvent<HTMLButtonElement>) => {
const selected = currentTarget.getAttribute('data-value') ?? '';
if (value !== selected) {
onChange(selected);
setValue(selected);
}
},
[value, setValue, onChange],
);
return (
<div>
<div className='setting-radio-panel'>
{items.map((item) => (
<button
className={classNames('setting-radio-panel__item', {
'setting-radio-panel__item__active': value === item.value,
})}
key={item.value}
onClick={handleChange}
data-value={item.value}
>
{item.title}
</button>
))}
</div>
{error && <div className='alert'>{alertMessage}</div>}
</div>
);
};
const MembersLink: React.FC<{
id: string;
isExclude: boolean;
onCountFetched?: (count: number) => void;
}> = ({ id, isExclude, onCountFetched }) => {
const [count, setCount] = useState(0);
const [avatars, setAvatars] = useState<string[]>([]);
useEffect(() => {
const api = isExclude ? apiGetExcludeAccounts : apiGetAccounts;
void api(id)
.then((data) => {
setCount(data.length);
if (onCountFetched) {
onCountFetched(data.length);
}
setAvatars(data.slice(0, 3).map((a) => a.avatar));
return '';
})
.catch(() => {
// Nothing
});
}, [id, setCount, setAvatars, isExclude, onCountFetched]);
return (
<Link
to={`/antennas/${id}/${isExclude ? 'exclude_members' : 'members'}`}
className='app-form__link'
>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='antennas.antenna_accounts'
defaultMessage='Antenna accounts'
/>
</strong>
<FormattedMessage
id='antennas.antenna_accounts_count'
defaultMessage='{count, plural, one {# member} other {# accounts}}'
values={{ count }}
/>
</div>
<div className='avatar-pile'>
{avatars.map((url) => (
<img key={url} src={url} alt='' />
))}
</div>
</Link>
);
};
const AntennaSetting: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
//const history = useHistory();
const antenna = useAppSelector((state) =>
id ? state.antennas.get(id) : undefined,
);
const [domainList, setDomainList] = useState([] as string[]);
const [excludeDomainList, setExcludeDomainList] = useState([] as string[]);
const [tagList, setTagList] = useState([] as string[]);
const [excludeTagList, setExcludeTagList] = useState([] as string[]);
const [keywordList, setKeywordList] = useState([] as string[]);
const [excludeKeywordList, setExcludeKeywordList] = useState([] as string[]);
const [accountsCount, setAccountsCount] = useState(0);
const [rangeMode, setRangeMode] = useState('accounts');
const [contentMode, setContentMode] = useState('keywords');
useEffect(() => {
if (id) {
dispatch(fetchAntenna(id));
void apiGetDomains(id).then((data) => {
setDomainList(data.domains);
setExcludeDomainList(data.exclude_domains);
return true;
});
void apiGetTags(id).then((data) => {
setTagList(data.tags);
setExcludeTagList(data.exclude_tags);
return true;
});
void apiGetKeywords(id).then((data) => {
setKeywordList(data.keywords);
setExcludeKeywordList(data.exclude_keywords);
return true;
});
}
}, [
dispatch,
id,
setDomainList,
setExcludeDomainList,
setTagList,
setExcludeTagList,
setKeywordList,
setExcludeKeywordList,
]);
const handleAccountsFetched = useCallback(
(count: number) => {
setAccountsCount(count);
},
[setAccountsCount],
);
const handleAddDomain = useCallback(
(value: string) => {
if (!id) return;
void apiAddDomain(id, value).then(() => {
setDomainList([...domainList, value]);
return value;
});
},
[id, domainList, setDomainList],
);
const handleRemoveDomain = useCallback(
(value: string) => {
if (!id) return;
void apiRemoveDomain(id, value).then(() => {
setDomainList(domainList.filter((v) => v !== value));
return value;
});
},
[id, domainList, setDomainList],
);
const handleAddExcludeDomain = useCallback(
(value: string) => {
if (!id) return;
void apiAddExcludeDomain(id, value).then(() => {
setExcludeDomainList([...excludeDomainList, value]);
return value;
});
},
[id, excludeDomainList, setExcludeDomainList],
);
const handleRemoveExcludeDomain = useCallback(
(value: string) => {
if (!id) return;
void apiRemoveExcludeDomain(id, value).then(() => {
setExcludeDomainList(excludeDomainList.filter((v) => v !== value));
return value;
});
},
[id, excludeDomainList, setExcludeDomainList],
);
const handleAddTag = useCallback(
(value: string) => {
if (!id) return;
void apiAddTag(id, value).then(() => {
setTagList([...tagList, value]);
return value;
});
},
[id, tagList, setTagList],
);
const handleRemoveTag = useCallback(
(value: string) => {
if (!id) return;
void apiRemoveTag(id, value).then(() => {
setTagList(tagList.filter((v) => v !== value));
return value;
});
},
[id, tagList, setTagList],
);
const handleAddExcludeTag = useCallback(
(value: string) => {
if (!id) return;
void apiAddExcludeTag(id, value).then(() => {
setExcludeTagList([...excludeTagList, value]);
return value;
});
},
[id, excludeTagList, setExcludeTagList],
);
const handleRemoveExcludeTag = useCallback(
(value: string) => {
if (!id) return;
void apiRemoveExcludeTag(id, value).then(() => {
setExcludeTagList(excludeTagList.filter((v) => v !== value));
return value;
});
},
[id, excludeTagList, setExcludeTagList],
);
const handleAddKeyword = useCallback(
(value: string) => {
if (!id) return;
void apiAddKeyword(id, value).then(() => {
setKeywordList([...keywordList, value]);
return value;
});
},
[id, keywordList, setKeywordList],
);
const handleRemoveKeyword = useCallback(
(value: string) => {
if (!id) return;
void apiRemoveKeyword(id, value).then(() => {
setKeywordList(keywordList.filter((v) => v !== value));
return value;
});
},
[id, keywordList, setKeywordList],
);
const handleAddExcludeKeyword = useCallback(
(value: string) => {
if (!id) return;
void apiAddExcludeKeyword(id, value).then(() => {
setExcludeKeywordList([...excludeKeywordList, value]);
return value;
});
},
[id, excludeKeywordList, setExcludeKeywordList],
);
const handleRemoveExcludeKeyword = useCallback(
(value: string) => {
if (!id) return;
void apiRemoveExcludeKeyword(id, value).then(() => {
setExcludeKeywordList(excludeKeywordList.filter((v) => v !== value));
return value;
});
},
[id, excludeKeywordList, setExcludeKeywordList],
);
const handleRangeRadioChange = useCallback(
(value: string) => {
setRangeMode(value);
},
[setRangeMode],
);
const handleContentRadioChange = useCallback(
(value: string) => {
setContentMode(value);
},
[setContentMode],
);
if (!antenna || !id) return <div />;
if (antenna.stl)
return (
<div className='antenna-setting'>
<p>
<FormattedMessage
id='antennas.in_stl_mode'
defaultMessage='This antenna is in STL mode.'
/>
</p>
</div>
);
if (antenna.ltl)
return (
<div className='antenna-setting'>
<p>
<FormattedMessage
id='antennas.in_ltl_mode'
defaultMessage='This antenna is in LTL mode.'
/>
</p>
</div>
);
const rangeRadioItems = [
{
value: 'accounts',
title: intl.formatMessage(messages.accounts, { count: accountsCount }),
},
{
value: 'domains',
title: intl.formatMessage(messages.domains, { count: domainList.length }),
},
];
const rangeRadioLengths = [accountsCount, domainList.length];
const contentRadioItems = [
{
value: 'keywords',
title: intl.formatMessage(messages.keywords, {
count: keywordList.length,
}),
},
{
value: 'tags',
title: intl.formatMessage(messages.tags, { count: tagList.length }),
},
];
const contentRadioLengths = [keywordList.length, tagList.length];
return (
<Column bindToDocument={!multiColumn} label={antenna.title}>
<ColumnHeader
title={antenna.title}
icon='antenna-ul'
iconComponent={AntennaIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable antenna-setting'>
<RadioPanel
items={rangeRadioItems}
valueLengths={rangeRadioLengths}
alertMessage={
<div className='alert'>
<FormattedMessage
id='antennas.warnings.range_radio'
defaultMessage='Simultaneous account and domain designation is not recommended.'
/>
</div>
}
onChange={handleRangeRadioChange}
/>
{rangeMode === 'accounts' && (
<MembersLink
id={id}
onCountFetched={handleAccountsFetched}
isExclude={false}
/>
)}
{rangeMode === 'domains' && (
<TextList
values={domainList}
icon='sitemap'
iconComponent={DomainIcon}
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
onAdd={handleAddDomain}
onRemove={handleRemoveDomain}
/>
)}
<RadioPanel
items={contentRadioItems}
valueLengths={contentRadioLengths}
alertMessage={
<div className='alert'>
<FormattedMessage
id='antennas.warnings.content_radio'
defaultMessage='Simultaneous keyword and tag designation is not recommended.'
/>
</div>
}
onChange={handleContentRadioChange}
/>
{contentMode === 'keywords' && (
<TextList
values={keywordList}
icon='paragraph'
iconComponent={KeywordIcon}
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
onAdd={handleAddKeyword}
onRemove={handleRemoveKeyword}
/>
)}
{contentMode === 'tags' && (
<TextList
values={tagList}
icon='hashtag'
iconComponent={HashtagIcon}
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
onAdd={handleAddTag}
onRemove={handleRemoveTag}
/>
)}
<h2>
<FormattedMessage
id='antennas.filter_not'
defaultMessage='Filter Not'
/>
</h2>
<h3>
<FormattedMessage
id='antennas.exclude_accounts'
defaultMessage='Exclude accounts'
/>
</h3>
<MembersLink id={id} isExclude />
<h3>
<FormattedMessage
id='antennas.exclude_domains'
defaultMessage='Exclude domains'
/>
</h3>
<TextList
values={excludeDomainList}
icon='sitemap'
iconComponent={DomainIcon}
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
onAdd={handleAddExcludeDomain}
onRemove={handleRemoveExcludeDomain}
/>
<h3>
<FormattedMessage
id='antennas.exclude_keywords'
defaultMessage='Exclude keywords'
/>
</h3>
<TextList
values={excludeKeywordList}
icon='paragraph'
iconComponent={KeywordIcon}
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
onAdd={handleAddExcludeKeyword}
onRemove={handleRemoveExcludeKeyword}
/>
<h3>
<FormattedMessage
id='antennas.exclude_tags'
defaultMessage='Exclude tags'
/>
</h3>
<TextList
values={excludeTagList}
icon='paragraph'
iconComponent={HashtagIcon}
label={intl.formatMessage(messages.addTagLabel)}
title={intl.formatMessage(messages.addTagTitle)}
onAdd={handleAddExcludeTag}
onRemove={handleRemoveExcludeTag}
/>
</div>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AntennaSetting;

View file

@ -1,97 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewAntennaForm from './components/new_antenna_form';
const messages = defineMessages({
heading: { id: 'column.antennas', defaultMessage: 'Antennas' },
subheading: { id: 'antennas.subheading', defaultMessage: 'Your antennas' },
insert_list: { id: 'antennas.insert_list', defaultMessage: 'List' },
insert_home: { id: 'antennas.insert_home', defaultMessage: 'Home' },
});
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
antennas: getOrderedAntennas(state),
});
class Antennas extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
antennas: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchAntennas());
}
render () {
const { intl, antennas, multiColumn } = this.props;
if (!antennas) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.antennas' defaultMessage="You don't have any antennas yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='wifi' iconComponent={AntennaIcon} multiColumn={multiColumn} />
<NewAntennaForm />
<ScrollableList
scrollKey='antennas'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{antennas.map(antenna => (
<ColumnLink key={antenna.get('id')} to={`/antennast/${antenna.get('id')}`} icon='wifi' iconComponent={AntennaIcon} text={antenna.get('title')}
badge={antenna.get('insert_feeds') ? intl.formatMessage(antenna.get('list') ? messages.insert_list : messages.insert_home) : undefined} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Antennas));

View file

@ -0,0 +1,190 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchAntennas } from 'mastodon/actions/antennas';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedAntennas } from 'mastodon/selectors/antennas';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.antennas', defaultMessage: 'Antennas' },
create: { id: 'antennas.create_antenna', defaultMessage: 'Create antenna' },
edit: { id: 'antennas.edit', defaultMessage: 'Edit antenna' },
delete: { id: 'antennas.delete', defaultMessage: 'Delete antenna' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const AntennaItem: React.FC<{
id: string;
title: string;
insert_feeds: boolean;
isList: boolean;
listTitle?: string;
stl: boolean;
ltl: boolean;
}> = ({ id, title, insert_feeds, isList, listTitle, stl, ltl }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_ANTENNA',
modalProps: {
antennaId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/antennas/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/antennas/${id}`} className='lists__item__title'>
<Icon id='antenna-ul' icon={AntennaIcon} />
<span>
{title}
{stl && (
<span className='column-link__badge'>
<FormattedMessage id='antennas.badge_stl' defaultMessage='STL' />
</span>
)}
{ltl && (
<span className='column-link__badge'>
<FormattedMessage id='antennas.badge_ltl' defaultMessage='LTL' />
</span>
)}
{insert_feeds && (
<span className='lists__item__memo'>
{isList && listTitle && (
<FormattedMessage
id='antennas.memo_insert_list'
defaultMessage='List: "{title}"'
values={{ title: listTitle }}
/>
)}
{!isList && (
<FormattedMessage
id='antennas.memo_insert_home'
defaultMessage='Inserts home timeline.'
/>
)}
</span>
)}
</span>
</Link>
<DropdownMenuContainer
scrollKey='antennas'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Antennas: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const antennas = useAppSelector((state) => getOrderedAntennas(state));
useEffect(() => {
dispatch(fetchAntennas());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='antennas.no_antennas_yet'
defaultMessage='No antennas yet.'
/>
<br />
<FormattedMessage
id='antennas.create_a_antenna_to_organize'
defaultMessage='Create a new antenna to organize your Home feed'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='antenna-ul'
iconComponent={AntennaIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/antennas/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='antennas'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{antennas.map((antenna) => (
<AntennaItem
key={antenna.id}
id={antenna.id}
title={antenna.title}
insert_feeds={antenna.insert_feeds}
isList={!!antenna.list}
listTitle={antenna.list?.title}
stl={antenna.stl}
ltl={antenna.ltl}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Antennas;

View file

@ -0,0 +1,392 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowing } from 'mastodon/actions/accounts';
import { fetchAntenna } from 'mastodon/actions/antennas';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiRequest } from 'mastodon/api';
import {
apiGetAccounts,
apiAddAccountToAntenna,
apiRemoveAccountFromAntenna,
apiRemoveExcludeAccountFromAntenna,
apiAddExcludeAccountToAntenna,
apiGetExcludeAccounts,
} from 'mastodon/api/antennas';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: {
id: 'column.antenna_members',
defaultMessage: 'Manage antenna members',
},
placeholder: {
id: 'antennas.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: {
id: 'antennas.add_to_antenna',
defaultMessage: 'Add to antenna',
},
add: { id: 'antennas.add_member', defaultMessage: 'Add' },
remove: { id: 'antennas.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
type Mode = 'remove' | 'add';
const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
}> = ({ onBack, onSubmit }) => {
const intl = useIntl();
const [value, setValue] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<ButtonInTabsBar>
<form className='column-search-header' onSubmit={handleSubmit}>
<button
type='button'
className='column-header__back-button compact'
onClick={onBack}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
</button>
<input
type='search'
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.placeholder)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
</form>
</ButtonInTabsBar>
);
};
const AccountItem: React.FC<{
accountId: string;
antennaId: string;
partOfAntenna: boolean;
isExclude?: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, antennaId, partOfAntenna, isExclude, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfAntenna) {
const api = isExclude
? apiRemoveExcludeAccountFromAntenna
: apiRemoveAccountFromAntenna;
void api(antennaId, accountId);
} else {
const api = isExclude
? apiAddExcludeAccountToAntenna
: apiAddAccountToAntenna;
void api(antennaId, accountId);
}
onToggle(accountId);
}, [accountId, antennaId, partOfAntenna, onToggle, isExclude]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
isHide={account.other_settings.hide_followers_count}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfAntenna ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const AntennaMembers: React.FC<{
isExclude?: boolean;
multiColumn?: boolean;
}> = ({ isExclude, multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchAntenna(id));
const api = isExclude ? apiGetExcludeAccounts : apiGetAccounts;
void api(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowing(me));
}
}, [dispatch, id, isExclude]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfAntenna = accountIds.includes(accountId);
if (partOfAntenna) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : accountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
{mode === 'remove' ? (
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='antenna-ul'
iconComponent={AntennaIcon}
multiColumn={multiColumn}
showBackButton
extraButton={
<button
onClick={handleSearchClick}
type='button'
className='column-header__button'
title={intl.formatMessage(messages.enterSearch)}
aria-label={intl.formatMessage(messages.enterSearch)}
>
<Icon id='plus' icon={AddIcon} />
</button>
}
/>
) : (
<ColumnSearchHeader
onBack={handleDismissSearchClick}
onSubmit={handleSearch}
/>
)}
<ScrollableList
scrollKey='antenna_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link
to={`/antennas/${id}/filtering`}
className='button button--block'
>
<FormattedMessage id='antennas.done' defaultMessage='Done' />
</Link>
</div>
</>
)
}
emptyMessage={
mode === 'remove' ? (
<>
<span>
<FormattedMessage
id='antennas.no_members_yet'
defaultMessage='No members yet.'
/>
<br />
<FormattedMessage
id='antennas.find_users_to_add'
defaultMessage='Find users to add'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
) : (
<FormattedMessage
id='antennas.no_results_found'
defaultMessage='No results found.'
/>
)
}
>
{displayedAccountIds.map((accountId) => (
<AccountItem
key={accountId}
accountId={accountId}
antennaId={id}
partOfAntenna={
displayedAccountIds === accountIds ||
accountIds.includes(accountId)
}
isExclude={isExclude}
onToggle={handleAccountToggle}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default AntennaMembers;

View file

@ -0,0 +1,491 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory, Link } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import Toggle from 'react-toggle';
import AntennaIcon from '@/material-icons/400-24px/wifi.svg?react';
import { fetchAntenna } from 'mastodon/actions/antennas';
import { createAntenna, updateAntenna } from 'mastodon/actions/antennas_typed';
import { fetchLists } from 'mastodon/actions/lists';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_antenna', defaultMessage: 'Edit antenna' },
create: { id: 'column.create_antenna', defaultMessage: 'Create antenna' },
});
const FiltersLink: React.FC<{
id: string;
}> = ({ id }) => {
return (
<Link to={`/antennas/${id}/filtering`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='antennas.filter_items'
defaultMessage='Move to antenna filter setting'
/>
</strong>
</div>
</Link>
);
};
const NewAntenna: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const antenna = useAppSelector((state) =>
id ? state.antennas.get(id) : undefined,
);
const lists = useAppSelector((state) => getOrderedLists(state));
const [title, setTitle] = useState('');
const [stl, setStl] = useState(false);
const [ltl, setLtl] = useState(false);
const [insertFeeds, setInsertFeeds] = useState(false);
const [listId, setListId] = useState('');
const [withMediaOnly, setWithMediaOnly] = useState(false);
const [ignoreReblog, setIgnoreReblog] = useState(false);
const [mode, setMode] = useState('filtering');
const [destination, setDestination] = useState('timeline');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchAntenna(id));
dispatch(fetchLists());
}
}, [dispatch, id]);
useEffect(() => {
if (id && antenna) {
setTitle(antenna.title);
setStl(antenna.stl);
setLtl(antenna.ltl);
setInsertFeeds(antenna.insert_feeds);
setListId(antenna.list?.id ?? '0');
setWithMediaOnly(antenna.with_media_only);
setIgnoreReblog(antenna.ignore_reblog);
if (antenna.stl) {
setMode('stl');
} else if (antenna.ltl) {
setMode('ltl');
} else {
setMode('filtering');
}
if (antenna.insert_feeds) {
if (antenna.list) {
setDestination('list');
} else {
setDestination('home');
}
} else {
setDestination('timeline');
}
}
}, [
setTitle,
setStl,
setLtl,
setInsertFeeds,
setListId,
setWithMediaOnly,
setIgnoreReblog,
setMode,
setDestination,
id,
antenna,
lists,
]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleListIdChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
setListId(value);
},
[setListId],
);
const handleModeChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
if (value === 'stl') {
setStl(true);
setLtl(false);
} else if (value === 'ltl') {
setStl(false);
setLtl(true);
} else if (value === 'filtering') {
setStl(false);
setLtl(false);
}
setMode(value);
},
[setLtl, setStl, setMode],
);
const handleDestinationChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>) => {
if (value === 'list') {
setInsertFeeds(true);
if (listId === '0' && lists.length > 0) {
setListId(lists[0]?.id ?? '0');
}
} else if (value === 'home') {
setInsertFeeds(true);
// listId = 0
} else if (value === 'timeline') {
setInsertFeeds(false);
}
setDestination(value);
},
[setDestination, setListId, listId, lists],
);
const handleWithMediaOnlyChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setWithMediaOnly(checked);
},
[setWithMediaOnly],
);
const handleIgnoreReblogChange = useCallback(
({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) => {
setIgnoreReblog(checked);
},
[setIgnoreReblog],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateAntenna({
id,
title,
stl,
ltl,
insert_feeds: insertFeeds,
list_id: destination === 'list' ? listId : '0',
with_media_only: withMediaOnly,
ignore_reblog: ignoreReblog,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createAntenna({
title,
stl,
ltl,
insert_feeds: insertFeeds,
list_id: destination === 'list' ? listId : '0',
with_media_only: withMediaOnly,
ignore_reblog: ignoreReblog,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/antennas/${result.payload.id}/edit`);
history.push(`/antennas/${result.payload.id}/members`);
}
return '';
});
}
}, [
history,
dispatch,
setSubmitting,
id,
title,
stl,
ltl,
insertFeeds,
listId,
withMediaOnly,
ignoreReblog,
destination,
]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='antenna-ul'
iconComponent={AntennaIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='antenna_title'>
<FormattedMessage
id='antennas.antenna_name'
defaultMessage='Antenna name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='antenna_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='antenna_list'>
<FormattedMessage id='antennas.mode' defaultMessage='Mode' />
</label>
<div className='label_input__wrapper'>
<select
id='antenna_insert_list'
value={mode}
onChange={handleModeChange}
>
<FormattedMessage
id='antennas.mode.stl'
defaultMessage='Social timeline mode'
>
{(msg) => <option value='stl'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='antennas.mode.ltl'
defaultMessage='Local timeline mode'
>
{(msg) => <option value='ltl'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='antennas.mode.filtering'
defaultMessage='Filtering'
>
{(msg) => <option value='filtering'>{msg}</option>}
</FormattedMessage>
</select>
</div>
</div>
</div>
</div>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='antenna_list'>
<FormattedMessage
id='antennas.destination'
defaultMessage='Destination'
/>
</label>
<div className='label_input__wrapper'>
<select
id='antenna_insert_destination'
value={destination}
onChange={handleDestinationChange}
>
<FormattedMessage
id='antennas.destination.home'
defaultMessage='Insert to home'
>
{(msg) => <option value='home'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='antennas.destination.list'
defaultMessage='Insert to list'
>
{(msg) => <option value='list'>{msg}</option>}
</FormattedMessage>
<FormattedMessage
id='antennas.destination.timeline'
defaultMessage='Antenna timeline only'
>
{(msg) => <option value='timeline'>{msg}</option>}
</FormattedMessage>
</select>
</div>
</div>
</div>
</div>
{destination === 'list' && (
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='antenna_list'>
<FormattedMessage
id='antennas.list_selection'
defaultMessage='List to insert'
/>
</label>
<div className='label_input__wrapper'>
<select
id='antenna_insert_list'
value={listId}
onChange={handleListIdChange}
>
{lists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
</select>
</div>
</div>
</div>
</div>
)}
{id && mode === 'filtering' && (
<div className='fields-group'>
<FiltersLink id={id} />
</div>
)}
{!id && mode === 'filtering' && (
<div className='fields-group'>
<div className='app-form__memo'>
<FormattedMessage
id='antennas.save_to_edit_filtering'
defaultMessage='You can edit the filtering after saving.'
/>
</div>
</div>
)}
{mode === 'filtering' && (
<>
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='antennas.media_only'
defaultMessage='Media only'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='antennas.media_only_hint'
defaultMessage='Only posts with media will be added antenna.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={withMediaOnly}
onChange={handleWithMediaOnlyChange}
/>
</div>
</div>
</label>
</div>
<div className='fields-group'>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='app-form__toggle'>
<div className='app-form__toggle__label'>
<strong>
<FormattedMessage
id='antennas.ignore_reblog'
defaultMessage='Exclude boosts'
/>
</strong>
<span className='hint'>
<FormattedMessage
id='antennas.ignore_reblog_hint'
defaultMessage='Boosts will be excluded from antenna detection.'
/>
</span>
</div>
<div className='app-form__toggle__toggle'>
<div>
<Toggle
checked={ignoreReblog}
onChange={handleIgnoreReblogChange}
/>
</div>
</div>
</label>
</div>
</>
)}
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage id='antennas.save' defaultMessage='Save' />
) : (
<FormattedMessage
id='antennas.create'
defaultMessage='Create'
/>
)}
</button>
</div>
</form>
</div>
<Helmet>
<title>
{intl.formatMessage(id ? messages.edit : messages.create)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default NewAntenna;

View file

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeBookmarkCategoryEditorTitle, submitBookmarkCategoryEditor } from 'mastodon/actions/bookmark_categories';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'bookmark_categories.new.title_placeholder', defaultMessage: 'New category title' },
title: { id: 'bookmark_categories.new.create', defaultMessage: 'Add category' },
});
const mapStateToProps = state => ({
value: state.getIn(['bookmarkCategoryEditor', 'title']),
disabled: state.getIn(['bookmarkCategoryEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeBookmarkCategoryEditorTitle(value)),
onSubmit: () => dispatch(submitBookmarkCategoryEditor(true)),
});
class NewBookmarkCategoryForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewBookmarkCategoryForm));

View file

@ -1,98 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewListForm from './components/new_bookmark_category_form';
const messages = defineMessages({
heading: { id: 'column.bookmark_categories', defaultMessage: 'Bookmark categories' },
subheading: { id: 'bookmark_categories.subheading', defaultMessage: 'Your categories' },
allBookmarks: { id: 'bookmark_categories.all_bookmarks', defaultMessage: 'All bookmarks' },
});
const getOrderedCategories = createSelector([state => state.get('bookmark_categories')], categories => {
if (!categories) {
return categories;
}
return categories.toList().filter(item => !!item && typeof item.get('title') !== 'undefined' && item.get('title') !== null).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
categories: getOrderedCategories(state),
});
class BookmarkCategories extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
categories: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchBookmarkCategories());
}
render () {
const { intl, categories, multiColumn } = this.props;
if (!categories) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.bookmark_categories' defaultMessage="You don't have any categories yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='bookmark' iconComponent={BookmarksIcon} multiColumn={multiColumn} />
<NewListForm />
<ColumnLink to='/bookmarks' icon='bookmark' iconComponent={BookmarkIcon} text={intl.formatMessage(messages.allBookmarks)} />
<ScrollableList
scrollKey='bookmark_categories'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{categories.map(category =>
<ColumnLink key={category.get('id')} to={`/bookmark_categories/${category.get('id')}`} icon='bookmark' iconComponent={BookmarkIcon} text={category.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(BookmarkCategories));

View file

@ -0,0 +1,181 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchBookmarkCategories } from 'mastodon/actions/bookmark_categories';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: {
id: 'column.bookmark_categories',
defaultMessage: 'BookmarkCategories',
},
create: {
id: 'bookmark_categories.create_bookmark_category',
defaultMessage: 'Create category',
},
edit: {
id: 'bookmark_categories.edit',
defaultMessage: 'Edit category',
},
delete: {
id: 'bookmark_categories.delete',
defaultMessage: 'Delete category',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
const BookmarkCategoryItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_BOOKMARK_CATEGORY',
modalProps: {
bookmark_categoryId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{
text: intl.formatMessage(messages.edit),
to: `/bookmark_categories/${id}/edit`,
},
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/bookmark_categories/${id}`} className='lists__item__title'>
<Icon id='bookmark_category-ul' icon={BookmarkIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='bookmark_categories'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const BookmarkCategories: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const bookmark_categories = useAppSelector((state) =>
getOrderedBookmarkCategories(state),
);
useEffect(() => {
dispatch(fetchBookmarkCategories());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='bookmark_categories.no_bookmark_categories_yet'
defaultMessage='No bookmark_categories yet.'
/>
<br />
<FormattedMessage
id='bookmark_categories.create_a_bookmark_category_to_organize'
defaultMessage='Create a new bookmark_category to organize your Home feed'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='bookmark_category-ul'
iconComponent={BookmarkIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/bookmark_categories/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='bookmark_categories'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
alwaysPrepend
prepend={
<div className='lists__item'>
<Link to={'/bookmarks'} className='lists__item__title'>
<Icon id='bookmarks' icon={BookmarksIcon} />
<span>
<FormattedMessage
id='bookmark_categories.all_bookmarks'
defaultMessage='All bookmarks'
/>
</span>
</Link>
</div>
}
>
{bookmark_categories.map((bookmark_category) => (
<BookmarkCategoryItem
key={bookmark_category.id}
id={bookmark_category.id}
title={bookmark_category.title}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkCategories;

View file

@ -0,0 +1,167 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import { fetchBookmarkCategory } from 'mastodon/actions/bookmark_categories';
import {
createBookmarkCategory,
updateBookmarkCategory,
} from 'mastodon/actions/bookmark_categories_typed';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: {
id: 'column.edit_bookmark_category',
defaultMessage: 'Edit bookmark_category',
},
create: {
id: 'column.create_bookmark_category',
defaultMessage: 'Create bookmark_category',
},
});
const NewBookmarkCategory: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const bookmark_category = useAppSelector((state) =>
id ? state.bookmark_categories.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchBookmarkCategory(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && bookmark_category) {
setTitle(bookmark_category.title);
}
}, [setTitle, id, bookmark_category]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateBookmarkCategory({
id,
title,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createBookmarkCategory({
title,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/bookmark_categories/${result.payload.id}/edit`);
history.push(`/bookmark_categories/${result.payload.id}/members`);
}
return '';
});
}
}, [history, dispatch, setSubmitting, id, title]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='bookmark_category-ul'
iconComponent={BookmarkIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='bookmark_category_title'>
<FormattedMessage
id='bookmark_categories.bookmark_category_name'
defaultMessage='BookmarkCategory name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='bookmark_category_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage
id='bookmark_categories.save'
defaultMessage='Save'
/>
) : (
<FormattedMessage
id='bookmark_categories.create'
defaultMessage='Create'
/>
)}
</button>
</div>
</form>
</div>
<Helmet>
<title>
{intl.formatMessage(id ? messages.edit : messages.create)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default NewBookmarkCategory;

View file

@ -1,43 +0,0 @@
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View file

@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromBookmarkCategoryAdder, addToBookmarkCategoryAdder } from '../../../actions/bookmark_categories';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'bookmark_categories.status.remove', defaultMessage: 'Remove from bookmark category' },
add: { id: 'bookmark_categories.status.add', defaultMessage: 'Add to bookmark category' },
});
const MapStateToProps = (state, { bookmarkCategoryId, added }) => ({
bookmarkCategory: state.get('bookmark_categories').get(bookmarkCategoryId),
added: typeof added === 'undefined' ? state.getIn(['bookmarkCategoryAdder', 'bookmarkCategories', 'items']).includes(bookmarkCategoryId) : added,
});
const mapDispatchToProps = (dispatch, { bookmarkCategoryId }) => ({
onRemove: () => dispatch(removeFromBookmarkCategoryAdder(bookmarkCategoryId)),
onAdd: () => dispatch(addToBookmarkCategoryAdder(bookmarkCategoryId)),
});
class BookmarkCategory extends ImmutablePureComponent {
static propTypes = {
bookmarkCategory: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { bookmarkCategory, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='bookmark' icon={BookmarkIcon} className='column-link__icon' fixedWidth />
{bookmarkCategory.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategory));

View file

@ -1,78 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { setupBookmarkCategoryAdder, resetBookmarkCategoryAdder } from '../../actions/bookmark_categories';
import NewBookmarkCategoryForm from '../bookmark_categories/components/new_bookmark_category_form';
// import Account from './components/account';
import BookmarkCategory from './components/bookmark_category';
const getOrderedBookmarkCategories = createSelector([state => state.get('bookmark_categories')], bookmarkCategories => {
if (!bookmarkCategories) {
return bookmarkCategories;
}
return bookmarkCategories.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
bookmarkCategoryIds: getOrderedBookmarkCategories(state).map(bookmarkCategory=>bookmarkCategory.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: statusId => dispatch(setupBookmarkCategoryAdder(statusId)),
onReset: () => dispatch(resetBookmarkCategoryAdder()),
});
class BookmarkCategoryAdder extends ImmutablePureComponent {
static propTypes = {
statusId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
bookmarkCategoryIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, statusId } = this.props;
onInitialize(statusId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { bookmarkCategoryIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
{/*
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
*/}
<NewBookmarkCategoryForm />
<div className='list-adder__lists'>
{bookmarkCategoryIds.map(BookmarkCategoryId => <BookmarkCategory key={BookmarkCategoryId} bookmarkCategoryId={BookmarkCategoryId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(BookmarkCategoryAdder));

View file

@ -0,0 +1,268 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import {
bookmarkCategoryEditorAddSuccess,
bookmarkCategoryEditorRemoveSuccess,
fetchBookmarkCategories,
} from 'mastodon/actions/bookmark_categories';
import { createBookmarkCategory } from 'mastodon/actions/bookmark_categories_typed';
import { unbookmark } from 'mastodon/actions/interactions';
import {
apiGetStatusBookmarkCategories,
apiAddStatusToBookmarkCategory,
apiRemoveStatusFromBookmarkCategory,
} from 'mastodon/api/bookmark_categories';
import type { ApiBookmarkCategoryJSON } from 'mastodon/api_types/bookmark_categories';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { bookmarkCategoryNeeded } from 'mastodon/initial_state';
import { getOrderedBookmarkCategories } from 'mastodon/selectors/bookmark_categories';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newBookmarkCategory: {
id: 'bookmark_categories.new_bookmark_category_name',
defaultMessage: 'New bookmark_category name',
},
createBookmarkCategory: {
id: 'bookmark_categories.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const BookmarkCategoryItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='bookmark_category-ul' icon={BookmarkIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewBookmarkCategoryItem: React.FC<{
onCreate: (bookmark_category: ApiBookmarkCategoryJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createBookmarkCategory({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='bookmark_category-ul' icon={BookmarkIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newBookmarkCategory)}
/>
</label>
<Button
text={intl.formatMessage(messages.createBookmarkCategory)}
type='submit'
/>
</form>
);
};
const BookmarkCategoryAdder: React.FC<{
statusId: string;
onClose: () => void;
}> = ({ statusId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const bookmark_categories = useAppSelector((state) =>
getOrderedBookmarkCategories(state),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return
const status = useAppSelector((state) => state.statuses.get(statusId));
const [bookmark_categoryIds, setBookmarkCategoryIds] = useState<string[]>(
[] as string[],
);
useEffect(() => {
dispatch(fetchBookmarkCategories());
apiGetStatusBookmarkCategories(statusId)
.then((data) => {
setBookmarkCategoryIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setBookmarkCategoryIds, statusId]);
const handleToggle = useCallback(
(bookmark_categoryId: string, checked: boolean) => {
if (checked) {
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
bookmark_categoryId,
...currentBookmarkCategoryIds,
]);
apiAddStatusToBookmarkCategory(bookmark_categoryId, statusId)
.then(() => {
dispatch(
bookmarkCategoryEditorAddSuccess(bookmark_categoryId, statusId),
);
return true;
})
.catch(() => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
currentBookmarkCategoryIds.filter(
(id) => id !== bookmark_categoryId,
),
);
});
} else {
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
currentBookmarkCategoryIds.filter((id) => id !== bookmark_categoryId),
);
apiRemoveStatusFromBookmarkCategory(bookmark_categoryId, statusId)
.then(() => {
dispatch(
bookmarkCategoryEditorRemoveSuccess(
bookmark_categoryId,
statusId,
),
);
if (
bookmarkCategoryNeeded &&
bookmark_categoryIds.filter((id) => id !== bookmark_categoryId)
.length === 0
) {
dispatch(unbookmark(status));
}
return true;
})
.catch(() => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
bookmark_categoryId,
...currentBookmarkCategoryIds,
]);
});
}
},
[setBookmarkCategoryIds, statusId, dispatch, bookmark_categoryIds, status],
);
const handleCreate = useCallback(
(bookmark_category: ApiBookmarkCategoryJSON) => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) => [
bookmark_category.id,
...currentBookmarkCategoryIds,
]);
apiAddStatusToBookmarkCategory(bookmark_category.id, statusId).catch(
() => {
setBookmarkCategoryIds((currentBookmarkCategoryIds) =>
currentBookmarkCategoryIds.filter(
(id) => id !== bookmark_category.id,
),
);
},
);
},
[setBookmarkCategoryIds, statusId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='bookmark_categories.add_to_bookmark_categories'
defaultMessage='Add {name} to bookmark_categories'
values={{ name: <strong>@</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewBookmarkCategoryItem onCreate={handleCreate} />
{bookmark_categories.map((bookmark_category) => (
<BookmarkCategoryItem
key={bookmark_category.id}
id={bookmark_category.id}
title={bookmark_category.title}
checked={bookmark_categoryIds.includes(bookmark_category.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default BookmarkCategoryAdder;

View file

@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { changeBookmarkCategoryEditorTitle, submitBookmarkCategoryEditor } from '../../../actions/bookmark_categories';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'bookmark_categories.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['bookmarkCategoryEditor', 'title']),
disabled: !state.getIn(['bookmarkCategoryEditor', 'isChanged']) || !state.getIn(['bookmarkCategoryEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeBookmarkCategoryEditorTitle(value)),
onSubmit: () => dispatch(submitBookmarkCategoryEditor(false)),
});
class EditBookmarkCategoryForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
iconComponent={CheckIcon}
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(EditBookmarkCategoryForm));

View file

@ -16,7 +16,7 @@ import { debounce } from 'lodash';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses , setupBookmarkCategoryEditor } from 'mastodon/actions/bookmark_categories';
import { deleteBookmarkCategory, expandBookmarkCategoryStatuses, fetchBookmarkCategory, fetchBookmarkCategoryStatuses } from 'mastodon/actions/bookmark_categories';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
@ -28,8 +28,6 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err
import { getBookmarkCategoryStatusList } from 'mastodon/selectors';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import EditBookmarkCategoryForm from './components/edit_bookmark_category_form';
const messages = defineMessages({
deleteMessage: { id: 'confirmations.delete_bookmark_category.message', defaultMessage: 'Are you sure you want to permanently delete this category?' },
@ -40,9 +38,8 @@ const messages = defineMessages({
const mapStateToProps = (state, { params }) => ({
bookmarkCategory: state.getIn(['bookmark_categories', params.id]),
statusIds: getBookmarkCategoryStatusList(state, params.id),
isLoading: state.getIn(['bookmark_categories', params.id, 'isLoading'], true),
isEditing: state.getIn(['bookmarkCategoryEditor', 'bookmarkCategoryId']) === params.id,
hasMore: !!state.getIn(['bookmark_categories', params.id, 'next']),
isLoading: state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmark_category_statuses', params.id, 'next']),
});
class BookmarkCategoryStatuses extends ImmutablePureComponent {
@ -57,7 +54,6 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
multiColumn: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isEditing: PropTypes.bool,
...WithRouterPropTypes,
};
@ -66,6 +62,16 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
this.props.dispatch(fetchBookmarkCategoryStatuses(this.props.params.id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchBookmarkCategory(id));
dispatch(fetchBookmarkCategoryStatuses(id));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
@ -87,7 +93,7 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
};
handleEditClick = () => {
this.props.dispatch(setupBookmarkCategoryEditor(this.props.params.id));
this.props.history.push(`/bookmark_categories/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {
@ -121,7 +127,7 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading, isEditing } = this.props;
const { intl, bookmarkCategory, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
const pinned = !!columnId;
if (typeof bookmarkCategory === 'undefined') {
@ -140,10 +146,6 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked posts yet. When you bookmark one, it will show up here." />;
const editor = isEditing && (
<EditBookmarkCategoryForm />
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
@ -165,8 +167,6 @@ class BookmarkCategoryStatuses extends ImmutablePureComponent {
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='bookmark_categories.delete' defaultMessage='Delete category' />
</button>
{editor}
</section>
</div>
</ColumnHeader>

View file

@ -1,43 +0,0 @@
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View file

@ -1,75 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromCircleAdder, addToCircleAdder } from '../../../actions/circles';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'circles.account.remove', defaultMessage: 'Remove from circle' },
add: { id: 'circles.account.add', defaultMessage: 'Add to circle' },
});
const MapStateToProps = (state, { circleId, added }) => ({
circle: state.get('circles').get(circleId),
added: typeof added === 'undefined' ? state.getIn(['circleAdder', 'circles', 'items']).includes(circleId) : added,
});
const mapDispatchToProps = (dispatch, { circleId }) => ({
onRemove: () => dispatch(removeFromCircleAdder(circleId)),
onAdd: () => dispatch(addToCircleAdder(circleId)),
});
class Circle extends ImmutablePureComponent {
static propTypes = {
circle: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { circle, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='user-circle' icon={CircleIcon} className='column-link__icon' fixedWidth />
{circle.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(Circle));

View file

@ -1,77 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { setupCircleAdder, resetCircleAdder } from '../../actions/circles';
import NewCircleForm from '../circles/components/new_circle_form';
import Account from './components/account';
import Circle from './components/circle';
// hack
const getOrderedCircles = createSelector([state => state.get('circles')], circles => {
if (!circles) {
return circles;
}
return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
circleIds: getOrderedCircles(state).map(circle=>circle.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupCircleAdder(accountId)),
onReset: () => dispatch(resetCircleAdder()),
});
class CircleAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
circleIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, circleIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewCircleForm />
<div className='list-adder__lists'>
{circleIds.map(CircleId => <Circle key={CircleId} circleId={CircleId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleAdder));

View file

@ -0,0 +1,213 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { fetchCircles } from 'mastodon/actions/circles';
import { createCircle } from 'mastodon/actions/circles_typed';
import {
apiGetAccountCircles,
apiAddAccountToCircle,
apiRemoveAccountFromCircle,
} from 'mastodon/api/circles';
import type { ApiCircleJSON } from 'mastodon/api_types/circles';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedCircles } from 'mastodon/selectors/circles';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newCircle: {
id: 'circles.new_circle_name',
defaultMessage: 'New circle name',
},
createCircle: {
id: 'circles.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const CircleItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='circle-ul' icon={CircleIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewCircleItem: React.FC<{
onCreate: (circle: ApiCircleJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createCircle({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='circle-ul' icon={CircleIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newCircle)}
/>
</label>
<Button text={intl.formatMessage(messages.createCircle)} type='submit' />
</form>
);
};
const CircleAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const circles = useAppSelector((state) => getOrderedCircles(state));
const [circleIds, setCircleIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchCircles());
apiGetAccountCircles(accountId)
.then((data) => {
setCircleIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setCircleIds, accountId]);
const handleToggle = useCallback(
(circleId: string, checked: boolean) => {
if (checked) {
setCircleIds((currentCircleIds) => [circleId, ...currentCircleIds]);
apiAddAccountToCircle(circleId, accountId).catch(() => {
setCircleIds((currentCircleIds) =>
currentCircleIds.filter((id) => id !== circleId),
);
});
} else {
setCircleIds((currentCircleIds) =>
currentCircleIds.filter((id) => id !== circleId),
);
apiRemoveAccountFromCircle(circleId, accountId).catch(() => {
setCircleIds((currentCircleIds) => [circleId, ...currentCircleIds]);
});
}
},
[setCircleIds, accountId],
);
const handleCreate = useCallback(
(circle: ApiCircleJSON) => {
setCircleIds((currentCircleIds) => [circle.id, ...currentCircleIds]);
apiAddAccountToCircle(circle.id, accountId).catch(() => {
setCircleIds((currentCircleIds) =>
currentCircleIds.filter((id) => id !== circle.id),
);
});
},
[setCircleIds, accountId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='circles.add_to_circles'
defaultMessage='Add {name} to circles'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewCircleItem onCreate={handleCreate} />
{circles.map((circle) => (
<CircleItem
key={circle.id}
id={circle.id}
title={circle.title}
checked={circleIds.includes(circle.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default CircleAdder;

View file

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { removeFromCircleEditor, addToCircleEditor } from '../../../actions/circles';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'circles.account.remove', defaultMessage: 'Remove from circle' },
add: { id: 'circles.account.add', defaultMessage: 'Add to circle' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['circleEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromCircleEditor(accountId)),
onAdd: () => dispatch(addToCircleEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { account, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View file

@ -1,76 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { changeCircleEditorTitle, submitCircleEditor } from '../../../actions/circles';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'circles.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['circleEditor', 'title']),
disabled: !state.getIn(['circleEditor', 'isChanged']) || !state.getIn(['circleEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeCircleEditorTitle(value)),
onSubmit: () => dispatch(submitCircleEditor(false)),
});
class CircleForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
iconComponent={CheckIcon}
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleForm));

View file

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CancelIcon from '@/material-icons/400-24px/cancel-fill.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { fetchCircleSuggestions, clearCircleSuggestions, changeCircleSuggestions } from '../../../actions/circles';
const messages = defineMessages({
search: { id: 'circles.search', defaultMessage: 'Search among people follow you' },
});
const mapStateToProps = state => ({
value: state.getIn(['circleEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchCircleSuggestions(value)),
onClear: () => dispatch(clearCircleSuggestions()),
onChange: value => dispatch(changeCircleSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -1,83 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { setupCircleEditor, clearCircleSuggestions, resetCircleEditor } from '../../actions/circles';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditCircleForm from './components/edit_circle_form';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['circleEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['circleEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: circleId => dispatch(setupCircleEditor(circleId)),
onClear: () => dispatch(clearCircleSuggestions()),
onReset: () => dispatch(resetCircleEditor()),
});
class CircleEditor extends ImmutablePureComponent {
static propTypes = {
circleId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, circleId } = this.props;
onInitialize(circleId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<EditCircleForm />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(CircleEditor));

View file

@ -38,9 +38,8 @@ const messages = defineMessages({
const mapStateToProps = (state, { params }) => ({
circle: state.getIn(['circles', params.id]),
statusIds: getCircleStatusList(state, params.id),
isLoading: state.getIn(['circles', params.id, 'isLoading'], true),
isEditing: state.getIn(['circleEditor', 'circleId']) === params.id,
hasMore: !!state.getIn(['circles', params.id, 'next']),
isLoading: state.getIn(['status_lists', 'circle_statuses', params.id, 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'circle_statuses', params.id, 'next']),
});
class CircleStatuses extends ImmutablePureComponent {
@ -63,6 +62,16 @@ class CircleStatuses extends ImmutablePureComponent {
this.props.dispatch(fetchCircleStatuses(this.props.params.id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
dispatch(fetchCircle(id));
dispatch(fetchCircleStatuses(id));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
@ -84,10 +93,7 @@ class CircleStatuses extends ImmutablePureComponent {
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'CIRCLE_EDITOR',
modalProps: { circleId: this.props.params.id },
}));
this.props.history.push(`/circles/${this.props.params.id}/edit`);
};
handleDeleteClick = () => {

View file

@ -1,80 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { changeCircleEditorTitle, submitCircleEditor } from 'mastodon/actions/circles';
import { Button } from 'mastodon/components/button';
const messages = defineMessages({
label: { id: 'circles.new.title_placeholder', defaultMessage: 'New circle title' },
title: { id: 'circles.new.create', defaultMessage: 'Add circle' },
});
const mapStateToProps = state => ({
value: state.getIn(['circleEditor', 'title']),
disabled: state.getIn(['circleEditor', 'isSubmitting']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeCircleEditorTitle(value)),
onSubmit: () => dispatch(submitCircleEditor(true)),
});
class NewCircleForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const label = intl.formatMessage(messages.label);
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={value}
disabled={disabled}
onChange={this.handleChange}
placeholder={label}
/>
</label>
<Button
disabled={disabled || !value}
text={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(NewCircleForm));

View file

@ -1,125 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import CirclesIcon from '@/material-icons/400-24px/account_circle-fill.svg?react';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import { fetchCircles, deleteCircle } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';
import NewCircleForm from './components/new_circle_form';
const messages = defineMessages({
heading: { id: 'column.circles', defaultMessage: 'Circles' },
subheading: { id: 'circles.subheading', defaultMessage: 'Your circles' },
deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' },
deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' },
});
const getOrderedCircles = createSelector([state => state.get('circles')], circles => {
if (!circles) {
return circles;
}
return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
circles: getOrderedCircles(state),
});
class Circles extends ImmutablePureComponent {
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
circles: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
};
UNSAFE_componentWillMount () {
this.props.dispatch(fetchCircles());
}
handleEditClick = (e) => {
e.preventDefault();
this.props.dispatch(openModal({
modalType: 'CIRCLE_EDITOR',
modalProps: { circleId: e.currentTarget.getAttribute('data-id') },
}));
};
handleRemoveClick = (e) => {
const { dispatch, intl } = this.props;
e.preventDefault();
const id = e.currentTarget.getAttribute('data-id');
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => {
dispatch(deleteCircle(id));
},
},
}));
};
render () {
const { intl, circles, multiColumn } = this.props;
if (!circles) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
const emptyMessage = <FormattedMessage id='empty_column.circles' defaultMessage="You don't have any circles yet. When you create one, it will show up here." />;
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.heading)}>
<ColumnHeader title={intl.formatMessage(messages.heading)} icon='user-circle' iconComponent={CirclesIcon} multiColumn={multiColumn} />
<NewCircleForm />
<ScrollableList
scrollKey='circles'
emptyMessage={emptyMessage}
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
bindToDocument={!multiColumn}
>
{circles.map(circle =>
<ColumnLink key={circle.get('id')} to={`/circles/${circle.get('id')}`} icon='user-circle' iconComponent={CircleIcon} text={circle.get('title')} />,
)}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Circles));

View file

@ -0,0 +1,145 @@
import { useEffect, useMemo, useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchCircles } from 'mastodon/actions/circles';
import { openModal } from 'mastodon/actions/modal';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { getOrderedCircles } from 'mastodon/selectors/circles';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.circles', defaultMessage: 'Circles' },
create: { id: 'circles.create_circle', defaultMessage: 'Create circle' },
edit: { id: 'circles.edit', defaultMessage: 'Edit circle' },
delete: { id: 'circles.delete', defaultMessage: 'Delete circle' },
more: { id: 'status.more', defaultMessage: 'More' },
});
const CircleItem: React.FC<{
id: string;
title: string;
}> = ({ id, title }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_CIRCLE',
modalProps: {
circleId: id,
},
}),
);
}, [dispatch, id]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.edit), to: `/circles/${id}/edit` },
{ text: intl.formatMessage(messages.delete), action: handleDeleteClick },
],
[intl, id, handleDeleteClick],
);
return (
<div className='lists__item'>
<Link to={`/circles/${id}`} className='lists__item__title'>
<Icon id='circle-ul' icon={CircleIcon} />
<span>{title}</span>
</Link>
<DropdownMenuContainer
scrollKey='circles'
items={menu}
icons='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
);
};
const Circles: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const circles = useAppSelector((state) => getOrderedCircles(state));
useEffect(() => {
dispatch(fetchCircles());
}, [dispatch]);
const emptyMessage = (
<>
<span>
<FormattedMessage
id='circles.no_circles_yet'
defaultMessage='No circles yet.'
/>
<br />
<FormattedMessage
id='circles.create_a_circle_to_organize'
defaultMessage='Create a new circle to organize your Home feed'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='circle-ul'
iconComponent={CircleIcon}
multiColumn={multiColumn}
extraButton={
<Link
to='/circles/new'
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
>
<Icon id='plus' icon={AddIcon} />
</Link>
}
/>
<ScrollableList
scrollKey='circles'
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>
{circles.map((circle) => (
<CircleItem key={circle.id} id={circle.id} title={circle.title} />
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default Circles;

View file

@ -0,0 +1,377 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { fetchFollowers } from 'mastodon/actions/accounts';
import { fetchCircle } from 'mastodon/actions/circles';
import { importFetchedAccounts } from 'mastodon/actions/importer';
import { apiRequest } from 'mastodon/api';
import {
apiGetAccounts,
apiAddAccountToCircle,
apiRemoveAccountFromCircle,
} from 'mastodon/api/circles';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { FollowersCounter } from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import { ShortNumber } from 'mastodon/components/short_number';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: {
id: 'column.circle_members',
defaultMessage: 'Manage circle members',
},
placeholder: {
id: 'circles.search_placeholder',
defaultMessage: 'Search people you follow',
},
enterSearch: { id: 'circles.add_to_circle', defaultMessage: 'Add to circle' },
add: { id: 'circles.add_member', defaultMessage: 'Add' },
remove: { id: 'circles.remove_member', defaultMessage: 'Remove' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
type Mode = 'remove' | 'add';
const ColumnSearchHeader: React.FC<{
onBack: () => void;
onSubmit: (value: string) => void;
}> = ({ onBack, onSubmit }) => {
const intl = useIntl();
const [value, setValue] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setValue(value);
onSubmit(value);
},
[setValue, onSubmit],
);
const handleSubmit = useCallback(() => {
onSubmit(value);
}, [onSubmit, value]);
return (
<ButtonInTabsBar>
<form className='column-search-header' onSubmit={handleSubmit}>
<button
type='button'
className='column-header__back-button compact'
onClick={onBack}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
</button>
<input
type='search'
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.placeholder)}
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus
/>
</form>
</ButtonInTabsBar>
);
};
const AccountItem: React.FC<{
accountId: string;
circleId: string;
partOfCircle: boolean;
onToggle: (accountId: string) => void;
}> = ({ accountId, circleId, partOfCircle, onToggle }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(accountId));
const handleClick = useCallback(() => {
if (partOfCircle) {
void apiRemoveAccountFromCircle(circleId, accountId);
} else {
void apiAddAccountToCircle(circleId, accountId);
}
onToggle(accountId);
}, [accountId, circleId, partOfCircle, onToggle]);
if (!account) {
return null;
}
const firstVerifiedField = account.fields.find((item) => !!item.verified_at);
return (
<div className='account'>
<div className='account__wrapper'>
<Link
key={account.id}
className='account__display-name'
title={account.acct}
to={`/@${account.acct}`}
data-hover-card-account={account.id}
>
<div className='account__avatar-wrapper'>
<Avatar account={account} size={36} />
</div>
<div className='account__contents'>
<DisplayName account={account} />
<div className='account__details'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
isHide={account.other_settings.hide_followers_count}
/>{' '}
{firstVerifiedField && (
<VerifiedBadge link={firstVerifiedField.value} />
)}
</div>
</div>
</Link>
<div className='account__relationship'>
<Button
text={intl.formatMessage(
partOfCircle ? messages.remove : messages.add,
)}
onClick={handleClick}
/>
</div>
</div>
</div>
);
};
const CircleMembers: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id: string }>();
const intl = useIntl();
const followingAccountIds = useAppSelector(
(state) => state.user_lists.getIn(['followers', me, 'items']) as string[],
);
const [searching, setSearching] = useState(false);
const [accountIds, setAccountIds] = useState<string[]>([]);
const [searchAccountIds, setSearchAccountIds] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [mode, setMode] = useState<Mode>('remove');
useEffect(() => {
if (id) {
setLoading(true);
dispatch(fetchCircle(id));
void apiGetAccounts(id)
.then((data) => {
dispatch(importFetchedAccounts(data));
setAccountIds(data.map((a) => a.id));
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
dispatch(fetchFollowers(me));
}
}, [dispatch, id]);
const handleSearchClick = useCallback(() => {
setMode('add');
}, [setMode]);
const handleDismissSearchClick = useCallback(() => {
setMode('remove');
setSearching(false);
}, [setMode]);
const handleAccountToggle = useCallback(
(accountId: string) => {
const partOfCircle = accountIds.includes(accountId);
if (partOfCircle) {
setAccountIds(accountIds.filter((id) => id !== accountId));
} else {
setAccountIds([accountId, ...accountIds]);
}
},
[accountIds, setAccountIds],
);
const searchRequestRef = useRef<AbortController | null>(null);
const handleSearch = useDebouncedCallback(
(value: string) => {
if (searchRequestRef.current) {
searchRequestRef.current.abort();
}
if (value.trim().length === 0) {
setSearching(false);
return;
}
setLoading(true);
searchRequestRef.current = new AbortController();
void apiRequest<ApiAccountJSON[]>('GET', 'v1/accounts/search', {
signal: searchRequestRef.current.signal,
params: {
q: value,
resolve: false,
following: true,
},
})
.then((data) => {
dispatch(importFetchedAccounts(data));
setSearchAccountIds(data.map((a) => a.id));
setLoading(false);
setSearching(true);
return '';
})
.catch(() => {
setSearching(true);
setLoading(false);
});
},
500,
{ leading: true, trailing: true },
);
let displayedAccountIds: string[];
if (mode === 'add') {
displayedAccountIds = searching ? searchAccountIds : followingAccountIds;
} else {
displayedAccountIds = accountIds;
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
>
{mode === 'remove' ? (
<ColumnHeader
title={intl.formatMessage(messages.heading)}
icon='circle-ul'
iconComponent={CircleIcon}
multiColumn={multiColumn}
showBackButton
extraButton={
<button
onClick={handleSearchClick}
type='button'
className='column-header__button'
title={intl.formatMessage(messages.enterSearch)}
aria-label={intl.formatMessage(messages.enterSearch)}
>
<Icon id='plus' icon={AddIcon} />
</button>
}
/>
) : (
<ColumnSearchHeader
onBack={handleDismissSearchClick}
onSubmit={handleSearch}
/>
)}
<ScrollableList
scrollKey='circle_members'
trackScroll={!multiColumn}
bindToDocument={!multiColumn}
isLoading={loading}
showLoading={loading && displayedAccountIds.length === 0}
hasMore={false}
footer={
mode === 'remove' && (
<>
{displayedAccountIds.length > 0 && <div className='spacer' />}
<div className='column-footer'>
<Link to={`/circles/${id}`} className='button button--block'>
<FormattedMessage id='circles.done' defaultMessage='Done' />
</Link>
</div>
</>
)
}
emptyMessage={
mode === 'remove' ? (
<>
<span>
<FormattedMessage
id='circles.no_members_yet'
defaultMessage='No members yet.'
/>
<br />
<FormattedMessage
id='circles.find_users_to_add'
defaultMessage='Find users to add'
/>
</span>
<SquigglyArrow className='empty-column-indicator__arrow' />
</>
) : (
<FormattedMessage
id='circles.no_results_found'
defaultMessage='No results found.'
/>
)
}
>
{displayedAccountIds.map((accountId) => (
<AccountItem
key={accountId}
accountId={accountId}
circleId={id}
partOfCircle={
displayedAccountIds === accountIds ||
accountIds.includes(accountId)
}
onToggle={handleAccountToggle}
/>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default CircleMembers;

View file

@ -0,0 +1,212 @@
import { useCallback, useState, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams, useHistory, Link } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit';
import CircleIcon from '@/material-icons/400-24px/account_circle.svg?react';
import { fetchCircle } from 'mastodon/actions/circles';
import { createCircle, updateCircle } from 'mastodon/actions/circles_typed';
import { apiGetAccounts } from 'mastodon/api/circles';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
edit: { id: 'column.edit_circle', defaultMessage: 'Edit circle' },
create: { id: 'column.create_circle', defaultMessage: 'Create circle' },
});
const MembersLink: React.FC<{
id: string;
}> = ({ id }) => {
const [count, setCount] = useState(0);
const [avatars, setAvatars] = useState<string[]>([]);
useEffect(() => {
void apiGetAccounts(id)
.then((data) => {
setCount(data.length);
setAvatars(data.slice(0, 3).map((a) => a.avatar));
return '';
})
.catch(() => {
// Nothing
});
}, [id, setCount, setAvatars]);
return (
<Link to={`/circles/${id}/members`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='circles.circle_members'
defaultMessage='Circle members'
/>
</strong>
<FormattedMessage
id='circles.circle_members_count'
defaultMessage='{count, plural, one {# member} other {# members}}'
values={{ count }}
/>
</div>
<div className='avatar-pile'>
{avatars.map((url) => (
<img key={url} src={url} alt='' />
))}
</div>
</Link>
);
};
const NewCircle: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const { id } = useParams<{ id?: string }>();
const intl = useIntl();
const history = useHistory();
const circle = useAppSelector((state) =>
id ? state.circles.get(id) : undefined,
);
const [title, setTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (id) {
dispatch(fetchCircle(id));
}
}, [dispatch, id]);
useEffect(() => {
if (id && circle) {
setTitle(circle.title);
}
}, [setTitle, id, circle]);
const handleTitleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
setSubmitting(true);
if (id) {
void dispatch(
updateCircle({
id,
title,
}),
).then(() => {
setSubmitting(false);
return '';
});
} else {
void dispatch(
createCircle({
title,
}),
).then((result) => {
setSubmitting(false);
if (isFulfilled(result)) {
history.replace(`/circles/${result.payload.id}/edit`);
history.push(`/circles/${result.payload.id}/members`);
}
return '';
});
}
}, [history, dispatch, setSubmitting, id, title]);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(id ? messages.edit : messages.create)}
>
<ColumnHeader
title={intl.formatMessage(id ? messages.edit : messages.create)}
icon='circle-ul'
iconComponent={CircleIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<div className='input with_label'>
<div className='label_input'>
<label htmlFor='circle_title'>
<FormattedMessage
id='circles.circle_name'
defaultMessage='Circle name'
/>
</label>
<div className='label_input__wrapper'>
<input
id='circle_title'
type='text'
value={title}
onChange={handleTitleChange}
maxLength={30}
required
placeholder=' '
/>
</div>
</div>
</div>
</div>
{id && (
<div className='fields-group'>
<MembersLink id={id} />
</div>
)}
{!id && (
<div className='fields-group'>
<div className='app-form__memo'>
<FormattedMessage
id='circles.save_to_edit_member'
defaultMessage='You can edit circle members after saving.'
/>
</div>
</div>
)}
<div className='actions'>
<button className='button' type='submit'>
{submitting ? (
<LoadingIndicator />
) : id ? (
<FormattedMessage id='circles.save' defaultMessage='Save' />
) : (
<FormattedMessage id='circles.create' defaultMessage='Create' />
)}
</button>
</div>
</form>
</div>
<Helmet>
<title>
{intl.formatMessage(id ? messages.edit : messages.create)}
</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default NewCircle;

View file

@ -5,7 +5,6 @@ import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchSuggestions } from 'mastodon/actions/suggestions';
@ -15,15 +14,15 @@ import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { Card } from './components/card';
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
suggestions: state.suggestions.items,
isLoading: state.suggestions.isLoading,
});
class Suggestions extends PureComponent {
static propTypes = {
isLoading: PropTypes.bool,
suggestions: ImmutablePropTypes.list,
suggestions: PropTypes.array,
dispatch: PropTypes.func.isRequired,
...WithRouterPropTypes,
};
@ -32,17 +31,17 @@ class Suggestions extends PureComponent {
const { dispatch, suggestions, history } = this.props;
// If we're navigating back to the screen, do not trigger a reload
if (history.action === 'POP' && suggestions.size > 0) {
if (history.action === 'POP' && suggestions.length > 0) {
return;
}
dispatch(fetchSuggestions(true));
dispatch(fetchSuggestions());
}
render () {
const { isLoading, suggestions } = this.props;
if (!isLoading && suggestions.isEmpty()) {
if (!isLoading && suggestions.length === 0) {
return (
<div className='explore__suggestions scrollable scrollable--flex'>
<div className='empty-column-indicator'>
@ -56,9 +55,9 @@ class Suggestions extends PureComponent {
<div className='explore__suggestions scrollable' data-nosnippet>
{isLoading ? <LoadingIndicator /> : suggestions.map(suggestion => (
<Card
key={suggestion.get('account')}
id={suggestion.get('account')}
source={suggestion.getIn(['sources', 0])}
key={suggestion.account_id}
id={suggestion.account_id}
source={suggestion.sources[0]}
/>
))}
</div>

View file

@ -145,7 +145,7 @@ class GettingStarted extends ImmutablePureComponent {
<ColumnLink key='bookmark' icon='bookmarks' iconComponent={BookmarksIcon} text={intl.formatMessage(messages.bookmarks)} to='/bookmark_categories' />,
<ColumnLink key='favourites' icon='star' iconComponent={StarIcon} text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='lists' icon='list-ul' iconComponent={ListAltIcon} text={intl.formatMessage(messages.lists)} to='/lists' />,
<ColumnLink key='antennas' icon='wifi' iconComponent={AntennaIcon} text={intl.formatMessage(messages.antennas)} to='/antennasw' />,
<ColumnLink key='antennas' icon='wifi' iconComponent={AntennaIcon} text={intl.formatMessage(messages.antennas)} to='/antennas' />,
<ColumnLink key='circles' icon='user-circle' iconComponent={CirclesIcon} text={intl.formatMessage(messages.circles)} to='/circles' />,
);

View file

@ -1,217 +0,0 @@
import PropTypes from 'prop-types';
import { useEffect, useCallback, useRef, useState } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch, useSelector } from 'react-redux';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
friendsOfFriendsHint: { id: 'follow_suggestions.hints.friends_of_friends', defaultMessage: 'This profile is popular among the people you follow.' },
similarToRecentlyFollowedHint: { id: 'follow_suggestions.hints.similar_to_recently_followed', defaultMessage: 'This profile is similar to the profiles you have most recently followed.' },
featuredHint: { id: 'follow_suggestions.hints.featured', defaultMessage: 'This profile has been hand-picked by the {domain} team.' },
mostFollowedHint: { id: 'follow_suggestions.hints.most_followed', defaultMessage: 'This profile is one of the most followed on {domain}.'},
mostInteractionsHint: { id: 'follow_suggestions.hints.most_interactions', defaultMessage: 'This profile has been recently getting a lot of attention on {domain}.' },
});
const Source = ({ id }) => {
const intl = useIntl();
let label, hint;
switch (id) {
case 'friends_of_friends':
hint = intl.formatMessage(messages.friendsOfFriendsHint);
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'similar_to_recently_followed':
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
label = <FormattedMessage id='follow_suggestions.personalized_suggestion' defaultMessage='Personalized suggestion' />;
break;
case 'featured':
hint = intl.formatMessage(messages.featuredHint, { domain });
label = <FormattedMessage id='follow_suggestions.curated_suggestion' defaultMessage='Staff pick' />;
break;
case 'most_followed':
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
case 'most_interactions':
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
label = <FormattedMessage id='follow_suggestions.popular_suggestion' defaultMessage='Popular suggestion' />;
break;
}
return (
<div className='inline-follow-suggestions__body__scrollable__card__text-stack__source' title={hint}>
<Icon icon={InfoIcon} />
{label}
</div>
);
};
Source.propTypes = {
id: PropTypes.oneOf(['friends_of_friends', 'similar_to_recently_followed', 'featured', 'most_followed', 'most_interactions']),
};
const Card = ({ id, sources }) => {
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', id]));
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
const dispatch = useDispatch();
const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion(id));
}, [id, dispatch]);
return (
<div className='inline-follow-suggestions__body__scrollable__card'>
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
<Link to={`/@${account.get('acct')}`}><Avatar account={account} size={72} /></Link>
</div>
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
<Link to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
</div>
<FollowButton accountId={id} />
</div>
);
};
Card.propTypes = {
id: PropTypes.string.isRequired,
sources: ImmutablePropTypes.list,
};
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions = ({ hidden }) => {
const intl = useIntl();
const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['suggestions', 'items']));
const isLoading = useSelector(state => state.getIn(['suggestions', 'isLoading']));
const dismissed = useSelector(state => state.getIn(['settings', 'dismissed_banners', DISMISSIBLE_ID]));
const bodyRef = useRef();
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
dispatch(fetchSuggestions());
}, [dispatch]);
useEffect(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}
}, [setCanScrollRight, setCanScrollLeft, bodyRef, suggestions]);
const handleLeftNav = useCallback(() => {
bodyRef.current.scrollLeft -= 200;
}, [bodyRef]);
const handleRightNav = useCallback(() => {
bodyRef.current.scrollLeft += 200;
}, [bodyRef]);
const handleScroll = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft((bodyRef.current.clientWidth - bodyRef.current.scrollLeft) < bodyRef.current.scrollWidth);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight((bodyRef.current.scrollLeft + bodyRef.current.clientWidth) < bodyRef.current.scrollWidth);
}
}, [setCanScrollRight, setCanScrollLeft, bodyRef]);
const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]);
if (dismissed || (!isLoading && suggestions.isEmpty())) {
return null;
}
if (hidden) {
return (
<div className='inline-follow-suggestions' />
);
}
return (
<div className='inline-follow-suggestions'>
<div className='inline-follow-suggestions__header'>
<h3><FormattedMessage id='follow_suggestions.who_to_follow' defaultMessage='Who to follow' /></h3>
<div className='inline-follow-suggestions__header__actions'>
<button className='link-button' onClick={handleDismiss}><FormattedMessage id='follow_suggestions.dismiss' defaultMessage="Don't show again" /></button>
<Link to='/explore/suggestions' className='link-button'><FormattedMessage id='follow_suggestions.view_all' defaultMessage='View all' /></Link>
</div>
</div>
<div className='inline-follow-suggestions__body'>
<div className='inline-follow-suggestions__body__scrollable' ref={bodyRef} onScroll={handleScroll}>
{suggestions.map(suggestion => (
<Card
key={suggestion.get('account')}
id={suggestion.get('account')}
sources={suggestion.get('sources')}
/>
))}
</div>
{canScrollLeft && (
<button className='inline-follow-suggestions__body__scroll-button left' onClick={handleLeftNav} aria-label={intl.formatMessage(messages.previous)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronLeftIcon} /></div>
</button>
)}
{canScrollRight && (
<button className='inline-follow-suggestions__body__scroll-button right' onClick={handleRightNav} aria-label={intl.formatMessage(messages.next)}>
<div className='inline-follow-suggestions__body__scroll-button__icon'><Icon icon={ChevronRightIcon} /></div>
</button>
)}
</div>
</div>
);
};
InlineFollowSuggestions.propTypes = {
hidden: PropTypes.bool,
};

View file

@ -0,0 +1,326 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { changeSetting } from 'mastodon/actions/settings';
import {
fetchSuggestions,
dismissSuggestion,
} from 'mastodon/actions/suggestions';
import type { ApiSuggestionSourceJSON } from 'mastodon/api_types/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
next: { id: 'lightbox.next', defaultMessage: 'Next' },
dismiss: {
id: 'follow_suggestions.dismiss',
defaultMessage: "Don't show again",
},
friendsOfFriendsHint: {
id: 'follow_suggestions.hints.friends_of_friends',
defaultMessage: 'This profile is popular among the people you follow.',
},
similarToRecentlyFollowedHint: {
id: 'follow_suggestions.hints.similar_to_recently_followed',
defaultMessage:
'This profile is similar to the profiles you have most recently followed.',
},
featuredHint: {
id: 'follow_suggestions.hints.featured',
defaultMessage: 'This profile has been hand-picked by the {domain} team.',
},
mostFollowedHint: {
id: 'follow_suggestions.hints.most_followed',
defaultMessage: 'This profile is one of the most followed on {domain}.',
},
mostInteractionsHint: {
id: 'follow_suggestions.hints.most_interactions',
defaultMessage:
'This profile has been recently getting a lot of attention on {domain}.',
},
});
const Source: React.FC<{
id: ApiSuggestionSourceJSON;
}> = ({ id }) => {
const intl = useIntl();
let label, hint;
switch (id) {
case 'friends_of_friends':
hint = intl.formatMessage(messages.friendsOfFriendsHint);
label = (
<FormattedMessage
id='follow_suggestions.personalized_suggestion'
defaultMessage='Personalized suggestion'
/>
);
break;
case 'similar_to_recently_followed':
hint = intl.formatMessage(messages.similarToRecentlyFollowedHint);
label = (
<FormattedMessage
id='follow_suggestions.personalized_suggestion'
defaultMessage='Personalized suggestion'
/>
);
break;
case 'featured':
hint = intl.formatMessage(messages.featuredHint, { domain });
label = (
<FormattedMessage
id='follow_suggestions.curated_suggestion'
defaultMessage='Staff pick'
/>
);
break;
case 'most_followed':
hint = intl.formatMessage(messages.mostFollowedHint, { domain });
label = (
<FormattedMessage
id='follow_suggestions.popular_suggestion'
defaultMessage='Popular suggestion'
/>
);
break;
case 'most_interactions':
hint = intl.formatMessage(messages.mostInteractionsHint, { domain });
label = (
<FormattedMessage
id='follow_suggestions.popular_suggestion'
defaultMessage='Popular suggestion'
/>
);
break;
}
return (
<div
className='inline-follow-suggestions__body__scrollable__card__text-stack__source'
title={hint}
>
<Icon id='' icon={InfoIcon} />
{label}
</div>
);
};
const Card: React.FC<{
id: string;
sources: [ApiSuggestionSourceJSON, ...ApiSuggestionSourceJSON[]];
}> = ({ id, sources }) => {
const intl = useIntl();
const account = useAppSelector((state) => state.accounts.get(id));
const firstVerifiedField = account?.fields.find((item) => !!item.verified_at);
const dispatch = useAppDispatch();
const handleDismiss = useCallback(() => {
void dispatch(dismissSuggestion({ accountId: id }));
}, [id, dispatch]);
return (
<div className='inline-follow-suggestions__body__scrollable__card'>
<IconButton
icon=''
iconComponent={CloseIcon}
onClick={handleDismiss}
title={intl.formatMessage(messages.dismiss)}
/>
<div className='inline-follow-suggestions__body__scrollable__card__avatar'>
<Link to={`/@${account?.acct}`}>
<Avatar account={account} size={72} />
</Link>
</div>
<div className='inline-follow-suggestions__body__scrollable__card__text-stack'>
<Link to={`/@${account?.acct}`}>
<DisplayName account={account} />
</Link>
{firstVerifiedField ? (
<VerifiedBadge link={firstVerifiedField.value} />
) : (
<Source id={sources[0]} />
)}
</div>
<FollowButton accountId={id} />
</div>
);
};
const DISMISSIBLE_ID = 'home/follow-suggestions';
export const InlineFollowSuggestions: React.FC<{
hidden?: boolean;
}> = ({ hidden }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const suggestions = useAppSelector((state) => state.suggestions.items);
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
const dismissed = useAppSelector(
(state) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
);
const bodyRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => {
void dispatch(fetchSuggestions());
}, [dispatch]);
useEffect(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, [setCanScrollRight, setCanScrollLeft, suggestions]);
const handleLeftNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft -= 200;
}, []);
const handleRightNav = useCallback(() => {
if (!bodyRef.current) {
return;
}
bodyRef.current.scrollLeft += 200;
}, []);
const handleScroll = useCallback(() => {
if (!bodyRef.current) {
return;
}
if (getComputedStyle(bodyRef.current).direction === 'rtl') {
setCanScrollLeft(
bodyRef.current.clientWidth - bodyRef.current.scrollLeft <
bodyRef.current.scrollWidth,
);
setCanScrollRight(bodyRef.current.scrollLeft < 0);
} else {
setCanScrollLeft(bodyRef.current.scrollLeft > 0);
setCanScrollRight(
bodyRef.current.scrollLeft + bodyRef.current.clientWidth <
bodyRef.current.scrollWidth,
);
}
}, [setCanScrollRight, setCanScrollLeft]);
const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]);
if (dismissed || (!isLoading && suggestions.length === 0)) {
return null;
}
if (hidden) {
return <div className='inline-follow-suggestions' />;
}
return (
<div className='inline-follow-suggestions'>
<div className='inline-follow-suggestions__header'>
<h3>
<FormattedMessage
id='follow_suggestions.who_to_follow'
defaultMessage='Who to follow'
/>
</h3>
<div className='inline-follow-suggestions__header__actions'>
<button className='link-button' onClick={handleDismiss}>
<FormattedMessage
id='follow_suggestions.dismiss'
defaultMessage="Don't show again"
/>
</button>
<Link to='/explore/suggestions' className='link-button'>
<FormattedMessage
id='follow_suggestions.view_all'
defaultMessage='View all'
/>
</Link>
</div>
</div>
<div className='inline-follow-suggestions__body'>
<div
className='inline-follow-suggestions__body__scrollable'
ref={bodyRef}
onScroll={handleScroll}
>
{suggestions.map((suggestion) => (
<Card
key={suggestion.account_id}
id={suggestion.account_id}
sources={suggestion.sources}
/>
))}
</div>
{canScrollLeft && (
<button
className='inline-follow-suggestions__body__scroll-button left'
onClick={handleLeftNav}
aria-label={intl.formatMessage(messages.previous)}
>
<div className='inline-follow-suggestions__body__scroll-button__icon'>
<Icon id='' icon={ChevronLeftIcon} />
</div>
</button>
)}
{canScrollRight && (
<button
className='inline-follow-suggestions__body__scroll-button right'
onClick={handleRightNav}
aria-label={intl.formatMessage(messages.next)}
>
<div className='inline-follow-suggestions__body__scroll-button__icon'>
<Icon id='' icon={ChevronRightIcon} />
</div>
</button>
)}
</div>
</div>
);
};

View file

@ -1,45 +0,0 @@
// Kmyblue tracking marker: copied antenna_adder/account, circle_adder/account
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { makeGetAccount } from '../../../selectors';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId }) => ({
account: getAccount(state, accountId),
});
return mapStateToProps;
};
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps)(injectIntl(Account));

View file

@ -1,82 +0,0 @@
// Kmyblue tracking marker: copied antenna_adder/antenna, circle_adder/circle, bookmark_category_adder/bookmark_category
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import { Icon } from 'mastodon/components/icon';
import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
exclusive: { id: 'lists.exclusive', defaultMessage: 'Hide list or antenna account posts from home' },
});
const MapStateToProps = (state, { listId, added }) => ({
list: state.get('lists').get(listId),
added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
});
const mapDispatchToProps = (dispatch, { listId }) => ({
onRemove: () => dispatch(removeFromListAdder(listId)),
onAdd: () => dispatch(addToListAdder(listId)),
});
class List extends ImmutablePureComponent {
static propTypes = {
list: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { list, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
const exclusiveIcon = list.get('exclusive') && <Icon id='eye-slash' icon={VisibilityOffIcon} title={intl.formatMessage(messages.exclusive)} className='column-link__icon' fixedWidth />;
return (
<div className='list'>
<div className='list__wrapper'>
<div className='list__display-name'>
<Icon id='list-ul' icon={ListAltIcon} className='column-link__icon' />
{exclusiveIcon}
{list.get('title')}
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(MapStateToProps, mapDispatchToProps)(injectIntl(List));

View file

@ -1,78 +0,0 @@
// Kmyblue tracking marker: copied antenna_adder, circle_adder, bookmark_category_adder
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { setupListAdder, resetListAdder } from '../../actions/lists';
import NewListForm from '../lists/components/new_list_form';
import Account from './components/account';
import List from './components/list';
// hack
const getOrderedLists = createSelector([state => state.get('lists')], lists => {
if (!lists) {
return lists;
}
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
});
const mapStateToProps = state => ({
listIds: getOrderedLists(state).map(list=>list.get('id')),
});
const mapDispatchToProps = dispatch => ({
onInitialize: accountId => dispatch(setupListAdder(accountId)),
onReset: () => dispatch(resetListAdder()),
});
class ListAdder extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
listIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, accountId } = this.props;
onInitialize(accountId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountId, listIds } = this.props;
return (
<div className='modal-root__modal list-adder'>
<div className='list-adder__account'>
<Account accountId={accountId} />
</div>
<NewListForm />
<div className='list-adder__lists'>
{listIds.map(ListId => <List key={ListId} listId={ListId} />)}
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListAdder));

View file

@ -0,0 +1,213 @@
import { useEffect, useState, useCallback } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { isFulfilled } from '@reduxjs/toolkit';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchLists } from 'mastodon/actions/lists';
import { createList } from 'mastodon/actions/lists_typed';
import {
apiGetAccountLists,
apiAddAccountToList,
apiRemoveAccountFromList,
} from 'mastodon/api/lists';
import type { ApiListJSON } from 'mastodon/api_types/lists';
import { Button } from 'mastodon/components/button';
import { CheckBox } from 'mastodon/components/check_box';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { getOrderedLists } from 'mastodon/selectors/lists';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
newList: {
id: 'lists.new_list_name',
defaultMessage: 'New list name',
},
createList: {
id: 'lists.create',
defaultMessage: 'Create',
},
close: {
id: 'lightbox.close',
defaultMessage: 'Close',
},
});
const ListItem: React.FC<{
id: string;
title: string;
checked: boolean;
onChange: (id: string, checked: boolean) => void;
}> = ({ id, title, checked, onChange }) => {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(id, e.target.checked);
},
[id, onChange],
);
return (
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label className='lists__item'>
<div className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<span>{title}</span>
</div>
<CheckBox value={id} checked={checked} onChange={handleChange} />
</label>
);
};
const NewListItem: React.FC<{
onCreate: (list: ApiListJSON) => void;
}> = ({ onCreate }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [title, setTitle] = useState('');
const handleChange = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLInputElement>) => {
setTitle(value);
},
[setTitle],
);
const handleSubmit = useCallback(() => {
if (title.trim().length === 0) {
return;
}
void dispatch(createList({ title })).then((result) => {
if (isFulfilled(result)) {
onCreate(result.payload);
setTitle('');
}
return '';
});
}, [setTitle, dispatch, onCreate, title]);
return (
<form className='lists__item' onSubmit={handleSubmit}>
<label className='lists__item__title'>
<Icon id='list-ul' icon={ListAltIcon} />
<input
type='text'
value={title}
onChange={handleChange}
maxLength={30}
required
placeholder={intl.formatMessage(messages.newList)}
/>
</label>
<Button text={intl.formatMessage(messages.createList)} type='submit' />
</form>
);
};
const ListAdder: React.FC<{
accountId: string;
onClose: () => void;
}> = ({ accountId, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const lists = useAppSelector((state) => getOrderedLists(state));
const [listIds, setListIds] = useState<string[]>([]);
useEffect(() => {
dispatch(fetchLists());
apiGetAccountLists(accountId)
.then((data) => {
setListIds(data.map((l) => l.id));
return '';
})
.catch(() => {
// Nothing
});
}, [dispatch, setListIds, accountId]);
const handleToggle = useCallback(
(listId: string, checked: boolean) => {
if (checked) {
setListIds((currentListIds) => [listId, ...currentListIds]);
apiAddAccountToList(listId, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
});
} else {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== listId),
);
apiRemoveAccountFromList(listId, accountId).catch(() => {
setListIds((currentListIds) => [listId, ...currentListIds]);
});
}
},
[setListIds, accountId],
);
const handleCreate = useCallback(
(list: ApiListJSON) => {
setListIds((currentListIds) => [list.id, ...currentListIds]);
apiAddAccountToList(list.id, accountId).catch(() => {
setListIds((currentListIds) =>
currentListIds.filter((id) => id !== list.id),
);
});
},
[setListIds, accountId],
);
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
className='dialog-modal__header__close'
title={intl.formatMessage(messages.close)}
icon='times'
iconComponent={CloseIcon}
onClick={onClose}
/>
<span className='dialog-modal__header__title'>
<FormattedMessage
id='lists.add_to_lists'
defaultMessage='Add {name} to lists'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
</span>
</div>
<div className='dialog-modal__content'>
<div className='lists-scrollable'>
<NewListItem onCreate={handleCreate} />
{lists.map((list) => (
<ListItem
key={list.id}
id={list.id}
title={list.title}
checked={listIds.includes(list.id)}
onChange={handleToggle}
/>
))}
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default ListAdder;

View file

@ -1,84 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor/account, circle_editor/account
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { removeFromListEditor, addToListEditor } from '../../../actions/lists';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import { IconButton } from '../../../components/icon_button';
import { makeGetAccount } from '../../../selectors';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
class Account extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired,
onRemove: PropTypes.func.isRequired,
onAdd: PropTypes.func.isRequired,
added: PropTypes.bool,
};
static defaultProps = {
added: false,
};
render () {
const { account, intl, onRemove, onAdd, added } = this.props;
let button;
if (added) {
button = <IconButton icon='times' iconComponent={CloseIcon} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' iconComponent={AddIcon} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
}
return (
<div className='account'>
<div className='account__wrapper'>
<div className='account__display-name'>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</div>
<div className='account__relationship'>
{button}
</div>
</div>
</div>
);
}
}
export default connect(makeMapStateToProps, mapDispatchToProps)(injectIntl(Account));

View file

@ -1,78 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor/edit_antenna_form, circle_editor/edit_circle_form, bookmark_category_editor/edit_bookmark_category_form
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { changeListEditorTitle, submitListEditor } from '../../../actions/lists';
import { IconButton } from '../../../components/icon_button';
const messages = defineMessages({
title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({
onChange: value => dispatch(changeListEditorTitle(value)),
onSubmit: () => dispatch(submitListEditor(false)),
});
class ListForm extends PureComponent {
static propTypes = {
value: PropTypes.string.isRequired,
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleSubmit = e => {
e.preventDefault();
this.props.onSubmit();
};
handleClick = () => {
this.props.onSubmit();
};
render () {
const { value, disabled, intl } = this.props;
const title = intl.formatMessage(messages.title);
return (
<form className='column-inline-form' onSubmit={this.handleSubmit}>
<input
className='setting-text'
value={value}
onChange={this.handleChange}
/>
<IconButton
disabled={disabled}
icon='check'
iconComponent={CheckIcon}
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListForm));

View file

@ -1,85 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor/search, circle_editor/search
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
import { Icon } from 'mastodon/components/icon';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
class Search extends PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
};
handleChange = e => {
this.props.onChange(e.target.value);
};
handleKeyUp = e => {
if (e.keyCode === 13) {
this.props.onSubmit(this.props.value);
}
};
handleClear = () => {
this.props.onClear();
};
render () {
const { value, intl } = this.props;
const hasValue = value.length > 0;
return (
<div className='list-editor__search search'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
<input
className='search__input'
type='text'
value={value}
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
placeholder={intl.formatMessage(messages.search)}
/>
</label>
<div role='button' tabIndex={0} className='search__icon' onClick={this.handleClear}>
<Icon id='search' icon={SearchIcon} className={classNames({ active: !hasValue })} />
<Icon id='times-circle' icon={CancelIcon} aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -1,85 +0,0 @@
// Kmyblue tracking marker: copied antenna_editor, circle_editor, bookmark_category_statuses
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import spring from 'react-motion/lib/spring';
import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists';
import Motion from '../ui/util/optional_motion';
import Account from './components/account';
import EditListForm from './components/edit_list_form';
import Search from './components/search';
const mapStateToProps = state => ({
accountIds: state.getIn(['listEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: listId => dispatch(setupListEditor(listId)),
onClear: () => dispatch(clearListSuggestions()),
onReset: () => dispatch(resetListEditor()),
});
class ListEditor extends ImmutablePureComponent {
static propTypes = {
listId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize, listId } = this.props;
onInitialize(listId);
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<EditListForm />
<Search />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex={-1} className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) => (
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
</div>
)}
</Motion>
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(ListEditor));

View file

@ -3,21 +3,19 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchList, updateList } from 'mastodon/actions/lists';
import { fetchList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal';
import { connectListStream } from 'mastodon/actions/streaming';
import { expandListTimeline } from 'mastodon/actions/timelines';
@ -25,17 +23,10 @@ import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
});
const mapStateToProps = (state, props) => ({
list: state.getIn(['lists', props.params.id]),
hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
@ -117,13 +108,6 @@ class ListTimeline extends PureComponent {
this.props.dispatch(expandListTimeline(id, { maxId }));
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'LIST_EDITOR',
modalProps: { listId: this.props.params.id },
}));
};
handleDeleteClick = () => {
const { dispatch, columnId } = this.props;
const { id } = this.props.params;
@ -133,36 +117,15 @@ class ListTimeline extends PureComponent {
handleEditAntennaClick = (e) => {
const id = e.currentTarget.getAttribute('data-id');
this.props.history.push(`/antennasw/${id}/edit`);
};
handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, target.value, undefined));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined, undefined));
};
onNotifyToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, undefined, undefined, target.checked));
this.props.history.push(`/antennas/${id}/edit`);
};
render () {
const { hasUnread, columnId, multiColumn, list, intl } = this.props;
const { hasUnread, columnId, multiColumn, list } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined;
const isNotify = list ? list.get('notify') : undefined;
const antennas = list ? (list.get('antennas')?.toArray() || []) : [];
const antennas = list ? (list.get('antennas') ?? []) : [];
if (typeof list === 'undefined') {
return (
@ -193,54 +156,24 @@ class ListTimeline extends PureComponent {
>
<div className='column-settings'>
<section className='column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Link to={`/lists/${id}/edit`} className='text-btn column-header__setting-btn'>
<Icon id='pencil' icon={EditIcon} /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
</button>
</Link>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' icon={DeleteIcon} /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
</button>
</section>
<section>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} checked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide list or antenna account posts from home' />
</label>
</div>
</section>
<section className='similar-row'>
<div className='setting-toggle'>
<Toggle id={`list-${id}-notify`} checked={isNotify} onChange={this.onNotifyToggle} />
<label htmlFor={`list-${id}-notify`} className='setting-toggle__label'>
<FormattedMessage id='lists.notify' defaultMessage='Notify these posts' />
</label>
</div>
</section>
{replies_policy !== undefined && (
<section aria-labelledby={`list-${id}-replies-policy`}>
<h3 id={`list-${id}-replies-policy`}><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></h3>
<div className='column-settings__row'>
{ ['none', 'list', 'followed'].map(policy => (
<RadioButton name='order' key={policy} value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
))}
</div>
</section>
)}
{ antennas.length > 0 && (
<section aria-labelledby={`list-${id}-antenna`}>
<h3><FormattedMessage id='lists.antennas' defaultMessage='Related antennas:' /></h3>
<ul className='column-settings__row'>
{ antennas.map(antenna => (
<li key={antenna.get('id')} className='column-settings__row__antenna'>
<button type='button' className='text-btn column-header__setting-btn' data-id={antenna.get('id')} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' icon={EditIcon} /> {antenna.get('title')}{antenna.get('stl') && ' [STL]'}
<li key={antenna.id} className='column-settings__row__antenna'>
<button type='button' className='text-btn column-header__setting-btn' data-id={antenna.id} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' icon={EditIcon} /> {antenna.title}{antenna.stl && ' [STL]'}{antenna.ltl && ' [LTL]'}
</button>
</li>
))}
@ -269,4 +202,4 @@ class ListTimeline extends PureComponent {
}
export default withRouter(connect(mapStateToProps)(injectIntl(ListTimeline)));
export default withRouter(connect(mapStateToProps)(ListTimeline));

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