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
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. Statuses were renamed from 'toots' to 'gabs'
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. 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
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_PUB_KEY`: Public key that is used when creating an access token or pairing https://btcpay.xxx.com/stores/yyy/Tokens/Create
- `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.[yourdomain].com/stores/[yourstore]/Tokens/Create
- `BTCPAY_MERCHANT_TOKEN`: Token created for facade *merchant*
## Deployment

View File

@ -2,7 +2,7 @@
module Admin
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_local_account!, only: [:enable, :memorialize, :approve, :reject]
@ -173,6 +173,22 @@ module Admin
redirect_to edit_pro_admin_account_path(@account.id)
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
def set_account
@ -211,5 +227,14 @@ module Admin
def pro_params
params.require(:account).permit(:is_pro, :pro_expires_at)
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

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
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
def check_account_suspension

View File

@ -20,13 +20,19 @@ class Api::V1::Groups::AccountsController < Api::BaseController
authorize @group, :join?
@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
end
def update
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
end
@ -34,6 +40,11 @@ class Api::V1::Groups::AccountsController < Api::BaseController
authorize @group, :leave?
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
end

View File

@ -12,7 +12,7 @@ class Api::V1::GroupsController < Api::BaseController
def index
case current_tab
when 'featured'
@groups = Group.where(is_featured: true).limit(25).all
@groups = Group.where(is_featured: true).limit(50).all
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
when 'admin'
@ -33,6 +33,8 @@ class Api::V1::GroupsController < Api::BaseController
end
def create
authorize :group, :create?
@group = Group.create!(group_params.merge(account: current_account))
render json: @group, serializer: REST::GroupSerializer
end

View File

@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController
respond_to :json
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
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
render json: file_type_error, status: 422

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@ class Api::V1::Timelines::GroupController < Api::BaseController
statuses = group_timeline_statuses.without_replies.paginate_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
).reject { |status| FeedManager.instance.filter?(:home, status, current_account.id) }
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.

View File

@ -4,6 +4,7 @@ class HomeController < ApplicationController
before_action :authenticate_user!
before_action :set_referrer_policy_header
before_action :set_initial_state_json
before_action :set_data_for_meta
def index
@body_classes = 'app-body'
@ -11,17 +12,40 @@ class HomeController < ApplicationController
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!
return if user_signed_in?
# 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 matches
if find_route_matches
redirect_to(homepage_path)
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
serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
@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
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)
)
end

View File

@ -9,6 +9,7 @@ class Settings::PreferencesController < Settings::BaseController
def update
user_settings.update(user_settings_params.to_h)
current_user.force_regeneration!
if current_user.update(user_params)
I18n.locale = current_user.locale
@ -51,7 +52,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_show_application,
:setting_advanced_layout,
: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)
)
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 { CancelToken, isCancel } from 'axios';
import { throttle } from 'lodash';
import moment from 'moment';
import { search as emojiSearch } from '../components/emoji/emoji_mart_search_light';
import { tagHistory } from '../settings';
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_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_QUOTE = 'COMPOSE_QUOTE';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_DIRECT = 'COMPOSE_DIRECT';
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_SETTINGS_CHANGE = 'COMPOSE_POLL_SETTINGS_CHANGE';
export const COMPOSE_SCHEDULED_AT_CHANGE = 'COMPOSE_SCHEDULED_AT_CHANGE';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
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() {
return {
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) {
return function (dispatch, getState) {
if (!me) return;
@ -139,9 +193,20 @@ export function submitCompose(routerHistory, group) {
dispatch(submitComposeRequest());
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,
scheduled_at,
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')),
sensitive: getState().getIn(['compose', 'sensitive']),
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) {
routerHistory.push('/messages');
}
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');
}
handleComposeSubmit(dispatch, getState, response, status);
}).catch(function (error) {
dispatch(submitComposeFail(error));
});
@ -561,3 +600,10 @@ export function changePollSettings(expiresIn, 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_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) => {
if (!me) return;
@ -522,3 +526,42 @@ export function groupRemoveStatusFail(groupId, id, 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);
}
if (status.quote && status.quote.id) {
processStatus(status.quote);
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll));
}

View File

@ -43,13 +43,17 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.reblog = status.reblog.id;
}
if (status.quote && status.quote.id) {
normalStatus.quote = status.quote.id;
}
if (status.poll && status.poll.id) {
normalStatus.poll = status.poll.id;
}
// Only calculate these values when status first encountered
// 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.contentHtml = normalOldStatus.get('contentHtml');
normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');

View File

