Compare commits

..

2 Commits

Author SHA1 Message Date
kibigo!
f32ef29808 Removed unnecessary constraint 2017-12-11 00:14:00 -08:00
kibigo!
a634d3876f Route flavours ~ URLs 2017-12-10 23:59:04 -08:00
796 changed files with 10402 additions and 22327 deletions

View File

@@ -27,7 +27,6 @@ plugins:
enabled: true
eslint:
enabled: true
channel: eslint-4
rubocop:
enabled: true
scss-lint:

View File

@@ -11,11 +11,10 @@ DB_PASS=
DB_PORT=5432
# Federation
# Note: Changing LOCAL_DOMAIN at a later time will cause unwanted side effects, including breaking all existing federation.
# Note: Changing LOCAL_DOMAIN or LOCAL_HTTPS at a later time will cause unwanted side effects.
# LOCAL_DOMAIN should *NOT* contain the protocol part of the domain e.g https://example.com.
LOCAL_DOMAIN=example.com
# Changing LOCAL_HTTPS in production is no longer supported. (Mastodon will always serve https:// links)
LOCAL_HTTPS=true
# Use this only if you need to run mastodon on a different domain than the one used for federation.
# You can read more about this option on https://github.com/tootsuite/documentation/blob/master/Running-Mastodon/Serving_a_different_domain.md

View File

@@ -17,9 +17,11 @@ plugins:
parserOptions:
sourceType: module
ecmaFeatures:
experimentalObjectRestSpread: true
arrowFunctions: true
jsx: true
ecmaVersion: 2018
destructuring: true
modules: true
spread: true
settings:
import/extensions:
@@ -112,7 +114,6 @@ rules:
react/self-closing-comp: error
jsx-a11y/accessible-emoji: warn
jsx-a11y/alt-text: warn
jsx-a11y/anchor-has-content: warn
jsx-a11y/aria-activedescendant-has-tabindex: warn
jsx-a11y/aria-props: warn
@@ -123,22 +124,16 @@ rules:
jsx-a11y/href-no-hash: warn
jsx-a11y/html-has-lang: warn
jsx-a11y/iframe-has-title: warn
jsx-a11y/img-has-alt: warn
jsx-a11y/img-redundant-alt: warn
jsx-a11y/interactive-supports-focus: warn
jsx-a11y/label-has-for: off
jsx-a11y/mouse-events-have-key-events: warn
jsx-a11y/no-access-key: warn
jsx-a11y/no-distracting-elements: warn
jsx-a11y/no-noninteractive-element-interactions:
- warn
- handlers:
- onClick
jsx-a11y/no-onchange: warn
jsx-a11y/no-redundant-roles: warn
jsx-a11y/no-static-element-interactions:
- warn
- handlers:
- onClick
jsx-a11y/onclick-has-focus: warn
jsx-a11y/onclick-has-role: warn
jsx-a11y/role-has-required-aria-props: warn
jsx-a11y/role-supports-aria-props: off
jsx-a11y/scope: warn

View File

@@ -1 +1 @@
2.5.0
2.4.2

View File

@@ -8,7 +8,7 @@ cache:
- public/packs-test
- tmp/cache/babel-loader
dist: trusty
sudo: false
sudo: required
notifications:
email: false
@@ -37,8 +37,8 @@ addons:
- yarn
rvm:
- 2.3.4
- 2.4.2
- 2.5.0
services:
- redis-server
@@ -49,7 +49,8 @@ install:
- yarn install
before_script:
- ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile
- bundle exec rake parallel:create parallel:load_schema parallel:prepare
- bundle exec rails assets:precompile
- ln -s /usr/bin/x86_64-linux-gnu-g++-6 "$HOME/g++"
script:

View File

