Merge branch 'develop' of https://code.gab.com/gab/social/gab-social into feature/frontend_refactor

This commit is contained in:
mgabdev 2020-01-28 11:29:26 -05:00
commit 0a3c6cea89
225 changed files with 5598 additions and 2652 deletions

View File

@ -1,11 +0,0 @@
# Changelog
Changes to Gab Social will be documented in this file.
## [1.0.0] - 2019-07-04
- Mastodon renamed to Gab Social
- Mastodon UI replaced with new Gab design
- Mastodon streaming server re-written for Node 10
- Qualified on Postgres 11
- Documentation moved to separate repo

View File

@ -8,21 +8,21 @@ Our goal is to establish the foundation of a federated network of social network
## Project goals ## Project goals
We have diverged from Gab Social in several ways in pursuit of our own goals. We have diverged from Mastodon in several ways in pursuit of our own goals.
1. Node.js has been updated to 10.15.3LTS for hosting the Streaming API in compliance with the Gab Platform. 1. Node.js has been updated to 10.15.3LTS for hosting the Streaming API in compliance with the Gab Platform.
1. Statuses were renamed from 'toots' to 'gabs' 1. Statuses were renamed from 'toots' to 'gabs'
1. The maximum length of a status was increased to 3,000 characters 1. The maximum length of a status was increased to 3,000 characters
1. Advanced media (MP4, WebM, etc.) was limited to PRO subscribers
1. The creation of custom emoji was limited to PRO subscribers
1. The browser client user experience has been significantly altered to match what users of Gab will expect 1. The browser client user experience has been significantly altered to match what users of Gab will expect
1. Features were added to integrate the system with the Gab platform (accessing trends from Dissenter, for example) 1. Features were added to integrate the system with the Gab platform (accessing trends from Dissenter, for example)
1. Groups and group moderation
1. Quote posting
## BTCPay ## BTCPay
In order to make BTC flow work, 3 enviornment variables need to be set: In order to make BTC flow work, 3 environment variables need to be set:
- `BTCPAY_LEGACY_TOKEN`: So called Legacy Tokens can be found in https://btcpay.xxx.com/stores/yyy/Tokens - `BTCPAY_LEGACY_TOKEN`: So called Legacy Tokens can be found in https://btcpay.[yourdomain].com/stores/[yourstore]/Tokens
- `BTCPAY_PUB_KEY`: Public key that is used when creating an access token or pairing https://btcpay.xxx.com/stores/yyy/Tokens/Create - `BTCPAY_PUB_KEY`: Public key that is used when creating an access token or pairing https://btcpay.[yourdomain].com/stores/[yourstore]/Tokens/Create
- `BTCPAY_MERCHANT_TOKEN`: Token created for facade *merchant* - `BTCPAY_MERCHANT_TOKEN`: Token created for facade *merchant*
## Deployment ## Deployment

View File

@ -2,7 +2,7 @@
module Admin module Admin
class AccountsController < BaseController class AccountsController < BaseController
before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject, :verify, :unverify, :add_donor_badge, :remove_donor_badge, :add_investor_badge, :remove_investor_badge, :edit_pro, :save_pro] before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject, :verify, :unverify, :add_donor_badge, :remove_donor_badge, :add_investor_badge, :remove_investor_badge, :edit_pro, :save_pro, :edit, :update]
before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload] before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload]
before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
@ -173,6 +173,22 @@ module Admin
redirect_to edit_pro_admin_account_path(@account.id) redirect_to edit_pro_admin_account_path(@account.id)
end end
def edit
redirect_to admin_account_path(@account.id) unless @account.local?
@user = @account.user
end
def update
redirect_to admin_account_path(@account.id) unless @account.local?
@user = @account.user
if @user.update(credentials_params)
redirect_to admin_account_path(@account.id), notice: I18n.t('generic.changes_saved_msg')
else
render action: :edit
end
end
private private
def set_account def set_account
@ -211,5 +227,14 @@ module Admin
def pro_params def pro_params
params.require(:account).permit(:is_pro, :pro_expires_at) params.require(:account).permit(:is_pro, :pro_expires_at)
end end
def credentials_params
new_params = params.require(:user).permit(:email, :password, :password_confirmation)
if new_params[:password].blank? && new_params[:password_confirmation].blank?
new_params.delete(:password)
new_params.delete(:password_confirmation)
end
new_params
end
end end
end end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
module Admin
class GroupsController < BaseController
before_action :set_group, except: [:index]
before_action :set_filter_params
def index
authorize :group, :index?
@groups = filtered_groups.page(params[:page])
end
def destroy
authorize @group, :destroy?
@group.destroy!
log_action :destroy, @group
flash[:notice] = I18n.t('admin.groups.destroyed_msg')
redirect_to admin_groups_path(page: params[:page], **@filter_params)
end
def enable_featured
authorize @group, :update?
@group.is_featured = true
@group.save!
log_action :update, @group
flash[:notice] = I18n.t('admin.groups.updated_msg')
redirect_to admin_groups_path(page: params[:page], **@filter_params)
end
def disable_featured
authorize @group, :update?
@group.is_featured = false
@group.save!
log_action :update, @group
flash[:notice] = I18n.t('admin.groups.updated_msg')
redirect_to admin_groups_path(page: params[:page], **@filter_params)
end
private
def set_group
@group = Group.find(params[:id])
end
def set_filter_params
@filter_params = filter_params.to_hash.symbolize_keys
end
def resource_params
params.require(:group).permit(:is_featured, :is_nsfw)
end
def filtered_groups
query = Group.order('is_featured DESC, member_count DESC')
if params[:title]
query = query.where("LOWER(title) LIKE LOWER(?)", "%#{params[:title]}%")
end
return query
end
def filter_params
params.permit(:sort,)
end
end
end

View File

@ -11,7 +11,12 @@ class Api::V1::AccountByUsernameController < Api::BaseController
end end
def set_account def set_account
@account = Account.find_local!(params[:username]) username, domain = params[:username].split("@")
if domain
@account = Account.find_remote!(username, domain)
else
@account = Account.find_local!(username)
end
end end
def check_account_suspension def check_account_suspension

View File

@ -20,13 +20,19 @@ class Api::V1::Groups::AccountsController < Api::BaseController
authorize @group, :join? authorize @group, :join?
@group.accounts << current_account @group.accounts << current_account
if current_user.allows_group_in_home_feed?
current_user.force_regeneration!
end
render json: @group, serializer: REST::GroupRelationshipSerializer, relationships: relationships render json: @group, serializer: REST::GroupRelationshipSerializer, relationships: relationships
end end
def update def update
authorize @group, :update_account? authorize @group, :update_account?
GroupAccount.where(group: @group, account_id: current_account.id).update(group_account_params) @account = @group.accounts.find(params[:account_id])
GroupAccount.where(group: @group, account: @account).update(group_account_params)
render_empty render_empty
end end
@ -34,6 +40,11 @@ class Api::V1::Groups::AccountsController < Api::BaseController
authorize @group, :leave? authorize @group, :leave?
GroupAccount.where(group: @group, account_id: current_account.id).destroy_all GroupAccount.where(group: @group, account_id: current_account.id).destroy_all
if current_user.allows_group_in_home_feed?
current_user.force_regeneration!
end
render json: @group, serializer: REST::GroupRelationshipSerializer, relationships: relationships render json: @group, serializer: REST::GroupRelationshipSerializer, relationships: relationships
end end

View File

@ -12,7 +12,7 @@ class Api::V1::GroupsController < Api::BaseController
def index def index
case current_tab case current_tab
when 'featured' when 'featured'
@groups = Group.where(is_featured: true).limit(25).all @groups = Group.where(is_featured: true).limit(50).all
when 'member' when 'member'
@groups = Group.joins(:group_accounts).where(is_archived: false, group_accounts: { account: current_account }).order('group_accounts.unread_count DESC, group_accounts.id DESC').all @groups = Group.joins(:group_accounts).where(is_archived: false, group_accounts: { account: current_account }).order('group_accounts.unread_count DESC, group_accounts.id DESC').all
when 'admin' when 'admin'
@ -33,6 +33,8 @@ class Api::V1::GroupsController < Api::BaseController
end end
def create def create
authorize :group, :create?
@group = Group.create!(group_params.merge(account: current_account)) @group = Group.create!(group_params.merge(account: current_account))
render json: @group, serializer: REST::GroupSerializer render json: @group, serializer: REST::GroupSerializer
end end

View File

@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController
respond_to :json respond_to :json
def create def create
@media = current_account.media_attachments.create!(media_params) @media = current_account.media_attachments.create!(account: current_account, file: media_params[:file], description: media_params[:description], focus: media_params[:focus])
render json: @media, serializer: REST::MediaAttachmentSerializer render json: @media, serializer: REST::MediaAttachmentSerializer
rescue Paperclip::Errors::NotIdentifiedByImageMagickError rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422 render json: file_type_error, status: 422

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::NotificationsController < Api::BaseController class Api::V1::NotificationsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss] before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss, :mark_read]
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss] before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss, :mark_read]
before_action :require_user! before_action :require_user!
after_action :insert_pagination_headers, only: :index after_action :insert_pagination_headers, only: :index
@ -30,6 +30,11 @@ class Api::V1::NotificationsController < Api::BaseController
render_empty render_empty
end end
def mark_read
current_account.notifications.find(params[:id]).mark_read!
render_empty
end
private private
def load_notifications def load_notifications

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::SearchController < Api::BaseController class Api::V1::SearchController < Api::BaseController
RESULTS_LIMIT = 20 RESULTS_LIMIT = 100
respond_to :json respond_to :json

View File

@ -3,10 +3,10 @@
class Api::V1::StatusesController < Api::BaseController class Api::V1::StatusesController < Api::BaseController
include Authorization include Authorization
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy] before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :destroy] before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
before_action :require_user!, except: [:show, :context, :card] before_action :require_user!, except: [:show, :context, :card]
before_action :set_status, only: [:show, :context, :card] before_action :set_status, only: [:show, :context, :card, :update, :revisions]
respond_to :json respond_to :json
@ -33,14 +33,10 @@ class Api::V1::StatusesController < Api::BaseController
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end end
def card def revisions
@card = @status.preview_cards.first @revisions = @status.revisions
if @card.nil? render json: @revisions, each_serializer: REST::StatusRevisionSerializer
render_empty
else
render json: @card, serializer: REST::PreviewCardSerializer
end
end end
def create def create
@ -55,11 +51,27 @@ class Api::V1::StatusesController < Api::BaseController
application: doorkeeper_token.application, application: doorkeeper_token.application,
poll: status_params[:poll], poll: status_params[:poll],
idempotency: request.headers['Idempotency-Key'], idempotency: request.headers['Idempotency-Key'],
group_id: status_params[:group_id]) group_id: status_params[:group_id],
quote_of_id: status_params[:quote_of_id])
render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
end end
def update
authorize @status, :update?
@status = EditStatusService.new.call(@status,
text: status_params[:status],
media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text],
visibility: status_params[:visibility],
application: doorkeeper_token.application,
idempotency: request.headers['Idempotency-Key'])
render json: @status, serializer: REST::StatusSerializer
end
def destroy def destroy
@status = Status.where(account_id: current_user.account).find(params[:id]) @status = Status.where(account_id: current_user.account).find(params[:id])
authorize @status, :destroy? authorize @status, :destroy?
@ -82,6 +94,7 @@ class Api::V1::StatusesController < Api::BaseController
params.permit( params.permit(
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:quote_of_id,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,

View File

@ -38,7 +38,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController
statuses = group_timeline_statuses.without_replies.paginate_by_id( statuses = group_timeline_statuses.without_replies.paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT), limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id) params_slice(:max_id, :since_id, :min_id)
) ).reject { |status| FeedManager.instance.filter?(:home, status, current_account.id) }
if truthy_param?(:only_media) if truthy_param?(:only_media)
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids. # `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.

View File

@ -4,6 +4,7 @@ class HomeController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_referrer_policy_header before_action :set_referrer_policy_header
before_action :set_initial_state_json before_action :set_initial_state_json
before_action :set_data_for_meta
def index def index
@body_classes = 'app-body' @body_classes = 'app-body'
@ -11,17 +12,40 @@ class HomeController < ApplicationController
private private
def set_data_for_meta
return if find_route_matches
if params[:username].present?
@account = Account.find_local(params[:username])
elsif params[:account_username].present?
@account = Account.find_local(params[:account_username])
if params[:id].present? && !@account.nil?
@status = @account.statuses.find(params[:id])
@stream_entry = @status.stream_entry
@type = @stream_entry.activity_type.downcase
end
end
if request.path.starts_with?('/tags') && params[:tag].present?
@tag = Tag.find_normalized(params[:tag])
end
end
def authenticate_user! def authenticate_user!
return if user_signed_in? return if user_signed_in?
# if no current user, dont allow to navigate to these paths # if no current user, dont allow to navigate to these paths
matches = request.path.match(/\A\/(home|groups|tags|lists|notifications|explore|follow_requests|blocks|domain_blocks|mutes)/) if find_route_matches
if matches
redirect_to(homepage_path) redirect_to(homepage_path)
end end
end end
def find_route_matches
request.path.match(/\A\/(home|groups|lists|notifications|explore|follow_requests|blocks|domain_blocks|mutes)/)
end
def set_initial_state_json def set_initial_state_json
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@initial_state_json = serializable_resource.to_json @initial_state_json = serializable_resource.to_json

View File

@ -0,0 +1,11 @@
class Settings::ExpensesController < Admin::BaseController
def index
@ammount = Redis.current.get("monthly_funding_ammount") || 0
end
def create
Redis.current.set("monthly_funding_ammount", params[:ammount])
redirect_to settings_expenses_path
end
end

View File

@ -25,7 +25,7 @@ class Settings::NotificationsController < Settings::BaseController
def user_settings_params def user_settings_params
params.require(:user).permit( params.require(:user).permit(
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account emails_from_gabcom),
interactions: %i(must_be_follower must_be_following must_be_following_dm) interactions: %i(must_be_follower must_be_following must_be_following_dm)
) )
end end

View File

@ -9,6 +9,7 @@ class Settings::PreferencesController < Settings::BaseController
def update def update
user_settings.update(user_settings_params.to_h) user_settings.update(user_settings_params.to_h)
current_user.force_regeneration!
if current_user.update(user_params) if current_user.update(user_params)
I18n.locale = current_user.locale I18n.locale = current_user.locale
@ -51,7 +52,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_show_application, :setting_show_application,
:setting_advanced_layout, :setting_advanced_layout,
:setting_group_in_home_feed, :setting_group_in_home_feed,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account emails_from_gabcom),
interactions: %i(must_be_follower must_be_following) interactions: %i(must_be_follower must_be_following)
) )
end end

View File

@ -0,0 +1,56 @@
class Settings::PromotionsController < Admin::BaseController
before_action :set_promotion, except: [:index, :new, :create]
def index
@promotions = Promotion.all
end
def new
@promotion = Promotion.new
end
def create
@promotion = Promotion.new(resource_params)
if @promotion.save
log_action :create, @promotion
redirect_to settings_promotions_path, notice: I18n.t('promotions.created_msg')
else
render :new
end
end
def edit
end
def update
if @promotion.update(resource_params)
log_action :update, @promotion
flash[:notice] = I18n.t('promotions.updated_msg')
else
flash[:alert] = I18n.t('promotions.update_failed_msg')
end
redirect_to settings_promotions_path
end
def destroy
@promotion.destroy!
log_action :destroy, @promotion
flash[:notice] = I18n.t('promotions.destroyed_msg')
redirect_to settings_promotions_path
end
private
def set_promotion
@promotion = Promotion.find(params[:id])
end
def set_filter_params
@filter_params = filter_params.to_hash.symbolize_keys
end
def resource_params
params.require(:promotion).permit(:expires_at, :status_id, :timeline_id, :position)
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Settings::ScheduledStatusesController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_account
before_action :set_scheduled_statuses, only: :index
before_action :set_scheduled_status, only: :destroy
def index
@scheduled_statuses
end
def destroy
@scheduled_status.destroy!
redirect_to settings_scheduled_statuses_path
end
private
def set_account
@account = current_user.account
end
def set_scheduled_statuses
@scheduled_statuses = @account.scheduled_statuses
end
def set_scheduled_status
@scheduled_status = @account.scheduled_statuses.find(params[:id])
end
end

