Merge remote-tracking branch 'glitchsoc/master'

master
noiob 2018-08-22 18:33:09 -07:00
commit eaca3d253b
280 changed files with 4362 additions and 762 deletions

View File

@ -64,12 +64,17 @@ aliases:
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production
- run: bundle install --clean --jobs 16 --path ./vendor/bundle/ --retry 3 --with pam_authentication --without development production && bundle clean
- save_cache:
key: v2-ruby-dependencies-{{ checksum "/tmp/.ruby-version" }}-{{ checksum "Gemfile.lock" }}
paths:
- ./.bundle/
- ./vendor/bundle/
- persist_to_workspace:
root: ~/projects/
paths:
- ./mastodon/.bundle/
- ./mastodon/vendor/bundle/
- &test_steps
steps:
@ -78,9 +83,6 @@ aliases:
- *install_system_dependencies
- run: sudo apt-get install -y ffmpeg
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run:
name: Prepare Tests
command: ./bin/rails parallel:create parallel:load_schema parallel:prepare
@ -116,8 +118,6 @@ jobs:
steps:
- *attach_workspace
- *install_system_dependencies
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run:
name: Precompile assets
command: ./bin/rails assets:precompile
@ -173,8 +173,6 @@ jobs:
<<: *defaults
steps:
- *attach_workspace
- run: ruby -e 'puts RUBY_VERSION' | tee /tmp/.ruby-version
- *restore_ruby_dependencies
- run: bundle exec i18n-tasks check-normalized
- run: bundle exec i18n-tasks unused
@ -189,9 +187,11 @@ workflows:
- install-ruby2.4:
requires:
- install
- install-ruby2.5
- install-ruby2.3:
requires:
- install
- install-ruby2.5
- build:
requires:
- install-ruby2.5

View File

@ -165,6 +165,7 @@ STREAMING_CLUSTER_NUM=1
# LDAP_BIND_DN=
# LDAP_PASSWORD=
# LDAP_UID=cn
# LDAP_SEARCH_FILTER="%{uid}=%{email}"
# PAM authentication (optional)
# PAM authentication uses for the email generation the "email" pam variable

View File

@ -9,4 +9,4 @@ about: Create a report to help us improve
* * * *
- [ ] I searched or browsed the repos other issues to ensure this is not a duplicate.
- [ ] This bug happens on a [tagged release](https://github.com/tootsuite/mastodon/releases) and not on `master` (If you're a user, don't worry about this).
- [ ] This bugs also occur on vanilla Mastodon

View File

@ -10,12 +10,12 @@ gem 'rails', '~> 5.2.1'
gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.0'
gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.1'
gem 'dotenv-rails', '~> 2.2', '< 2.3'
gem 'aws-sdk-s3', '~> 1.9', require: false
gem 'fog-core', '~> 1.45'
gem 'fog-local', '~> 0.5', require: false
gem 'fog-openstack', '~> 0.1', require: false
gem 'paperclip', '~> 6.0'
gem 'paperclip-av-transcoder', '~> 0.6'
@ -41,7 +41,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.2'
gem 'doorkeeper', '~> 4.2', '< 4.3'
gem 'doorkeeper', '~> 4.4'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'

View File

@ -181,7 +181,7 @@ GEM
docile (1.3.0)
domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0)
doorkeeper (4.2.6)
doorkeeper (4.4.2)
railties (>= 4.2)
dotenv (2.2.2)
dotenv-rails (2.2.2)
@ -220,8 +220,6 @@ GEM
fog-json (1.0.2)
fog-core (~> 1.0)
multi_json (~> 1.10)
fog-local (0.5.0)
fog-core (>= 1.27, < 3.0)
fog-openstack (0.1.25)
fog-core (~> 1.40)
fog-json (>= 1.0)
@ -324,6 +322,8 @@ GEM
nokogiri (>= 1.5.9)
mail (2.7.0)
mini_mime (>= 0.1.1)
makara (0.4.0)
activerecord (>= 3.0.0)
marcel (0.3.2)
mimemagic (~> 0.3.2)
mario-redis-lock (1.2.1)
@ -672,14 +672,13 @@ DEPENDENCIES
devise (~> 4.4)
devise-two-factor (~> 3.0)
devise_pam_authenticatable2 (~> 9.1)
doorkeeper (~> 4.2, < 4.3)
doorkeeper (~> 4.4)
dotenv-rails (~> 2.2, < 2.3)
fabrication (~> 2.20)
faker (~> 1.8)
fast_blank (~> 1.0)
fastimage
fog-core (~> 1.45)
fog-local (~> 0.5)
fog-openstack (~> 0.1)
fuubar (~> 2.2)
goldfinger (~> 2.1)
@ -700,6 +699,7 @@ DEPENDENCIES
letter_opener_web (~> 1.3)
link_header (~> 0.0)
lograge (~> 0.10)
makara (~> 0.4)
mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.0)

View File

@ -31,7 +31,7 @@ class StatusesIndex < Chewy::Index
},
}
define_type ::Status.without_reblogs do
define_type ::Status.unscoped.without_reblogs do
crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }

View File

@ -43,7 +43,7 @@ class AccountsController < ApplicationController
format.json do
skip_session!
render_cached_json(['activitypub', 'actor', @account.cache_key], content_type: 'application/activity+json') do
render_cached_json(['activitypub', 'actor', @account], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
end
end

View File

@ -30,6 +30,12 @@ module Admin
redirect_to admin_invites_path
end
def deactivate_all
authorize :invite, :deactivate_all?
Invite.available.in_batches.update_all(expires_at: Time.now.utc)
redirect_to admin_invites_path
end
private
def resource_params

View File

@ -44,14 +44,8 @@ module Admin
when 'resolve'
@report.resolve!(current_account)
log_action :resolve, @report
when 'suspend'
Admin::SuspensionWorker.perform_async(@report.target_account.id)
log_action :resolve, @report
log_action :suspend, @report.target_account
resolve_all_target_account_reports
when 'silence'
@report.resolve!(current_account)
@report.target_account.update!(silenced: true)
log_action :resolve, @report

View File

@ -28,6 +28,10 @@ module Admin
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_account_statuses_path(@account.id, current_params)
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
redirect_to admin_account_statuses_path(@account.id, current_params)
end

View File

@ -4,11 +4,24 @@ module Admin
class SuspensionsController < BaseController
before_action :set_account
def new
@suspension = Form::AdminSuspensionConfirmation.new(report_id: params[:report_id])
end
def create
authorize @account, :suspend?
Admin::SuspensionWorker.perform_async(@account.id)
log_action :suspend, @account
redirect_to admin_accounts_path
@suspension = Form::AdminSuspensionConfirmation.new(suspension_params)
if suspension_params[:acct] == @account.acct
resolve_report! if suspension_params[:report_id]
perform_suspend!
mark_reports_resolved!
redirect_to admin_accounts_path
else
flash.now[:alert] = I18n.t('admin.suspensions.bad_acct_msg')
render :new
end
end
def destroy
@ -23,5 +36,25 @@ module Admin
def set_account
@account = Account.find(params[:account_id])
end
def suspension_params
params.require(:form_admin_suspension_confirmation).permit(:acct, :report_id)
end
def resolve_report!
report = Report.find(suspension_params[:report_id])
report.resolve!(current_account)
log_action :resolve, report
end
def perform_suspend!
@account.suspend!
Admin::SuspensionWorker.perform_async(@account.id)
log_action :suspend, @account
end
def mark_reports_resolved!
Report.where(target_account: @account).unresolved.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
end
end
end

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
class Api::V1::EndorsementsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
before_action :require_user!
after_action :insert_pagination_headers
respond_to :json
def index
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
private
def load_accounts
if unlimited?
endorsed_accounts.all
else
endorsed_accounts.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
end
def endorsed_accounts
current_account.endorsed_accounts
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
if records_continue?
api_v1_endorsements_url pagination_params(max_id: pagination_max_id)
end
end
def prev_path
return if unlimited?
unless @accounts.empty?
api_v1_endorsements_url pagination_params(since_id: pagination_since_id)
end
end
def pagination_max_id
@accounts.last.id
end
def pagination_since_id
@accounts.first.id
end
def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def unlimited?
params[:limit] == '0'
end
end

View File

@ -17,8 +17,7 @@ class Api::V1::StatusesController < Api::BaseController
CONTEXT_LIMIT = 4_096
def show
cached = Rails.cache.read(@status.cache_key)
@status = cached unless cached.nil?
@status = cache_collection([@status], Status).first
render json: @status, serializer: REST::StatusSerializer
end

View File

@ -178,12 +178,8 @@ class ApplicationController < ActionController::Base
return raw unless klass.respond_to?(:with_includes)
raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
uncached_ids = []
cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key))
raw.each do |item|
uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key)
end
cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)
uncached_ids = raw.map(&:id) - cached_keys_with_value.keys
klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
@ -191,11 +187,11 @@ class ApplicationController < ActionController::Base
uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h
uncached.each_value do |item|
Rails.cache.write(item.cache_key, item)
Rails.cache.write(item, item)
end
end
raw.map { |item| cached_keys_with_value[item.cache_key] || uncached[item.id] }.compact
raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
end
def respond_with_error(code)
@ -211,7 +207,6 @@ class ApplicationController < ActionController::Base
def render_cached_json(cache_key, **options)
options[:expires_in] ||= 3.minutes
cache_key = cache_key.join(':') if cache_key.is_a?(Enumerable)
cache_public = options.key?(:public) ? options.delete(:public) : true
content_type = options.delete(:content_type) || 'application/json'