@@ -1,4 +1,4 @@
FROM ruby:2.5.0-alpine3.7
FROM ruby:2.4.2-alpine3.6
LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server"
@@ -40,7 +40,6 @@ RUN apk -U upgrade \
protobuf \
su-exec \
tini \
tzdata \
&& update-ca-certificates \
&& mkdir -p /tmp/src /opt \
&& wget -O yarn.tar.gz "https://github.com/yarnpkg/yarn/releases/download/v$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
@@ -62,9 +61,6 @@ RUN apk -U upgrade \
&& rm -rf /tmp/* /var/cache/apk/*
COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/
COPY stack-fix.c /lib
RUN gcc -shared -fPIC /lib/stack-fix.c -o /lib/stack-fix.so
RUN rm /lib/stack-fix.c
RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \

13
Gemfile
View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '>= 2.3.0', '< 2.6.0'
ruby '>= 2.3.0', '< 2.5.0'
gem 'pkg-config', '~> 1.2'
@@ -14,13 +14,12 @@ gem 'pg', '~> 0.20'
gem 'pghero', '~> 1.7'
gem 'dotenv-rails', '~> 2.2'
gem 'aws-sdk', '~> 2.10', require: false
gem 'fog-aws', '~> 1.4', require: false
gem 'fog-core', '~> 1.45'
gem 'fog-local', '~> 0.4', require: false
gem 'fog-openstack', '~> 0.1', require: false
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6'
gem 'posix-spawn'
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5'
@@ -29,15 +28,15 @@ gem 'browser'
gem 'charlock_holmes', '~> 0.7.5'
gem 'iso-639'
gem 'cld3', '~> 3.2.0'
gem 'devise', '~> 4.4'
gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0'
gem 'doorkeeper', '~> 4.2'
gem 'fast_blank', '~> 1.0'
gem 'goldfinger', '~> 2.1'
gem 'goldfinger', '~> 2.0'
gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.5'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 3.0'
gem 'http', '~> 2.2'
gem 'http_accept_language', '~> 2.1'
gem 'httplog', '~> 0.99'
gem 'idn-ruby', require: 'idn'
@@ -50,7 +49,6 @@ gem 'oj', '~> 3.3'
gem 'ostatus2', '~> 2.0'
gem 'ox', '~> 2.8'
gem 'pundit', '~> 1.1'
gem 'premailer-rails'
gem 'rack-attack', '~> 5.0'
gem 'rack-cors', '~> 0.4', require: 'rack/cors'
gem 'rack-timeout', '~> 0.4'
@@ -60,7 +58,6 @@ gem 'redis', '~> 3.3', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 0.10'
gem 'ruby-oembed', '~> 0.12', require: 'oembed'
gem 'ruby-progressbar', '~> 1.4'
gem 'sanitize', '~> 4.4'
gem 'sidekiq', '~> 5.0'
gem 'sidekiq-scheduler', '~> 2.1'

View File

@@ -57,20 +57,12 @@ GEM
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-sdk (2.10.100)
aws-sdk-resources (= 2.10.100)
aws-sdk-core (2.10.100)
aws-sigv4 (~> 1.0)
jmespath (~> 1.0)
aws-sdk-resources (2.10.100)
aws-sdk-core (= 2.10.100)
aws-sigv4 (1.0.2)
bcrypt (3.1.11)
better_errors (2.4.0)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
binding_of_caller (0.8.0)
binding_of_caller (0.7.3)
debug_inspector (>= 0.0.1)
bootsnap (1.1.5)
msgpack (~> 1.0)
@@ -110,7 +102,7 @@ GEM
activesupport
charlock_holmes (0.7.5)
chunky_png (1.3.8)
cld3 (3.2.2)
cld3 (3.2.1)
ffi (>= 1.1.0, < 1.10.0)
climate_control (0.2.0)
cocaine (0.5.8)
@@ -122,10 +114,8 @@ GEM
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.3)
css_parser (1.6.0)
addressable
debug_inspector (0.0.3)
devise (4.4.0)
devise (4.3.0)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0, < 5.2)
@@ -162,6 +152,11 @@ GEM
i18n (~> 0.5)
fast_blank (1.0.0)
ffi (1.9.18)
fog-aws (1.4.1)
fog-core (~> 1.38)
fog-json (~> 1.0)
fog-xml (~> 0.1)
ipaddress (~> 0.8)
fog-core (1.45.0)
builder
excon (~> 0.58)
@@ -175,15 +170,18 @@ GEM
fog-core (>= 1.40)
fog-json (>= 1.0)
ipaddress (>= 0.8)
fog-xml (0.1.3)
fog-core
nokogiri (>= 1.5.11, < 2.0.0)
formatador (0.2.5)
fuubar (2.2.0)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
globalid (0.4.1)
activesupport (>= 4.2.0)
goldfinger (2.1.0)
goldfinger (2.0.1)
addressable (~> 2.5)
http (~> 3.0)
http (~> 2.2)
nokogiri (~> 1.8)
oj (~> 3.0)
hamlit (2.8.5)
@@ -202,14 +200,14 @@ GEM
hiredis (0.6.1)
hkdf (0.3.0)
htmlentities (4.3.4)
http (3.0.0)
http (2.2.2)
addressable (~> 2.3)
http-cookie (~> 1.0)
http-form_data (>= 2.0.0.pre.pre2, < 3)
http-form_data (~> 1.0.1)
http_parser.rb (~> 0.6.0)
http-cookie (1.0.3)
domain_name (~> 0.5)
http-form_data (2.0.0)
http-form_data (1.0.3)
http_accept_language (2.1.1)
http_parser.rb (0.6.0)
httplog (0.99.7)
@@ -230,7 +228,6 @@ GEM
idn-ruby (0.1.0)
ipaddress (0.8.3)
iso-639 (0.2.8)
jmespath (1.3.1)
json (2.1.0)
json-ld (2.1.7)
multi_json (~> 1.12)
@@ -300,12 +297,14 @@ GEM
concurrent-ruby (~> 1.0.0)
sidekiq (>= 3.5.0)
statsd-ruby (~> 1.2.0)
oj (3.3.10)
oj (3.3.9)
openssl (2.0.6)
orm_adapter (0.5.0)
ostatus2 (2.0.3)
addressable (~> 2.5)
http (~> 3.0)
nokogiri (~> 1.8)
ostatus2 (2.0.1)
addressable (~> 2.4)
http (~> 2.0)
nokogiri (~> 1.6)
openssl (~> 2.0)
ox (2.8.2)
paperclip (5.1.0)
activemodel (>= 4.2.0)
@@ -325,15 +324,7 @@ GEM
pghero (1.7.0)
activerecord
pkg-config (1.2.8)
posix-spawn (0.3.13)
powerpack (0.1.1)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
htmlentities (>= 4.0.0)
premailer-rails (1.10.1)
actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9)
pry (0.11.3)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
@@ -553,7 +544,6 @@ DEPENDENCIES
active_record_query_trace (~> 1.5)
addressable (~> 2.5)
annotate (~> 2.7)
aws-sdk (~> 2.10)
better_errors (~> 2.4)
binding_of_caller (~> 0.7)
bootsnap
@@ -569,22 +559,23 @@ DEPENDENCIES
charlock_holmes (~> 0.7.5)
cld3 (~> 3.2.0)
climate_control (~> 0.2)
devise (~> 4.4)
devise (~> 4.2)
devise-two-factor (~> 3.0)
doorkeeper (~> 4.2)
dotenv-rails (~> 2.2)
fabrication (~> 2.18)
faker (~> 1.7)
fast_blank (~> 1.0)
fog-aws (~> 1.4)
fog-core (~> 1.45)
fog-local (~> 0.4)
fog-openstack (~> 0.1)
fuubar (~> 2.2)
goldfinger (~> 2.1)
goldfinger (~> 2.0)
hamlit-rails (~> 0.2)
hiredis (~> 0.6)
htmlentities (~> 4.3)
http (~> 3.0)
http (~> 2.2)
http_accept_language (~> 2.1)
httplog (~> 0.99)
i18n-tasks (~> 0.9)
@@ -610,8 +601,6 @@ DEPENDENCIES
pg (~> 0.20)
pghero (~> 1.7)
pkg-config (~> 1.2)
posix-spawn
premailer-rails
pry-rails (~> 0.3)
puma (~> 3.10)
pundit (~> 1.1)
@@ -631,7 +620,6 @@ DEPENDENCIES
rspec-sidekiq (~> 3.0)
rubocop
ruby-oembed (~> 0.12)
ruby-progressbar (~> 1.4)
sanitize (~> 4.4)
scss_lint (~> 0.55)
sidekiq (~> 5.0)
@@ -651,7 +639,7 @@ DEPENDENCIES
webpush
RUBY VERSION
ruby 2.5.0p0
ruby 2.4.2p198
BUNDLED WITH
1.16.1
1.16.0

2
Vagrantfile vendored
View File

@@ -79,7 +79,7 @@ VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "ubuntu/xenial64"
config.vm.box = "ubuntu/trusty64"
config.vm.provider :virtualbox do |vb|
vb.name = "mastodon"

View File

@@ -2,8 +2,7 @@
class AccountsController < ApplicationController
include AccountControllerConcern
before_action :set_cache_headers
include SignatureVerification
def show
respond_to do |format|
@@ -28,11 +27,10 @@ class AccountsController < ApplicationController
end
format.json do
skip_session!
render_cached_json(['activitypub', 'actor', @account.cache_key], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
end
render json: @account,
serializer: ActivityPub::ActorSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
end
end
@@ -51,7 +49,7 @@ class AccountsController < ApplicationController
end
def default_statuses
@account.statuses.not_local_only.where(visibility: [:public, :unlisted])
@account.statuses.where(visibility: [:public, :unlisted])
end
def only_media_scope

View File

@@ -28,7 +28,7 @@ class ActivityPub::InboxesController < Api::BaseController
def upgrade_account
if signed_request_account.ostatus?
signed_request_account.update(last_webfingered_at: nil)
ResolveAccountWorker.perform_async(signed_request_account.acct)
ResolveRemoteAccountWorker.perform_async(signed_request_account.acct)
end
Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?

View File

@@ -89,8 +89,7 @@ module Admin
:username,
:display_name,
:email,
:ip,
:staff
:ip
)
end
end

View File

@@ -3,7 +3,6 @@
module Admin
class CustomEmojisController < BaseController
before_action :set_custom_emoji, except: [:index, :new, :create]
before_action :set_filter_params
def index
authorize :custom_emoji, :index?
@@ -33,26 +32,23 @@ module Admin
if @custom_emoji.update(resource_params)
log_action :update, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.updated_msg')
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
else
flash[:alert] = I18n.t('admin.custom_emojis.update_failed_msg')
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
end
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
end
def destroy
authorize @custom_emoji, :destroy?
@custom_emoji.destroy!
log_action :destroy, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.destroyed_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
end
def copy
authorize @custom_emoji, :copy?
emoji = CustomEmoji.find_or_initialize_by(domain: nil,
shortcode: @custom_emoji.shortcode)
emoji = CustomEmoji.find_or_initialize_by(domain: nil, shortcode: @custom_emoji.shortcode)
emoji.image = @custom_emoji.image
if emoji.save
@@ -62,23 +58,21 @@ module Admin
flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
end
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
redirect_to admin_custom_emojis_path(page: params[:page])
end
def enable
authorize @custom_emoji, :enable?
@custom_emoji.update!(disabled: false)
log_action :enable, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
end
def disable
authorize @custom_emoji, :disable?
@custom_emoji.update!(disabled: true)
log_action :disable, @custom_emoji
flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg')
redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
end
private
@@ -87,10 +81,6 @@ module Admin
@custom_emoji = CustomEmoji.find(params[:id])
end
def set_filter_params
@filter_params = filter_params.to_hash.symbolize_keys
end
def resource_params
params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
end
@@ -102,9 +92,7 @@ module Admin
def filter_params
params.permit(
:local,
:remote,
:by_domain,
:shortcode
:remote
)
end
end

View File

@@ -17,8 +17,6 @@ module Admin
bootstrap_timeline_accounts
thumbnail
min_invite_role
activity_api_enabled
peers_api_enabled
).freeze
BOOLEAN_SETTINGS = %w(
@@ -26,8 +24,6 @@ module Admin
open_deletion
timeline_preview
show_staff_badge
activity_api_enabled
peers_api_enabled
).freeze
UPLOAD_SETTINGS = %w(

View File

@@ -6,8 +6,8 @@ class Api::BaseController < ApplicationController
include RateLimitHeaders
skip_before_action :verify_authenticity_token
skip_before_action :store_current_location
protect_from_forgery with: :null_session
rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
render json: { error: e.to_s }, status: 422
@@ -72,4 +72,19 @@ class Api::BaseController < ApplicationController
def render_empty
render json: {}, status: 200
end
def set_maps(statuses) # rubocop:disable Style/AccessorMethodName
if current_account.nil?
@reblogs_map = {}
@favourites_map = {}
@mutes_map = {}
return
end
status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
@reblogs_map = Status.reblogs_map(status_ids, current_account)
@favourites_map = Status.favourites_map(status_ids, current_account)
@mutes_map = Status.mutes_map(conversation_ids, current_account)
end
end

View File

@@ -1,20 +0,0 @@
# frozen_string_literal: true
class Api::V1::Accounts::ListsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
before_action :set_account
respond_to :json
def index
@lists = @account.lists.where(account: current_account)
render json: @lists, each_serializer: REST::ListSerializer
end
private
def set_account
@account = Account.find(params[:account_id])
end
end

View File

@@ -21,12 +21,12 @@ class Api::V1::AccountsController < Api::BaseController
end
def block
BlockService.new.call(current_user.account, @account, note: params[:note])
BlockService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def mute
MuteService.new.call(current_user.account, @account, notifications: params[:notifications], note: params[:note])
MuteService.new.call(current_user.account, @account, notifications: params[:notifications])
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end

View File

@@ -1,36 +0,0 @@
# frozen_string_literal: true
class Api::V1::Instances::ActivityController < Api::BaseController
before_action :require_enabled_api!
respond_to :json
def show
render_cached_json('api:v1:instances:activity:show', expires_in: 1.day) { activity }
end
private
def activity
weeks = []
12.times do |i|
day = i.weeks.ago.to_date
week_id = day.cweek
week = Date.commercial(day.cwyear, week_id)
weeks << {
week: week.to_time.to_i.to_s,
statuses: Redis.current.get("activity:statuses:local:#{week_id}") || '0',
logins: Redis.current.pfcount("activity:logins:#{week_id}").to_s,
registrations: Redis.current.get("activity:accounts:local:#{week_id}") || '0',
}
end
weeks
end
def require_enabled_api!
head 404 unless Setting.activity_api_enabled
end
end

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
class Api::V1::Instances::PeersController < Api::BaseController
before_action :require_enabled_api!
respond_to :json
def index
render_cached_json('api:v1:instances:peers:index', expires_in: 1.day) { Account.remote.domains }
end
private
def require_enabled_api!
head 404 unless Setting.peers_api_enabled
end
end

View File

@@ -1,14 +1,18 @@
# frozen_string_literal: true
class Api::V1::ListsController < Api::BaseController
LISTS_LIMIT = 50
before_action -> { doorkeeper_authorize! :read }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write }, except: [:index, :show]
before_action :require_user!
before_action :set_list, except: [:index, :create]
after_action :insert_pagination_headers, only: :index
def index
@lists = List.where(account: current_account).all
@lists = List.where(account: current_account).paginate_by_max_id(limit_param(LISTS_LIMIT), params[:max_id], params[:since_id])
render json: @lists, each_serializer: REST::ListSerializer
end
@@ -40,4 +44,36 @@ class Api::V1::ListsController < Api::BaseController
def list_params
params.permit(:title)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
if records_continue?
api_v1_lists_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless @lists.empty?
api_v1_lists_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
@lists.last.id
end
def pagination_since_id
@lists.first.id
end
def records_continue?
@lists.size == limit_param(LISTS_LIMIT)
end
def pagination_params(core_params)
params.permit(:limit).merge(core_params)
end
end

View File

@@ -9,11 +9,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController
def show
@statuses = load_statuses
render json: @statuses,
each_serializer: REST::StatusSerializer,
relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id),
status: regeneration_in_progress? ? 206 : 200
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
@@ -61,8 +57,4 @@ class Api::V1::Timelines::HomeController < Api::BaseController
def pagination_since_id
@statuses.first.id
end
def regeneration_in_progress?
Redis.current.exists("account:#{current_account.id}:regeneration")
end
end

View File

@@ -4,7 +4,6 @@ class Api::Web::PushSubscriptionsController < Api::BaseController
respond_to :json
before_action :require_user!
protect_from_forgery with: :exception
def create
params.require(:subscription).require(:endpoint)
@@ -29,8 +28,6 @@ class Api::Web::PushSubscriptionsController < Api::BaseController
},
}
data.deep_merge!(params[:data]) if params[:data]
web_subscription = ::Web::PushSubscription.create!(
endpoint: params[:subscription][:endpoint],
key_p256dh: params[:subscription][:keys][:p256dh],

View File

@@ -31,7 +31,7 @@ class ApplicationController < ActionController::Base
private
def https_enabled?
Rails.env.production?
Rails.env.production? && ENV['LOCAL_HTTPS'] == 'true'
end
def store_current_location
@@ -81,6 +81,7 @@ class ApplicationController < ActionController::Base
end
def pack?(data, pack_name)
return false unless data
if data['pack'].is_a?(Hash) && data['pack'].key?(pack_name)
return true if data['pack'][pack_name].is_a?(String) || data['pack'][pack_name].is_a?(Hash)
end
@@ -89,16 +90,17 @@ class ApplicationController < ActionController::Base
def nil_pack(data, pack_name, skin = 'default')
{
common: pack_name == 'common' ? nil : resolve_pack(data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
flavour: data['name'],
common: pack_name == 'common' ? nil : resolve_pack(!data || data['name'] ? Themes.instance.flavour(current_flavour) : Themes.instance.core, 'common', skin),
flavour: data ? data['name'] : nil,
pack: nil,
preload: nil,
skin: nil,
supported_locales: data['locales'],
supported_locales: data ? data['locales'] : nil,
}
end
def resolve_pack(data, pack_name, skin = 'default')
return nil_pack(data, pack_name, skin) unless data
result = pack(data, pack_name, skin)
unless result
if data['name'] && data.key?('fallback')
@@ -154,13 +156,14 @@ class ApplicationController < ActionController::Base
end
def current_flavour
return Setting.default_settings['flavour'] unless Themes.instance.flavours.include? current_user&.setting_flavour
current_user.setting_flavour
return params[:use_flavour].to_s if Themes.instance.flavours.include? params[:use_flavour].to_s
return current_user.setting_flavour if Themes.instance.flavours.include? current_user&.setting_flavour
Setting.default_settings['flavour']
end
def current_skin
return 'default' unless Themes.instance.skins_for(current_flavour).include? current_user&.setting_skin
current_user.setting_skin
return current_user.setting_skin if Themes.instance.skins_for(current_flavour).include? current_user&.setting_skin
'default'
end
def cache_collection(raw, klass)
@@ -192,31 +195,8 @@ class ApplicationController < ActionController::Base
format.any { head code }
format.html do
set_locale
use_pack 'error'
render "errors/#{code}", layout: 'error', status: code
end
end
end
def render_cached_json(cache_key, **options)
options[:expires_in] ||= 3.minutes
cache_key = cache_key.join(':') if cache_key.is_a?(Enumerable)
cache_public = options.key?(:public) ? options.delete(:public) : true
content_type = options.delete(:content_type) || 'application/json'
data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do
yield.to_json
end
expires_in options[:expires_in], public: cache_public
render json: data, content_type: content_type
end
def set_cache_headers
response.headers['Vary'] = 'Accept'
end
def skip_session!
request.session_options[:skip] = true
end
end

View File

@@ -2,9 +2,14 @@
class Auth::ConfirmationsController < Devise::ConfirmationsController
layout 'auth'
before_action :set_pack
def show
super do |user|
BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty?
end
end
private
def set_pack

View File

@@ -38,10 +38,6 @@ class Auth::RegistrationsController < Devise::RegistrationsController
new_user_session_path
end
def after_update_path_for(_resource)
edit_user_registration_path
end
def check_enabled_registrations
redirect_to root_path if single_user_mode? || !allowed_registrations?
end

View File

@@ -8,8 +8,8 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create]
skip_before_action :check_suspension, only: [:destroy]
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
prepend_before_action :set_pack
before_action :set_instance_presenter, only: [:new]
before_action :set_pack
def create
super do |resource|

View File

@@ -5,7 +5,6 @@ class AuthorizeFollowsController < ApplicationController
before_action :authenticate_user!
before_action :set_pack
before_action :set_body_classes
def show
@account = located_account || render(:error)
@@ -46,7 +45,7 @@ class AuthorizeFollowsController < ApplicationController
end
def account_from_remote_follow
ResolveAccountService.new.call(acct_without_prefix)
ResolveRemoteAccountService.new.call(acct_without_prefix)
end
def acct_param_is_url?
@@ -64,8 +63,4 @@ class AuthorizeFollowsController < ApplicationController
def acct_params
params.fetch(:acct, '')
end
def set_body_classes
@body_classes = 'modal-layout'
end
end

View File

@@ -44,8 +44,7 @@ module RateLimitHeaders
end
def api_throttle_data
most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] }
request.env['rack.attack.throttle_data'][most_limited_type]
request.env['rack.attack.throttle_data']['api']
end
def request_time

View File

@@ -114,7 +114,7 @@ module SignatureVerification
def account_from_key_id(key_id)
if key_id.start_with?('acct:')
ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false)

View File

@@ -3,6 +3,7 @@
module UserTrackingConcern
extend ActiveSupport::Concern
REGENERATE_FEED_DAYS = 14
UPDATE_SIGN_IN_HOURS = 24
included do
@@ -13,10 +14,24 @@ module UserTrackingConcern
def set_user_activity
return unless user_needs_sign_in_update?
# Mark as signed-in today
current_user.update_tracked_fields!(request)
# Regenerate feed if needed
regenerate_feed! if user_needs_feed_update?
end
def user_needs_sign_in_update?
user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_HOURS.hours.ago)
end
def user_needs_feed_update?
current_user.last_sign_in_at < REGENERATE_FEED_DAYS.days.ago
end
def regenerate_feed!
Redis.current.setnx("account:#{current_user.account_id}:regeneration", true) == 1 && Redis.current.expire("account:#{current_user.account_id}:regeneration", 3_600 * 24)
RegenerationWorker.perform_async(current_user.account_id)
end
end

View File

@@ -2,16 +2,14 @@
class EmojisController < ApplicationController
before_action :set_emoji
before_action :set_cache_headers
def show
respond_to do |format|
format.json do
skip_session!
render_cached_json(['activitypub', 'emoji', @emoji.cache_key], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter)
end
render json: @emoji,
serializer: ActivityPub::EmojiSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
end
end

View File

@@ -7,6 +7,7 @@ class HomeController < ApplicationController
def index
@body_classes = 'app-body'
redirect_to "/$#{current_flavour}/#{params[:glob] || ''}" unless Themes.instance.flavours.include?(params[:use_flavour].to_s) or request.path.start_with?("/$#{current_flavour}")
end
private
@@ -58,7 +59,7 @@ class HomeController < ApplicationController
end
def default_redirect_path
if request.path.start_with?('/web')
if request.path.start_with?('/web') || request.path.match?(/\A\$[\w-]+/)
new_user_session_path
elsif single_user_mode?
short_account_path(Account.first)

View File

@@ -5,7 +5,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :set_pack
include Localized
@@ -14,8 +13,4 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
def store_current_location
store_location_for(:user, request.url)
end
def set_pack
use_pack 'auth'
end
end

View File

@@ -5,7 +5,6 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :set_pack
include Localized
@@ -14,8 +13,4 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
def store_current_location
store_location_for(:user, request.url)
end
def set_pack
use_pack 'settings'
end
end

View File

@@ -43,8 +43,4 @@ class RemoteFollowController < ApplicationController
def suspended_account?
@account.suspended?
end
def set_body_classes
@body_classes = 'modal-layout'
end
end

View File

@@ -1,32 +0,0 @@
# frozen_string_literal: true
class Settings::FlavoursController < Settings::BaseController
def index
redirect_to action: 'show', flavour: current_flavour
end
def show
unless Themes.instance.flavours.include?(params[:flavour]) || (params[:flavour] == current_flavour)
redirect_to action: 'show', flavour: current_flavour
end
@listing = Themes.instance.flavours
@selected = params[:flavour]
end
def update
user_settings.update(user_settings_params)
redirect_to action: 'show', flavour: params[:flavour]
end
private
def user_settings
UserSettingsDecorator.new(current_user)
end
def user_settings_params
{ setting_flavour: params.require(:flavour),
setting_skin: params.dig(:user, :setting_skin) }.with_indifferent_access
end
end

View File

@@ -28,7 +28,6 @@ class Settings::MigrationsController < ApplicationController
end
def migration_account_changed?
current_account.moved_to_account_id != @migration.account&.id &&
current_account.id != @migration.account&.id
current_account.moved_to_account_id != @migration.account&.id
end
end

View File

@@ -39,6 +39,8 @@ class Settings::PreferencesController < Settings::BaseController
:setting_reduce_motion,
:setting_system_font_ui,
:setting_noindex,
:setting_flavour,
:setting_skin,
notification_emails: %i(follow follow_request reblog favourite mention digest),
interactions: %i(must_be_follower must_be_following)
)

View File

@@ -2,8 +2,10 @@
module Settings
module TwoFactorAuthentication
class ConfirmationsController < BaseController
before_action :ensure_otp_secret
class ConfirmationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
def new
prepare_two_factor_form
@@ -36,10 +38,6 @@ module Settings
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Rails.configuration.x.local_domain)
@qrcode = RQRCode::QRCode.new(@provision_url)
end
def ensure_otp_secret
redirect_to settings_two_factor_authentication_path unless current_user.otp_secret
end
end
end
end

View File

@@ -2,7 +2,11 @@
module Settings
module TwoFactorAuthentication
class RecoveryCodesController < BaseController
class RecoveryCodesController < ApplicationController
layout 'admin'
before_action :authenticate_user!
def create
@recovery_codes = current_user.generate_otp_backup_codes!
current_user.save!

View File

@@ -15,14 +15,13 @@ class SharesController < ApplicationController
private
def initial_state_params
text = [params[:title], params[:text], params[:url]].compact.join(' ')
{
settings: Web::Setting.find_by(user: current_user)&.data || {},
push_subscription: current_account.user.web_push_subscription(current_session),
current_account: current_account,
token: current_session.token,
admin: Account.find_local(Setting.site_contact_username),
text: text,
text: params[:text],
}
end
@@ -31,6 +30,6 @@ class SharesController < ApplicationController
end
def set_body_classes
@body_classes = 'modal-layout compose-standalone'
@body_classes = 'compose-standalone'
end
end

View File

@@ -10,7 +10,6 @@ class StatusesController < ApplicationController
before_action :set_link_headers
before_action :check_account_suspension
before_action :redirect_to_original, only: [:show]
before_action :set_cache_headers
def show
respond_to do |format|
@@ -23,21 +22,19 @@ class StatusesController < ApplicationController
end
format.json do
skip_session! unless @stream_entry.hidden?
render_cached_json(['activitypub', 'note', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
end
render json: @status,
serializer: ActivityPub::NoteSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
end
end
def activity
skip_session!
render_cached_json(['activitypub', 'activity', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
end
render json: @status,
serializer: ActivityPub::ActivitySerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
def embed

View File

@@ -1,19 +1,15 @@
# frozen_string_literal: true
module WellKnown
class HostMetaController < ActionController::Base
class HostMetaController < ApplicationController
include RoutingHelper
before_action { response.headers['Vary'] = 'Accept' }
def show
@webfinger_template = "#{webfinger_url}?resource={uri}"
respond_to do |format|
format.xml { render content_type: 'application/xrd+xml' }
end
expires_in(3.days, public: true)
end
end
end

View File

@@ -1,11 +1,9 @@
# frozen_string_literal: true
module WellKnown
class WebfingerController < ActionController::Base
class WebfingerController < ApplicationController
include RoutingHelper
before_action { response.headers['Vary'] = 'Accept' }
def show
@account = Account.find_local!(username_from_resource)
@@ -18,8 +16,6 @@ module WellKnown
render content_type: 'application/xrd+xml'
end
end
expires_in(3.days, public: true)
rescue ActiveRecord::RecordNotFound
head 404
end

View File

@@ -34,7 +34,7 @@ module Admin::ActionLogsHelper
link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status'
tmp_status = Status.new(attributes)
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status)
link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status)
end
end

View File

@@ -1,12 +1,11 @@
# frozen_string_literal: true
module Admin::FilterHelper
ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip staff).freeze
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip).freeze
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
new_url = filtered_url_for(link_to_params)

View File

@@ -6,6 +6,6 @@ module InstanceHelper
end
def site_hostname
@site_hostname ||= Addressable::URI.parse("//#{Rails.configuration.x.local_domain}").display_uri.host
Rails.configuration.x.local_domain
end
end

View File

@@ -39,10 +39,6 @@ module JsonLdHelper
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
end
def unsupported_uri_scheme?(uri)
!uri.start_with?('http://', 'https://')
end
def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
graph.dump(:normalize)

View File

@@ -1,4 +0,0 @@
# frozen_string_literal: true
module MailerHelper
end

View File

@@ -4,7 +4,6 @@ module RoutingHelper
extend ActiveSupport::Concern
include Rails.application.routes.url_helpers
include ActionView::Helpers::AssetTagHelper
include Webpacker::Helper
included do
def default_url_options
@@ -18,10 +17,6 @@ module RoutingHelper
URI.join(root_url, source).to_s
end
def full_pack_url(source, **options)
full_asset_url(asset_pack_path(source, options))
end
private
def use_storage?

View File

@@ -10,13 +10,11 @@ module SettingsHelper
eo: 'Esperanto',
es: 'Español',
fa: 'فارسی',
gl: 'Galego',
fi: 'Suomi',
fr: 'Français',
he: 'עברית',
hr: 'Hrvatski',
hu: 'Magyar',
hy: 'Հայերեն',
id: 'Bahasa Indonesia',
io: 'Ido',
it: 'Italiano',
@@ -29,9 +27,6 @@ module SettingsHelper
pt: 'Português',
'pt-BR': 'Português do Brasil',
ru: 'Русский',
sk: 'Slovensky',
sr: 'Српски',
'sr-Latn': 'Srpski (latinica)',
sv: 'Svenska',
th: 'ภาษาไทย',
tr: 'Türkçe',

View File

@@ -30,13 +30,13 @@ delegate(document, batchCheckboxClassName, 'change', () => {
});
delegate(document, '.media-spoiler-show-button', 'click', () => {
[].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
element.click();
[].forEach.call(document.querySelectorAll('.activity-stream .media-spoiler-wrapper'), (content) => {
content.classList.add('media-spoiler-wrapper__visible');
});
});
delegate(document, '.media-spoiler-hide-button', 'click', () => {
[].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
element.click();
[].forEach.call(document.querySelectorAll('.activity-stream .media-spoiler-wrapper'), (content) => {
content.classList.remove('media-spoiler-wrapper__visible');
});
});

View File

@@ -1 +0,0 @@
import 'styles/mailer.scss';

View File

@@ -37,3 +37,7 @@ delegate(document, '#account_header', 'change', ({ target }) => {
header.style.backgroundImage = `url(${url})`;
});
delegate(document, '#user_setting_flavour, #user_setting_skin', 'change', ({ target }) => {
target.form.submit();
});

View File

@@ -10,9 +10,6 @@ pack:
embed: embed.js
error:
home:
mailer:
filename: mailer.js
stylesheet: true
modal:
public: public.js
settings: settings.js

View File

@@ -12,7 +12,6 @@ import {
} from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_CYCLE_ELEFRIEND = 'COMPOSE_CYCLE_ELEFRIEND';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
@@ -39,6 +38,7 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE';
export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
@@ -55,12 +55,6 @@ export function changeCompose(text) {
};
};
export function cycleElefriendCompose() {
return {
type: COMPOSE_CYCLE_ELEFRIEND,
};
};
export function replyCompose(status, router) {
return (dispatch, getState) => {
dispatch({
@@ -68,7 +62,7 @@ export function replyCompose(status, router) {
status: status,
});
if (router && !getState().getIn(['compose', 'mounted'])) {
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
};
@@ -125,11 +119,6 @@ export function submitCompose() {
}).then(function (response) {
dispatch(submitComposeSuccess({ ...response.data }));
// If the response has no data then we can't do anything else.
if (!response.data) {
return;
}
// To make the app more responsive, immediately get the status into the columns
const insertOrRefresh = (timelineId, refreshAction) => {
@@ -327,14 +316,21 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
export function selectComposeSuggestion(position, token, suggestion) {
return (dispatch, getState) => {
const completion = typeof suggestion === 'object' && suggestion.id ? (
dispatch(useEmoji(suggestion)),
suggestion.native || suggestion.colons
) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
let completion, startPosition;
if (typeof suggestion === 'object' && suggestion.id) {
completion = suggestion.native || suggestion.colons;
startPosition = position - 1;
dispatch(useEmoji(suggestion));
} else {
completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position;
}
dispatch({
type: COMPOSE_SUGGESTION_SELECT,
position,
position: startPosition,
token,
completion,
});
@@ -353,11 +349,10 @@ export function unmountCompose() {
};
};
export function changeComposeAdvancedOption(option, value) {
export function toggleComposeAdvancedOption(option) {
return {
option,
type: COMPOSE_ADVANCED_OPTIONS_CHANGE,
value,
option: option,
};
}
@@ -394,3 +389,10 @@ export function insertEmojiCompose(position, emoji) {
emoji,
};
};
export function changeComposing(value) {
return {
type: COMPOSE_COMPOSING_CHANGE,
value,
};
}

View File

@@ -10,10 +10,6 @@ export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FA
export function fetchFavouritedStatuses() {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
return;
}
dispatch(fetchFavouritedStatusesRequest());
api(getState).get('/api/v1/favourites').then(response => {
@@ -50,7 +46,7 @@ export function expandFavouritedStatuses() {
return (dispatch, getState) => {
const url = getState().getIn(['status_lists', 'favourites', 'next'], null);
if (url === null || getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
if (url === null) {
return;
}

View File

@@ -42,7 +42,7 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
const unescapeHTML = (html) => {
const wrapper = document.createElement('div');
html = html.replace(/<br \/>|<br>|\n/g, ' ');
html = html.replace(/<br \/>|<br>|\n/, ' ');
wrapper.innerHTML = html;
return wrapper.textContent;
};

View File

@@ -1,7 +1,9 @@
import axios from 'axios';
export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS';
export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
export function setBrowserSupport (value) {
return {
@@ -23,12 +25,28 @@ export function clearSubscription () {
};
}
export function setAlerts (path, value) {
export function changeAlerts(key, value) {
return dispatch => {
dispatch({
type: SET_ALERTS,
path,
type: ALERTS_CHANGE,
key,
value,
});
dispatch(saveSettings());
};
}
export function saveSettings() {
return (_, getState) => {
const state = getState().get('push_notifications');
const subscription = state.get('subscription');
const alerts = state.get('alerts');
axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
data: {
alerts,
},
});
};
}

View File

@@ -1,23 +0,0 @@
import {
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
setAlerts,
} from './setter';
import { register, saveSettings } from './registerer';
export {
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
register,
};
export function changeAlerts(path, value) {
return dispatch => {
dispatch(setAlerts(path, value));
dispatch(saveSettings());
};
}

View File

@@ -1,149 +0,0 @@
import api from 'flavours/glitch/util/api';
import { pushNotificationsSetting } from 'flavours/glitch/util/settings';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
// Taken from https://www.npmjs.com/package/web-push
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};
const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
const getRegistration = () => navigator.serviceWorker.ready;
const getPushSubscription = (registration) =>
registration.pushManager.getSubscription()
.then(subscription => ({ registration, subscription }));
const subscribe = (registration) =>
registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
});
const unsubscribe = ({ registration, subscription }) =>
subscription ? subscription.unsubscribe().then(() => registration) : registration;
const sendSubscriptionToBackend = (getState, subscription, me) => {
const params = { subscription };
if (me) {
const data = pushNotificationsSetting.get(me);
if (data) {
params.data = data;
}
}
return api(getState).post('/api/web/push_subscriptions', params).then(response => response.data);
};
// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
export function register () {
return (dispatch, getState) => {
dispatch(setBrowserSupport(supportsPushNotifications));
const me = getState().getIn(['meta', 'me']);
if (me && !pushNotificationsSetting.get(me)) {
const alerts = getState().getIn(['push_notifications', 'alerts']);
if (alerts) {
pushNotificationsSetting.set(me, { alerts: alerts });
}
}
if (supportsPushNotifications) {
if (!getApplicationServerKey()) {
console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
return;
}
getRegistration()
.then(getPushSubscription)
.then(({ registration, subscription }) => {
if (subscription !== null) {
// We have a subscription, check if it is still valid
const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']);
// If the VAPID public key did not change and the endpoint corresponds
// to the endpoint saved in the backend, the subscription is valid
if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
return subscription;
} else {
// Something went wrong, try to subscribe again
return unsubscribe({ registration, subscription }).then(subscribe).then(
subscription => sendSubscriptionToBackend(getState, subscription, me));
}
}
// No subscription, try to subscribe
return subscribe(registration).then(
subscription => sendSubscriptionToBackend(getState, subscription, me));
})
.then(subscription => {
// If we got a PushSubscription (and not a subscription object from the backend)
// it means that the backend subscription is valid (and was set during hydration)
if (!(subscription instanceof PushSubscription)) {
dispatch(setSubscription(subscription));
if (me) {
pushNotificationsSetting.set(me, { alerts: subscription.alerts });
}
}
})
.catch(error => {
if (error.code === 20 && error.name === 'AbortError') {
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
} else if (error.code === 5 && error.name === 'InvalidCharacterError') {
console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
}
// Clear alerts and hide UI settings
dispatch(clearSubscription());
if (me) {
pushNotificationsSetting.remove(me);
}
try {
getRegistration()
.then(getPushSubscription)
.then(unsubscribe);
} catch (e) {
}
});
} else {
console.warn('Your browser does not support Web Push Notifications.');
}
};
}
export function saveSettings() {
return (_, getState) => {
const state = getState().get('push_notifications');
const subscription = state.get('subscription');
const alerts = state.get('alerts');
const data = { alerts };
api(getState).put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
data,
}).then(() => {
const me = getState().getIn(['meta', 'me']);
if (me) {
pushNotificationsSetting.set(me, data);
}
});
};
}

View File

@@ -1,14 +1,14 @@
import api from 'flavours/glitch/util/api';
import axios from 'axios';
import { debounce } from 'lodash';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';
export function changeSetting(path, value) {
export function changeSetting(key, value) {
return dispatch => {
dispatch({
type: SETTING_CHANGE,
path,
key,
value,
});
@@ -21,9 +21,9 @@ const debouncedSave = debounce((dispatch, getState) => {
return;
}
const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
api(getState).put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
}, 5000, { trailing: true });
export function saveSettings() {

View File

@@ -19,14 +19,13 @@ export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next, partial) {
export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
return {
type: TIMELINE_REFRESH_SUCCESS,
timeline,
statuses,
skipLoading,
next,
partial,
};
};
@@ -89,7 +88,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
return function (dispatch, getState) {
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
if (timeline.get('isLoading') || (timeline.get('online') && !timeline.get('isPartial'))) {
if (timeline.get('isLoading') || timeline.get('online')) {
return;
}
@@ -105,12 +104,8 @@ export function refreshTimeline(timelineId, path, params = {}) {
dispatch(refreshTimelineRequest(timelineId, skipLoading));
api(getState).get(path, { params }).then(response => {
if (response.status === 206) {
dispatch(refreshTimelineSuccess(timelineId, [], skipLoading, null, true));
} else {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null, false));
}
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(refreshTimelineSuccess(timelineId, response.data, skipLoading, next ? next.uri : null));
}).catch(error => {
dispatch(refreshTimelineFail(timelineId, error, skipLoading));
});

View File

@@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
@@ -27,10 +27,8 @@ export default class Account extends ImmutablePureComponent {
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hidden: PropTypes.bool,
small: PropTypes.bool,
};
handleFollow = () => {
@@ -54,12 +52,7 @@ export default class Account extends ImmutablePureComponent {
}
render () {
const {
account,
hidden,
intl,
small,
} = this.props;
const { account, intl, hidden } = this.props;
if (!account) {
return <div />;
@@ -76,7 +69,7 @@ export default class Account extends ImmutablePureComponent {
let buttons;
if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
@@ -94,45 +87,27 @@ export default class Account extends ImmutablePureComponent {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />;
}
buttons = (
<Fragment>
<div>
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />
{hidingNotificationsButton}
</Fragment>
</div>
);
} else if (!account.get('moved')) {
} else {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
}
}
return small ? (
<Permalink
className='account small'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
>
<div className='account__avatar-wrapper'>
<Avatar
account={account}
size={24}
/>
</div>
<DisplayName
account={account}
inline
/>
</Permalink>
) : (
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>
{buttons ?
<div className='account__relationship'>
{buttons}
</div>
: null}
<div className='account__relationship'>
{buttons}
</div>
</div>
</div>
);

View File

@@ -21,9 +21,9 @@ export default class AttachmentList extends ImmutablePureComponent {
<ul className='attachment-list__list'>
{media.map(attachment =>
(<li key={attachment.get('id')}>
<li key={attachment.get('id')}>
<a href={attachment.get('remote_url')} target='_blank' rel='noopener'>{filename(attachment.get('remote_url'))}</a>
</li>)
</li>
)}
</ul>
</div>

View File

@@ -0,0 +1,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || '';
export default class AutosuggestEmoji extends React.PureComponent {
static propTypes = {
emoji: PropTypes.object.isRequired,
};
render () {
const { emoji } = this.props;
let url;
if (emoji.custom) {
url = emoji.imageUrl;
} else {
const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
if (!mapping) {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
}
return (
<div className='autosuggest-emoji'>
<img
className='emojione'
src={url}
alt={emoji.native || emoji.colons}
/>
{emoji.colons}
</div>
);
}
}

View File

@@ -0,0 +1,223 @@
import React from 'react';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { isRtl } from 'flavours/glitch/util/rtl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import classNames from 'classnames';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
let left = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
let right = str.slice(caretPosition).search(/[\s\u200B]/);
if (right < 0) {
word = str.slice(left);
} else {
word = str.slice(left, right + caretPosition);
}
if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
return [null, null];
}
word = word.trim().toLowerCase();
if (word.length > 0) {
return [left + 1, word];
} else {
return [null, null];
}
};
export default class AutosuggestTextarea extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
onSuggestionSelected: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onPaste: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
};
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange = (e) => {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
}
onKeyDown = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
switch(e.key) {
case 'Escape':
if (!suggestionsHidden) {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
}
onKeyUp = e => {
if (e.key === 'Escape' && this.state.suggestionsHidden) {
document.querySelector('.ui').parentElement.focus();
}
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
onBlur = () => {
this.setState({ suggestionsHidden: true });
}
onSuggestionClick = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea.focus();
}
componentWillReceiveProps (nextProps) {
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea = (c) => {
this.textarea = c;
}
onPaste = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
}
renderSuggestion = (suggestion, i) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else {
inner = <AutosuggestAccountContainer id={suggestion} />;
key = suggestion;
}
return (
<div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
{inner}
</div>
);
}
render () {
const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return (
<div className='autosuggest-textarea'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
inputRef={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style}
aria-autocomplete='list'
/>
</label>
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
{suggestions.map(this.renderSuggestion)}
</div>
</div>
);
}
}

View File

@@ -1,22 +1,19 @@
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
export default class Avatar extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
className: PropTypes.string,
size: PropTypes.number.isRequired,
style: PropTypes.object,
inline: PropTypes.bool,
animate: PropTypes.bool,
inline: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
animate: false,
size: 20,
inline: false,
};
@@ -36,19 +33,17 @@ export default class Avatar extends React.PureComponent {
}
render () {
const {
account,
animate,
className,
inline,
size,
} = this.props;
const { account, size, animate, inline } = this.props;
const { hovering } = this.state;
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
let className = 'account__avatar';
if (inline) {
className = className + ' account__avatar-inline';
}
const style = {
...this.props.style,
@@ -65,7 +60,7 @@ export default class Avatar extends React.PureComponent {
return (
<div
className={computedClass}
className={className}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
style={style}

View File

@@ -1,29 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
};
static defaultProps = {
animate: autoPlayGif,
};
render() {
const { account, friend, animate } = this.props;
const { account, friend } = this.props;
const baseStyle = {
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
backgroundImage: `url(${account.get('avatar_static')})`,
};
const overlayStyle = {
backgroundImage: `url(${friend.get(animate ? 'avatar' : 'avatar_static')})`,
backgroundImage: `url(${friend.get('avatar_static')})`,
};
return (

View File

@@ -6,9 +6,9 @@ import PropTypes from 'prop-types';
const Collapsable = ({ fullHeight, isVisible, children }) => (
<Motion defaultStyle={{ opacity: !isVisible ? 0 : 100, height: isVisible ? fullHeight : 0 }} style={{ opacity: spring(!isVisible ? 0 : 100), height: spring(!isVisible ? 0 : fullHeight) }}>
{({ opacity, height }) =>
(<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
<div style={{ height: `${height}px`, overflow: 'hidden', opacity: opacity / 100, display: Math.floor(opacity) === 0 ? 'none' : 'block' }}>
{children}
</div>)
</div>
}
</Motion>
);

View File

@@ -1,30 +1,20 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
// The component.
export default function DisplayName ({
account,
className,
inline,
}) {
const computedClass = classNames('display-name', { inline }, className);
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const displayNameHtml = { __html: this.props.account.get('display_name_html') };
return (
<span className='display-name'>
<strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
</span>
);
}
// The result.
return account ? (
<span className={computedClass}>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
{inline ? ' ' : null}
<span className='display-name__account'>@{account.get('acct')}</span>
</span>
) : null;
}
// Props.
DisplayName.propTypes = {
account: ImmutablePropTypes.map,
className: PropTypes.string,
inline: PropTypes.bool,
};

View File

@@ -133,13 +133,8 @@ export default class Dropdown extends React.PureComponent {
this.props.onModalOpen({
status,
actions: items.map(
(item, i) => item ? {
...item,
name: `${item.text}-${i}`,
onClick: this.handleItemClick.bind(this, i),
} : null
),
actions: items,
onClick: this.handleItemClick,
});
return;
@@ -167,7 +162,8 @@ export default class Dropdown extends React.PureComponent {
}
}
handleItemClick = (i, e) => {
handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
this.handleClose();

View File

@@ -1,26 +0,0 @@
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// This just renders a FontAwesome icon.
export default function Icon ({
className,
fullwidth,
icon,
}) {
const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
return icon ? (
<span
aria-hidden='true'
className={computedClass}
/>
) : null;
}
// Props.
Icon.propTypes = {
className: PropTypes.string,
fullwidth: PropTypes.bool,
icon: PropTypes.string,
};

View File

@@ -116,7 +116,7 @@ export default class IconButton extends React.PureComponent {
return (
<Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
{({ rotate }) =>
(<button
<button
aria-label={title}
aria-pressed={pressed}
aria-expanded={expanded}
@@ -128,7 +128,7 @@ export default class IconButton extends React.PureComponent {
>
<i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
{this.props.label}
</button>)
</button>
}
</Motion>
);

View File

@@ -1,97 +0,0 @@
// Inspired by <CommonLink> from Mastodon GO!
// ~ 😘 kibi!
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Utils.
import { assignHandlers } from 'flavours/glitch/util/react_helpers';
// Handlers.
const handlers = {
// We don't handle clicks that are made with modifiers, since these
// often have special browser meanings (eg, "open in new tab").
click (e) {
const { onClick } = this.props;
if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
onClick(e);
e.preventDefault(); // Prevents following of the link
},
};
// The component.
export default class Link extends React.PureComponent {
// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
}
// Rendering.
render () {
const { click } = this.handlers;
const {
children,
className,
href,
onClick,
role,
title,
...rest
} = this.props;
const computedClass = classNames('link', className, `role-${role}`);
// We assume that our `onClick` is a routing function and give it
// the qualities of a link even if no `href` is provided. However,
// if we have neither an `onClick` or an `href`, our link is
// purely presentational.
const conditionalProps = {};
if (href) {
conditionalProps.href = href;
conditionalProps.onClick = click;
} else if (onClick) {
conditionalProps.onClick = click;
conditionalProps.role = 'link';
conditionalProps.tabIndex = 0;
} else {
conditionalProps.role = 'presentation';
}
// If we were provided a `role` it overwrites any that we may have
// set above. This can be used for "links" which are actually
// buttons.
if (role) {
conditionalProps.role = role;
}
// Rendering. We set `rel='noopener'` for user privacy, and our
// `target` as `'_blank'`.
return (
<a
className={computedClass}
{...conditionalProps}
rel='noopener'
target='_blank'
title={title}
{...rest}
>{children}</a>
);
}
}
// Props.
Link.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
href: PropTypes.string, // The link destination
onClick: PropTypes.func, // A function to call instead of opening the link
role: PropTypes.string, // An ARIA role for the link
title: PropTypes.string, // A title for the link
};

View File

@@ -9,26 +9,7 @@ import classNames from 'classnames';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
const messages = defineMessages({
hidden: {
defaultMessage: 'Media hidden',
id: 'status.media_hidden',
},
sensitive: {
defaultMessage: 'Sensitive',
id: 'media_gallery.sensitive',
},
toggle: {
defaultMessage: 'Click to view',
id: 'status.sensitive_toggle',
},
toggle_visible: {
defaultMessage: 'Toggle visibility',
id: 'media_gallery.toggle_visible',
},
warning: {
defaultMessage: 'Sensitive content',
id: 'status.sensitive_warning',
},
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
class Item extends React.PureComponent {
@@ -225,79 +206,48 @@ export default class MediaGallery extends React.PureComponent {
this.props.onOpenMedia(this.props.media, index);
}
isStandaloneEligible() {
const { media, standalone } = this.props;
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
}
render () {
const {
handleClick,
handleOpen,
} = this;
const {
fullwidth,
intl,
letterbox,
media,
sensitive,
standalone,
} = this.props;
const { media, intl, sensitive, letterbox, fullwidth } = this.props;
const { visible } = this.state;
const size = media.take(4).size;
const computedClass = classNames('media-gallery', `size-${size}`, { 'full-width': fullwidth });
let children;
if (!visible) {
let warning;
if (sensitive) {
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
} else {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
children = (
<button className='media-spoiler' onClick={this.handleOpen}>
<span className='media-spoiler__warning'>{warning}</span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</button>
);
} else {
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
} else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} />);
}
}
return (
<div className={computedClass}>
{visible ? (
<div className='sensitive-info'>
<IconButton
icon='eye'
onClick={handleOpen}
overlay
title={intl.formatMessage(messages.toggle_visible)}
/>
{sensitive ? (
<span className='sensitive-marker'>
<FormattedMessage {...messages.sensitive} />
</span>
) : null}
</div>
) : null}
{function () {
switch (true) {
case !visible:
return (
<button
className='media-spoiler'
onClick={handleOpen}
>
<span className='media-spoiler__warning'>
<FormattedMessage {...(sensitive ? messages.warning : messages.hidden)} />
</span>
<span className='media-spoiler__trigger'>
<FormattedMessage {...messages.toggle} />
</span>
</button>
);
case standalone && media.size === 1 && !!media.getIn([0, 'meta', 'small', 'aspect']):
return (
<Item
attachment={media.get(0)}
onClick={handleClick}
standalone
/>
);
default:
return media.take(4).map(
(attachment, i) => (
<Item
attachment={attachment}
index={i}
key={attachment.get('id')}
letterbox={letterbox}
onClick={handleClick}
size={size}
/>
)
);
}
}()}
<div className={`media-gallery size-${size} ${fullwidth ? 'full-width' : ''}`}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div>
{children}
</div>
);
}

View File

@@ -2,14 +2,9 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
const MissingIndicator = () => (
<div className='regeneration-indicator missing-indicator'>
<div className='missing-indicator'>
<div>
<div className='regeneration-indicator__figure' />
<div className='regeneration-indicator__label'>
<FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' />
<FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' />
</div>
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
</div>
</div>
);

View File

@@ -22,13 +22,7 @@ export default class Permalink extends React.PureComponent {
}
render () {
const {
children,
className,
href,
to,
...other
} = this.props;
const { href, children, className, ...other } = this.props;
return (
<a target='_blank' href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>

View File

@@ -6,24 +6,24 @@ export default class SettingText extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
settingPath: PropTypes.array.isRequired,
settingKey: PropTypes.array.isRequired,
label: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
handleChange = (e) => {
this.props.onChange(this.props.settingPath, e.target.value);
this.props.onChange(this.props.settingKey, e.target.value);
}
render () {
const { settings, settingPath, label } = this.props;
const { settings, settingKey, label } = this.props;
return (
<label>
<span style={{ display: 'none' }}>{label}</span>
<input
className='setting-text'
value={settings.getIn(settingPath)}
value={settings.getIn(settingKey)}
onChange={this.handleChange}
placeholder={label}
/>

View File

@@ -47,6 +47,7 @@ export default class Status extends ImmutablePureComponent {
state = {
isExpanded: null,
markedForDelete: false,
}
// Avoid checking props that are functions (and whose equality will always
@@ -66,6 +67,7 @@ export default class Status extends ImmutablePureComponent {
updateOnStates = [
'isExpanded',
'markedForDelete',
]
// If our settings have changed to disable collapsed statuses, then we
@@ -119,15 +121,15 @@ export default class Status extends ImmutablePureComponent {
if (function () {
switch (true) {
case !!collapse:
case !!autoCollapseSettings.get('all'):
case autoCollapseSettings.get('notifications') && !!muted:
case collapse:
case autoCollapseSettings.get('all'):
case autoCollapseSettings.get('notifications') && muted:
case autoCollapseSettings.get('lengthy') && node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
):
case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size:
return true;
default:
return false;
@@ -316,14 +318,14 @@ export default class Status extends ImmutablePureComponent {
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (<Component
{Component => <Component
preview={video.get('preview_url')}
src={video.get('url')}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
onOpenVideo={this.handleOpenVideo}
/>)}
/>}
</Bundle>
);
mediaIcon = 'video-camera';
@@ -380,6 +382,7 @@ export default class Status extends ImmutablePureComponent {
const computedClass = classNames('status', `status-${status.get('visibility')}`, {
collapsed: isExpanded === false,
'has-background': isExpanded === false && background,
'marked-for-delete': this.state.markedForDelete,
muted,
}, 'focusable');

View File

@@ -104,8 +104,8 @@ export default class StatusHeader extends React.PureComponent {
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
onClick={this.handleCollapsedClick}

View File

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import StatusContainer from 'flavours/glitch/containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ScrollableList from './scrollable_list';
import { FormattedMessage } from 'react-intl';
export default class StatusList extends ImmutablePureComponent {
@@ -17,7 +16,6 @@ export default class StatusList extends ImmutablePureComponent {
trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
isPartial: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
@@ -50,23 +48,8 @@ export default class StatusList extends ImmutablePureComponent {
}
render () {
const { statusIds, ...other } = this.props;
const { isLoading, isPartial } = other;
if (isPartial) {
return (
<div className='regeneration-indicator'>
<div>
<div className='regeneration-indicator__figure' />
<div className='regeneration-indicator__label'>
<FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading&hellip;' />
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
</div>
);
}
const { statusIds, ...other } = this.props;
const { isLoading } = other;
const scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId) => (

View File

@@ -57,7 +57,7 @@ export default class Mastodon extends React.PureComponent {
return (
<IntlProvider locale={locale} messages={messages}>
<Provider store={store}>
<BrowserRouter basename='/web'>
<BrowserRouter basename='/$glitch'>
<ScrollContext>
<Route path='/' component={UI} />
</ScrollContext>

View File

@@ -63,15 +63,7 @@ export default class Header extends ImmutablePureComponent {
<div className='account__header__wrapper'>
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div>
<a
href={account.get('url')}
className='account__header__avatar'
role='presentation'
target='_blank'
rel='noopener'
>
<Avatar account={account} size={90} />
</a>
<Avatar account={account} size={90} />
<span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
<span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>

View File

@@ -95,10 +95,10 @@ export default class AccountGallery extends ImmutablePureComponent {
<div className='account-gallery__container'>
{medias.map(media =>
(<MediaItem
<MediaItem
key={media.get('id')}
media={media}
/>)
/>
)}
{loadMore}
</div>

View File

@@ -26,7 +26,7 @@ export default class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
);

View File

@@ -8,8 +8,8 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
onChange (path, checked) {
dispatch(changeSetting(['community', ...path], checked));
onChange (key, checked) {
dispatch(changeSetting(['community', ...key], checked));
},
});

View File

@@ -0,0 +1,62 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports.
import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
import ComposeDropdown from './dropdown';
const messages = defineMessages({
local_only_short :
{ id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
local_only_long :
{ id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
advanced_options_icon_title :
{ id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
});
@injectIntl
export default class ComposeAdvancedOptions extends React.PureComponent {
static propTypes = {
values : ImmutablePropTypes.contains({
do_not_federate : PropTypes.bool.isRequired,
}).isRequired,
onChange : PropTypes.func.isRequired,
intl : PropTypes.object.isRequired,
};
render () {
const { intl, values } = this.props;
const options = [
{ icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
];
const anyEnabled = values.some((enabled) => enabled);
const optionElems = options.map((option) => {
return (
<ComposeAdvancedOptionsToggle
onChange={this.props.onChange}
active={values.get(option.name)}
key={option.name}
name={option.name}
shortText={intl.formatMessage(option.shortText)}
longText={intl.formatMessage(option.longText)}
/>
);
});
return (
<ComposeDropdown
title={intl.formatMessage(messages.advanced_options_icon_title)}
icon='home'
highlight={anyEnabled}
>
{optionElems}
</ComposeDropdown>
);
}
}

View File

@@ -0,0 +1,35 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import Toggle from 'react-toggle';
export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
static propTypes = {
onChange: PropTypes.func.isRequired,
active: PropTypes.bool.isRequired,
name: PropTypes.string.isRequired,
shortText: PropTypes.string.isRequired,
longText: PropTypes.string.isRequired,
}
onToggle = () => {
this.props.onChange(this.props.name);
}
render() {
const { active, shortText, longText } = this.props;
return (
<div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
<div className='advanced-options-dropdown__option__toggle'>
<Toggle checked={active} onChange={this.onToggle} />
</div>
<div className='advanced-options-dropdown__option__content'>
<strong>{shortText}</strong>
{longText}
</div>
</div>
);
}
}

View File

@@ -0,0 +1,131 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, defineMessages } from 'react-intl';
// Our imports //
import ComposeDropdown from './dropdown';
import { uploadCompose } from 'flavours/glitch/actions/compose';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { openModal } from 'flavours/glitch/actions/modal';
const messages = defineMessages({
upload :
{ id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
doodle :
{ id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
attach :
{ id: 'compose.attach', defaultMessage: 'Attach...' },
});
const mapStateToProps = state => ({
// This horrible expression is copied from vanilla upload_button_container
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
});
const mapDispatchToProps = dispatch => ({
onSelectFile (files) {
dispatch(uploadCompose(files));
},
onOpenDoodle () {
dispatch(openModal('DOODLE', { noEsc: true }));
},
});
@injectIntl
@connect(mapStateToProps, mapDispatchToProps)
export default class ComposeAttachOptions extends ImmutablePureComponent {
static propTypes = {
intl : PropTypes.object.isRequired,
resetFileKey: PropTypes.number,
acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
disabled: PropTypes.bool,
onSelectFile: PropTypes.func.isRequired,
onOpenDoodle: PropTypes.func.isRequired,
};
handleItemClick = bt => {
if (bt === 'upload') {
this.fileElement.click();
}
if (bt === 'doodle') {
this.props.onOpenDoodle();
}
this.dropdown.setState({ open: false });
};
handleFileChange = (e) => {
if (e.target.files.length > 0) {
this.props.onSelectFile(e.target.files);
}
}
setFileRef = (c) => {
this.fileElement = c;
}
setDropdownRef = (c) => {
this.dropdown = c;
}
render () {
const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
const options = [
{ icon: 'cloud-upload', text: messages.upload, name: 'upload' },
{ icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
];
const optionElems = options.map((item) => {
const hdl = () => this.handleItemClick(item.name);
return (
<div
role='button'
tabIndex='0'
key={item.name}
onClick={hdl}
className='privacy-dropdown__option'
>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{intl.formatMessage(item.text)}</strong>
</div>
</div>
);
});
return (
<div>
<ComposeDropdown
title={intl.formatMessage(messages.attach)}
icon='paperclip'
disabled={disabled}
ref={this.setDropdownRef}
>
{optionElems}
</ComposeDropdown>
<input
key={resetFileKey}
ref={this.setFileRef}
type='file'
multiple={false}
accept={acceptContentTypes.toArray().join(',')}
onChange={this.handleFileChange}
disabled={disabled}
style={{ display: 'none' }}
/>
</div>
);
}
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class AutosuggestAccount extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
};
render () {
const { account } = this.props;
return (
<div className='autosuggest-account'>
<div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
<DisplayName account={account} />
</div>
);
}
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import PropTypes from 'prop-types';
import { length } from 'stringz';
export default class CharacterCounter extends React.PureComponent {
static propTypes = {
text: PropTypes.string.isRequired,
max: PropTypes.number.isRequired,
};
checkRemainingText (diff) {
if (diff < 0) {
return <span className='character-counter character-counter--over'>{diff}</span>;
}
return <span className='character-counter'>{diff}</span>;
}
render () {
const diff = this.props.max - length(this.props.text);
return this.checkRemainingText(diff);
}
}

View File

@@ -0,0 +1,286 @@
import React from 'react';
import CharacterCounter from './character_counter';
import Button from 'flavours/glitch/components/button';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from 'flavours/glitch/components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container';
import WarningContainer from '../containers/warning_container';
import { isMobile } from 'flavours/glitch/util/is_mobile';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { length } from 'stringz';
import { countableText } from 'flavours/glitch/util/counter';
import ComposeAttachOptions from './attach_options';
import initialState from 'flavours/glitch/util/initial_state';
const maxChars = initialState.max_toot_chars;
const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
});
@injectIntl
export default class ComposeForm extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string.isRequired,
suggestion_token: PropTypes.string,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
advanced_options: ImmutablePropTypes.contains({
do_not_federate: PropTypes.bool,
}),
spoiler_text: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
preselectDate: PropTypes.instanceOf(Date),
is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onPrivacyChange: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
settings : ImmutablePropTypes.map.isRequired,
};
static defaultProps = {
showSearch: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
handleSubmit2 = () => {
this.props.onPrivacyChange(this.props.settings.get('side_arm'));
this.handleSubmit();
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
this.props.onSubmit();
}
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this._restoreCaret = null;
this.props.onSuggestionSelected(tokenStart, token, value);
}
handleChangeSpoilerText = (e) => {
this.props.onChangeSpoilerText(e.target.value);
}
componentWillReceiveProps (nextProps) {
// If this is the update where we've finished uploading,
// save the last caret position so we can restore it below!
if (!nextProps.is_uploading && this.props.is_uploading) {
this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
}
}
componentDidUpdate (prevProps) {
// This statement does several things:
// - If we're beginning a reply, and,
// - Replying to zero or one users, places the cursor at the end of the textbox.
// - Replying to more than one user, selects any usernames past the first;
// this provides a convenient shortcut to drop everyone else from the conversation.
// - If we've just finished uploading an image, and have a saved caret position,
// restores the cursor to that position after the text changes!
if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
let selectionEnd, selectionStart;
if (this.props.preselectDate !== prevProps.preselectDate) {
selectionEnd = this.props.text.length;
selectionStart = this.props.text.search(/\s/) + 1;
} else if (typeof this._restoreCaret === 'number') {
selectionStart = this._restoreCaret;
selectionEnd = this._restoreCaret;
} else {
selectionEnd = this.props.text.length;
selectionStart = selectionEnd;
}
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus();
} else if(prevProps.is_submitting && !this.props.is_submitting) {
this.autosuggestTextarea.textarea.focus();
}
}
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
}
handleEmojiPick = (data) => {
const position = this.autosuggestTextarea.textarea.selectionStart;
const emojiChar = data.native;
this._restoreCaret = position + emojiChar.length + 1;
this.props.onPickEmoji(position, data);
}
render () {
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.is_submitting;
const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
const text = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
const secondaryVisibility = this.props.settings.get('side_arm');
let showSideArm = secondaryVisibility !== 'none';
let publishText = '';
let publishText2 = '';
let title = '';
let title2 = '';
const privacyIcons = {
none: '',
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
};
title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
if (showSideArm) {
// Enhanced behavior with dual toot buttons
publishText = (
<span>
{
<i
className={`fa fa-${privacyIcons[this.props.privacy]}`}
style={{ paddingRight: '5px' }}
/>
}{intl.formatMessage(messages.publish)}
</span>
);
title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
publishText2 = (
<i
className={`fa fa-${privacyIcons[secondaryVisibility]}`}
aria-label={title2}
/>
);
} else {
// Original vanilla behavior - no icon if public or unlisted
if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
}
const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
return (
<div className='compose-form'>
<Collapsable isVisible={this.props.spoiler} fullHeight={50}>
<div className='spoiler-input'>
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
<input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input' id='cw-spoiler-input' />
</label>
</div>
</Collapsable>
<WarningContainer />
<ReplyIndicatorContainer />
<div className='compose-form__autosuggest-wrapper'>
<AutosuggestTextarea
ref={this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={!showSearch && !isMobile(window.innerWidth)}
/>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
</div>
<div className='compose-form__modifiers'>
<UploadFormContainer />
</div>
<div className='compose-form__buttons'>
<ComposeAttachOptions />
<SensitiveButtonContainer />
<div className='compose-form__buttons-separator' />
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<ComposeAdvancedOptionsContainer />
</div>
<div className='compose-form__publish'>
<div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
<div className='compose-form__publish-button-wrapper'>
{
showSideArm ?
<Button
className='compose-form__publish__side-arm'
text={publishText2}
title={title2}
onClick={this.handleSubmit2}
disabled={submitDisabled}
/> : ''
}
<Button
className='compose-form__publish__primary'
text={publishText}
title={title}
onClick={this.handleSubmit}
disabled={submitDisabled}
/>
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,77 @@
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
// Our imports.
import IconButton from 'flavours/glitch/components/icon_button';
const iconStyle = {
height : null,
lineHeight : '27px',
};
export default class ComposeDropdown extends React.PureComponent {
static propTypes = {
title: PropTypes.string.isRequired,
icon: PropTypes.string,
highlight: PropTypes.bool,
disabled: PropTypes.bool,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
state = {
open: false,
};
onGlobalClick = (e) => {
if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
this.setState({ open: false });
}
};
componentDidMount () {
window.addEventListener('click', this.onGlobalClick);
window.addEventListener('touchstart', this.onGlobalClick);
}
componentWillUnmount () {
window.removeEventListener('click', this.onGlobalClick);
window.removeEventListener('touchstart', this.onGlobalClick);
}
onToggleDropdown = () => {
if (this.props.disabled) return;
this.setState({ open: !this.state.open });
};
setRef = (c) => {
this.node = c;
};
render () {
const { open } = this.state;
let { highlight, title, icon, disabled } = this.props;
if (!icon) icon = 'ellipsis-h';
return (
<div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${highlight ? 'active' : ''} `}>
<div className='advanced-options-dropdown__value'>
<IconButton
className={'inverted'}
title={title}
icon={icon} active={open || highlight}
size={18}
style={iconStyle}
disabled={disabled}
onClick={this.onToggleDropdown}
/>
</div>
<div className='advanced-options-dropdown__dropdown'>
{this.props.children}
</div>
</div>
);
}
}

View File

@@ -1,8 +1,3 @@
import { connect } from 'react-redux';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable';
import { useEmoji } from 'flavours/glitch/actions/emojis';
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
@@ -30,80 +25,6 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort ) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
skinTone: state.getIn(['settings', 'skinTone']),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
},
});
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously
@@ -356,7 +277,6 @@ class EmojiPickerMenu extends React.PureComponent {
}
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class EmojiPickerDropdown extends React.PureComponent {

View File

@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from 'flavours/glitch/components/avatar';
import IconButton from 'flavours/glitch/components/icon_button';
import Permalink from 'flavours/glitch/components/permalink';
import { FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
export default class NavigationBar extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
};
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={40} />
</Permalink>
<div className='navigation-bar__profile'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
</Permalink>
<a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
</div>
<IconButton title='' icon='close' onClick={this.props.onClose} />
</div>
);
}
}

View File

@@ -0,0 +1,200 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button';
import Overlay from 'react-overlays/lib/Overlay';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import classNames from 'classnames';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
});
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
class PrivacyDropdownMenu extends React.PureComponent {
static propTypes = {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
handleClick = e => {
if (e.key === 'Escape') {
this.props.onClose();
} else if (!e.key || e.key === 'Enter') {
const value = e.currentTarget.getAttribute('data-index');
e.preventDefault();
this.props.onClose();
this.props.onChange(value);
}
}
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount () {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render () {
const { style, items, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
{items.map(item =>
<div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
<div className='privacy-dropdown__option__icon'>
<i className={`fa fa-fw fa-${item.icon}`} />
</div>
<div className='privacy-dropdown__option__content'>
<strong>{item.text}</strong>
{item.meta}
</div>
</div>
)}
</div>
)}
</Motion>
);
}
}
@injectIntl
export default class PrivacyDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
open: false,
};
handleToggle = () => {
if (this.props.isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
this.props.onModalOpen({
actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
onClick: this.handleModalActionClick,
});
}
} else {
this.setState({ open: !this.state.open });
}
}
handleModalActionClick = (e) => {
e.preventDefault();
const { value } = this.options[e.currentTarget.getAttribute('data-index')];
this.props.onModalClose();
this.props.onChange(value);
}
handleKeyDown = e => {
switch(e.key) {
case 'Enter':
this.handleToggle();
break;
case 'Escape':
this.handleClose();
break;
}
}
handleClose = () => {
this.setState({ open: false });
}
handleChange = value => {
this.props.onChange(value);
}
componentWillMount () {
const { intl: { formatMessage } } = this.props;
this.options = [
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
];
}
render () {
const { value, intl } = this.props;
const { open } = this.state;
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<IconButton
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
title={intl.formatMessage(messages.change_privacy)}
size={18}
expanded={open}
active={open}
inverted
onClick={this.handleToggle}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
<Overlay show={open} placement='bottom' target={this}>
<PrivacyDropdownMenu
items={this.options}
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
/>
</Overlay>
</div>
);
}
}

View File

@@ -0,0 +1,63 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from 'flavours/glitch/components/avatar';
import IconButton from 'flavours/glitch/components/icon_button';
import DisplayName from 'flavours/glitch/components/display_name';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
});
@injectIntl
export default class ReplyIndicator extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
onCancel: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleClick = () => {
this.props.onCancel();
}
handleAccountClick = (e) => {
if (e.button === 0) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
}
render () {
const { status, intl } = this.props;
if (!status) {
return null;
}
const content = { __html: status.get('contentHtml') };
return (
<div className='reply-indicator'>
<div className='reply-indicator__header'>
<div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
<div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
</div>
);
}
}

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