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
225 changed files with 5598 additions and 2652 deletions

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;
@@ -521,4 +525,43 @@ export function groupRemoveStatusFail(groupId, id, error) {
id,
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 {
type: NOTIFICATIONS_SCROLL_TOP,
top,
};
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,
status,
raw_text,
export function editStatus(status) {
return dispatch => {
dispatch({
type: STATUS_EDIT,
status,
});
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));
});
@@ -272,7 +270,7 @@ export function muteStatusFail(id, error) {
export function unmuteStatus(id) {
return (dispatch, getState) => {
if (!me) return;
dispatch(unmuteStatusRequest(id));
api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => {

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 top = 'auto';
let left = 'auto';
let width = 100;
let height = '100%';
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = '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)}
disabled={isLoading}
maxId={i > 0 ? statusIds.get(i - 1) : null}
onClick={onLoadMore}
/>
);
}
return (
let scrollableContent = (isLoading || statusIds.size > 0) ? (
statusIds.map((statusId, index) => statusId === null ? (
<LoadGap
key={'gap:' + statusIds.get(index + 1)}
disabled={isLoading}
maxId={index > 0 ? statusIds.get(index - 1) : null}
onClick={onLoadMore}
/>
) : (
<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

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

View File

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

View File

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

View File

@@ -1,11 +1,24 @@
import ImmutablePropTypes from 'react-immutable-proptypes';
import 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,9 +69,15 @@ 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 { id } = this.props.params;
const { columnId, group, relationships, account, intl } = this.props;
const { collapsed } = this.state;
const { id } = this.props.params;
if (typeof group === 'undefined' || !relationships) {
return (<ColumnIndicator type='loading' />);
@@ -69,18 +92,44 @@ class GroupTimeline extends ImmutablePureComponent {
<TimelineComposeBlock size={46} group={group} shouldCondense={true} autoFocus={false} />
}
<div className='group__feed'>
<StatusListContainer
scrollKey={`group_timeline-${columnId}`}
timelineId={`group:${id}`}
onLoadMore={this.handleLoadMore}
group={group}
withGroupAdmin={relationships && relationships.get('admin')}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group post new statuses, they will appear here.' />}
/>
</div>
</div>
);
<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}
group={group}
withGroupAdmin={relationships && relationships.get('admin')}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='There is nothing in this group yet. When members of this group post new statuses, they will appear here.' />}
/>
</div>
</div>
);
}
}

View File

@@ -59,16 +59,12 @@ export default class Card extends ImmutablePureComponent {
static propTypes = {
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 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 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 = (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 embed = '';
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,16 +192,21 @@ export default class DetailedStatus extends ImmutablePureComponent {
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</NavLink>
{status.get('group') && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
</div>
)}
{(status.get('group') || status.get('revised_at') !== null) && (
<div className='status__meta'>
{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>
)}
<StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
{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%);
}
}

View File

@@ -0,0 +1,174 @@
.sidebar-menu {
display: flex;
position: fixed;
flex-direction: column;
width: 275px;
height: 100vh;
top: 0;
bottom: 0;
left: 0;
background: $gab-background-container;
transform: translateX(-275px);
transition: all 0.15s linear;
z-index: 10001;
body.theme-gabsocial-light & {
background: $gab-background-container-light;
}
&__root {
display: none;
}
&__wrapper {
display: block;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
background-color: transparent;
transition: background-color 0.2s linear;
transition-delay: 0.1s;
}
&__content {
display: flex;
flex: 1 1;
flex-direction: column;
padding-bottom: 40px;
overflow: hidden;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
}
&__section {
display: block;
margin: 4px 0;
border-top: 1px solid lighten($ui-base-color, 4%);
&--borderless {
margin: 0;
border-top: none;
}
}
@media (max-width: 400px) {
width: 90vw;
}
}
.sidebar-menu__root--visible {
display: block;
.sidebar-menu {
transform: translateX(0);
}
.sidebar-menu__wrapper {
background-color: rgba(0,0,0,0.3);
}
}
.sidebar-menu-header {
display: flex;
height: 46px;
padding: 12px 14px;
border-bottom: 1px solid lighten($ui-base-color, 4%);
box-sizing: border-box;
align-items: center;
&__title {
display: block;
font-size: 18px;
font-weight: 600;
color: $primary-text-color;
}
&__btn {
margin-left: auto;
}
}
.sidebar-menu-profile {
display: block;
padding: 14px 18px;
&__avatar {
display: block;
width: 56px;
height: 56px;
}
&__name {
display: block;
margin-top: 10px;
.display-name__account {
display: block;
margin-top: 2px;
}
}
&__stats {
display: flex;
margin-top: 12px;
}
}
.sidebar-menu-profile-stat {
display: flex;
font-size: 14px;
text-decoration: none;
&:not(:first-of-type) {
margin-left: 18px;
}
&__value {
display: flex;
margin-right: 3px;
font-weight: 700;
color: $primary-text-color;
}
&__label {
display: flex;
color: $primary-text-color;
}
&:hover {
text-decoration: underline;
}
}
.sidebar-menu-item {
display: flex;
padding: 16px 18px;
cursor: pointer;
text-decoration: none;
color: $primary-text-color;
font-size: 15px;
font-weight: 400;
height: 50px;
box-sizing: border-box;
&:hover {
background-color: rgba($gab-brand-default, 0.1);
.fa {
color: $primary-text-color;
}
}
.fa {
margin-right: 10px;
}
&:hover {
&__title {
color: $primary-text-color;
}
}
}

View File

@@ -0,0 +1,82 @@
.status-revisions-root {
@media screen and (max-width: 960px) {
width: 100%;
}
}
.status-revisions {
padding: 8px 0 0;
overflow: hidden;
background-color: $classic-base-color;
border-radius: 6px;
@media screen and (max-width: 960px) {
height: 90vh;
}
&__header {
display: block;
position: relative;
border-bottom: 1px solid lighten($classic-base-color, 8%);
border-radius: 6px 6px 0 0;
padding-top: 12px;
padding-bottom: 12px;
&__title {
display: block;
width: 80%;
margin: 0 auto;
font-size: 18px;
font-weight: bold;
line-height: 24px;
color: $primary-text-color;
text-align: center;
}
}
&__close {
position: absolute;
right: 10px;
top: 10px;
}
&__content {
display: flex;
flex-direction: row;
width: 500px;
flex-direction: column;
overflow: hidden;
overflow-y: scroll;
height: calc(100% - 80px);
-webkit-overflow-scrolling: touch;
widows: 90%;
}
@media screen and (max-width: 960px) {
width: 100%;
}
&-list {
width: 100%;
&__error {
padding: 15px;
text-align: center;
font-weight: bold;
}
&__item {
padding: 15px;
border-bottom: 1px solid lighten($classic-base-color, 8%);
&__timestamp {
opacity: 0.5;
font-size: 13px;
}
&__text {
font-size: 15px;
}
}
}
}

View File

@@ -326,6 +326,9 @@
}
@media screen and (max-width: 550px) {
font-size: 12px;
padding: 12px 10px;
&.optional {
display: none;
}