diff options
Diffstat (limited to 'app/javascript/flavours/glitch')
85 files changed, 1720 insertions, 135 deletions
diff --git a/app/javascript/flavours/glitch/actions/announcements.js b/app/javascript/flavours/glitch/actions/announcements.js new file mode 100644 index 000000000..871409d43 --- /dev/null +++ b/app/javascript/flavours/glitch/actions/announcements.js @@ -0,0 +1,180 @@ +import api from 'flavours/glitch/util/api'; +import { normalizeAnnouncement } from './importer/normalizer'; + +export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST'; +export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS'; +export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL'; +export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; +export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; + +export const ANNOUNCEMENTS_DISMISS_REQUEST = 'ANNOUNCEMENTS_DISMISS_REQUEST'; +export const ANNOUNCEMENTS_DISMISS_SUCCESS = 'ANNOUNCEMENTS_DISMISS_SUCCESS'; +export const ANNOUNCEMENTS_DISMISS_FAIL = 'ANNOUNCEMENTS_DISMISS_FAIL'; + +export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST'; +export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; + +export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST'; +export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS'; +export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL'; + +export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE'; + +export const ANNOUNCEMENTS_TOGGLE_SHOW = 'ANNOUNCEMENTS_TOGGLE_SHOW'; + +const noOp = () => {}; + +export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => { + dispatch(fetchAnnouncementsRequest()); + + api(getState).get('/api/v1/announcements').then(response => { + dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x)))); + }).catch(error => { + dispatch(fetchAnnouncementsFail(error)); + }).finally(() => { + done(); + }); +}; + +export const fetchAnnouncementsRequest = () => ({ + type: ANNOUNCEMENTS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchAnnouncementsSuccess = announcements => ({ + type: ANNOUNCEMENTS_FETCH_SUCCESS, + announcements, + skipLoading: true, +}); + +export const fetchAnnouncementsFail= error => ({ + type: ANNOUNCEMENTS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const updateAnnouncements = announcement => ({ + type: ANNOUNCEMENTS_UPDATE, + announcement: normalizeAnnouncement(announcement), +}); + +export const dismissAnnouncement = announcementId => (dispatch, getState) => { + dispatch(dismissAnnouncementRequest(announcementId)); + + api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`).then(() => { + dispatch(dismissAnnouncementSuccess(announcementId)); + }).catch(error => { + dispatch(dismissAnnouncementFail(announcementId, error)); + }); +}; + +export const dismissAnnouncementRequest = announcementId => ({ + type: ANNOUNCEMENTS_DISMISS_REQUEST, + id: announcementId, +}); + +export const dismissAnnouncementSuccess = announcementId => ({ + type: ANNOUNCEMENTS_DISMISS_SUCCESS, + id: announcementId, +}); + +export const dismissAnnouncementFail = (announcementId, error) => ({ + type: ANNOUNCEMENTS_DISMISS_FAIL, + id: announcementId, + error, +}); + +export const addReaction = (announcementId, name) => (dispatch, getState) => { + const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId); + + let alreadyAdded = false; + + if (announcement) { + const reaction = announcement.get('reactions').find(x => x.get('name') === name); + if (reaction && reaction.get('me')) { + alreadyAdded = true; + } + } + + if (!alreadyAdded) { + dispatch(addReactionRequest(announcementId, name, alreadyAdded)); + } + + api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(addReactionSuccess(announcementId, name, alreadyAdded)); + }).catch(err => { + if (!alreadyAdded) { + dispatch(addReactionFail(announcementId, name, err)); + } + }); +}; + +export const addReactionRequest = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionSuccess = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const addReactionFail = (announcementId, name, error) => ({ + type: ANNOUNCEMENTS_REACTION_ADD_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const removeReaction = (announcementId, name) => (dispatch, getState) => { + dispatch(removeReactionRequest(announcementId, name)); + + api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => { + dispatch(removeReactionSuccess(announcementId, name)); + }).catch(err => { + dispatch(removeReactionFail(announcementId, name, err)); + }); +}; + +export const removeReactionRequest = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionSuccess = (announcementId, name) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS, + id: announcementId, + name, + skipLoading: true, +}); + +export const removeReactionFail = (announcementId, name, error) => ({ + type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + id: announcementId, + name, + error, + skipLoading: true, +}); + +export const updateReaction = reaction => ({ + type: ANNOUNCEMENTS_REACTION_UPDATE, + reaction, +}); + +export const toggleShowAnnouncements = () => ({ + type: ANNOUNCEMENTS_TOGGLE_SHOW, +}); + +export const deleteAnnouncement = id => ({ + type: ANNOUNCEMENTS_DELETE, + id, +}); diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index f80642bd8..0be746048 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -91,9 +91,11 @@ export function cycleElefriendCompose() { export function replyCompose(status, routerHistory) { return (dispatch, getState) => { + const prependCWRe = getState().getIn(['local_settings', 'prepend_cw_re']); dispatch({ type: COMPOSE_REPLY, status: status, + prependCWRe: prependCWRe, }); ensureComposeIsVisible(getState, routerHistory); diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index 2bc603930..52ad17779 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js @@ -74,7 +74,6 @@ export function normalizeStatus(status, normalOldStatus) { export function normalizePoll(poll) { const normalPoll = { ...poll }; - const emojiMap = makeEmojiMap(normalPoll); normalPoll.options = poll.options.map((option, index) => ({ @@ -85,3 +84,12 @@ export function normalizePoll(poll) { return normalPoll; } + +export function normalizeAnnouncement(announcement) { + const normalAnnouncement = { ...announcement }; + const emojiMap = makeEmojiMap(normalAnnouncement); + + normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); + + return normalAnnouncement; +} diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index c3a5fe86f..7ffab404d 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -1,9 +1,15 @@ +import api from 'flavours/glitch/util/api'; + +export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; +export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; +export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL'; + export const submitMarkers = () => (dispatch, getState) => { const accessToken = getState().getIn(['meta', 'access_token'], ''); const params = {}; const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]); - const lastNotificationId = getState().getIn(['notifications', 'items', 0, 'id']); + const lastNotificationId = getState().getIn(['notifications', 'lastReadId']); if (lastHomeId) { params.home = { @@ -11,7 +17,7 @@ export const submitMarkers = () => (dispatch, getState) => { }; } - if (lastNotificationId) { + if (lastNotificationId && lastNotificationId !== '0') { params.notifications = { last_read_id: lastNotificationId, }; @@ -28,3 +34,39 @@ export const submitMarkers = () => (dispatch, getState) => { client.setRequestHeader('Authorization', `Bearer ${accessToken}`); client.send(JSON.stringify(params)); }; + +export const fetchMarkers = () => (dispatch, getState) => { + const params = { timeline: ['notifications'] }; + + dispatch(fetchMarkersRequest()); + + api(getState).get('/api/v1/markers', { params }).then(response => { + dispatch(fetchMarkersSuccess(response.data)); + }).catch(error => { + dispatch(fetchMarkersFail(error)); + }); +}; + +export function fetchMarkersRequest() { + return { + type: MARKERS_FETCH_REQUEST, + skipLoading: true, + }; +}; + +export function fetchMarkersSuccess(markers) { + return { + type: MARKERS_FETCH_SUCCESS, + markers, + skipLoading: true, + }; +}; + +export function fetchMarkersFail(error) { + return { + type: MARKERS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, + }; +}; diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 940f3c3d4..b3de7b5bf 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -168,9 +168,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) { dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems)); fetchRelatedRelationships(dispatch, response.data); - done(); }).catch(error => { dispatch(expandNotificationsFail(error, isLoadingMore)); + }).finally(() => { done(); }); }; @@ -199,6 +199,7 @@ export function expandNotificationsFail(error, isLoadingMore) { type: NOTIFICATIONS_EXPAND_FAIL, error, skipLoading: !isLoadingMore, + skipAlert: !isLoadingMore, }; }; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 21379f492..2f82ea805 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -8,6 +8,12 @@ import { } from './timelines'; import { updateNotifications, expandNotifications } from './notifications'; import { updateConversations } from './conversations'; +import { + fetchAnnouncements, + updateAnnouncements, + updateReaction as updateAnnouncementsReaction, + deleteAnnouncement, +} from './announcements'; import { fetchFilters } from './filters'; import { getLocale } from 'mastodon/locales'; @@ -44,6 +50,15 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, case 'filters_changed': dispatch(fetchFilters()); break; + case 'announcement': + dispatch(updateAnnouncements(JSON.parse(data.payload))); + break; + case 'announcement.reaction': + dispatch(updateAnnouncementsReaction(JSON.parse(data.payload))); + break; + case 'announcement.delete': + dispatch(deleteAnnouncement(data.payload)); + break; } }, }; @@ -51,7 +66,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null, } const refreshHomeTimelineAndNotification = (dispatch, done) => { - dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); + dispatch(expandHomeTimeline({}, () => + dispatch(expandNotifications({}, () => + dispatch(fetchAnnouncements(done)))))); }; export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index 097878c3b..2ef78025e 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -112,9 +112,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { dispatch(importFetchedStatuses(response.data)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); - done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); + }).finally(() => { done(); }); }; diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js new file mode 100644 index 000000000..e3235e368 --- /dev/null +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedNumber } from 'react-intl'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; +import { reduceMotion } from 'flavours/glitch/util/initial_state'; + +export default class AnimatedNumber extends React.PureComponent { + + static propTypes = { + value: PropTypes.number.isRequired, + }; + + state = { + direction: 1, + }; + + componentWillReceiveProps (nextProps) { + if (nextProps.value > this.props.value) { + this.setState({ direction: 1 }); + } else if (nextProps.value < this.props.value) { + this.setState({ direction: -1 }); + } + } + + willEnter = () => { + const { direction } = this.state; + + return { y: -1 * direction }; + } + + willLeave = () => { + const { direction } = this.state; + + return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) }; + } + + render () { + const { value } = this.props; + const { direction } = this.state; + + if (reduceMotion) { + return <FormattedNumber value={value} />; + } + + const styles = [{ + key: `${value}`, + data: value, + style: { y: spring(0, { damping: 35, stiffness: 400 }) }, + }]; + + return ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <span className='animated-number'> + {items.map(({ key, data, style }) => ( + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span> + ))} + </span> + )} + </TransitionMotion> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js index dd1162429..01bd4a246 100644 --- a/app/javascript/flavours/glitch/components/column_header.js +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -2,18 +2,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import ImmutablePropTypes from 'react-immutable-proptypes'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import Icon from 'flavours/glitch/components/icon'; -import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; - const messages = defineMessages({ show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' }, moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' }, - enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, }); export default @injectIntl @@ -28,26 +24,21 @@ class ColumnHeader extends React.PureComponent { title: PropTypes.node, icon: PropTypes.string, active: PropTypes.bool, - localSettings : ImmutablePropTypes.map, multiColumn: PropTypes.bool, extraButton: PropTypes.node, showBackButton: PropTypes.bool, - notifCleaning: PropTypes.bool, // true only for the notification column - notifCleaningActive: PropTypes.bool, - onEnterCleaningMode: PropTypes.func, children: PropTypes.node, pinned: PropTypes.bool, placeholder: PropTypes.bool, onPin: PropTypes.func, onMove: PropTypes.func, onClick: PropTypes.func, - intl: PropTypes.object.isRequired, + appendContent: PropTypes.node, }; state = { collapsed: true, animating: false, - animatingNCD: false, }; historyBack = (skip) => { @@ -89,10 +80,6 @@ class ColumnHeader extends React.PureComponent { this.setState({ animating: false }); } - handleTransitionEndNCD = () => { - this.setState({ animatingNCD: false }); - } - handlePin = () => { if (!this.props.pinned) { this.historyBack(); @@ -100,16 +87,9 @@ class ColumnHeader extends React.PureComponent { this.props.onPin(); } - onEnterCleaningMode = () => { - this.setState({ animatingNCD: true }); - this.props.onEnterCleaningMode(!this.props.notifCleaningActive); - } - render () { - const { intl, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, notifCleaning, notifCleaningActive, placeholder } = this.props; - const { collapsed, animating, animatingNCD } = this.state; - - let title = this.props.title; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { 'active': active, @@ -128,20 +108,8 @@ class ColumnHeader extends React.PureComponent { 'active': !collapsed, }); - const notifCleaningButtonClassName = classNames('column-header__button', { - 'active': notifCleaningActive, - }); - - const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { - 'collapsed': !notifCleaningActive, - 'animating': animatingNCD, - }); - let extraContent, pinButton, moveButtons, backButton, collapseButton; - //*glitch - const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); - if (children) { extraContent = ( <div key='extra-content' className='column-header__collapsible__extra'> @@ -202,33 +170,17 @@ class ColumnHeader extends React.PureComponent { <div className='column-header__buttons'> {hasTitle && backButton} {extraButton} - { notifCleaning ? ( - <button - aria-label={msgEnterNotifCleaning} - title={msgEnterNotifCleaning} - onClick={this.onEnterCleaningMode} - className={notifCleaningButtonClassName} - > - <Icon id='eraser' /> - </button> - ) : null} {collapseButton} </div> </h1> - { notifCleaning ? ( - <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> - <div className='column-header__collapsible-inner nopad-drawer'> - {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } - </div> - </div> - ) : null} - <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}> <div className='column-header__collapsible-inner'> {(!collapsed || animating) && collapsedContent} </div> </div> + + {appendContent} </div> ); diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index ab5b7a572..cc4d714e8 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -184,7 +184,7 @@ export default class Dropdown extends React.PureComponent { icon: PropTypes.string.isRequired, items: PropTypes.array.isRequired, size: PropTypes.number.isRequired, - ariaLabel: PropTypes.string, + title: PropTypes.string, disabled: PropTypes.bool, status: ImmutablePropTypes.map, isUserTouching: PropTypes.func, @@ -197,7 +197,7 @@ export default class Dropdown extends React.PureComponent { }; static defaultProps = { - ariaLabel: 'Menu', + title: 'Menu', }; state = { @@ -277,14 +277,14 @@ export default class Dropdown extends React.PureComponent { } render () { - const { icon, items, size, ariaLabel, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props; + const { icon, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard } = this.props; const open = this.state.id === openDropdownId; return ( <div> <IconButton icon={icon} - title={ariaLabel} + title={title} active={open} disabled={disabled} size={size} diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 85ee79e11..9754c73dc 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -43,6 +43,7 @@ class Item extends React.PureComponent { onClick: PropTypes.func.isRequired, displayWidth: PropTypes.number, visible: PropTypes.bool.isRequired, + autoplay: PropTypes.bool, }; static defaultProps = { @@ -68,9 +69,13 @@ class Item extends React.PureComponent { } } + getAutoPlay() { + return this.props.autoplay || autoPlayGif; + } + hoverToPlay () { const { attachment } = this.props; - return !autoPlayGif && attachment.get('type') === 'gifv'; + return !this.getAutoPlay() && attachment.get('type') === 'gifv'; } handleClick = (e) => { @@ -222,7 +227,7 @@ class Item extends React.PureComponent { </a> ); } else if (attachment.get('type') === 'gifv') { - const autoPlay = !isIOS() && autoPlayGif; + const autoPlay = !isIOS() && this.getAutoPlay(); thumbnail = ( <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> @@ -271,6 +276,7 @@ class MediaGallery extends React.PureComponent { defaultWidth: PropTypes.number, cacheWidth: PropTypes.func, visible: PropTypes.bool, + autoplay: PropTypes.bool, onToggleVisibility: PropTypes.func, }; @@ -328,7 +334,7 @@ class MediaGallery extends React.PureComponent { } render () { - const { media, intl, sensitive, letterbox, fullwidth, defaultWidth } = this.props; + const { media, intl, sensitive, letterbox, fullwidth, defaultWidth, autoplay } = this.props; const { visible } = this.state; const size = media.take(4).size; const uncached = media.every(attachment => attachment.get('type') === 'unknown'); @@ -350,9 +356,9 @@ class MediaGallery extends React.PureComponent { } if (this.isStandaloneEligible()) { - children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />; + children = <Item standalone autoplay={autoplay} 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} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />); + children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} visible={visible || uncached} />); } if (uncached) { diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js index aa4b73cfe..711181dcd 100644 --- a/app/javascript/flavours/glitch/components/relative_timestamp.js +++ b/app/javascript/flavours/glitch/components/relative_timestamp.js @@ -3,6 +3,7 @@ import { injectIntl, defineMessages } from 'react-intl'; import PropTypes from 'prop-types'; const messages = defineMessages({ + today: { id: 'relative_time.today', defaultMessage: 'today' }, just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, @@ -65,12 +66,14 @@ const getUnitDelay = units => { } }; -export const timeAgoString = (intl, date, now, year) => { +export const timeAgoString = (intl, date, now, year, timeGiven = true) => { const delta = now - date.getTime(); let relativeTime; - if (delta < 10 * SECOND) { + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else if (delta < 10 * SECOND) { relativeTime = intl.formatMessage(messages.just_now); } else if (delta < 7 * DAY) { if (delta < MINUTE) { @@ -91,12 +94,14 @@ export const timeAgoString = (intl, date, now, year) => { return relativeTime; }; -const timeRemainingString = (intl, date, now) => { +const timeRemainingString = (intl, date, now, timeGiven = true) => { const delta = date.getTime() - now; let relativeTime; - if (delta < 10 * SECOND) { + if (delta < DAY && !timeGiven) { + relativeTime = intl.formatMessage(messages.today); + } else if (delta < 10 * SECOND) { relativeTime = intl.formatMessage(messages.moments_remaining); } else if (delta < MINUTE) { relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) }); @@ -173,8 +178,9 @@ class RelativeTimestamp extends React.Component { render () { const { timestamp, intl, year, futureDate } = this.props; + const timeGiven = timestamp.includes('T'); const date = new Date(timestamp); - const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year); + const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now, timeGiven) : timeAgoString(intl, date, this.state.now, year, timeGiven); return ( <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}> diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 2c79de4db..a5822866a 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -42,6 +42,14 @@ const isLinkMisleading = (link) => { const linkText = linkTextParts.join(''); const targetURL = new URL(link.href); + if (targetURL.protocol === 'magnet:') { + return !linkText.startsWith('magnet:'); + } + + if (targetURL.protocol === 'xmpp:') { + return !(linkText === targetURL.href || 'xmpp:' + linkText === targetURL.href); + } + // The following may not work with international domain names if (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { return false; @@ -120,9 +128,19 @@ export default class StatusContent extends React.PureComponent { if (tagLinks && isLinkMisleading(link)) { // Add a tag besides the link to display its origin + const url = new URL(link.href); const tag = document.createElement('span'); tag.classList.add('link-origin-tag'); - tag.textContent = `[${new URL(link.href).host}]`; + switch (url.protocol) { + case 'xmpp:': + tag.textContent = `[${url.href}]`; + break; + case 'magnet:': + tag.textContent = '(magnet)'; + break; + default: + tag.textContent = `[${url.host}]`; + } link.insertAdjacentText('beforeend', ' '); link.insertAdjacentElement('beforeend', tag); } diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index 2ef4ff602..f25c82a00 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -112,6 +112,7 @@ class AccountTimeline extends ImmutablePureComponent { onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />} bindToDocument={!multiColumn} + timelineId='account' /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js index d1c406933..557120960 100644 --- a/app/javascript/flavours/glitch/features/directory/components/account_card.js +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js @@ -22,6 +22,7 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, }); const makeMapStateToProps = () => { diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index 6e5518b0c..3717fcd82 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -372,6 +372,7 @@ class EmojiPickerDropdown extends React.PureComponent { onPickEmoji: PropTypes.func.isRequired, onSkinTone: PropTypes.func.isRequired, skinTone: PropTypes.number.isRequired, + button: PropTypes.node, }; state = { @@ -432,18 +433,18 @@ class EmojiPickerDropdown extends React.PureComponent { } render () { - const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props; + const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props; const title = intl.formatMessage(messages.emoji); const { active, loading, placement } = this.state; return ( <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> - <img + {button || <img className={classNames('emojione', { 'pulse-loading': active && loading })} alt='🙂' src={`${assetHost}/emoji/1f602.svg`} - /> + />} </div> <Overlay show={active} placement={placement} target={this.findTarget}> diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js new file mode 100644 index 000000000..e34c9009b --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js @@ -0,0 +1,436 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ReactSwipeableViews from 'react-swipeable-views'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Icon from 'flavours/glitch/components/icon'; +import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; +import { autoPlayGif, reduceMotion } from 'flavours/glitch/util/initial_state'; +import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; +import { mascot } from 'flavours/glitch/util/initial_state'; +import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; +import classNames from 'classnames'; +import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; +import TransitionMotion from 'react-motion/lib/TransitionMotion'; +import spring from 'react-motion/lib/spring'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +class Content extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + announcement: ImmutablePropTypes.map.isRequired, + }; + + setRef = c => { + this.node = c; + } + + componentDidMount () { + this._updateLinks(); + this._updateEmojis(); + } + + componentDidUpdate () { + this._updateLinks(); + this._updateEmojis(); + } + + _updateEmojis () { + const node = this.node; + + if (!node || autoPlayGif) { + return; + } + + const emojis = node.querySelectorAll('.custom-emoji'); + + for (var i = 0; i < emojis.length; i++) { + let emoji = emojis[i]; + + if (emoji.classList.contains('status-emoji')) { + continue; + } + + emoji.classList.add('status-emoji'); + + emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false); + emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false); + } + } + + _updateLinks () { + const node = this.node; + + if (!node) { + return; + } + + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + + if (link.classList.contains('status-link')) { + continue; + } + + link.classList.add('status-link'); + + let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url')); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else { + link.setAttribute('title', link.href); + link.classList.add('unhandled-link'); + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } + } + + onMentionClick = (mention, e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, ''); + + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/timelines/tag/${hashtag}`); + } + } + + handleEmojiMouseEnter = ({ target }) => { + target.src = target.getAttribute('data-original'); + } + + handleEmojiMouseLeave = ({ target }) => { + target.src = target.getAttribute('data-static'); + } + + render () { + const { announcement } = this.props; + + return ( + <div + className='announcements__item__content' + ref={this.setRef} + dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }} + /> + ); + } + +} + +const assetHost = process.env.CDN_HOST || ''; + +class Emoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.string.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + hovered: PropTypes.bool.isRequired, + }; + + render () { + const { emoji, emojiMap, hovered } = this.props; + + if (unicodeMapping[emoji]) { + const { filename, shortCode } = unicodeMapping[this.props.emoji]; + const title = shortCode ? `:${shortCode}:` : ''; + + return ( + <img + draggable='false' + className='emojione' + alt={emoji} + title={title} + src={`${assetHost}/emoji/${filename}.svg`} + /> + ); + } else if (emojiMap.get(emoji)) { + const filename = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']); + const shortCode = `:${emoji}:`; + + return ( + <img + draggable='false' + className='emojione custom-emoji' + alt={shortCode} + title={shortCode} + src={filename} + /> + ); + } else { + return null; + } + } + +} + +class Reaction extends ImmutablePureComponent { + + static propTypes = { + announcementId: PropTypes.string.isRequired, + reaction: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + style: PropTypes.object, + }; + + state = { + hovered: false, + }; + + handleClick = () => { + const { reaction, announcementId, addReaction, removeReaction } = this.props; + + if (reaction.get('me')) { + removeReaction(announcementId, reaction.get('name')); + } else { + addReaction(announcementId, reaction.get('name')); + } + } + + handleMouseEnter = () => this.setState({ hovered: true }) + + handleMouseLeave = () => this.setState({ hovered: false }) + + render () { + const { reaction } = this.props; + + let shortCode = reaction.get('name'); + + if (unicodeMapping[shortCode]) { + shortCode = unicodeMapping[shortCode].shortCode; + } + + return ( + <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}> + <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span> + <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span> + </button> + ); + } + +} + +class ReactionsBar extends ImmutablePureComponent { + + static propTypes = { + announcementId: PropTypes.string.isRequired, + reactions: ImmutablePropTypes.list.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + }; + + handleEmojiPick = data => { + const { addReaction, announcementId } = this.props; + addReaction(announcementId, data.native.replace(/:/g, '')); + } + + willEnter () { + return { scale: reduceMotion ? 1 : 0 }; + } + + willLeave () { + return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) }; + } + + render () { + const { reactions } = this.props; + const visibleReactions = reactions.filter(x => x.get('count') > 0); + + const styles = visibleReactions.map(reaction => ({ + key: reaction.get('name'), + data: reaction, + style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) }, + })).toArray(); + + return ( + <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}> + {items => ( + <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}> + {items.map(({ key, data, style }) => ( + <Reaction + key={key} + reaction={data} + style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }} + announcementId={this.props.announcementId} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + emojiMap={this.props.emojiMap} + /> + ))} + + {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />} + </div> + )} + </TransitionMotion> + ); + } + +} + +class Announcement extends ImmutablePureComponent { + + static propTypes = { + announcement: ImmutablePropTypes.map.isRequired, + emojiMap: ImmutablePropTypes.map.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + selected: PropTypes.bool, + }; + + state = { + unread: !this.props.announcement.get('read'), + }; + + componentDidUpdate () { + const { selected, announcement } = this.props; + if (!selected && this.state.unread !== !announcement.get('read')) { + this.setState({ unread: !announcement.get('read') }); + } + } + + render () { + const { announcement } = this.props; + const { unread } = this.state; + const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at')); + const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at')); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear(); + const skipTime = announcement.get('all_day'); + + return ( + <div className='announcements__item'> + <strong className='announcements__item__range'> + <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' /> + {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>} + </strong> + + <Content announcement={announcement} /> + + <ReactionsBar + reactions={announcement.get('reactions')} + announcementId={announcement.get('id')} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + emojiMap={this.props.emojiMap} + /> + + {unread && <span className='announcements__item__unread' />} + </div> + ); + } + +} + +export default @injectIntl +class Announcements extends ImmutablePureComponent { + + static propTypes = { + announcements: ImmutablePropTypes.list, + emojiMap: ImmutablePropTypes.map.isRequired, + dismissAnnouncement: PropTypes.func.isRequired, + addReaction: PropTypes.func.isRequired, + removeReaction: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + index: 0, + }; + + componentDidMount () { + this._markAnnouncementAsRead(); + } + + componentDidUpdate () { + this._markAnnouncementAsRead(); + } + + _markAnnouncementAsRead () { + const { dismissAnnouncement, announcements } = this.props; + const { index } = this.state; + const announcement = announcements.get(index); + if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); + } + + handleChangeIndex = index => { + this.setState({ index: index % this.props.announcements.size }); + } + + handleNextClick = () => { + this.setState({ index: (this.state.index + 1) % this.props.announcements.size }); + } + + handlePrevClick = () => { + this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size }); + } + + render () { + const { announcements, intl } = this.props; + const { index } = this.state; + + if (announcements.isEmpty()) { + return null; + } + + return ( + <div className='announcements'> + <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} /> + + <div className='announcements__container'> + <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}> + {announcements.map((announcement, idx) => ( + <Announcement + key={announcement.get('id')} + announcement={announcement} + emojiMap={this.props.emojiMap} + addReaction={this.props.addReaction} + removeReaction={this.props.removeReaction} + intl={intl} + selected={index === idx} + /> + ))} + </ReactSwipeableViews> + + {announcements.size > 1 && ( + <div className='announcements__pagination'> + <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} /> + <span>{index + 1} / {announcements.size}</span> + <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} /> + </div> + )} + </div> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js new file mode 100644 index 000000000..d472323f8 --- /dev/null +++ b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux'; +import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements'; +import Announcements from '../components/announcements'; +import { createSelector } from 'reselect'; +import { Map as ImmutableMap } from 'immutable'; + +const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap())); + +const mapStateToProps = state => ({ + announcements: state.getIn(['announcements', 'items']), + emojiMap: customEmojiMap(state), +}); + +const mapDispatchToProps = dispatch => ({ + dismissAnnouncement: id => dispatch(dismissAnnouncement(id)), + addReaction: (id, name) => dispatch(addReaction(id, name)), + removeReaction: (id, name) => dispatch(removeReaction(id, name)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Announcements); diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js index 1df3fb4fe..7a5268780 100644 --- a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js +++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { fetchTrends } from '../../../actions/trends'; +import { fetchTrends } from 'mastodon/actions/trends'; import Trends from '../components/trends'; const mapStateToProps = state => ({ diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js index 9b71a4404..cc8e4664c 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.js +++ b/app/javascript/flavours/glitch/features/home_timeline/index.js @@ -9,14 +9,23 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { Link } from 'react-router-dom'; +import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements'; +import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container'; +import classNames from 'classnames'; +import IconWithBadge from 'flavours/glitch/components/icon_with_badge'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, + show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' }, + hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' }, }); const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, isPartial: state.getIn(['timelines', 'home', 'isPartial']), + hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), + unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), + showAnnouncements: state.getIn(['announcements', 'show']), }); export default @connect(mapStateToProps) @@ -30,6 +39,9 @@ class HomeTimeline extends React.PureComponent { isPartial: PropTypes.bool, columnId: PropTypes.string, multiColumn: PropTypes.bool, + hasAnnouncements: PropTypes.bool, + unreadAnnouncements: PropTypes.number, + showAnnouncements: PropTypes.bool, }; handlePin = () => { @@ -60,6 +72,7 @@ class HomeTimeline extends React.PureComponent { } componentDidMount () { + this.props.dispatch(fetchAnnouncements()); this._checkIfReloadNeeded(false, this.props.isPartial); } @@ -92,10 +105,31 @@ class HomeTimeline extends React.PureComponent { } } + handleToggleAnnouncementsClick = (e) => { + e.stopPropagation(); + this.props.dispatch(toggleShowAnnouncements()); + } + render () { - const { intl, hasUnread, columnId, multiColumn } = this.props; + const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const pinned = !!columnId; + let announcementsButton = null; + + if (hasAnnouncements) { + announcementsButton = ( + <button + className={classNames('column-header__button', { 'active': showAnnouncements })} + title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)} + aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)} + aria-pressed={showAnnouncements ? 'true' : 'false'} + onClick={this.handleToggleAnnouncementsClick} + > + <IconWithBadge id='bullhorn' count={unreadAnnouncements} /> + </button> + ); + } + return ( <Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}> <ColumnHeader @@ -107,6 +141,8 @@ class HomeTimeline extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + extraButton={announcementsButton} + appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />} > <ColumnSettingsContainer /> </ColumnHeader> diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js index e08c12c76..0b3428027 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -168,6 +168,14 @@ class LocalSettingsPage extends React.PureComponent { </LocalSettingsPageItem> <LocalSettingsPageItem settings={settings} + item={['prepend_cw_re']} + id='mastodon-settings--prepend_cw_re' + onChange={onChange} + > + <FormattedMessage id='settings.prepend_cw_re' defaultMessage='Prepend “re: ” to content warnings when replying' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} item={['preselect_on_reply']} id='mastodon-settings--preselect_on_reply' onChange={onChange} diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 7f06d70c5..26710feff 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import classNames from 'classnames'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Column from 'flavours/glitch/components/column'; @@ -22,9 +23,13 @@ import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; import LoadGap from 'flavours/glitch/components/load_gap'; +import Icon from 'flavours/glitch/components/icon'; + +import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, + enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, }); const getNotifications = createSelector([ @@ -94,6 +99,10 @@ class Notifications extends React.PureComponent { trackScroll: true, }; + state = { + animatingNCD: false, + }; + handleLoadGap = (maxId) => { this.props.dispatch(expandNotifications({ maxId })); }; @@ -176,8 +185,19 @@ class Notifications extends React.PureComponent { } } + handleTransitionEndNCD = () => { + this.setState({ animatingNCD: false }); + } + + onEnterCleaningMode = () => { + this.setState({ animatingNCD: true }); + this.props.onEnterCleaningMode(!this.props.notifCleaningActive); + } + render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; + const { notifCleaning, notifCleaningActive } = this.props; + const { animatingNCD } = this.state; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -232,6 +252,36 @@ class Notifications extends React.PureComponent { </ScrollableList> ); + const notifCleaningButtonClassName = classNames('column-header__button', { + 'active': notifCleaningActive, + }); + + const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', { + 'collapsed': !notifCleaningActive, + 'animating': animatingNCD, + }); + + const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); + + const notifCleaningButton = ( + <button + aria-label={msgEnterNotifCleaning} + title={msgEnterNotifCleaning} + onClick={this.onEnterCleaningMode} + className={notifCleaningButtonClassName} + > + <Icon id='eraser' /> + </button> + ); + + const notifCleaningDrawer = ( + <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}> + <div className='column-header__collapsible-inner nopad-drawer'> + {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null } + </div> + </div> + ); + return ( <Column bindToDocument={!multiColumn} @@ -250,9 +300,8 @@ class Notifications extends React.PureComponent { pinned={pinned} multiColumn={multiColumn} localSettings={this.props.localSettings} - notifCleaning - notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text - onEnterCleaningMode={this.props.onEnterCleaningMode} + extraButton={notifCleaningButton} + appendContent={notifCleaningDrawer} > <ColumnSettingsContainer /> </ColumnHeader> diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index c48bfaccd..d71a3ae08 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -18,6 +18,7 @@ const messages = defineMessages({ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + more: { id: 'status.more', defaultMessage: 'More' }, mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, @@ -207,7 +208,7 @@ class ActionBar extends React.PureComponent { <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div> <div className='detailed-status__action-bar-dropdown'> - <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' /> + <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} /> </div> </div> ); diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 898011c88..c4ac8f0a6 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -7,7 +7,7 @@ import StatusContent from 'flavours/glitch/components/status_content'; import MediaGallery from 'flavours/glitch/components/media_gallery'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import { Link } from 'react-router-dom'; -import { FormattedDate, FormattedNumber } from 'react-intl'; +import { FormattedDate } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from 'flavours/glitch/features/video'; @@ -17,6 +17,7 @@ import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import classNames from 'classnames'; import PollContainer from 'flavours/glitch/containers/poll_container'; import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; export default class DetailedStatus extends ImmutablePureComponent { @@ -204,7 +205,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> <Icon id={reblogIcon} /> <span className='detailed-status__reblogs'> - <FormattedNumber value={status.get('reblogs_count')} /> + <AnimatedNumber value={status.get('reblogs_count')} /> </span> </Link> ); @@ -213,7 +214,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> <Icon id={reblogIcon} /> <span className='detailed-status__reblogs'> - <FormattedNumber value={status.get('reblogs_count')} /> + <AnimatedNumber value={status.get('reblogs_count')} /> </span> </a> ); @@ -224,7 +225,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> <Icon id='star' /> <span className='detailed-status__favorites'> - <FormattedNumber value={status.get('favourites_count')} /> + <AnimatedNumber value={status.get('favourites_count')} /> </span> </Link> ); @@ -233,7 +234,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> <Icon id='star' /> <span className='detailed-status__favorites'> - <FormattedNumber value={status.get('favourites_count')} /> + <AnimatedNumber value={status.get('favourites_count')} /> </span> </a> ); diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 322f92477..411d2a88d 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -568,6 +568,7 @@ class Status extends ImmutablePureComponent { <HotKeys handlers={handlers}> <div className='focusable' tabIndex='0' aria-label={textForScreenReader(intl, status, false, !status.get('hidden'))}> <DetailedStatus + key={`details-${status.get('id')}`} status={status} settings={settings} onOpenVideo={this.handleOpenVideo} @@ -580,6 +581,7 @@ class Status extends ImmutablePureComponent { /> <ActionBar + key={`action-bar-${status.get('id')}`} status={status} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index c7d6c374c..23e8dac7e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -191,7 +191,6 @@ class MediaModal extends ImmutablePureComponent { style={swipeableViewsStyle} containerStyle={containerStyle} onChangeIndex={this.handleSwipe} - onSwitching={this.handleSwitching} index={index} > {content} diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 5c861fdee..9f9ef561a 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -12,7 +12,7 @@ import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { fetchFilters } from 'flavours/glitch/actions/filters'; import { clearHeight } from 'flavours/glitch/actions/height_cache'; -import { submitMarkers } from 'flavours/glitch/actions/markers'; +import { submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers'; import UploadArea from './components/upload_area'; import PermaLink from 'flavours/glitch/components/permalink'; @@ -388,6 +388,7 @@ class UI extends React.Component { this.favicon = new Favico({ animation:"none" }); + this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); setTimeout(() => this.props.dispatch(fetchFilters()), 500); diff --git a/app/javascript/flavours/glitch/locales/ast.js b/app/javascript/flavours/glitch/locales/ast.js new file mode 100644 index 000000000..41355c24c --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ast.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ast.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/bn.js b/app/javascript/flavours/glitch/locales/bn.js new file mode 100644 index 000000000..a453498b3 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/bn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/bn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/br.js b/app/javascript/flavours/glitch/locales/br.js new file mode 100644 index 000000000..966bd1b2f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/br.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/br.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/co.js b/app/javascript/flavours/glitch/locales/co.js new file mode 100644 index 000000000..6e9e46797 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/co.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/co.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/cs.js b/app/javascript/flavours/glitch/locales/cs.js new file mode 100644 index 000000000..ac7db0327 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/cs.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/cs.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/cy.js b/app/javascript/flavours/glitch/locales/cy.js new file mode 100644 index 000000000..09412bd72 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/cy.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/cy.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/da.js b/app/javascript/flavours/glitch/locales/da.js new file mode 100644 index 000000000..2b08806be --- /dev/null +++ b/app/javascript/flavours/glitch/locales/da.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/da.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/el.js b/app/javascript/flavours/glitch/locales/el.js new file mode 100644 index 000000000..2d9bb829f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/el.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/el.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/es-AR.js b/app/javascript/flavours/glitch/locales/es-AR.js new file mode 100644 index 000000000..0dffabcd4 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/es-AR.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/es-AR.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/et.js b/app/javascript/flavours/glitch/locales/et.js new file mode 100644 index 000000000..e3ea6b2a9 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/et.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/et.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/eu.js b/app/javascript/flavours/glitch/locales/eu.js new file mode 100644 index 000000000..946410b67 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/eu.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/eu.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ga.js b/app/javascript/flavours/glitch/locales/ga.js new file mode 100644 index 000000000..af2846ff8 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ga.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ga.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/gl.js b/app/javascript/flavours/glitch/locales/gl.js new file mode 100644 index 000000000..6a9140b1a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/gl.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/gl.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/hi.js b/app/javascript/flavours/glitch/locales/hi.js new file mode 100644 index 000000000..1a569495f --- /dev/null +++ b/app/javascript/flavours/glitch/locales/hi.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/hi.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/hy.js b/app/javascript/flavours/glitch/locales/hy.js new file mode 100644 index 000000000..96f6a4d19 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/hy.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/hy.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/is.js b/app/javascript/flavours/glitch/locales/is.js new file mode 100644 index 000000000..b05a08ad0 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/is.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/is.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ka.js b/app/javascript/flavours/glitch/locales/ka.js new file mode 100644 index 000000000..3e06f4282 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ka.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ka.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/kab.js b/app/javascript/flavours/glitch/locales/kab.js new file mode 100644 index 000000000..5ed1156ef --- /dev/null +++ b/app/javascript/flavours/glitch/locales/kab.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/kab.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/kk.js b/app/javascript/flavours/glitch/locales/kk.js new file mode 100644 index 000000000..8d00fb035 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/kk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/kk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/kn.js b/app/javascript/flavours/glitch/locales/kn.js new file mode 100644 index 000000000..1c50e3628 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/kn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/kn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/lt.js b/app/javascript/flavours/glitch/locales/lt.js new file mode 100644 index 000000000..47453aeeb --- /dev/null +++ b/app/javascript/flavours/glitch/locales/lt.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/lt.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/lv.js b/app/javascript/flavours/glitch/locales/lv.js new file mode 100644 index 000000000..cdbcdf799 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/lv.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/lv.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/mk.js b/app/javascript/flavours/glitch/locales/mk.js new file mode 100644 index 000000000..55e510b59 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/mk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/mk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ml.js b/app/javascript/flavours/glitch/locales/ml.js new file mode 100644 index 000000000..d00331a1a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ml.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ml.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/mr.js b/app/javascript/flavours/glitch/locales/mr.js new file mode 100644 index 000000000..fb3cde92a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/mr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/mr.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ms.js b/app/javascript/flavours/glitch/locales/ms.js new file mode 100644 index 000000000..61033c521 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ms.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ms.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/nn.js b/app/javascript/flavours/glitch/locales/nn.js new file mode 100644 index 000000000..4c42368cb --- /dev/null +++ b/app/javascript/flavours/glitch/locales/nn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/nn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ro.js b/app/javascript/flavours/glitch/locales/ro.js new file mode 100644 index 000000000..a16446c6a --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ro.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ro.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sk.js b/app/javascript/flavours/glitch/locales/sk.js new file mode 100644 index 000000000..5fba6ab97 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sk.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sk.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sl.js b/app/javascript/flavours/glitch/locales/sl.js new file mode 100644 index 000000000..c53c1bae8 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sl.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sl.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sq.js b/app/javascript/flavours/glitch/locales/sq.js new file mode 100644 index 000000000..2fb7a2973 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sq.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sq.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sr-Latn.js b/app/javascript/flavours/glitch/locales/sr-Latn.js new file mode 100644 index 000000000..b42d5eaaf --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sr-Latn.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sr-Latn.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/sr.js b/app/javascript/flavours/glitch/locales/sr.js new file mode 100644 index 000000000..8793d8d1e --- /dev/null +++ b/app/javascript/flavours/glitch/locales/sr.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/sr.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ta.js b/app/javascript/flavours/glitch/locales/ta.js new file mode 100644 index 000000000..d6ecdcb1b --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ta.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ta.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/te.js b/app/javascript/flavours/glitch/locales/te.js new file mode 100644 index 000000000..afd6e4f7b --- /dev/null +++ b/app/javascript/flavours/glitch/locales/te.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/te.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/ur.js b/app/javascript/flavours/glitch/locales/ur.js new file mode 100644 index 000000000..97ba291b0 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/ur.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/ur.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/locales/vi.js b/app/javascript/flavours/glitch/locales/vi.js new file mode 100644 index 000000000..499a96727 --- /dev/null +++ b/app/javascript/flavours/glitch/locales/vi.js @@ -0,0 +1,7 @@ +import inherited from 'mastodon/locales/vi.json'; + +const messages = { + // No translations available. +}; + +export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index 973d6ee46..d1adfb17a 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -97,15 +97,6 @@ function main() { delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); - - delegate(document, '.blocks-table button.icon-button', 'click', function(e) { - e.preventDefault(); - - const classList = this.firstElementChild.classList; - classList.toggle('fa-chevron-down'); - classList.toggle('fa-chevron-up'); - this.parentElement.parentElement.nextElementSibling.classList.toggle('hidden'); - }); }); delegate(document, '.sidebar__toggle__icon', 'click', () => { diff --git a/app/javascript/flavours/glitch/reducers/announcements.js b/app/javascript/flavours/glitch/reducers/announcements.js new file mode 100644 index 000000000..34e08eac8 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/announcements.js @@ -0,0 +1,102 @@ +import { + ANNOUNCEMENTS_FETCH_REQUEST, + ANNOUNCEMENTS_FETCH_SUCCESS, + ANNOUNCEMENTS_FETCH_FAIL, + ANNOUNCEMENTS_UPDATE, + ANNOUNCEMENTS_REACTION_UPDATE, + ANNOUNCEMENTS_REACTION_ADD_REQUEST, + ANNOUNCEMENTS_REACTION_ADD_FAIL, + ANNOUNCEMENTS_REACTION_REMOVE_REQUEST, + ANNOUNCEMENTS_REACTION_REMOVE_FAIL, + ANNOUNCEMENTS_TOGGLE_SHOW, + ANNOUNCEMENTS_DELETE, + ANNOUNCEMENTS_DISMISS_SUCCESS, +} from '../actions/announcements'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, + show: false, +}); + +const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => { + if (announcement.get('id') === id) { + return announcement.update('reactions', reactions => { + const idx = reactions.findIndex(reaction => reaction.get('name') === name); + + if (idx > -1) { + return reactions.update(idx, reaction => updater(reaction)); + } + + return reactions.push(updater(fromJS({ name, count: 0 }))); + }); + } + + return announcement; +})); + +const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count)); + +const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1)); + +const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1)); + +const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at')); + +const updateAnnouncement = (state, announcement) => { + const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id')); + + if (idx > -1) { + // Deep merge is used because announcements from the streaming API do not contain + // personalized data about which reactions have been selected by the given user, + // and that is information we want to preserve + return state.update('items', list => sortAnnouncements(list.update(idx, x => x.mergeDeep(announcement)))); + } + + return state.update('items', list => sortAnnouncements(list.unshift(announcement))); +}; + +export default function announcementsReducer(state = initialState, action) { + switch(action.type) { + case ANNOUNCEMENTS_TOGGLE_SHOW: + return state.withMutations(map => { + map.set('show', !map.get('show')); + }); + case ANNOUNCEMENTS_FETCH_REQUEST: + return state.set('isLoading', true); + case ANNOUNCEMENTS_FETCH_SUCCESS: + return state.withMutations(map => { + const items = fromJS(action.announcements); + + map.set('items', items); + map.set('isLoading', false); + }); + case ANNOUNCEMENTS_FETCH_FAIL: + return state.set('isLoading', false); + case ANNOUNCEMENTS_UPDATE: + return updateAnnouncement(state, fromJS(action.announcement)); + case ANNOUNCEMENTS_REACTION_UPDATE: + return updateReactionCount(state, action.reaction); + case ANNOUNCEMENTS_REACTION_ADD_REQUEST: + case ANNOUNCEMENTS_REACTION_REMOVE_FAIL: + return addReaction(state, action.id, action.name); + case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: + case ANNOUNCEMENTS_REACTION_ADD_FAIL: + return removeReaction(state, action.id, action.name); + case ANNOUNCEMENTS_DISMISS_SUCCESS: + return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true })); + case ANNOUNCEMENTS_DELETE: + return state.update('items', list => { + const idx = list.findIndex(x => x.get('id') === action.id); + + if (idx > -1) { + return list.delete(idx); + } + + return list; + }); + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js index 0f807790b..92a9859d9 100644 --- a/app/javascript/flavours/glitch/reducers/compose.js +++ b/app/javascript/flavours/glitch/reducers/compose.js @@ -387,7 +387,7 @@ export default function compose(state = initialState, action) { if (action.status.get('spoiler_text').length > 0) { let spoiler_text = action.status.get('spoiler_text'); - if (!spoiler_text.match(/^re[: ]/i)) { + if (action.prependCWRe && !spoiler_text.match(/^re[: ]/i)) { spoiler_text = 're: '.concat(spoiler_text); } map.set('spoiler', true); diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index 7dbca3a29..586b84749 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -35,8 +35,10 @@ import pinnedAccountsEditor from './pinned_accounts_editor'; import polls from './polls'; import identity_proofs from './identity_proofs'; import trends from './trends'; +import announcements from './announcements'; const reducers = { + announcements, dropdown_menu, timelines, meta, diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index ad94ea243..3d94d665c 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -17,6 +17,7 @@ const initialState = ImmutableMap({ confirm_missing_media_description: false, confirm_boost_missing_media_description: false, confirm_before_clearing_draft: true, + prepend_cw_re: true, preselect_on_reply: true, inline_preview_cards: true, hicolor_privacy_icons: false, diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 3623e90da..d0eb7bb5a 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -23,6 +23,9 @@ import { FOLLOW_REQUEST_AUTHORIZE_SUCCESS, FOLLOW_REQUEST_REJECT_SUCCESS, } from 'flavours/glitch/actions/accounts'; +import { + MARKERS_FETCH_SUCCESS, +} from 'flavours/glitch/actions/markers'; import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks'; import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; @@ -104,7 +107,7 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id')); } } else { - mutable.update('unread', unread => unread + items.filter(item => compareId(item.get('id'), lastReadId) > 0).size); + mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0)); } if (!next) { @@ -197,10 +200,24 @@ const shouldCountUnreadNotifications = (state) => { return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0); }; +const recountUnread = (state, last_read_id) => { + return state.withMutations(mutable => { + if (compareId(last_read_id, mutable.get('lastReadId')) > 0) { + mutable.set('lastReadId', last_read_id); + } + + if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) { + mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0)); + } + }); +} + export default function notifications(state = initialState, action) { let st; switch(action.type) { + case MARKERS_FETCH_SUCCESS: + return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state; case NOTIFICATIONS_MOUNT: return updateMounted(state); case NOTIFICATIONS_UNMOUNT: diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js index ee8ac929d..8926e49f1 100644 --- a/app/javascript/flavours/glitch/reducers/statuses.js +++ b/app/javascript/flavours/glitch/reducers/statuses.js @@ -41,8 +41,7 @@ export default function statuses(state = initialState, action) { case FAVOURITE_REQUEST: return state.setIn([action.status.get('id'), 'favourited'], true); case UNFAVOURITE_SUCCESS: - const favouritesCount = action.status.get('favourites_count'); - return state.setIn([action.status.get('id'), 'favourites_count'], favouritesCount - 1); + return state.updateIn([action.status.get('id'), 'favourites_count'], x => Math.max(0, x - 1)); case FAVOURITE_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); case BOOKMARK_REQUEST: diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js index 8ceb71d03..ab7dac66a 100644 --- a/app/javascript/flavours/glitch/selectors/index.js +++ b/app/javascript/flavours/glitch/selectors/index.js @@ -27,6 +27,7 @@ export const toServerSideType = columnType => { case 'notifications': case 'public': case 'thread': + case 'account': return columnType; default: if (columnType.indexOf('list:') > -1) { diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index d2f477d19..26a98c66f 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -186,17 +186,22 @@ $content-width: 840px; padding-bottom: 40px; border-bottom: 1px solid lighten($ui-base-color, 8%); - margin-bottom: 40px; + + margin: -15px -15px 40px 0; flex-wrap: wrap; align-items: center; - justify-content: space-between; + & > * { + margin-top: 15px; + margin-right: 15px; + } + &-actions { display: inline-flex; - & > * { + & > :not(:first-child) { margin-left: 5px; } } @@ -894,3 +899,50 @@ a.name-tag, color: $primary-text-color; } } + +.center-text { + text-align: center; +} + +.announcements-list { + border: 1px solid lighten($ui-base-color, 4%); + border-radius: 4px; + + &__item { + padding: 15px 0; + background: $ui-base-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + + &__title { + padding: 0 15px; + display: block; + font-weight: 500; + font-size: 18px; + line-height: 1.5; + color: $secondary-text-color; + text-decoration: none; + margin-bottom: 10px; + + &:hover, + &:focus, + &:active { + color: $primary-text-color; + } + } + + &__meta { + padding: 0 15px; + color: $dark-text-color; + } + + &__action-bar { + display: flex; + justify-content: space-between; + align-items: center; + } + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index 5be4da48a..6305e2a4d 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -681,13 +681,13 @@ &__links { font-size: 14px; color: $darker-text-color; + padding: 10px 0; a { display: inline-block; color: $darker-text-color; text-decoration: none; - padding: 10px; - padding-top: 20px; + padding: 5px 10px; font-weight: 500; strong { diff --git a/app/javascript/flavours/glitch/styles/components/announcements.scss b/app/javascript/flavours/glitch/styles/components/announcements.scss new file mode 100644 index 000000000..ac4c199cd --- /dev/null +++ b/app/javascript/flavours/glitch/styles/components/announcements.scss @@ -0,0 +1,225 @@ +.announcements__item__content { + word-wrap: break-word; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + p { + margin-bottom: 10px; + white-space: pre-wrap; + + &:last-child { + margin-bottom: 0; + } + } + + a { + color: $secondary-text-color; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &.mention { + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } + + &.unhandled-link { + color: lighten($ui-highlight-color, 8%); + } + } +} + +.announcements { + background: lighten($ui-base-color, 8%); + font-size: 13px; + display: flex; + align-items: flex-end; + + &__mastodon { + width: 124px; + flex: 0 0 auto; + + @media screen and (max-width: 124px + 300px) { + display: none; + } + } + + &__container { + width: calc(100% - 124px); + flex: 0 0 auto; + position: relative; + + @media screen and (max-width: 124px + 300px) { + width: 100%; + } + } + + &__item { + box-sizing: border-box; + width: 100%; + padding: 15px; + padding-right: 15px + 18px; + position: relative; + font-size: 15px; + line-height: 20px; + word-wrap: break-word; + font-weight: 400; + + &__range { + display: block; + font-weight: 500; + margin-bottom: 10px; + } + + &__unread { + position: absolute; + top: 15px; + right: 15px; + display: inline-block; + background: $highlight-text-color; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + margin: 0 .15em; + } + } + + &__pagination { + padding: 15px; + color: $darker-text-color; + position: absolute; + bottom: 3px; + right: 0; + } +} + +.layout-multiple-columns .announcements__mastodon { + display: none; +} + +.layout-multiple-columns .announcements__container { + width: 100%; +} + +.reactions-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 15px; + margin-left: -2px; + width: calc(100% - (90px - 33px)); + + &__item { + flex-shrink: 0; + background: lighten($ui-base-color, 12%); + border: 0; + border-radius: 3px; + margin: 2px; + cursor: pointer; + user-select: none; + padding: 0 6px; + display: flex; + align-items: center; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &__emoji { + display: block; + margin: 3px 0; + width: 16px; + height: 16px; + + img { + display: block; + margin: 0; + width: 100%; + height: 100%; + min-width: auto; + min-height: auto; + vertical-align: bottom; + object-fit: contain; + } + } + + &__count { + display: block; + min-width: 9px; + font-size: 13px; + font-weight: 500; + text-align: center; + margin-left: 6px; + color: $darker-text-color; + } + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 16%); + transition: all 200ms ease-out; + transition-property: background-color, color; + + &__count { + color: lighten($darker-text-color, 4%); + } + } + + &.active { + transition: all 100ms ease-in; + transition-property: background-color, color; + background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 80%); + + .reactions-bar__item__count { + color: lighten($highlight-text-color, 8%); + } + } + } + + .emoji-picker-dropdown { + margin: 2px; + } + + &:hover .emoji-button { + opacity: 0.85; + } + + .emoji-button { + color: $darker-text-color; + margin: 0; + font-size: 16px; + width: auto; + flex-shrink: 0; + padding: 0 6px; + height: 22px; + display: flex; + align-items: center; + opacity: 0.5; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + opacity: 1; + color: lighten($darker-text-color, 4%); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + } + + &--empty { + .emoji-button { + padding: 0; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 6ba9698c5..525dcaf90 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -224,13 +224,16 @@ .column-header__wrapper { position: relative; flex: 0 0 auto; + z-index: 1; &.active { + box-shadow: 0 1px 0 rgba($highlight-text-color, 0.3); + &::before { display: block; content: ""; position: absolute; - top: 35px; + bottom: -13px; left: 0; right: 0; margin: 0 auto; @@ -241,6 +244,11 @@ background: radial-gradient(ellipse, rgba($ui-highlight-color, 0.23) 0%, rgba($ui-highlight-color, 0) 60%); } } + + .announcements { + z-index: 1; + position: relative; + } } .column-header { @@ -273,8 +281,6 @@ } &.active { - box-shadow: 0 1px 0 rgba($ui-highlight-color, 0.3); - .column-header__icon { color: $highlight-text-color; text-shadow: 0 0 10px rgba($ui-highlight-color, 0.4); @@ -378,6 +384,8 @@ color: $darker-text-color; transition: max-height 150ms ease-in-out, opacity 300ms linear; opacity: 1; + z-index: 1; + position: relative; &.collapsed { max-height: 0; diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index 51287f62e..943776010 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -1,5 +1,16 @@ .composer { padding: 10px; + + .emoji-picker-dropdown { + position: absolute; + right: 5px; + top: 5px; + + ::-webkit-scrollbar-track:hover, + ::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } + } } .character-counter { @@ -235,17 +246,6 @@ } } -.emoji-picker-dropdown { - position: absolute; - right: 5px; - top: 5px; - - ::-webkit-scrollbar-track:hover, - ::-webkit-scrollbar-track:active { - background-color: rgba($base-overlay-background, 0.3); - } -} - .compose-form__autosuggest-wrapper, .autosuggest-input { position: relative; diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss index 93a3f62ed..d5463e406 100644 --- a/app/javascript/flavours/glitch/styles/components/drawer.scss +++ b/app/javascript/flavours/glitch/styles/components/drawer.scss @@ -210,7 +210,7 @@ display: block; object-fit: contain; object-position: bottom left; - width: 100%; + width: 85%; height: 100%; pointer-events: none; user-drag: none; diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 8e576fd86..d97ab436d 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -3,6 +3,14 @@ -ms-overflow-style: -ms-autohiding-scrollbar; } +.animated-number { + display: inline-flex; + flex-direction: column; + align-items: stretch; + overflow: hidden; + position: relative; +} + .link-button { display: block; font-size: 15px; @@ -1649,3 +1657,4 @@ noscript { @import 'local_settings'; @import 'error_boundary'; @import 'single_column'; +@import 'announcements'; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 00f947cdc..fa26c4706 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -510,6 +510,7 @@ .status-check-box__status { margin: 10px 0 10px 10px; flex: 1; + overflow: hidden; .media-gallery { max-width: 250px; diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index 1920c33ea..396e87c6c 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -213,6 +213,12 @@ code { } } + .input.datetime .label_input select { + display: inline-block; + width: auto; + flex: 0; + } + .required abbr { text-decoration: none; color: lighten($error-value-color, 12%); diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss index 49d0e7f71..9c86cca58 100644 --- a/app/javascript/flavours/glitch/styles/polls.scss +++ b/app/javascript/flavours/glitch/styles/polls.scss @@ -170,6 +170,7 @@ .compose-form__poll-wrapper { border-top: 1px solid darken($simple-background-color, 8%); + overflow-x: hidden; ul { padding: 10px; diff --git a/app/javascript/flavours/glitch/styles/statuses.scss b/app/javascript/flavours/glitch/styles/statuses.scss index 611d5185b..22fa7b3fd 100644 --- a/app/javascript/flavours/glitch/styles/statuses.scss +++ b/app/javascript/flavours/glitch/styles/statuses.scss @@ -222,3 +222,20 @@ } } } + +.status__content__read-more-button { + display: block; + font-size: 15px; + line-height: 20px; + color: lighten($ui-highlight-color, 8%); + border: 0; + background: transparent; + padding: 0; + padding-top: 8px; + text-decoration: none; + + &:hover, + &:active { + text-decoration: underline; + } +} diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js index f7e4ceb93..af18dcfdd 100644 --- a/app/javascript/flavours/glitch/util/numbers.js +++ b/app/javascript/flavours/glitch/util/numbers.js @@ -4,9 +4,13 @@ import { FormattedNumber } from 'react-intl'; export const shortNumberFormat = number => { if (number < 1000) { return <FormattedNumber value={number} />; - } else if (number < 1000000) { + } else if (number < 10000) { return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; - } else { + } else if (number < 1000000) { + return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={0} />K</Fragment>; + } else if (number < 10000000) { return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>; + } else { + return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={0} />M</Fragment>; } }; diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js index 50f90d44c..0cb2b228f 100644 --- a/app/javascript/flavours/glitch/util/stream.js +++ b/app/javascript/flavours/glitch/util/stream.js @@ -2,6 +2,14 @@ import WebSocketClient from '@gamestdio/websocket'; const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); +const knownEventTypes = [ + 'update', + 'delete', + 'notification', + 'conversation', + 'filters_changed', +]; + export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) { return (dispatch, getState) => { const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); @@ -69,14 +77,43 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) { - const params = [ `stream=${stream}` ]; - - const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); - - ws.onopen = connected; - ws.onmessage = e => received(JSON.parse(e.data)); - ws.onclose = disconnected; - ws.onreconnect = reconnected; + const params = stream.split('&'); + stream = params.shift(); + + if (streamingAPIBaseURL.startsWith('ws')) { + params.unshift(`stream=${stream}`); + const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken); + + ws.onopen = connected; + ws.onmessage = e => received(JSON.parse(e.data)); + ws.onclose = disconnected; + ws.onreconnect = reconnected; + + return ws; + } + + stream = stream.replace(/:/g, '/'); + params.push(`access_token=${accessToken}`); + const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${stream}?${params.join('&')}`); + + let firstConnect = true; + es.onopen = () => { + if (firstConnect) { + firstConnect = false; + connected(); + } else { + reconnected(); + } + }; + for (let type of knownEventTypes) { + es.addEventListener(type, (e) => { + received({ + event: e.type, + payload: e.data, + }); + }); + } + es.onerror = disconnected; - return ws; + return es; }; |