View File

@ -1,6 +1,7 @@
import api from '../api'; import api from '../api';
import { CancelToken, isCancel } from 'axios'; import { CancelToken, isCancel } from 'axios';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import moment from 'moment';
import { search as emojiSearch } from '../components/emoji/emoji_mart_search_light'; import { search as emojiSearch } from '../components/emoji/emoji_mart_search_light';
import { tagHistory } from '../settings'; import { tagHistory } from '../settings';
import { useEmoji } from './emojis'; import { useEmoji } from './emojis';
@ -20,6 +21,7 @@ export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL'; export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY'; export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL'; export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT'; export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
export const COMPOSE_MENTION = 'COMPOSE_MENTION'; export const COMPOSE_MENTION = 'COMPOSE_MENTION';
@ -60,6 +62,8 @@ export const COMPOSE_POLL_OPTION_CHANGE = 'COMPOSE_POLL_OPTION_CHANGE';
export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE'; export const COMPOSE_POLL_OPTION_REMOVE = 'COMPOSE_POLL_OPTION_REMOVE';
export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE'; export const COMPOSE_POLL_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
export const COMPOSE_SCHEDULED_AT_CHANGE = 'COMPOSE_SCHEDULED_AT_CHANGE';
const messages = defineMessages({ const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
@ -91,6 +95,17 @@ export function replyCompose(status, routerHistory) {
}; };
}; };
export function quoteCompose(status, routerHistory) {
return (dispatch, getState) => {
dispatch({
type: COMPOSE_QUOTE,
status: status,
});
dispatch(openModal('COMPOSE'));
};
};
export function cancelReplyCompose() { export function cancelReplyCompose() {
return { return {
type: COMPOSE_REPLY_CANCEL, type: COMPOSE_REPLY_CANCEL,
@ -125,6 +140,45 @@ export function directCompose(account, routerHistory) {
}; };
}; };
export function handleComposeSubmit(dispatch, getState, response, status) {
if (!dispatch || !getState) return;
const isScheduledStatus = response.data['scheduled_at'] !== undefined;
if (isScheduledStatus) {
dispatch(showAlertForError({
response: {
data: {},
status: 200,
statusText: 'Successfully scheduled status',
}
}));
dispatch(submitComposeSuccess({ ...response.data }));
return;
}
dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data }));
// To make the app more responsive, immediately push the status into the columns
const insertIfOnline = timelineId => {
const timeline = getState().getIn(['timelines', timelineId]);
if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
let dequeueArgs = {};
if (timelineId === 'community') dequeueArgs.onlyMedia = getState().getIn(['settings', 'community', 'other', 'onlyMedia']);
dispatch(dequeueTimeline(timelineId, null, dequeueArgs));
dispatch(updateTimeline(timelineId, { ...response.data }));
}
};
if (response.data.visibility !== 'direct') {
insertIfOnline('home');
} else if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
insertIfOnline('community');
insertIfOnline('public');
}
}
export function submitCompose(routerHistory, group) { export function submitCompose(routerHistory, group) {
return function (dispatch, getState) { return function (dispatch, getState) {
if (!me) return; if (!me) return;
@ -139,9 +193,20 @@ export function submitCompose(routerHistory, group) {
dispatch(submitComposeRequest()); dispatch(submitComposeRequest());
dispatch(closeModal()); dispatch(closeModal());
api(getState).post('/api/v1/statuses', { const id = getState().getIn(['compose', 'id']);
const endpoint = id === null
? '/api/v1/statuses'
: `/api/v1/statuses/${id}`;
const method = id === null ? 'post' : 'put';
let scheduled_at = getState().getIn(['compose', 'scheduled_at'], null);
if (scheduled_at !== null) scheduled_at = moment.utc(scheduled_at).toDate();
api(getState)[method](endpoint, {
status, status,
scheduled_at,
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
quote_of_id: getState().getIn(['compose', 'quote_of_id'], null),
media_ids: media.map(item => item.get('id')), media_ids: media.map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive']), sensitive: getState().getIn(['compose', 'sensitive']),
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''), spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
@ -156,33 +221,7 @@ export function submitCompose(routerHistory, group) {
if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) { if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
routerHistory.push('/messages'); routerHistory.push('/messages');
} }
handleComposeSubmit(dispatch, getState, response, status);
dispatch(insertIntoTagHistory(response.data.tags, status));
dispatch(submitComposeSuccess({ ...response.data }));
// To make the app more responsive, immediately push the status
// into the columns
const insertIfOnline = timelineId => {
const timeline = getState().getIn(['timelines', timelineId]);
if (timeline && timeline.get('items').size > 0 && timeline.getIn(['items', 0]) !== null && timeline.get('online')) {
let dequeueArgs = {};
if (timelineId === 'community') dequeueArgs.onlyMedia = getState().getIn(['settings', 'community', 'other', 'onlyMedia']),
dispatch(dequeueTimeline(timelineId, null, dequeueArgs));
dispatch(updateTimeline(timelineId, { ...response.data }));
}
};
if (response.data.visibility !== 'direct') {
insertIfOnline('home');
}
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
insertIfOnline('community');
insertIfOnline('public');
}
}).catch(function (error) { }).catch(function (error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(error));
}); });
@ -561,3 +600,10 @@ export function changePollSettings(expiresIn, isMultiple) {
isMultiple, isMultiple,
}; };
}; };
export function changeScheduledAt(date) {
return {
type: COMPOSE_SCHEDULED_AT_CHANGE,
date,
};
};

View File

@ -51,6 +51,10 @@ export const GROUP_REMOVE_STATUS_REQUEST = 'GROUP_REMOVE_STATUS_REQUEST';
export const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS'; export const GROUP_REMOVE_STATUS_SUCCESS = 'GROUP_REMOVE_STATUS_SUCCESS';
export const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL'; export const GROUP_REMOVE_STATUS_FAIL = 'GROUP_REMOVE_STATUS_FAIL';
export const GROUP_UPDATE_ROLE_REQUEST = 'GROUP_UPDATE_ROLE_REQUEST';
export const GROUP_UPDATE_ROLE_SUCCESS = 'GROUP_UPDATE_ROLE_SUCCESS';
export const GROUP_UPDATE_ROLE_FAIL = 'GROUP_UPDATE_ROLE_FAIL';
export const fetchGroup = id => (dispatch, getState) => { export const fetchGroup = id => (dispatch, getState) => {
if (!me) return; if (!me) return;
@ -522,3 +526,42 @@ export function groupRemoveStatusFail(groupId, id, error) {
error, error,
}; };
}; };
export function updateRole(groupId, id, role) {
return (dispatch, getState) => {
if (!me) return;
dispatch(updateRoleRequest(groupId, id));
api(getState).patch(`/api/v1/groups/${groupId}/accounts?account_id=${id}`, { role }).then(response => {
dispatch(updateRoleSuccess(groupId, id));
}).catch(error => {
dispatch(updateRoleFail(groupId, id, error));
});
};
};
export function updateRoleRequest(groupId, id) {
return {
type: GROUP_REMOVED_ACCOUNTS_CREATE_REQUEST,
groupId,
id,
};
};
export function updateRoleSuccess(groupId, id) {
return {
type: GROUP_REMOVED_ACCOUNTS_CREATE_SUCCESS,
groupId,
id,
};
};
export function updateRoleFail(groupId, id, error) {
return {
type: GROUP_REMOVED_ACCOUNTS_CREATE_FAIL,
groupId,
id,
error,
};
};

View File

@ -71,6 +71,10 @@ export function importFetchedStatuses(statuses) {
processStatus(status.reblog); processStatus(status.reblog);
} }
if (status.quote && status.quote.id) {
processStatus(status.quote);
}
if (status.poll && status.poll.id) { if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll)); pushUnique(polls, normalizePoll(status.poll));
} }

View File

@ -43,13 +43,17 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.reblog = status.reblog.id; normalStatus.reblog = status.reblog.id;
} }
if (status.quote && status.quote.id) {
normalStatus.quote = status.quote.id;
}
if (status.poll && status.poll.id) { if (status.poll && status.poll.id) {
normalStatus.poll = status.poll.id; normalStatus.poll = status.poll.id;
} }
// Only calculate these values when status first encountered // Only calculate these values when status first encountered
// Otherwise keep the ones already in the reducer // Otherwise keep the ones already in the reducer
if (normalOldStatus) { if (normalOldStatus && normalOldStatus.get('content') === normalStatus.content && normalOldStatus.get('spoiler_text') === normalStatus.spoiler_text) {
normalStatus.search_index = normalOldStatus.get('search_index'); normalStatus.search_index = normalOldStatus.get('search_index');
normalStatus.contentHtml = normalOldStatus.get('contentHtml'); normalStatus.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');

View File

@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html';
import { getFilters, regexFromFilters } from '../selectors'; import { getFilters, regexFromFilters } from '../selectors';
import { me } from 'gabsocial/initial_state'; import { me } from 'gabsocial/initial_state';
export const NOTIFICATIONS_INITIALIZE = 'NOTIFICATIONS_INITIALIZE';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
export const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE'; export const NOTIFICATIONS_UPDATE_QUEUE = 'NOTIFICATIONS_UPDATE_QUEUE';
@ -27,6 +28,7 @@ export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_MARK_READ = 'NOTIFICATIONS_MARK_READ';
export const MAX_QUEUED_NOTIFICATIONS = 40; export const MAX_QUEUED_NOTIFICATIONS = 40;
@ -43,6 +45,12 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
} }
}; };
export function initializeNotifications() {
return {
type: NOTIFICATIONS_INITIALIZE,
};
}
export function updateNotifications(notification, intlMessages, intlLocale) { export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => { return (dispatch, getState) => {
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
@ -134,6 +142,7 @@ export function dequeueNotifications() {
dispatch({ dispatch({
type: NOTIFICATIONS_DEQUEUE, type: NOTIFICATIONS_DEQUEUE,
}); });
dispatch(markReadNotifications());
} }
}; };
@ -225,10 +234,13 @@ export function clearNotifications() {
}; };
export function scrollTopNotifications(top) { export function scrollTopNotifications(top) {
return { return (dispatch, getState) => {
type: NOTIFICATIONS_SCROLL_TOP, dispatch({
top, type: NOTIFICATIONS_SCROLL_TOP,
}; top,
});
dispatch(markReadNotifications());
}
}; };
export function setFilter (filterType) { export function setFilter (filterType) {
@ -242,3 +254,20 @@ export function setFilter (filterType) {
dispatch(saveSettings()); dispatch(saveSettings());
}; };
}; };
export function markReadNotifications() {
return (dispatch, getState) => {
if (!me) return;
const top_notification = parseInt(getState().getIn(['notifications', 'items', 0, 'id']));
const last_read = getState().getIn(['notifications', 'lastRead']);
if (top_notification && top_notification > last_read) {
api(getState).post('/api/v1/notifications/mark_read', {id: top_notification}).then(response => {
dispatch({
type: NOTIFICATIONS_MARK_READ,
notification: top_notification,
});
});
}
}
};

View File

