Merge branch 'kb_development' into kb_migration

This commit is contained in:
KMY 2023-08-20 08:35:42 +09:00
commit 80d50f1656
61 changed files with 2307 additions and 164 deletions

View file

@ -59,7 +59,7 @@ class AntennasController < ApplicationController
end
def resource_params
params.require(:antenna).permit(:title, :list, :available, :stl, :expires_in, :with_media_only, :ignore_reblog, :keywords_raw, :exclude_keywords_raw, :domains_raw, :exclude_domains_raw, :accounts_raw, :exclude_accounts_raw, :tags_raw, :exclude_tags_raw)
params.require(:antenna).permit(:title, :list, :available, :insert_feeds, :stl, :expires_in, :with_media_only, :ignore_reblog, :keywords_raw, :exclude_keywords_raw, :domains_raw, :exclude_domains_raw, :accounts_raw, :exclude_accounts_raw, :tags_raw, :exclude_tags_raw)
end
def thin_resource_params

View file

@ -1,19 +1,24 @@
# frozen_string_literal: true
class Api::V1::Antennas::AccountsController < Api::BaseController
# before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' } # , except: [:show]
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :set_antenna
# after_action :insert_pagination_headers, only: :show
after_action :insert_pagination_headers, only: :show
def show
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
def create
ApplicationRecord.transaction do
antenna_accounts.each do |account|
@antenna.antenna_accounts.create!(account: account, exclude: false)
@antenna.update!(any_accounts: false)
@antenna.update!(any_accounts: false) if @antenna.any_accounts
end
end
@ -32,6 +37,14 @@ class Api::V1::Antennas::AccountsController < Api::BaseController
@antenna = Antenna.where(account: current_account).find(params[:antenna_id])
end
def load_accounts
if unlimited?
@antenna.accounts.without_suspended.includes(:account_stat).all
else
@antenna.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
end
end
def antenna_accounts
Account.find(account_ids)
end

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
class Api::V1::Antennas::DomainsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :set_antenna
def show
@domains = load_domains
@exclude_domains = load_exclude_domains
render json: { domains: @domains, exclude_domains: @exclude_domains }
end
def create
ApplicationRecord.transaction do
domains.each do |domain|
@antenna.antenna_domains.create!(name: domain, exclude: false)
@antenna.update!(any_domains: false) if @antenna.any_domains
end
end
render_empty
end
def destroy
AntennaDomain.where(antenna: @antenna, name: domains).destroy_all
@antenna.update!(any_domains: true) unless @antenna.antenna_domains.where(exclude: false).any?
render_empty
end
private
def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:antenna_id])
end
def load_domains
@antenna.antenna_domains.pluck(:name)
end
def load_exclude_domains
@antenna.exclude_domains || []
end
def domains
Array(resource_params[:domains])
end
def resource_params
params.permit(domains: [])
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
class Api::V1::Antennas::KeywordsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:show]
before_action :require_user!
before_action :set_antenna
def show
@keywords = load_keywords
@exclude_keywords = load_exclude_keywords
render json: { keywords: @keywords, exclude_keywords: @exclude_keywords }
end
def create
new_keywords = @antenna.keywords || []
keywords.each do |keyword|
raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_keyword') if new_keywords.include?(keyword)
new_keywords << keyword
end
@antenna.update!(keywords: new_keywords, any_keywords: new_keywords.empty?)
render_empty
end
def destroy
new_keywords = @antenna.keywords || []
new_keywords -= keywords
@antenna.update!(keywords: new_keywords, any_keywords: new_keywords.empty?)
render_empty
end
private
def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:antenna_id])
end
def load_keywords
@antenna.keywords || []
end
def load_exclude_keywords
@antenna.exclude_keywords || []
end
def keywords
Array(resource_params[:keywords])
end
def resource_params
params.permit(keywords: [])
end
end

View file

@ -5,7 +5,7 @@ class Api::V1::AntennasController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:lists' }, except: [:index, :show]
before_action :require_user!
before_action :set_antenna, except: [:index]
before_action :set_antenna, except: [:index, :create]
rescue_from ArgumentError do |e|
render json: { error: e.to_s }, status: 422
@ -20,9 +20,28 @@ class Api::V1::AntennasController < Api::BaseController
render json: @antenna, serializer: REST::AntennaSerializer
end
def create
@antenna = Antenna.create!(antenna_params.merge(account: current_account, list_id: 0))
render json: @antenna, serializer: REST::AntennaSerializer
end
def update
@antenna.update!(antenna_params)
render json: @antenna, serializer: REST::AntennaSerializer
end
def destroy
@antenna.destroy!
render_empty
end
private
def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:id])
end
def antenna_params
params.permit(:title, :list_id, :insert_feeds, :stl, :with_media_only, :ignore_reblog)
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
class Api::V1::Timelines::AntennaController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:lists' }
before_action :require_user!
before_action :set_antenna
before_action :set_statuses
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user.account_id)
end
private
def set_antenna
@antenna = Antenna.where(account: current_account).find(params[:id])
end
def set_statuses
@statuses = cached_list_statuses
end
def cached_list_statuses
cache_collection list_statuses, Status
end
def list_statuses
list_feed.get(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id],
params[:min_id]
)
end
def list_feed
AntennaFeed.new(@antenna)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def next_path
api_v1_timelines_antenna_url params[:id], pagination_params(max_id: pagination_max_id)
end
def prev_path
api_v1_timelines_antenna_url params[:id], pagination_params(min_id: pagination_since_id)
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
end

View file

