Compare commits

..

1 Commits

Author SHA1 Message Date
kibigo!
866e441df3 [WIP] Initial status work 2017-08-14 15:07:22 -07:00
527 changed files with 7168 additions and 12982 deletions

View File

@@ -26,7 +26,7 @@ LOCAL_HTTPS=true
# ALTERNATE_DOMAINS=example1.com,example2.com
# Application secrets
# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose)
PAPERCLIP_SECRET=
SECRET_KEY_BASE=
OTP_SECRET=
@@ -36,7 +36,7 @@ OTP_SECRET=
# You should only generate this once per instance. If you later decide to change it, all push subscription will
# be invalidated, requiring the users to access the website again to resubscribe.
#
# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose)
#
# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html
VAPID_PRIVATE_KEY=
@@ -98,15 +98,6 @@ SMTP_FROM_ADDRESS=notifications@example.com
# S3_ENDPOINT=
# S3_SIGNATURE_VERSION=
# Swift (optional)
# SWIFT_ENABLED=true
# SWIFT_USERNAME=
# SWIFT_TENANT=
# SWIFT_PASSWORD=
# SWIFT_AUTH_URL=
# SWIFT_CONTAINER=
# SWIFT_OBJECT_URL=
# Optional alias for S3 if you want to use Cloudfront or Cloudflare in front
# S3_CLOUDFRONT_HOST=

View File

@@ -49,7 +49,6 @@ rules:
- warn
- allow:
- error
- warn
no-fallthrough: error
no-irregular-whitespace: error
no-mixed-spaces-and-tabs: warn

View File

@@ -10,7 +10,6 @@ AllCops:
- 'node_modules/**/*'
- 'Vagrantfile'
- 'vendor/**/*'
- 'lib/json_ld/*'
Bundler/OrderedGems:
Enabled: false

View File

@@ -1,15 +0,0 @@
# CODEOWNERS for tootsuite/mastodon
# Translators
# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address.
# /app/javascript/mastodon/locales/fr.json @żelipapą
# /app/views/user_mailer/*.fr.html.erb @żelipapą
# /app/views/user_mailer/*.fr.text.erb @żelipapą
# /config/locales/*.fr.yml @żelipapą
# /config/locales/fr.yml @żelipapą
/app/javascript/mastodon/locales/pl.json @m4sk1n
/app/views/user_mailer/*.pl.html.erb @m4sk1n
/app/views/user_mailer/*.pl.text.erb @m4sk1n
/config/locales/*.pl.yml @m4sk1n
/config/locales/pl.yml @m4sk1n

View File

@@ -1,4 +1,4 @@
FROM ruby:2.4.1-alpine3.6
FROM ruby:2.4.1-alpine
LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server"
@@ -14,7 +14,9 @@ EXPOSE 3000 4000
WORKDIR /mastodon
RUN apk -U upgrade \
RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \
&& echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \
&& apk -U upgrade \
&& apk add -t build-dependencies \
build-base \
icu-dev \
@@ -29,15 +31,15 @@ RUN apk -U upgrade \
file \
git \
icu-libs \
imagemagick \
imagemagick@edge \
libidn \
libpq \
nodejs-npm \
nodejs \
nodejs-npm@edge \
nodejs@edge \
protobuf \
su-exec \
tini \
yarn \
yarn@edge \
&& update-ca-certificates \
&& wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \
&& echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \

View File

@@ -15,7 +15,6 @@ gem 'pghero', '~> 1.7'
gem 'dotenv-rails', '~> 2.2'
gem 'aws-sdk', '~> 2.9'
gem 'fog-openstack', '~> 0.1'
gem 'paperclip', '~> 5.1'
gem 'paperclip-av-transcoder', '~> 0.6'
@@ -23,8 +22,7 @@ gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.5'
gem 'bootsnap'
gem 'browser'
gem 'charlock_holmes', '~> 0.7.5'
gem 'iso-639'
gem 'charlock_holmes', '~> 0.7.3'
gem 'cld3', '~> 3.1'
gem 'devise', '~> 4.2'
gem 'devise-two-factor', '~> 3.0'
@@ -70,9 +68,6 @@ gem 'tzinfo-data', '~> 1.2017'
gem 'webpacker', '~> 2.0'
gem 'webpush'
gem 'json-ld-preloaded', '~> 2.2.1'
gem 'rdf-normalize', '~> 0.3.1'
group :development, :test do
gem 'fabrication', '~> 2.16'
gem 'fuubar', '~> 2.2'

View File

@@ -44,8 +44,8 @@ GEM
i18n (~> 0.7)
minitest (~> 5.1)
tzinfo (~> 1.1)
addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0)
addressable (2.5.1)
public_suffix (~> 2.0, >= 2.0.2)
airbrussh (1.3.0)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.2)
@@ -74,13 +74,13 @@ GEM
debug_inspector (>= 0.0.1)
bootsnap (1.1.2)
msgpack (~> 1.0)
brakeman (3.7.2)
brakeman (3.6.2)
browser (2.4.0)
builder (3.2.3)
bullet (5.5.1)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.10.0)
bundler-audit (0.6.0)
bundler-audit (0.5.0)
bundler (~> 1.2)
thor (~> 0.18)
capistrano (3.8.2)
@@ -108,7 +108,7 @@ GEM
xpath (~> 2.0)
case_transform (0.2)
activesupport
charlock_holmes (0.7.5)
charlock_holmes (0.7.3)
chunky_png (1.3.8)
cld3 (3.1.3)
ffi (>= 1.1.0, < 1.10.0)
@@ -154,25 +154,12 @@ GEM
erubis (2.7.0)
et-orbi (1.0.5)
tzinfo
excon (0.58.0)
execjs (2.7.0)
fabrication (2.16.2)
faker (1.7.3)
i18n (~> 0.5)
fast_blank (1.0.0)
ffi (1.9.18)
fog-core (1.45.0)
builder
excon (~> 0.58)
formatador (~> 0.2)
fog-json (1.0.2)
fog-core (~> 1.0)
multi_json (~> 1.10)
fog-openstack (0.1.21)
fog-core (>= 1.40)
fog-json (>= 1.0)
ipaddress (>= 0.8)
formatador (0.2.5)
fuubar (2.2.0)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
@@ -192,8 +179,6 @@ GEM
activesupport (>= 4.0.1)
hamlit (>= 1.2.0)
railties (>= 4.0.1)
hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (0.3.5)
highline (1.7.8)
hiredis (0.6.1)
@@ -224,17 +209,8 @@ GEM
rainbow (~> 2.2)
terminal-table (>= 1.5.1)
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.5)
multi_json (~> 1.12)
rdf (~> 2.2)
json-ld-preloaded (2.2.1)
json-ld (~> 2.1, >= 2.1.5)
multi_json (~> 1.11)
rdf (~> 2.2)
jsonapi-renderer (0.1.3)
jwt (1.5.6)
kaminari (1.0.1)
@@ -322,7 +298,7 @@ GEM
slop (~> 3.4)
pry-rails (0.3.6)
pry (>= 0.10.4)
public_suffix (3.0.0)
public_suffix (2.0.5)
puma (3.9.1)
pundit (1.1.0)
activesupport (>= 3.0.0)
@@ -372,11 +348,6 @@ GEM
rainbow (2.2.2)
rake
rake (12.0.0)
rdf (2.2.8)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.3.2)
rdf (~> 2.0)
redis (3.3.3)
redis-actionpack (5.0.1)
actionpack (>= 4.0, < 6)
@@ -483,7 +454,7 @@ GEM
temple (0.8.0)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
thor (0.20.0)
thor (0.19.4)
thread (0.2.2)
thread_safe (0.3.6)
tilt (2.0.8)
@@ -540,7 +511,7 @@ DEPENDENCIES
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 2.14)
charlock_holmes (~> 0.7.5)
charlock_holmes (~> 0.7.3)
cld3 (~> 3.1)
climate_control (~> 0.2)
devise (~> 4.2)
@@ -550,7 +521,6 @@ DEPENDENCIES
fabrication (~> 2.16)
faker (~> 1.7)
fast_blank (~> 1.0)
fog-openstack (~> 0.1)
fuubar (~> 2.2)
goldfinger (~> 2.0)
hamlit-rails (~> 0.2)
@@ -561,8 +531,6 @@ DEPENDENCIES
httplog (~> 0.99)
i18n-tasks (~> 0.9)
idn-ruby
iso-639
json-ld-preloaded (~> 2.2.1)
kaminari (~> 1.0)
letter_opener (~> 1.4)
letter_opener_web (~> 1.3)
@@ -592,7 +560,6 @@ DEPENDENCIES
rails-controller-testing (~> 1.0)
rails-i18n (~> 5.0)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.3.1)
redis (~> 3.3)
redis-namespace (~> 1.5)
redis-rails (~> 5.0)
@@ -623,4 +590,4 @@ RUBY VERSION
ruby 2.4.1p111
BUNDLED WITH
1.15.4
1.15.3

View File

@@ -7,78 +7,24 @@ class AccountsController < ApplicationController
def show
respond_to do |format|
format.html do
@pinned_statuses = []
if current_account && @account.blocking?(current_account)
@statuses = []
return
end
@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
@statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
@next_url = next_url unless @statuses.empty?
end
format.atom do
@entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id])
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? }))
render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a))
end
format.json do
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
end
end
end
private
def show_pinned_statuses?
[replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none?
end
def filtered_statuses
default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if media_requested?
statuses.merge!(no_replies_scope) unless replies_requested?
end
end
def default_statuses
@account.statuses.where(visibility: [:public, :unlisted])
end
def only_media_scope
Status.where(id: account_media_status_ids)
end
def account_media_status_ids
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct
end
def no_replies_scope
Status.without_replies
end
def set_account
@account = Account.find_local!(params[:username])
end
def next_url
if media_requested?
short_account_media_url(@account, max_id: @statuses.last.id)
elsif replies_requested?
short_account_with_replies_url(@account, max_id: @statuses.last.id)
else
short_account_url(@account, max_id: @statuses.last.id)
end
end
def media_requested?
request.path.ends_with?('/media')
end
def replies_requested?
request.path.ends_with?('/with_replies')
end
end

View File

@@ -1,40 +0,0 @@
# frozen_string_literal: true
class ActivityPub::InboxesController < Api::BaseController
include SignatureVerification
before_action :set_account
def create
if signed_request_account
upgrade_account
process_payload
head 201
else
head 202
end
end
private
def set_account
@account = Account.find_local!(params[:account_username]) if params[:account_username]
end
def body
@body ||= request.body.read
end
def upgrade_account
if signed_request_account.ostatus?
signed_request_account.update(last_webfingered_at: nil)
ResolveRemoteAccountWorker.perform_async(signed_request_account.acct)
end
Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?
end
def process_payload
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'))
end
end

View File

@@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController
@statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id])
@statuses = cache_collection(@statuses, Status)
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
private

View File

@@ -17,7 +17,7 @@ module Admin
end
def unsubscribe
Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id)
UnsubscribeService.new.call(@account)
redirect_to admin_account_path(@account.id)
end

View File

@@ -13,7 +13,6 @@ module Admin
closed_registrations_message
open_deletion
timeline_preview
bootstrap_timeline_accounts
).freeze
BOOLEAN_SETTINGS = %w(

View File

@@ -9,7 +9,7 @@ module Admin
before_action :set_account
before_action :set_status, only: [:update, :destroy]
PER_PAGE = 20
PAR_PAGE = 20
def index
@statuses = @account.statuses
@@ -17,7 +17,7 @@ module Admin
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
@statuses.merge!(Status.where(id: account_media_status_ids))
end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE)
@form = Form::StatusBatch.new
end

View File

@@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController
links = []
links << [next_path, [%w(rel next)]] if next_path
links << [prev_path, [%w(rel prev)]] if prev_path
response.headers['Link'] = LinkHeader.new(links) unless links.empty?
response.headers['Link'] = LinkHeader.new(links)
end
def limit_param(default_limit)
@@ -62,12 +62,11 @@ class Api::BaseController < ApplicationController
end
def require_user!
if current_user
current_resource_owner
set_user_activity
else
rescue ActiveRecord::RecordNotFound
render json: { error: 'This method requires an authenticated user' }, status: 422
end
end
def render_empty
render json: {}, status: 200

View File

@@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController
respond_to :json
def show
@status = status_finder.status
render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
@stream_entry = find_stream_entry.stream_entry
render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
end
private
def status_finder
StatusFinder.new(params[:url])
def find_stream_entry
StreamEntryFinder.new(params[:url])
end
def maxwidth_or_default

View File

@@ -1,7 +1,6 @@
# frozen_string_literal: true
class Api::V1::Accounts::CredentialsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read }, except: [:update]
before_action -> { doorkeeper_authorize! :write }, only: [:update]
before_action :require_user!
@@ -11,9 +10,8 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
end
def update
current_account.update!(account_params)
@account = current_account
UpdateAccountService.new.call(@account, account_params, raise_error: true)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
render json: @account, serializer: REST::CredentialAccountSerializer
end

View File

@@ -29,7 +29,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def account_statuses
default_statuses.tap do |statuses|
statuses.merge!(only_media_scope) if params[:only_media]
statuses.merge!(pinned_scope) if params[:pinned]
statuses.merge!(no_replies_scope) if params[:exclude_replies]
end
end
@@ -54,10 +53,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct
end
def pinned_scope
@account.pinned_statuses
end
def no_replies_scope
Status.without_replies
end

View File

@@ -14,10 +14,7 @@ class Api::V1::AccountsController < Api::BaseController
def follow
FollowService.new.call(current_user.account, @account.acct)
options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
def block
@@ -26,7 +23,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def mute
MuteService.new.call(current_user.account, @account, notifications: params[:notifications])
MuteService.new.call(current_user.account, @account)
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end
@@ -51,7 +48,7 @@ class Api::V1::AccountsController < Api::BaseController
@account = Account.find(params[:id])
end
def relationships(options = {})
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options)
def relationships
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
end
end

View File

@@ -1,8 +0,0 @@
# frozen_string_literal: true
require 'mastodon/extension'
class Api::V1::ExtensionsController < Api::BaseController
def index
render json: Mastodon::Extension.all
end
end

View File

@@ -10,12 +10,6 @@ class Api::V1::FollowsController < Api::BaseController
raise ActiveRecord::RecordNotFound if follow_params[:uri].blank?
@account = FollowService.new.call(current_user.account, target_uri).try(:target_account)
if @account.nil?
username, domain = target_uri.split('@')
@account = Account.find_remote!(username, domain)
end
render json: @account, serializer: REST::AccountSerializer
end

View File

@@ -8,15 +8,10 @@ class Api::V1::MutesController < Api::BaseController
respond_to :json
def index
@data = @accounts = load_accounts
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
def details
@data = @mutes = load_mutes
render json: @mutes, each_serializer: REST::MuteSerializer
end
private
def load_accounts
@@ -27,10 +22,6 @@ class Api::V1::MutesController < Api::BaseController
Account.includes(:muted_by).references(:muted_by)
end
def load_mutes
paginated_mutes.includes(:account, :target_account).to_a
end
def paginated_mutes
Mute.where(account: current_account).paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
@@ -45,34 +36,26 @@ class Api::V1::MutesController < Api::BaseController
def next_path
if records_continue?
url_for pagination_params(max_id: pagination_max_id)
api_v1_mutes_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
unless@data.empty?
url_for pagination_params(since_id: pagination_since_id)
unless @accounts.empty?
api_v1_mutes_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
if params[:action] == "details"
@mutes.last.id
else
@accounts.last.muted_by_ids.last
end
end
def pagination_since_id
if params[:action] == "details"
@mutes.first.id
else
@accounts.first.muted_by_ids.first
end
end
def records_continue?
@data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)

View File

@@ -1,28 +0,0 @@
# frozen_string_literal: true
class Api::V1::Statuses::PinsController < Api::BaseController
include Authorization
before_action -> { doorkeeper_authorize! :write }
before_action :require_user!
before_action :set_status
respond_to :json
def create
StatusPin.create!(account: current_account, status: @status)
render json: @status, serializer: REST::StatusSerializer
end
def destroy
pin = StatusPin.find_by(account: current_account, status: @status)
pin&.destroy!
render json: @status, serializer: REST::StatusSerializer
end
private
def set_status
@status = Status.find(params[:status_id])
end
end

View File

@@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController
end
def card
@card = @status.preview_cards.first
@card = PreviewCard.find_by(status: @status)
if @card.nil?
render_empty

View File

@@ -1,17 +0,0 @@
# frozen_string_literal: true
class Api::Web::EmbedsController < Api::BaseController
respond_to :json
before_action :require_user!
def create
status = StatusFinder.new(params[:url]).status
render json: status, serializer: OEmbedSerializer, width: 400
rescue ActiveRecord::RecordNotFound
oembed = OEmbed::Providers.get(params[:url])
render json: Oj.dump(oembed.fields)
rescue OEmbed::NotFound
render json: {}, status: :not_found
end
end

View File

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

View File

@@ -23,7 +23,6 @@ module AccountControllerConcern
[
webfinger_account_link,
atom_account_url_link,
actor_url_link,
]
)
end
@@ -42,13 +41,6 @@ module AccountControllerConcern
]
end
def actor_url_link
[
ActivityPub::TagManager.instance.uri_for(@account),
[%w(rel alternate), %w(type application/activity+json)],
]
end
def webfinger_account_url
webfinger_url(resource: @account.to_webfinger_s)
end

View File

@@ -31,7 +31,7 @@ module SignatureVerification
return
end
account = account_from_key_id(signature_params['keyId'])
account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, ''))
if account.nil?
@signed_request_account = nil
@@ -49,10 +49,6 @@ module SignatureVerification
end
end
def request_body
@request_body ||= request.raw_post
end
private
def build_signed_string(signed_headers)
@@ -61,8 +57,6 @@ module SignatureVerification
signed_headers.split(' ').map do |signed_header|
if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
elsif signed_header == 'digest'
"digest: #{body_digest}"
else
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
end
@@ -79,10 +73,6 @@ module SignatureVerification
(Time.now.utc - time_sent).abs <= 30
end
def body_digest
"SHA-256=#{Digest::SHA256.base64digest(request_body)}"
end
def to_header_name(name)
name.split(/-/).map(&:capitalize).join('-')
end
@@ -91,16 +81,7 @@ module SignatureVerification
signature_params['keyId'].blank? ||
signature_params['signature'].blank? ||
signature_params['algorithm'].blank? ||
signature_params['algorithm'] != 'rsa-sha256'
end
def account_from_key_id(key_id)
if key_id.start_with?('acct:')
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)
account
end
signature_params['algorithm'] != 'rsa-sha256' ||
!signature_params['keyId'].start_with?('acct:')
end
end

View File

@@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end

View File

@@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end

View File

@@ -1,18 +0,0 @@
# frozen_string_literal: true
class IntentsController < ApplicationController
def show
uri = Addressable::URI.parse(params[:uri])
if uri.scheme == 'web+mastodon'
case uri.host
when 'follow'
return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
when 'share'
return redirect_to share_path(text: uri.query_values['text'])
end
end
not_found
end
end

View File

@@ -1,72 +0,0 @@
# frozen_string_literal: true
class Settings::ApplicationsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
before_action :set_application, only: [:show, :update, :destroy, :regenerate]
before_action :prepare_scopes, only: [:create, :update]
def index
@applications = current_user.applications.page(params[:page])
end
def new
@application = Doorkeeper::Application.new(
redirect_uri: Doorkeeper.configuration.native_redirect_uri,
scopes: 'read write follow'
)
end
def show; end
def create
@application = current_user.applications.build(application_params)
if @application.save
redirect_to settings_applications_path, notice: I18n.t('applications.created')
else
render :new
end
end
def update
if @application.update(application_params)
redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
def destroy
@application.destroy
redirect_to settings_applications_path, notice: I18n.t('applications.destroyed')
end
def regenerate
@access_token = current_user.token_for_app(@application)
@access_token.destroy
redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated')
end
private
def set_application
@application = current_user.applications.find(params[:id])
end
def application_params
params.require(:doorkeeper_application).permit(
:name,
:redirect_uri,
:scopes,
:website
)
end
def prepare_scopes
scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array
end
end

View File

@@ -14,8 +14,7 @@ class Settings::ProfilesController < ApplicationController
def show; end
def update
if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
if @account.update(account_params)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show

View File

@@ -1,30 +0,0 @@
# frozen_string_literal: true
class SharesController < ApplicationController
layout 'modal'
before_action :authenticate_user!
before_action :set_body_classes
def show
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json
end
private
def initial_state_params
{
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: params[:text],
}
end
def set_body_classes
@body_classes = 'compose-standalone'
end
end

View File

@@ -9,7 +9,6 @@ class StatusesController < ApplicationController
before_action :set_status
before_action :set_link_headers
before_action :check_account_suspension
before_action :redirect_to_original, only: [:show]
def show
respond_to do |format|
@@ -21,18 +20,13 @@ class StatusesController < ApplicationController
end
format.json do
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter
end
end
end
def activity
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
def embed
response.headers['X-Frame-Options'] = 'ALLOWALL'
render 'stream_entries/embed', layout: 'embedded'
render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
end
private
@@ -42,12 +36,7 @@ class StatusesController < ApplicationController
end
def set_link_headers
response.headers['Link'] = LinkHeader.new(
[
[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
[ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]],
]
)
response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
end
def set_status
@@ -64,8 +53,4 @@ class StatusesController < ApplicationController
def check_account_suspension
gone if @account.suspended?
end
def redirect_to_original
redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog?
end
end

View File

@@ -25,7 +25,10 @@ class StreamEntriesController < ApplicationController
end
def embed
redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301
response.headers['X-Frame-Options'] = 'ALLOWALL'
return gone if @stream_entry.activity.nil?
render layout: 'embedded'
end
private
@@ -35,12 +38,7 @@ class StreamEntriesController < ApplicationController
end
def set_link_headers
response.headers['Link'] = LinkHeader.new(
[
[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]],
[ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]],
]
)
response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]])
end
def set_stream_entry

View File

@@ -12,7 +12,7 @@ class TagsController < ApplicationController
format.html
format.json do
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter
end
end
end

View File

@@ -5,10 +5,6 @@ module ApplicationHelper
current_page?(path) ? 'active' : ''
end
def active_link_to(label, path, options = {})
link_to label, path, options.merge(class: active_nav_class(path))
end
def show_landing_strip?
!user_signed_in? && !single_user_mode?
end

View File

@@ -1,52 +0,0 @@
# frozen_string_literal: true
module JsonLdHelper
def equals_or_includes?(haystack, needle)
haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
end
def first_of_value(value)
value.is_a?(Array) ? value.first : value
end
def value_or_id(value)
value.is_a?(String) || value.nil? ? value : value['id']
end
def supported_context?(json)
!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)
end
def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
graph.dump(:normalize)
end
def fetch_resource(uri)
response = build_request(uri).perform
return if response.code != 200
body_to_json(response.to_s)
end
def body_to_json(body)
body.is_a?(String) ? Oj.load(body, mode: :strict) : body
rescue Oj::ParseError
nil
end
def merge_context(context, new_context)
if context.is_a?(Array)
context << new_context
else
[context, new_context]
end
end
private
def build_request(uri)
request = Request.new(:get, uri)
request.add_headers('Accept' => 'application/activity+json, application/ld+json')
request
end
end

View File

@@ -12,14 +12,6 @@ module RoutingHelper
end
def full_asset_url(source, options = {})
source = ActionController::Base.helpers.asset_url(source, options) unless use_storage?
URI.join(root_url, source).to_s
end
private
def use_storage?
Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift
Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s
end
end

View File

@@ -30,7 +30,6 @@ module SettingsHelper
th: 'ภาษาไทย',
tr: 'Türkçe',
uk: 'Українська',
zh: '中文',
'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)',
@@ -40,10 +39,6 @@ module SettingsHelper
HUMAN_LOCALES[locale]
end
def filterable_languages
I18n.available_locales.map { |locale| locale.to_s.split('-').first.to_sym }.uniq
end
def hash_to_object(hash)
HashObject.new(hash)
end

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
module StreamEntriesHelper
EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_CONTROLLER = 'stream_entries'
EMBEDDED_ACTION = 'embed'
def display_name(account)

View File

@@ -0,0 +1,113 @@
// <CommonAvatar>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/avatar
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonAvatar extends React.PureComponent {
// Props and state.
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
circular: PropTypes.bool,
className: PropTypes.string,
comrade: ImmutablePropTypes.map,
}
state = {
hovering: false,
}
// Starts or stops animation on hover.
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
}
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
}
// Renders the component.
render () {
const {
handleMouseEnter,
handleMouseLeave,
} = this;
const {
account,
animate,
circular,
className,
comrade,
...others
} = this.props;
const { hovering } = this.state;
const computedClass = classNames('glitch', 'glitch__common__avatar', {
_circular: circular,
}, className);
// We store the image srcs here for later.
const src = account.get('avatar');
const staticSrc = account.get('avatar_static');
const comradeSrc = comrade ? comrade.get('avatar') : null;
const comradeStaticSrc = comrade ? comrade.get('avatar_static') : null;
// Avatars are a straightforward div with image(s) inside.
return comrade ? (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
<img
className='avatar\main'
src={hovering || animate ? src : staticSrc}
alt=''
/>
<img
className='avatar\comrade'
src={hovering || animate ? comradeSrc : comradeStaticSrc}
alt=''
/>
</div>
) : (
<div
className={computedClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...others}
>
<img
className='avatar\solo'
src={hovering || animate ? src : staticSrc}
alt=''
/>
</div>
);
}
}

View File

@@ -0,0 +1,41 @@
@import 'variables';
.glitch.glitch__common__avatar {
display: inline-block;
position: relative;
& > img {
display: block;
position: static;
margin: 0;
border-radius: $ui-avatar-border-size;
width: 100%;
height: 100%;
&.avatar\\comrade {
position: absolute;
right: 0;
bottom: 0;
width: 50%;
height: 50%;
}
&.avatar\\main {
margin: 0 30% 30% 0;
width: 70%;
height: 70%;
}
}
&._circular {
& > img {
transition: border-radius ($glitch-animation-speed * .3s);
}
&:not(:hover) {
& > img {
border-radius: 50%;
}
}
}
}

View File

@@ -0,0 +1,146 @@
// <CommonButton>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/button
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Our imports.
import CommonLink from 'glitch/components/common/link';
import CommonIcon from 'glitch/components/common/icon';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonButton extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
animate: PropTypes.bool,
children: PropTypes.node,
className: PropTypes.string,
disabled: PropTypes.bool,
href: PropTypes.string,
icon: PropTypes.string,
onClick: PropTypes.func,
showTitle: PropTypes.bool,
title: PropTypes.string,
}
state = {
loaded: false,
}
// The `loaded` state property activates our animations. We wait
// until an activation change in order to prevent unsightly
// animations when the component first mounts.
componentWillReceiveProps (nextProps) {
const { active } = this.props;
// The double "not"s here cast both arguments to booleans.
if (!nextProps.active !== !active) this.setState({ loaded: true });
}
handleClick = (e) => {
const { onClick } = this.props;
if (!onClick) return;
onClick(e);
e.preventDefault();
}
// Rendering the component.
render () {
const { handleClick } = this;
const {
active,
animate,
children,
className,
disabled,
href,
icon,
onClick,
showTitle,
title,
...others
} = this.props;
const { loaded } = this.state;
const computedClass = classNames('glitch', 'glitch__common__button', className, {
_active: active && !href, // Links can't be active
_animated: animate && loaded,
_disabled: disabled,
_link: href,
_star: icon === 'star',
'_with-text': children || title && showTitle,
});
let conditionalProps = {};
// If href is provided, we render a link.
if (href) {
if (!disabled && href) conditionalProps.href = href;
if (title && !showTitle) {
if (!children) conditionalProps.title = title;
else conditionalProps['aria-label'] = title;
}
if (onClick) {
if (!disabled) conditionalProps.onClick = handleClick;
else conditionalProps['aria-disabled'] = true;
conditionalProps.role = 'button';
conditionalProps.tabIndex = 0;
}
return (
<CommonLink
className={computedClass}
{...conditionalProps}
{...others}
>
{children}
{title && showTitle ? <span className='button\title'>{title}</span> : null}
<CommonIcon name={icon} className='button\icon' />
</CommonLink>
);
// Otherwise, we render a button.
} else {
if (active !== void 0) conditionalProps['aria-pressed'] = active;
if (title && !showTitle) {
if (!children) conditionalProps.title = title;
else conditionalProps['aria-label'] = title;
}
if (onClick && !disabled) {
conditionalProps.onClick = handleClick;
}
return (
<button
className={computedClass}
{...conditionalProps}
disabled={disabled}
{...others}
tabIndex='0'
type='button'
>
{children}
{title && showTitle ? <span className='button\title'>{title}</span> : null}
<CommonIcon name={icon} className='button\icon' />
</button>
);
}
};
}

View File

@@ -0,0 +1,134 @@
@import 'variables';
.glitch.glitch__common__button {
display: inline-block;
border: none;
padding: 0;
color: $ui-base-lighter-color;
background: transparent;
outline: thin transparent dotted;
font-size: inherit;
text-decoration: none;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
&._animated .button\\icon {
animation-name: glitch__common__button__deactivate;
animation-duration: .9s;
animation-timing-function: ease-in-out;
@keyframes glitch__common__button__deactivate {
from {
transform: rotate(360deg);
}
57% {
transform: rotate(-60deg);
}
86% {
transform: rotate(30deg);
}
to {
transform: rotate(0deg);
}
}
}
&._active {
.button\\icon {
color: $ui-highlight-color;
}
&._animated .button\\icon {
animation-name: glitch__common__button__activate;
@keyframes glitch__common__button__activate {
from {
transform: rotate(0deg);
}
57% {
transform: rotate(420deg); // Blazin' 😎
}
86% {
transform: rotate(330deg);
}
to {
transform: rotate(360deg);
}
}
}
/*
The special `._star` class is given to buttons which have a star
icon (see JS). When they are active, we give them a gold star ⭐️.
*/
&._star .button\\icon {
color: $gold-star;
}
}
/*
For links, we consider them disabled if they don't have an `href`
attribute (see JS).
*/
&._disabled {
opacity: $glitch-disabled-opacity;
cursor: default;
}
/*
This is confusing becuase of the names, but the `color .3 ease-out`
transition is actually used when easing *in* to a hovering/active/
focusing state, and the default transition is used when leaving. Our
buttons are a little slower to glow than they are to fade.
*/
&:active,
&:focus,
&:hover {
color: $glitch-lighter-color;
transition: color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
}
&:focus {
outline-color: currentColor;
}
/*
Buttons with text have a number of different styling rules and an
overall different appearance.
*/
&._with-text {
display: inline-block;
border: none;
border-radius: .35em;
padding: 0 .5em;
color: $glitch-texture-color;
background: $ui-base-lighter-color;
font-size: .75em;
font-weight: inherit;
text-transform: uppercase;
line-height: 1.6;
cursor: pointer;
vertical-align: baseline;
transition: background-color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out;
.button\\icon {
display: inline-block;
font-size: 1.25em;
vertical-align: -.1em;
}
& > *:not(:first-child) {
margin: 0 0 0 .4em;
border-left: 1px solid currentColor;
padding: 0 0 0 .3em;
}
&:active,
&:hover,
&:focus {
color: $glitch-texture-color;
background: $glitch-lighter-color;
transition: background-color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out;
}
}
}