@ -37,7 +37,6 @@ export function submitSearch() {
params: { params: {
q: value, q: value,
resolve: true, resolve: true,
limit: 5,
}, },
}).then(response => { }).then(response => {
if (response.data.accounts) { if (response.data.accounts) {

View File

@ -0,0 +1,14 @@
export const SIDEBAR_OPEN = 'SIDEBAR_OPEN';
export const SIDEBAR_CLOSE = 'SIDEBAR_CLOSE';
export function openSidebar() {
return {
type: SIDEBAR_OPEN,
};
};
export function closeSidebar() {
return {
type: SIDEBAR_CLOSE,
};
};

View File

@ -0,0 +1,16 @@
import api from '../api';
export const STATUS_REVISION_LIST_LOAD = 'STATUS_REVISION_LIST';
export const STATUS_REVISION_LIST_LOAD_SUCCESS = 'STATUS_REVISION_LIST_SUCCESS';
export const STATUS_REVISION_LIST_LOAD_FAIL = 'STATUS_REVISION_LIST_FAIL';
const loadSuccess = data => ({ type: STATUS_REVISION_LIST_LOAD_SUCCESS, payload: data });
const loadFail = e => ({ type: STATUS_REVISION_LIST_LOAD_FAIL, payload: e });
export function load(statusId) {
return (dispatch, getState) => {
api(getState).get(`/api/v1/statuses/${statusId}/revisions`)
.then(res => dispatch(loadSuccess(res.data)))
.catch(e => dispatch(loadFail(e)));
};
}

View File

@ -3,7 +3,7 @@ import openDB from '../storage/db';
import { evictStatus } from '../storage/modifier'; import { evictStatus } from '../storage/modifier';
import { deleteFromTimelines } from './timelines'; import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer'; import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
import { ensureComposeIsVisible } from './compose'; import { openModal } from './modal';
import { me } from 'gabsocial/initial_state'; import { me } from 'gabsocial/initial_state';
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST'; export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
@ -29,7 +29,7 @@ export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL';
export const STATUS_REVEAL = 'STATUS_REVEAL'; export const STATUS_REVEAL = 'STATUS_REVEAL';
export const STATUS_HIDE = 'STATUS_HIDE'; export const STATUS_HIDE = 'STATUS_HIDE';
export const REDRAFT = 'REDRAFT'; export const STATUS_EDIT = 'STATUS_EDIT';
export function fetchStatusRequest(id, skipLoading) { export function fetchStatusRequest(id, skipLoading) {
return { return {
@ -132,15 +132,18 @@ export function fetchStatusFail(id, error, skipLoading) {
}; };
}; };
export function redraft(status, raw_text) { export function editStatus(status) {
return { return dispatch => {
type: REDRAFT, dispatch({
status, type: STATUS_EDIT,
raw_text, status,
});
dispatch(openModal('COMPOSE'));
}; };
}; };
export function deleteStatus(id, routerHistory, withRedraft = false) { export function deleteStatus(id, routerHistory) {
return (dispatch, getState) => { return (dispatch, getState) => {
if (!me) return; if (!me) return;
@ -156,11 +159,6 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
evictStatus(id); evictStatus(id);
dispatch(deleteStatusSuccess(id)); dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id)); dispatch(deleteFromTimelines(id));
if (withRedraft) {
dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => { }).catch(error => {
dispatch(deleteStatusFail(id, error)); dispatch(deleteStatusFail(id, error));
}); });

View File

@ -10,6 +10,7 @@ import { updateNotificationsQueue, expandNotifications } from './notifications';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { fetchFilters } from './filters'; import { fetchFilters } from './filters';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import { handleComposeSubmit } from './compose';
const { messages } = getLocale(); const { messages } = getLocale();
@ -61,3 +62,18 @@ export const connectHashtagStream = (id, tag, accept) => connectTimelineStream
export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`); export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);
export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`); export const connectGroupStream = id => connectTimelineStream(`group:${id}`, `group&group=${id}`);
export const connectStatusUpdateStream = () => {
return connectStream('statuscard', null, (dispatch, getState) => {
return {
onConnect() {},
onDisconnect() {},
onReceive (data) {
if (!data['event'] || !data['payload']) return;
if (data.event === 'update') {
handleComposeSubmit(dispatch, getState, {data: JSON.parse(data.payload)}, null)
}
},
};
});
}

View File

@ -43,9 +43,9 @@ export default class ExtendedVideoPlayer extends PureComponent {
return ( return (
<div className='extended-video-player'> <div className='extended-video-player'>
<video <video
playsInline
ref={this.setRef} ref={this.setRef}
src={src} src={src}
autoPlay
role='button' role='button'
tabIndex='0' tabIndex='0'
aria-label={alt} aria-label={alt}

View File

@ -0,0 +1,40 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { shortNumberFormat } from '../utils/numbers';
const messages = defineMessages({
members: { id: 'groups.card.members', defaultMessage: 'Members' },
});
export default
@injectIntl
class GroupListItem extends ImmutablePureComponent {
static propTypes = {
group: ImmutablePropTypes.map.isRequired,
}
render() {
const { intl, group } = this.props;
if (!group) return null;
return (
<div className='trends__item'>
<div className='trends__item__name'>
<Link to={`/groups/${group.get('id')}`}>
<strong>{group.get('title')}</strong>
<br />
<span>
{shortNumberFormat(group.get('member_count'))}
&nbsp;
{intl.formatMessage(messages.members)}
</span>
</Link>
</div>
</div>
);
}
}

File diff suppressed because one or more lines are too long

View File

@ -7,9 +7,11 @@ import { decode } from 'blurhash';
import IconButton from '../icon_button'; import IconButton from '../icon_button';
import { isIOS } from '../../utils/is_mobile'; import { isIOS } from '../../utils/is_mobile';
import { autoPlayGif, displayMedia } from '../../initial_state'; import { autoPlayGif, displayMedia } from '../../initial_state';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
import './media_gallery.scss'; import './media_gallery.scss';
const messages = defineMessages({ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
warning: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' }, warning: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' },
@ -26,6 +28,7 @@ class Item extends ImmutablePureComponent {
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number, displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired, visible: PropTypes.bool.isRequired,
dimensions: PropTypes.object,
}; };
static defaultProps = { static defaultProps = {
@ -51,9 +54,15 @@ class Item extends ImmutablePureComponent {
} }
} }
handleLoadedMetaData = (e) => {
if (!this.hoverToPlay()) {
e.target.play();
}
}
hoverToPlay () { hoverToPlay () {
const { attachment } = this.props; const { attachment } = this.props;
return !autoPlayGif && attachment.get('type') === 'gifv'; return autoPlayGif === false && attachment.get('type') === 'gifv';
} }
handleClick = (e) => { handleClick = (e) => {
@ -105,43 +114,36 @@ class Item extends ImmutablePureComponent {
} }
render () { render () {
const { attachment, index, size, standalone, displayWidth, visible } = this.props; const { attachment, index, size, standalone, displayWidth, visible, dimensions } = this.props;
const width = (size === 1) ? 100 : 50; const ar = attachment.getIn(['meta', 'small', 'aspect']);
const height = (size === 4 || (size === 3 && index > 0)) ? 50 : 100;
let top = 'auto'; let width = 100;
let left = 'auto'; let height = '100%';
let top = 'auto';
let left = 'auto';
let bottom = 'auto'; let bottom = 'auto';
let right = 'auto'; let right = 'auto';
let float = 'left';
let position = 'relative';
switch(size) { if (dimensions) {
case 2: width = dimensions.w;
if (index === 0) right = '2px'; height = dimensions.h;
else left = '2px'; top = dimensions.t || 'auto';
break; right = dimensions.r || 'auto';
case 3: bottom = dimensions.b || 'auto';
if (index === 0) right = '2px'; left = dimensions.l || 'auto';
else if (index > 0) left = '2px'; float = dimensions.float || 'left';
position = dimensions.pos || 'relative';
if (index === 1) bottom = '2px';
else if (index > 1) top = '2px';
break;
case 4:
if (index === 0 || index === 2) right = '2px';
if (index === 1 || index === 3) left = '2px';
if (index < 2) bottom = '2px';
else top = '2px';
break;
} }
let thumbnail = ''; let thumbnail = '';
if (attachment.get('type') === 'unknown') { if (attachment.get('type') === 'unknown') {
return ( return (
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<a className='media-item__thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}> <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' /> <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
</a> </a>
</div> </div>
@ -182,7 +184,7 @@ class Item extends ImmutablePureComponent {
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif; const autoPlay = !isIOS() && autoPlayGif !== false;
thumbnail = ( thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@ -195,9 +197,12 @@ class Item extends ImmutablePureComponent {
onClick={this.handleClick} onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
onLoadedMetadata={this.handleLoadedMetaData}
autoPlay={autoPlay} autoPlay={autoPlay}
type='video/mp4'
loop loop
muted muted
playsInline
/> />
<span className='media-gallery__gifv__label'>GIF</span> <span className='media-gallery__gifv__label'>GIF</span>
@ -206,7 +211,7 @@ class Item extends ImmutablePureComponent {
} }
return ( return (
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} /> <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
{visible && thumbnail} {visible && thumbnail}
</div> </div>
@ -262,7 +267,7 @@ class MediaGallery extends PureComponent {
} }
handleRef = (node) => { handleRef = (node) => {
if (node /*&& this.isStandaloneEligible()*/) { if (node) {
// offsetWidth triggers a layout, so only calculate when we need to // offsetWidth triggers a layout, so only calculate when we need to
if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth); if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
@ -272,11 +277,6 @@ class MediaGallery extends PureComponent {
} }
} }
isStandaloneEligible() {
const { media, standalone } = this.props;
return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
}
render () { render () {
const { media, intl, sensitive, height, defaultWidth } = this.props; const { media, intl, sensitive, height, defaultWidth } = this.props;
const { visible } = this.state; const { visible } = this.state;
@ -286,24 +286,221 @@ class MediaGallery extends PureComponent {
let children, spoilerButton; let children, spoilerButton;
const style = {}; const style = {};
const size = media.take(4).size;
if (this.isStandaloneEligible()) { const standard169 = width / (16 / 9);
if (width) { const standard169_percent = 100 / (16 / 9);
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']); const standard169_px = `${standard169}px`;
const panoSize = Math.floor(width / maximumAspectRatio);
const panoSize_px = `${Math.floor(width / maximumAspectRatio)}px`;
let itemsDimensions = [];
if (size == 1 && width) {
const aspectRatio = media.getIn([0, 'meta', 'small', 'aspect']);
if (isPanoramic(aspectRatio)) {
style.height = Math.floor(width / maximumAspectRatio);
} else if (isPortrait(aspectRatio)) {
style.height = Math.floor(width / minimumAspectRatio);
} else {
style.height = Math.floor(width / aspectRatio);
}
} else if (size > 1 && width) {
const ar1 = media.getIn([0, 'meta', 'small', 'aspect']);
const ar2 = media.getIn([1, 'meta', 'small', 'aspect']);
const ar3 = media.getIn([2, 'meta', 'small', 'aspect']);
const ar4 = media.getIn([3, 'meta', 'small', 'aspect']);
if (size == 2) {
if (isPortrait(ar1) && isPortrait(ar2)) {
style.height = width - (width / maximumAspectRatio);
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
style.height = panoSize * 2;
} else if (
(isPanoramic(ar1) && isPortrait(ar2)) ||
(isPortrait(ar1) && isPanoramic(ar2)) ||
(isPanoramic(ar1) && isNonConformingRatio(ar2)) ||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
) {
style.height = (width * 0.6) + (width / maximumAspectRatio);
} else {
style.height = width / 2;
}
//
if (isPortrait(ar1) && isPortrait(ar2)) {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '100%', l: '2px' }
];
} else if (isPanoramic(ar1) && isPanoramic(ar2)) {
itemsDimensions = [
{ w: 100, h: panoSize_px, b: '2px' },
{ w: 100, h: panoSize_px, t: '2px' }
];
} else if (
(isPanoramic(ar1) && isPortrait(ar2)) ||
(isPanoramic(ar1) && isNonConformingRatio(ar2))
) {
itemsDimensions = [
{ w: 100, h: `${(width / maximumAspectRatio)}px`, b: '2px' },
{ w: 100, h: `${(width * 0.6)}px`, t: '2px' },
];
} else if (
(isPortrait(ar1) && isPanoramic(ar2)) ||
(isNonConformingRatio(ar1) && isPanoramic(ar2))
) {
itemsDimensions = [
{ w: 100, h: `${(width * 0.6)}px`, b: '2px' },
{ w: 100, h: `${(width / maximumAspectRatio)}px`, t: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '100%', r: '2px' },
{ w: 50, h: '100%', l: '2px' }
];
}
} else if (size == 3) {
if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
style.height = panoSize * 3;
} else if (isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3)) {
style.height = Math.floor(width / minimumAspectRatio);
} else {
style.height = width;
}
//
if (isPanoramic(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
itemsDimensions = [
{ w: 100, h: `50%`, b: '2px' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' }
];
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) {
itemsDimensions = [
{ w: 100, h: panoSize_px, b: '4px' },
{ w: 100, h: panoSize_px },
{ w: 100, h: panoSize_px, t: '4px' }
];
} else if (isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3)) {
itemsDimensions = [
{ w: 50, h: `100%`, r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3)) {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', l: '-2px', b: '-2px', pos: 'absolute', float: 'none' },
{ w: 50, h: `100%`, r: '-2px', t: '0px', b: '0px', pos: 'absolute', float: 'none' }
];
} else if (
(isNonConformingRatio(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3)) ||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3))
) {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: `100%`, l: '2px', float: 'right' },
{ w: 50, h: '50%', t: '2px', r: '2px' }
];
} else if (
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3)) ||
(isPanoramic(ar1) && isPanoramic(ar2) && isPortrait(ar3))
) {
itemsDimensions = [
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
{ w: 100, h: `${width - panoSize}px`, t: '2px' }
];
} else if (
(isNonConformingRatio(ar1) && isPanoramic(ar2) && isPanoramic(ar3)) ||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3))
) {
itemsDimensions = [
{ w: 100, h: `${width - panoSize}px`, b: '2px' },
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
];
} else {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 100, h: `50%`, t: '2px' }
];
}
} else if (size == 4) {
if (
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
(isPortrait(ar1) && isPortrait(ar2) && isPortrait(ar3) && isNonConformingRatio(ar4)) ||
(isPortrait(ar1) && isPortrait(ar2) && isNonConformingRatio(ar3) && isPortrait(ar4)) ||
(isPortrait(ar1) && isNonConformingRatio(ar2) && isPortrait(ar3) && isPortrait(ar4)) ||
(isNonConformingRatio(ar1) && isPortrait(ar2) && isPortrait(ar3) && isPortrait(ar4))
) {
style.height = Math.floor(width / minimumAspectRatio);
} else if (isPanoramic(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
style.height = panoSize * 2;
} else if (
(isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
(isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
) {
style.height = panoSize + (width / 2);
} else {
style.height = width;
}
//
if (isPanoramic(ar1) && isPanoramic(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) {
itemsDimensions = [
{ w: 50, h: panoSize_px, b: '2px', r: '2px' },
{ w: 50, h: panoSize_px, b: '2px', l: '2px' },
{ w: 50, h: `${(width / 2)}px`, t: '2px', r: '2px' },
{ w: 50, h: `${(width / 2)}px`, t: '2px', l: '2px' },
];
} else if (isNonConformingRatio(ar1) && isNonConformingRatio(ar2) && isPanoramic(ar3) && isPanoramic(ar4)) {
itemsDimensions = [
{ w: 50, h: `${(width / 2)}px`, b: '2px', r: '2px' },
{ w: 50, h: `${(width / 2)}px`, b: '2px', l: '2px' },
{ w: 50, h: panoSize_px, t: '2px', r: '2px' },
{ w: 50, h: panoSize_px, t: '2px', l: '2px' },
];
} else if (
(isPortrait(ar1) && isNonConformingRatio(ar2) && isNonConformingRatio(ar3) && isNonConformingRatio(ar4)) ||
(isPortrait(ar1) && isPanoramic(ar2) && isPanoramic(ar3) && isPanoramic(ar4))
) {
itemsDimensions = [
{ w: 67, h: '100%', r: '2px' },
{ w: 33, h: '33%', b: '4px', l: '2px' },
{ w: 33, h: '33%', l: '2px' },
{ w: 33, h: '33%', t: '4px', l: '2px' }
];
} else {
itemsDimensions = [
{ w: 50, h: '50%', b: '2px', r: '2px' },
{ w: 50, h: '50%', b: '2px', l: '2px' },
{ w: 50, h: '50%', t: '2px', r: '2px' },
{ w: 50, h: '50%', t: '2px', l: '2px' },
];
}
} }
} else if (width) {
style.height = width / (16/9);
} else { } else {
style.height = height; style.height = height;
} }
const size = media.take(4).size; children = media.take(4).map((attachment, i) => (
<Item
if (this.isStandaloneEligible()) { key={attachment.get('id')}
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; onClick={this.handleClick}
} else { attachment={attachment}
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />); index={i}
} size={size}
displayWidth={width}
visible={visible}
dimensions={itemsDimensions[i]}
/>
));
if (visible) { if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />; spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;

View File

@ -20,11 +20,18 @@ export default class ActionsModal extends ImmutablePureComponent {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />; return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
} }
const { icon = null, text, meta = null, active = false, href = '#' } = action; const { icon = null, text, meta = null, active = false, href = '#', isLogout } = action;
return ( return (
<li key={`${text}-${i}`}> <li key={`${text}-${i}`}>
<a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}> <a
href={href}
rel='noopener'
onClick={this.props.onClick}
data-index={i}
className={classNames({ active })}
data-method={isLogout ? 'delete' : null}
>
{icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />} {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' inverted />}
<div> <div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div> <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>

View File

@ -10,6 +10,7 @@ const messages = defineMessages({
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
composeId: state.getIn(['compose', 'id']),
composeText: state.getIn(['compose', 'text']), composeText: state.getIn(['compose', 'text']),
}); });
@ -32,6 +33,7 @@ class ModalBase extends PureComponent {
onOpenModal: PropTypes.func.isRequired, onOpenModal: PropTypes.func.isRequired,
onCancelReplyCompose: PropTypes.func.isRequired, onCancelReplyCompose: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
composeId: PropTypes.string,
composeText: PropTypes.string, composeText: PropTypes.string,
type: PropTypes.string, type: PropTypes.string,
}; };
@ -50,9 +52,9 @@ class ModalBase extends PureComponent {
} }
handleOnClose = () => { handleOnClose = () => {
const { onOpenModal, composeText, onClose, intl, type, onCancelReplyCompose } = this.props; const { onOpenModal, composeText, composeId, onClose, intl, type, onCancelReplyCompose } = this.props;
if (composeText && type === 'COMPOSE') { if (!composeId && composeText && type == 'COMPOSE') {
onOpenModal('CONFIRM', { onOpenModal('CONFIRM', {
message: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this status?' />, message: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this status?' />,
confirm: intl.formatMessage(messages.confirm), confirm: intl.formatMessage(messages.confirm),

View File

@ -21,6 +21,7 @@ import {
EmbedModal, EmbedModal,
ListEditor, ListEditor,
ListAdder, ListAdder,
StatusRevisionModal,
} from '../../features/ui/util/async-components'; } from '../../features/ui/util/async-components';
const MODAL_COMPONENTS = { const MODAL_COMPONENTS = {
@ -34,10 +35,12 @@ const MODAL_COMPONENTS = {
'EMBED': EmbedModal, 'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor, 'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER':ListAdder, 'LIST_ADDER': ListAdder,
'HOTKEYS': () => Promise.resolve({ default: HotkeysModal }), 'HOTKEYS': () => Promise.resolve({ default: HotkeysModal }),
'STATUS_REVISION': StatusRevisionModal,
'COMPOSE': () => Promise.resolve({ default: ComposeModal }), 'COMPOSE': () => Promise.resolve({ default: ComposeModal }),
'UNAUTHORIZED': () => Promise.resolve({ default: UnauthorizedModal }), 'UNAUTHORIZED': () => Promise.resolve({ default: UnauthorizedModal }),
'PRO_UPGRADE': () => Promise.resolve({ default: ProUpgradeModal }),
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View File

@ -0,0 +1,227 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import Avatar from './avatar';
import IconButton from './icon_button';
import Icon from './icon';
import DisplayName from './display_name';
import { closeSidebar } from '../actions/sidebar';
import { shortNumberFormat } from '../utils/numbers';
import { me } from '../initial_state';
import { makeGetAccount } from '../selectors';
import ProgressPanel from '../features/ui/components/progress_panel';
const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' },
follows: { id: 'account.follows', defaultMessage: 'Follows' },
profile: { id: 'account.profile', defaultMessage: 'Profile' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
lists: { id: 'column.lists', defaultMessage: 'Lists', },
apps: { id: 'tabs_bar.apps', defaultMessage: 'Apps' },
more: { id: 'sidebar.more', defaultMessage: 'More' },
pro: { id: 'promo.gab_pro', defaultMessage: 'Upgrade to GabPRO' },
trends: { id: 'promo.trends', defaultMessage: 'Trends' },
search: { id: 'tabs_bar.search', defaultMessage: 'Search' },
shop: { id: 'tabs_bar.shop', defaultMessage: 'Store - Buy Merch' },
donate: { id: 'tabs_bar.donate', defaultMessage: 'Make a Donation' },
})
const mapStateToProps = state => {
const getAccount = makeGetAccount();
return {
account: getAccount(state, me),
sidebarOpen: state.get('sidebar').sidebarOpen,
};
};
const mapDispatchToProps = (dispatch) => ({
onClose () {
dispatch(closeSidebar());
},
});
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class SidebarMenu extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
account: ImmutablePropTypes.map,
sidebarOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
};
state = {
moreOpen: false,
}
componentDidUpdate () {
if (!me) return;
if (this.props.sidebarOpen) {
document.body.classList.add('with-modals--active');
} else {
document.body.classList.remove('with-modals--active');
}
}
toggleMore = () => {
this.setState({
moreOpen: !this.state.moreOpen
});
}
handleSidebarClose = () => {
this.props.onClose();
this.setState({
moreOpen: false,
});
}
render () {
const { sidebarOpen, intl, account } = this.props;
const { moreOpen } = this.state;
if (!me || !account) return null;
const acct = account.get('acct');
const isPro = account.get('is_pro');
const classes = classNames('sidebar-menu__root', {
'sidebar-menu__root--visible': sidebarOpen,
});
const moreIcon = moreOpen ? 'minus' : 'plus';
const moreContainerStyle = { display: moreOpen ? 'block' : 'none' };
return (
<div className={classes}>
<div className='sidebar-menu__wrapper' role='button' onClick={this.handleSidebarClose} />
<div className='sidebar-menu'>
<div className='sidebar-menu-header'>
<span className='sidebar-menu-header__title'>Account Info</span>
<IconButton title='close' onClick={this.handleSidebarClose} icon='close' className='sidebar-menu-header__btn' />
</div>
<div className='sidebar-menu__content'>
<div className='sidebar-menu-profile'>
<div className='sidebar-menu-profile__avatar'>
<Link to={`/${acct}`} title={acct} onClick={this.handleSidebarClose}>
<Avatar account={account} />
</Link>
</div>
<div className='sidebar-menu-profile__name'>
<DisplayName account={account}/>
</div>
<div className='sidebar-menu-profile__stats'>
<NavLink className='sidebar-menu-profile-stat' to={`/${acct}/followers`} onClick={this.handleSidebarClose} title={intl.formatNumber(account.get('followers_count'))}>
<strong className='sidebar-menu-profile-stat__value'>{shortNumberFormat(account.get('followers_count'))}</strong>
<span className='sidebar-menu-profile-stat__label'>{intl.formatMessage(messages.followers)}</span>
</NavLink>
<NavLink className='sidebar-menu-profile-stat' to={`/${acct}/following`} onClick={this.handleSidebarClose} title={intl.formatNumber(account.get('following_count'))}>
<strong className='sidebar-menu-profile-stat__value'>{shortNumberFormat(account.get('following_count'))}</strong>
<span className='sidebar-menu-profile-stat__label'>{intl.formatMessage(messages.follows)}</span>
</NavLink>
</div>
</div>
<div className='sidebar-menu__section'>
<ProgressPanel />
</div>
<div className='sidebar-menu__section sidebar-menu__section--borderless'>
<NavLink className='sidebar-menu-item' to={`/${acct}`} onClick={this.handleSidebarClose}>
<Icon id='user' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.profile)}</span>
</NavLink>
{
!isPro &&
<a className='sidebar-menu-item' href='https://pro.gab.com'>
<Icon id='arrow-up' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.pro)}</span>
</a>
}
<a className='sidebar-menu-item' href='https://shop.dissenter.com/category/donations'>
<Icon id='heart' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.donate)}</span>
</a>
<a className='sidebar-menu-item' href='https://shop.dissenter.com'>
<Icon id='shopping-cart' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.shop)}</span>
</a>
<a className='sidebar-menu-item' href='https://trends.gab.com'>
<Icon id='signal' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.trends)}</span>
</a>
<NavLink className='sidebar-menu-item' to='/search' onClick={this.handleSidebarClose}>
<Icon id='search' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.search)}</span>
</NavLink>
<a className='sidebar-menu-item' href='/settings/preferences'>
<Icon id='cog' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.preferences)}</span>
</a>
</div>
<div className='sidebar-menu__section'>
<div className='sidebar-menu-item' onClick={this.toggleMore} role='button'>
<Icon id={moreIcon} fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.more)}</span>
</div>
<div style={moreContainerStyle}>
<NavLink className='sidebar-menu-item' to='/lists' onClick={this.handleSidebarClose}>
<Icon id='list' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.lists)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/follow_requests' onClick={this.handleSidebarClose}>
<Icon id='user-plus' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.follow_requests)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/blocks' onClick={this.handleSidebarClose}>
<Icon id='ban' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.blocks)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/domain_blocks' onClick={this.handleSidebarClose}>
<Icon id='sitemap' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.domain_blocks)}</span>
</NavLink>
<NavLink className='sidebar-menu-item' to='/mutes' onClick={this.handleSidebarClose}>
<Icon id='times-circle' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span>
</NavLink>
<a className='sidebar-menu-item' href='/filters'>
<Icon id='filter' fixedWidth />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.filters)}</span>
</a>
</div>
</div>
<div className='sidebar-menu__section'>
<a className='sidebar-menu-item' href='/auth/sign_out' data-method='delete'>
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.logout)}</span>
</a>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -1,4 +1,18 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import Avatar from './avatar';
import AvatarOverlay from './avatar_overlay';
import AvatarComposite from './avatar_composite';
import RelativeTimestamp from './relative_timestamp';
import DisplayName from './display_name';
import StatusContent from './status_content';
import StatusQuote from './status_quote';
import StatusActionBar from './status_action_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
@ -63,9 +77,12 @@ class Status extends ImmutablePureComponent {
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
onClick: PropTypes.func, onClick: PropTypes.func,
onReply: PropTypes.func, onReply: PropTypes.func,
onShowRevisions: PropTypes.func,
onQuote: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
onEdit: PropTypes.func,
onDirect: PropTypes.func, onDirect: PropTypes.func,
onMention: PropTypes.func, onMention: PropTypes.func,
onPin: PropTypes.func, onPin: PropTypes.func,
@ -86,6 +103,8 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
group: ImmutablePropTypes.map, group: ImmutablePropTypes.map,
promoted: PropTypes.bool,
onOpenProUpgradeModal: PropTypes.func,
}; };
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -248,11 +267,16 @@ class Status extends ImmutablePureComponent {
this.node = c; this.node = c;
}; };
render() {
handleOpenProUpgradeModal = () => {
this.props.onOpenProUpgradeModal();
}
render () {
let media = null; let media = null;
let statusAvatar, prepend, rebloggedByText, reblogContent; let statusAvatar, prepend, rebloggedByText, reblogContent;
const { intl, hidden, featured, unread, showThread, group } = this.props; const { intl, hidden, featured, otherAccounts, unread, showThread, group, promoted } = this.props;
let { status, account, ...other } = this.props; let { status, account, ...other } = this.props;
@ -284,7 +308,14 @@ class Status extends ImmutablePureComponent {
); );
} }
if (featured) { if (promoted) {
prepend = (
<button className='status__prepend status__prepend--promoted' onClick={this.handleOpenProUpgradeModal}>
<div className='status__prepend-icon-wrapper'><Icon id='star' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.promoted' defaultMessage='Promoted gab' />
</button>
);
} else if (featured) {
prepend = ( prepend = (
<div className='status__prepend'> <div className='status__prepend'>
<div className='status__prepend-icon-wrapper'> <div className='status__prepend-icon-wrapper'>
@ -341,6 +372,7 @@ class Status extends ImmutablePureComponent {
blurhash={video.get('blurhash')} blurhash={video.get('blurhash')}
src={video.get('url')} src={video.get('url')}
alt={video.get('description')} alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
width={this.props.cachedMediaWidth} width={this.props.cachedMediaWidth}
height={110} height={110}
inline inline
@ -376,7 +408,6 @@ class Status extends ImmutablePureComponent {
<Card <Card
onOpenMedia={this.props.onOpenMedia} onOpenMedia={this.props.onOpenMedia}
card={status.get('card')} card={status.get('card')}
compact
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth} defaultWidth={this.props.cachedMediaWidth}
/> />
@ -446,10 +477,10 @@ class Status extends ImmutablePureComponent {
</NavLink> </NavLink>
</div> </div>
{!group && status.get('group') && ( {((!group && status.get('group')) || status.get('revised_at') !== null) && (
<div className='status__meta'> <div className='status__meta'>
Posted in{' '} {!group && status.get('group') && <React.Fragment>Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink></React.Fragment>}
<NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink> {status.get('revised_at') !== null && <a onClick={() => other.onShowRevisions(status)}> Edited</a>}
</div> </div>
)} )}
@ -464,9 +495,11 @@ class Status extends ImmutablePureComponent {
{media} {media}
{showThread && {status.get('quote') && <StatusQuote
status.get('in_reply_to_id') && id={status.get('quote')}
status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && ( />}
{showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
<button className='status__content__read-more-button' onClick={this.handleClick}> <button className='status__content__read-more-button' onClick={this.handleClick}>
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' /> <FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
</button> </button>

View File

@ -12,18 +12,20 @@ import './status_action_bar.scss';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
open: { id: 'status.open', defaultMessage: 'Expand this status' }, open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' },
@ -57,6 +59,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
onOpenUnauthorizedModal: PropTypes.func.isRequired, onOpenUnauthorizedModal: PropTypes.func.isRequired,
onReply: PropTypes.func, onReply: PropTypes.func,
onQuote: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
onReblog: PropTypes.func, onReblog: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
@ -87,13 +90,12 @@ class StatusActionBar extends ImmutablePureComponent {
} }
} }
handleShareClick = () => { handleQuoteClick = () => {
navigator.share({ if (me) {
text: this.props.status.get('search_index'), this.props.onQuote(this.props.status, this.context.router.history);
url: this.props.status.get('url'), } else {
}).catch((e) => { this.props.onOpenUnauthorizedModal();
if (e.name !== 'AbortError') console.error(e); }
});
} }
handleFavouriteClick = () => { handleFavouriteClick = () => {
@ -116,8 +118,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onDelete(this.props.status, this.context.router.history); this.props.onDelete(this.props.status, this.context.router.history);
} }
handleRedraftClick = () => { handleEditClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true); this.props.onEdit(this.props.status);
} }
handlePinClick = () => { handlePinClick = () => {
@ -215,9 +217,8 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick }); menu.push({ text: formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
} }
} }
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
menu.push({ text: formatMessage(messages.redraft), action: this.handleRedraftClick });
} else { } else {
menu.push({ text: formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null); menu.push(null);
@ -290,19 +291,13 @@ class StatusActionBar extends ImmutablePureComponent {
{reblogCount !== 0 && <Link to={`/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/reblogs`} className='status-action-bar-item__link'>{reblogCount}</Link>} {reblogCount !== 0 && <Link to={`/${status.getIn(['account', 'acct'])}/posts/${status.get('id')}/reblogs`} className='status-action-bar-item__link'>{reblogCount}</Link>}
</div> </div>
<div className='status-action-bar-item'> <div className='status__action-bar__counter'>
<IconButton <IconButton className='status__action-bar-button' disabled={!publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-left' onClick={this.handleQuoteClick} />
className='status-action-bar-item__btn star-icon' </div>
active={status.get('favourited')} <div className='status__action-bar__counter'>
pressed={status.get('favourited')} <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
title={formatMessage(messages.favourite)} {favoriteCount !== 0 && <span className='detailed-status__link'>{favoriteCount}</span>}
icon='star'
onClick={this.handleFavouriteClick}
/>
{favoriteCount !== 0 && <span className='status-action-bar-item__link'>{favoriteCount}</span>}
</div> </div>
{shareButton}
<div className='status-action-bar__dropdown'> <div className='status-action-bar__dropdown'>
<DropdownMenuContainer <DropdownMenuContainer

View File

@ -53,6 +53,7 @@ class StatusCheckBox extends ImmutablePureComponent {
blurhash={video.get('blurhash')} blurhash={video.get('blurhash')}
src={video.get('url')} src={video.get('url')}
alt={video.get('description')} alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
width={239} width={239}
height={110} height={110}
inline inline

View File

@ -8,7 +8,7 @@ import Icon from '../icon';
import './status_content.scss'; import './status_content.scss';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) const MAX_HEIGHT = 200;
export default class StatusContent extends ImmutablePureComponent { export default class StatusContent extends ImmutablePureComponent {
@ -44,7 +44,7 @@ export default class StatusContent extends ImmutablePureComponent {
} }
link.classList.add('status-link'); link.classList.add('status-link');
let mention = this.props.status.get('mentions').find(item => link.href === `/${item.get('acct')}`); let mention = this.props.status.get('mentions').find(item => link.href === `${item.get('url')}`);
if (mention) { if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false); link.addEventListener('click', this.onMentionClick.bind(this, mention), false);

View File

@ -26,12 +26,24 @@ export default class StatusList extends ImmutablePureComponent {
withGroupAdmin: PropTypes.bool, withGroupAdmin: PropTypes.bool,
onScrollToTop: PropTypes.func, onScrollToTop: PropTypes.func,
onScroll: PropTypes.func, onScroll: PropTypes.func,
promotion: PropTypes.object,
promotedStatus: ImmutablePropTypes.map,
fetchStatus: PropTypes.func,
}; };
componentDidMount() { componentDidMount() {
this.handleDequeueTimeline(); this.handleDequeueTimeline();
this.fetchPromotedStatus();
}; };
fetchPromotedStatus() {
const { promotion, promotedStatus, fetchStatus } = this.props;
if (promotion && !promotedStatus) {
fetchStatus(promotion.status_id);
}
}
getFeaturedStatusCount = () => { getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
} }
@ -84,30 +96,23 @@ export default class StatusList extends ImmutablePureComponent {
} }
render () { render () {
const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props; const { statusIds, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, promotion, promotedStatus, ...other } = this.props;
if (isPartial) { if (isPartial) {
return ( <ColumnIndicator type='loading' /> ); return ( <ColumnIndicator type='loading' /> );
} }
let scrollableContent = null; let scrollableContent = (isLoading || statusIds.size > 0) ? (
if (isLoading || statusIds.size > 0) { statusIds.map((statusId, index) => statusId === null ? (
scrollableContent = statusIds.map((statusId, i) => { <LoadGap
if (statusId === null) { key={'gap:' + statusIds.get(index + 1)}
return ( disabled={isLoading}
<LoadMore maxId={index > 0 ? statusIds.get(index - 1) : null}
gap onClick={onLoadMore}
key={'gap:' + statusIds.get(i + 1)} />
disabled={isLoading} ) : (
maxId={i > 0 ? statusIds.get(i - 1) : null} <React.Fragment key={statusId}>
onClick={onLoadMore}
/>
);
}
return (
<StatusContainer <StatusContainer
key={statusId}
id={statusId} id={statusId}
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
@ -116,9 +121,17 @@ export default class StatusList extends ImmutablePureComponent {
withGroupAdmin={withGroupAdmin} withGroupAdmin={withGroupAdmin}
showThread showThread
/> />
); {promotedStatus && index === promotion.position && (
}); <StatusContainer
} id={promotion.status_id}
contextType={timelineId}
promoted
showThread
/>
)}
</React.Fragment>
))
) : null;
if (scrollableContent && featuredStatusIds) { if (scrollableContent && featuredStatusIds) {
scrollableContent = featuredStatusIds.map(statusId => ( scrollableContent = featuredStatusIds.map(statusId => (

View File

@ -0,0 +1,43 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import StatusContent from './status_content';
import DisplayName from './display_name';
import { connect } from 'react-redux';
import { NavLink } from 'react-router-dom';
const mapStateToProps = (state, { id }) => ({
status: state.getIn(['statuses', id]),
account: state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
});
@connect(mapStateToProps)
export default class StatusQuote extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
};
render() {
const { status, account } = this.props;
const statusUrl = `/${account.get('acct')}/posts/${status.get('id')}`;
return (
<NavLink to={statusUrl} className="status__quote">
<DisplayName account={account} />
<StatusContent
status={status}
expanded={false}
onClick
collapsable
/>
</NavLink>
);
}
}

View File

@ -9,7 +9,10 @@ import UI from '../features/ui';
import Introduction from '../features/introduction'; import Introduction from '../features/introduction';
import { fetchCustomEmojis } from '../actions/custom_emojis'; import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming'; import {
connectUserStream,
connectStatusUpdateStream,
} from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales'; import { getLocale } from '../locales';
import initialState from '../initial_state'; import initialState from '../initial_state';
@ -68,6 +71,7 @@ export default class GabSocial extends PureComponent {
componentDidMount() { componentDidMount() {
this.disconnect = store.dispatch(connectUserStream()); this.disconnect = store.dispatch(connectUserStream());
store.dispatch(connectStatusUpdateStream());
} }
componentWillUnmount () { componentWillUnmount () {

View File

@ -4,6 +4,7 @@ import {
replyCompose, replyCompose,
mentionCompose, mentionCompose,
directCompose, directCompose,
quoteCompose,
} from '../actions/compose'; } from '../actions/compose';
import { import {
reblog, reblog,
@ -18,6 +19,7 @@ import {
muteStatus, muteStatus,
unmuteStatus, unmuteStatus,
deleteStatus, deleteStatus,
editStatus,
hideStatus, hideStatus,
revealStatus, revealStatus,
} from '../actions/statuses'; } from '../actions/statuses';
@ -35,11 +37,11 @@ import {
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
quoteConfirm: { id: 'confirmations.quote.confirm', defaultMessage: 'Quote' },
quoteMessage: { id: 'confirmations.quote.message', defaultMessage: 'Quoting now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
}); });
@ -70,6 +72,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}); });
}, },
onQuote (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.quoteMessage),
confirm: intl.formatMessage(messages.quoteConfirm),
onConfirm: () => dispatch(quoteCompose(status, router)),
}));
} else {
dispatch(quoteCompose(status, router));
}
});
},
onModalReblog (status) { onModalReblog (status) {
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
@ -86,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onShowRevisions (status) {
dispatch(openModal('STATUS_REVISION', { status }));
},
onFavourite (status) { onFavourite (status) {
if (status.get('favourited')) { if (status.get('favourited')) {
dispatch(unfavourite(status)); dispatch(unfavourite(status));
@ -109,18 +130,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
})); }));
}, },
onDelete (status, history, withRedraft = false) { onDelete (status, history) {
if (!deleteModal) { if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft)); dispatch(deleteStatus(status.get('id'), history));
} else { } else {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), onConfirm: () => dispatch(deleteStatus(status.get('id'), history)),
})); }));
} }
}, },
onEdit (status) {
dispatch(editStatus(status));
},
onDirect (account, router) { onDirect (account, router) {
dispatch(directCompose(account, router)); dispatch(directCompose(account, router));
}, },
@ -183,6 +208,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(groupRemoveStatus(groupId, statusId)); dispatch(groupRemoveStatus(groupId, statusId));
}, },
onOpenProUpgradeModal() {
dispatch(openModal('PRO_UPGRADE'));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

@ -1,14 +1,15 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { me } from '../initial_state'; import { me, promotions } from '../initial_state';
import StatusList from '../components/status_list/status_list';
import { dequeueTimeline } from 'gabsocial/actions/timelines'; import { dequeueTimeline } from 'gabsocial/actions/timelines';
import { scrollTopTimeline } from '../actions/timelines'; import { scrollTopTimeline } from '../actions/timelines';
import { sample } from 'lodash';
import { fetchStatus } from '../actions/statuses';
const makeGetStatusIds = () => createSelector([ const makeGetStatusIds = () => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()), (state, { type, id }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()), (state, { type, id }) => state.getIn(['timelines', id, 'items'], ImmutableList()),
(state) => state.get('statuses'), (state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => { ], (columnSettings, statusIds, statuses) => {
return statusIds.filter(id => { return statusIds.filter(id => {
@ -31,13 +32,16 @@ const makeGetStatusIds = () => createSelector([
const mapStateToProps = (state, {timelineId}) => { const mapStateToProps = (state, {timelineId}) => {
const getStatusIds = makeGetStatusIds(); const getStatusIds = makeGetStatusIds();
const promotion = promotions.length > 0 && sample(promotions.filter(p => p.timeline_id === timelineId));
return { return {
statusIds: getStatusIds(state, { type: timelineId }), statusIds: getStatusIds(state, { type: timelineId.substring(0,5) === 'group' ? 'group' : timelineId, id: timelineId }),
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
hasMore: state.getIn(['timelines', timelineId, 'hasMore']), hasMore: state.getIn(['timelines', timelineId, 'hasMore']),
totalQueuedItemsCount: state.getIn(['timelines', timelineId, 'totalQueuedItemsCount']), totalQueuedItemsCount: state.getIn(['timelines', timelineId, 'totalQueuedItemsCount']),
promotion: promotion,
promotedStatus: promotion && state.getIn(['statuses', promotion.status_id])
}; };
}; };
@ -51,6 +55,9 @@ const mapDispatchToProps = (dispatch, ownProps) => ({
onScroll: debounce(() => { onScroll: debounce(() => {
dispatch(scrollTopTimeline(ownProps.timelineId, false)); dispatch(scrollTopTimeline(ownProps.timelineId, false));
}, 100), }, 100),
fetchStatus(id) {
dispatch(fetchStatus(id));
}
}); });
export default connect(mapStateToProps, mapDispatchToProps)(StatusList); export default connect(mapStateToProps, mapDispatchToProps)(StatusList);

View File

@ -5,9 +5,11 @@ import { autoPlayGif, displayMedia } from 'gabsocial/initial_state';
import classNames from 'classnames'; import classNames from 'classnames';
import { decode } from 'blurhash'; import { decode } from 'blurhash';
import { isIOS } from 'gabsocial/utils/is_mobile'; import { isIOS } from 'gabsocial/utils/is_mobile';
import conversations_list_container from '../../direct_timeline/containers/conversations_list_container';
import './media_item.scss'; import './media_item.scss';
export default class MediaItem extends ImmutablePureComponent { export default class MediaItem extends ImmutablePureComponent {
static propTypes = { static propTypes = {
@ -125,8 +127,10 @@ export default class MediaItem extends ImmutablePureComponent {
onMouseEnter={this.handleMouseEnter} onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave} onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay} autoPlay={autoPlay}
preload='auto'
loop loop
muted muted
playsInline
/> />
<span className='media-gallery__gifv__label'>GIF</span> <span className='media-gallery__gifv__label'>GIF</span>

View File

@ -44,7 +44,7 @@ class ActionBar extends PureComponent {
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick }); menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' }); menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });

View File

@ -18,9 +18,13 @@ import { isMobile } from '../../../../utils/is_mobile';
import { countableText } from '../../util/counter'; import { countableText } from '../../util/counter';
import Icon from '../../../../components/icon'; import Icon from '../../../../components/icon';
import Button from '../../../../components/button'; import Button from '../../../../components/button';
import SchedulePostDropdownContainer from '../containers/schedule_post_dropdown_container';
import UploadFormContainer from '../containers/upload_form_container';
import QuotedStatusPreviewContainer from '../containers/quoted_status_preview_container';
import './compose_form.scss'; import './compose_form.scss';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d'; const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
const maxPostCharacterCount = 3000; const maxPostCharacterCount = 3000;
@ -29,6 +33,7 @@ const messages = defineMessages({
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Gab' }, publish: { id: 'compose_form.publish', defaultMessage: 'Gab' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
schedulePost: { id: 'compose_form.schedule_post', defaultMessage: 'Schedule Post' }
}); });
export default @injectIntl export default @injectIntl
@ -44,6 +49,7 @@ class ComposeForm extends ImmutablePureComponent {
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
edit: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
suggestions: ImmutablePropTypes.list, suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool, spoiler: PropTypes.bool,
@ -69,6 +75,8 @@ class ComposeForm extends ImmutablePureComponent {
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
group: ImmutablePropTypes.map, group: ImmutablePropTypes.map,
isModalOpen: PropTypes.bool, isModalOpen: PropTypes.bool,
scheduledAt: PropTypes.instanceOf(Date),
setScheduledAt: PropTypes.func.isRequired,
}; };
static defaultProps = { static defaultProps = {
@ -93,12 +101,21 @@ class ComposeForm extends ImmutablePureComponent {
handleClick = (e) => { handleClick = (e) => {
if (!this.form) return false; if (!this.form) return false;
if (e.target) {
if (e.target.classList.contains('react-datepicker__time-list-item')) return;
}
if (!this.form.contains(e.target)) { if (!this.form.contains(e.target)) {
this.handleClickOutside(); this.handleClickOutside();
} }
} }
handleClickOutside = () => { handleClickOutside = () => {
const { shouldCondense, scheduledAt, text, isModalOpen } = this.props;
const condensed = shouldCondense && !text;
if (condensed && scheduledAt && !isModalOpen) { //Reset scheduled date if condensing
this.props.setScheduledAt(null);
}
this.setState({ this.setState({
composeFocused: false, composeFocused: false,
}); });
@ -198,7 +215,7 @@ class ComposeForm extends ImmutablePureComponent {
} }
render () { render () {
const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen } = this.props; const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, quoteOfId, edit, scheduledAt } = this.props;
const condensed = shouldCondense && !this.props.text && !this.state.composeFocused; const condensed = shouldCondense && !this.props.text && !this.state.composeFocused;
const disabled = this.props.isSubmitting; const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join(''); const text = [this.props.spoilerText, countableText(this.props.text)].join('');
@ -213,6 +230,10 @@ class ComposeForm extends ImmutablePureComponent {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
} }
if (scheduledAt) {
publishText = intl.formatMessage(messages.schedulePost);
}
const composeClassNames = classNames({ const composeClassNames = classNames({
'compose-form': true, 'compose-form': true,
'condensed': condensed, 'condensed': condensed,
@ -265,20 +286,25 @@ class ComposeForm extends ImmutablePureComponent {
{ {
!condensed && !condensed &&
<div className='compose-form__modifiers'> <div className='compose-form__modifiers'>
<UploadForm /> <UploadFormContainer />
<PollFormContainer /> {!edit && <PollFormContainer />}
</div> </div>
} }
</AutosuggestTextbox> </AutosuggestTextbox>
{quoteOfId && <QuotedStatusPreviewContainer id={quoteOfId} />}
{ {
!condensed && !condensed &&
<div className='compose-form__buttons-wrapper'> <div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'> <div className='compose-form__buttons'>
<UploadButtonContainer /> <UploadButtonContainer />
<PollButtonContainer /> {!edit && <PollButtonContainer />}
<PrivacyDropdownContainer /> <PrivacyDropdownContainer />
<SpoilerButtonContainer /> <SpoilerButtonContainer />
<SchedulePostDropdownContainer
position={isModalOpen ? 'top' : undefined}
/>
</div> </div>
<CharacterCounter max={maxPostCharacterCount} text={text} /> <CharacterCounter max={maxPostCharacterCount} text={text} />
</div> </div>

View File

@ -0,0 +1,26 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
export default class QuotedStatusPreview extends React.PureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
}
render() {
const { status, account } = this.props;
return (
<div className='compose-form__quote-preview'>
<DisplayName account={account} />
<StatusContent
status={status}
expanded={false}
/>
</div>
);
}
}

View File

@ -0,0 +1,114 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, defineMessages } from 'react-intl';
import DatePicker from 'react-datepicker';
import IconButton from '../../../components/icon_button';
import { isMobile } from '../../../is_mobile';
import "react-datepicker/dist/react-datepicker.css";
const messages = defineMessages({
schedule_status: { id: 'schedule_status.title', defaultMessage: 'Schedule Status' },
});
class DatePickerWrapper extends React.PureComponent {
static propTypes = {
value: PropTypes.string,
onClick: PropTypes.func,
};
render() {
const { value, onClick } = this.props;
return (
<button className="schedule-post-dropdown-wrapper" onClick={onClick}>
{value}
</button>
)
}
}
export default @injectIntl
class SchedulePostDropdown extends React.PureComponent {
static propTypes = {
date: PropTypes.instanceOf(Date),
setScheduledAt: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
isPro: PropTypes.bool,
onOpenProUpgradeModal: PropTypes.func.isRequired,
position: PropTypes.string,
};
handleToggle = () => {
if (!this.props.isPro) {
return this.props.onOpenProUpgradeModal();
}
const { date } = this.props;
const value = date ? null : new Date();
this.handleSetDate(value);
}
handleSetDate = (date) => {
this.props.setScheduledAt(date);
}
render () {
const { intl, date, isPro, position } = this.props;
const open = !!date;
const datePickerDisabled = !isPro;
const withPortal = isMobile(window.innerWidth);
const popperPlacement = position || undefined;
return (
<div className='schedule-post-dropdown'>
<div className='schedule-post-dropdown__container'>
<IconButton
inverted
className='schedule-post-dropdown__icon'
icon='calendar'
title={intl.formatMessage(messages.schedule_status)}
size={18}
expanded={open}
active={open}
onClick={this.handleToggle}
style={{ height: null, lineHeight: '27px' }}
/>
</div>
{
open &&
<DatePicker
target={this}
className='schedule-post-dropdown__datepicker'
minDate={new Date()}
selected={date}
onChange={date => this.handleSetDate(date)}
timeFormat="p"
timeIntervals={15}
timeCaption="Time"
dateFormat="MMM d, yyyy h:mm aa"
disabled={datePickerDisabled}
showTimeSelect
customInput={<DatePickerWrapper />}
withPortal={withPortal}
popperPlacement={popperPlacement}
popperModifiers={{
offset: {
enabled: true,
offset: "0px, 5px"
},
preventOverflow: {
enabled: true,
escapeWithReference: false,
boundariesElement: "viewport"
}
}}
/>
}
</div>
);
}
}

View File

@ -1,18 +1,19 @@
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl'; import Hashtag from '../../../components/hashtag';
import AccountContainer from '../../../../containers/account_container'; import Icon from 'gabsocial/components/icon';
import StatusContainer from '../../../../containers/status_container'; import WhoToFollowPanel from '../../ui/components/who_to_follow_panel';
import TrendingItem from '../../../../components/trending_item'; // import TrendsPanel from '../../ui/components/trends_panel';
import Icon from '../../../../components/icon'; import GroupListItem from 'gabsocial/components/group_list_item';
import { WhoToFollowPanel } from '../../../../components/panel';
import './search_results.scss'; export default
@injectIntl
export default class SearchResults extends ImmutablePureComponent { class SearchResults extends ImmutablePureComponent {
static propTypes = { static propTypes = {
results: ImmutablePropTypes.map.isRequired, results: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
location: PropTypes.object,
}; };
state = { state = {
@ -20,7 +21,7 @@ export default class SearchResults extends ImmutablePureComponent {
} }
render () { render () {
const { results } = this.props; const { results, location } = this.props;
const { isSmallScreen } = this.state; const { isSmallScreen } = this.state;
if (results.isEmpty() && isSmallScreen) { if (results.isEmpty() && isSmallScreen) {
@ -31,44 +32,46 @@ export default class SearchResults extends ImmutablePureComponent {
); );
} }
let accounts, statuses, hashtags; const pathname = location.pathname || '';
const showPeople = pathname === '/search/people';
const showHashtags = pathname === '/search/hashtags';
const showGroups = pathname === '/search/groups';
const isTop = !showPeople && !showHashtags && !showGroups;
let accounts, statuses, hashtags, groups;
let count = 0; let count = 0;
if (results.get('accounts') && results.get('accounts').size > 0) {
count += results.get('accounts').size; if (results.get('accounts') && results.get('accounts').size > 0 && (isTop || showPeople)) {
const size = isTop ? Math.min(results.get('accounts').size, 5) : results.get('accounts').size;
count += size;
accounts = ( accounts = (
<div className='search-results__section'> <div className='search-results__section'>
<h5> <h5><Icon id='user' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
<Icon id='users' fixedWidth /> {results.get('accounts').slice(0, size).map(accountId => <AccountContainer key={accountId} id={accountId} />)}
<FormattedMessage id='search_results.accounts' defaultMessage='People' />
</h5>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div> </div>
); );
} }
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size; if (results.get('groups') && results.get('groups').size > 0 && (isTop || showGroups)) {
statuses = ( const size = isTop ? Math.min(results.get('groups').size, 5) : results.get('groups').size;
count += size;
groups = (
<div className='search-results__section'> <div className='search-results__section'>
<h5> <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.groups' defaultMessage='Groups' /></h5>
<Icon id='quote-right' fixedWidth /> {results.get('groups').slice(0, size).map(group => <GroupListItem key={`search-${group.get('name')}`} group={group} />)}
<FormattedMessage id='search_results.statuses' defaultMessage='Gabs' />
</h5>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</div> </div>
); );
} }
if (results.get('hashtags') && results.get('hashtags').size > 0) { if (results.get('hashtags') && results.get('hashtags').size > 0 && (isTop || showHashtags)) {
count += results.get('hashtags').size; const size = isTop ? Math.min(results.get('hashtags').size, 5) : results.get('hashtags').size;
count += size;
hashtags = ( hashtags = (
<div className='search-results__section'> <div className='search-results__section'>
<h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').slice(0, size).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
{results.get('hashtags').map(hashtag => <TrendingItem key={hashtag.get('name')} hashtag={hashtag} />)}
</div> </div>
); );
} }
@ -87,6 +90,7 @@ export default class SearchResults extends ImmutablePureComponent {
</div> </div>
{accounts} {accounts}
{groups}
{statuses} {statuses}
{hashtags} {hashtags}
</div> </div>

View File

@ -10,8 +10,7 @@ import './upload.scss';
const messages = defineMessages({ const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
undo: { id: 'upload_form.undo', defaultMessage: 'Delete' }, delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
focus: { id: 'upload_form.focus', defaultMessage: 'Crop' },
}); });
export default @injectIntl export default @injectIntl
@ -100,24 +99,10 @@ class Upload extends ImmutablePureComponent {
<div className='compose-form-upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'> <div className='compose-form-upload' tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div className='compose-form-upload__thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<div className={classNames('compose-form-upload__actions', { active })}> <div className={classNames('compose-form__upload__actions', { active })}>
<IconButton <button className='icon-button' title={intl.formatMessage(messages.delete)} onClick={this.handleUndoClick}><Icon id='times'/></button>
onClick={this.handleUndoClick} {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
icon='times'
title={intl.formatMessage(messages.undo)}
text={intl.formatMessage(messages.undo)}
/>
{
media.get('type') === 'image' &&
<IconButton
onClick={this.handleFocalPointClick}
icon='crosshairs'
title={intl.formatMessage(messages.focus)}
text={intl.formatMessage(messages.focus)}
/>
}
</div> </div>
<div className={classNames('compose-form-upload__description', { active })}> <div className={classNames('compose-form-upload__description', { active })}>

View File

@ -8,9 +8,11 @@ import {
changeComposeSpoilerText, changeComposeSpoilerText,
insertEmojiCompose, insertEmojiCompose,
uploadCompose, uploadCompose,
changeScheduledAt,
} from '../../../actions/compose'; } from '../../../actions/compose';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
edit: state.getIn(['compose', 'id']) !== null,
text: state.getIn(['compose', 'text']), text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']), suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']), spoiler: state.getIn(['compose', 'spoiler']),
@ -25,6 +27,8 @@ const mapStateToProps = state => ({
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isModalOpen: state.get('modal').modalType === 'COMPOSE', isModalOpen: state.get('modal').modalType === 'COMPOSE',
quoteOfId: state.getIn(['compose', 'quote_of_id']),
scheduledAt: state.getIn(['compose', 'scheduled_at']),
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
@ -61,6 +65,9 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(insertEmojiCompose(position, data, needsSpace)); dispatch(insertEmojiCompose(position, data, needsSpace));
}, },
setScheduledAt (date) {
dispatch(changeScheduledAt(date));
},
}); });
function mergeProps(stateProps, dispatchProps, ownProps) { function mergeProps(stateProps, dispatchProps, ownProps) {

View File

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import QuotedStatusPreview from '../components/quoted_status_preview';
const mapStateToProps = (state, { id }) => ({
status: state.getIn(['statuses', id]),
account: state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
});
export default connect(mapStateToProps)(QuotedStatusPreview);

View File

@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import SchedulePostDropdown from '../components/schedule_post_dropdown';
import { changeScheduledAt } from '../../../actions/compose';
import { openModal } from '../../../actions/modal';
import { me } from '../../../initial_state';
const mapStateToProps = state => ({
date: state.getIn(['compose', 'scheduled_at']),
isPro: state.getIn(['accounts', me, 'is_pro']),
});
const mapDispatchToProps = dispatch => ({
setScheduledAt (date) {
dispatch(changeScheduledAt(date));
},
onOpenProUpgradeModal() {
dispatch(openModal('PRO_UPGRADE'));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(SchedulePostDropdown);

View File

@ -1,5 +1,6 @@
import SearchResults from '../components/search_results'; import SearchResults from '../components/search_results';
import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions'; import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
import { withRouter } from 'react-router-dom';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
results: state.getIn(['search', 'results']), results: state.getIn(['search', 'results']),
@ -11,4 +12,4 @@ const mapDispatchToProps = dispatch => ({
dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))), dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults); export default withRouter(connect(mapStateToProps, mapDispatchToProps)(SearchResults));

View File

@ -6,17 +6,20 @@ import { Link } from 'react-router-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import GroupCard from './card'; import GroupCard from './card';
import GroupCreate from '../create'; import GroupCreate from '../create';
import { me } from 'gabsocial/initial_state';
import { openModal } from '../../../actions/modal';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.groups', defaultMessage: 'Groups' }, heading: { id: 'column.groups', defaultMessage: 'Groups' },
create: { id: 'groups.create', defaultMessage: 'Create group' }, create: { id: 'groups.create', defaultMessage: 'Create group' },
tab_featured: { id: 'groups.tab_featured', defaultMessage: 'Featured' }, tab_featured: { id: 'groups.tab_featured', defaultMessage: 'Featured' },
tab_member: { id: 'groups.tab_member', defaultMessage: 'Groups you\'re in' }, tab_member: { id: 'groups.tab_member', defaultMessage: 'Member' },
tab_admin: { id: 'groups.tab_admin', defaultMessage: 'Groups you manage' }, tab_admin: { id: 'groups.tab_admin', defaultMessage: 'Manage' },
}); });
const mapStateToProps = (state, { activeTab }) => ({ const mapStateToProps = (state, { activeTab }) => ({
groupIds: state.getIn(['group_lists', activeTab]), groupIds: state.getIn(['group_lists', activeTab]),
account: state.getIn(['accounts', me]),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -42,12 +45,27 @@ class Groups extends ImmutablePureComponent {
} }
} }
handleOpenProUpgradeModal = () => {
this.props.dispatch(openModal('PRO_UPGRADE'));
}
renderHeader() { renderHeader() {
const { intl, activeTab } = this.props; const { intl, activeTab, account, onOpenProUpgradeModal } = this.props;
const isPro = account.get('is_pro');
return ( return (
<div className="group-column-header"> <div className="group-column-header">
<div className="group-column-header__cta"><Link to="/groups/create" className="button">{intl.formatMessage(messages.create)}</Link></div> <div className="group-column-header__cta">
{
account && isPro &&
<Link to="/groups/create" className="button standard-small">{intl.formatMessage(messages.create)}</Link>
}
{
account && !isPro &&
<button onClick={this.handleOpenProUpgradeModal} className="button standard-small">{intl.formatMessage(messages.create)}</button>
}
</div>
<div className="group-column-header__title">{intl.formatMessage(messages.heading)}</div> <div className="group-column-header__title">{intl.formatMessage(messages.heading)}</div>
<div className="column-header__wrapper"> <div className="column-header__wrapper">

View File

@ -3,18 +3,22 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ColumnIndicator from '../../../components/column_indicator'; import ColumnIndicator from '../../../components/column_indicator';
import { import {
fetchMembers, fetchMembers,
expandMembers, expandMembers,
updateRole,
createRemovedAccount,
} from '../../../actions/groups'; } from '../../../actions/groups';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import Column from '../../../components/column'; import Column from '../../../components/column';
import ScrollableList from '../../../components/scrollable_list'; import ScrollableList from '../../../components/scrollable_list';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
const mapStateToProps = (state, { params: { id } }) => ({ const mapStateToProps = (state, { params: { id } }) => ({
group: state.getIn(['groups', id]), group: state.getIn(['groups', id]),
accountIds: state.getIn(['user_lists', 'groups', id, 'items']), relationships: state.getIn(['group_relationships', id]),
hasMore: !!state.getIn(['user_lists', 'groups', id, 'next']), accountIds: state.getIn(['user_lists', 'groups', id, 'items']),
hasMore: !!state.getIn(['user_lists', 'groups', id, 'next']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -44,24 +48,44 @@ class GroupMembers extends ImmutablePureComponent {
}, 300, { leading: true }); }, 300, { leading: true });
render () { render () {
const { accountIds, hasMore, group } = this.props; const { accountIds, hasMore, group, relationships, dispatch } = this.props;
if (!group || !accountIds) { if (!group || !accountIds || !relationships) {
return (<ColumnIndicator type='loading' />); return (
} <Column>
<LoadingIndicator />
</Column>
);
}
return ( return (
<Column> <Column>
<ScrollableList <ScrollableList
scrollKey='members' scrollKey='members'
hasMore={hasMore} hasMore={hasMore}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='group.members.empty' defaultMessage='This group does not has any members.' />} emptyMessage={<FormattedMessage id='group.members.empty' defaultMessage='This group does not has any members.' />}
> >
{accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} {accountIds.map(id => {
</ScrollableList> let menu = [];
</Column>
); if (relationships.get('admin')) {
menu = [
{ text: 'Remove from group', action: () => dispatch(createRemovedAccount(group.get('id'), id)) },
{ text: 'Make administrator', action: () => dispatch(updateRole(group.get('id'), id, 'admin')) },
]
}
return (
<div className="group-account-wrapper" key={id}>
<AccountContainer id={id} withNote={false} actionIcon="none" onActionClick={() => true} />
{menu.length > 0 && <DropdownMenuContainer items={menu} icon='ellipsis-h' size={18} direction='right' />}
</div>
);
})}
</ScrollableList>
</Column>
);
} }
} }

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting, saveSettings } from '../../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'group']),
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['group', ...key], checked));
},
onSave () {
dispatch(saveSettings());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -1,11 +1,24 @@
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import StatusListContainer from '../../ui/containers/status_list_container';
import StatusListContainer from '../../../containers/status_list_container'; import Column from '../../../components/column';
import { FormattedMessage, injectIntl } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { connectGroupStream } from '../../../actions/streaming'; import { connectGroupStream } from '../../../actions/streaming';
import { expandGroupTimeline } from '../../../actions/timelines'; import { expandGroupTimeline } from '../../../actions/timelines';
import ColumnIndicator from '../../../components/column_indicator'; import MissingIndicator from '../../../components/missing_indicator';
import TimelineComposeBlock from '../../../components/timeline_compose_block'; import LoadingIndicator from '../../../components/loading_indicator';
import ComposeFormContainer from '../../../../gabsocial/features/compose/containers/compose_form_container';
import { me } from 'gabsocial/initial_state';
import Avatar from '../../../components/avatar';
import { Link } from 'react-router-dom';
import classNames from 'classnames';
import ColumnSettingsContainer from "./containers/column_settings_container";
import Icon from 'gabsocial/components/icon';
const messages = defineMessages({
tabLatest: { id: 'group.timeline.tab_latest', defaultMessage: 'Latest' },
show: { id: 'group.timeline.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'group.timeline.hide_settings', defaultMessage: 'Hide settings' },
});
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
group: state.getIn(['groups', props.params.id]), group: state.getIn(['groups', props.params.id]),
@ -31,6 +44,10 @@ class GroupTimeline extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
state = {
collapsed: true,
}
componentDidMount () { componentDidMount () {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id } = this.props.params; const { id } = this.props.params;
@ -52,9 +69,15 @@ class GroupTimeline extends ImmutablePureComponent {
this.props.dispatch(expandGroupTimeline(id, { maxId })); this.props.dispatch(expandGroupTimeline(id, { maxId }));
} }
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed });
}
render () { render () {
const { columnId, group, relationships } = this.props; const { columnId, group, relationships, account, intl } = this.props;
const { id } = this.props.params; const { collapsed } = this.state;
const { id } = this.props.params;
if (typeof group === 'undefined' || !relationships) { if (typeof group === 'undefined' || !relationships) {
return (<ColumnIndicator type='loading' />); return (<ColumnIndicator type='loading' />);
@ -69,18 +92,44 @@ class GroupTimeline extends ImmutablePureComponent {
<TimelineComposeBlock size={46} group={group} shouldCondense={true} autoFocus={false} /> <TimelineComposeBlock size={46} group={group} shouldCondense={true} autoFocus={false} />
} }
<div className='group__feed'> <div className='group__feed'>
<StatusListContainer <div className="column-header__wrapper">
scrollKey={`group_timeline-${columnId}`} <h1 className="column-header">
timelineId={`group:${id}`} <Link to={`/groups/${id}`} className={classNames('btn grouped active')}>
onLoadMore={this.handleLoadMore} {intl.formatMessage(messages.tabLatest)}
group={group} </Link>
withGroupAdmin={relationships && relationships.get('admin')}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group post new statuses, they will appear here.' />} <div className='column-header__buttons'>
/> <button
</div> className={classNames('column-header__button', { 'active': !collapsed })}
</div> title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
); aria-label={intl.formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
><Icon id='sliders' /></button>
</div>
</h1>
{!collapsed && <div className='column-header__collapsible'>
<div className='column-header__collapsible-inner'>
<div className='column-header__collapsible__extra'>
<ColumnSettingsContainer />
</div>
</div>
</div>}
</div>
<StatusListContainer
alwaysPrepend
scrollKey={`group_timeline-${columnId}`}
timelineId={`group:${id}`}
onLoadMore={this.handleLoadMore}
group={group}
withGroupAdmin={relationships && relationships.get('admin')}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group post new statuses, they will appear here.' />}
/>
</div>
</div>
);
} }
} }

View File

@ -59,16 +59,12 @@ export default class Card extends ImmutablePureComponent {
static propTypes = { static propTypes = {
card: ImmutablePropTypes.map, card: ImmutablePropTypes.map,
maxDescription: PropTypes.number,
onOpenMedia: PropTypes.func.isRequired, onOpenMedia: PropTypes.func.isRequired,
compact: PropTypes.bool,
defaultWidth: PropTypes.number, defaultWidth: PropTypes.number,
cacheWidth: PropTypes.func, cacheWidth: PropTypes.func,
}; };
static defaultProps = { static defaultProps = {
maxDescription: 50,
compact: false,
}; };
state = { state = {
@ -132,37 +128,52 @@ export default class Card extends ImmutablePureComponent {
ref={this.setRef} ref={this.setRef}
className='status-card__image status-card-video' className='status-card__image status-card-video'
dangerouslySetInnerHTML={content} dangerouslySetInnerHTML={content}
style={{ height }} style={{
height,
paddingBottom: 0,
}}
/> />
); );
} }
render () { render () {
const { card, maxDescription, compact } = this.props; const { card } = this.props;
const { width, embedded } = this.state; const { width, embedded } = this.state;
if (card === null) { if (card === null) {
return null; return null;
} }
const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); const maxDescription = 150;
const horizontal = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded; const cardImg = card.get('image');
const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
const horizontal = (card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
const interactive = card.get('type') !== 'link'; const interactive = card.get('type') !== 'link';
const className = classnames('status-card', { horizontal, compact, interactive }); const className = classnames('status-card', {
const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>; horizontal,
const ratio = card.get('width') / card.get('height'); interactive,
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio); compact: !cardImg && !interactive,
});
const title = interactive ?
<a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'>
<strong>{card.get('title')}</strong>
</a>
: <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
const description = ( const description = (
<div className='status-card__content'> <div className='status-card__content'>
{title} {title}
{!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} {!horizontal && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
<span className='status-card__host'>{provider}</span> <span className='status-card__host'>
<Icon id='link' fixedWidth />
{' '}
{provider}
</span>
</div> </div>
); );
let embed = ''; let embed = '';
let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; let thumbnail = card ? <div style={{ backgroundImage: `url(${cardImg})` }} className='status-card__image-image' /> : thumbnail = <div className='status-card__image-image' />;
if (interactive) { if (interactive) {
if (embedded) { if (embedded) {
@ -177,7 +188,6 @@ export default class Card extends ImmutablePureComponent {
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
{thumbnail} {thumbnail}
<div className='status-card__actions'> <div className='status-card__actions'>
<div> <div>
<button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button> <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
@ -191,10 +201,10 @@ export default class Card extends ImmutablePureComponent {
return ( return (
<div className={className} ref={this.setRef}> <div className={className} ref={this.setRef}>
{embed} {embed}
{!compact && description} {description}
</div> </div>
); );
} else if (card.get('image')) { } else if (cardImg) {
embed = ( embed = (
<div className='status-card__image'> <div className='status-card__image'>
{thumbnail} {thumbnail}

View File

@ -1,3 +1,4 @@
import StatusQuote from '../../../components/status_quote';
import { Link, NavLink } from 'react-router-dom'; import { Link, NavLink } from 'react-router-dom';
import { FormattedDate, FormattedNumber } from 'react-intl'; import { FormattedDate, FormattedNumber } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
@ -32,12 +33,17 @@ export default class DetailedStatus extends ImmutablePureComponent {
compact: PropTypes.bool, compact: PropTypes.bool,
showMedia: PropTypes.bool, showMedia: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func, onToggleMediaVisibility: PropTypes.func,
onShowRevisions: PropTypes.func,
}; };
state = { state = {
height: null, height: null,
}; };
handleShowRevisions = () => {
this.props.onShowRevisions(this.props.status);
}
handleOpenVideo = (media, startTime) => { handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime); this.props.onOpenVideo(media, startTime);
} }
@ -110,6 +116,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
blurhash={video.get('blurhash')} blurhash={video.get('blurhash')}
src={video.get('url')} src={video.get('url')}
alt={video.get('description')} alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
width={300} width={300}
height={150} height={150}
inline inline
@ -185,16 +192,21 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} localDomain={this.props.domain} /> <DisplayName account={status.get('account')} localDomain={this.props.domain} />
</NavLink> </NavLink>
{status.get('group') && ( {(status.get('group') || status.get('revised_at') !== null) && (
<div className='status__meta'> <div className='status__meta'>
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink> {status.get('group') && <React.Fragment>Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink></React.Fragment>}
</div> {status.get('revised_at') !== null && <a onClick={this.handleShowRevisions}> Edited</a>}
)} </div>
)}
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} /> <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
{media} {media}
{status.get('quote') && <StatusQuote
id={status.get('quote')}
/>}
<div className='detailed-status__meta'> <div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />

View File

@ -10,13 +10,16 @@ import './detailed_status_action_bar.scss';
const messages = defineMessages({ const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' }, delete: { id: 'status.delete', defaultMessage: 'Delete' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, edit: { id: 'status.edit', defaultMessage: 'Edit' },
direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' },
cannot_quote: { id: 'status.cannot_quote', defaultMessage: 'This post cannot be quoted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
@ -50,6 +53,7 @@ class ActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func.isRequired, onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
@ -79,6 +83,14 @@ class ActionBar extends ImmutablePureComponent {
} }
} }
handleQuoteClick = (e) => {
if (me) {
this.props.onQuote(this.props.status, e);
} else {
this.props.onOpenUnauthorizedModal();
}
}
handleFavouriteClick = () => { handleFavouriteClick = () => {
if (me) { if (me) {
this.props.onFavourite(this.props.status); this.props.onFavourite(this.props.status);
@ -91,8 +103,8 @@ class ActionBar extends ImmutablePureComponent {
this.props.onDelete(this.props.status, this.context.router.history); this.props.onDelete(this.props.status, this.context.router.history);
} }
handleRedraftClick = () => { handleEditClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true); this.props.onEdit(this.props.status);
} }
handleMentionClick = () => { handleMentionClick = () => {
@ -176,7 +188,7 @@ class ActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
} else { } else {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null); menu.push(null);
@ -208,17 +220,11 @@ class ActionBar extends ImmutablePureComponent {
let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private'); let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
return ( return (
<div className='detailed-status-action-bar'> <div className='detailed-status__action-bar'>
<div className='detailed-status-action-bar__button'> <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /> <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
</div> <div className='detailed-status__button'><IconButton disabled={reblog_disabled} title={reblog_disabled ? intl.formatMessage(messages.cannot_quote) : intl.formatMessage(messages.quote)} icon='quote-left' onClick={this.handleQuoteClick} /></div>
<div className='detailed-status-action-bar__button'> <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
<IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
</div>
<div className='detailed-status-action-bar__button'>
<IconButton className='star-icon' active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
</div>
{shareButton} {shareButton}
<div className='detailed-status-action-bar__dropdown'> <div className='detailed-status-action-bar__dropdown'>

View File

@ -31,8 +31,6 @@ import { showAlertForError } from '../../../actions/alerts';
const messages = defineMessages({ const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }, deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' },
blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
@ -106,14 +104,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
})); }));
}, },
onDelete (status, history, withRedraft = false) { onDelete (status, history) {
if (!deleteModal) { if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft)); dispatch(deleteStatus(status.get('id'), history));
} else { } else {
dispatch(openModal('CONFIRM', { dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)), onConfirm: () => dispatch(deleteStatus(status.get('id'), history)),
})); }));
} }
}, },

View File

@ -0,0 +1,58 @@
import React from 'react';
import PropTypes from 'prop-types';
import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage, injectIntl } from 'react-intl';
import NotificationsCounterIcon from './notifications_counter_icon';
import { me } from '../../../initial_state';
const links = [
<NavLink key='pr1' className='footer-bar__link' to='/home' data-preview-title-id='column.home'>
<i className='tabs-bar__link__icon home'/>
<FormattedMessage id='tabs_bar.home' defaultMessage='Home' />
</NavLink>,
<NavLink key='pr2' className='footer-bar__link' to='/notifications' data-preview-title-id='column.notifications'>
<i className='tabs-bar__link__icon notifications'/>
<NotificationsCounterIcon />
<FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' />
</NavLink>,
<NavLink key='pr3' className='footer-bar__link' to='/groups' data-preview-title-id='column.groups'>
<i className='tabs-bar__link__icon groups'/>
<FormattedMessage id='tabs_bar.groups' defaultMessage='Groups' />
</NavLink>,
<a key='pl4' className='footer-bar__link footer-bar__link--trends' href='https://trends.gab.com' data-preview-title-id='tabs_bar.trends'>
<i className='tabs-bar__link__icon trends'/>
<FormattedMessage id='tabs_bar.trends' defaultMessage='Trends' />
</a>,
]
export default
@injectIntl
@withRouter
class FooterBar extends React.PureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
}
render () {
const { intl: { formatMessage } } = this.props;
if (!me) return null;
return (
<div className='footer-bar'>
<div className='footer-bar__container'>
{
links.map((link) =>
React.cloneElement(link, {
key: link.props.to,
'aria-label': formatMessage({
id: link.props['data-preview-title-id']
})
}))
}
</div>
</div>
);
}
}