@ -1,29 +1,106 @@
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';
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_ADD_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST';
export const ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS';
export const ANTENNA_EDITOR_ADD_ACCOUNT_FAIL = 'ANTENNA_EDITOR_ADD_ACCOUNT_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_REMOVE_ACCOUNT_REQUEST = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS';
export const ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL = 'ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL';
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_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_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_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_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_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_RESET = 'ANTENNA_ADDER_RESET';
export const ANTENNA_ADDER_SETUP = 'ANTENNA_ADDER_SETUP';
export const fetchAntenna = id => (dispatch, getState) => {
if (getState().getIn(['antennas', id])) {
return;
}
dispatch(fetchAntennaRequest(id));
api(getState).get(`/api/v1/antennas/${id}`)
.then(({ data }) => dispatch(fetchAntennaSuccess(data)))
.catch(err => dispatch(fetchAntennaFail(id, err)));
};
export const fetchAntennaRequest = id => ({
type: ANTENNA_FETCH_REQUEST,
id,
});
export const fetchAntennaSuccess = antenna => ({
type: ANTENNA_FETCH_SUCCESS,
antenna,
});
export const fetchAntennaFail = (id, error) => ({
type: ANTENNA_FETCH_FAIL,
id,
error,
});
export const fetchAntennas = () => (dispatch, getState) => {
dispatch(fetchAntennasRequest());
@ -47,6 +124,113 @@ 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 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, with_media_only, ignore_reblog, insert_feeds) => (dispatch, getState) => {
dispatch(updateAntennaRequest(id));
api(getState).put(`/api/v1/antennas/${id}`, { title, list_id, stl, 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));
api(getState).delete(`/api/v1/antennas/${id}`)
.then(() => dispatch(deleteAntennaSuccess(id)))
.catch(err => dispatch(deleteAntennaFail(id, err)));
};
export const deleteAntennaRequest = id => ({
type: ANTENNA_DELETE_REQUEST,
id,
});
export const deleteAntennaSuccess = id => ({
type: ANTENNA_DELETE_SUCCESS,
id,
});
export const deleteAntennaFail = (id, error) => ({
type: ANTENNA_DELETE_FAIL,
id,
error,
});
export const fetchAntennaAccounts = antennaId => (dispatch, getState) => {
dispatch(fetchAntennaAccountsRequest(antennaId));
@ -74,95 +258,251 @@ export const fetchAntennaAccountsFail = (id, error) => ({
error,
});
export const addAccountToAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(addAccountToAntennaRequest(antennaId, accountId));
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(addAccountToAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(addAccountToAntennaFail(antennaId, accountId, err)));
.then(() => dispatch(addToAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(addToAntennaFail(antennaId, accountId, err)));
};
export const addAccountToAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_REQUEST,
export const addToAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_REQUEST,
antennaId,
accountId,
});
export const addAccountToAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS,
export const addToAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_ADD_SUCCESS,
antennaId,
accountId,
});
export const addAccountToAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_ADD_ACCOUNT_FAIL,
export const addToAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_ADD_FAIL,
antennaId,
accountId,
error,
});
export const removeAccountFromAntennaEditor = accountId => (dispatch, getState) => {
dispatch(removeAccountFromAntenna(getState().getIn(['antennaEditor', 'antennaId']), accountId));
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 removeAccountFromAntenna = (antennaId, accountId) => (dispatch, getState) => {
dispatch(removeAccountFromAntennaRequest(antennaId, accountId));
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 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 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(removeAccountFromAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(removeAccountFromAntennaFail(antennaId, accountId, err)));
.then(() => dispatch(removeFromAntennaSuccess(antennaId, accountId)))
.catch(err => dispatch(removeFromAntennaFail(antennaId, accountId, err)));
};
export const removeAccountFromAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_REQUEST,
export const removeFromAntennaRequest = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_REQUEST,
antennaId,
accountId,
});
export const removeAccountFromAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS,
export const removeFromAntennaSuccess = (antennaId, accountId) => ({
type: ANTENNA_EDITOR_REMOVE_SUCCESS,
antennaId,
accountId,
});
export const removeAccountFromAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_REMOVE_ACCOUNT_FAIL,
export const removeFromAntennaFail = (antennaId, accountId, error) => ({
type: ANTENNA_EDITOR_REMOVE_FAIL,
antennaId,
accountId,
error,
});
export const addToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addAccountToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
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 removeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeAccountFromAntenna(antennaId, getState().getIn(['antennaAdder', '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 removeDomainFromAntennaRequest = (antennaId, domain) => ({
type: ANTENNA_EDITOR_REMOVE_DOMAIN_REQUEST,
antennaId,
domain,
});
export const fetchAccountAntennasSuccess = (id, antennas) => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
id,
antennas,
export const removeDomainFromAntennaSuccess = (antennaId, domain) => ({
type: ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS,
antennaId,
domain,
});
export const fetchAccountAntennasFail = (id, err) => ({
type: ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
id,
err,
export const removeDomainFromAntennaFail = (antennaId, domain, error) => ({
type: ANTENNA_EDITOR_REMOVE_DOMAIN_FAIL,
antennaId,
domain,
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 resetAntennaAdder = () => ({
@ -178,3 +518,36 @@ export const setupAntennaAdder = accountId => (dispatch, getState) => {
dispatch(fetchAccountAntennas(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 addToAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(addToAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};
export const removeFromAntennaAdder = antennaId => (dispatch, getState) => {
dispatch(removeFromAntenna(antennaId, getState().getIn(['antennaAdder', 'accountId'])));
};

View file

@ -238,7 +238,6 @@ export const fetchListSuggestions = q => (dispatch, getState) => {
const params = {
q,
resolve: false,
limit: 4,
following: true,
};

View file

@ -22,6 +22,7 @@ import {
fillPublicTimelineGaps,
fillCommunityTimelineGaps,
fillListTimelineGaps,
fillAntennaTimelineGaps,
} from './timelines';
/**
@ -185,3 +186,10 @@ export const connectDirectStream = () =>
*/
export const connectListStream = listId =>
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, { fillGaps: () => fillListTimelineGaps(listId) });
/**
* @param {string} antennaId
* @returns {function(): void}
*/
export const connectAntennaStream = antennaId =>
connectTimelineStream(`antenna:${antennaId}`, 'antenna', { antenna: antennaId }, { fillGaps: () => fillAntennaTimelineGaps(antennaId) });

View file

@ -149,6 +149,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandAntennaTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
@ -163,6 +164,7 @@ export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }, done);
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
export const fillAntennaTimelineGaps = (id, done = noOp) => fillTimelineGaps(`antenna:${id}`, `/api/v1/timelines/antenna/${id}`, {}, done);
export function expandTimelineRequest(timeline, isLoadingMore) {
return {

View file

@ -334,7 +334,7 @@ class ScrollableList extends PureComponent {
intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
>
{cloneElement(child, {
{cloneElement(child, child.type.name === 'ColumnLink' ? {} : {
getScrollPosition: this.getScrollPosition,
updateScrollBottom: this.updateScrollBottom,
cachedMediaWidth: this.state.cachedMediaWidth,

View file

@ -558,7 +558,7 @@ class Status extends ImmutablePureComponent {
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
sensitive={status.get('sensitive')}
sensitive={status.get('sensitive') && !status.get('spoiler_text')}
/>
);
isCardMediaWithSensitive = status.get('spoiler_text').length > 0;

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

@ -6,14 +6,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { Icon } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { removeFromAntennaAdder, addToAntennaAdder } from '../../../actions/antennas';
import { IconButton } from '../../../components/icon_button';
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' },
remove: { id: 'antennas.account.remove', defaultMessage: 'Remove from antenna' },
add: { id: 'antennas.account.add', defaultMessage: 'Add to antenna' },
});
const MapStateToProps = (state, { antennaId, added }) => ({

View file

@ -8,8 +8,9 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setupAntennaAdder, resetAntennaAdder } from '../../actions/antennas';
import NewAntennaForm from '../antennas/components/new_antenna_form';
import Account from '../list_adder/components/account';
import Account from './components/account';
import Antenna from './components/antenna';
// hack
@ -60,8 +61,11 @@ class AntennaAdder extends ImmutablePureComponent {
<Account accountId={accountId} />
</div>
<NewAntennaForm />
<div className='list-adder__lists'>
{antennaIds.map(AntennaId => <Antenna key={AntennaId} antennaId={AntennaId} />)}
{antennaIds.map(antennaId => <Antenna key={antennaId} antennaId={antennaId} />)}
</div>
</div>
);

View file

@ -0,0 +1,79 @@
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 { removeFromAntennaEditor, addToAntennaEditor } 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)),
});
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' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
} else {
button = <IconButton icon='plus' 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

@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
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'
title={title}
onClick={this.handleClick}
/>
</form>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AntennaForm));

View file

@ -0,0 +1,81 @@
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 { 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' className={classNames({ active: !hasValue })} />
<Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
</div>
</div>
);
}
}
export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(Search));

View file

@ -0,0 +1,83 @@
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, 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 => ({
onInitialize: antennaId => dispatch(setupAntennaEditor(antennaId)),
onClear: () => dispatch(clearAntennaSuggestions()),
onReset: () => dispatch(resetAntennaEditor()),
});
class AntennaEditor extends ImmutablePureComponent {
static propTypes = {
antennaId: 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, antennaId } = this.props;
onInitialize(antennaId);
}
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'>
<EditAntennaForm />
<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(AntennaEditor));

View file

@ -0,0 +1,96 @@
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 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,
value: PropTypes.string.isRequired,
onRemove: PropTypes.func.isRequired,
};
handleRemove = () => {
this.props.onRemove(this.props.value);
};
render () {
const { icon, value } = this.props;
return (
<div className='setting-text-list-item'>
<Icon id={icon} />
<span className='label'>{value}</span>
<IconButton icon='trash' 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,
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();
};
render () {
const { icon, value, values, disabled, label, title } = this.props;
return (
<div className='setting-text-list'>
{values.map((val) => (
<TextListItem key={val} value={val} icon={icon} onRemove={this.props.onRemove} />
))}
<form className='add-text-form'>
<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

@ -0,0 +1,374 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } 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 { fetchAntenna, deleteAntenna, updateAntenna, addDomainToAntenna, removeDomainFromAntenna, fetchAntennaDomains, fetchAntennaKeywords, removeKeywordFromAntenna, addKeywordToAntenna } 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 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' },
addDomainTitle: { id: 'antennas.add_domain', defaultMessage: 'Add domain' },
addKeywordTitle: { id: 'antennas.add_keyword', defaultMessage: 'Add keyword' },
});
const mapStateToProps = (state, props) => ({
antenna: state.getIn(['antennas', props.params.id]),
lists: state.get('lists'),
domains: state.getIn(['antennas', props.params.id, 'domains']) || ImmutableList(),
keywords: state.getIn(['antennas', props.params.id, 'keywords']) || ImmutableList(),
});
class AntennaSetting extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
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.list,
domains: ImmutablePropTypes.list,
keywords: ImmutablePropTypes.list,
intl: PropTypes.object.isRequired,
};
state = {
domainName: '',
keywordName: '',
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('ANTENNA', { id: this.props.params.id }));
this.context.router.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(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(fetchLists());
}
}
setRef = c => {
this.column = c;
};
handleEditClick = () => {
this.props.dispatch(openModal({
modalType: 'ANTENNA_EDITOR',
modalProps: { antennaId: this.props.params.id },
}));
};
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.context.router.history.push('/antennasw');
}
},
},
}));
};
handleTimelineClick = () => {
this.context.router.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));
};
onMediaOnlyToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, 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, target.checked, undefined));
};
onNoInsertFeedsToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateAntenna(id, undefined, false, 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));
};
noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
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));
render () {
const { columnId, multiColumn, antenna, lists, domains, keywords, 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 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) {
columnSettings = (
<>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-mediaonly`} defaultChecked={isMediaOnly} onChange={this.onMediaOnlyToggle} />
<label htmlFor={`antenna-${id}-mediaonly`} className='setting-toggle__label'>
<FormattedMessage id='antennas.media_only' defaultMessage='Media only' />
</label>
</div>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-ignorereblog`} defaultChecked={isIgnoreReblog} onChange={this.onIgnoreReblogToggle} />
<label htmlFor={`antenna-${id}-ignorereblog`} className='setting-toggle__label'>
<FormattedMessage id='antennas.ignore_reblog' defaultMessage='Exclude boosts' />
</label>
</div>
</>
)
}
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>
);
}
const listOptions = lists.toArray().map((list) => {
return { value: list[1].get('id'), label: list[1].get('title') }
});
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader
icon='wifi'
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditAntennaClick}>
<Icon id='pencil' /> <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' /> <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' /> <FormattedMessage id='antennas.go_timeline' defaultMessage='Go to antenna timeline' />
</button>
</div>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-stl`} defaultChecked={isStl} onChange={this.onStlToggle} />
<label htmlFor={`antenna-${id}-stl`} className='setting-toggle__label'>
<FormattedMessage id='antennas.stl' defaultMessage='STL mode' />
</label>
</div>
<div className='setting-toggle'>
<Toggle id={`antenna-${id}-noinsertfeeds`} defaultChecked={isInsertFeeds} onChange={this.onNoInsertFeedsToggle} />
<label htmlFor={`antenna-${id}-noinsertfeeds`} className='setting-toggle__label'>
<FormattedMessage id='antennas.insert_feeds' defaultMessage='Insert to feeds' />
</label>
</div>
{columnSettings}
</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-select__container'
classNamePrefix='column-select'
name='lists'
placeholder={this.props.intl.formatMessage(messages.placeholder)}
defaultOptions
/>
</NonceProvider>
</>
)}
{!isStl && (
<>
<h3><FormattedMessage id='antennas.accounts' defaultMessage='{count} accounts' values={{ count: antenna.get('accounts_count') }} /></h3>
<Button text={intl.formatMessage(messages.editAccounts)} onClick={this.handleEditClick} />
<h3><FormattedMessage id='antennas.domains' defaultMessage='{count} domains' values={{ count: antenna.get('domains_count') }} /></h3>
<TextList
onChange={this.onDomainNameChanged}
onAdd={this.onDomainAdd}
onRemove={this.onDomainRemove}
value={this.state.domainName}
values={domains.get('domains') || ImmutableList()}
icon='sitemap'
label={intl.formatMessage(messages.addDomainLabel)}
title={intl.formatMessage(messages.addDomainTitle)}
/>
<h3><FormattedMessage id='antennas.tags' defaultMessage='{count} tags' values={{ count: antenna.get('tags_count') }} /></h3>
<h3><FormattedMessage id='antennas.keywords' defaultMessage='{count} keywords' values={{ count: antenna.get('keywords_count') }} /></h3>
<TextList
onChange={this.onKeywordNameChanged}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
value={this.state.keywordName}
values={keywords.get('keywords') || ImmutableList()}
icon='paragraph'
label={intl.formatMessage(messages.addKeywordLabel)}
title={intl.formatMessage(messages.addKeywordTitle)}
/>
</>
)}
</div>
<Helmet>
<title>{title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(AntennaSetting));

View file

@ -0,0 +1,200 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { fetchAntenna, deleteAntenna } from 'mastodon/actions/antennas';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { openModal } from 'mastodon/actions/modal';
import { connectAntennaStream } from 'mastodon/actions/streaming';
import { expandAntennaTimeline } from 'mastodon/actions/timelines';
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 StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
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' },
});
const mapStateToProps = (state, props) => ({
antenna: state.getIn(['antennas', props.params.id]),
hasUnread: state.getIn(['timelines', `antenna:${props.params.id}`, 'unread']) > 0,
});
class AntennaTimeline extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
antenna: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
intl: PropTypes.object.isRequired,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('ANTENNA_TIMELINE', { id: this.props.params.id }));
this.context.router.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(expandAntennaTimeline(id));
this.disconnect = dispatch(connectAntennaStream(id));
}
UNSAFE_componentWillReceiveProps (nextProps) {
const { dispatch } = this.props;
const { id } = nextProps.params;
if (id !== this.props.params.id) {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
dispatch(fetchAntenna(id));
dispatch(expandAntennaTimeline(id));
this.disconnect = dispatch(connectAntennaStream(id));
}
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
};
handleLoadMore = maxId => {
const { id } = this.props.params;
this.props.dispatch(expandAntennaTimeline(id, { maxId }));
};
handleEditClick = () => {
this.context.router.history.push(`/antennasw/${this.props.params.id}`);
};
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.context.router.history.push('/antennasw');
}
},
},
}));
};
render () {
const { hasUnread, columnId, multiColumn, antenna } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
const title = antenna ? antenna.get('title') : id;
if (typeof antenna === 'undefined') {
return (
<Column>
<div className='scrollable'>
<LoadingIndicator />
</div>
</Column>
);
} else if (antenna === false) {
return (
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
);
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader
icon='wifi'
active={hasUnread}
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<div className='column-settings__row column-header__links'>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleEditClick}>
<Icon id='pencil' /> <FormattedMessage id='antennas.edit' defaultMessage='Edit antenna' />
</button>
<button type='button' className='text-btn column-header__setting-btn' tabIndex={0} onClick={this.handleDeleteClick}>
<Icon id='trash' /> <FormattedMessage id='antennas.delete' defaultMessage='Delete antenna' />
</button>
</div>
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`antenna_timeline-${columnId}`}
timelineId={`antenna:${id}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.antenna' defaultMessage='There is nothing in this antenna yet. When members of this list post new statuses, they will appear here.' />}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(AntennaTimeline));