View File

@@ -0,0 +1,59 @@
// <CommonIcon>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/icon
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const CommonIcon = ({
className,
name,
proportional,
title,
...others
}) => name ? (
<span
className={classNames('glitch', 'glitch__common__icon', className)}
{...others}
>
<span
aria-hidden
className={`fa ${proportional ? '' : 'fa-fw'} fa-${name} icon\fa`}
{...(title ? { title } : {})}
/>
{title ? (
<span className='_for-screenreader'>{title}</span>
) : null}
</span>
) : null;
// Props.
CommonIcon.propTypes = {
className: PropTypes.string,
name: PropTypes.string,
proportional: PropTypes.bool,
title: PropTypes.string,
};
// Export.
export default CommonIcon;

View File

@@ -0,0 +1,14 @@
@import 'variables';
.glitch.glitch__common__icon {
display: inline-block;
._for-screenreader {
position: absolute;
margin: -1px -1px;
width: 1px;
height: 1px;
clip: rect(0, 0, 0, 0);
overflow: hidden;
}
}

View File

@@ -0,0 +1,74 @@
// <CommonLink>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/link
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class CommonLink extends React.PureComponent {
// Props.
static propTypes = {
children: PropTypes.node,
className: PropTypes.string,
destination: PropTypes.string,
history: PropTypes.object,
href: PropTypes.string,
};
// We only reroute the link if it is an unadorned click, we have
// access to the router, and there is somewhere to reroute it *to*.
handleClick = (e) => {
const { destination, history } = this.props;
if (!history || !destination || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
history.push(destination);
e.preventDefault();
}
// Rendering.
render () {
const { handleClick } = this;
const { children, className, destination, history, href, ...others } = this.props;
const computedClass = classNames('glitch', 'glitch__common__link', className);
const conditionalProps = {};
if (href) {
conditionalProps.href = href;
conditionalProps.onClick = handleClick;
} else if (destination) {
conditionalProps.onClick = handleClick;
conditionalProps.role = 'link';
conditionalProps.tabIndex = 0;
} else conditionalProps.role = 'presentation';
return (
<a
className={computedClass}
{...conditionalProps}
{...others}
rel='noopener'
target='_blank'
>{children}</a>
);
}
}

View File

@@ -0,0 +1,11 @@
@import 'variables';
/*
Most link styling happens elsewhere but we disable text-decoration
here.
*/
.glitch.glitch__common__link {
display: inline;
color: $ui-secondary-color;
text-decoration: none;
}

View File

@@ -0,0 +1,49 @@
// <CommonSeparator>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/common/separator
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const CommonSeparator = ({
className,
visible,
...others
}) => visible ? (
<span
className={
classNames('glitch', 'glitch__common__separator', className)
}
{...others}
role='separator'
/> // Contents provided via CSS.
) : null;
// Props.
CommonSeparator.propTypes = {
className: PropTypes.string,
visible: PropTypes.bool,
};
// Export.
export default CommonSeparator;

View File

@@ -0,0 +1,15 @@
@import 'variables';
/*
The default contents for a separator is an interpunct, surrounded by
spaces. However, this can be changed using CSS selectors.
*/
.glitch.glitch__common__separator {
display: inline-block;
&::after {
display: inline-block;
padding: 0 .3em;
content: "·";
}
}

View File

@@ -0,0 +1,52 @@
// <ListConversationContainer>
// =================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation/container
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import { connect } from 'react-redux';
// Mastodon imports.
import { fetchContext } from 'mastodon/actions/statuses';
// Our imports.
import ListConversation from '.';
// * * * * * * * //
// State mapping
// -------------
const mapStateToProps = (state, { id }) => {
return {
ancestors : state.getIn(['contexts', 'ancestors', id]),
descendants : state.getIn(['contexts', 'descendants', id]),
};
};
// * * * * * * * //
// Dispatch mapping
// ----------------
const mapDispatchToProps = (dispatch) => ({
fetch (id) {
dispatch(fetchContext(id));
},
});
// * * * * * * * //
// Connecting
// ----------
export default connect(mapStateToProps, mapDispatchToProps)(ListConversation);

View File

@@ -0,0 +1,80 @@
// <ListConversation>
// ====================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ScrollContainer from 'react-router-scroll';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import StatusContainer from 'glitch/components/status/container';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class ListConversation extends ImmutablePureComponent {
// Props.
static propTypes = {
id: PropTypes.number.isRequired,
ancestors: ImmutablePropTypes.list,
descendants: ImmutablePropTypes.list,
fetch: PropTypes.func.isRequired,
}
// If this is a detailed status, we should fetch its contents and
// context upon mounting.
componentWillMount () {
const { id, fetch } = this.props;
fetch(id);
}
// Similarly, if the component receives new props, we need to fetch
// the new status.
componentWillReceiveProps (nextProps) {
const { id, fetch } = this.props;
if (nextProps.id !== id) fetch(nextProps.id);
}
// We just render our status inside a column with its
// ancestors and decendants.
render () {
const { id, ancestors, descendants } = this.props;
return (
<ScrollContainer scrollKey='thread'>
<div className='glitch glitch__list__conversation scrollable'>
{ancestors && ancestors.size > 0 ? (
ancestors.map(
ancestor => <StatusContainer key={ancestor} id={ancestor} route />
)
) : null}
<StatusContainer key={id} id={id} detailed route />
{descendants && descendants.size > 0 ? (
descendants.map(
descendant => <StatusContainer key={descendant} id={descendant} route />
)
) : null}
</div>
</ScrollContainer>
);
}
};