View File

@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../../../initial_state';
import IconButton from '../../../components/icon_button';
import Icon from '../../../components/icon';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};
class ProUpgradeModal extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
};
onClickClose = () => {
this.props.onClose('PRO_UPGRADE');
};
render () {
const { intl } = this.props;
return (
<div className='modal-root__modal compose-modal pro-upgrade-modal'>
<div className='compose-modal__header'>
<h3 className='compose-modal__header__title'><FormattedMessage id='promo.gab_pro' defaultMessage='Upgrade to GabPRO' /></h3>
<IconButton className='compose-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={this.onClickClose} size={20} />
</div>
<div className='compose-modal__content pro-upgrade-modal__content'>
<div>
<span className="pro-upgrade-modal__text">
<FormattedMessage id='pro_upgrade_modal.text' defaultMessage='Gab is fully funded by people like you. Please consider supporting us on our mission to defend free expression online for all people.' />
<FormattedMessage id='pro_upgrade_modal.benefits' defaultMessage='Here are just some of the benefits that thousands of GabPRO members receive:' />
</span>
<ul className="pro-upgrade-modal__list">
<li>Schedule Posts</li>
<li>Get Verified</li>
<li>Create Groups</li>
<li>Larger Video and Image Uploads</li>
<li>Receive the PRO Badge</li>
<li>Remove in-feed promotions</li>
<li>More value being added daily!</li>
</ul>
<a href='https://pro.gab.com' className='pro-upgrade-modal__button button'>
<Icon id='arrow-up' fixedWidth/>
<FormattedMessage id='promo.gab_pro' defaultMessage='Upgrade to GabPRO' />
</a>
</div>
</div>
</div>
);
}
}
export default injectIntl(connect(mapStateToProps)(ProUpgradeModal));