View file

@ -0,0 +1,80 @@
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,101 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchAntennas } from 'mastodon/actions/antennas';
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 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' },
});
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' 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' text={antenna.get('title')}>
<span className='antenna-list-detail'>
<span className='group'><Icon id='users' />{antenna.get('accounts_count')}</span>
<span className='group'><Icon id='sitemap' />{antenna.get('domains_count')}</span>
<span className='group'><Icon id='hashtag' />{antenna.get('tags_count')}</span>
<span className='group'><Icon id='paragraph' />{antenna.get('keywords_count')}</span>
</span>
</ColumnLink>
))}
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Antennas));

View file

@ -146,7 +146,7 @@ class ListTimeline extends PureComponent {
handleEditAntennaClick = (e) => {
const id = e.currentTarget.getAttribute('data-id');
window.open(`/antennas/${id}/edit`, '_blank');
this.context.router.history.push(`/antennasw/${id}/edit`);
}
handleRepliesPolicyChange = ({ target }) => {

View file

@ -232,7 +232,7 @@ class DetailedStatus extends ImmutablePureComponent {
);
}
} else if (status.get('card')) {
media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
media = <Card sensitive={status.get('sensitive') && !status.get('spoiler_text')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
isCardMediaWithSensitive = status.get('spoiler_text').length > 0;
}

View file

@ -5,10 +5,11 @@ import { NavLink } from 'react-router-dom';
import { Icon } from 'mastodon/components/icon';
const ColumnLink = ({ icon, text, to, href, method, badge, transparent, ...other }) => {
const ColumnLink = ({ icon, text, to, href, method, badge, transparent, children, ...other }) => {
const className = classNames('column-link', { 'column-link--transparent': transparent });
const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
const iconElement = typeof icon === 'string' ? <Icon id={icon} fixedWidth className='column-link__icon' /> : icon;
const childElement = typeof children !== 'undefined' ? <p>{children}</p> : null;
if (href) {
return (
@ -24,6 +25,7 @@ const ColumnLink = ({ icon, text, to, href, method, badge, transparent, ...other
{iconElement}
<span>{text}</span>
{badgeElement}
{childElement}
</NavLink>
);
}
@ -37,6 +39,7 @@ ColumnLink.propTypes = {
method: PropTypes.string,
badge: PropTypes.node,
transparent: PropTypes.bool,
children: PropTypes.any,
};
export default ColumnLink;

View file

@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchAntennas } from 'mastodon/actions/antennas';
import { fetchLists } from 'mastodon/actions/lists';
import ColumnLink from './column_link';
@ -19,8 +20,17 @@ const getOrderedLists = createSelector([state => state.get('lists')], lists => {
return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(8);
});
const getOrderedAntennas = createSelector([state => state.get('antennas')], antennas => {
if (!antennas) {
return antennas;
}
return antennas.toList().filter(item => !!item && !item.get('insert_feeds')).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(8);
});
const mapStateToProps = state => ({
lists: getOrderedLists(state),
antennas: getOrderedAntennas(state),
});
class ListPanel extends ImmutablePureComponent {
@ -28,17 +38,20 @@ class ListPanel extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
lists: ImmutablePropTypes.list,
antennas: ImmutablePropTypes.list,
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchLists());
dispatch(fetchAntennas());
}
render () {
const { lists } = this.props;
const { lists, antennas } = this.props;
const size = (lists ? lists.size : 0) + (antennas ? antennas.size : 0);
if (!lists || lists.isEmpty()) {
if (size === 0) {
return null;
}
@ -46,9 +59,12 @@ class ListPanel extends ImmutablePureComponent {
<div className='list-panel'>
<hr />
{lists.map(list => (
{lists && lists.map(list => (
<ColumnLink icon='list-ul' key={list.get('id')} strict text={list.get('title')} to={`/lists/${list.get('id')}`} transparent />
))}
{antennas && antennas.take(8 - (lists ? lists.size : 0)).map(antenna => (
<ColumnLink icon='wifi' key={antenna.get('id')} strict text={antenna.get('title')} to={`/antennast/${antenna.get('id')}`} transparent />
))}
</div>
);
}

View file

@ -11,6 +11,7 @@ import {
EmbedModal,
ListEditor,
ListAdder,
AntennaEditor,
AntennaAdder,
CompareHistoryModal,
FilterModal,
@ -46,6 +47,7 @@ export const MODAL_COMPONENTS = {
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'ANTENNA_EDITOR': AntennaEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER': ListAdder,
'ANTENNA_ADDER': AntennaAdder,

View file

@ -27,6 +27,7 @@ const messages = defineMessages({
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
antennas: { id: 'navigation_bar.antennas', defaultMessage: 'Antennas' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
@ -49,6 +50,10 @@ class NavigationPanel extends Component {
return (match || location.pathname.startsWith('/public')) && !location.pathname.endsWith('/fixed');
};
isAntennasActive = (match, location) => {
return (match || location.pathname.startsWith('/antennast'));
};
render () {
const { intl } = this.props;
const { signedIn, disabledAccountId } = this.context.identity;
@ -92,7 +97,6 @@ class NavigationPanel extends Component {
{signedIn && (
<>
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<ListPanel />
<hr />
</>
@ -100,6 +104,8 @@ class NavigationPanel extends Component {
{signedIn && (
<>
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<ColumnLink transparent to='/antennasw' icon='wifi' text={intl.formatMessage(messages.antennas)} isActive={this.isAntennasActive} />
<FollowRequestsColumnLink />
<ColumnLink transparent to='/conversations' icon='at' text={intl.formatMessage(messages.direct)} />
</>

View file

@ -48,6 +48,7 @@ import {
StatusReferences,
DirectTimeline,
HashtagTimeline,
AntennaTimeline,
Notifications,
FollowRequests,
FavouritedStatuses,
@ -60,6 +61,8 @@ import {
Mutes,
PinnedStatuses,
Lists,
Antennas,
AntennaSetting,
Directory,
Explore,
ReactionDeck,
@ -207,6 +210,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/antennasw/:id' component={AntennaSetting} content={children} />
<WrappedRoute path='/antennast/:id' component={AntennaTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/emoji_reactions' component={EmojiReactedStatuses} content={children} />
@ -248,6 +253,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/antennasw' component={Antennas} content={children} />
<Route component={BundleColumnError} />
</WrappedSwitch>

View file

@ -34,6 +34,10 @@ export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
}
export function AntennaTimeline () {
return import(/* webpackChunkName: "features/antenna_timeline" */'../../antenna_timeline');
}
export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
}
@ -42,6 +46,10 @@ export function Lists () {
return import(/* webpackChunkName: "features/lists" */'../../lists');
}
export function Antennas () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
}
@ -158,6 +166,14 @@ export function AntennaAdder () {
return import(/*webpackChunkName: "features/antenna_adder" */'../../antenna_adder');
}
export function AntennaEditor () {
return import(/*webpackChunkName: "features/antenna_editor" */'../../antenna_editor');
}
export function AntennaSetting () {
return import(/*webpackChunkName: "features/antenna_setting" */'../../antenna_setting');
}
export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
}

View file

@ -88,6 +88,28 @@
"alert.unexpected.message": "不明なエラーが発生しました。",
"alert.unexpected.title": "エラー!",
"announcement.announcement": "お知らせ",
"antennas.accounts": "{count} のアカウント",
"antennas.delete": "アンテナを削除",
"antennas.domains": "{count} のドメイン",
"antennas.edit": "アンテナを編集",
"antennas.edit_static": "旧編集画面に移動",
"antennas.edit_accounts": "アカウントを編集",
"antennas.go_timeline": "タイムラインを見る",
"antennas.ignore_reblog": "ブーストを除外",
"antennas.in_stl_mode": "STLモードが有効になっています",
"antennas.insert_feeds": "リストまたはホームに挿入",
"antennas.keywords": "{count} のキーワード",
"antennas.media_only": "メディアのみ",
"antennas.new.create": "アンテナを作成",
"antennas.new.title_placeholder": "新規アンテナ名",
"antennas.not_related_list": "このアンテナはどのリストにも関連付けられていません。",
"antennas.related_list": "このアンテナは {listTitle} に関連付けられています。",
"antennas.search": "すべてのユーザーから検索",
"antennas.select.no_options_message": "リストがありません",
"antennas.select.placeholder": "リストを選択",
"antennas.subheading": "あなたのアンテナ",
"antennas.stl": "STLモード",
"antennas.tags": "{count} のタグ",
"attachments_list.unprocessed": "(未処理)",
"audio.hide": "音声を閉じる",
"autosuggest_hashtag.per_week": "{count} 回 / 週",
@ -110,6 +132,7 @@
"closed_registrations_modal.preamble": "Mastodonは分散型なのでどのサーバーでアカウントを作成してもこのサーバーのユーザーを誰でもフォローして交流することができます。また自分でホスティングすることもできます",
"closed_registrations_modal.title": "Mastodonでアカウントを作成",
"column.about": "概要",
"column.antennas": "アンテナ",
"column.blocks": "ブロックしたユーザー",
"column.bookmarks": "ブックマーク",
"column.community": "ローカルタイムライン",
@ -179,6 +202,8 @@
"confirmations.delete.message": "本当に削除しますか?",
"confirmations.delete_list.confirm": "削除",
"confirmations.delete_list.message": "本当にこのリストを完全に削除しますか?",
"confirmations.delete_antenna.confirm": "削除",
"confirmations.delete_antenna.message": "本当にこのアンテナを完全に削除しますか?",
"confirmations.discard_edit_media.confirm": "破棄",
"confirmations.discard_edit_media.message": "メディアの説明またはプレビューに保存されていない変更があります。それでも破棄しますか?",
"confirmations.domain_block.confirm": "ドメイン全体をブロック",
@ -234,6 +259,8 @@
"empty_column.account_suspended": "アカウントは停止されています",
"empty_column.account_timeline": "投稿がありません!",
"empty_column.account_unavailable": "プロフィールは利用できません",
"empty_column.antenna": "このアンテナはまだ何も拾っていません。このアンテナの設定にマッチした投稿が検出されるとここに表示されます。",
"empty_column.antennas": "まだアンテナがありません。アンテナを作るとここに表示されます。",
"empty_column.blocks": "まだ誰もブロックしていません。",
"empty_column.bookmarked_statuses": "まだ何もブックマーク登録していません。ブックマーク登録するとここに表示されます。",
"empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",

View file

@ -6,8 +6,8 @@ import {
ANTENNA_ADDER_ANTENNAS_FETCH_REQUEST,
ANTENNA_ADDER_ANTENNAS_FETCH_SUCCESS,
ANTENNA_ADDER_ANTENNAS_FETCH_FAIL,
ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS,
ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS,
ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_SUCCESS,
} from '../actions/antennas';
const initialState = ImmutableMap({
@ -38,9 +38,9 @@ export default function antennaAdderReducer(state = initialState, action) {
map.set('loaded', true);
map.set('items', ImmutableList(action.antennas.map(item => item.id)));
}));
case ANTENNA_EDITOR_ADD_ACCOUNT_SUCCESS:
case ANTENNA_EDITOR_ADD_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.unshift(action.antennaId));
case ANTENNA_EDITOR_REMOVE_ACCOUNT_SUCCESS:
case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['antennas', 'items'], antenna => antenna.filterNot(item => item === action.antennaId));
default:
return state;

View file

@ -1,9 +1,23 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
ANTENNA_CREATE_REQUEST,
ANTENNA_CREATE_FAIL,
ANTENNA_CREATE_SUCCESS,
ANTENNA_UPDATE_REQUEST,
ANTENNA_UPDATE_FAIL,
ANTENNA_UPDATE_SUCCESS,
ANTENNA_EDITOR_RESET,
ANTENNA_EDITOR_SETUP,
ANTENNA_EDITOR_TITLE_CHANGE,
ANTENNA_ACCOUNTS_FETCH_REQUEST,
ANTENNA_ACCOUNTS_FETCH_SUCCESS,
ANTENNA_ACCOUNTS_FETCH_FAIL,
ANTENNA_EDITOR_SUGGESTIONS_READY,
ANTENNA_EDITOR_SUGGESTIONS_CLEAR,
ANTENNA_EDITOR_SUGGESTIONS_CHANGE,
ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_SUCCESS,
} from '../actions/antennas';
const initialState = ImmutableMap({
@ -11,16 +25,51 @@ const initialState = ImmutableMap({
isSubmitting: false,
isChanged: false,
title: '',
accountsCount: 0,
accounts: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
});
export default function antennaEditorReducer(state = initialState, action) {
switch(action.type) {
case ANTENNA_EDITOR_RESET:
return initialState;
case ANTENNA_EDITOR_SETUP:
return state.withMutations(map => {
map.set('antennaId', action.antenna.get('id'));
map.set('title', action.antenna.get('title'));
map.set('accountsCount', action.antenna.get('accounts_count'));
map.set('isSubmitting', false);
});
case ANTENNA_EDITOR_TITLE_CHANGE:
return state.withMutations(map => {
map.set('title', action.value);
map.set('isChanged', true);
});
case ANTENNA_CREATE_REQUEST:
case ANTENNA_UPDATE_REQUEST:
return state.withMutations(map => {
map.set('isSubmitting', true);
map.set('isChanged', false);
});
case ANTENNA_CREATE_FAIL:
case ANTENNA_UPDATE_FAIL:
return state.set('isSubmitting', false);
case ANTENNA_CREATE_SUCCESS:
case ANTENNA_UPDATE_SUCCESS:
return state.withMutations(map => {
map.set('isSubmitting', false);
map.set('antennaId', action.antenna.id);
});
case ANTENNA_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['accounts', 'isLoading'], true);
case ANTENNA_ACCOUNTS_FETCH_FAIL:
@ -31,6 +80,19 @@ export default function antennaEditorReducer(state = initialState, action) {
map.set('loaded', true);
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
}));
case ANTENNA_EDITOR_SUGGESTIONS_CHANGE:
return state.setIn(['suggestions', 'value'], action.value);
case ANTENNA_EDITOR_SUGGESTIONS_READY:
return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
case ANTENNA_EDITOR_SUGGESTIONS_CLEAR:
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
map.set('items', ImmutableList());
map.set('value', '');
}));
case ANTENNA_EDITOR_ADD_SUCCESS:
return state.updateIn(['accounts', 'items'], antenna => antenna.unshift(action.accountId));
case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.updateIn(['accounts', 'items'], antenna => antenna.filterNot(item => item === action.accountId));
default:
return state;
}

View file

@ -1,12 +1,36 @@
import { Map as ImmutableMap, fromJS } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import {
ANTENNA_FETCH_SUCCESS,
ANTENNA_FETCH_FAIL,
ANTENNAS_FETCH_SUCCESS,
ANTENNA_CREATE_SUCCESS,
ANTENNA_UPDATE_SUCCESS,
ANTENNA_DELETE_SUCCESS,
ANTENNA_EDITOR_ADD_SUCCESS,
ANTENNA_EDITOR_REMOVE_SUCCESS,
ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS,
ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS,
ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS,
ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS,
ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS,
ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS,
} from '../actions/antennas';
const initialState = ImmutableMap();
const normalizeAntenna = (state, antenna) => state.set(antenna.id, fromJS(antenna));
const normalizeAntenna = (state, antenna) => {
const old = state.get(antenna.id);
let s = state.set(antenna.id, fromJS(antenna));
if (old) {
s = s.setIn([antenna.id, 'domains'], old.get('domains'));
s = s.setIn([antenna.id, 'keywords'], old.get('keywords'));
s = s.setIn([antenna.id, 'accounts_count'], old.get('accounts_count'));
s = s.setIn([antenna.id, 'domains_count'], old.get('domains_count'));
s = s.setIn([antenna.id, 'keywords_count'], old.get('keywords_count'));
}
return s;
};
const normalizeAntennas = (state, antennas) => {
antennas.forEach(antenna => {
@ -18,8 +42,31 @@ const normalizeAntennas = (state, antennas) => {
export default function antennas(state = initialState, action) {
switch(action.type) {
case ANTENNA_FETCH_SUCCESS:
case ANTENNA_CREATE_SUCCESS:
case ANTENNA_UPDATE_SUCCESS:
return normalizeAntenna(state, action.antenna);
case ANTENNAS_FETCH_SUCCESS:
return normalizeAntennas(state, action.antennas);
case ANTENNA_DELETE_SUCCESS:
case ANTENNA_FETCH_FAIL:
return state.set(action.id, false);
case ANTENNA_EDITOR_ADD_SUCCESS:
return state.setIn([action.antennaId, 'accounts_count'], state.getIn([action.antennaId, 'accounts_count']) + 1);
case ANTENNA_EDITOR_REMOVE_SUCCESS:
return state.setIn([action.antennaId, 'accounts_count'], state.getIn([action.antennaId, 'accounts_count']) - 1);
case ANTENNA_EDITOR_ADD_DOMAIN_SUCCESS:
return state.setIn([action.antennaId, 'domains_count'], state.getIn([action.antennaId, 'domains_count']) + 1).updateIn([action.antennaId, 'domains', 'domains'], domains => (ImmutableList(domains || [])).push(action.domain));
case ANTENNA_EDITOR_REMOVE_DOMAIN_SUCCESS:
return state.setIn([action.antennaId, 'domains_count'], state.getIn([action.antennaId, 'domains_count']) - 1).updateIn([action.antennaId, 'domains', 'domains'], domains => (ImmutableList(domains || [])).filterNot(domain => domain === action.domain));
case ANTENNA_EDITOR_FETCH_DOMAINS_SUCCESS:
return state.setIn([action.id, 'domains'], ImmutableMap(action.domains));
case ANTENNA_EDITOR_ADD_KEYWORD_SUCCESS:
return state.setIn([action.antennaId, 'keywords_count'], state.getIn([action.antennaId, 'keywords_count']) + 1).updateIn([action.antennaId, 'keywords', 'keywords'], keywords => (ImmutableList(keywords || [])).push(action.keyword));
case ANTENNA_EDITOR_REMOVE_KEYWORD_SUCCESS:
return state.setIn([action.antennaId, 'keywords_count'], state.getIn([action.antennaId, 'keywords_count']) - 1).updateIn([action.antennaId, 'keywords', 'keywords'], keywords => (ImmutableList(keywords || [])).filterNot(keyword => keyword === action.keyword));
case ANTENNA_EDITOR_FETCH_KEYWORDS_SUCCESS:
return state.setIn([action.id, 'keywords'], ImmutableMap(action.keywords));
default:
return state;
}

View file

@ -107,6 +107,8 @@ const sharedCallbacks = {
return channelName === streamChannelName && params.tag === streamIdentifier;
} else if (channelName === 'list') {
return channelName === streamChannelName && params.list === streamIdentifier;
} else if (channelName === 'antenna') {
return channelName === streamChannelName && params.antenna === streamIdentifier;
}
return false;

View file

@ -7364,6 +7364,61 @@ noscript {
}
}
.antenna-list-detail {
font-size: 12px;
margin-left: 24px;
.group {
margin-right: 16px;
i.fa {
color: $light-text-color;
margin-right: 4px;
font-size: 10px;
}
}
}
.antenna-setting {
margin: 8px 16px 32px;
h3 {
font-size: 16px;
margin: 40px 0 16px;
font-weight: bold;
}
}
.setting-text-list {
.add-text-form {
display: flex;
gap: 8px;
label {
flex: 1 1 auto;
input {
width: 100%;
}
}
}
}
.setting-text-list-item {
display: flex;
font-size: 14px;
margin: 8px 0;
i.fa {
color: $darker-text-color;
}
.label {
flex: 1;
margin: 0 8px;
}
}
.reaction_deck_container {
&__row {
display: flex;

View file

@ -90,6 +90,14 @@ class FeedManager
true
end
def push_to_antenna(antenna, status, update: false)
return false unless add_to_feed(:antenna, antenna.id, status, aggregate_reblogs: antenna.account.user&.aggregates_reblogs?)
trim(:antenna, antenna.id)
PushUpdateWorker.perform_async(antenna.account_id, status.id, "timeline:antenna:#{antenna.id}", { 'update' => update }) if push_update_required?("timeline:antenna:#{antenna.id}")
true
end
# Remove a status from a list feed and send a streaming API update
# @param [List] list
# @param [Status] status
@ -102,6 +110,13 @@ class FeedManager
true
end
def unpush_from_antenna(antenna, status, update: false)
return false unless remove_from_feed(:antenna, antenna.id, status, aggregate_reblogs: antenna.account.user&.aggregates_reblogs?)
redis.publish("timeline:antenna:#{antenna.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update
true
end
# Fill a home feed with an account's statuses
# @param [Account] from_account
# @param [Account] into_account

View file

@ -24,6 +24,7 @@
# exclude_tags :jsonb
# stl :boolean default(FALSE), not null
# ignore_reblog :boolean default(FALSE), not null
# insert_feeds :boolean default(FALSE), not null
#
class Antenna < ApplicationRecord
include Expireable
@ -33,6 +34,7 @@ class Antenna < ApplicationRecord
has_many :antenna_domains, inverse_of: :antenna, dependent: :destroy
has_many :antenna_tags, inverse_of: :antenna, dependent: :destroy
has_many :antenna_accounts, inverse_of: :antenna, dependent: :destroy
has_many :accounts, through: :antenna_accounts
belongs_to :account
belongs_to :list, optional: true

View file

@ -13,4 +13,10 @@
#
class AntennaDomain < ApplicationRecord
belongs_to :antenna
validate :duplicate_domain
def duplicate_domain
raise Mastodon::ValidationError, I18n.t('antennas.errors.duplicate_domain') if AntennaDomain.exists?(antenna_id: antenna_id, name: name, exclude: exclude)
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AntennaFeed < Feed
def initialize(antenna)
super(:antenna, antenna.id)
end
end

View file

@ -1,9 +1,35 @@
# frozen_string_literal: true
class REST::AntennaSerializer < ActiveModel::Serializer
attributes :id, :title
attributes :id, :title, :stl, :insert_feeds, :with_media_only, :ignore_reblog, :accounts_count, :domains_count, :tags_count, :keywords_count
class ListSerializer < ActiveModel::Serializer
attributes :id, :title
def id
object.id.to_s
end
end
has_one :list, serializer: ListSerializer, optional: true
def id
object.id.to_s
end
def accounts_count
object.antenna_accounts.count
end
def domains_count
object.antenna_domains.count
end
def tags_count
object.antenna_tags.count
end
def keywords_count
object.keywords&.size || 0
end
end

View file

@ -16,4 +16,8 @@ class REST::ListSerializer < ActiveModel::Serializer
end
has_many :antennas, serializer: AntennaSerializer
def antennas
object.antennas.where(insert_feeds: true)
end
end

View file

@ -47,6 +47,7 @@ class BatchedRemoveStatusService < BaseService
unpush_from_home_timelines(account, account_statuses)
unpush_from_list_timelines(account, account_statuses)
unpush_from_antenna_timelines(account, account_statuses)
end
# Cannot be batched
@ -76,6 +77,14 @@ class BatchedRemoveStatusService < BaseService
end
end
def unpush_from_antenna_timelines(_account, statuses)
Antenna.availables.select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |antenna|
statuses.each do |status|
FeedManager.instance.unpush_from_antenna(antenna, status)
end
end
end
def unpush_from_public_timelines(status, pipeline)
return unless status.public_visibility? && status.id > @status_id_cutoff

View file

@ -51,10 +51,11 @@ class FanOutOnWriteService < BaseService
when :public, :unlisted, :public_unlisted, :login, :private
deliver_to_all_followers!
deliver_to_lists!
deliver_to_antennas! if [:public, :public_unlisted, :login].include?(@status.visibility.to_sym) && !@account.dissubscribable
deliver_to_antennas! unless @account.dissubscribable
deliver_to_stl_antennas!
when :limited
deliver_to_lists_mentioned_accounts_only!
deliver_to_antennas! unless @account.dissubscribable
deliver_to_mentioned_followers!
else
deliver_to_mentioned_followers!
@ -159,9 +160,6 @@ class FanOutOnWriteService < BaseService
antennas = Antenna.availables
antennas = antennas.left_joins(:antenna_domains).where(any_domains: true).or(Antenna.left_joins(:antenna_domains).where(antenna_domains: { name: domain }))
antennas = antennas.where(with_media_only: false) unless @status.with_media?
antennas = antennas.where(ignore_reblog: false) unless @status.reblog?
antennas = antennas.where(stl: false)
antennas = Antenna.where(id: antennas.select(:id))
antennas = antennas.left_joins(:antenna_accounts).where(any_accounts: true).or(Antenna.left_joins(:antenna_accounts).where(antenna_accounts: { account: @account }))
@ -171,13 +169,18 @@ class FanOutOnWriteService < BaseService
antennas = antennas.left_joins(:antenna_tags).where(any_tags: true).or(Antenna.left_joins(:antenna_tags).where(antenna_tags: { tag_id: tag_ids }))
antennas = antennas.where(account_id: Account.without_suspended.joins(:user).select('accounts.id').where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago))
antennas = antennas.where(account: @status.account.followers) if [:public, :public_unlisted, :login, :limited].exclude?(@status.visibility.to_sym)
antennas = antennas.where(account: @status.mentioned_accounts) if @status.visibility.to_sym == :limited
antennas = antennas.where(with_media_only: false) unless @status.with_media?
antennas = antennas.where(ignore_reblog: false) unless @status.reblog?
antennas = antennas.where(stl: false)
collection = AntennaCollection.new(@status, @options[:update], false)
antennas.in_batches do |ans|
ans.each do |antenna|
next unless antenna.enabled?
next if antenna.keywords.any? && antenna.keywords.none? { |keyword| @status.text.include?(keyword) }
next if antenna.keywords&.any? && antenna.keywords&.none? { |keyword| @status.text.include?(keyword) }
next if antenna.exclude_keywords&.any? { |keyword| @status.text.include?(keyword) }
next if antenna.exclude_accounts&.include?(@status.account_id)
next if antenna.exclude_domains&.include?(domain)
@ -273,25 +276,25 @@ class FanOutOnWriteService < BaseService
def push(antenna)
if antenna.list_id.zero?
@home_account_ids << antenna.account_id
else
@list_ids << antenna.list_id
@home_account_ids << { id: antenna.account_id, antenna_id: antenna.id } if @home_account_ids.none? { |id| id.id == antenna.account_id }
elsif @list_ids.none? { |id| id.id == antenna.list_id }
@list_ids << { id: antenna.list_id, antenna_id: antenna.id }
end
end
def deliver!
lists = @list_ids.uniq
homes = @home_account_ids.uniq
lists = @list_ids
homes = @home_account_ids
if lists.any?
FeedInsertWorker.push_bulk(lists) do |list|
[@status.id, list, 'list', { 'update' => @update, 'stl_home' => @stl_home || false }]
[@status.id, list[:id], 'list', { 'update' => @update, 'stl_home' => @stl_home || false, 'antenna_id' => list[:antenna_id] }]
end
end
if homes.any?
FeedInsertWorker.push_bulk(homes) do |home|
[@status.id, home, 'home', { 'update' => @update }]
[@status.id, home[:id], 'home', { 'update' => @update, 'antenna_id' => home[:antenna_id] }]
end
end
end

View file

@ -81,6 +81,9 @@ class RemoveStatusService < BaseService
Antenna.availables.where(list_id: 0).select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |antenna|
FeedManager.instance.unpush_from_home(antenna.account, @status)
end
Antenna.availables.select(:id, :account_id).includes(account: :user).reorder(nil).find_each do |antenna|
FeedManager.instance.unpush_from_antenna(antenna, @status)
end
end
def remove_from_mentions

View file

@ -32,9 +32,6 @@
.fields-group
= f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html')
.fields-group
= f.input :noai, as: :boolean, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_noai'), hint: I18n.t('simple_form.hints.defaults.setting_noai')
%h4= t('admin.settings.discovery.publish_statistics')
.fields-group

View file

@ -13,6 +13,8 @@
.fields-group.fields-row__column.fields-row__column-6
= f.input :available, wrapper: :with_label, label: t('antennas.edit.available'), hint: false
.fields-row
= f.input :insert_feeds, wrapper: :with_label, label: t('antennas.edit.insert_feeds')
.fields-row
= f.input :stl, wrapper: :with_label, label: t('antennas.edit.stl'), hint: t('antennas.edit.stl_hint')

View file

@ -40,9 +40,6 @@
.fields-group
= ff.input :'web.hide_recent_emojis', wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_hide_recent_emojis'), hint: false
.fields-group
= ff.input :emoji_reaction_streaming_notify_impl2, as: :boolean, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_emoji_reaction_streaming_notify_impl2'), hint: I18n.t('simple_form.hints.defaults.setting_emoji_reaction_streaming_notify_impl2')
%h4= t 'appearance.discovery'
.fields-group

View file

@ -39,16 +39,8 @@
.fields-group
= ff.input :default_sensitive, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_default_sensitive'), hint: I18n.t('simple_form.hints.defaults.setting_default_sensitive')
%h4= t 'preferences.stop_deliver'
.fields-group
= ff.input :send_without_domain_blocks, kmyblue: true, recommended: :not_recommended, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_send_without_domain_blocks'), hint: I18n.t('simple_form.hints.defaults.setting_send_without_domain_blocks')
.fields-group
= ff.input :reject_public_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_public_unlisted_subscription')
.fields-group
= ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription')
= ff.input :emoji_reaction_streaming_notify_impl2, as: :boolean, wrapper: :with_label, kmyblue: true, label: I18n.t('simple_form.labels.defaults.setting_emoji_reaction_streaming_notify_impl2'), hint: I18n.t('simple_form.hints.defaults.setting_emoji_reaction_streaming_notify_impl2')
%h4= t 'preferences.public_timelines'

View file

@ -51,5 +51,15 @@
.fields-group
= ff.input :show_application, wrapper: :with_label
%h4= t 'privacy.stop_deliver'
%p.lead= t('privacy.stop_deliver_hint_html')
.fields-group
= ff.input :reject_public_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_public_unlisted_subscription')
.fields-group
= ff.input :reject_unlisted_subscription, kmyblue: true, as: :boolean, wrapper: :with_label, label: I18n.t('simple_form.labels.defaults.setting_reject_unlisted_subscription'), hint: I18n.t('simple_form.hints.defaults.setting_reject_unlisted_subscription')
.actions
= f.button :button, t('generic.save_changes'), type: :submit

View file

@ -9,6 +9,7 @@ class FeedInsertWorker
@type = type.to_sym
@status = Status.find(status_id)
@options = options.symbolize_keys
@antenna = Antenna.find(@options[:antenna_id]) if @options[:antenna_id].present?
case @type
when :home, :tags
@ -55,12 +56,18 @@ class FeedInsertWorker
end
def perform_push
case @type
when :home, :tags
FeedManager.instance.push_to_home(@follower, @status, update: update?)
when :list
FeedManager.instance.push_to_list(@list, @status, update: update?)
if @antenna.nil? || @antenna.insert_feeds
case @type
when :home, :tags
FeedManager.instance.push_to_home(@follower, @status, update: update?)
when :list
FeedManager.instance.push_to_list(@list, @status, update: update?)
end
end
return if @antenna.nil?
FeedManager.instance.push_to_antenna(@antenna, @status, update: update?)
end
def perform_unpush
@ -70,6 +77,10 @@ class FeedInsertWorker
when :list
FeedManager.instance.unpush_from_list(@list, @status, update: true)
end
return if @antenna.nil?
FeedManager.instance.unpush_from_antenna(@antenna, @status, update: true)
end
def perform_notify

View file

@ -1041,6 +1041,7 @@ en:
exclude_tags_raw: Excluding hashtag list
hint: 下のリストに、絞り込み条件・除外条件を入力します。条件は複数指定することができます。1行につき1つずつ入力してください。空行、コメント、重複を含めることはできません。
ignore_reblog: Ignore BTs
insert_feeds: Insert home/list timeline (Destination list setting is NOT available if this checkbox is NOT set)
keywords_hint: キーワードは1つあたり最低2文字です。キーワードによる絞り込みを指定した場合、検索許可に対応しているサーバーからの投稿は、検索許可が「公開」以外のものは掲載されなくなります
keywords_raw: Keyword list
list: Destination list
@ -1588,7 +1589,6 @@ en:
other: Other
posting_defaults: Posting defaults
public_timelines: Public timelines
stop_deliver: Stop delivering
privacy:
hint_html: "<strong>Customize how you want your profile and your posts to be found.</strong> A variety of features in Mastodon can help you reach a wider audience when enabled. Take a moment to review these settings to make sure they fit your use case."
privacy: Privacy
@ -1597,6 +1597,7 @@ en:
reach_hint_html: Control whether you want to be discovered and followed by new people. Do you want your posts to appear on the Explore screen? Do you want other people to see you in their follow recommendations? Do you want to accept all new followers automatically, or have granular control over each one?
search: Search
search_hint_html: Control how you want to be found. Do you want people to find you by what you've publicly posted about? Do you want people outside Mastodon to find your profile when searching the web? Please mind that total exclusion from all search engines cannot be guaranteed for public information.
stop_deliver: Stop delivering
title: Privacy and reach
privacy_policy:
title: Privacy Policy

View file

@ -1023,6 +1023,8 @@ ja:
keyword: キーワード
tag: ハッシュタグ
errors:
duplicate_domain: すでに同じドメインが登録されています
duplicate_keyword: すでに同じキーワードが登録されています
empty_contexts: 絞り込み条件が1つも指定されていないため無効です除外条件はカウントされません
invalid_list_owner: これはあなたのリストではありません
over_limit: 所持できるアンテナ数 %{limit}を超えています
@ -1041,6 +1043,7 @@ ja:
exclude_tags_raw: 除外するハッシュタグ
hint: 下のリストに、絞り込み条件・除外条件を入力します。条件は複数指定することができます。1行につき1つずつ入力してください。空行、コメント、重複を含めることはできません。絞り込み条件除外条件ではないは最低1つ設定しなければいけません。
ignore_reblog: ブーストを含めない
insert_feeds: 投稿をホーム・リストに流す(投稿配置先リストの設定を有効にするには、この設定を有効にする必要があります)
keywords_hint: キーワードは1つあたり最低2文字です。キーワードによる絞り込みを指定した場合、検索許可に対応しているサーバーからの投稿は、検索許可が「公開」以外のものは掲載されなくなります
keywords_raw: 絞り込むキーワード
list: 投稿配置先リスト
@ -1579,7 +1582,17 @@ ja:
other: その他
posting_defaults: デフォルトの投稿設定
public_timelines: 公開タイムライン
privacy:
hint_html: "<strong>Customize how you want your profile and your posts to be found.</strong> A variety of features in Mastodon can help you reach a wider audience when enabled. Take a moment to review these settings to make sure they fit your use case."
privacy: Privacy
privacy_hint_html: Control how much you want to disclose for the benefit of others. People discover interesting profiles and cool apps by browsing other people's follows and seeing which apps they post from, but you may prefer to keep it hidden.
reach: Reach
reach_hint_html: Control whether you want to be discovered and followed by new people. Do you want your posts to appear on the Explore screen? Do you want other people to see you in their follow recommendations? Do you want to accept all new followers automatically, or have granular control over each one?
search: Search
search_hint_html: Control how you want to be found. Do you want people to find you by what you've publicly posted about? Do you want people outside Mastodon to find your profile when searching the web? Please mind that total exclusion from all search engines cannot be guaranteed for public information.
stop_deliver: 配送停止
stop_deliver_hint_html: Mastodonの投稿を、他のソフトウェアでは自由に検索することができます。Mastodon内で行ったプライバシーの設定は無視され、あなたの投稿が意図しない人に見つかるおそれがあります。ここでは、他のサーバーやソフトウェアであなたの投稿が見つからないようにする設定が可能です。ただしリスクは伴います。
title: Privacy and reach
privacy_policy:
title: プライバシーポリシー
reactions:

View file

@ -4,6 +4,8 @@ ja:
account:
display_name: フルネーム、ハンドルネームなど
fields: ホームページ、代名詞、年齢など何でも構いません。
hide_collections: People will not be able to browse through your follows and followers. People that you follow will see that you follow them regardless.
locked: People will request to follow you and you will be able to either accept or reject new followers.
note: '自己紹介には #ハッシュタグ や、ほかのアカウントのユーザー名 (@user) を使用できます'
account_alias:
acct: 引っ越し元のユーザー名@ドメインを指定してください
@ -68,7 +70,7 @@ ja:
setting_display_media_expand: Misskeyなどは4個を超えて投稿可能です。その追加分を最大16個まで表示します。kmyblueからアップロードはできません
setting_noai: AI学習への利用を禁止するメタタグをプロフィールページに追加します。ただし実効性があるとは限りません
setting_public_post_to_unlisted: 未対応のサードパーティアプリからもローカル公開で投稿できますが、公開投稿はWeb以外できなくなります
setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーのうち管理人が指定したものに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください
setting_reject_unlisted_subscription: Misskeyやそのフォーク(Calckeyなど)は、フォローしていないアカウントの「未収載」投稿を **購読・検索** することができます。これはkmyblueの挙動と異なります。そのようなサーバーに、指定した公開範囲の投稿を「フォロワーのみ」として配送します。ただし構造上、完璧な対応は困難でたまに未収載として配信されること、ご理解ください
setting_show_application: 投稿するのに使用したアプリが投稿の詳細ビューに表示されるようになります
setting_stop_emoji_reaction_streaming: 通信容量の節約に役立ちます
setting_unsafe_limited_distribution: Mastodon 3.5、4.0、4.1のサーバーにも限定投稿(相互のみ)が届くようになりますが、安全でない方法で送信します
@ -133,6 +135,9 @@ ja:
sessions:
otp: '携帯電話のアプリで生成された二要素認証コードを入力するか、リカバリーコードを使用してください:'
webauthn: USBキーの場合は、必ず挿入し、必要に応じてタップしてください。
settings:
indexable: Your profile page may appear in search results on Google, Bing, and others.
show_application: You will always be able to see which app published your post regardless.
tag:
name: 視認性向上などのためにアルファベット大文字小文字の変更のみ行うことができます
user:
@ -154,6 +159,8 @@ ja:
fields:
name: ラベル
value: 内容
hide_collections: (仮訳)フォロー・フォロワー一覧を隠す
locked: (仮訳)新規フォローを承認制にする
account_alias:
acct: 引っ越し元のユーザー ID
account_migration:
@ -243,8 +250,8 @@ ja:
setting_noai: 自分のコンテンツのAI学習利用に対して不快感を表明する
setting_public_post_to_unlisted: サードパーティアプリから投稿するとき、公開投稿をローカル公開に変更する
setting_reduce_motion: アニメーションの動きを減らす
setting_reject_public_unlisted_subscription: 管理者の指定したサーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する
setting_reject_unlisted_subscription: 管理者の指定したサーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する
setting_reject_public_unlisted_subscription: Misskey系サーバーに「ローカル公開」投稿を「フォロワーのみ」に変換して配送する
setting_reject_unlisted_subscription: Misskey系サーバーに「未収載」投稿を「フォロワーのみ」に変換して配送する
setting_send_without_domain_blocks: 管理人の設定した配送停止設定を拒否する (非推奨)
setting_show_application: 送信したアプリを開示する
setting_stop_emoji_reaction_streaming: スタンプのストリーミングを停止する
@ -334,6 +341,9 @@ ja:
trending_tag: 新しいトレンドのレビューをする必要がある時
rule:
text: ルール
settings:
indexable: (仮訳)プロフィールページを検索エンジンの収集対象に含める
show_application: (仮訳)投稿に使用したアプリを開示する
tag:
listable: 検索とディレクトリへの使用を許可する
name: ハッシュタグ

View file

@ -16,6 +16,8 @@ Rails.application.routes.draw do
/public/remote
/conversations
/lists/(*any)
/antennasw/(*any)
/antennast/(*any)
/notifications
/favourites
/emoji_reactions

View file

@ -49,6 +49,7 @@ namespace :api, format: false do
resource :public, only: :show, controller: :public
resources :tag, only: :show
resources :list, only: :show
resources :antenna, only: :show
end
get '/streaming', to: 'streaming#index'
@ -207,6 +208,8 @@ namespace :api, format: false do
resources :antennas, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], controller: 'antennas/accounts'
resource :domains, only: [:show, :create, :destroy], controller: 'antennas/domains'
resource :keywords, only: [:show, :create, :destroy], controller: 'antennas/keywords'
end
namespace :featured_tags do

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class AddNoInsertFeedsToAntennas < ActiveRecord::Migration[7.0]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
class Antenna < ApplicationRecord
end
def up
safety_assured do
add_column_with_default :antennas, :insert_feeds, :boolean, default: false, allow_null: false
Antenna.where(insert_feeds: false).update_all(insert_feeds: true) # rubocop:disable Rails/SkipsModelValidations
end
end
def down
remove_column :antennas, :insert_feeds
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_08_14_223300) do
ActiveRecord::Schema[7.0].define(version: 2023_08_19_084858) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -308,6 +308,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_08_14_223300) do
t.jsonb "exclude_tags"
t.boolean "stl", default: false, null: false
t.boolean "ignore_reblog", default: false, null: false
t.boolean "insert_feeds", default: false, null: false
t.index ["account_id"], name: "index_antennas_on_account_id"
t.index ["any_accounts"], name: "index_antennas_on_any_accounts"
t.index ["any_domains"], name: "index_antennas_on_any_domains"

View file

@ -486,6 +486,8 @@ const startServer = async () => {
return 'direct';
case '/api/v1/streaming/list':
return 'list';
case '/api/v1/streaming/antenna':
return 'antenna';
default:
return undefined;
}
@ -701,6 +703,33 @@ const startServer = async () => {
});
});
/**
* @param {string} antennaId
* @param {any} req
* @returns {Promise.<void>}
*/
const authorizeAntennaAccess = (antennaId, req) => new Promise((resolve, reject) => {
const { accountId } = req;
pgPool.connect((err, client, done) => {
if (err) {
reject();
return;
}
client.query('SELECT id, account_id FROM antennas WHERE id = $1 LIMIT 1', [antennaId], (err, result) => {
done();
if (err || result.rows.length === 0 || result.rows[0].account_id !== accountId) {
reject();
return;
}
resolve();
});
});
});
/**
* @param {string[]} ids
* @param {any} req
@ -1214,6 +1243,17 @@ const startServer = async () => {
reject('Not authorized to stream this list');
});
break;
case 'antenna':
authorizeAntennaAccess(params.antenna, req).then(() => {
resolve({
channelIds: [`timeline:antenna:${params.antenna}`],
options: { needsFiltering: false },
});
}).catch(() => {
reject('Not authorized to stream this antenna');
});
break;
default:
reject('Unknown stream type');
@ -1228,6 +1268,8 @@ const startServer = async () => {
const streamNameFromChannelName = (channelName, params) => {
if (channelName === 'list') {
return [channelName, params.list];
} else if (channelName === 'antenna') {
return [channelName, params.antenna];
} else if (['hashtag', 'hashtag:local'].includes(channelName)) {
return [channelName, params.tag];
} else {