View File

@ -1,71 +0,0 @@
# frozen_string_literal: true
class AuthorizeFollowsController < ApplicationController
layout 'modal'
before_action :authenticate_user!
before_action :set_pack
before_action :set_body_classes
def show
@account = located_account || render(:error)
end
def create
@account = follow_attempt.try(:target_account)
if @account.nil?
render :error
else
render :success
end
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
render :error
end
private
def set_pack
use_pack 'modal'
end
def follow_attempt
FollowService.new.call(current_account, acct_without_prefix)
end
def located_account
if acct_param_is_url?
account_from_remote_fetch
else
account_from_remote_follow
end
end
def account_from_remote_fetch
FetchRemoteAccountService.new.call(acct_without_prefix)
end
def account_from_remote_follow
ResolveAccountService.new.call(acct_without_prefix)
end
def acct_param_is_url?
parsed_uri.path && %w(http https).include?(parsed_uri.scheme)
end
def parsed_uri
Addressable::URI.parse(acct_without_prefix).normalize
end
def acct_without_prefix
acct_params.gsub(/\Aacct:/, '')
end
def acct_params
params.fetch(:acct, '')
end
def set_body_classes
@body_classes = 'modal-layout'
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
class AuthorizeInteractionsController < ApplicationController
include Authorization
layout 'modal'
before_action :authenticate_user!
before_action :set_body_classes
before_action :set_resource
before_action :set_pack
def show
if @resource.is_a?(Account)
render :show
elsif @resource.is_a?(Status)
redirect_to web_url("statuses/#{@resource.id}")
else
render :error
end
end
def create
if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource)
render :success
else
render :error
end
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
render :error
end
private
def set_resource
@resource = located_resource || render(:error)
authorize(@resource, :show?) if @resource.is_a?(Status)
end
def located_resource
if uri_param_is_url?
ResolveURLService.new.call(uri_param)
else
account_from_remote_follow
end
end
def account_from_remote_follow
ResolveAccountService.new.call(uri_param)
end
def uri_param_is_url?
parsed_uri.path && %w(http https).include?(parsed_uri.scheme)
end
def parsed_uri
Addressable::URI.parse(uri_param).normalize
end
def uri_param
params[:uri] || params.fetch(:acct, '').gsub(/\Aacct:/, '')
end
def set_body_classes
@body_classes = 'modal-layout'
end
def set_pack
use_pack 'modal'
end
end

View File

@ -9,7 +9,7 @@ class EmojisController < ApplicationController
format.json do
skip_session!
render_cached_json(['activitypub', 'emoji', @emoji.cache_key], content_type: 'application/activity+json') do
render_cached_json(['activitypub', 'emoji', @emoji], content_type: 'application/activity+json') do
ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter)
end
end

View File

@ -8,7 +8,7 @@ class IntentsController < ApplicationController
if uri.scheme == 'web+mastodon'
case uri.host
when 'follow'
return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, ''))
return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].gsub(/\Aacct:/, ''))
when 'share'
return redirect_to share_path(text: uri.query_values['text'])
end

View File

@ -47,5 +47,6 @@ class RemoteFollowController < ApplicationController
def set_body_classes
@body_classes = 'modal-layout'
@hide_header = true
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
class RemoteInteractionController < ApplicationController
include Authorization
layout 'modal'
before_action :set_status
before_action :set_body_classes
before_action :set_pack
def new
@remote_follow = RemoteFollow.new(session_params)
end
def create
@remote_follow = RemoteFollow.new(resource_params)
if @remote_follow.valid?
session[:remote_follow] = @remote_follow.acct
redirect_to @remote_follow.interact_address_for(@status)
else
render :new
end
end
private
def resource_params
params.require(:remote_follow).permit(:acct)
end
def session_params
{ acct: session[:remote_follow] }
end
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
# Reraise in order to get a 404
raise ActiveRecord::RecordNotFound
end
def set_body_classes
@body_classes = 'modal-layout'
@hide_header = true
end
def set_pack
use_pack 'modal'
end
end

View File