View File

@ -0,0 +1,29 @@
import React from 'react';
import { monthlyExpensesComplete } from '../../../initial_state';
export default class ProgressPanel extends React.PureComponent {
render() {
if (!monthlyExpensesComplete) return null;
const completed = Math.min(monthlyExpensesComplete, 100);
const style = {
width: `${completed}%`,
};
return (
<div className='wtf-panel progress-panel'>
<div className='wtf-panel-header progress-panel-header'>
<div className='wtf-panel-header__label'>Gab's Operational Expenses</div>
</div>
<div className='wtf-panel__content progress-panel__content'>
<span className='progress-panel__text'>We are 100% funded by you.</span>
<div className='progress-panel__bar-container'>
<a className='progress-panel__bar' style={style} href='https://shop.dissenter.com/category/donations'>
<span className='progress-panel__bar__text'>{completed}% covered this month</span>
</a>
</div>
</div>
</div>
)
}
}

View File

@ -0,0 +1,44 @@
import React from 'react';
import { injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ModalLoading from './modal_loading';
import RelativeTimestamp from '../../../components/relative_timestamp';
export default @injectIntl
class StatusRevisionsList extends ImmutablePureComponent {
static propTypes = {
loading: PropTypes.bool.isRequired,
error: PropTypes.object,
data: PropTypes.array
};
render () {
const { loading, error, data } = this.props;
if (loading || !data) return <ModalLoading />;
if (error) return (
<div className='status-revisions-list'>
<div className='status-revisions-list__error'>
An error occured
</div>
</div>
);
return (
<div className='status-revisions-list'>
{data.map((revision, i) => (
<div key={i} className='status-revisions-list__item'>
<div className='status-revisions-list__item__timestamp'>
<RelativeTimestamp timestamp={revision.created_at} />
</div>
<div className='status-revisions-list__item__text'>{revision.text}</div>
</div>
))}
</div>
);
}
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from 'gabsocial/components/icon_button';
import StatusRevisionListContainer from '../containers/status_revision_list_container';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class StatusRevisionModal extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
status: ImmutablePropTypes.map.isRequired
};
render () {
const { intl, onClose, status } = this.props;
return (
<div className='modal-root__modal status-revisions-root'>
<div className='status-revisions'>
<div className='status-revisions__header'>
<h3 className='status-revisions__header__title'><FormattedMessage id='status_revisions.heading' defaultMessage='Revision History' /></h3>
<IconButton className='status-revisions__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
</div>
<div className='status-revisions__content'>
<StatusRevisionListContainer id={status.get('id')} />
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { load } from '../../../actions/status_revision_list';
import StatusRevisionList from '../components/status_revision_list';
class StatusRevisionListContainer extends ImmutablePureComponent {
componentDidMount() {
this.props.load(this.props.id);
}
render() {
return <StatusRevisionList {...this.props} />;
}
}
const mapStateToProps = state => ({
loading: state.getIn(['status_revision_list', 'loading']),
error: state.getIn(['status_revision_list', 'error']),
data: state.getIn(['status_revision_list', 'data']),
});
const mapDispatchToProps = {
load
};
export default connect(mapStateToProps, mapDispatchToProps)(StatusRevisionListContainer);

View File

@ -110,6 +110,10 @@ export function MuteModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../../../components/modal'); return import(/* webpackChunkName: "modals/mute_modal" */'../../../components/modal');
} }
export function StatusRevisionModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/status_revision_modal');
}
export function ReportModal () { export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../../../components/modal'); return import(/* webpackChunkName: "modals/report_modal" */'../../../components/modal');
} }