View File

@@ -1,16 +1,25 @@
import React, { PureComponent } from 'react';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { ScrollContainer } from 'react-router-scroll';
import PropTypes from 'prop-types';
import IntersectionObserverArticle from './intersection_observer_article';
import LoadMore from './load_more';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import IntersectionObserverWrapper from 'mastodon/features/ui/util/intersection_observer_wrapper';
import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable';
import { defineMessages, injectIntl } from 'react-intl';
export default class ScrollableList extends PureComponent {
import StatusContainer from 'glitch/components/status/container';
import CommonButton from 'glitch/components/common/button';
const messages = defineMessages({
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
});
@injectIntl
export default class ListStatuses extends ImmutablePureComponent {
static propTypes = {
scrollKey: PropTypes.string.isRequired,
statusIds: ImmutablePropTypes.list.isRequired,
onScrollToBottom: PropTypes.func,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
@@ -20,15 +29,12 @@ export default class ScrollableList extends PureComponent {
hasMore: PropTypes.bool,
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
children: PropTypes.node,
};
static defaultProps = {
trackScroll: true,
};
state = {
lastMouseMove: null,
currentDetail: null,
};
intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -51,14 +57,6 @@ export default class ScrollableList extends PureComponent {
trailing: true,
});
handleMouseMove = throttle(() => {
this._lastMouseMove = new Date();
}, 300);
handleMouseLeave = () => {
this._lastMouseMove = null;
}
componentDidMount () {
this.attachScrollListener();
this.attachIntersectionObserver();
@@ -68,15 +66,11 @@ export default class ScrollableList extends PureComponent {
}
componentDidUpdate (prevProps) {
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
// Reset the scroll position when a new child comes in in order not to
// Reset the scroll position when a new toot comes in in order not to
// jerk the scrollbar around if you're already scrolled down the page.
if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) {
const newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) {
if (prevProps.statusIds.first() !== this.props.statusIds.first()) {
let newScrollTop = this.node.scrollHeight - this._oldScrollPosition;
if (this.node.scrollTop !== newScrollTop) {
this.node.scrollTop = newScrollTop;
}
@@ -84,6 +78,7 @@ export default class ScrollableList extends PureComponent {
this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop;
}
}
}
componentWillUnmount () {
this.detachScrollListener();
@@ -109,17 +104,6 @@ export default class ScrollableList extends PureComponent {
this.node.removeEventListener('scroll', this.handleScroll);
}
getFirstChildKey (props) {
const { children } = props;
let firstChild = children;
if (children instanceof ImmutableList) {
firstChild = children.get(0);
} else if (Array.isArray(children)) {
firstChild = children[0];
}
return firstChild && firstChild.key;
}
setRef = (c) => {
this.node = c;
}
@@ -129,10 +113,6 @@ export default class ScrollableList extends PureComponent {
this.props.onScrollToBottom();
}
_recentlyMoved () {
return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
}
handleKeyDown = (e) => {
if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
const article = (() => {
@@ -159,23 +139,48 @@ export default class ScrollableList extends PureComponent {
}
}
render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
const childrenCount = React.Children.count(children);
handleSetDetail = (id) => {
this.setState({ currentDetail : id });
}
const loadMore = <LoadMore visible={!isLoading && childrenCount > 0 && hasMore} onClick={this.handleLoadMore} />;
render () {
const {
handleKeyDown,
handleLoadMore,
handleSetDetail,
intersectionObserverWrapper,
setRef,
} = this;
const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, intl } = this.props;
const { currentDetail } = this.state;
const loadMore = (
<CommonButton
className='load-more'
disabled={isLoading || statusIds.size > 0 && hasMore}
onClick={handleLoadMore}
showTitle
title={intl.formatMessage(messages.load_more)}
/>
);
let scrollableArea = null;
if (isLoading || childrenCount > 0 || !emptyMessage) {
if (isLoading || statusIds.size > 0 || !emptyMessage) {
scrollableArea = (
<div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
<div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
<div className='scrollable' ref={setRef}>
<div role='feed' className='status-list' onKeyDown={handleKeyDown}>
{prepend}
{React.Children.map(this.props.children, (child, index) => (
<IntersectionObserverArticle key={child.key} id={child.key} index={index} listLength={childrenCount} intersectionObserverWrapper={this.intersectionObserverWrapper}>
{child}
</IntersectionObserverArticle>
{statusIds.map((statusId, index) => (
<StatusContainer
key={statusId}
id={statusId}
index={index}
listLength={statusIds.size}
detailed={currentDetail === statusId}
setDetail={handleSetDetail}
intersectionObserverWrapper={intersectionObserverWrapper}
/>
))}
{loadMore}
@@ -184,7 +189,7 @@ export default class ScrollableList extends PureComponent {
);
} else {
scrollableArea = (
<div className='empty-column-indicator' ref={this.setRef}>
<div className='empty-column-indicator' ref={setRef}>
{emptyMessage}
</div>
);

View File

@@ -1,12 +1,38 @@
// `<NotificationFollow>`
// ======================
/*
// * * * * * * * //
`<NotificationFollow>`
======================
// Imports
// -------
This component renders a follow notification.
// Package imports.
__Props:__
- __`id` (`PropTypes.number.isRequired`) :__
This is the id of the notification.
- __`onDeleteNotification` (`PropTypes.func.isRequired`) :__
The function to call when a notification should be
dismissed/deleted.
- __`account` (`PropTypes.object.isRequired`) :__
The account associated with the follow notification, ie the account
which followed the user.
- __`intl` (`PropTypes.object.isRequired`) :__
Our internationalization object, inserted by `@injectIntl`.
*/
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
@@ -14,18 +40,22 @@ import { FormattedMessage } from 'react-intl';
import escapeTextContentForBrowser from 'escape-html';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import Permalink from '../../../mastodon/components/permalink';
import AccountContainer from '../../../mastodon/containers/account_container';
// Our imports.
// Our imports //
import NotificationOverlayContainer from '../notification/overlay/container';
// * * * * * * * //
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
// Implementation
// --------------
/*
Implementation:
---------------
*/
export default class NotificationFollow extends ImmutablePureComponent {
@@ -35,10 +65,24 @@ export default class NotificationFollow extends ImmutablePureComponent {
notification : ImmutablePropTypes.map.isRequired,
};
/*
### `render()`
This actually renders the component.
*/
render () {
const { account, notification } = this.props;
// Links to the display name.
/*
`link` is a container for the account's `displayName`, which links to
the account timeline using a `<Permalink>`.
*/
const displayName = account.get('display_name') || account.get('username');
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
const link = (
@@ -51,7 +95,12 @@ export default class NotificationFollow extends ImmutablePureComponent {
/>
);
// Renders.
/*
We can now render our component.
*/
return (
<div className='notification notification-follow'>
<div className='notification__message'>

View File

@@ -8,6 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports //
import StatusContainer from '../status/container';
import NotificationFollow from './follow';
import NotificationOverlayContainer from './overlay/container';
export default class Notification extends ImmutablePureComponent {
@@ -65,6 +66,9 @@ export default class Notification extends ImmutablePureComponent {
render () {
const { notification } = this.props;
return (
<div class='status'>
{(() => {
switch (notification.get('type')) {
case 'follow':
return this.renderFollow(notification);
@@ -74,9 +78,13 @@ export default class Notification extends ImmutablePureComponent {
return this.renderFavourite(notification);
case 'reblog':
return this.renderReblog(notification);
}
default:
return null;
}
})()}
<NotificationOverlayContainer notification={notification} />
</div>
);
}
}

View File

@@ -1,40 +1,34 @@
/*
// <NotificationOverlayContainer>
// ==============================
`<NotificationOverlayContainer>`
=========================
This container connects `<NotificationOverlay>`s to the Redux store.
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/notification/overlay/container
*/
// * * * * * * * //
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
/*
// Imports
// -------
Imports:
--------
*/
// Package imports //
// Package imports.
import { connect } from 'react-redux';
// Our imports //
// Mastodon imports.
import { markNotificationForDelete } from 'mastodon/actions/notifications';
// Our imports.
import NotificationOverlay from './notification_overlay';
import { markNotificationForDelete } from '../../../../mastodon/actions/notifications';
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
// State mapping
// -------------
/*
const mapStateToProps = state => ({
show: state.getIn(['notifications', 'cleaningMode']),
});
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We only need to provide a dispatch for
deleting notifications.
*/
// Dispatch mapping
// ----------------
const mapDispatchToProps = dispatch => ({
onMarkForDelete(id, yes) {
@@ -42,8 +36,4 @@ const mapDispatchToProps = dispatch => ({
},
});
const mapStateToProps = state => ({
show: state.getIn(['notifications', 'cleaningMode']),
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);

View File

@@ -10,10 +10,6 @@ import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
// Mastodon imports //
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
const messages = defineMessages({
markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
});

View File

@@ -1,188 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
import IconButton from '../../../mastodon/components/icon_button';
import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
embed: { id: 'status.embed', defaultMessage: 'Embed' },
});
@injectIntl
export default class StatusActionBar extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onMention: PropTypes.func,
onMute: PropTypes.func,
onBlock: PropTypes.func,
onReport: PropTypes.func,
onEmbed: PropTypes.func,
onMuteConversation: PropTypes.func,
onPin: PropTypes.func,
me: PropTypes.number,
withDismiss: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'me',
'withDismiss',
]
handleReplyClick = () => {
this.props.onReply(this.props.status, this.context.router.history);
}
handleShareClick = () => {
navigator.share({
text: this.props.status.get('search_index'),
url: this.props.status.get('url'),
});
}
handleFavouriteClick = () => {
this.props.onFavourite(this.props.status);
}
handleReblogClick = (e) => {
this.props.onReblog(this.props.status, e);
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
}
handlePinClick = () => {
this.props.onPin(this.props.status);
}
handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history);
}
handleMuteClick = () => {
this.props.onMute(this.props.status.get('account'));
}
handleBlockClick = () => {
this.props.onBlock(this.props.status.get('account'));
}
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
handleEmbed = () => {
this.props.onEmbed(this.props.status);
}
handleReport = () => {
this.props.onReport(this.props.status);
}
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
}
render () {
const { status, me, intl, withDismiss } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
menu.push(null);
if (status.getIn(['account', 'id']) === me || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
}
if (status.getIn(['account', 'id']) === me) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
} else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
}
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
<IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}
<div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
</div>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div>
);
}
}

View File

@@ -0,0 +1,268 @@
// <StatusActionBar>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/action_bar
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
delete:
{ id: 'status.delete', defaultMessage: 'Delete' },
mention:
{ id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute:
{ id: 'account.mute', defaultMessage: 'Mute @{name}' },
block:
{ id: 'account.block', defaultMessage: 'Block @{name}' },
reply:
{ id: 'status.reply', defaultMessage: 'Reply' },
replyAll:
{ id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog:
{ id: 'status.reblog', defaultMessage: 'Boost' },
cannot_reblog:
{ id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite:
{ id: 'status.favourite', defaultMessage: 'Favourite' },
open:
{ id: 'status.open', defaultMessage: 'Expand this status' },
report:
{ id: 'status.report', defaultMessage: 'Report @{name}' },
muteConversation:
{ id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation:
{ id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
share:
{ id: 'status.share', defaultMessage: 'Share' },
more:
{ id: 'status.more', defaultMessage: 'More' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusActionBar extends ImmutablePureComponent {
// Props.
static propTypes = {
detailed: PropTypes.bool,
handler: PropTypes.objectOf(PropTypes.func).isRequired,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
me: PropTypes.number,
status: ImmutablePropTypes.map.isRequired,
};
// These handle all of our actions.
handleReplyClick = () => {
const { handler, history, status } = this.props;
handler.reply(status, { history }); // hack
}
handleFavouriteClick = () => {
const { handler, status } = this.props;
handler.favourite(status);
}
handleReblogClick = (e) => {
const { handler, status } = this.props;
handler.reblog(status, e.shiftKey);
}
handleDeleteClick = () => {
const { handler, status } = this.props;
handler.delete(status);
}
handleMentionClick = () => {
const { handler, history, status } = this.props;
handler.mention(status.get('account'), { history }); // hack
}
handleMuteClick = () => {
const { handler, status } = this.props;
handler.mute(status.get('account'));
}
handleBlockClick = () => {
const { handler, status } = this.props;
handler.block(status.get('account'));
}
handleOpen = () => {
const { history, status } = this.props;
history.push(`/statuses/${status.get('id')}`);
}
handleReport = () => {
const { handler, status } = this.props;
handler.report(status);
}
handleShare = () => {
const { status } = this.props;
navigator.share({
text: status.get('search_index'),
url: status.get('url'),
});
}
handleConversationMuteClick = () => {
const { handler, status } = this.props;
handler.muteConversation(status);
}
// Renders our component.
render () {
const {
handleBlockClick,
handleConversationMuteClick,
handleDeleteClick,
handleFavouriteClick,
handleMentionClick,
handleMuteClick,
handleOpen,
handleReblogClick,
handleReplyClick,
handleReport,
handleShare,
} = this;
const { detailed, intl, me, status } = this.props;
const account = status.get('account');
const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct';
const reblogTitle = reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog);
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
let menu = [];
let replyIcon;
let replyTitle;
// This builds our menu.
if (!detailed) {
menu.push({
text: intl.formatMessage(messages.open),
action: handleOpen,
});
menu.push(null);
}
menu.push({
text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation),
action: handleConversationMuteClick,
});
menu.push(null);
if (account.get('id') === me) {
menu.push({
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
});
} else {
menu.push({
text: intl.formatMessage(messages.mention, {
name: account.get('username'),
}),
action: handleMentionClick,
});
menu.push(null);
menu.push({
text: intl.formatMessage(messages.mute, {
name: account.get('username'),
}),
action: handleMuteClick,
});
menu.push({
text: intl.formatMessage(messages.block, {
name: account.get('username'),
}),
action: handleBlockClick,
});
menu.push({
text: intl.formatMessage(messages.report, {
name: account.get('username'),
}),
action: handleReport,
});
}
// This selects our reply icon.
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
// Now we can render the component.
return (
<div className='glitch glitch__status__action-bar'>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess}
title={replyTitle}
icon={replyIcon}
onClick={handleReplyClick}
/>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess || reblogDisabled}
active={status.get('reblogged')}
title={reblogTitle}
icon='retweet'
onClick={handleReblogClick}
/>
<CommonButton
className='action-bar\button'
disabled={anonymousAccess}
animate
active={status.get('favourited')}
title={intl.formatMessage(messages.favourite)}
icon='star'
onClick={handleFavouriteClick}
/>
{
'share' in navigator ? (
<CommonButton
className='action-bar\button'
disabled={status.get('visibility') !== 'public'}
title={intl.formatMessage(messages.share)}
icon='share-alt'
onClick={handleShare}
/>
) : null
}
<div className='action-bar\button'>
<DropdownMenuContainer
items={menu}
disabled={anonymousAccess}
icon='ellipsis-h'
size={18}
direction='right'
aria-label={intl.formatMessage(messages.more)}
/>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,28 @@
@import 'variables';
.glitch.glitch__status__action-bar {
display: block;
height: 1.25em;
font-size: 1.25em;
line-height: 1;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
// Dropdown style override for centering on the icon
.dropdown--active {
position: relative;
.dropdown__content.dropdown__right {
left: calc(50% + 3px);
right: initial;
transform: translate(-50%, 0);
top: 22px;
}
&::after {
right: 1px;
bottom: -2px;
}
}
}

View File

@@ -1,73 +1,64 @@
/*
// <StatusContainer>
// =================
`<StatusContainer>`
===================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/container
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. Documentation by @kibi@glitch.social. The code
detecting reblogs has been moved here from <Status>.
// For more information, please contact:
// @kibi@glitch.social
*/
// * * * * * * * //
/* * * * */
// Imports
// -------
/*
Imports:
--------
*/
// Package imports //
// Package imports.
import React from 'react';
import { connect } from 'react-redux';
import {
defineMessages,
injectIntl,
FormattedMessage,
} from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import { createStructuredSelector } from 'reselect';
// Mastodon imports //
import { makeGetStatus } from '../../../mastodon/selectors';
// Mastodon imports.
import { blockAccount, muteAccount } from 'mastodon/actions/accounts';
import {
replyCompose,
mentionCompose,
} from '../../../mastodon/actions/compose';
} from 'mastodon/actions/compose';
import {
reblog,
favourite,
unreblog,
unfavourite,
pin,
unpin,
} from '../../../mastodon/actions/interactions';
import { blockAccount } from '../../../mastodon/actions/accounts';
import { initMuteModal } from '../../../mastodon/actions/mutes';
} from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { initReport } from 'mastodon/actions/reports';
import {
muteStatus,
unmuteStatus,
deleteStatus,
} from '../../../mastodon/actions/statuses';
import { initReport } from '../../../mastodon/actions/reports';
import { openModal } from '../../../mastodon/actions/modal';
} from 'mastodon/actions/statuses';
import { fetchStatusCard } from 'mastodon/actions/cards';
// Our imports //
// Our imports.
import Status from '.';
import makeStatusSelector from 'glitch/selectors/status';
/* * * * */
// * * * * * * * //
/*
Inital setup:
-------------
The `messages` constant is used to define any messages that we will
need in our component. In our case, these are the various confirmation
messages used with statuses.
*/
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
blockConfirm : {
id : 'confirmations.block.confirm',
defaultMessage : 'Block',
},
deleteConfirm : {
id : 'confirmations.delete.confirm',
defaultMessage : 'Delete',
@@ -76,133 +67,68 @@ const messages = defineMessages({
id : 'confirmations.delete.message',
defaultMessage : 'Are you sure you want to delete this status?',
},
blockConfirm : {
id : 'confirmations.block.confirm',
defaultMessage : 'Block',
muteConfirm : {
id : 'confirmations.mute.confirm',
defaultMessage : 'Mute',
},
});
/* * * * */
// * * * * * * * //
/*
State mapping:
--------------
The `mapStateToProps()` function maps various state properties to the
props of our component. We wrap this in a `makeMapStateToProps()`
function to give us closure and preserve `getStatus()` across function
calls.
*/
// State mapping
// -------------
// We wrap our `mapStateToProps()` function in a
// `makeMapStateToProps()` to give us a closure and preserve
// `makeGetStatus()`'s value.
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const statusSelector = makeStatusSelector();
const mapStateToProps = (state, ownProps) => {
let status = getStatus(state, ownProps.id);
// State mapping.
return (state, ownProps) => {
let status = statusSelector(state, ownProps.id);
let reblogStatus = status.get('reblog', null);
let account = undefined;
let comrade = undefined;
let prepend = undefined;
/*
Here we process reblogs. If our status is a reblog, then we create a
`prependMessage` to pass along to our `<Status>` along with the
reblogger's `account`, and set `coreStatus` (the one we will actually
render) to the status which has been reblogged.
*/
// Processes reblogs and generates their prepend.
if (reblogStatus !== null && typeof reblogStatus === 'object') {
account = status.get('account');
comrade = status.get('account');
status = reblogStatus;
prepend = 'reblogged_by';
prepend = 'reblogged';
}
/*
Here are the props we pass to `<Status>`.
*/
// This is what we pass to <Status>.
return {
status : status,
account : account || ownProps.account,
autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
comrade: comrade || ownProps.comrade,
deleteModal: state.getIn(['meta', 'delete_modal']),
me: state.getIn(['meta', 'me']),
settings : state.get('local_settings'),
prepend: prepend || ownProps.prepend,
reblogModal: state.getIn(['meta', 'boost_modal']),
deleteModal : state.getIn(['meta', 'delete_modal']),
autoPlayGif : state.getIn(['meta', 'auto_play_gif']),
settings: state.get('local_settings'),
status: status,
};
};
};
return mapStateToProps;
};
// * * * * * * * //
/* * * * */
// Dispatch mapping
// ----------------
/*
Dispatch mapping:
-----------------
The `mapDispatchToProps()` function maps dispatches to our store to the
various props of our component. We need to provide dispatches for all
of the things you can do with a status: reply, reblog, favourite, et
cetera.
For a few of these dispatches, we open up confirmation modals; the rest
just immediately execute their corresponding actions.
*/
const mapDispatchToProps = (dispatch, { intl }) => ({
onReply (status, router) {
dispatch(replyCompose(status, router));
const makeMapDispatchToProps = (dispatch) => {
const dispatchSelector = createStructuredSelector({
handler: ({ intl }) => ({
block (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
}));
},
onModalReblog (status) {
dispatch(reblog(status));
},
onReblog (status, e) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (e.shiftKey || !this.reblogModal) {
this.onModalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
}
}
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
onPin (status) {
if (status.get('pinned')) {
dispatch(unpin(status));
} else {
dispatch(pin(status));
}
},
onEmbed (status) {
dispatch(openModal('EMBED', { url: status.get('url') }));
},
onDelete (status) {
if (!this.deleteModal) {
delete (status) {
if (!this.deleteModal) { // TODO: THIS IS BORKN (this refers to handler)
dispatch(deleteStatus(status.get('id')));
} else {
dispatch(openModal('CONFIRM', {
@@ -212,44 +138,75 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
}
},
onMention (account, router) {
favourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
},
fetchCard (status) {
dispatch(fetchStatusCard(status.get('id')));
},
mention (account, router) {
dispatch(mentionCompose(account, router));
},
onOpenMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
modalReblog (status) {
dispatch(reblog(status));
},
onOpenVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
onBlock (account) {
mute (account) {
dispatch(openModal('CONFIRM', {
message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.blockConfirm),
onConfirm: () => dispatch(blockAccount(account.get('id'))),
message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.muteConfirm),
onConfirm: () => dispatch(muteAccount(account.get('id'))),
}));
},
onReport (status) {
dispatch(initReport(status.get('account'), status));
},
onMute (account) {
dispatch(initMuteModal(account));
},
onMuteConversation (status) {
muteConversation (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
openMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
},
openVideo (media, time) {
dispatch(openModal('VIDEO', { media, time }));
},
reblog (status, withShift) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else {
if (withShift || !this.reblogModal) { // TODO: THIS IS BORKN (this refers to handler)
this.modalReblog(status);
} else {
dispatch(openModal('BOOST', { status, onReblog: this.modalReblog }));
}
}
},
reply (status, router) {
dispatch(replyCompose(status, router));
},
report (status) {
dispatch(initReport(status.get('account'), status));
},
}),
});
return (_, ownProps) => dispatchSelector(ownProps);
};
// * * * * * * * //
// Connecting
// ----------
// `connect` will only update when its resultant props change. So
// `withRouter` won't get called unless an update is already planned.
// This is intended behaviour because we only care about the (mutable)
// `history` object.
export default injectIntl(
connect(makeMapStateToProps, mapDispatchToProps)(Status)
connect(makeMapStateToProps, makeMapDispatchToProps)(
withRouter(Status)
)
);

View File

@@ -1,247 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classnames from 'classnames';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
import { isRtl } from '../../../mastodon/rtl';
import Permalink from '../../../mastodon/components/permalink';
export default class StatusContent extends React.PureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
expanded: PropTypes.oneOf([true, false, null]),
setExpansion: PropTypes.func,
onHeightUpdate: PropTypes.func,
media: PropTypes.element,
mediaIcon: PropTypes.string,
parseClick: PropTypes.func,
disabled: PropTypes.bool,
};
state = {
hidden: true,
};
componentDidMount () {
const node = this.node;
const links = node.querySelectorAll('a');
for (var i = 0; i < links.length; ++i) {
let link = links[i];
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
} else {
link.addEventListener('click', this.onLinkClick.bind(this), false);
link.setAttribute('title', link.href);
}
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noopener');
}
}
componentDidUpdate () {
if (this.props.onHeightUpdate) {
this.props.onHeightUpdate();
}
}
onLinkClick = (e) => {
if (this.props.expanded === false) {
if (this.props.parseClick) this.props.parseClick(e);
}
}
onMentionClick = (mention, e) => {
if (this.props.parseClick) {
this.props.parseClick(e, `/accounts/${mention.get('id')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (this.props.parseClick) {
this.props.parseClick(e, `/timelines/tag/${hashtag}`);
}
}
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
handleMouseUp = (e) => {
const { parseClick } = this.props;
if (!this.startXY) {
return;
}
const [ startX, startY ] = this.startXY;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) {
return;
}
if (deltaX + deltaY < 5 && e.button === 0 && parseClick) {
parseClick(e);
}
this.startXY = null;
}
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.setExpansion) {
this.props.setExpansion(this.props.expanded ? null : true);
} else {
this.setState({ hidden: !this.state.hidden });
}
}
setRef = (c) => {
this.node = c;
}
render () {
const {
status,
media,
mediaIcon,
parseClick,
disabled,
} = this.props;
const hidden = (
this.props.setExpansion ?
!this.props.expanded :
this.state.hidden
);
const content = { __html: emojify(status.get('content')) };
const spoilerContent = {
__html: emojify(escapeTextContentForBrowser(
status.get('spoiler_text', '')
)),
};
const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', {
'status__content--with-action': parseClick && !disabled,
});
if (isRtl(status.get('search_index'))) {
directionStyle.direction = 'rtl';
}
if (status.get('spoiler_text').length > 0) {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink
to={`/accounts/${item.get('id')}`}
href={item.get('url')}
key={item.get('id')}
className='mention'
>
@<span>{item.get('username')}</span>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
const toggleText = hidden ? [
<FormattedMessage
id='status.show_more'
defaultMessage='Show more'
key='0'
/>,
mediaIcon ? (
<i
className={
`fa fa-fw fa-${mediaIcon} status__content__spoiler-icon`
}
aria-hidden='true'
key='1'
/>
) : null,
] : [
<FormattedMessage
id='status.show_less'
defaultMessage='Show less'
key='0'
/>,
];
if (hidden) {
mentionsPlaceholder = <div>{mentionLinks}</div>;
}
return (
<div className={classNames}>
<p
style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
<span dangerouslySetInnerHTML={spoilerContent} />
{' '}
<button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
{toggleText}
</button>
</p>
{mentionsPlaceholder}
<div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
<div
ref={this.setRef}
style={directionStyle}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{media}
</div>
</div>
);
} else if (parseClick) {
return (
<div
className={classNames}
style={directionStyle}
>
<div
ref={this.setRef}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
dangerouslySetInnerHTML={content}
/>
{media}
</div>
);
} else {
return (
<div
className='status__content'
style={directionStyle}
>
<div ref={this.setRef} dangerouslySetInnerHTML={content} />
{media}
</div>
);
}
}
}

View File

@@ -0,0 +1,190 @@
// <StatusContentCard>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/card
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import punycode from 'punycode';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports.
import emojify from 'mastodon/emoji';
// Our imports.
import CommonLink from 'glitch/components/common/link';
import CommonSeparator from 'glitch/components/common/separator';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Reliably gets the hostname from a URL.
const getHostname = url => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
// * * * * * * * //
// The component
// -------------
export default class Card extends ImmutablePureComponent {
// Props.
static propTypes = {
card: ImmutablePropTypes.map.isRequired,
fullwidth: PropTypes.bool,
letterbox: PropTypes.bool,
}
// Rendering.
render () {
const { card, fullwidth, letterbox } = this.props;
let media = null;
let text = null;
let author = null;
let provider = null;
let caption = null;
// This gets all of our card properties.
const authorName = card.get('author_name');
const authorUrl = card.get('author_url');
const description = card.get('description');
const html = card.get('html');
const image = card.get('image');
const providerName = card.get('provider_name');
const providerUrl = card.get('provider_url');
const title = card.get('title');
const type = card.get('type');
const url = card.get('url');
// Sets our class.
const computedClass = classNames('glitch', 'glitch__status__content__card', type, {
_fullwidth: fullwidth,
_letterbox: letterbox,
});
// A card is required to render.
if (!card) return null;
// This generates our card media (image or video).
switch(type) {
case 'photo':
media = (
<CommonLink
className='card\media card\photo'
href={url}
>
<img
alt={title}
src={image}
/>
</CommonLink>
);
break;
case 'video':
media = (
<div
className='card\media card\video'
dangerouslySetInnerHTML={{ __html: html }}
/>
);
break;
}
// If we have at least a title or a description, then we can
// render some textual contents.
if (title || description) {
text = (
<CommonLink
className='card\description'
href={url}
>
{type === 'link' && image ? (
<div className='card\thumbnail'>
<img
alt=''
className='card\image'
src={image}
/>
</div>
) : null}
{title ? (
<h1 className='card\title'>{title}</h1>
) : null}
{emojify(description)}
</CommonLink>
);
}
// This creates links or spans (depending on whether a URL was
// provided) for the card author and provider.
if (authorUrl) {
author = (
<CommonLink
className='card\author card\link'
href={authorUrl}
>
{authorName ? authorName : punycode.toUnicode(getHostname(authorUrl))}
</CommonLink>
);
} else if (authorName) {
author = <span className='card\author'>{authorName}</span>;
}
if (providerUrl) {
provider = (
<CommonLink
className='card\provider card\link'
href={providerUrl}
>
{providerName ? providerName : punycode.toUnicode(getHostname(providerUrl))}
</CommonLink>
);
} else if (providerName) {
provider = <span className='card\provider'>{providerName}</span>;
}
// If we have either the author or the provider, then we can
// render an attachment.
if (author || provider) {
caption = (
<figcaption className='card\caption'>
{author}
<CommonSeparator
className='card\separator'
visible={author && provider}
/>
{provider}
</figcaption>
);
}
// Putting the pieces together and returning.
return (
<figure className={computedClass}>
{media}
{text}
{caption}
</figure>
);
}
}

View File

@@ -0,0 +1,123 @@
@import 'variables';
.glitch.glitch__content__card {
display: block;
border: thin $glitch-texture-color solid;
border-radius: .35em;
background: $glitch-darker-color;
.card\\caption {
color: $ui-primary-color;
background: $glitch-texture-color;
font-size: (1.25em / 1.35); // approx. .925em
.card\\link { // caption links
color: inherit;
&:hover {
text-decoration: underline;
}
}
}
.card\\media {
display: block;
position: relative;
width: 100%;
height: 13.5em;
/*
Our fallback styles letterbox the media, but we'll expand it to
fill the container if supported. This won't do anything for
`<iframe>`s, but we'll just have to trust them to manage their
own content.
*/
& > * {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.card\\description {
color: $ui-secondary-color;
background: $ui-base-color;
.card\\thumbnail {
position: relative;
float: left;
width: 6.75em;
height: 100%;
background: $glitch-darker-color;
& > img {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
/*
We have to divide the bottom margin of titles by their font-size to
get them to match what we use elsewhere.
*/
.card\\title {
margin-bottom: (.75em * 1.35 / 1.5);
font-size: 1.5em;
line-height: 1.125; // = 1.35 * (1.25 / 1.5)
}
}
&._fullwidth {
margin-left: -.75em;
width: calc(100% + 1.5em);
}
/*
If `letterbox` is specified, then we don't need object-fit (since
we essentially just do a scale-down).
*/
&._letterbox {
.card\\description .card\\thumbnail {
& > img {
width: auto;
height: auto;
object-fit: fill;
}
}
.card\\media {
& > * {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -0,0 +1,191 @@
// <StatusContentGallery>
// ======================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Our imports.
import StatusContentGalleryItem from './item';
import StatusContentGalleryPlayer from './player';
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
hide: { id: 'media_gallery.hide_media', defaultMessage: 'Hide media' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGallery extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
autoPlayGif: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
sensitive: PropTypes.bool,
standalone: PropTypes.bool,
};
state = {
visible: !this.props.sensitive,
};
// Handles media clicks.
handleMediaClick = index => {
const { attachments, onOpenMedia, standalone } = this.props;
if (standalone) return;
onOpenMedia(attachments, index);
}
// Handles showing and hiding.
handleToggle = () => {
this.setState({ visible: !this.state.visible });
}
// Handles video clicks.
handleVideoClick = time => {
const { attachments, onOpenVideo, standalone } = this.props;
if (standalone) return;
onOpenVideo(attachments.get(0), time);
}
// Renders.
render () {
const { handleMediaClick, handleToggle, handleVideoClick } = this;
const {
attachments,
autoPlayGif,
fullwidth,
intl,
letterbox,
sensitive,
} = this.props;
const { visible } = this.state;
const computedClass = classNames('glitch', 'glitch__status__content__gallery', {
_fullwidth: fullwidth,
});
const useableAttachments = attachments.take(4);
let button;
let children;
let size;
// This handles hidden media
if (!this.state.visible) {
button = (
<CommonButton
active
className='gallery\sensitive gallery\curtain'
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
>
<span className='gallery\message'>
<strong className='gallery\warning'>
{sensitive ? (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
) : (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
)}
</strong>
<FormattedMessage
defaultMessage='Click to view'
id='status.sensitive_toggle'
/>
</span>
</CommonButton>
); // No children with hidden media
// If our media is visible, then we render it alongside the
// "eyeball" button.
} else {
button = (
<CommonButton
className='gallery\sensitive gallery\button'
icon={visible ? 'eye' : 'eye-slash'}
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
/>
);
// If our first item is a video, we render a player. Otherwise,
// we render our images.
if (attachments.getIn([0, 'type']) === 'video') {
size = 1;
children = (
<StatusContentGalleryPlayer
attachment={attachments.get(0)}
autoPlayGif={autoPlayGif}
intl={intl}
letterbox={letterbox}
onClick={handleVideoClick}
/>
);
} else {
size = useableAttachments.size;
children = useableAttachments.map(
(attachment, index) => (
<StatusContentGalleryItem
attachment={attachment}
autoPlayGif={autoPlayGif}
gallerySize={size}
index={index}
intl={intl}
key={attachment.get('id')}
letterbox={letterbox}
onClick={handleMediaClick}
/>
)
);
}
}
// Renders the gallery.
return (
<div
className={computedClass}
style={{ height: `${this.props.height}px` }}
>
{button}
{children}
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
// <StatusContentGalleryItem>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/item
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
expand: { id: 'media_gallery.expand', defaultMessage: 'Expand image' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryItem extends ImmutablePureComponent {
// Props.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
gallerySize: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
};
// Click handling.
handleClick = this.props.onClick.bind(this, this.props.index);
// Item rendering.
render () {
const { handleClick } = this;
const {
attachment,
autoPlayGif,
gallerySize,
intl,
letterbox,
} = this.props;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let thumbnail = '';
const computedClass = classNames('glitch', 'glitch__status__content__gallery__item', {
_letterbox: letterbox,
});
// If our gallery has more than one item, our images only take up
// half the width. We need this for image `sizes` calculations.
let multiplier = gallerySize === 1 ? 1 : .5;
// Image attachments
if (attachment.get('type') === 'image') {
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
// This lets the browser conditionally select the preview or
// original image depending on what the rendered size ends up
// being. We, of course, have no way of knowing what the width
// of the gallery will be postCSS, but we conservatively roll
// with 400px. (Note: Upstream Mastodon used media queries here,
// but because our page layout is user-configurable, we don't
// bother.)
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `${(400 * multiplier) >> 0}px`;
// The image.
thumbnail = (
<img
alt=''
className='item\image'
sizes={sizes}
src={previewUrl}
srcSet={srcSet}
/>
);
// Gifv attachments.
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<video
autoPlay={autoPlay}
className='item\gifv'
loop
muted
poster={previewUrl}
src={originalUrl}
/>
);
}
// Rendering. We render the item inside of a button+link, which
// provides the original. (We can do this for gifvs because we
// don't show the controls.)
return (
<CommonButton
className={computedClass}
data-gallery-size={gallerySize}
href={remoteUrl || originalUrl}
key={attachment.get('id')}
onClick={handleClick}
title={intl.formatMessage(messages.expand)}
>{thumbnail}</CommonButton>
);
}
}

View File

@@ -0,0 +1,88 @@
@import 'variables';
.glitch.glitch__status__content__gallery__item {
display: inline-block;
position: relative;
width: 100%;
height: 100%;
cursor: zoom-in;
.item\\image,
.item\\gifv {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.letterbox {
.item\\image,
.item\\gifv {
width: auto;
height: auto;
object-fit: fill;
}
}
&[data-gallery-size="2"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:last-child {
margin: .375em .375em .375em .1875em;
}
}
&[data-gallery-size="3"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:nth-last-child(2) {
float: right;
height: calc(50% - .5625em);
margin: .375em .375em .1875em .1875em;
}
&:last-child {
float: right;
height: calc(50% - .5625em);
margin: .1875em .375em .1875em .375em;
}
}
&[data-gallery-size="4"] {
width: calc(50% - .5625em);
height: calc(50% - .5625em);
margin: .375em .1875em .1875em .375em;
&:nth-last-child(3) {
margin: .375em .375em .1875em .1875em;
}
&:nth-last-child(2) {
margin: .1875em .1875em .375em .375em;
}
&:last-child {
margin: .1875em .375em .375em .1875em;
}
}
}
// add GIF label in CSS

View File

@@ -0,0 +1,233 @@
// <StatusContentGalleryPlayer>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/player
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
mute: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
open: { id: 'video_player.open', defaultMessage: 'Open video' },
play: { id: 'video_player.play', defaultMessage: 'Play video' },
pause: { id: 'video_player.pause', defaultMessage: 'Pause video' },
expand: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryPlayer extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
}
state = {
hasAudio: true,
muted: true,
preview: !isIOS() && this.props.autoPlayGif,
videoError: false,
}
// Basic video controls.
handleMute = () => {
this.setState({ muted: !this.state.muted });
}
handlePlayPause = () => {
const { video } = this;
if (video.paused) {
video.play();
} else {
video.pause();
}
}
// When clicking we either open (de-preview) the video or we
// expand it, depending. Note that when we de-preview the video will
// also begin playing (except on iOS) due to its `autoplay`
// attribute.
handleClick = () => {
const { setState, video } = this;
const { onClick } = this.props;
const { preview } = this.state;
if (preview) setState({ preview: false });
else {
video.pause();
onClick(video.currentTime);
}
}
// Loading and errors. We have to do some hacks in order to check if
// the video has audio imo. There's probably a better way to do this
// but that's how upstream has it.
handleLoadedData = () => {
const { video } = this;
if (('WebkitAppearance' in document.documentElement.style && video.audioTracks.length === 0) || video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
// On mounting or update, we ensure our video has the needed event
// listeners. We can't necessarily do this right away because there
// might be a preview up.
componentDidMount () {
this.componentDidUpdate();
}
componentDidUpdate () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('error', handleVideoError);
}
// On unmounting, we remove the listeners from the video element.
componentWillUnmount () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.removeEventListener('loadeddata', handleLoadedData);
video.removeEventListener('error', handleVideoError);
}
// Getting a reference to our video.
setRef = (c) => {
this.video = c;
}
// Rendering.
render () {
const {
handleClick,
handleMute,
handlePlayPause,
setRef,
video,
} = this;
const {
attachment,
letterbox,
intl,
} = this.props;
const {
hasAudio,
muted,
preview,
videoError,
} = this.state;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let content = null;
const computedClass = classNames('glitch', 'glitch__status__content__gallery__player', {
_letterbox: letterbox,
});
// This gets our content: either a preview image, an error
// message, or the video.
switch (true) {
case preview:
content = (
<img
alt=''
className='player\preview'
src={previewUrl}
/>
);
break;
case videoError:
content = (
<span className='player\error'>
<FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' />
</span>
);
break;
default:
content = (
<video
autoPlay={!isIOS()}
className='player\video'
loop
muted={muted}
poster={previewUrl}
ref={setRef}
src={originalUrl}
/>
);
break;
}
// Everything goes inside of a button because everything is a
// button. This is okay wrt the video element because it doesn't
// have controls.
return (
<div className={computedClass}>
<CommonButton
className='player\box'
href={remoteUrl || originalUrl}
key='box'
onClick={handleClick}
title={intl.formatMessage(preview ? messages.open : messages.expand)}
>{content}</CommonButton>
{!preview ? (
<CommonButton
active={!video.paused}
className='player\play-pause player\button'
icon={video.paused ? 'play' : 'pause'}
key='play'
onClick={handlePlayPause}
title={intl.formatMessage(messages.play)}
/>
) : null}
{!preview && hasAudio ? (
<CommonButton
active={!muted}
className='player\mute player\button'
icon={muted ? 'volume-off' : 'volume-up'}
key='mute'
onClick={handleMute}
title={intl.formatMessage(messages.mute)}
/>
) : null}
</div>
);
}
}

View File

@@ -0,0 +1,71 @@
@import 'variables';
.glitch.glitch__status__content__gallery__player {
display: block;
padding: (1.5em * 1.35) 0; // Creates black bars at the bottom/top
width: 100%;
height: calc(100% - (1.5em * 1.35 * 2));
cursor: zoom-in;
.player\\box {
display: block;
position: relative;
width: 100%;
height: 100%;
& > img,
& > video {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.player\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&.player\\play-pause {
bottom: 0;
left: 0;
}
&.player\\mute {
bottom: 0;
right: 0;
}
}
&._letterbox {
.player\\box {
& > img,
& > video {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -0,0 +1,74 @@
@import 'variables';
.glitch.glitch__status__content__gallery {
display: block;
position: relative;
color: $ui-primary-color;
background: $base-shadow-color;
.gallery\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&:hover {
opacity: 1;
}
&.gallery\\sensitive {
top: 0;
left: 0;
}
}
.gallery\\curtain.gallery\\sensitive {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 0;
padding: 0;
color: $ui-secondary-color;
background: $base-overlay-background;
font-size: (1.25em / 1.35); // approx. .925em
line-height: 1.35;
text-align: center;
white-space: nowrap;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in;
.gallery\\message {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 2.6em;
margin: auto;
.gallery\\warning {
display: block;
font-size: (1.35em / 1.25);
line-height: 1.35;
}
}
&:active,
&:focus,
&:hover {
color: $primary-text-color;
background: $base-overlay-background; // No change
transition: color ($glitch-animation-speed * .3s) ease-out;
}
}
}

View File

@@ -0,0 +1,520 @@
// <StatusContent>
// ===============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isRtl } from 'mastodon/rtl';
// Our imports.
import StatusContentCard from './card';
import StatusContentGallery from './gallery';
import StatusContentUnknown from './unknown';
import CommonButton from 'glitch/components/common/button';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
card_link :
{ id: 'status.card', defaultMessage: 'Card' },
video_link :
{ id: 'status.video', defaultMessage: 'Video' },
image_link :
{ id: 'status.image', defaultMessage: 'Image' },
unknown_link :
{ id: 'status.unknown_attachment', defaultMessage: 'Unknown attachment' },
hashtag :
{ id: 'status.hashtag', defaultMessage: 'Hashtag @{name}' },
show_more :
{ id: 'status.show_more', defaultMessage: 'Show more' },
show_less :
{ id: 'status.show_less', defaultMessage: 'Show less' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContent extends ImmutablePureComponent {
// Props and state.
static propTypes = {
autoPlayGif: PropTypes.bool,
detailed: PropTypes.bool,
expanded: PropTypes.oneOf([true, false, null]),
handler: PropTypes.object.isRequired,
hideMedia: PropTypes.bool,
history: PropTypes.object,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func,
onHeightUpdate: PropTypes.func,
setExpansion: PropTypes.func,
status: ImmutablePropTypes.map.isRequired,
}
state = {
hidden: true,
}
// Variables.
text = null
// Our constructor preprocesses our status content and turns it into
// an array of React elements, stored in `this.text`.
constructor (props) {
super(props);
const { intl, history, status } = props;
// This creates a document fragment with the DOM contents of our
// status's text and a TreeWalker to walk them.
const range = document.createRange();
range.selectNode(document.body);
const walker = document.createTreeWalker(
range.createContextualFragment(status.get('contentHtml')),
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
{ acceptNode (node) {
const name = node.nodeName;
switch (true) {
case node.parentElement && node.parentElement.nodeName.toUpperCase() === 'A':
return NodeFilter.FILTER_REJECT; // No link children
case node.nodeType === Node.TEXT_NODE:
case name.toUpperCase() === 'A':
case name.toUpperCase() === 'P':
case name.toUpperCase() === 'BR':
case name.toUpperCase() === 'IMG': // Emoji
return NodeFilter.FILTER_ACCEPT;
default:
return NodeFilter.FILTER_SKIP;
}
} },
);
const attachments = status.get('attachments');
const card = (!attachments || !attachments.size) && status.get('card');
this.text = [];
let currentP = [];
// This walks the contents of our status.
while (walker.nextNode()) {
const node = walker.currentNode;
const nodeName = node.nodeName.toUpperCase();
switch (nodeName) {
// If our element is a link, then we process it here.
case 'A':
currentP.push((() => {
// Here we detect what kind of link we're dealing with.
let mention = status.get('mentions') ? status.get('mentions').find(
item => node.href === item.get('url')
) : null;
let tag = status.get('tags') ? status.get('tags').find(
item => node.href === item.get('url')
) : null;
let attachment = attachments ? attachments.find(
item => node.href === item.get('url') || node.href === item.get('text_url') || node.href === item.get('remote_url')
) : null;
let text = node.textContent;
let icon = '';
let type = '';
// We use a switch to select our link type.
switch (true) {
// This handles cards.
case card && node.href === card.get('url'):
text = card.get('title') || intl.formatMessage(messages.card);
icon = 'id-card-o';
return (
<CommonButton
className={'content\card content\button'}
href={node.href}
icon={icon}
key={currentP.length}
showTitle
title={text}
/>
);
// This handles mentions.
case mention && (text.replace(/^@/, '') === mention.get('username') || text.replace(/^@/, '') === mention.get('acct')):
icon = text[0] === '@' ? '@' : '';
text = mention.get('acct').split('@');
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
return (
<CommonLink
className='content\mention content\link'
destination={`/accounts/${mention.get('id')}`}
history={history}
href={node.href}
key={currentP.length}
title={'@' + mention.get('acct')}
>
{icon ? <span className='content\at'>{icon}</span> : null}
<span className='content\username'>{text[0]}</span>
{text[1] ? <span className='content\at'>@</span> : null}
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
</CommonLink>
);
// This handles attachment links.
case !!attachment:
type = attachment.get('type');
switch (type) {
case 'unknown':
text = intl.formatMessage(messages.unknown_attachment);
icon = 'question';
break;
case 'video':
text = intl.formatMessage(messages.video);
icon = 'video-camera';
break;
default:
text = intl.formatMessage(messages.image);
icon = 'picture-o';
break;
}
return (
<CommonButton
className={`content\\${type} content\\button`}
href={node.href}
icon={icon}
key={currentP.length}
showTitle
title={text}
/>
);
// This handles hashtag links.
case !!tag && (text.replace(/^#/, '') === tag.get('name')):
icon = text[0] === '#' ? '#' : '';
text = tag.get('name');
return (
<CommonLink
className='content\tag content\link'
destination={`/timelines/tag/${tag.get('name')}`}
history={history}
href={node.href}
key={currentP.length}
title={intl.formatMessage(messages.hashtag, { name: tag.get('name') })}
>
{icon ? <span className='content\hash'>{icon}</span> : null}
<span className='content\tagname'>{text}</span>
</CommonLink>
);
// This handles all other links.
default:
if (text === node.href && text.length > 23) {
text = text.substr(0, 22) + '…';
}
return (
<CommonLink
className='content\link'
href={node.href}
key={currentP.length}
title={node.href}
>{text}</CommonLink>
);
}
})());
break;
// If our element is an IMG, we only render it if it's an emoji.
case 'IMG':
if (!node.classList.contains('emojione')) break;
currentP.push(
<img
alt={node.alt}
className={'content\emojione'}
draggable={false}
key={currentP.length}
src={node.src}
{...(node.title ? { title: node.title } : {})}
/>
);
break;
// If our element is a BR, we pass it along.
case 'BR':
currentP.push(<br key={currentP.length} />);
break;
// If our element is a P, then we need to start a new paragraph.
// If our paragraph has content, we need to push it first.
case 'P':
if (currentP.length) this.text.push(
<p key={this.text.length}>
{currentP}
</p>
);
currentP = [];
break;
// Otherwise we just push the text.
default:
currentP.push(node.textContent);
}
}
// If there is unpushed paragraph content after walking the entire
// status contents, we push it here.
if (currentP.length) this.text.push(
<p key={this.text.length}>
{currentP}
</p>
);
}
// When our content changes, we need to update the height of the
// status.
componentDidUpdate () {
if (this.props.onHeightUpdate) {
this.props.onHeightUpdate();
}
}
// When the mouse is pressed down, we grab its position.
handleMouseDown = (e) => {
this.startXY = [e.clientX, e.clientY];
}
// When the mouse is raised, we handle the click if it wasn't a part
// of a drag.
handleMouseUp = (e) => {
const { startXY } = this;
const { onClick } = this.props;
const { button, clientX, clientY, target } = e;
// This gets the change in mouse position. If `startXY` isn't set,
// it means that the click originated elsewhere.
if (!startXY) return;
const [ deltaX, deltaY ] = [clientX - startXY[0], clientY - startXY[1]];
// This switch prevents an overly lengthy if.
switch (true) {
// If the button being released isn't the main mouse button, or if
// we don't have a click parsing function, or if the mouse has
// moved more than 5px, OR if the target of the mouse event is a
// button or a link, we do nothing.
case button !== 0:
case !onClick:
case Math.sqrt(deltaX ** 2 + deltaY ** 2) >= 5:
case (
target.matches || target.msMatchesSelector || target.webkitMatchesSelector || (() => void 0)
).call(target, 'button, button *, a, a *'):
break;
// Otherwise, we parse the click.
default:
onClick(e);
break;
}
// This resets our mouse location.
this.startXY = null;
}
// This expands and collapses our spoiler.
handleSpoilerClick = (e) => {
e.preventDefault();
if (this.props.setExpansion) {
this.props.setExpansion(this.props.expanded ? null : true);
} else {
this.setState({ hidden: !this.state.hidden });
}
}
// Renders our component.
render () {
const {
handleMouseDown,
handleMouseUp,
handleSpoilerClick,
text,
} = this;
const {
autoPlayGif,
detailed,
expanded,
handler,
hideMedia,
intl,
letterbox,
onClick,
setExpansion,
status,
} = this.props;
const attachments = status.get('attachments');
const card = status.get('card');
const hidden = setExpansion ? !expanded : this.state.hidden;
const computedClass = classNames('glitch', 'glitch__status__content', {
_actionable: !detailed && onClick,
_rtl: isRtl(status.get('search_index')),
});
let media = null;
let mediaIcon = '';
// This defines our media.
if (!hideMedia) {
// If there aren't any attachments, we try showing a card.
if ((!attachments || !attachments.size) && card) {
media = (
<StatusContentCard
card={card}
className='content\attachments content\card'
fullwidth={detailed}
letterbox={letterbox}
/>
);
mediaIcon = 'id-card-o';
// If any of the attachments are of unknown type, we render an
// unknown attachments list.
} else if (attachments && attachments.some(
(item) => item.get('type') === 'unknown'
)) {
media = (
<StatusContentUnknown
attachments={attachments}
className='content\attachments content\unknown'
fullwidth={detailed}
/>
);
mediaIcon = 'question';
// Otherwise, we display the gallery.
} else if (attachments) {
media = (
<StatusContentGallery
attachments={attachments}
autoPlayGif={autoPlayGif}
className='content\attachments content\gallery'
fullwidth={detailed}
intl={intl}
letterbox={letterbox}
onOpenMedia={handler.openMedia}
onOpenVideo={handler.openVideo}
sensitive={status.get('sensitive')}
standalone={!history}
/>
);
mediaIcon = attachments.getIn([0, 'type']) === 'video' ? 'film' : 'picture-o';
}
}
// Spoiler stuff.
if (status.get('spoiler_text').length > 0) {
// This gets our list of mentions.
const mentionLinks = status.get('mentions').map(mention => {
const text = mention.get('acct').split('@');
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
return (
<CommonLink
className='content\mention content\link'
destination={`/accounts/${mention.get('id')}`}
history={history}
href={mention.get('url')}
key={mention.get('id')}
title={'@' + mention.get('acct')}
>
<span className='content\at'>@</span>
<span className='content\username'>{text[0]}</span>
{text[1] ? <span className='content\at'>@</span> : null}
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
</CommonLink>
);
}).reduce((aggregate, item) => [...aggregate, ' ', item], []);
// Component rendering.
return (
<div className={computedClass}>
<div
className='content\spoiler'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>
<p>
<span
className='content\warning'
dangerouslySetInnerHTML={status.get('spoilerHtml')}
/>
{' '}
<CommonButton
active={!hidden}
className='content\showmore'
icon={hidden && mediaIcon}
onClick={handleSpoilerClick}
showTitle={hidden}
title={intl.formatMessage(messages.show_more)}
>
{hidden ? null : (
<FormattedMessage {...messages.show_less} />
)}
</CommonButton>
</p>
</div>
{hidden ? mentionLinks : null}
<div className='content\contents' hidden={hidden}>
<div
className='content\text'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>{text}</div>
{media}
</div>
</div>
);
// Non-spoiler statuses.
} else {
return (
<div className={computedClass}>
<div className='content\contents'>
<div
className='content\text'
{...(onClick ? {
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
} : {})}
>{text}</div>
{media}
</div>
</div>
);
}
}
}

View File

@@ -0,0 +1,101 @@
@import 'variables';
.glitch.glitch__status__content {
position: relative;
padding: (.75em * 1.35) .75em;
color: $primary-text-color;
direction: ltr; // but see `&.rtl` below
word-wrap: break-word;
overflow: visible;
white-space: pre-wrap;
.content\\contents {
.content\\attachments {
.content\\text + & {
margin-top: (.75em * 1.35);
}
}
&[hidden] {
display: none;
}
.content\\spoiler + & {
margin-top: (.75em * 1.35);
}
}
.content\\emojione {
width: 1.2em;
height: 1.2em;
}
.content\\spoiler,
.content\\text { // text-containing elements
p {
margin-bottom: (.75em * 1.35);
&:last-child {
margin-bottom: 0;
}
}
.content\\link {
color: $ui-secondary-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
/*
For mentions, we only underline the username and instance (not
the @'s).
*/
&.content\\mention {
.content\\at {
color: $glitch-lighter-color;
}
&:hover {
text-decoration: none;
.content\\instance,
.content\\username {
text-decoration: underline;
}
}
}
/*
Similarly, for tags, we only underline the tag name (not the
hash).
*/
&.content\\tag {
.content\\hash {
color: $glitch-lighter-color;
}
&:hover {
text-decoration: none;
.content\\tagname {
text-decoration: underline;
}
}
}
}
}
&._actionable {
.content\\text,
.content\\spoiler {
cursor: pointer;
}
}
&._rtl {
direction: rtl;
}
}

View File

@@ -0,0 +1,70 @@
// <StatusContentUnknown>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/unknown
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class StatusContentUnknown extends ImmutablePureComponent {
// Props.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
fullwidth: PropTypes.bool,
}
render () {
const { attachments, fullwidth } = this.props;
const computedClass = classNames('glitch', 'glitch__status__content__unknown', {
_fullwidth: fullwidth,
});
return (
<ul className={computedClass}>
{attachments.map(attachment => (
<li
className='unknown\attachment'
key={attachment.get('id')}
>
<CommonLink
className='unknown\link'
href={attachment.get('remote_url')}
>
<CommonIcon
className='unknown\icon'
name='link'
/>
{attachment.get('title') || attachment.get('remote_url')}
</CommonLink>
</li>
))}
</ul>
);
}
}

View File

@@ -0,0 +1,141 @@
// <StatusFooter>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/footer
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedDate } from 'react-intl';
// Mastodon imports.
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
import CommonSeparator from 'glitch/components/common/separator';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
public :
{ id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted :
{ id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private :
{ id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct :
{ id: 'privacy.direct.short', defaultMessage: 'Direct' },
permalink:
{ id: 'status.permalink', defaultMessage: 'Permalink' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusFooter extends ImmutablePureComponent {
// Props.
static propTypes = {
application: ImmutablePropTypes.map.isRequired,
datetime: PropTypes.string,
detailed: PropTypes.bool,
href: PropTypes.string,
intl: PropTypes.object.isRequired,
visibility: PropTypes.string,
}
// Rendering.
render () {
const { application, datetime, detailed, href, intl, visibility } = this.props;
const visibilityIcon = {
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[visibility];
const computedClass = classNames('glitch', 'glitch__status__footer', {
_detailed: detailed,
});
// If our status isn't detailed, our footer only contains the
// relative timestamp and visibility information.
if (!detailed) return (
<footer className={computedClass}>
<CommonLink
className='footer\timestamp footer\link'
href={href}
title={intl.formatMessage(messages.permalink)}
><RelativeTimestamp timestamp={datetime} /></CommonLink>
<CommonSeparator className='footer\separator' visible />
<CommonIcon
className='footer\icon'
name={visibilityIcon}
proportional
title={intl.formatMessage(messages[visibility])}
/>
</footer>
);
// Otherwise, we give the full timestamp and include a link to the
// application which posted the status if applicable.
return (
<footer className={computedClass}>
<CommonLink
className='footer\timestamp'
href={href}
title={intl.formatMessage(messages.permalink)}
>
<FormattedDate
value={new Date(datetime)}
hour12={false}
year='numeric'
month='short'
day='2-digit'
hour='2-digit'
minute='2-digit'
/>
</CommonLink>
<CommonSeparator className='footer\separator' visible={!!application} />
{
application ? (
<CommonLink
className='footer\application footer\link'
href={application.get('website')}
>{application.get('name')}</CommonLink>
) : null
}
<CommonSeparator className='footer\separator' visible />
<CommonIcon
name={visibilityIcon}
className='footer\icon'
proportional
title={intl.formatMessage(messages[visibility])}
/>
</footer>
);
}
}

View File

@@ -0,0 +1,18 @@
@import 'variables';
.glitch.glitch__status__footer {
display: block;
height: 1.25em;
font-size: (1.25em / 1.35);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.footer\\link {
color: inherit;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,79 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../../mastodon/components/icon_button';
// Our imports //
import StatusGalleryItem from './item';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
});
@injectIntl
export default class StatusGallery extends React.PureComponent {
static propTypes = {
sensitive: PropTypes.bool,
media: ImmutablePropTypes.list.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
onOpenMedia: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
state = {
visible: !this.props.sensitive,
};
handleOpen = () => {
this.setState({ visible: !this.state.visible });
}
handleClick = (index) => {
this.props.onOpenMedia(this.props.media, index);
}
render () {
const { media, intl, sensitive, letterbox, fullwidth } = this.props;
let children;
if (!this.state.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 = (
<div role='button' tabIndex='0' 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>
</div>
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <StatusGalleryItem key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} letterbox={letterbox} />);
}
return (
<div className={`media-gallery ${fullwidth ? 'full-width' : ''}`} style={{ height: `${this.props.height}px` }}>
<div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div>
{children}
</div>
);
}
}

View File

@@ -1,152 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
// Mastodon imports //
import { isIOS } from '../../../../mastodon/is_mobile';
export default class StatusGalleryItem extends React.PureComponent {
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
autoPlayGif: PropTypes.bool.isRequired,
};
handleMouseEnter = (e) => {
if (this.hoverToPlay()) {
e.target.play();
}
}
handleMouseLeave = (e) => {
if (this.hoverToPlay()) {
e.target.pause();
e.target.currentTime = 0;
}
}
hoverToPlay () {
const { attachment, autoPlayGif } = this.props;
return !autoPlayGif && attachment.get('type') === 'gifv';
}
handleClick = (e) => {
const { index, onClick } = this.props;
if (e.button === 0) {
e.preventDefault();
onClick(index);
}
e.stopPropagation();
}
render () {
const { attachment, index, size, letterbox } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
let thumbnail = '';
if (attachment.get('type') === 'image') {
const previewUrl = attachment.get('preview_url');
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
thumbnail = (
<a
className='media-gallery__item-thumbnail'
href={attachment.get('remote_url') || originalUrl}
onClick={this.handleClick}
target='_blank'
>
<img className={letterbox ? 'letterbox' : ''} src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && this.props.autoPlayGif;
thumbnail = (
<div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}>
<video
className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`}
role='application'
src={attachment.get('url')}
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
loop
muted
/>
<span className='media-gallery__gifv__label'>GIF</span>
</div>
);
}
return (
<div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
{thumbnail}
</div>
);
}
}

View File

@@ -1,146 +0,0 @@
/*
`<StatusHeader>`
================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
// Mastodon imports.
import Avatar from '../../../mastodon/components/avatar';
import AvatarOverlay from '../../../mastodon/components/avatar_overlay';
import DisplayName from '../../../mastodon/components/display_name';
import IconButton from '../../../mastodon/components/icon_button';
import VisibilityIcon from './visibility_icon';
// * * * * * * * //
// Initial setup
// -------------
// Messages for use with internationalization stuff.
const messages = defineMessages({
collapse: { id: 'status.collapse', defaultMessage: 'Collapse' },
uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' },
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
// * * * * * * * //
// The component
// -------------
@injectIntl
export default class StatusHeader extends React.PureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map,
mediaIcon: PropTypes.string,
collapsible: PropTypes.bool,
collapsed: PropTypes.bool,
parseClick: PropTypes.func.isRequired,
setExpansion: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
// Handles clicks on collapsed button
handleCollapsedClick = (e) => {
const { collapsed, setExpansion } = this.props;
if (e.button === 0) {
setExpansion(collapsed ? null : false);
e.preventDefault();
}
}
// Handles clicks on account name/image
handleAccountClick = (e) => {
const { status, parseClick } = this.props;
parseClick(e, `/accounts/${+status.getIn(['account', 'id'])}`);
}
// Rendering.
render () {
const {
status,
friend,
mediaIcon,
collapsible,
collapsed,
intl,
} = this.props;
const account = status.get('account');
return (
<header className='status__info'>
<a
href={account.get('url')}
target='_blank'
className='status__avatar'
onClick={this.handleAccountClick}
>
{
friend ? (
<AvatarOverlay account={account} friend={friend} />
) : (
<Avatar account={account} size={48} />
)
}
</a>
<a
href={account.get('url')}
target='_blank'
className='status__display-name'
onClick={this.handleAccountClick}
>
<DisplayName account={account} />
</a>
<div className='status__info__icons'>
{mediaIcon ? (
<i
className={`fa fa-fw fa-${mediaIcon}`}
aria-hidden='true'
/>
) : null}
{(
<VisibilityIcon visibility={status.get('visibility')} />
)}
{collapsible ? (
<IconButton
className='status__collapse-button'
animate flip
active={collapsed}
title={
collapsed ?
intl.formatMessage(messages.uncollapse) :
intl.formatMessage(messages.collapse)
}
icon='angle-double-up'
onClick={this.handleCollapsedClick}
/>
) : null}
</div>
</header>
);
}
}

View File

@@ -0,0 +1,76 @@
// <StatusHeader>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/header
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Our imports.
import CommonAvatar from 'glitch/components/common/avatar';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component:
// --------------
export default class StatusHeader extends ImmutablePureComponent {
// Props.
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
comrade: ImmutablePropTypes.map,
history: PropTypes.object,
};
// Renders our component.
render () {
const {
account,
comrade,
history,
} = this.props;
// This displays our header.
return (
<header className='glitch glitch__status__header'>
<CommonLink
className='header\link'
destination={`/accounts/${account.get('id')}`}
history={history}
href={account.get('url')}
>
<CommonAvatar
account={account}
className='header\avatar'
comrade={comrade}
/>
</CommonLink>
<b
className='header\display-name'
dangerouslySetInnerHTML={{
__html: account.get('display_name_html'),
}}
/>
<code className='header\account'>@{account.get('acct')}</code>
</header>
);
}
}

View File

@@ -0,0 +1,45 @@
@import 'variables';
.glitch.glitch__status__header {
display: block;
height: 3.35em;
/*
Note that the computed value of `em` changes for `.account`, since it
has a different font-size.
*/
.header\\account,
.header\\display-name {
display: block;
border: none; // masto compat.
padding: 0; // masto compat.
max-width: none; // masto compat.
height: 1.35em;
overflow: hidden;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
This means that the heights of the account and display name together
are 2.6em.
*/
.header\\account {
font-size: (1.25em / 1.35); // approx. .925em
}
.header\\avatar {
float: left;
margin-right: .75em;
width: 3.35em;
height: 3.35em;
}
.header\\display-name {
padding-top: .75em;
}
}

View File

@@ -1,342 +1,128 @@
/*
// <Status>
// ========
`<Status>`
==========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status
Original file by @gargron@mastodon.social et al as part of
tootsuite/mastodon. *Heavily* rewritten (and documented!) by
@kibi@glitch.social as a part of glitch-soc/mastodon. The following
features have been added:
// For more information, please contact:
// @kibi@glitch.social
- Better separating the "guts" of statuses from their wrapper(s)
- Collapsing statuses
- Moving images inside of CWs
// * * * * * * * //
A number of aspects of this original file have been split off into
their own components for better maintainance; for these, see:
// Imports
// -------
- <StatusHeader>
- <StatusPrepend>
…And, of course, the other <Status>-related components as well.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
// Mastodon imports //
import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
// Mastodon imports.
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
// Our imports //
import StatusPrepend from './prepend';
import StatusHeader from './header';
import StatusContent from './content';
// Our imports.
import StatusActionBar from './action_bar';
import StatusGallery from './gallery';
import StatusPlayer from './player';
import NotificationOverlayContainer from '../notification/overlay/container';
import StatusContent from './content';
import StatusFooter from './footer';
import StatusHeader from './header';
import StatusMissing from './missing';
import StatusNav from './nav';
import StatusPrepend from './prepend';
import CommonButton from 'glitch/components/common/button';
/* * * * */
// Stylesheet imports.
import './style';
/*
// * * * * * * * //
The `<Status>` component:
-------------------------
// Initial setup
// -------------
The `<Status>` component is a container for statuses. It consists of a
few parts:
// Holds our localization messages.
const messages = defineMessages({
detailed:
{ id: 'status.detailed', defaultMessage: 'Detailed view' },
});
- The `<StatusPrepend>`, which contains tangential information about
the status, such as who reblogged it.
- The `<StatusHeader>`, which contains the avatar and username of the
status author, as well as a media icon and the "collapse" toggle.
- The `<StatusContent>`, which contains the content of the status.
- The `<StatusActionBar>`, which provides actions to be performed
on statuses, like reblogging or sending a reply.
// * * * * * * * //
### Context
- __`router` (`PropTypes.object`) :__
We need to get our router from the surrounding React context.
### Props
- __`id` (`PropTypes.number`) :__
The id of the status.
- __`status` (`ImmutablePropTypes.map`) :__
The status object, straight from the store.
- __`account` (`ImmutablePropTypes.map`) :__
Don't be confused by this one! This is **not** the account which
posted the status, but the associated account with any further
action (eg, a reblog or a favourite).
- __`settings` (`ImmutablePropTypes.map`) :__
These are our local settings, fetched from our store. We need this
to determine how best to collapse our statuses, among other things.
- __`me` (`PropTypes.number`) :__
This is the id of the currently-signed-in user.
- __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
`onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
`onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
These are all functions passed through from the
`<StatusContainer>`. We don't deal with them directly here.
- __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__
These tell whether or not the user has modals activated for
reblogging and deleting statuses. They are used by the `onReblog`
and `onDelete` functions, but we don't deal with them here.
- __`autoPlayGif` (`PropTypes.bool`) :__
This tells the frontend whether or not to autoplay gifs!
- __`muted` (`PropTypes.bool`) :__
This has nothing to do with a user or conversation mute! "Muted" is
what Mastodon internally calls the subdued look of statuses in the
notifications column. This should be `true` for notifications, and
`false` otherwise.
- __`collapse` (`PropTypes.bool`) :__
This prop signals a directive from a higher power to (un)collapse
a status. Most of the time it should be `undefined`, in which case
we do nothing.
- __`prepend` (`PropTypes.string`) :__
The type of prepend: `'reblogged_by'`, `'reblog'`, or
`'favourite'`.
- __`withDismiss` (`PropTypes.bool`) :__
Whether or not the status can be dismissed. Used for notifications.
- __`intersectionObserverWrapper` (`PropTypes.object`) :__
This holds our intersection observer. In Mastodon parlance,
an "intersection" is just when the status is viewable onscreen.
### State
- __`isExpanded` :__
Should be either `true`, `false`, or `null`. The meanings of
these values are as follows:
- __`true` :__ The status contains a CW and the CW is expanded.
- __`false` :__ The status is collapsed.
- __`null` :__ The status is not collapsed or expanded.
- __`isIntersecting` :__
This boolean tells us whether or not the status is currently
onscreen.
- __`isHidden` :__
This boolean tells us if the status has been unrendered to save
CPUs.
*/
// The component
// -------------
export default class Status extends ImmutablePureComponent {
static contextTypes = {
router : PropTypes.object,
};
// Props, and state.
static propTypes = {
id : PropTypes.number,
status : ImmutablePropTypes.map,
account : ImmutablePropTypes.map,
settings : ImmutablePropTypes.map,
notification : ImmutablePropTypes.map,
me : PropTypes.number,
onFavourite : PropTypes.func,
onReblog : PropTypes.func,
onModalReblog : PropTypes.func,
onDelete : PropTypes.func,
onPin : PropTypes.func,
onMention : PropTypes.func,
onMute : PropTypes.func,
onMuteConversation : PropTypes.func,
onBlock : PropTypes.func,
onEmbed : PropTypes.func,
onHeightChange : PropTypes.func,
onReport : PropTypes.func,
onOpenMedia : PropTypes.func,
onOpenVideo : PropTypes.func,
reblogModal : PropTypes.bool,
deleteModal : PropTypes.bool,
autoPlayGif: PropTypes.bool,
comrade: ImmutablePropTypes.map,
deleteModal: PropTypes.bool,
detailed: PropTypes.bool,
handler: PropTypes.objectOf(PropTypes.func).isRequired,
history: PropTypes.object,
index: PropTypes.number,
id: PropTypes.number,
listLength: PropTypes.number,
me: PropTypes.number,
muted: PropTypes.bool,
collapse : PropTypes.bool,
prepend: PropTypes.string,
withDismiss : PropTypes.bool,
reblogModal: PropTypes.bool,
setDetail: PropTypes.func,
settings: ImmutablePropTypes.map,
status: ImmutablePropTypes.map,
intersectionObserverWrapper: PropTypes.object,
};
intl : PropTypes.object,
}
state = {
isExpanded: null,
isIntersecting: true,
isHidden: false,
markedForDelete : false,
}
/*
// Instance variables.
componentMounted = false;
### Implementation
#### `updateOnProps` and `updateOnStates`.
`updateOnProps` and `updateOnStates` tell the component when to update.
We specify them explicitly because some of our props are dynamically=
generated functions, which would otherwise always trigger an update.
Of course, this means that if we add an important prop, we will need
to remember to specify it here.
*/
updateOnProps = [
'status',
'account',
'settings',
'prepend',
'me',
'boostModal',
'autoPlayGif',
'muted',
'collapse',
'notification',
]
updateOnStates = [
'isExpanded',
'markedForDelete',
]
/*
#### `componentWillReceiveProps()`.
If our settings have changed to disable collapsed statuses, then we
need to make sure that we uncollapse every one. We do that by watching
for changes to `settings.collapsed.enabled` in
`componentWillReceiveProps()`.
We also need to watch for changes on the `collapse` prop---if this
changes to anything other than `undefined`, then we need to collapse or
uncollapse our status accordingly.
*/
componentWillReceiveProps (nextProps) {
if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
if (this.state.isExpanded === false) {
this.setExpansion(null);
}
} else if (
nextProps.collapse !== this.props.collapse &&
nextProps.collapse !== undefined
) this.setExpansion(nextProps.collapse ? false : null);
// Prior to mounting, we fetch the status's card if this is a
// detailed status and we don't already have it.
componentWillMount () {
const { detailed, handler, status } = this.props;
if (!status.get('card') && detailed) handler.fetchCard(status);
}
/*
#### `componentDidMount()`.
When mounting, we just check to see if our status should be collapsed,
and collapse it if so. We don't need to worry about whether collapsing
is enabled here, because `setExpansion()` already takes that into
account.
The cases where a status should be collapsed are:
- The `collapse` prop has been set to `true`
- The user has decided in local settings to collapse all statuses.
- The user has decided to collapse all notifications ('muted'
statuses).
- The user has decided to collapse long statuses and the status is
over 400px (without media, or 650px with).
- The status is a reply and the user has decided to collapse all
replies.
- The status contains media and the user has decided to collapse all
statuses with media.
We also start up our intersection observer to monitor our statuses.
`componentMounted` lets us know that everything has been set up
properly and our intersection observer is good to go.
*/
// On mounting, we start up our intersection observer.
// `componentMounted` tells us everything worked out OK.
componentDidMount () {
const { node, handleIntersection } = this;
const {
status,
settings,
collapse,
muted,
id,
intersectionObserverWrapper,
} = this.props;
const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
if (
collapse ||
autoCollapseSettings.get('all') || (
autoCollapseSettings.get('notifications') && muted
) || (
autoCollapseSettings.get('lengthy') &&
node.clientHeight > (
status.get('media_attachments').size && !muted ? 650 : 400
)
) || (
autoCollapseSettings.get('replies') &&
status.get('in_reply_to_id', null) !== null
) || (
autoCollapseSettings.get('media') &&
!(status.get('spoiler_text').length) &&
status.get('media_attachments').size
)
) this.setExpansion(false);
const { handleIntersection, node } = this;
const { id, intersectionObserverWrapper } = this.props;
if (!intersectionObserverWrapper) return;
else intersectionObserverWrapper.observe(
id,
node,
handleIntersection
);
this.componentMounted = true;
}
/*
#### `shouldComponentUpdate()`.
If the status is about to be both offscreen (not intersecting) and
hidden, then we only need to update it if it's not that way currently.
If the status is moving from offscreen to onscreen, then we *have* to
re-render, so that we can unhide the element if necessary.
If neither of these cases are true, we can leave it up to our
`updateOnProps` and `updateOnStates` arrays.
*/
// If the status is about to be both offscreen (not intersecting)
// and hidden, then we don't bother updating unless it's not already
// that way currently. Alternatively, if we're moving from offscreen
// to onscreen, we *have* to re-render. As a default case we just
// rely on `updateOnProps` and `updateOnStates` via the
// built-in `shouldComponentUpdate()` function.
shouldComponentUpdate (nextProps, nextState) {
switch (true) {
case !nextState.isIntersecting && nextState.isHidden:
return this.state.isIntersecting || !this.state.isHidden;
switch (true) {
case this.state.isIntersecting:
case !this.state.isHidden:
case nextProps.listLength !== this.props.listLength:
case nextProps.index !== this.props.index:
return true;
default:
return false;
}
case nextState.isIntersecting && !this.state.isIntersecting:
return true;
default:
@@ -344,419 +130,259 @@ If neither of these cases are true, we can leave it up to our
}
}
/*
#### `componentDidUpdate()`.
If our component is being rendered for any reason and an update has
triggered, this will save its height.
This is, frankly, a bit overkill, as the only instance when we
actually *need* to update the height right now should be when the
value of `isExpanded` has changed. But it makes for more readable
code and prevents bugs in the future where the height isn't set
properly after some change.
*/
componentDidUpdate () {
if (
this.state.isIntersecting || !this.state.isHidden
) this.saveHeight();
// If our component is about to update and is detailed, we request
// its card if we don't have it.
componentWillUpdate (nextProps) {
const { detailed, handler, status } = this.props;
if (!status.get('card') && nextProps.detailed && !detailed) {
handler.fetchCard(status);
}
}
/*
#### `componentWillUnmount()`.
If our component is about to unmount, then we'd better unset
`this.componentMounted`.
*/
// If the component is updated for any reason we save the height.
componentDidUpdate () {
const { isHidden, isIntersecting } = this.state;
if (isIntersecting || !isHidden) this.saveHeight();
}
// If our component is about to unmount, we'd better unset
// `componentMounted` lol.
componentWillUnmount () {
const { node } = this;
const { id, intersectionObserverWrapper } = this.props;
intersectionObserverWrapper.unobserve(id, node);
this.componentMounted = false;
}
/*
#### `handleIntersection()`.
`handleIntersection()` either hides the status (if it is offscreen) or
unhides it (if it is onscreen). It's called by
`intersectionObserverWrapper.observe()`.
If our status isn't intersecting, we schedule an idle task (using the
aptly-named `scheduleIdleTask()`) to hide the status at the next
available opportunity.
tootsuite/mastodon left us with the following enlightening comment
regarding this function:
> Edge 15 doesn't support isIntersecting, but we can infer it
It then implements a polyfill (intersectionRect.height > 0) which isn't
actually sufficient. The short answer is, this behaviour isn't really
supported on Edge but we can get kinda close.
*/
// Doesn't quite work on Edge 15 but it gets close. This tells us if
// our status is onscreen, and if not we hide it at the next
// available opportunity. This isn't a huge deal (but it saves some
// rendering cycles if we don't have as much DOM) so we schedule
// it using `scheduleIdleTask`.
handleIntersection = (entry) => {
const isIntersecting = (
typeof entry.isIntersecting === 'boolean' ?
entry.isIntersecting :
entry.intersectionRect.height > 0
);
this.setState(
(prevState) => {
this.setState((prevState) => {
if (prevState.isIntersecting && !isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {
isIntersecting : isIntersecting,
isIntersecting,
isHidden: false,
};
}
);
});
}
/*
#### `hideIfNotIntersecting()`.
This function will hide the status if we're still not intersecting.
Hiding the status means that it will just render an empty div instead
of actual content, which saves RAMS and CPUs or some such.
*/
// Because we scheduled toot-hiding as an idle task (see above), we
// *do* need to ensure that it's still not intersecting before we
// hide it lol.
hideIfNotIntersecting = () => {
if (!this.componentMounted) return;
this.setState(
(prevState) => ({ isHidden: !prevState.isIntersecting })
);
this.setState((prevState) => ({
isHidden: !prevState.isIntersecting,
}));
}
/*
#### `saveHeight()`.
`saveHeight()` saves the height of our status so that when whe hide it
we preserve its dimensions. We only want to store our height, though,
if our status has content (otherwise, it would imply that it is
already hidden).
*/
// `saveHeight()` saves the status height so that we preserve its
// dimensions when it's being hidden.
saveHeight = () => {
if (this.node && this.node.children.length) {
this.height = this.node.getBoundingClientRect().height;
}
}
/*
#### `setExpansion()`.
`setExpansion()` sets the value of `isExpanded` in our state. It takes
one argument, `value`, which gives the desired value for `isExpanded`.
The default for this argument is `null`.
`setExpansion()` automatically checks for us whether toot collapsing
is enabled, so we don't have to.
We use a `switch` statement to simplify our code.
*/
// `setExpansion` handles expanding and collapsing statuses. Note
// that `isExpanded` is a *trinary* value:
setExpansion = (value) => {
const { detailed } = this.props;
switch (true) {
// A value of `null` or `undefined` means the status should be
// neither expanded or collapsed.
case value === undefined || value === null:
this.setState({ isExpanded: null });
break;
case !value && this.props.settings.getIn(['collapsed', 'enabled']):
this.setState({ isExpanded: false });
// A value of `false` means that the status should be collapsed.
case !value:
if (!detailed) this.setState({ isExpanded: false });
else this.setState({ isExpanded: null }); // fallback
break;
// A value of `true` means that the status should be expanded.
case !!value:
this.setState({ isExpanded: true });
break;
}
}
/*
#### `handleRef()`.
`handleRef()` just saves a reference to our status node to `this.node`.
It also saves our height, in case the height of our node has changed.
*/
// Stores our node and saves its height.
handleRef = (node) => {
this.node = node;
this.saveHeight();
}
/*
#### `parseClick()`.
`parseClick()` takes a click event and responds appropriately.
If our status is collapsed, then clicking on it should uncollapse it.
If `Shift` is held, then clicking on it should collapse it.
Otherwise, we open the url handed to us in `destination`, if
applicable.
*/
parseClick = (e, destination) => {
const { router } = this.context;
const { status } = this.props;
const { isExpanded } = this.state;
if (!router) return;
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
if (e.button === 0) {
if (isExpanded === false) this.setExpansion(null);
else if (e.shiftKey) {
this.setExpansion(false);
document.getSelection().removeAllRanges();
} else router.history.push(destination);
// `handleClick()` handles all clicking stuff. We route links and
// make our status detailed if it isn't already.
handleClick = (e) => {
const { detailed, history, id, setDetail, status } = this.props;
if (!history || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
if (setDetail) setDetail(detailed ? null : id);
else history.push(`/statuses/${status.get('id')}`);
e.preventDefault();
}
}
/*
#### `render()`.
`render()` actually puts our element on the screen. The particulars of
this operation are further explained in the code below.
*/
// Puts our element on the screen.
render () {
const {
parseClick,
setExpansion,
saveHeight,
handleRef,
handleClick,
saveHeight,
setExpansion,
} = this;
const { router } = this.context;
const {
status,
account,
settings,
collapsed,
autoPlayGif,
comrade,
detailed,
handler,
history,
index,
intl,
listLength,
me,
muted,
prepend,
intersectionObserverWrapper,
onOpenVideo,
onOpenMedia,
autoPlayGif,
notification,
...other
setDetail,
settings,
status,
} = this.props;
const { isExpanded, isIntersecting, isHidden } = this.state;
let background = null;
let attachments = null;
let media = null;
let mediaIcon = null;
/*
If we don't have a status, then we don't render anything.
*/
const {
isExpanded,
isHidden,
isIntersecting,
} = this.state;
let account = status.get('account');
let computedClass = 'glitch glitch__status';
let conditionalProps = {};
let selectorAttribs = {};
// If there's no status, we can't render lol.
if (status === null) {
return null;
return <StatusMissing />;
}
/*
// Here are extra data-* attributes for use with CSS selectors.
// We don't use these but users can via UserStyles.
selectorAttribs = {
'data-status-by': `@${account.get('acct')}`,
};
if (prepend && comrade) {
selectorAttribs[`data-${prepend === 'favourite' ? 'favourited' : 'boosted'}-by`] = `@${comrade.get('acct')}`;
}
If our status is offscreen and hidden, then we render an empty <div> in
its place. We fill it with "content" but note that opacity is set to 0.
// If our index and list length have been set, we can set the
// corresponding ARIA attributes.
if (isFinite(index) && isFinite(listLength)) conditionalProps = {
'aria-posinset': index,
'aria-setsize': listLength,
};
*/
// This sets our class names.
computedClass = classNames('glitch', 'glitch__status', {
_detailed: detailed,
_muted: muted,
}, `_${status.get('visibility')}`);
// If our status is offscreen and hidden, we render an empty div.
if (!isIntersecting && isHidden) {
return (
<div
ref={this.handleRef}
<article
{...conditionalProps}
data-id={status.get('id')}
ref={handleRef}
style={{
height: `${this.height}px`,
opacity: 0,
overflow: 'hidden',
visibility: 'hidden',
}}
tabIndex='0'
>
{
status.getIn(['account', 'display_name']) ||
status.getIn(['account', 'username'])
}
<div hidden>
{account.get('display_name') || account.get('username')}
{' '}
{status.get('content')}
</div>
</article>
);
}
/*
If user backgrounds for collapsed statuses are enabled, then we
initialize our background accordingly. This will only be rendered if
the status is collapsed.
*/
if (
settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])
) background = status.getIn(['account', 'header']);
/*
This handles our media attachments. Note that we don't show media on
muted (notification) statuses. If the media type is unknown, then we
simply ignore it.
After we have generated our appropriate media element and stored it in
`media`, we snatch the thumbnail to use as our `background` if media
backgrounds for collapsed statuses are enabled.
*/
attachments = status.get('media_attachments');
if (attachments.size && !muted) {
if (attachments.some((item) => item.get('type') === 'unknown')) {
} else if (
attachments.getIn([0, 'type']) === 'video'
) {
media = ( // Media type is 'video'
<StatusPlayer
media={attachments.get(0)}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenVideo={onOpenVideo}
/>
);
mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv'
media = (
<StatusGallery
media={attachments}
sensitive={status.get('sensitive')}
letterbox={settings.getIn(['media', 'letterbox'])}
fullwidth={settings.getIn(['media', 'fullwidth'])}
height={250}
onOpenMedia={onOpenMedia}
autoPlayGif={autoPlayGif}
/>
);
mediaIcon = 'picture-o';
}
if (
!status.get('sensitive') &&
!(status.get('spoiler_text').length > 0) &&
settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
) background = attachments.getIn([0, 'preview_url']);
}
/*
Here we prepare extra data-* attributes for CSS selectors.
Users can use those for theming, hiding avatars etc via UserStyle
*/
const selectorAttribs = {
'data-status-by': `@${status.getIn(['account', 'acct'])}`,
};
if (prepend && account) {
const notifKind = {
favourite: 'favourited',
reblog: 'boosted',
reblogged_by: 'boosted',
}[prepend];
selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
}
/*
Finally, we can render our status. We just put the pieces together
from above. We only render the action bar if the status isn't
collapsed.
*/
// Otherwise, we can render our status!
return (
<article
className={
`status${
muted ? ' muted' : ''
} status-${status.get('visibility')}${
isExpanded === false ? ' collapsed' : ''
}${
isExpanded === false && background ? ' has-background' : ''
}${
this.state.markedForDelete ? ' marked-for-delete' : ''
}`
}
style={{
backgroundImage: (
isExpanded === false && background ?
`url(${background})` :
'none'
),
}}
className={computedClass}
{...conditionalProps}
data-id={status.get('id')}
ref={handleRef}
{...selectorAttribs}
tabIndex='0'
>
{prepend && account ? (
{prepend && comrade ? (
<StatusPrepend
comrade={comrade}
history={history}
type={prepend}
account={account}
parseClick={parseClick}
notificationId={this.props.notificationId}
/>
) : null}
{setDetail ? (
<CommonButton
active={detailed}
className='status\detail status\button'
icon={detailed ? 'minus' : 'plus'}
onClick={handleClick}
title={intl.formatMessage(messages.detailed)}
/>
) : null}
<StatusHeader
status={status}
friend={account}
mediaIcon={mediaIcon}
collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isExpanded === false}
parseClick={parseClick}
setExpansion={setExpansion}
account={account}
comrade={comrade}
history={history}
/>
<StatusContent
status={status}
media={media}
mediaIcon={mediaIcon}
autoPlayGif={autoPlayGif}
detailed={detailed}
expanded={isExpanded}
setExpansion={setExpansion}
handler={handler}
hideMedia={muted}
history={history}
intl={intl}
letterbox={settings.getIn(['media', 'letterbox'])}
onClick={handleClick}
onHeightUpdate={saveHeight}
parseClick={parseClick}
disabled={!router}
/>
{isExpanded !== false ? (
<StatusActionBar
{...other}
setExpansion={setExpansion}
status={status}
account={status.get('account')}
/>
) : null}
{notification ? (
<NotificationOverlayContainer
notification={notification}
<StatusFooter
application={status.get('application')}
datetime={status.get('created_at')}
detailed={detailed}
href={status.get('url')}
intl={intl}
visibility={status.get('visibility')}
/>
<StatusActionBar
detailed={detailed}
handler={handler}
history={history}
intl={intl}
me={me}
status={status}
/>
{detailed ? (
<StatusNav id={status.get('id')} intl={intl} />
) : null}
</article>
);

View File

@@ -0,0 +1,33 @@
// <StatusMissing>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/missing
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import React from 'react';
import { FormattedMessage } from 'react-intl';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
const StatusMissing = () => (
<div className='glitch glitch__status__missing'>
<FormattedMessage id='missing_indicator.label' defaultMessage='Not found' />
</div>
);
export default StatusMissing;

View File

@@ -0,0 +1,95 @@
// <StatusNav>
// ========
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/nav
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports
// -------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Localization messages.
const messages = defineMessages({
conversation:
{ id : 'status.view_conversation', defaultMessage : 'View conversation' },
reblogs:
{ id : 'status.view_reblogs', defaultMessage : 'View reblogs' },
favourites:
{ id : 'status.view_favourites', defaultMessage : 'View favourites' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusNav extends ImmutablePureComponent {
// Props.
static propTypes = {
id: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
}
// Rendering.
render () {
const { id, intl } = this.props;
return (
<nav className='glitch glitch__status__nav'>
<CommonLink
className='nav\conversation'
destination={`/statuses/${id}`}
title={intl.formatMessage(messages.conversation)}
>
<CommonIcon
className='nav\icon'
name='comments-o'
/>
</CommonLink>
<CommonLink
className='nav\reblogs'
destination={`/statuses/${id}/reblogs`}
title={intl.formatMessage(messages.reblogs)}
>
<CommonIcon
className='nav\icon'
name='retweet'
/>
</CommonLink>
<CommonLink
className='nav\favourites'
destination={`/statuses/${id}/favourites`}
title={intl.formatMessage(messages.favourites)}
>
<CommonIcon
className='nav\icon'
name='star'
/>
</CommonLink>
</nav>
);
}
}

View File

@@ -1,203 +0,0 @@
// Package imports //
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
// Mastodon imports //
import IconButton from '../../../mastodon/components/icon_button';
import { isIOS } from '../../../mastodon/is_mobile';
const messages = defineMessages({
toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
@injectIntl
export default class StatusPlayer extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
letterbox: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number,
sensitive: PropTypes.bool,
intl: PropTypes.object.isRequired,
autoplay: PropTypes.bool,
onOpenVideo: PropTypes.func.isRequired,
};
static defaultProps = {
height: 110,
};
state = {
visible: !this.props.sensitive,
preview: true,
muted: true,
hasAudio: true,
videoError: false,
};
handleClick = () => {
this.setState({ muted: !this.state.muted });
}
handleVideoClick = (e) => {
e.stopPropagation();
const node = this.video;
if (node.paused) {
node.play();
} else {
node.pause();
}
}
handleOpen = () => {
this.setState({ preview: !this.state.preview });
}
handleVisibility = () => {
this.setState({
visible: !this.state.visible,
preview: true,
});
}
handleExpand = () => {
this.video.pause();
this.props.onOpenVideo(this.props.media, this.video.currentTime);
}
setRef = (c) => {
this.video = c;
}
handleLoadedData = () => {
if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
componentDidMount () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentDidUpdate () {
if (!this.video) {
return;
}
this.video.addEventListener('loadeddata', this.handleLoadedData);
this.video.addEventListener('error', this.handleVideoError);
}
componentWillUnmount () {
if (!this.video) {
return;
}
this.video.removeEventListener('loadeddata', this.handleLoadedData);
this.video.removeEventListener('error', this.handleVideoError);
}
render () {
const { media, intl, letterbox, fullwidth, height, sensitive, autoplay } = this.props;
let spoilerButton = (
<div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
<IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
</div>
);
let expandButton = !this.context.router ? '' : (
<div className='status__video-player-expand'>
<IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
</div>
);
let muteButton = '';
if (this.state.hasAudio) {
muteButton = (
<div className='status__video-player-mute'>
<IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
</div>
);
}
if (!this.state.visible) {
if (sensitive) {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
} else {
return (
<div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}>
{spoilerButton}
<span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
<span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
</div>
);
}
}
if (this.state.preview && !autoplay) {
return (
<div role='button' tabIndex='0' className={`media-spoiler-video ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
{spoilerButton}
<div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
</div>
);
}
if (this.state.videoError) {
return (
<div style={{ height: `${height}px` }} className='video-error-cover' >
<span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
</div>
);
}
return (
<div className={`status__video-player ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px` }}>
{spoilerButton}
{muteButton}
{expandButton}
<video
className={`status__video-player-video${letterbox ? ' letterbox' : ''}`}
role='button'
tabIndex='0'
ref={this.setRef}
src={media.get('url')}
autoPlay={!isIOS()}
loop
muted={this.state.muted}
onClick={this.handleVideoClick}
/>
</div>
);
}
}

View File

@@ -1,165 +0,0 @@
/*
`<StatusPrepend>`
=================
Originally a part of `<Status>`, but extracted into a separate
component for better documentation and maintainance by
@kibi@glitch.social as a part of glitch-soc/mastodon.
*/
/* * * * */
/*
Imports:
--------
*/
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import escapeTextContentForBrowser from 'escape-html';
import { FormattedMessage } from 'react-intl';
// Mastodon imports //
import emojify from '../../../mastodon/emoji';
/* * * * */
/*
The `<StatusPrepend>` component:
--------------------------------
The `<StatusPrepend>` component holds a status's prepend, ie the text
that says “X reblogged this,” etc. It is represented by an `<aside>`
element.
### Props
- __`type` (`PropTypes.string`) :__
The type of prepend. One of `'reblogged_by'`, `'reblog'`,
`'favourite'`.
- __`account` (`ImmutablePropTypes.map`) :__
The account associated with the prepend.
- __`parseClick` (`PropTypes.func.isRequired`) :__
Our click parsing function.
*/
export default class StatusPrepend extends React.PureComponent {
static propTypes = {
type: PropTypes.string.isRequired,
account: ImmutablePropTypes.map.isRequired,
parseClick: PropTypes.func.isRequired,
notificationId: PropTypes.number,
};
/*
### Implementation
#### `handleClick()`.
This is just a small wrapper for `parseClick()` that gets fired when
an account link is clicked.
*/
handleClick = (e) => {
const { account, parseClick } = this.props;
parseClick(e, `/accounts/${+account.get('id')}`);
}
/*
#### `<Message>`.
`<Message>` is a quick functional React component which renders the
actual prepend message based on our provided `type`. First we create a
`link` for the account's name, and then use `<FormattedMessage>` to
generate the message.
*/
Message = () => {
const { type, account } = this.props;
let link = (
<a
onClick={this.handleClick}
href={account.get('url')}
className='status__display-name'
>
<b
dangerouslySetInnerHTML={{
__html : emojify(escapeTextContentForBrowser(
account.get('display_name') || account.get('username')
)),
}}
/>
</a>
);
switch (type) {
case 'reblogged_by':
return (
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} boosted'
values={{ name : link }}
/>
);
case 'favourite':
return (
<FormattedMessage
id='notification.favourite'
defaultMessage='{name} favourited your status'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
id='notification.reblog'
defaultMessage='{name} boosted your status'
values={{ name : link }}
/>
);
}
return null;
}
/*
#### `render()`.
Our `render()` is incredibly simple; we just render the icon and then
the `<Message>` inside of an <aside>.
*/
render () {
const { Message } = this;
const { type } = this.props;
return !type ? null : (
<aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}>
<div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
<i
className={`fa fa-fw fa-${
type === 'favourite' ? 'star star-icon' : 'retweet'
} status__prepend-icon`}
/>
</div>
<Message />
</aside>
);
}
}

View File

@@ -0,0 +1,99 @@
// <StatusPrepend>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/header
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
// Our imports.
import CommonIcon from 'glitch/components/common/icon';
import CommonLink from 'glitch/components/common/link';
// Stylesheet imports.
import './style';
// * * * * * * * //
// The component
// -------------
export default class StatusPrepend extends React.PureComponent {
// Props.
static propTypes = {
comrade: ImmutablePropTypes.map.isRequired,
history: PropTypes.object,
type: PropTypes.string.isRequired,
};
// This is a quick functional React component to get the prepend
// message.
Message = () => {
const { comrade, history, type } = this.props;
let link = (
<CommonLink
className='prepend\comrade'
destination={`/accounts/${comrade.get('id')}`}
history={history}
href={comrade.get('url')}
>
{comrade.get('display_name_html') || comrade.get('username')}
</CommonLink>
);
switch (type) {
case 'favourite':
return (
<FormattedMessage
defaultMessage='{name} favourited your status'
id='notification.favourite'
values={{ name : link }}
/>
);
case 'reblog':
return (
<FormattedMessage
defaultMessage='{name} boosted your status'
id='notification.reblog'
values={{ name : link }}
/>
);
case 'reblogged':
return (
<FormattedMessage
defaultMessage='{name} boosted'
id='status.reblogged_by'
values={{ name : link }}
/>
);
}
return null;
}
// This renders the prepend icon and the prepend message in sequence.
render () {
const { Message } = this;
const { type } = this.props;
return type ? (
<aside className='glitch glitch__status__prepend'>
<CommonIcon
className={`prepend\\icon prepend\\${type}`}
name={type === 'favourite' ? 'star' : 'retweet'}
/>
<Message />
</aside>
) : null;
}
}

View File

@@ -0,0 +1,33 @@
@import 'variables';
.glitch.glitch__status__prepend {
display: block;
position: relative;
margin: 0 0 1em;
color: $ui-base-lighter-color;
padding: 0 0 0 (3.35em * .7);
.prepend\\icon {
display: block;
position: absolute;
margin: auto;
top: 0;
left: 0;
width: (3.35em * .7);
height: 1.35em;
text-align: center;
&.prepend\\reblog,
&.prepend\\reblogged {
color: $ui-highlight-color;
}
&.prepend\\favourite {
color: $gold-star;
}
}
.prepend\\comrade {
color: $glitch-lighter-color;
}
}

View File

@@ -0,0 +1,34 @@
@import 'variables';
.glitch.glitch__status {
display: block;
border-bottom: 1px solid $glitch-texture-color;
padding: (.75em * 1.35) .75em;
color: $ui-secondary-color;
font-size: medium;
line-height: 1.35;
cursor: default;
animation: fade 150ms linear;
@keyframes fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
/*
The detail button is styled to line up with the textual content of
status headers. See the `<StatusHeader>` CSS for more details on
their specific layout.
*/
.status\\detail.status\\button {
float: right;
width: 1.35em; // 2.6em of parent
height: 1.35em; // 2.6em of parent
font-size: (2.6em / 1.35); // approx. 1.925em
text-align: center;
}
&._direct:not(._muted) {
background: $glitch-texture-color;
}
}

View File

@@ -1,48 +0,0 @@
// Package imports //
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({
public: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
@injectIntl
export default class VisibilityIcon extends ImmutablePureComponent {
static propTypes = {
visibility: PropTypes.string,
intl: PropTypes.object.isRequired,
withLabel: PropTypes.bool,
};
render() {
const { withLabel, visibility, intl } = this.props;
const visibilityClass = {
public: 'globe',
unlisted: 'unlock-alt',
private: 'lock',
direct: 'envelope',
}[visibility];
const label = intl.formatMessage(messages[visibility]);
const icon = (<i
className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`}
title={label}
aria-hidden='true'
/>);
if (withLabel) {
return (<span style={{ whiteSpace: 'nowrap' }}>{icon} {label}</span>);
} else {
return icon;
}
}
}

View File

@@ -5,7 +5,6 @@
"layout.desktop": "Desktop",
"layout.mobile": "Mobile",
"navigation_bar.app_settings": "App settings",
"getting_started.onboarding": "Show me around",
"onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
"onboarding.page_one.welcome": "Welcome to {domain}!",
"onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.",

View File

@@ -0,0 +1,7 @@
import { createStructuredSelector } from 'reselect';
const makeIntlSelector = () => createStructuredSelector({
intl: ({ intl }) => intl,
});
export default makeIntlSelector;

View File

@@ -0,0 +1,33 @@
import { createSelector } from 'reselect';
const makeStatusSelector = () => {
return createSelector(
[
(state, id) => state.getIn(['statuses', id]),
(state, id) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
(state, id) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
(state, id) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
(state, id) => state.getIn(['cards', id], null),
],
(statusBase, statusReblog, accountBase, accountReblog, card) => {
if (!statusBase) {
return null;
}
if (statusReblog) {
statusReblog = statusReblog.set('account', accountReblog);
} else {
statusReblog = null;
}
return statusBase.withMutations(map => {
map.set('reblog', statusReblog);
map.set('account', accountBase);
map.set('card', card);
});
}
);
};
export default makeStatusSelector;

View File

@@ -240,11 +240,11 @@ export function unblockAccountFail(error) {
};
export function muteAccount(id, notifications) {
export function muteAccount(id) {
return (dispatch, getState) => {
dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => {

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