@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html';
import { getFilters, regexFromFilters } from '../selectors';
import { me } from 'gabsocial/initial_state';
export const NOTIFICATIONS_INITIALIZE = 'NOTIFICATIONS_INITIALIZE';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
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_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_MARK_READ = 'NOTIFICATIONS_MARK_READ';
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) {
return (dispatch, getState) => {
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
@ -134,6 +142,7 @@ export function dequeueNotifications() {
dispatch({
type: NOTIFICATIONS_DEQUEUE,
});
dispatch(markReadNotifications());
}
};
@ -225,10 +234,13 @@ export function clearNotifications() {
};
export function scrollTopNotifications(top) {
return {
return (dispatch, getState) => {
dispatch({
type: NOTIFICATIONS_SCROLL_TOP,
top,
};
});
dispatch(markReadNotifications());
}
};
export function setFilter (filterType) {
@ -242,3 +254,20 @@ export function setFilter (filterType) {
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: {
q: value,
resolve: true,
limit: 5,
},
}).then(response => {
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 { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus } from './importer';
import { ensureComposeIsVisible } from './compose';
import { openModal } from './modal';
import { me } from 'gabsocial/initial_state';
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_HIDE = 'STATUS_HIDE';
export const REDRAFT = 'REDRAFT';
export const STATUS_EDIT = 'STATUS_EDIT';
export function fetchStatusRequest(id, skipLoading) {
return {
@ -132,15 +132,18 @@ export function fetchStatusFail(id, error, skipLoading) {
};
};
export function redraft(status, raw_text) {
return {
type: REDRAFT,
export function editStatus(status) {
return dispatch => {
dispatch({
type: STATUS_EDIT,
status,
raw_text,
});
dispatch(openModal('COMPOSE'));
};
};
export function deleteStatus(id, routerHistory, withRedraft = false) {
export function deleteStatus(id, routerHistory) {
return (dispatch, getState) => {
if (!me) return;
@ -156,11 +159,6 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
evictStatus(id);
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
if (withRedraft) {
dispatch(redraft(status, response.data.text));
ensureComposeIsVisible(getState, routerHistory);
}
}).catch(error => {
dispatch(deleteStatusFail(id, error));
});

View File

@ -10,6 +10,7 @@ import { updateNotificationsQueue, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { fetchFilters } from './filters';
import { getLocale } from '../locales';
import { handleComposeSubmit } from './compose';
const { messages } = getLocale();
@ -61,3 +62,18 @@ export const connectHashtagStream = (id, tag, accept) => connectTimelineStream
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${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 (
<div className='extended-video-player'>
<video
playsInline
ref={this.setRef}
src={src}
autoPlay
role='button'
tabIndex='0'
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 { isIOS } from '../../utils/is_mobile';
import { autoPlayGif, displayMedia } from '../../initial_state';
import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio';
import './media_gallery.scss';
const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
warning: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' },
@ -26,6 +28,7 @@ class Item extends ImmutablePureComponent {
onClick: PropTypes.func.isRequired,
displayWidth: PropTypes.number,
visible: PropTypes.bool.isRequired,
dimensions: PropTypes.object,
};
static defaultProps = {
@ -51,9 +54,15 @@ class Item extends ImmutablePureComponent {
}
}
handleLoadedMetaData = (e) => {
if (!this.hoverToPlay()) {
e.target.play();
}
}
hoverToPlay () {
const { attachment } = this.props;
return !autoPlayGif && attachment.get('type') === 'gifv';
return autoPlayGif === false && attachment.get('type') === 'gifv';
}
handleClick = (e) => {
@ -105,43 +114,36 @@ class Item extends ImmutablePureComponent {
}
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 height = (size === 4 || (size === 3 && index > 0)) ? 50 : 100;
const ar = attachment.getIn(['meta', 'small', 'aspect']);
let width = 100;
let height = '100%';
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
let float = 'left';
let position = 'relative';
switch(size) {
case 2:
if (index === 0) right = '2px';
else left = '2px';
break;
case 3:
if (index === 0) right = '2px';
else if (index > 0) left = '2px';
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;
if (dimensions) {
width = dimensions.w;
height = dimensions.h;
top = dimensions.t || 'auto';
right = dimensions.r || 'auto';
bottom = dimensions.b || 'auto';
left = dimensions.l || 'auto';
float = dimensions.float || 'left';
position = dimensions.pos || 'relative';
}
let thumbnail = '';
if (attachment.get('type') === 'unknown') {
return (
<div className={classNames('media-item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
<a className='media-item__thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
<div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ position, float, left, top, right, bottom, height, width: `${width}%` }}>
<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' />
</a>
</div>
@ -182,7 +184,7 @@ class Item extends ImmutablePureComponent {
</a>
);
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
const autoPlay = !isIOS() && autoPlayGif !== false;
thumbnail = (
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
@ -195,9 +197,12 @@ class Item extends ImmutablePureComponent {
onClick={this.handleClick}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onLoadedMetadata={this.handleLoadedMetaData}
autoPlay={autoPlay}
type='video/mp4'
loop
muted
playsInline
/>
<span className='media-gallery__gifv__label'>GIF</span>
@ -206,7 +211,7 @@ class Item extends ImmutablePureComponent {
}
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 })} />
{visible && thumbnail}
</div>
@ -262,7 +267,7 @@ class MediaGallery extends PureComponent {
}
handleRef = (node) => {
if (node /*&& this.isStandaloneEligible()*/) {
if (node) {
// offsetWidth triggers a layout, so only calculate when we need to
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 () {
const { media, intl, sensitive, height, defaultWidth } = this.props;
const { visible } = this.state;
@ -286,24 +286,221 @@ class MediaGallery extends PureComponent {
let children, spoilerButton;
const style = {};
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
if (width) {
style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
const standard169 = width / (16 / 9);
const standard169_percent = 100 / (16 / 9);
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 {
style.height = height;
}
const size = media.take(4).size;
if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
} else {
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} />);
}
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}
dimensions={itemsDimensions[i]}
/>
));
if (visible) {
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' />;
}
const { icon = null, text, meta = null, active = false, href = '#' } = action;
const { icon = null, text, meta = null, active = false, href = '#', isLogout } = action;
return (
<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 />}
<div>
<div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>

View File

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

View File

@ -21,6 +21,7 @@ import {
EmbedModal,
ListEditor,
ListAdder,
StatusRevisionModal,
} from '../../features/ui/util/async-components';
const MODAL_COMPONENTS = {
@ -34,10 +35,12 @@ const MODAL_COMPONENTS = {
'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER':ListAdder,
'LIST_ADDER': ListAdder,
'HOTKEYS': () => Promise.resolve({ default: HotkeysModal }),
'STATUS_REVISION': StatusRevisionModal,
'COMPOSE': () => Promise.resolve({ default: ComposeModal }),
'UNAUTHORIZED': () => Promise.resolve({ default: UnauthorizedModal }),
'PRO_UPGRADE': () => Promise.resolve({ default: ProUpgradeModal }),
};
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 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 ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
@ -63,9 +77,12 @@ class Status extends ImmutablePureComponent {
account: ImmutablePropTypes.map,
onClick: PropTypes.func,
onReply: PropTypes.func,
onShowRevisions: PropTypes.func,
onQuote: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onEdit: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onPin: PropTypes.func,
@ -86,6 +103,8 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
group: ImmutablePropTypes.map,
promoted: PropTypes.bool,
onOpenProUpgradeModal: PropTypes.func,
};
// Avoid checking props that are functions (and whose equality will always
@ -248,11 +267,16 @@ class Status extends ImmutablePureComponent {
this.node = c;
};
render() {
handleOpenProUpgradeModal = () => {
this.props.onOpenProUpgradeModal();
}
render () {
let media = null;
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;
@ -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 = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'>
@ -341,6 +372,7 @@ class Status extends ImmutablePureComponent {
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
width={this.props.cachedMediaWidth}
height={110}
inline
@ -376,7 +408,6 @@ class Status extends ImmutablePureComponent {
<Card
onOpenMedia={this.props.onOpenMedia}
card={status.get('card')}
compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
/>
@ -446,10 +477,10 @@ class Status extends ImmutablePureComponent {
</NavLink>
</div>
{!group && status.get('group') && (
{((!group && status.get('group')) || status.get('revised_at') !== null) && (
<div className='status__meta'>
Posted in{' '}
<NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
{!group && status.get('group') && <React.Fragment>Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink></React.Fragment>}
{status.get('revised_at') !== null && <a onClick={() => other.onShowRevisions(status)}> Edited</a>}
</div>
)}
@ -464,9 +495,11 @@ class Status extends ImmutablePureComponent {
{media}
{showThread &&
status.get('in_reply_to_id') &&
status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
{status.get('quote') && <StatusQuote
id={status.get('quote')}
/>}
{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}>
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
</button>

View File

@ -12,18 +12,20 @@ import './status_action_bar.scss';
const messages = defineMessages({
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}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
share: { id: 'status.share', defaultMessage: 'Share' },
more: { id: 'status.more', defaultMessage: 'More' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
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' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
report: { id: 'status.report', defaultMessage: 'Report @{name}' },
@ -57,6 +59,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired,
onOpenUnauthorizedModal: PropTypes.func.isRequired,
onReply: PropTypes.func,
onQuote: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
@ -87,13 +90,12 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
handleShareClick = () => {
navigator.share({
text: this.props.status.get('search_index'),
url: this.props.status.get('url'),
}).catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
handleQuoteClick = () => {
if (me) {
this.props.onQuote(this.props.status, this.context.router.history);
} else {
this.props.onOpenUnauthorizedModal();
}
}
handleFavouriteClick = () => {
@ -116,8 +118,8 @@ class StatusActionBar extends ImmutablePureComponent {
this.props.onDelete(this.props.status, this.context.router.history);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true);
handleEditClick = () => {
this.props.onEdit(this.props.status);
}
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(messages.delete), action: this.handleDeleteClick });
menu.push({ text: formatMessage(messages.redraft), action: this.handleRedraftClick });
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
} else {
menu.push({ text: formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
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>}
</div>
<div className='status-action-bar-item'>
<IconButton
className='status-action-bar-item__btn star-icon'
active={status.get('favourited')}
pressed={status.get('favourited')}
title={formatMessage(messages.favourite)}
icon='star'
onClick={this.handleFavouriteClick}
/>
{favoriteCount !== 0 && <span className='status-action-bar-item__link'>{favoriteCount}</span>}
<div className='status__action-bar__counter'>
<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} />
</div>
<div className='status__action-bar__counter'>
<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} />
{favoriteCount !== 0 && <span className='detailed-status__link'>{favoriteCount}</span>}
</div>
{shareButton}
<div className='status-action-bar__dropdown'>
<DropdownMenuContainer

View File

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

View File

@ -8,7 +8,7 @@ import Icon from '../icon';
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 {
@ -44,7 +44,7 @@ export default class StatusContent extends ImmutablePureComponent {
}
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) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);

View File

@ -26,12 +26,24 @@ export default class StatusList extends ImmutablePureComponent {
withGroupAdmin: PropTypes.bool,
onScrollToTop: PropTypes.func,
onScroll: PropTypes.func,
promotion: PropTypes.object,
promotedStatus: ImmutablePropTypes.map,
fetchStatus: PropTypes.func,
};
componentDidMount() {
this.handleDequeueTimeline();
this.fetchPromotedStatus();
};
fetchPromotedStatus() {
const { promotion, promotedStatus, fetchStatus } = this.props;
if (promotion && !promotedStatus) {
fetchStatus(promotion.status_id);
}
}
getFeaturedStatusCount = () => {
return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
}
@ -84,30 +96,23 @@ export default class StatusList extends ImmutablePureComponent {
}
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) {
return ( <ColumnIndicator type='loading' /> );
}
let scrollableContent = null;
if (isLoading || statusIds.size > 0) {
scrollableContent = statusIds.map((statusId, i) => {
if (statusId === null) {
return (
<LoadMore
gap
key={'gap:' + statusIds.get(i + 1)}
let scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId, index) => statusId === null ? (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={i > 0 ? statusIds.get(i - 1) : null}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
);
}
return (
) : (
<React.Fragment key={statusId}>
<StatusContainer
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
@ -116,9 +121,17 @@ export default class StatusList extends ImmutablePureComponent {
withGroupAdmin={withGroupAdmin}
showThread
/>
);
});
}
{promotedStatus && index === promotion.position && (
<StatusContainer
id={promotion.status_id}
contextType={timelineId}
promoted
showThread
/>
)}
</React.Fragment>
))
) : null;
if (scrollableContent && featuredStatusIds) {
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 { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store';
import { connectUserStream } from '../actions/streaming';
import {
connectUserStream,
connectStatusUpdateStream,
} from '../actions/streaming';
import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import initialState from '../initial_state';
@ -68,6 +71,7 @@ export default class GabSocial extends PureComponent {
componentDidMount() {
this.disconnect = store.dispatch(connectUserStream());
store.dispatch(connectStatusUpdateStream());
}
componentWillUnmount () {

View File

@ -4,6 +4,7 @@ import {
replyCompose,
mentionCompose,
directCompose,
quoteCompose,
} from '../actions/compose';
import {
reblog,
@ -18,6 +19,7 @@ import {
muteStatus,
unmuteStatus,
deleteStatus,
editStatus,
hideStatus,
revealStatus,
} from '../actions/statuses';
@ -35,11 +37,11 @@ import {
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
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' },
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?' },
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' },
});
@ -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) {
if (status.get('reblogged')) {
dispatch(unreblog(status));
@ -86,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onShowRevisions (status) {
dispatch(openModal('STATUS_REVISION', { status }));
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
@ -109,18 +130,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onDelete (status, history, withRedraft = false) {
onDelete (status, history) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft));
dispatch(deleteStatus(status.get('id'), history));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history)),
}));
}
},
onEdit (status) {
dispatch(editStatus(status));
},
onDirect (account, router) {
dispatch(directCompose(account, router));
},
@ -183,6 +208,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(groupRemoveStatus(groupId, statusId));
},
onOpenProUpgradeModal() {
dispatch(openModal('PRO_UPGRADE'));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

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

View File

@ -5,9 +5,11 @@ import { autoPlayGif, displayMedia } from 'gabsocial/initial_state';
import classNames from 'classnames';
import { decode } from 'blurhash';
import { isIOS } from 'gabsocial/utils/is_mobile';
import conversations_list_container from '../../direct_timeline/containers/conversations_list_container';
import './media_item.scss';
export default class MediaItem extends ImmutablePureComponent {
static propTypes = {
@ -125,8 +127,10 @@ export default class MediaItem extends ImmutablePureComponent {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
autoPlay={autoPlay}
preload='auto'
loop
muted
playsInline
/>
<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.blocks), to: '/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({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick });
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 Icon from '../../../../components/icon';
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';
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;
@ -29,6 +33,7 @@ const messages = defineMessages({
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
publish: { id: 'compose_form.publish', defaultMessage: 'Gab' },
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
schedulePost: { id: 'compose_form.schedule_post', defaultMessage: 'Schedule Post' }
});
export default @injectIntl
@ -44,6 +49,7 @@ class ComposeForm extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
edit: PropTypes.bool.isRequired,
text: PropTypes.string.isRequired,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
@ -69,6 +75,8 @@ class ComposeForm extends ImmutablePureComponent {
autoFocus: PropTypes.bool,
group: ImmutablePropTypes.map,
isModalOpen: PropTypes.bool,
scheduledAt: PropTypes.instanceOf(Date),
setScheduledAt: PropTypes.func.isRequired,
};
static defaultProps = {
@ -93,12 +101,21 @@ class ComposeForm extends ImmutablePureComponent {
handleClick = (e) => {
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)) {
this.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({
composeFocused: false,
});
@ -198,7 +215,7 @@ class ComposeForm extends ImmutablePureComponent {
}
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 disabled = this.props.isSubmitting;
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);
}
if (scheduledAt) {
publishText = intl.formatMessage(messages.schedulePost);
}
const composeClassNames = classNames({
'compose-form': true,
'condensed': condensed,
@ -265,20 +286,25 @@ class ComposeForm extends ImmutablePureComponent {
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm />
<PollFormContainer />
<UploadFormContainer />
{!edit && <PollFormContainer />}
</div>
}
</AutosuggestTextbox>
{quoteOfId && <QuotedStatusPreviewContainer id={quoteOfId} />}
{
!condensed &&
<div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
<PollButtonContainer />
{!edit && <PollButtonContainer />}
<PrivacyDropdownContainer />
<SpoilerButtonContainer />
<SchedulePostDropdownContainer
position={isModalOpen ? 'top' : undefined}
/>
</div>
<CharacterCounter max={maxPostCharacterCount} text={text} />
</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 ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../../containers/account_container';
import StatusContainer from '../../../../containers/status_container';
import TrendingItem from '../../../../components/trending_item';
import Icon from '../../../../components/icon';
import { WhoToFollowPanel } from '../../../../components/panel';
import Hashtag from '../../../components/hashtag';
import Icon from 'gabsocial/components/icon';
import WhoToFollowPanel from '../../ui/components/who_to_follow_panel';
// import TrendsPanel from '../../ui/components/trends_panel';
import GroupListItem from 'gabsocial/components/group_list_item';
import './search_results.scss';
export default class SearchResults extends ImmutablePureComponent {
export default
@injectIntl
class SearchResults extends ImmutablePureComponent {
static propTypes = {
results: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
location: PropTypes.object,
};
state = {
@ -20,7 +21,7 @@ export default class SearchResults extends ImmutablePureComponent {
}
render () {
const { results } = this.props;
const { results, location } = this.props;
const { isSmallScreen } = this.state;
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;
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 = (
<div className='search-results__section'>
<h5>
<Icon id='users' fixedWidth />
<FormattedMessage id='search_results.accounts' defaultMessage='People' />
</h5>
{results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
<h5><Icon id='user' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
{results.get('accounts').slice(0, size).map(accountId => <AccountContainer key={accountId} id={accountId} />)}
</div>
);
}
if (results.get('statuses') && results.get('statuses').size > 0) {
count += results.get('statuses').size;
statuses = (
if (results.get('groups') && results.get('groups').size > 0 && (isTop || showGroups)) {
const size = isTop ? Math.min(results.get('groups').size, 5) : results.get('groups').size;
count += size;
groups = (
<div className='search-results__section'>
<h5>
<Icon id='quote-right' fixedWidth />
<FormattedMessage id='search_results.statuses' defaultMessage='Gabs' />
</h5>
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
<h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.groups' defaultMessage='Groups' /></h5>
{results.get('groups').slice(0, size).map(group => <GroupListItem key={`search-${group.get('name')}`} group={group} />)}
</div>
);
}
if (results.get('hashtags') && results.get('hashtags').size > 0) {
count += results.get('hashtags').size;
if (results.get('hashtags') && results.get('hashtags').size > 0 && (isTop || showHashtags)) {
const size = isTop ? Math.min(results.get('hashtags').size, 5) : results.get('hashtags').size;
count += size;
hashtags = (
<div className='search-results__section'>
<h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').map(hashtag => <TrendingItem key={hashtag.get('name')} hashtag={hashtag} />)}
{results.get('hashtags').slice(0, size).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>
);
}
@ -87,6 +90,7 @@ export default class SearchResults extends ImmutablePureComponent {
</div>
{accounts}
{groups}
{statuses}
{hashtags}
</div>

View File

@ -10,8 +10,7 @@ import './upload.scss';
const messages = defineMessages({
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
undo: { id: 'upload_form.undo', defaultMessage: 'Delete' },
focus: { id: 'upload_form.focus', defaultMessage: 'Crop' },
delete: { id: 'upload_form.undo', defaultMessage: 'Delete' },
});
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'>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => (
<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 })}>
<IconButton
onClick={this.handleUndoClick}
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 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 })}>
<button className='icon-button' title={intl.formatMessage(messages.delete)} onClick={this.handleUndoClick}><Icon id='times'/></button>
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
</div>
<div className={classNames('compose-form-upload__description', { active })}>

View File

@ -8,9 +8,11 @@ import {
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
changeScheduledAt,
} from '../../../actions/compose';
const mapStateToProps = state => ({
edit: state.getIn(['compose', 'id']) !== null,
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
@ -25,6 +27,8 @@ const mapStateToProps = state => ({
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isModalOpen: state.get('modal').modalType === 'COMPOSE',
quoteOfId: state.getIn(['compose', 'quote_of_id']),
scheduledAt: state.getIn(['compose', 'scheduled_at']),
});
const mapDispatchToProps = (dispatch) => ({
@ -61,6 +65,9 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(insertEmojiCompose(position, data, needsSpace));
},
setScheduledAt (date) {
dispatch(changeScheduledAt(date));
},
});
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 { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
import { withRouter } from 'react-router-dom';
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
@ -11,4 +12,4 @@ const mapDispatchToProps = dispatch => ({
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 GroupCard from './card';
import GroupCreate from '../create';
import { me } from 'gabsocial/initial_state';
import { openModal } from '../../../actions/modal';
const messages = defineMessages({
heading: { id: 'column.groups', defaultMessage: 'Groups' },
create: { id: 'groups.create', defaultMessage: 'Create group' },
tab_featured: { id: 'groups.tab_featured', defaultMessage: 'Featured' },
tab_member: { id: 'groups.tab_member', defaultMessage: 'Groups you\'re in' },
tab_admin: { id: 'groups.tab_admin', defaultMessage: 'Groups you manage' },
tab_member: { id: 'groups.tab_member', defaultMessage: 'Member' },
tab_admin: { id: 'groups.tab_admin', defaultMessage: 'Manage' },
});
const mapStateToProps = (state, { activeTab }) => ({
groupIds: state.getIn(['group_lists', activeTab]),
account: state.getIn(['accounts', me]),
});
export default @connect(mapStateToProps)
@ -42,12 +45,27 @@ class Groups extends ImmutablePureComponent {
}
}
handleOpenProUpgradeModal = () => {
this.props.dispatch(openModal('PRO_UPGRADE'));
}
renderHeader() {
const { intl, activeTab } = this.props;
const { intl, activeTab, account, onOpenProUpgradeModal } = this.props;
const isPro = account.get('is_pro');
return (
<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="column-header__wrapper">

View File

@ -5,14 +5,18 @@ import ColumnIndicator from '../../../components/column_indicator';
import {
fetchMembers,
expandMembers,
updateRole,
createRemovedAccount,
} from '../../../actions/groups';
import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import Column from '../../../components/column';
import ScrollableList from '../../../components/scrollable_list';
import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
const mapStateToProps = (state, { params: { id } }) => ({
group: state.getIn(['groups', id]),
relationships: state.getIn(['group_relationships', id]),
accountIds: state.getIn(['user_lists', 'groups', id, 'items']),
hasMore: !!state.getIn(['user_lists', 'groups', id, 'next']),
});
@ -44,10 +48,14 @@ class GroupMembers extends ImmutablePureComponent {
}, 300, { leading: true });
render () {
const { accountIds, hasMore, group } = this.props;
const { accountIds, hasMore, group, relationships, dispatch } = this.props;
if (!group || !accountIds) {
return (<ColumnIndicator type='loading' />);
if (!group || !accountIds || !relationships) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
@ -58,7 +66,23 @@ class GroupMembers extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
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 => {
let menu = [];
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 ImmutablePureComponent from 'react-immutable-pure-component';
import StatusListContainer from '../../../containers/status_list_container';
import { FormattedMessage, injectIntl } from 'react-intl';
import StatusListContainer from '../../ui/containers/status_list_container';
import Column from '../../../components/column';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { connectGroupStream } from '../../../actions/streaming';
import { expandGroupTimeline } from '../../../actions/timelines';
import ColumnIndicator from '../../../components/column_indicator';
import TimelineComposeBlock from '../../../components/timeline_compose_block';
import MissingIndicator from '../../../components/missing_indicator';
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) => ({
group: state.getIn(['groups', props.params.id]),
@ -31,6 +44,10 @@ class GroupTimeline extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
};
state = {
collapsed: true,
}
componentDidMount () {
const { dispatch } = this.props;
const { id } = this.props.params;
@ -52,8 +69,14 @@ class GroupTimeline extends ImmutablePureComponent {
this.props.dispatch(expandGroupTimeline(id, { maxId }));
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed });
}
render () {
const { columnId, group, relationships } = this.props;
const { columnId, group, relationships, account, intl } = this.props;
const { collapsed } = this.state;
const { id } = this.props.params;
if (typeof group === 'undefined' || !relationships) {
@ -70,7 +93,33 @@ class GroupTimeline extends ImmutablePureComponent {
}
<div className='group__feed'>
<div className="column-header__wrapper">
<h1 className="column-header">
<Link to={`/groups/${id}`} className={classNames('btn grouped active')}>
{intl.formatMessage(messages.tabLatest)}
</Link>
<div className='column-header__buttons'>
<button
className={classNames('column-header__button', { 'active': !collapsed })}
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}

View File

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

View File

@ -1,3 +1,4 @@
import StatusQuote from '../../../components/status_quote';
import { Link, NavLink } from 'react-router-dom';
import { FormattedDate, FormattedNumber } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -32,12 +33,17 @@ export default class DetailedStatus extends ImmutablePureComponent {
compact: PropTypes.bool,
showMedia: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func,
onShowRevisions: PropTypes.func,
};
state = {
height: null,
};
handleShowRevisions = () => {
this.props.onShowRevisions(this.props.status);
}
handleOpenVideo = (media, startTime) => {
this.props.onOpenVideo(media, startTime);
}
@ -110,6 +116,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
aspectRatio={video.getIn(['meta', 'small', 'aspect'])}
width={300}
height={150}
inline
@ -185,9 +192,10 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</NavLink>
{status.get('group') && (
{(status.get('group') || status.get('revised_at') !== null) && (
<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>}
{status.get('revised_at') !== null && <a onClick={this.handleShowRevisions}> Edited</a>}
</div>
)}
@ -195,6 +203,10 @@ export default class DetailedStatus extends ImmutablePureComponent {
{media}
{status.get('quote') && <StatusQuote
id={status.get('quote')}
/>}
<div className='detailed-status__meta'>
<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' />

View File

@ -10,13 +10,16 @@ import './detailed_status_action_bar.scss';
const messages = defineMessages({
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}' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
quote: { id: 'status.quote', defaultMessage: 'Quote' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' },
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' },
mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
@ -50,6 +53,7 @@ class ActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired,
onReply: PropTypes.func.isRequired,
onReblog: PropTypes.func.isRequired,
onQuote: PropTypes.func.isRequired,
onFavourite: PropTypes.func.isRequired,
onDelete: 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 = () => {
if (me) {
this.props.onFavourite(this.props.status);
@ -91,8 +103,8 @@ class ActionBar extends ImmutablePureComponent {
this.props.onDelete(this.props.status, this.context.router.history);
}
handleRedraftClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true);
handleEditClick = () => {
this.props.onEdit(this.props.status);
}
handleMentionClick = () => {
@ -176,7 +188,7 @@ class ActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null);
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 {
menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
menu.push(null);
@ -208,17 +220,11 @@ class ActionBar extends ImmutablePureComponent {
let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
return (
<div className='detailed-status-action-bar'>
<div className='detailed-status-action-bar__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>
<div className='detailed-status-action-bar__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 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>
<div className='detailed-status__action-bar'>
<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>
<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 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__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}
<div className='detailed-status-action-bar__dropdown'>

View File

@ -31,8 +31,6 @@ import { showAlertForError } from '../../../actions/alerts';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
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' },
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?' },
@ -106,14 +104,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}));
},
onDelete (status, history, withRedraft = false) {
onDelete (status, history) {
if (!deleteModal) {
dispatch(deleteStatus(status.get('id'), history, withRedraft));
dispatch(deleteStatus(status.get('id'), history));
} else {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
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');
}
export function StatusRevisionModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/status_revision_modal');
}
export function ReportModal () {
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 isStaff = getMeta('is_staff');
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;

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.hashtag": "etiqueta",
"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_results.accounts": "Xente",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"search_results.hashtags": "Hashtags",

View File

@ -1026,7 +1026,7 @@
"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"
},
{

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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "tagar",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"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.hashtag": "แฮชแท็ก",
"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_results.accounts": "ผู้คน",
"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.hashtag": "hashtag",
"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_results.accounts": "İnsanlar",
"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.hashtag": "hashtag",
"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_results.accounts": "People",
"search_results.hashtags": "Hashtags",

View File

@ -4,6 +4,7 @@ import {
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_QUOTE,
COMPOSE_DIRECT,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
@ -35,16 +36,18 @@ import {
COMPOSE_POLL_OPTION_CHANGE,
COMPOSE_POLL_OPTION_REMOVE,
COMPOSE_POLL_SETTINGS_CHANGE,
COMPOSE_SCHEDULED_AT_CHANGE,
} from '../actions/compose';
import { TIMELINE_DELETE } from '../actions/timelines';
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 uuid from '../utils/uuid';
import { me } from '../initial_state';
import { unescapeHTML } from '../utils/html';
const initialState = ImmutableMap({
id: null,
mounted: 0,
sensitive: false,
spoiler: false,
@ -55,6 +58,7 @@ const initialState = ImmutableMap({
caretPosition: null,
preselectDate: null,
in_reply_to: null,
quote_of_id: null,
is_composing: false,
is_submitting: false,
is_changing_upload: false,
@ -69,6 +73,7 @@ const initialState = ImmutableMap({
resetFileKey: Math.floor((Math.random() * 0x10000)),
idempotencyKey: null,
tagHistory: ImmutableList(),
scheduled_at: null,
});
const initialPoll = ImmutableMap({
@ -89,17 +94,20 @@ function statusToTextMentions(state, status) {
function clearAll(state) {
return state.withMutations(map => {
map.set('id', null);
map.set('text', '');
map.set('spoiler', false);
map.set('spoiler_text', '');
map.set('is_submitting', false);
map.set('is_changing_upload', false);
map.set('in_reply_to', null);
map.set('quote_of_id', null);
map.set('privacy', state.get('default_privacy'));
map.set('sensitive', false);
map.update('media_attachments', list => list.clear());
map.set('poll', null);
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;
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;
@ -247,6 +258,24 @@ export default function compose(state = initialState, action) {
map.set('preselectDate', new Date());
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) {
map.set('spoiler', true);
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_RESET:
return state.withMutations(map => {
map.set('id', null);
map.set('quote_of_id', null);
map.set('in_reply_to', null);
map.set('text', '');
map.set('spoiler', false);
@ -265,6 +296,7 @@ export default function compose(state = initialState, action) {
map.set('privacy', state.get('default_privacy'));
map.set('poll', null);
map.set('idempotencyKey', uuid());
map.set('scheduled_at', null);
});
case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true);
@ -329,10 +361,12 @@ export default function compose(state = initialState, action) {
return item;
}));
case REDRAFT:
case STATUS_EDIT:
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('quote_of_id', action.status.get('quote_of_id'));
map.set('privacy', action.status.get('visibility'));
map.set('media_attachments', action.status.get('media_attachments'));
map.set('focusDate', new Date());
@ -346,14 +380,6 @@ export default function compose(state = initialState, action) {
map.set('spoiler', false);
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:
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));
case COMPOSE_POLL_SETTINGS_CHANGE:
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:
return state;
}

View File

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

View File

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

View File

@ -24,6 +24,12 @@ const initialState = ImmutableMap({
}),
}),
group: ImmutableMap({
shows: ImmutableMap({
reply: true,
}),
}),
notifications: ImmutableMap({
alerts: ImmutableMap({
follow: true,
@ -57,6 +63,10 @@ const initialState = ImmutableMap({
}),
community: ImmutableMap({
other: ImmutableMap({
allFediverse: false,
onlyMedia: false,
}),
regex: ImmutableMap({
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/group-card';
@import 'gabsocial/components/group-detail';
@import 'gabsocial/components/group-accounts';
@import 'gabsocial/components/group-form';
@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/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