View File

@ -23,5 +23,8 @@ export const mascot = getMeta('mascot');
export const profile_directory = getMeta('profile_directory'); export const profile_directory = getMeta('profile_directory');
export const isStaff = getMeta('is_staff'); export const isStaff = getMeta('is_staff');
export const forceSingleColumn = !getMeta('advanced_layout'); export const forceSingleColumn = !getMeta('advanced_layout');
export const promotions = initialState && initialState.promotions;
export const unreadCount = getMeta('unread_count');
export const monthlyExpensesComplete = getMeta('monthly_expenses_complete');
export default initialState; export default initialState;

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "etiqueta", "search_popout.tips.hashtag": "etiqueta",
"search_popout.tips.status": "estáu", "search_popout.tips.status": "estáu",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "usuariu", "search_popout.tips.user": "usuariu",
"search_results.accounts": "Xente", "search_results.accounts": "Xente",
"search_results.hashtags": "Etiquetes", "search_results.hashtags": "Etiquetes",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -1026,7 +1026,7 @@
"id": "search_popout.tips.full_text" "id": "search_popout.tips.full_text"
}, },
{ {
"defaultMessage": "Simple text returns matching display names, usernames and hashtags", "defaultMessage": "Simple text returns matching display names, usernames, groups and hashtags",
"id": "search_popout.tips.text" "id": "search_popout.tips.text"
}, },
{ {

View File

@ -310,7 +310,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favorited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favorited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "felhasználó", "search_popout.tips.user": "felhasználó",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "tagar", "search_popout.tips.hashtag": "tagar",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "แฮชแท็ก", "search_popout.tips.hashtag": "แฮชแท็ก",
"search_popout.tips.status": "สถานะ", "search_popout.tips.status": "สถานะ",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "ผู้ใช้", "search_popout.tips.user": "ผู้ใช้",
"search_results.accounts": "ผู้คน", "search_results.accounts": "ผู้คน",
"search_results.hashtags": "แฮชแท็ก", "search_results.hashtags": "แฮชแท็ก",

View File

@ -307,7 +307,7 @@
"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": "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.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "durum", "search_popout.tips.status": "durum",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "kullanıcı", "search_popout.tips.user": "kullanıcı",
"search_results.accounts": "İnsanlar", "search_results.accounts": "İnsanlar",
"search_results.hashtags": "Hashtagler", "search_results.hashtags": "Hashtagler",

View File

@ -307,7 +307,7 @@
"search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.", "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, reposted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
"search_popout.tips.hashtag": "hashtag", "search_popout.tips.hashtag": "hashtag",
"search_popout.tips.status": "status", "search_popout.tips.status": "status",
"search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags", "search_popout.tips.text": "Simple text returns matching display names, usernames, groups and hashtags",
"search_popout.tips.user": "user", "search_popout.tips.user": "user",
"search_results.accounts": "People", "search_results.accounts": "People",
"search_results.hashtags": "Hashtags", "search_results.hashtags": "Hashtags",

View File

@ -4,6 +4,7 @@ import {
COMPOSE_CHANGE, COMPOSE_CHANGE,
COMPOSE_REPLY, COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL, COMPOSE_REPLY_CANCEL,
COMPOSE_QUOTE,
COMPOSE_DIRECT, COMPOSE_DIRECT,
COMPOSE_MENTION, COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST, COMPOSE_SUBMIT_REQUEST,
@ -35,16 +36,18 @@ import {
COMPOSE_POLL_OPTION_CHANGE, COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_OPTION_REMOVE, COMPOSE_POLL_OPTION_REMOVE,
COMPOSE_POLL_SETTINGS_CHANGE, COMPOSE_POLL_SETTINGS_CHANGE,
COMPOSE_SCHEDULED_AT_CHANGE,
} from '../actions/compose'; } from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines'; import { TIMELINE_DELETE } from '../actions/timelines';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { REDRAFT } from '../actions/statuses'; import { STATUS_EDIT } from '../actions/statuses';
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
import uuid from '../utils/uuid'; import uuid from '../utils/uuid';
import { me } from '../initial_state'; import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
id: null,
mounted: 0, mounted: 0,
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
@ -55,6 +58,7 @@ const initialState = ImmutableMap({
caretPosition: null, caretPosition: null,
preselectDate: null, preselectDate: null,
in_reply_to: null, in_reply_to: null,
quote_of_id: null,
is_composing: false, is_composing: false,
is_submitting: false, is_submitting: false,
is_changing_upload: false, is_changing_upload: false,
@ -69,6 +73,7 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)), resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null, idempotencyKey: null,
tagHistory: ImmutableList(), tagHistory: ImmutableList(),
scheduled_at: null,
}); });
const initialPoll = ImmutableMap({ const initialPoll = ImmutableMap({
@ -89,17 +94,20 @@ function statusToTextMentions(state, status) {
function clearAll(state) { function clearAll(state) {
return state.withMutations(map => { return state.withMutations(map => {
map.set('id', null);
map.set('text', ''); map.set('text', '');
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
map.set('is_submitting', false); map.set('is_submitting', false);
map.set('is_changing_upload', false); map.set('is_changing_upload', false);
map.set('in_reply_to', null); map.set('in_reply_to', null);
map.set('quote_of_id', null);
map.set('privacy', state.get('default_privacy')); map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false); map.set('sensitive', false);
map.update('media_attachments', list => list.clear()); map.update('media_attachments', list => list.clear());
map.set('poll', null); map.set('poll', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('scheduled_at', null);
}); });
}; };
@ -189,7 +197,10 @@ const expandMentions = status => {
const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement; const fragment = domParser.parseFromString(status.get('content'), 'text/html').documentElement;
status.get('mentions').forEach(mention => { status.get('mentions').forEach(mention => {
fragment.querySelector(`a[href="/${mention.get('acct')}"]`).textContent = `@${mention.get('acct')}`; const mentionFragment = fragment.querySelector(`a[href$="/${mention.get('acct')}"]`);
if (mentionFragment) {
fragment.querySelector(`a[href$="/${mention.get('acct')}"]`).textContent = `@${mention.get('acct')}`;
}
}); });
return fragment.innerHTML; return fragment.innerHTML;
@ -247,6 +258,24 @@ export default function compose(state = initialState, action) {
map.set('preselectDate', new Date()); map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);
map.set('spoiler_text', action.status.get('spoiler_text'));
} else {
map.set('spoiler', false);
map.set('spoiler_text', '');
}
});
case COMPOSE_QUOTE:
return state.withMutations(map => {
map.set('quote_of_id', action.status.get('id'));
map.set('text', '');
map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('preselectDate', new Date());
map.set('idempotencyKey', uuid());
if (action.status.get('spoiler_text').length > 0) { if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true); map.set('spoiler', true);
map.set('spoiler_text', action.status.get('spoiler_text')); map.set('spoiler_text', action.status.get('spoiler_text'));
@ -258,6 +287,8 @@ export default function compose(state = initialState, action) {
case COMPOSE_REPLY_CANCEL: case COMPOSE_REPLY_CANCEL:
case COMPOSE_RESET: case COMPOSE_RESET:
return state.withMutations(map => { return state.withMutations(map => {
map.set('id', null);
map.set('quote_of_id', null);
map.set('in_reply_to', null); map.set('in_reply_to', null);
map.set('text', ''); map.set('text', '');
map.set('spoiler', false); map.set('spoiler', false);
@ -265,6 +296,7 @@ export default function compose(state = initialState, action) {
map.set('privacy', state.get('default_privacy')); map.set('privacy', state.get('default_privacy'));
map.set('poll', null); map.set('poll', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('scheduled_at', null);
}); });
case COMPOSE_SUBMIT_REQUEST: case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true); return state.set('is_submitting', true);
@ -329,10 +361,12 @@ export default function compose(state = initialState, action) {
return item; return item;
})); }));
case REDRAFT: case STATUS_EDIT:
return state.withMutations(map => { return state.withMutations(map => {
map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('id', action.status.get('id'));
map.set('text', unescapeHTML(expandMentions(action.status)));
map.set('in_reply_to', action.status.get('in_reply_to_id')); map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('quote_of_id', action.status.get('quote_of_id'));
map.set('privacy', action.status.get('visibility')); map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments')); map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
@ -346,14 +380,6 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
} }
if (action.status.get('poll')) {
map.set('poll', ImmutableMap({
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
multiple: action.status.getIn(['poll', 'multiple']),
expires_in: 24 * 3600,
}));
}
}); });
case COMPOSE_POLL_ADD: case COMPOSE_POLL_ADD:
return state.set('poll', initialPoll); return state.set('poll', initialPoll);
@ -367,6 +393,8 @@ export default function compose(state = initialState, action) {
return state.updateIn(['poll', 'options'], options => options.delete(action.index)); return state.updateIn(['poll', 'options'], options => options.delete(action.index));
case COMPOSE_POLL_SETTINGS_CHANGE: case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); return state.update('poll', poll => poll.set('expires_in', action.expiresIn).set('multiple', action.isMultiple));
case COMPOSE_SCHEDULED_AT_CHANGE:
return state.set('scheduled_at', action.date);
default: default:
return state; return state;
} }