@ -19,6 +19,10 @@ class StatusesController < ApplicationController
before_action :set_referrer_policy_header, only: [:show]
before_action :set_cache_headers
content_security_policy only: :embed do |p|
p.frame_ancestors(false)
end
def show
respond_to do |format|
format.html do
@ -34,7 +38,7 @@ class StatusesController < ApplicationController
format.json do
skip_session! unless @stream_entry.hidden?
render_cached_json(['activitypub', 'note', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
render_cached_json(['activitypub', 'note', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
end
end
@ -44,7 +48,7 @@ class StatusesController < ApplicationController
def activity
skip_session!
render_cached_json(['activitypub', 'activity', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
render_cached_json(['activitypub', 'activity', @status], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
end
end

View File

@ -33,8 +33,12 @@ module Admin::ActionLogsHelper
when 'DomainBlock', 'EmailDomainBlock'
link_to attributes['domain'], "https://#{attributes['domain']}"
when 'Status'
tmp_status = Status.new(attributes)
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status)
tmp_status = Status.new(attributes.except('reblogs_count', 'favourites_count'))
if tmp_status.account
link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
else
I18n.t('admin.action_logs.deleted_status')
end
end
end

View File

@ -38,4 +38,14 @@ module HomeHelper
end
end
end
def obscured_counter(count)
if count <= 0
0
elsif count == 1
1
else
'1+'
end
end
end

View File

@ -73,8 +73,10 @@ module JsonLdHelper
end
end
def body_to_json(body)
body.is_a?(String) ? Oj.load(body, mode: :strict) : body
def body_to_json(body, compare_id: nil)
json = body.is_a?(String) ? Oj.load(body, mode: :strict) : body
return if compare_id.present? && json['id'] != compare_id
json
rescue Oj::ParseError
nil
end

View File

@ -27,6 +27,7 @@ module SettingsHelper
io: 'Ido',
it: 'Italiano',
ja: '日本語',
ka: 'ქართული',
ko: '한국어',
nl: 'Nederlands',
no: 'Norsk',

View File

@ -19,7 +19,7 @@ module StreamEntriesHelper
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('settings.edit_profile')])
end
elsif current_account.following?(account) || current_account.requested?(account)
link_to account_unfollow_path(account), class: 'button logo-button', data: { method: :post } do
link_to account_unfollow_path(account), class: 'button logo-button button--destructive', data: { method: :post } do
safe_join([render(file: Rails.root.join('app', 'javascript', 'images', 'logo.svg')), t('accounts.unfollow')])
end
else

View File

@ -1,9 +1,6 @@
// This file will be loaded on admin pages, regardless of theme.
import { delegate } from 'rails-ujs';
import { start } from '../mastodon/common';
start();
function handleDeleteStatus(event) {
const [data] = event.detail;

View File

@ -37,3 +37,17 @@ delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
return false;
});
delegate(document, '.modal-button', 'click', e => {
e.preventDefault();
let href;
if (e.target.nodeName !== 'A') {
href = e.target.parentNode.href;
} else {
href = e.target.href;
}
window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
});

View File

@ -72,6 +72,17 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST';
export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS';
export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL';
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY';
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR';
export const PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE = 'PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE';
export const PINNED_ACCOUNTS_EDITOR_RESET = 'PINNED_ACCOUNTS_EDITOR_RESET';
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
@ -733,3 +744,76 @@ export function unpinAccountFail(error) {
error,
};
};
export function fetchPinnedAccounts() {
return (dispatch, getState) => {
dispatch(fetchPinnedAccountsRequest());
api(getState).get(`/api/v1/endorsements`, { params: { limit: 0 } })
.then(({ data }) => dispatch(fetchPinnedAccountsSuccess(data)))
.catch(err => dispatch(fetchPinnedAccountsFail(err)));
};
};
export function fetchPinnedAccountsRequest() {
return {
type: PINNED_ACCOUNTS_FETCH_REQUEST,
};
};
export function fetchPinnedAccountsSuccess(accounts, next) {
return {
type: PINNED_ACCOUNTS_FETCH_SUCCESS,
accounts,
next,
};
};
export function fetchPinnedAccountsFail(error) {
return {
type: PINNED_ACCOUNTS_FETCH_FAIL,
error,
};
};
export function fetchPinnedAccountsSuggestions(q) {
return (dispatch, getState) => {
const params = {
q,
resolve: false,
limit: 4,
following: true,
};
api(getState).get('/api/v1/accounts/search', { params })
.then(({ data }) => dispatch(fetchPinnedAccountsSuggestionsReady(q, data)));
};
};
export function fetchPinnedAccountsSuggestionsReady(query, accounts) {
return {
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
query,
accounts,
};
};
export function clearPinnedAccountsSuggestions() {
return {
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
};
};
export function changePinnedAccountsSuggestions(value) {
return {
type: PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
value,
}
};
export function resetPinnedAccountsEditor() {
return {
type: PINNED_ACCOUNTS_EDITOR_RESET,
};
};

View File

@ -211,11 +211,11 @@ export function uploadCompose(files) {
};
};
export function changeUploadCompose(id, description) {
export function changeUploadCompose(id, params) {
return (dispatch, getState) => {
dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => {
dispatch(changeUploadComposeFail(id, error));

View File

@ -32,7 +32,7 @@ export function submitSearch() {
dispatch(fetchSearchRequest());
api(getState).get('/api/v1/search', {
api(getState).get('/api/v2/search', {
params: {
q: value,
resolve: true,

View File

@ -72,7 +72,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
return;
}
if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
params.since_id = timeline.getIn(['items', 0]);
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { Link } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { shortNumberFormat } from 'flavours/glitch/util/numbers';
const Hashtag = ({ hashtag }) => (
<div className='trends__item'>
<div className='trends__item__name'>
<Link to={`/timelines/tag/${hashtag.get('name')}`}>
#<span>{hashtag.get('name')}</span>
</Link>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
</div>
<div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
</div>
<div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>
</div>
);
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
export default Hashtag;

View File

@ -528,6 +528,7 @@ export default class Status extends ImmutablePureComponent {
{...other}
status={status}
account={status.get('account')}
showReplyCount={settings.get('show_reply_count')}
/>
) : null}
{notification ? (

View File

@ -33,6 +33,16 @@ const messages = defineMessages({
embed: { id: 'status.embed', defaultMessage: 'Embed' },
});
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
@injectIntl
export default class StatusActionBar extends ImmutablePureComponent {
@ -56,6 +66,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
onPin: PropTypes.func,
onBookmark: PropTypes.func,
withDismiss: PropTypes.bool,
showReplyCount: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -63,6 +74,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'showReplyCount',
'withDismiss',
]
@ -134,7 +146,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
}
render () {
const { status, intl, withDismiss } = this.props;
const { status, intl, withDismiss, showReplyCount } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me;
@ -188,9 +200,27 @@ export default class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
let replyButton = (
<IconButton
className='status__action-bar-button'
disabled={anonymousAccess}
title={replyTitle}
icon={replyIcon}
onClick={this.handleReplyClick}
/>
);
if (showReplyCount) {
replyButton = (
<div className='status__action-bar__counter'>
{replyButton}
<span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span>
</div>
);
}
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
{replyButton}
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} 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}

View File

@ -95,28 +95,71 @@ function mapStateToProps (state) {
};
// Dispatch mapping.
const mapDispatchToProps = {
onCancelReply: cancelReplyCompose,
onChangeAdvancedOption: changeComposeAdvancedOption,
onChangeDescription: changeUploadCompose,
onChangeSensitivity: changeComposeSensitivity,
onChangeSpoilerText: changeComposeSpoilerText,
onChangeSpoilerness: changeComposeSpoilerness,
onChangeText: changeCompose,
onChangeVisibility: changeComposeVisibility,
onClearSuggestions: clearComposeSuggestions,
onCloseModal: closeModal,
onFetchSuggestions: fetchComposeSuggestions,
onInsertEmoji: insertEmojiCompose,
onMount: mountCompose,
onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
onSelectSuggestion: selectComposeSuggestion,
onSubmit: submitCompose,
onUndoUpload: undoUploadCompose,
onUnmount: unmountCompose,
onUpload: uploadCompose,
};
const mapDispatchToProps = (dispatch) => ({
onCancelReply() {
dispatch(cancelReplyCompose());
},
onChangeAdvancedOption(option, value) {
dispatch(changeComposeAdvancedOption(option, value));
},
onChangeDescription(id, description) {
dispatch(changeUploadCompose(id, { description }));
},
onChangeSensitivity() {
dispatch(changeComposeSensitivity());
},
onChangeSpoilerText(text) {
dispatch(changeComposeSpoilerText(text));
},
onChangeSpoilerness() {
dispatch(changeComposeSpoilerness());
},
onChangeText(text) {
dispatch(changeCompose(text));
},
onChangeVisibility(value) {
dispatch(changeComposeVisibility(value));
},
onClearSuggestions() {
dispatch(clearComposeSuggestions());
},
onCloseModal() {
dispatch(closeModal());
},
onFetchSuggestions(token) {
dispatch(fetchComposeSuggestions(token));
},
onInsertEmoji(position, emoji) {
dispatch(insertEmojiCompose(position, emoji));
},
onMount() {
dispatch(mountCompose());
},
onOpenActionModal(props) {
dispatch(openModal('ACTIONS', props));
},
onOpenDoodleModal() {
dispatch(openModal('DOODLE', { noEsc: true }));
},
onOpenFocalPointModal(id) {
dispatch(openModal('FOCAL_POINT', { id }));
},
onSelectSuggestion(position, token, suggestion) {
dispatch(selectComposeSuggestion(position, token, suggestion));
},
onSubmit() {
dispatch(submitCompose());
},
onUndoUpload(id) {
dispatch(undoUploadCompose(id));
},
onUnmount() {
dispatch(unmountCompose());
},
onUpload(files) {
dispatch(uploadCompose(files));
},
});
// Handlers.
const handlers = {
@ -194,6 +237,13 @@ const handlers = {
this.textarea = textareaComponent.textarea;
}
},
// Sets a reference to the CW field.
handleRefSpoilerText (spoilerComponent) {
if (spoilerComponent) {
this.spoilerText = spoilerComponent.spoilerText;
}
}
};
// The component.
@ -206,6 +256,7 @@ class Composer extends React.Component {
// Instance variables.
this.textarea = null;
this.spoilerText = null;
}
// Tells our state the composer has been mounted.
@ -234,6 +285,7 @@ class Composer extends React.Component {
componentDidUpdate (prevProps) {
const {
textarea,
spoilerText,
} = this;
const {
focusDate,
@ -265,6 +317,16 @@ class Composer extends React.Component {
// Refocuses the textarea after submitting.
} else if (textarea && prevProps.isSubmitting && !isSubmitting) {
textarea.focus();
} else if (this.props.spoiler !== prevProps.spoiler) {
if (this.props.spoiler) {
if (spoilerText) {
spoilerText.focus();
}
} else {
if (textarea) {
textarea.focus();
}
}
}
}
@ -276,6 +338,7 @@ class Composer extends React.Component {
handleSelect,
handleSubmit,
handleRefTextarea,
handleRefSpoilerText,
} = this.handlers;
const {
acceptContentTypes,
@ -299,6 +362,7 @@ class Composer extends React.Component {
onFetchSuggestions,
onOpenActionsModal,
onOpenDoodleModal,
onOpenFocalPointModal,
onUndoUpload,
onUpload,
privacy,
@ -334,6 +398,7 @@ class Composer extends React.Component {
onChange={handleChangeSpoiler}
onSubmit={handleSubmit}
text={spoilerText}
ref={handleRefSpoilerText}
/>
<ComposerTextarea
advancedOptions={advancedOptions}
@ -357,6 +422,7 @@ class Composer extends React.Component {
intl={intl}
media={media}
onChangeDescription={onChangeDescription}
onOpenFocalPointModal={onOpenFocalPointModal}
onRemove={onUndoUpload}
progress={progress}
uploading={isUploading}

View File

@ -168,6 +168,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
const computedClass = classNames('composer--options--dropdown', {
active,
open,
top: placement === 'top',
});
// The result.

View File

@ -1,6 +1,7 @@
// Package imports.
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages } from 'react-intl';
// Components.
@ -89,7 +90,7 @@ export default class ComposerReply extends React.PureComponent {
}
ComposerReply.propTypes = {
status: PropTypes.map.isRequired,
status: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onCancel: PropTypes.func,
};

View File

@ -33,6 +33,10 @@ const handlers = {
onSubmit();
}
},
handleRefSpoilerText (spoilerText) {
this.spoilerText = spoilerText;
},
};
// The component.
@ -46,7 +50,7 @@ export default class ComposerSpoiler extends React.PureComponent {
// Rendering.
render () {
const { handleKeyDown } = this.handlers;
const { handleKeyDown, handleRefSpoilerText } = this.handlers;
const {
hidden,
intl,
@ -68,6 +72,7 @@ export default class ComposerSpoiler extends React.PureComponent {
placeholder={intl.formatMessage(messages.placeholder)}
type='text'
value={text}
ref={handleRefSpoilerText}
/>
</label>
</div>

View File

@ -13,6 +13,7 @@ export default function ComposerUploadForm ({
intl,
media,
onChangeDescription,
onOpenFocalPointModal,
onRemove,
progress,
uploading,
@ -31,8 +32,12 @@ export default function ComposerUploadForm ({
key={item.get('id')}
id={item.get('id')}
intl={intl}
focusX={item.getIn(['meta', 'focus', 'x'])}
focusY={item.getIn(['meta', 'focus', 'y'])}
mediaType={item.get('type')}
preview={item.get('preview_url')}
onChangeDescription={onChangeDescription}
onOpenFocalPointModal={onOpenFocalPointModal}
onRemove={onRemove}
/>
))}
@ -46,8 +51,8 @@ export default function ComposerUploadForm ({
ComposerUploadForm.propTypes = {
intl: PropTypes.object.isRequired,
media: ImmutablePropTypes.list,
onChangeDescription: PropTypes.func,
onRemove: PropTypes.func,
onChangeDescription: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
progress: PropTypes.number,
uploading: PropTypes.bool,
};

View File

@ -25,6 +25,10 @@ const messages = defineMessages({
defaultMessage: 'Describe for the visually impaired',
id: 'upload_form.description',
},
crop: {
defaultMessage: 'Crop',
id: 'upload_form.focus',
},
});
// Handlers.
@ -37,11 +41,10 @@ const handlers = {
onChangeDescription,
} = this.props;
const { dirtyDescription } = this.state;
this.setState({ dirtyDescription: null, focused: false });
if (id && onChangeDescription && dirtyDescription !== null) {
this.setState({
dirtyDescription: null,
focused: false,
});
onChangeDescription(id, dirtyDescription);
}
},
@ -77,6 +80,17 @@ const handlers = {
onRemove(id);
}
},
// Opens the focal point modal.
handleFocalPointClick () {
const {
id,
onOpenFocalPointModal,
} = this.props;
if (id && onOpenFocalPointModal) {
onOpenFocalPointModal(id);
}
},
};
// The component.
@ -102,18 +116,25 @@ export default class ComposerUploadFormItem extends React.PureComponent {
handleMouseEnter,
handleMouseLeave,
handleRemove,
handleFocalPointClick,
} = this.handlers;
const {
description,
intl,
preview,
focusX,
focusY,
mediaType,
} = this.props;
const {
focused,
hovered,
dirtyDescription,
} = this.state;
const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
const active = hovered || focused;
const computedClass = classNames('composer--upload_form--item', { active });
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
const description = dirtyDescription || (dirtyDescription !== '' && this.props.description) || '';
// The result.
return (
@ -136,15 +157,15 @@ export default class ComposerUploadFormItem extends React.PureComponent {
style={{
transform: `scale(${scale})`,
backgroundImage: preview ? `url(${preview})` : null,
backgroundPosition: `${x}% ${y}%`
}}
>
<IconButton
className='close'
icon='times'
onClick={handleRemove}
size={36}
title={intl.formatMessage(messages.undo)}
/>
<div className={classNames('composer--upload_form--actions', { active })}>
<button className='icon-button' onClick={handleRemove}>
<i className='fa fa-times' /> <FormattedMessage {...messages.undo} />
</button>
{mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>}
</div>
<label>
<span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
<input
@ -154,7 +175,7 @@ export default class ComposerUploadFormItem extends React.PureComponent {
onFocus={handleFocus}
placeholder={intl.formatMessage(messages.description)}
type='text'
value={dirtyDescription || description || ''}
value={description}
/>
</label>
</div>
@ -171,7 +192,11 @@ ComposerUploadFormItem.propTypes = {
description: PropTypes.string,
id: PropTypes.string,
intl: PropTypes.object.isRequired,
onChangeDescription: PropTypes.func,
onRemove: PropTypes.func,
onChangeDescription: PropTypes.func.isRequired,
onOpenFocalPointModal: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
focusX: PropTypes.number,
focusY: PropTypes.number,
mediaType: PropTypes.string,
preview: PropTypes.string,
};

View File

@ -12,6 +12,7 @@ import { Link } from 'react-router-dom';
// Components.
import AccountContainer from 'flavours/glitch/containers/account_container';
import StatusContainer from 'flavours/glitch/containers/status_container';
import Hashtag from 'flavours/glitch/components/hashtag';
// Utils.
import Motion from 'flavours/glitch/util/optional_motion';
@ -98,15 +99,7 @@ export default function DrawerResults ({
<section>
<h5><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{hashtags.map(
hashtag => (
<Link
className='hashtag'
key={hashtag}
to={`/timelines/tag/${hashtag}`}
>#{hashtag}</Link>
)
)}
{hashtags.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</section>
) : null}
</div>

View File

@ -129,20 +129,20 @@ export default class GettingStarted extends ImmutablePureComponent {
}
if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
navItems.push(<ColumnLink icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
navItems.push(<ColumnLink key='5' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
}
if (myAccount.get('locked')) {
navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
}
navItems.push(<ColumnLink key='6' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
navItems.push(<ColumnLink key='7' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
listItems = listItems.concat([
<div key='7'>
<ColumnLink key='8' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
<div key='8'>
<ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
{lists.map(list =>
<ColumnLink key={(8 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
<ColumnLink key={(9 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
)}
</div>,
]);

View File

@ -21,6 +21,7 @@ const messages = defineMessages({
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
});
@connect()
@ -33,27 +34,33 @@ export default class gettingStartedMisc extends ImmutablePureComponent {
};
openOnboardingModal = (e) => {
e.preventDefault();
this.props.dispatch(openModal('ONBOARDING'));
}
openFeaturedAccountsModal = (e) => {
this.props.dispatch(openModal('PINNED_ACCOUNTS_EDITOR'));
}
render () {
const { intl } = this.props;
let i = 1;
return (
<Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<div className='scrollable'>
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
<ColumnLink key='19' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
<ColumnLink key='20' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
<ColumnLink key='21' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink key='22' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnLink icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
<ColumnLink key='23' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
<ColumnLink key='{i++}' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
<ColumnLink key='{i++}' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
<ColumnLink key='{i++}' icon='users' text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />
<ColumnLink key='{i++}' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink key='{i++}' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnLink key='{i++}' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
<ColumnLink key='{i++}' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
<ColumnLink key='{i++}' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink key='{i++}' icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
</div>
</Column>
);

View File

@ -1,38 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import IconButton from 'flavours/glitch/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
import { defineMessages } from 'react-intl';
const messages = defineMessages({
remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
@connect(makeMapStateToProps, mapDispatchToProps)
@injectIntl
export default class Account extends ImmutablePureComponent {
static propTypes = {

View File

@ -1,26 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
import { defineMessages } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
});
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class Search extends React.PureComponent {
static propTypes = {

View File

@ -0,0 +1,24 @@
import React from 'react';
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { injectIntl } from 'react-intl';
import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
import Account from '../components/account';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(removeFromListEditor(accountId)),
onAdd: () => dispatch(addToListEditor(accountId)),
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));

View File

@ -0,0 +1,17 @@
import React from 'react';
import { connect } from 'react-redux';
import { injectIntl } from 'react-intl';
import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
import Search from '../components/search';
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchListSuggestions(value)),
onClear: () => dispatch(clearListSuggestions()),
onChange: value => dispatch(changeListSuggestions(value)),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));

View File

@ -5,8 +5,8 @@ import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl } from 'react-intl';
import { setupListEditor, clearListSuggestions, resetListEditor } from 'flavours/glitch/actions/lists';
import Account from './components/account';
import Search from './components/search';
import AccountContainer from './containers/account_container';
import SearchContainer from './containers/search_container';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
@ -56,11 +56,11 @@ export default class ListEditor extends ImmutablePureComponent {
<div className='modal-root__modal list-editor'>
<h4>{title}</h4>
<Search />
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
@ -68,7 +68,7 @@ export default class ListEditor extends ImmutablePureComponent {
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
</div>)
}
</Motion>

View File

@ -35,33 +35,44 @@ export default class LocalSettingsPage extends React.PureComponent {
<h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
<LocalSettingsPageItem
settings={settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
]}
item={['show_reply_count']}
id='mastodon-settings--reply-count'
onChange={onChange}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['stretch']}
id='mastodon-settings--stretch'
onChange={onChange}
>
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['navbar_under']}
id='mastodon-settings--navbar_under'
onChange={onChange}
>
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
<FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' />
</LocalSettingsPageItem>
<section>
<h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
<LocalSettingsPageItem
settings={settings}
item={['layout']}
id='mastodon-settings--layout'
options={[
{ value: 'auto', message: intl.formatMessage(messages.layout_auto) },
{ value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
{ value: 'single', message: intl.formatMessage(messages.layout_mobile) },
]}
onChange={onChange}
>
<FormattedMessage id='settings.layout' defaultMessage='Layout:' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['stretch']}
id='mastodon-settings--stretch'
onChange={onChange}
>
<FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
</LocalSettingsPageItem>
<LocalSettingsPageItem
settings={settings}
item={['navbar_under']}
id='mastodon-settings--navbar_under'
onChange={onChange}
>
<FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
</LocalSettingsPageItem>
</section>
<section>
<h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2>
<LocalSettingsPageItem

View File

@ -0,0 +1,24 @@
import React from 'react';
import { connect } from 'react-redux';
import { makeGetAccount } from 'flavours/glitch/selectors';
import { injectIntl } from 'react-intl';
import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts';
import Account from 'flavours/glitch/features/list_editor/components/account';
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { accountId, added }) => ({
account: getAccount(state, accountId),
added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added,
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { accountId }) => ({
onRemove: () => dispatch(unpinAccount(accountId)),
onAdd: () => dispatch(pinAccount(accountId)),
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));

View File

@ -0,0 +1,21 @@
import React from 'react';
import { connect } from 'react-redux';
import { injectIntl } from 'react-intl';
import {
fetchPinnedAccountsSuggestions,
clearPinnedAccountsSuggestions,
changePinnedAccountsSuggestions
} from '../../../actions/accounts';
import Search from 'flavours/glitch/features/list_editor/components/search';
const mapStateToProps = state => ({
value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']),
});
const mapDispatchToProps = dispatch => ({
onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)),
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
onChange: value => dispatch(changePinnedAccountsSuggestions(value)),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));

View File

@ -0,0 +1,78 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, FormattedMessage } from 'react-intl';
import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts';
import AccountContainer from './containers/account_container';
import SearchContainer from './containers/search_container';
import Motion from 'flavours/glitch/util/optional_motion';
import spring from 'react-motion/lib/spring';
const mapStateToProps = state => ({
accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']),
searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']),
});
const mapDispatchToProps = dispatch => ({
onInitialize: () => dispatch(fetchPinnedAccounts()),
onClear: () => dispatch(clearPinnedAccountsSuggestions()),
onReset: () => dispatch(resetPinnedAccountsEditor()),
});
@connect(mapStateToProps, mapDispatchToProps)
@injectIntl
export default class PinnedAccountsEditor extends ImmutablePureComponent {
static propTypes = {
onClose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
onInitialize: PropTypes.func.isRequired,
onClear: PropTypes.func.isRequired,
onReset: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
accountIds: ImmutablePropTypes.list.isRequired,
searchAccountIds: ImmutablePropTypes.list.isRequired,
};
componentDidMount () {
const { onInitialize } = this.props;
onInitialize();
}
componentWillUnmount () {
const { onReset } = this.props;
onReset();
}
render () {
const { accountIds, searchAccountIds, onClear } = this.props;
const showSearch = searchAccountIds.size > 0;
return (
<div className='modal-root__modal list-editor'>
<h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4>
<SearchContainer />
<div className='drawer__pager'>
<div className='drawer__inner list-editor__accounts'>
{accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
</div>
{showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
{({ x }) =>
(<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
{searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
</div>)
}
</Motion>
</div>
</div>
);
}
}

View File

@ -327,10 +327,10 @@ export default class Status extends ImmutablePureComponent {
if (status && ancestorsIds && ancestorsIds.size > 0) {
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
if (element) {
window.requestAnimationFrame(() => {
element.scrollIntoView(true);
this._scrolledIntoView = true;
}
});
this._scrolledIntoView = true;
}
}

View File

@ -22,8 +22,13 @@ const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => {
</Link>
);
} else {
const handleOnClick = (e) => {
e.preventDefault();
e.stopPropagation();
return onClick(e);
}
return (
<a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}>
<a href='#' onClick={onClick && handleOnClick} className='column-link' tabIndex='0'>
<i className={`fa fa-fw fa-${icon} column-link__icon`} />
{text}
{badgeElement}

View File

@ -0,0 +1,122 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ImageLoader from './image_loader';
import classNames from 'classnames';
import { changeUploadCompose } from 'flavours/glitch/actions/compose';
import { getPointerPosition } from 'flavours/glitch/features/video';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = (dispatch, { id }) => ({
onSave: (x, y) => {
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
},
});
@connect(mapStateToProps, mapDispatchToProps)
export default class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
};
state = {
x: 0,
y: 0,
focusX: 0,
focusY: 0,
dragging: false,
};
componentWillMount () {
this.updatePositionFromMedia(this.props.media);
}
componentWillReceiveProps (nextProps) {
if (this.props.media.get('id') !== nextProps.media.get('id')) {
this.updatePositionFromMedia(nextProps.media);
}
}
componentWillUnmount () {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
this.updatePosition(e);
this.setState({ dragging: true });
}
handleMouseMove = e => {
this.updatePosition(e);
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
}
updatePosition = e => {
const { x, y } = getPointerPosition(this.node, e);
const focusX = (x - .5) * 2;
const focusY = (y - .5) * -2;
this.setState({ x, y, focusX, focusY });
}
updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
if (focusX && focusY) {
const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5;
this.setState({ x, y, focusX, focusY });
} else {
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
}
}
setRef = c => {
this.node = c;
}
render () {
const { media } = this.props;
const { x, y, dragging } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
return (
<div className='modal-root__modal video-modal focal-point-modal'>
<div className={classNames('focal-point', { dragging })} ref={this.setRef}>
<ImageLoader
previewSrc={media.get('preview_url')}
src={media.get('url')}
width={width}
height={height}
/>
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
</div>
</div>
);
}
}

View File

@ -11,6 +11,7 @@ import BoostModal from './boost_modal';
import FavouriteModal from './favourite_modal';
import DoodleModal from './doodle_modal';
import ConfirmationModal from './confirmation_modal';
import FocalPointModal from './focal_point_modal';
import {
OnboardingModal,
MuteModal,
@ -18,6 +19,7 @@ import {
SettingsModal,
EmbedModal,
ListEditor,
PinnedAccountsEditor,
} from 'flavours/glitch/util/async-components';
const MODAL_COMPONENTS = {
@ -34,6 +36,8 @@ const MODAL_COMPONENTS = {
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
};
export default class ModalRoot extends React.PureComponent {

View File

@ -92,6 +92,7 @@ const keyMap = {
goToProfile: 'g u',
goToBlocked: 'g b',
goToMuted: 'g m',
goToRequests: 'g r',
toggleSpoiler: 'x',
};
@ -369,6 +370,10 @@ export default class UI extends React.Component {
this.props.history.push('/mutes');
}
handleHotkeyGoToRequests = () => {
this.props.history.push('/follow_requests');
}
render () {
const { width, draggingOver } = this.state;
const { children, layout, isWide, navbarUnder, dropdownMenuIsOpen } = this.props;
@ -408,6 +413,7 @@ export default class UI extends React.Component {
goToProfile: this.handleHotkeyGoToProfile,
goToBlocked: this.handleHotkeyGoToBlocked,
goToMuted: this.handleHotkeyGoToMuted,
goToRequests: this.handleHotkeyGoToRequests,
};
return (

View File

@ -160,6 +160,9 @@ export default class Video extends React.PureComponent {
this.setState({ dragging: true });
this.video.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
}
handleMouseUp = () => {
@ -176,8 +179,10 @@ export default class Video extends React.PureComponent {
const { x } = getPointerPosition(this.seek, e);
const currentTime = Math.floor(this.video.duration * x);
this.video.currentTime = currentTime;
this.setState({ currentTime });
if (!isNaN(currentTime)) {
this.video.currentTime = currentTime;
this.setState({ currentTime });
}
}, 60);
togglePlay = () => {
@ -290,6 +295,15 @@ export default class Video extends React.PureComponent {
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
}
let preload;
if (startTime || fullscreen || dragging) {
preload = 'auto';
} else if (detailed) {
preload = 'metadata';
} else {
preload = 'none';
}
return (
<div
className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })}
@ -304,7 +318,7 @@ export default class Video extends React.PureComponent {
ref={this.setVideoRef}
src={src}
poster={preview}
preload={startTime ? 'auto' : 'none'}
preload={preload}
loop
role='button'
tabIndex='0'

View File

@ -6,6 +6,8 @@ import {
FOLLOWING_EXPAND_SUCCESS,
FOLLOW_REQUESTS_FETCH_SUCCESS,
FOLLOW_REQUESTS_EXPAND_SUCCESS,
PINNED_ACCOUNTS_FETCH_SUCCESS,
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
} from 'flavours/glitch/actions/accounts';
import {
BLOCKS_FETCH_SUCCESS,
@ -141,6 +143,8 @@ export default function accounts(state = initialState, action) {
case MUTES_EXPAND_SUCCESS:
case LIST_ACCOUNTS_FETCH_SUCCESS:
case LIST_EDITOR_SUGGESTIONS_READY:
case PINNED_ACCOUNTS_FETCH_SUCCESS:
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
return action.accounts ? normalizeAccounts(state, action.accounts) : state;
case NOTIFICATIONS_EXPAND_SUCCESS:
case SEARCH_FETCH_SUCCESS:

View File

@ -371,7 +371,7 @@ export default function compose(state = initialState, action) {
.set('is_submitting', false)
.update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) {
return item.set('description', action.media.description);
return fromJS(action.media);
}
return item;

View File

@ -28,6 +28,7 @@ import custom_emojis from './custom_emojis';
import lists from './lists';
import listEditor from './list_editor';
import filters from './filters';
import pinnedAccountsEditor from './pinned_accounts_editor';
const reducers = {
dropdown_menu,
@ -59,6 +60,7 @@ const reducers = {
lists,
listEditor,
filters,
pinnedAccountsEditor,
};
export default combineReducers(reducers);

View File

@ -11,6 +11,7 @@ const initialState = ImmutableMap({
navbar_under : false,
side_arm : 'none',
side_arm_reply_mode : 'keep',
show_reply_count : false,
collapsed : ImmutableMap({
enabled : true,
auto : ImmutableMap({

View File

@ -0,0 +1,57 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
PINNED_ACCOUNTS_EDITOR_RESET,
PINNED_ACCOUNTS_FETCH_REQUEST,
PINNED_ACCOUNTS_FETCH_SUCCESS,
PINNED_ACCOUNTS_FETCH_FAIL,
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY,
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR,
PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE,
ACCOUNT_PIN_SUCCESS,
ACCOUNT_UNPIN_SUCCESS,
} from '../actions/accounts';
const initialState = ImmutableMap({
accounts: ImmutableMap({
items: ImmutableList(),
loaded: false,
isLoading: false,
}),
suggestions: ImmutableMap({
value: '',
items: ImmutableList(),
}),
});
export default function listEditorReducer(state = initialState, action) {
switch(action.type) {
case PINNED_ACCOUNTS_EDITOR_RESET:
return initialState;
case PINNED_ACCOUNTS_FETCH_REQUEST:
return state.setIn(['accounts', 'isLoading'], true);
case PINNED_ACCOUNTS_FETCH_FAIL:
return state.setIn(['accounts', 'isLoading'], false);
case PINNED_ACCOUNTS_FETCH_SUCCESS:
return state.update('accounts', accounts => accounts.withMutations(map => {
map.set('isLoading', false);
map.set('loaded', true);
map.set('items', ImmutableList(action.accounts.map(item => item.id)));
}));
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CHANGE:
return state.setIn(['suggestions', 'value'], action.value);
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_READY:
return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id)));
case PINNED_ACCOUNTS_EDITOR_SUGGESTIONS_CLEAR:
return state.update('suggestions', suggestions => suggestions.withMutations(map => {
map.set('items', ImmutableList());
map.set('value', '');
}));
case ACCOUNT_PIN_SUCCESS:
return state.updateIn(['accounts', 'items'], list => list.unshift(action.relationship.id));
case ACCOUNT_UNPIN_SUCCESS:
return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.relationship.id));
default:
return state;
}
};

View File

@ -9,7 +9,7 @@ import {
COMPOSE_REPLY,
COMPOSE_DIRECT,
} from 'flavours/glitch/actions/compose';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({
value: '',
@ -39,7 +39,7 @@ export default function search(state = initialState, action) {
return state.set('results', ImmutableMap({
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
hashtags: ImmutableList(action.results.hashtags),
hashtags: fromJS(action.results.hashtags),
})).set('submitted', true);
default:
return state;

View File

@ -1,5 +1,6 @@
import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable';
import { me } from 'flavours/glitch/util/initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
@ -77,7 +78,7 @@ export const makeGetStatus = () => {
return null;
}
const regex = regexFromFilters(filters);
const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
let filtered = false;
if (statusReblog) {

View File

@ -255,11 +255,12 @@
& > div {
position: relative;
border-radius: 4px;
height: 100px;
height: 140px;
width: 100%;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
overflow: hidden;
input {
display: block;
@ -298,6 +299,34 @@
}
}
.composer--upload_form--actions {
background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
display: flex;
align-items: flex-start;
justify-content: space-between;
opacity: 0;
transition: opacity .1s ease;
.icon-button {
flex: 0 1 auto;
color: $ui-secondary-color;
font-size: 14px;
font-weight: 500;
padding: 10px;
font-family: inherit;
&:hover,
&:focus,
&:active {
color: lighten($ui-secondary-color, 4%);
}
}
&.active {
opacity: 1;
}
}
.composer--upload_form--progress {
display: flex;
padding: 10px;
@ -377,6 +406,12 @@
background: $ui-highlight-color;
transition: none;
}
&.top {
& > .value {
border-radius: 0 0 4px 4px;
box-shadow: 0 4px 4px rgba($base-shadow-color, 0.1);
}
}
}
}

View File

@ -763,3 +763,39 @@
}
}
}
.focal-point {
position: relative;
cursor: pointer;
overflow: hidden;
&.dragging {
cursor: move;
}
img {
max-width: 80vw;
max-height: 80vh;
width: auto;
height: auto;
margin: auto;
}
&__reticle {
position: absolute;
width: 100px;
height: 100px;
transform: translate(-50%, -50%);
background: url('~/images/reticle.png') no-repeat 0 0;
border-radius: 50%;
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
}
&__overlay {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
}

View File

@ -90,16 +90,80 @@
font-weight: 500;
}
.search-results__hashtag {
display: block;
padding: 10px;
color: $secondary-text-color;
text-decoration: none;
.trends {
&__header {
color: $dark-text-color;
background: lighten($ui-base-color, 2%);
border-bottom: 1px solid darken($ui-base-color, 4%);
font-weight: 500;
padding: 15px;
font-size: 16px;
cursor: default;
&:hover,
&:active,
&:focus {
color: lighten($secondary-text-color, 4%);
text-decoration: underline;
.fa {
display: inline-block;
margin-right: 5px;
}
}
&__item {
display: flex;
align-items: center;
padding: 15px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
&__name {
flex: 1 1 auto;
color: $dark-text-color;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
strong {
font-weight: 500;
}
a {
color: $darker-text-color;
text-decoration: none;
font-size: 14px;
font-weight: 500;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover,
&:focus,
&:active {
span {
text-decoration: underline;
}
}
}
}
&__current {
flex: 0 0 auto;
width: 100px;
font-size: 24px;
line-height: 36px;
font-weight: 500;
text-align: center;
color: $secondary-text-color;
}
&__sparkline {
flex: 0 0 auto;
width: 50px;
path {
stroke: lighten($highlight-text-color, 6%) !important;
}
}
}
}

View File

@ -417,15 +417,31 @@
align-items: center;
display: flex;
margin-top: 8px;
&__counter {
display: inline-flex;
margin-right: 11px;
align-items: center;
.status__action-bar-button {
margin-right: 4px;
}
&__label {
display: inline-block;
width: 14px;
font-size: 12px;
font-weight: 500;
color: $action-button-color;
}
}
}
.status__action-bar-button {
float: left;
margin-right: 18px;
}
.status__action-bar-dropdown {
float: left;
height: 23.15px;
width: 23.15px;
}

View File

@ -38,6 +38,10 @@ export function ListEditor () {
return import(/* webpackChunkName: "flavours/glitch/async/list_editor" */'flavours/glitch/features/list_editor');
}
export function PinnedAccountsEditor () {
return import(/* webpackChunkName: "flavours/glitch/async/pinned_accounts_editor" */'flavours/glitch/features/pinned_accounts_editor');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "flavours/glitch/async/direct_timeline" */'flavours/glitch/features/direct_timeline');
}

View File

@ -0,0 +1,10 @@
import React, { Fragment } from 'react';
import { FormattedNumber } from 'react-intl';
export const shortNumberFormat = number => {
if (number < 1000) {
return <FormattedNumber value={number} />;
} else {
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>;
}
};

View File

@ -18,7 +18,7 @@ pack:
mailer:
modal:
public: public.js
settings:
settings: public.js
share: share.js
# (OPTIONAL) The directory which contains localization files for

View File

@ -140,7 +140,7 @@ export function redraft(status) {
};
};
export function deleteStatus(id, withRedraft = false) {
export function deleteStatus(id, router, withRedraft = false) {
return (dispatch, getState) => {
const status = getState().getIn(['statuses', id]);
@ -153,6 +153,10 @@ export function deleteStatus(id, withRedraft = false) {
if (withRedraft) {
dispatch(redraft(status));
if (!getState().getIn(['compose', 'mounted'])) {
router.push('/statuses/new');
}
}
}).catch(error => {
dispatch(deleteStatusFail(id, error));

View File

@ -55,7 +55,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
return;
}
if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) {
if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) {
params.since_id = timeline.getIn(['items', 0]);
}

View File

@ -137,7 +137,7 @@ class DropdownMenu extends React.PureComponent {
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className='dropdown-menu' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>

View File

@ -50,7 +50,7 @@ class Item extends React.PureComponent {
handleClick = (e) => {
const { index, onClick } = this.props;
if (e.button === 0) {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
onClick(index);
}

View File

@ -65,7 +65,7 @@ export default class Status extends ImmutablePureComponent {
}
handleAccountClick = (e) => {
if (this.context.router && e.button === 0) {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);

View File

@ -32,6 +32,16 @@ const messages = defineMessages({
embed: { id: 'status.embed', defaultMessage: 'Embed' },
});
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
@injectIntl
export default class StatusActionBar extends ImmutablePureComponent {
@ -86,11 +96,11 @@ export default class StatusActionBar extends ImmutablePureComponent {
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
this.props.onDelete(this.props.status, this.context.router.history);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, true);
this.props.onDelete(this.props.status, this.context.router.history, true);
}
handlePinClick = () => {
@ -194,7 +204,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<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}

View File

@ -64,7 +64,7 @@ export default class StatusContent extends React.PureComponent {
}
onMentionClick = (mention, e) => {
if (this.context.router && e.button === 0) {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/accounts/${mention.get('id')}`);
}
@ -73,7 +73,7 @@ export default class StatusContent extends React.PureComponent {
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '').toLowerCase();
if (this.context.router && e.button === 0) {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/timelines/tag/${hashtag}`);
}

View File

@ -93,14 +93,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onDelete (status, withRedraft = false) {
onDelete (status, history, withRedraft = false) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), withRedraft));
dispatch(deleteStatus(status.get('id'), history, withRedraft));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
}));
}
},

View File

@ -147,17 +147,17 @@ export default class ActionBar extends React.PureComponent {
<div className='account__action-bar'>
<div className='account__action-bar-links'>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
<FormattedMessage id='account.posts' defaultMessage='Toots' />
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong>
</Link>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<FormattedMessage id='account.follows' defaultMessage='Follows' />
<strong>{shortNumberFormat(account.get('following_count'))}</strong>
</Link>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
<Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<FormattedMessage id='account.followers' defaultMessage='Followers' />
<strong>{shortNumberFormat(account.get('followers_count'))}</strong>
</Link>

View File

@ -28,6 +28,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
style: PropTypes.object,
items: PropTypes.array.isRequired,
value: PropTypes.string.isRequired,
placement: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
@ -119,7 +120,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
render () {
const { mounted } = this.state;
const { style, items, value } = this.props;
const { style, items, placement, value } = this.props;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
@ -127,7 +128,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
<div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'>
@ -226,7 +227,7 @@ export default class PrivacyDropdown extends React.PureComponent {
const valueOption = this.options.find(item => item.value === value);
return (
<div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
<IconButton
className='privacy-dropdown__value-icon'
@ -247,6 +248,7 @@ export default class PrivacyDropdown extends React.PureComponent {
value={value}
onClose={this.handleClose}
onChange={this.handleChange}
placement={placement}
/>
</Overlay>
</div>

View File

@ -30,7 +30,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
}
handleAccountClick = (e) => {
if (e.button === 0) {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}

View File

@ -20,6 +20,7 @@ export default class Upload extends ImmutablePureComponent {
onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
state = {
@ -28,6 +29,17 @@ export default class Upload extends ImmutablePureComponent {
dirtyDescription: null,
};
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
}
}
handleSubmit = () => {
this.handleInputBlur();
this.props.onSubmit();
}
handleUndoClick = () => {
this.props.onUndo(this.props.media.get('id'));
}
@ -93,6 +105,7 @@ export default class Upload extends ImmutablePureComponent {
onFocus={this.handleInputFocus}
onChange={this.handleInputChange}
onBlur={this.handleInputBlur}
onKeyDown={this.handleKeyDown}
/>
</label>
</div>

View File

@ -2,6 +2,7 @@ import { connect } from 'react-redux';
import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modal';
import { submitCompose } from '../../../actions/compose';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@ -21,6 +22,10 @@ const mapDispatchToProps = dispatch => ({
dispatch(openModal('FOCAL_POINT', { id }));
},
onSubmit () {
dispatch(submitCompose());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

View File

@ -139,6 +139,7 @@ export default class GettingStarted extends ImmutablePureComponent {
{multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='https://github.com/tootsuite/documentation#documentation' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>

View File

@ -65,11 +65,11 @@ export default class ActionBar extends React.PureComponent {
}
handleDeleteClick = () => {
this.props.onDelete(this.props.status);
this.props.onDelete(this.props.status, this.context.router.history);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, true);
this.props.onDelete(this.props.status, this.context.router.history, true);
}
handleDirectClick = () => {

View File

@ -26,7 +26,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
};
handleAccountClick = (e) => {
if (e.button === 0) {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}

View File

@ -174,16 +174,16 @@ export default class Status extends ImmutablePureComponent {
}
}
handleDeleteClick = (status, withRedraft = false) => {
handleDeleteClick = (status, history, withRedraft = false) => {
const { dispatch, intl } = this.props;
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), withRedraft));
dispatch(deleteStatus(status.get('id'), history, withRedraft));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
}));
}
}
@ -355,7 +355,9 @@ export default class Status extends ImmutablePureComponent {
if (status && ancestorsIds && ancestorsIds.size > 0) {
const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
element.scrollIntoView(true);
window.requestAnimationFrame(() => {
element.scrollIntoView(true);
});
this._scrolledIntoView = true;
}
}

View File

@ -37,7 +37,7 @@ export default class BoostModal extends ImmutablePureComponent {
}
handleAccountClick = (e) => {
if (e.button === 0) {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.props.onClose();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);

View File

@ -89,6 +89,7 @@ const keyMap = {
goToProfile: 'g u',
goToBlocked: 'g b',
goToMuted: 'g m',
goToRequests: 'g r',
toggleHidden: 'x',
};
@ -427,6 +428,10 @@ export default class UI extends React.PureComponent {
this.context.router.history.push('/mutes');
}
handleHotkeyGoToRequests = () => {
this.context.router.history.push('/follow_requests');
}
render () {
const { draggingOver } = this.state;
const { children, isComposing, location, dropdownMenuIsOpen } = this.props;
@ -449,6 +454,7 @@ export default class UI extends React.PureComponent {
goToProfile: this.handleHotkeyGoToProfile,
goToBlocked: this.handleHotkeyGoToBlocked,
goToMuted: this.handleHotkeyGoToMuted,
goToRequests: this.handleHotkeyGoToRequests,
};
return (

View File

@ -158,6 +158,9 @@ export default class Video extends React.PureComponent {
this.setState({ dragging: true });
this.video.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
}
handleMouseUp = () => {
@ -174,8 +177,10 @@ export default class Video extends React.PureComponent {
const { x } = getPointerPosition(this.seek, e);
const currentTime = Math.floor(this.video.duration * x);
this.video.currentTime = currentTime;
this.setState({ currentTime });
if (!isNaN(currentTime)) {
this.video.currentTime = currentTime;
this.setState({ currentTime });
}
}, 60);
togglePlay = () => {
@ -281,6 +286,15 @@ export default class Video extends React.PureComponent {
playerStyle.height = height;
}
let preload;
if (startTime || fullscreen || dragging) {
preload = 'auto';
} else if (detailed) {
preload = 'metadata';
} else {
preload = 'none';
}
return (
<div
role='menuitem'
@ -296,7 +310,7 @@ export default class Video extends React.PureComponent {
ref={this.setVideoRef}
src={src}
poster={preview}
preload={startTime ? 'auto' : 'none'}
preload={preload}
loop
role='button'
tabIndex='0'

View File

@ -7,7 +7,7 @@
"account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
"account.domain_blocked": "النطاق مخفي",
"account.edit_profile": "تعديل الملف الشخصي",
"account.endorse": "Feature on profile",
"account.endorse": "إبرازه على الملف الشخصي",
"account.follow": "تابِع",
"account.followers": "المتابعون",
"account.follows": "يتبع",
@ -27,7 +27,7 @@
"account.show_reblogs": "عرض ترقيات @{name}",
"account.unblock": "إلغاء الحظر عن @{name}",
"account.unblock_domain": "فك حظر {domain}",
"account.unendorse": "Don't feature on profile",
"account.unendorse": "إزالة ترويجه مِن الملف الشخصي",
"account.unfollow": "إلغاء المتابعة",
"account.unmute": "إلغاء الكتم عن @{name}",
"account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
@ -90,7 +90,7 @@
"confirmations.redraft.message": "هل أنت متأكد من أنك تريد حذف هذا المنشور و إعادة صياغته ؟ سوف تفقد جميع الردود و الترقيات و المفضلة المتصلة به.",
"confirmations.unfollow.confirm": "إلغاء المتابعة",
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
"embed.instructions": "يمكنكم إدماج هذه الحالة على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
"embed.instructions": "يمكنكم إدماج هذا المنشور على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
"embed.preview": "هكذا ما سوف يبدو عليه :",
"emoji_button.activity": "الأنشطة",
"emoji_button.custom": "مخصص",
@ -109,7 +109,7 @@
"empty_column.community": "الخط الزمني المحلي فارغ. أكتب شيئا ما للعامة كبداية !",
"empty_column.direct": "لم تتلق أية رسالة خاصة مباشِرة بعد. سوف يتم عرض الرسائل المباشرة هنا إن قمت بإرسال واحدة أو تلقيت البعض منها.",
"empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
"empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.",
"empty_column.home": "إنّ الخيط الزمني لصفحتك الرئيسية فارغ. قم بزيارة {public} أو استخدم حقل البحث لكي تكتشف مستخدمين آخرين.",
"empty_column.home.public_timeline": "الخيط العام",
"empty_column.list": "هذه القائمة فارغة مؤقتا و لكن سوف تمتلئ تدريجيا عندما يبدأ الأعضاء المُنتَمين إليها بنشر تبويقات.",
"empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
@ -162,6 +162,7 @@
"missing_indicator.label": "تعذر العثور عليه",
"missing_indicator.sublabel": "تعذر العثور على هذا المورد",
"mute_modal.hide_notifications": "هل تود إخفاء الإخطارات القادمة من هذا المستخدم ؟",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "الحسابات المحجوبة",
"navigation_bar.community_timeline": "الخيط العام المحلي",
"navigation_bar.direct": "الرسائل المباشِرة",
@ -181,7 +182,7 @@
"navigation_bar.preferences": "التفضيلات",
"navigation_bar.public_timeline": "الخيط العام الموحد",
"navigation_bar.security": "الأمان",
"notification.favourite": "{name} أعجب بمنشورك",
"notification.favourite": "أُعجِب {name} بمنشورك",
"notification.follow": "{name} يتابعك",
"notification.mention": "{name} ذكرك",
"notification.reblog": "{name} قام بترقية تبويقك",
@ -272,7 +273,7 @@
"status.pinned": "تبويق مثبَّت",
"status.reblog": "رَقِّي",
"status.reblog_private": "القيام بالترقية إلى الجمهور الأصلي",
"status.reblogged_by": "{name} رقى",
"status.reblogged_by": "رقّاه {name}",
"status.redraft": "إزالة و إعادة الصياغة",
"status.reply": "ردّ",
"status.replyAll": "رُد على الخيط",

View File

@ -162,6 +162,7 @@
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"mute_modal.hide_notifications": "Hide notifications from this user?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.direct": "Direct messages",

View File

@ -162,6 +162,7 @@
"missing_indicator.label": "Not found",
"missing_indicator.sublabel": "This resource could not be found",
"mute_modal.hide_notifications": "Hide notifications from this user?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.community_timeline": "Local timeline",
"navigation_bar.direct": "Direct messages",

View File

@ -162,6 +162,7 @@
"missing_indicator.label": "No trobat",
"missing_indicator.sublabel": "Aquest recurs no pot ser trobat",
"mute_modal.hide_notifications": "Amagar notificacions d'aquest usuari?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Usuaris bloquejats",
"navigation_bar.community_timeline": "Línia de temps Local",
"navigation_bar.direct": "Missatges directes",

View File

@ -162,6 +162,7 @@
"missing_indicator.label": "Micca trovu",
"missing_indicator.sublabel": "Ùn era micca pussivule di truvà sta risorsa",
"mute_modal.hide_notifications": "Piattà nutificazione da st'utilizatore?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Utilizatori bluccati",
"navigation_bar.community_timeline": "Linea pubblica lucale",
"navigation_bar.direct": "Missaghji diretti",

View File

@ -7,7 +7,7 @@
"account.disclaimer_full": "Níže uvedené informace nemusejí zcela odrážet profil uživatele.",
"account.domain_blocked": "Doména skryta",
"account.edit_profile": "Upravit profil",
"account.endorse": "Feature on profile",
"account.endorse": "Představit na profilu",
"account.follow": "Sleduj",
"account.followers": "Sledovatelé",
"account.follows": "Sleduje",
@ -27,7 +27,7 @@
"account.show_reblogs": "Zobrazit boosty od uživatele @{name}",
"account.unblock": "Odblokovat uživatele @{name}",
"account.unblock_domain": "Odkrýt doménu {domain}",
"account.unendorse": "Don't feature on profile",
"account.unendorse": "Nepředstavit na profilu",
"account.unfollow": "Přestat sledovat",
"account.unmute": "Přestat ignorovat uživatele @{name}",
"account.unmute_notifications": "Odtišit oznámení od uživatele @{name}",
@ -162,6 +162,7 @@
"missing_indicator.label": "Nenalezeno",
"missing_indicator.sublabel": "Tento zdroj se nepodařilo najít",
"mute_modal.hide_notifications": "Skrýt oznámení před tímto uživatelem?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blokovaní uživatelé",
"navigation_bar.community_timeline": "Místní časová osa",
"navigation_bar.direct": "Přímé zprávy",
@ -229,11 +230,11 @@
"privacy.unlisted.short": "Nezobrazované",
"regeneration_indicator.label": "Načítám…",
"regeneration_indicator.sublabel": "Váš domovský proud se připravuje!",
"relative_time.days": "{number}d",
"relative_time.hours": "{number}h",
"relative_time.days": "{number} d",
"relative_time.hours": "{number} h",
"relative_time.just_now": "teď",
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.minutes": "{number} m",
"relative_time.seconds": "{number} s",
"reply_indicator.cancel": "Zrušit",
"report.forward": "Přeposlat k {target}",
"report.forward_hint": "Tento účet je z jiného serveru. Chcete na něj také poslat anonymizovanou kopii?",

View File

@ -7,27 +7,27 @@
"account.disclaimer_full": "Nedenstående oplysninger reflekterer ikke nødvendigvis brugerens profil fuldstændigt.",
"account.domain_blocked": "Domænet er blevet skjult",
"account.edit_profile": "Rediger profil",
"account.endorse": "Feature on profile",
"account.endorse": "Fremhæv på profil",
"account.follow": "Følg",
"account.followers": "Følgere",
"account.follows": "Følger",
"account.follows_you": "Følger dig",
"account.hide_reblogs": "Skjul fremhævelserne fra @{name}",
"account.media": "Multimedier",
"account.media": "Medie",
"account.mention": "Nævn @{name}",
"account.moved_to": "{name} er flyttet til:",
"account.mute": "Dæmp @{name}",
"account.mute_notifications": "Dæmp notifikationer fra @{name}",
"account.muted": "Dæmpet",
"account.posts": "Dyt",
"account.posts_with_replies": "Toots og svar",
"account.posts": "Trut",
"account.posts_with_replies": "Trut samt svar",
"account.report": "Rapporter @{name}",
"account.requested": "Afventer godkendelse. Tryk for at annullere følgeanmodning",
"account.share": "Del @{name}s profil",
"account.show_reblogs": "Vis fremhævelserne fra @{name}",
"account.unblock": "Fjern blokeringen af @{name}",
"account.unblock_domain": "Skjul ikke længere {domain}",
"account.unendorse": "Don't feature on profile",
"account.unendorse": "Fremhæv ikke på profil",
"account.unfollow": "Følg ikke længere",
"account.unmute": "Fjern dæmpningen af @{name}",
"account.unmute_notifications": "Fjern dæmpningen af notifikationer fra @{name}",
@ -83,7 +83,7 @@
"confirmations.delete_list.confirm": "Slet",
"confirmations.delete_list.message": "Er du sikker på, du vil slette denne liste?",
"confirmations.domain_block.confirm": "Skjul helt domæne",
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
"confirmations.domain_block.message": "Er du helt sikker på du vil blokere hele {domain} domænet? I de fleste tilfælde vil få specifikke blokeringer eller dæmpninger være nok og at fortrække. Du vil ikke se indhold fra det domæne hverken på offentlige tidslinjer eller i dine notifikationer. Dine følgere fra det domæne vil blive fjernet.",
"confirmations.mute.confirm": "Dæmp",
"confirmations.mute.message": "Er du sikker på, du vil dæmpe {name}?",
"confirmations.redraft.confirm": "Slet & omskriv",
@ -103,7 +103,7 @@
"emoji_button.people": "Mennesker",
"emoji_button.recent": "Oftest brugt",
"emoji_button.search": "Søg...",
"emoji_button.search_results": "Søgeresultat",
"emoji_button.search_results": "Søgeresultater",
"emoji_button.symbols": "Symboler",
"emoji_button.travel": "Rejser & steder",
"empty_column.community": "Den lokale tidslinje er tom. Skriv noget offentligt for at starte lavinen!",
@ -139,7 +139,7 @@
"keyboard_shortcuts.hotkey": "Hurtigtast",
"keyboard_shortcuts.legend": "for at vise denne legende",
"keyboard_shortcuts.mention": "for at nævne forfatteren",
"keyboard_shortcuts.profile": "to open author's profile",
"keyboard_shortcuts.profile": "til profil af åben forfatter",
"keyboard_shortcuts.reply": "for at svare",
"keyboard_shortcuts.search": "for at fokusere søgningen",
"keyboard_shortcuts.toggle_hidden": "for at vise/skjule tekst bag CW",
@ -162,6 +162,7 @@
"missing_indicator.label": "Ikke fundet",
"missing_indicator.sublabel": "Denne ressource kunne ikke blive fundet",
"mute_modal.hide_notifications": "Skjul notifikationer fra denne bruger?",
"navigation_bar.apps": "Mobile apps",
"navigation_bar.blocks": "Blokerede brugere",
"navigation_bar.community_timeline": "Lokal tidslinje",
"navigation_bar.direct": "Direkte beskeder",
@ -215,8 +216,8 @@
"onboarding.page_six.read_guidelines": "Læs venligst {domain}s {guidelines}!",
"onboarding.page_six.various_app": "apps til mobilen",
"onboarding.page_three.profile": "Rediger din profil for at ændre profilbillede, beskrivelse og visningsnavn. Der vil du også finde andre indstillinger.",
"onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
"onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
"onboarding.page_three.search": "Brug søgefeltdet for at finde folk og at kigge på hashtags, så som {illustration} and {introductions}. For at finde en person der ikke er på denne instans, brug deres fulde brugernavn.",
"onboarding.page_two.compose": "Skriv opslag fra skrive kolonnen. Du kan uploade billeder, ændre privatlivsindstillinger, og tilføje indholds advarsler med ikoner forneden.",
"onboarding.skip": "Spring over",
"privacy.change": "Ændre status privatliv",
"privacy.direct.long": "Post til kun de nævnte brugere",
@ -243,7 +244,7 @@
"report.target": "Anmelder {target}",
"search.placeholder": "Søg",
"search_popout.search_format": "Avanceret søgeformat",
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.full_text": "Simpel tekst returnerer statusser du har skrevet, favoriseret, fremhævet, eller er blevet nævnt i, lige så vel som matchende brugernavne, visningsnavne, og hashtags.",
"search_popout.tips.hashtag": "emnetag",
"search_popout.tips.status": "status",
"search_popout.tips.text": "Simpelt tekst returnerer passende visningsnavne, brugernavne og hashtags",
@ -275,7 +276,7 @@
"status.reblogged_by": "{name} fremhævede",
"status.redraft": "Slet og omskriv",
"status.reply": "Svar",
"status.replyAll": "Svar tråd",
"status.replyAll": "Svar samtale",
"status.report": "Anmeld @{name}",
"status.sensitive_toggle": "Tryk for at se",
"status.sensitive_warning": "Følsomt indhold",
@ -291,11 +292,11 @@
"tabs_bar.local_timeline": "Lokal",
"tabs_bar.notifications": "Notifikationer",
"tabs_bar.search": "Søg",
"trends.count_by_accounts": "{count} {rawCount, flere, en {person} flere {people}} snakker",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} snakker",
"ui.beforeunload": "Din kladde vil gå tabt hvis du forlader Mastodon.",
"upload_area.title": "Træk og slip for at uploade",
"upload_button.label": "Tilføj multimedier",
"upload_form.description": "Beskrivelse for de svagtseende",
"upload_button.label": "Tilføj medie",
"upload_form.description": "Beskriv for de svagtseende",
"upload_form.focus": "Beskær",
"upload_form.undo": "Slet",
"upload_progress.label": "Uploader...",

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