View File

@ -36,6 +36,8 @@ import groups from './groups';
import group_relationships from './group_relationships'; import group_relationships from './group_relationships';
import group_lists from './group_lists'; import group_lists from './group_lists';
import group_editor from './group_editor'; import group_editor from './group_editor';
import sidebar from './sidebar';
import status_revision_list from './status_revision_list';
const reducers = { const reducers = {
dropdown_menu, dropdown_menu,
@ -75,6 +77,8 @@ const reducers = {
group_relationships, group_relationships,
group_lists, group_lists,
group_editor, group_editor,
sidebar,
status_revision_list,
}; };
export default combineReducers(reducers); export default combineReducers(reducers);

View File

@ -1,4 +1,5 @@
import { import {
NOTIFICATIONS_INITIALIZE,
NOTIFICATIONS_UPDATE, NOTIFICATIONS_UPDATE,
NOTIFICATIONS_EXPAND_SUCCESS, NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST, NOTIFICATIONS_EXPAND_REQUEST,
@ -9,6 +10,7 @@ import {
NOTIFICATIONS_UPDATE_QUEUE, NOTIFICATIONS_UPDATE_QUEUE,
NOTIFICATIONS_DEQUEUE, NOTIFICATIONS_DEQUEUE,
MAX_QUEUED_NOTIFICATIONS, MAX_QUEUED_NOTIFICATIONS,
NOTIFICATIONS_MARK_READ,
} from '../actions/notifications'; } from '../actions/notifications';
import { import {
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
@ -17,6 +19,7 @@ import {
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../utils/compare_id'; import compareId from '../utils/compare_id';
import { unreadCount } from 'gabsocial/initial_state';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
@ -26,6 +29,7 @@ const initialState = ImmutableMap({
isLoading: false, isLoading: false,
queuedNotifications: ImmutableList(), //max = MAX_QUEUED_NOTIFICATIONS queuedNotifications: ImmutableList(), //max = MAX_QUEUED_NOTIFICATIONS
totalQueuedNotificationsCount: 0, //used for queuedItems overflow for MAX_QUEUED_NOTIFICATIONS+ totalQueuedNotificationsCount: 0, //used for queuedItems overflow for MAX_QUEUED_NOTIFICATIONS+
lastRead: -1,
}); });
const notificationToMap = notification => ImmutableMap({ const notificationToMap = notification => ImmutableMap({
@ -126,6 +130,10 @@ const updateNotificationsQueue = (state, notification, intlMessages, intlLocale)
export default function notifications(state = initialState, action) { export default function notifications(state = initialState, action) {
switch(action.type) { switch(action.type) {
case NOTIFICATIONS_INITIALIZE:
return state.set('unread', unreadCount);
case NOTIFICATIONS_MARK_READ:
return state.set('lastRead', action.notification);
case NOTIFICATIONS_EXPAND_REQUEST: case NOTIFICATIONS_EXPAND_REQUEST:
return state.set('isLoading', true); return state.set('isLoading', true);
case NOTIFICATIONS_EXPAND_FAIL: case NOTIFICATIONS_EXPAND_FAIL:

View File

@ -43,6 +43,7 @@ export default function search(state = initialState, action) {
accounts: ImmutableList(action.results.accounts.map(item => item.id)), accounts: ImmutableList(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)), statuses: ImmutableList(action.results.statuses.map(item => item.id)),
hashtags: fromJS(action.results.hashtags), hashtags: fromJS(action.results.hashtags),
groups: fromJS(action.results.groups),
})).set('submitted', true); })).set('submitted', true);
default: default:
return state; return state;

View File

@ -24,6 +24,12 @@ const initialState = ImmutableMap({
}), }),
}), }),
group: ImmutableMap({
shows: ImmutableMap({
reply: true,
}),
}),
notifications: ImmutableMap({ notifications: ImmutableMap({
alerts: ImmutableMap({ alerts: ImmutableMap({
follow: true, follow: true,
@ -57,6 +63,10 @@ const initialState = ImmutableMap({
}), }),
community: ImmutableMap({ community: ImmutableMap({
other: ImmutableMap({
allFediverse: false,
onlyMedia: false,
}),
regex: ImmutableMap({ regex: ImmutableMap({
body: '', body: '',
}), }),

View File

@ -0,0 +1,12 @@
import { SIDEBAR_OPEN, SIDEBAR_CLOSE } from '../actions/sidebar';
export default function sidebar(state={}, action) {
switch(action.type) {
case SIDEBAR_OPEN:
return { sidebarOpen: true };
case SIDEBAR_CLOSE:
return { sidebarOpen: false };
default:
return state;
}
};

View File

@ -0,0 +1,31 @@
import { Map as ImmutableMap } from 'immutable';
import {
STATUS_REVISION_LIST_LOAD,
STATUS_REVISION_LIST_LOAD_SUCCESS,
STATUS_REVISION_LIST_LOAD_FAIL
} from '../actions/status_revision_list';
const initialState = ImmutableMap({
loading: false,
error: null,
data: null
});
export default function statusRevisionList(state = initialState, action) {
switch(action.type) {
case STATUS_REVISION_LIST_LOAD:
return initialState;
case STATUS_REVISION_LIST_LOAD_SUCCESS:
return state.withMutations(mutable => {
mutable.set('loading', false);
mutable.set('data', action.payload);
});
case STATUS_REVISION_LIST_LOAD_FAIL:
return state.withMutations(mutable => {
mutable.set('loading', false);
mutable.set('error', action.payload);
});
default:
return state;
}
};

View File

@ -0,0 +1,17 @@
export const minimumAspectRatio = .8;
export const maximumAspectRatio = 2.8;
export const isPanoramic = ar => {
if (isNaN(ar)) return false;
return ar >= maximumAspectRatio;
}
export const isPortrait = ar => {
if (isNaN(ar)) return false;
return ar <= minimumAspectRatio;
}
export const isNonConformingRatio = ar => {
if (isNaN(ar)) return false;
return !isPanoramic(ar) && !isPortrait(ar);
}

View File

@ -25,8 +25,12 @@
@import 'gabsocial/components/compose-form'; @import 'gabsocial/components/compose-form';
@import 'gabsocial/components/group-card'; @import 'gabsocial/components/group-card';
@import 'gabsocial/components/group-detail'; @import 'gabsocial/components/group-detail';
@import 'gabsocial/components/group-accounts';
@import 'gabsocial/components/group-form'; @import 'gabsocial/components/group-form';
@import 'gabsocial/components/group-sidebar-panel'; @import 'gabsocial/components/group-sidebar-panel';
@import 'gabsocial/components/sidebar-menu';
@import 'gabsocial/components/status-revisions';
@import 'gabsocial/components/footer-bar';
@import 'gabsocial/polls'; @import 'gabsocial/polls';
@import 'gabsocial/emoji_picker'; @import 'gabsocial/emoji_picker';

View File

@ -0,0 +1,45 @@
.footer-bar {
display: block;
position: fixed;
background: #000;
height: 58px;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
transition: transform 0.2s ease;
overflow: hidden;
padding-bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
@media screen and (min-width: 895px) {
display: none !important;
}
&__container {
display: flex;
flex-direction: row;
align-items: stretch;
}
&__link {
display: flex;
flex: 1 1 auto;
margin: 0;
min-width: 36px;
height: 58px;
padding-top: 4px;
justify-content: center;
color: white;
text-decoration: none;
border-top: 2px solid transparent;
&.active {
border-top-color: $gab-brand-default;
}
& > span {
display: none;
}
}
}

View File

@ -0,0 +1,10 @@
.group-account-wrapper {
position: relative;
& > div > .icon-button {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
}
}

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