Merge pull request #1274 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
This commit is contained in:
ThibG 2020-02-03 10:27:07 +01:00 committed by GitHub
commit 94cdbc4982
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 281 additions and 79 deletions

View File

@ -58,7 +58,9 @@ RUN npm install -g yarn && \
COPY Gemfile* package.json yarn.lock /opt/mastodon/ COPY Gemfile* package.json yarn.lock /opt/mastodon/
RUN cd /opt/mastodon && \ RUN cd /opt/mastodon && \
bundle install -j$(nproc) --deployment --without development test && \ bundle config set deployment 'true' && \
bundle config set without 'development test' && \
bundle install -j$(nproc) && \
yarn install --pure-lockfile yarn install --pure-lockfile
FROM ubuntu:18.04 FROM ubuntu:18.04

2
Vagrantfile vendored
View File

@ -12,7 +12,7 @@ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main' sudo apt-add-repository 'deb https://dl.yarnpkg.com/debian/ stable main'
# Add repo for NodeJS # Add repo for NodeJS
curl -sL https://deb.nodesource.com/setup_8.x | sudo bash - curl -sL https://deb.nodesource.com/setup_10.x | sudo bash -
# Add firewall rule to redirect 80 to PORT and save # Add firewall rule to redirect 80 to PORT and save
sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]} sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-port #{ENV["PORT"]}

View File

@ -19,11 +19,7 @@ class Api::V1::AnnouncementsController < Api::BaseController
def set_announcements def set_announcements
@announcements = begin @announcements = begin
scope = Announcement.published Announcement.published.chronological
scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed)
scope.chronological
end end
end end

View File

@ -7,6 +7,10 @@ export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; 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_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
@ -56,6 +60,32 @@ export const updateAnnouncements = announcement => ({
announcement: normalizeAnnouncement(announcement), 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) => { export const addReaction = (announcementId, name) => (dispatch, getState) => {
const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId); const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId);

View File

@ -302,10 +302,23 @@ class Announcement extends ImmutablePureComponent {
addReaction: PropTypes.func.isRequired, addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.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 () { render () {
const { announcement } = this.props; const { announcement } = this.props;
const { unread } = this.state;
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at')); 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 endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
const now = new Date(); const now = new Date();
@ -330,6 +343,8 @@ class Announcement extends ImmutablePureComponent {
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap} emojiMap={this.props.emojiMap}
/> />
{unread && <span className='announcements__item__unread' />}
</div> </div>
); );
} }
@ -342,6 +357,7 @@ class Announcements extends ImmutablePureComponent {
static propTypes = { static propTypes = {
announcements: ImmutablePropTypes.list, announcements: ImmutablePropTypes.list,
emojiMap: ImmutablePropTypes.map.isRequired, emojiMap: ImmutablePropTypes.map.isRequired,
dismissAnnouncement: PropTypes.func.isRequired,
addReaction: PropTypes.func.isRequired, addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -351,6 +367,21 @@ class Announcements extends ImmutablePureComponent {
index: 0, 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 => { handleChangeIndex = index => {
this.setState({ index: index % this.props.announcements.size }); this.setState({ index: index % this.props.announcements.size });
} }
@ -377,7 +408,7 @@ class Announcements extends ImmutablePureComponent {
<div className='announcements__container'> <div className='announcements__container'>
<ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}> <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
{announcements.map(announcement => ( {announcements.map((announcement, idx) => (
<Announcement <Announcement
key={announcement.get('id')} key={announcement.get('id')}
announcement={announcement} announcement={announcement}
@ -385,6 +416,7 @@ class Announcements extends ImmutablePureComponent {
addReaction={this.props.addReaction} addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
intl={intl} intl={intl}
selected={index === idx}
/> />
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { addReaction, removeReaction } from 'flavours/glitch/actions/announcements'; import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements';
import Announcements from '../components/announcements'; import Announcements from '../components/announcements';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
@ -12,6 +12,7 @@ const mapStateToProps = state => ({
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
addReaction: (id, name) => dispatch(addReaction(id, name)), addReaction: (id, name) => dispatch(addReaction(id, name)),
removeReaction: (id, name) => dispatch(removeReaction(id, name)), removeReaction: (id, name) => dispatch(removeReaction(id, name)),
}); });

View File

@ -24,7 +24,7 @@ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']), isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'unread']).size, unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']), showAnnouncements: state.getIn(['announcements', 'show']),
}); });

View File

@ -10,14 +10,14 @@ import {
ANNOUNCEMENTS_REACTION_REMOVE_FAIL, ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
ANNOUNCEMENTS_TOGGLE_SHOW, ANNOUNCEMENTS_TOGGLE_SHOW,
ANNOUNCEMENTS_DELETE, ANNOUNCEMENTS_DELETE,
ANNOUNCEMENTS_DISMISS_SUCCESS,
} from '../actions/announcements'; } from '../actions/announcements';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
isLoading: false, isLoading: false,
show: false, show: false,
unread: ImmutableSet(),
}); });
const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => { const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
@ -42,24 +42,11 @@ const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.
const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).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 addUnread = (state, items) => {
if (state.get('show')) {
return state;
}
const newIds = ImmutableSet(items.map(x => x.get('id')));
const oldIds = ImmutableSet(state.get('items').map(x => x.get('id')));
return state.update('unread', unread => unread.union(newIds.subtract(oldIds)));
};
const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at')); const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at'));
const updateAnnouncement = (state, announcement) => { const updateAnnouncement = (state, announcement) => {
const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id')); const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id'));
state = addUnread(state, [announcement]);
if (idx > -1) { if (idx > -1) {
// Deep merge is used because announcements from the streaming API do not contain // 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, // personalized data about which reactions have been selected by the given user,
@ -74,7 +61,6 @@ export default function announcementsReducer(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ANNOUNCEMENTS_TOGGLE_SHOW: case ANNOUNCEMENTS_TOGGLE_SHOW:
return state.withMutations(map => { return state.withMutations(map => {
if (!map.get('show')) map.set('unread', ImmutableSet());
map.set('show', !map.get('show')); map.set('show', !map.get('show'));
}); });
case ANNOUNCEMENTS_FETCH_REQUEST: case ANNOUNCEMENTS_FETCH_REQUEST:
@ -83,10 +69,6 @@ export default function announcementsReducer(state = initialState, action) {
return state.withMutations(map => { return state.withMutations(map => {
const items = fromJS(action.announcements); const items = fromJS(action.announcements);
map.set('unread', ImmutableSet());
addUnread(map, items);
map.set('items', items); map.set('items', items);
map.set('isLoading', false); map.set('isLoading', false);
}); });
@ -102,8 +84,10 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
case ANNOUNCEMENTS_REACTION_ADD_FAIL: case ANNOUNCEMENTS_REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name); return removeReaction(state, action.id, action.name);
case ANNOUNCEMENTS_DISMISS_SUCCESS:
return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true }));
case ANNOUNCEMENTS_DELETE: case ANNOUNCEMENTS_DELETE:
return state.update('unread', set => set.delete(action.id)).update('items', list => { return state.update('items', list => {
const idx = list.findIndex(x => x.get('id') === action.id); const idx = list.findIndex(x => x.get('id') === action.id);
if (idx > -1) { if (idx > -1) {

View File

@ -41,8 +41,7 @@ export default function statuses(state = initialState, action) {
case FAVOURITE_REQUEST: case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true); return state.setIn([action.status.get('id'), 'favourited'], true);
case UNFAVOURITE_SUCCESS: case UNFAVOURITE_SUCCESS:
const favouritesCount = action.status.get('favourites_count'); return state.updateIn([action.status.get('id'), 'favourites_count'], x => Math.max(0, x - 1));
return state.setIn([action.status.get('id'), 'favourites_count'], favouritesCount - 1);
case FAVOURITE_FAIL: case FAVOURITE_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
case BOOKMARK_REQUEST: case BOOKMARK_REQUEST:

View File

@ -81,6 +81,18 @@
font-weight: 500; font-weight: 500;
margin-bottom: 10px; 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 { &__pagination {

View File

@ -210,7 +210,7 @@
display: block; display: block;
object-fit: contain; object-fit: contain;
object-position: bottom left; object-position: bottom left;
width: 100%; width: 85%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
user-drag: none; user-drag: none;

View File

@ -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;
}
}

View File

@ -7,6 +7,10 @@ export const ANNOUNCEMENTS_FETCH_FAIL = 'ANNOUNCEMENTS_FETCH_FAIL';
export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE'; export const ANNOUNCEMENTS_UPDATE = 'ANNOUNCEMENTS_UPDATE';
export const ANNOUNCEMENTS_DELETE = 'ANNOUNCEMENTS_DELETE'; 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_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS'; export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL'; export const ANNOUNCEMENTS_REACTION_ADD_FAIL = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
@ -56,6 +60,32 @@ export const updateAnnouncements = announcement => ({
announcement: normalizeAnnouncement(announcement), 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) => { export const addReaction = (announcementId, name) => (dispatch, getState) => {
const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId); const announcement = getState().getIn(['announcements', 'items']).find(x => x.get('id') === announcementId);

View File

@ -302,10 +302,23 @@ class Announcement extends ImmutablePureComponent {
addReaction: PropTypes.func.isRequired, addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.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 () { render () {
const { announcement } = this.props; const { announcement } = this.props;
const { unread } = this.state;
const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at')); 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 endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
const now = new Date(); const now = new Date();
@ -330,6 +343,8 @@ class Announcement extends ImmutablePureComponent {
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
emojiMap={this.props.emojiMap} emojiMap={this.props.emojiMap}
/> />
{unread && <span className='announcements__item__unread' />}
</div> </div>
); );
} }
@ -342,6 +357,7 @@ class Announcements extends ImmutablePureComponent {
static propTypes = { static propTypes = {
announcements: ImmutablePropTypes.list, announcements: ImmutablePropTypes.list,
emojiMap: ImmutablePropTypes.map.isRequired, emojiMap: ImmutablePropTypes.map.isRequired,
dismissAnnouncement: PropTypes.func.isRequired,
addReaction: PropTypes.func.isRequired, addReaction: PropTypes.func.isRequired,
removeReaction: PropTypes.func.isRequired, removeReaction: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
@ -351,6 +367,21 @@ class Announcements extends ImmutablePureComponent {
index: 0, 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 => { handleChangeIndex = index => {
this.setState({ index: index % this.props.announcements.size }); this.setState({ index: index % this.props.announcements.size });
} }
@ -377,7 +408,7 @@ class Announcements extends ImmutablePureComponent {
<div className='announcements__container'> <div className='announcements__container'>
<ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}> <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
{announcements.map(announcement => ( {announcements.map((announcement, idx) => (
<Announcement <Announcement
key={announcement.get('id')} key={announcement.get('id')}
announcement={announcement} announcement={announcement}
@ -385,6 +416,7 @@ class Announcements extends ImmutablePureComponent {
addReaction={this.props.addReaction} addReaction={this.props.addReaction}
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
intl={intl} intl={intl}
selected={index === idx}
/> />
))} ))}
</ReactSwipeableViews> </ReactSwipeableViews>

View File

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { addReaction, removeReaction } from 'mastodon/actions/announcements'; import { addReaction, removeReaction, dismissAnnouncement } from 'mastodon/actions/announcements';
import Announcements from '../components/announcements'; import Announcements from '../components/announcements';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
@ -12,6 +12,7 @@ const mapStateToProps = state => ({
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
addReaction: (id, name) => dispatch(addReaction(id, name)), addReaction: (id, name) => dispatch(addReaction(id, name)),
removeReaction: (id, name) => dispatch(removeReaction(id, name)), removeReaction: (id, name) => dispatch(removeReaction(id, name)),
}); });

View File

@ -24,7 +24,7 @@ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']), isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'unread']).size, unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']), showAnnouncements: state.getIn(['announcements', 'show']),
}); });

View File

@ -293,6 +293,10 @@
}, },
{ {
"descriptors": [ "descriptors": [
{
"defaultMessage": "today",
"id": "relative_time.today"
},
{ {
"defaultMessage": "now", "defaultMessage": "now",
"id": "relative_time.just_now" "id": "relative_time.just_now"
@ -1742,6 +1746,14 @@
"defaultMessage": "Home", "defaultMessage": "Home",
"id": "column.home" "id": "column.home"
}, },
{
"defaultMessage": "Show announcements",
"id": "home.show_announcements"
},
{
"defaultMessage": "Hide announcements",
"id": "home.hide_announcements"
},
{ {
"defaultMessage": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", "defaultMessage": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
"id": "empty_column.home" "id": "empty_column.home"

View File

@ -188,6 +188,8 @@
"home.column_settings.basic": "Basic", "home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies", "home.column_settings.show_replies": "Show replies",
"home.hide_announcements": "Hide announcements",
"home.show_announcements": "Show announcements",
"intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
@ -338,6 +340,7 @@
"relative_time.just_now": "now", "relative_time.just_now": "now",
"relative_time.minutes": "{number}m", "relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s", "relative_time.seconds": "{number}s",
"relative_time.today": "today",
"reply_indicator.cancel": "Cancel", "reply_indicator.cancel": "Cancel",
"report.forward": "Forward to {target}", "report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?", "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",

View File

@ -10,14 +10,14 @@ import {
ANNOUNCEMENTS_REACTION_REMOVE_FAIL, ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
ANNOUNCEMENTS_TOGGLE_SHOW, ANNOUNCEMENTS_TOGGLE_SHOW,
ANNOUNCEMENTS_DELETE, ANNOUNCEMENTS_DELETE,
ANNOUNCEMENTS_DISMISS_SUCCESS,
} from '../actions/announcements'; } from '../actions/announcements';
import { Map as ImmutableMap, List as ImmutableList, Set as ImmutableSet, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
isLoading: false, isLoading: false,
show: false, show: false,
unread: ImmutableSet(),
}); });
const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => { const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
@ -42,24 +42,11 @@ const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.
const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).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 addUnread = (state, items) => {
if (state.get('show')) {
return state;
}
const newIds = ImmutableSet(items.map(x => x.get('id')));
const oldIds = ImmutableSet(state.get('items').map(x => x.get('id')));
return state.update('unread', unread => unread.union(newIds.subtract(oldIds)));
};
const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at')); const sortAnnouncements = list => list.sortBy(x => x.get('starts_at') || x.get('published_at'));
const updateAnnouncement = (state, announcement) => { const updateAnnouncement = (state, announcement) => {
const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id')); const idx = state.get('items').findIndex(x => x.get('id') === announcement.get('id'));
state = addUnread(state, [announcement]);
if (idx > -1) { if (idx > -1) {
// Deep merge is used because announcements from the streaming API do not contain // 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, // personalized data about which reactions have been selected by the given user,
@ -74,7 +61,6 @@ export default function announcementsReducer(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ANNOUNCEMENTS_TOGGLE_SHOW: case ANNOUNCEMENTS_TOGGLE_SHOW:
return state.withMutations(map => { return state.withMutations(map => {
if (!map.get('show')) map.set('unread', ImmutableSet());
map.set('show', !map.get('show')); map.set('show', !map.get('show'));
}); });
case ANNOUNCEMENTS_FETCH_REQUEST: case ANNOUNCEMENTS_FETCH_REQUEST:
@ -83,10 +69,6 @@ export default function announcementsReducer(state = initialState, action) {
return state.withMutations(map => { return state.withMutations(map => {
const items = fromJS(action.announcements); const items = fromJS(action.announcements);
map.set('unread', ImmutableSet());
addUnread(map, items);
map.set('items', items); map.set('items', items);
map.set('isLoading', false); map.set('isLoading', false);
}); });
@ -102,8 +84,10 @@ export default function announcementsReducer(state = initialState, action) {
case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST: case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
case ANNOUNCEMENTS_REACTION_ADD_FAIL: case ANNOUNCEMENTS_REACTION_ADD_FAIL:
return removeReaction(state, action.id, action.name); return removeReaction(state, action.id, action.name);
case ANNOUNCEMENTS_DISMISS_SUCCESS:
return updateAnnouncement(state, fromJS({ 'id': action.id, 'read': true }));
case ANNOUNCEMENTS_DELETE: case ANNOUNCEMENTS_DELETE:
return state.update('unread', set => set.delete(action.id)).update('items', list => { return state.update('items', list => {
const idx = list.findIndex(x => x.get('id') === action.id); const idx = list.findIndex(x => x.get('id') === action.id);
if (idx > -1) { if (idx > -1) {

View File

@ -42,8 +42,7 @@ export default function statuses(state = initialState, action) {
case FAVOURITE_REQUEST: case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true); return state.setIn([action.status.get('id'), 'favourited'], true);
case UNFAVOURITE_SUCCESS: case UNFAVOURITE_SUCCESS:
const favouritesCount = action.status.get('favourites_count'); return state.updateIn([action.status.get('id'), 'favourites_count'], x => Math.max(0, x - 1));
return state.setIn([action.status.get('id'), 'favourites_count'], favouritesCount - 1);
case FAVOURITE_FAIL: case FAVOURITE_FAIL:
return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false);
case BOOKMARK_REQUEST: case BOOKMARK_REQUEST:

View File

@ -923,6 +923,7 @@
background: transparent; background: transparent;
padding: 0; padding: 0;
padding-top: 8px; padding-top: 8px;
text-decoration: none;
&:hover, &:hover,
&:active { &:active {
@ -2522,7 +2523,7 @@ a.account__display-name {
display: block; display: block;
object-fit: contain; object-fit: contain;
object-position: bottom left; object-position: bottom left;
width: 100%; width: 85%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
user-drag: none; user-drag: none;
@ -6693,6 +6694,18 @@ noscript {
font-weight: 500; font-weight: 500;
margin-bottom: 10px; 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 { &__pagination {

View File

@ -6,6 +6,7 @@ class UserMailer < Devise::Mailer
helper :accounts helper :accounts
helper :application helper :application
helper :instance helper :instance
helper :statuses
add_template_helper RoutingHelper add_template_helper RoutingHelper

View File

@ -74,14 +74,13 @@ class Account < ApplicationRecord
enum protocol: [:ostatus, :activitypub] enum protocol: [:ostatus, :activitypub]
validates :username, presence: true validates :username, presence: true
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
# Remote user validations # Remote user validations
validates :username, uniqueness: { scope: :domain, case_sensitive: true }, if: -> { !local? && will_save_change_to_username? }
validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? } validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? }
# Local user validations # Local user validations
validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' } validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? } validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? } validates :display_name, length: { maximum: MAX_DISPLAY_NAME_LENGTH }, if: -> { local? && will_save_change_to_display_name? }
validates :note, note_length: { maximum: MAX_NOTE_LENGTH }, if: -> { local? && will_save_change_to_note? } validates :note, note_length: { maximum: MAX_NOTE_LENGTH }, if: -> { local? && will_save_change_to_note? }

View File

@ -48,7 +48,7 @@ module AccountFinderConcern
end end
def with_usernames def with_usernames
Account.where.not(username: '') Account.where.not(Account.arel_table[:username].lower.eq '')
end end
def matching_username def matching_username
@ -56,11 +56,7 @@ module AccountFinderConcern
end end
def matching_domain def matching_domain
if domain.nil? Account.where(Account.arel_table[:domain].lower.eq(domain.nil? ? nil : domain.to_s.downcase))
Account.where(domain: nil)
else
Account.where(Account.arel_table[:domain].lower.eq domain.to_s.downcase)
end
end end
end end
end end

View File

@ -4,15 +4,25 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
attributes :id, :content, :starts_at, :ends_at, :all_day, attributes :id, :content, :starts_at, :ends_at, :all_day,
:published_at, :updated_at :published_at, :updated_at
attribute :read, if: :current_user?
has_many :mentions has_many :mentions
has_many :tags, serializer: REST::StatusSerializer::TagSerializer has_many :tags, serializer: REST::StatusSerializer::TagSerializer
has_many :emojis, serializer: REST::CustomEmojiSerializer has_many :emojis, serializer: REST::CustomEmojiSerializer
has_many :reactions, serializer: REST::ReactionSerializer has_many :reactions, serializer: REST::ReactionSerializer
def current_user?
!current_user.nil?
end
def id def id
object.id.to_s object.id.to_s
end end
def read
object.announcement_mutes.where(account: current_user.account).exists?
end
def content def content
Formatter.instance.linkify(object.text) Formatter.instance.linkify(object.text)
end end

View File

@ -7,8 +7,9 @@ class UniqueUsernameValidator < ActiveModel::Validator
return if account.username.nil? return if account.username.nil?
normalized_username = account.username.downcase normalized_username = account.username.downcase
normalized_domain = account.domain&.downcase
scope = Account.where(domain: nil).where('lower(username) = ?', normalized_username) scope = Account.where(Account.arel_table[:username].lower.eq normalized_username).where(Account.arel_table[:domain].lower.eq normalized_domain)
scope = scope.where.not(id: account.id) if account.persisted? scope = scope.where.not(id: account.id) if account.persisted?
account.errors.add(:username, :taken) if scope.exists? account.errors.add(:username, :taken) if scope.exists?

View File

@ -45,6 +45,10 @@
- elsif status.preview_card - elsif status.preview_card
= react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
- if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
= t 'statuses.show_thread'
.status__action-bar .status__action-bar
.status__action-bar__counter .status__action-bar__counter
= link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button modal-button', style: 'font-size: 18px; width: 23.1429px; height: 23.1429px; line-height: 23.15px;' do

View File

@ -1103,6 +1103,7 @@ en:
other: "%{count} votes" other: "%{count} votes"
vote: Vote vote: Vote
show_more: Show more show_more: Show more
show_thread: Show thread
sign_in_to_participate: Sign in to participate in the conversation sign_in_to_participate: Sign in to participate in the conversation
title: '%{name}: "%{quote}"' title: '%{name}: "%{quote}"'
visibilities: visibilities:

View File

@ -98,7 +98,7 @@ en:
all_day: All-day event all_day: All-day event
ends_at: End of event ends_at: End of event
scheduled_at: Schedule publication scheduled_at: Schedule publication
starts_at: Begin of event starts_at: Start of event
text: Announcement text: Announcement
defaults: defaults:
autofollow: Invite to follow your account autofollow: Invite to follow your account

View File

@ -17,7 +17,7 @@ module Mastodon
end end
def flags def flags
'rc1' 'rc2'
end end
def suffix def suffix

View File

@ -619,18 +619,18 @@ RSpec.describe Account, type: :model do
end end
context 'when is remote' do context 'when is remote' do
it 'is invalid if the username is not unique in case-sensitive comparison among accounts in the same normalized domain' do it 'is invalid if the username is same among accounts in the same normalized domain' do
Fabricate(:account, domain: 'にゃん', username: 'username') Fabricate(:account, domain: 'にゃん', username: 'username')
account = Fabricate.build(:account, domain: 'xn--r9j5b5b', username: 'username') account = Fabricate.build(:account, domain: 'xn--r9j5b5b', username: 'username')
account.valid? account.valid?
expect(account).to model_have_error_on_field(:username) expect(account).to model_have_error_on_field(:username)
end end
it 'is valid even if the username is unique only in case-sensitive comparison among accounts in the same normalized domain' do it 'is invalid if the username is not unique in case-insensitive comparison among accounts in the same normalized domain' do
Fabricate(:account, domain: 'にゃん', username: 'username') Fabricate(:account, domain: 'にゃん', username: 'username')
account = Fabricate.build(:account, domain: 'xn--r9j5b5b', username: 'Username') account = Fabricate.build(:account, domain: 'xn--r9j5b5b', username: 'Username')
account.valid? account.valid?
expect(account).not_to model_have_error_on_field(:username) expect(account).to model_have_error_on_field(:username)
end end
it 'is valid even if the username contains hyphens' do it 'is valid even if the username contains hyphens' do

View File

@ -4,22 +4,65 @@ require 'rails_helper'
describe UniqueUsernameValidator do describe UniqueUsernameValidator do
describe '#validate' do describe '#validate' do
context 'when local account' do
it 'does not add errors if username is nil' do
account = double(username: nil, domain: nil, persisted?: false, errors: double(add: nil))
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
it 'does not add errors when existing one is subject itself' do
account = Fabricate(:account, username: 'abcdef')
expect(account).to be_valid
end
it 'adds an error when the username is already used with ignoring cases' do
Fabricate(:account, username: 'ABCdef')
account = double(username: 'abcDEF', domain: nil, persisted?: false, errors: double(add: nil))
subject.validate(account)
expect(account.errors).to have_received(:add)
end
it 'does not add errors when same username remote account exists' do
Fabricate(:account, username: 'abcdef', domain: 'example.com')
account = double(username: 'abcdef', domain: nil, persisted?: false, errors: double(add: nil))
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
end
end
context 'when remote account' do
it 'does not add errors if username is nil' do it 'does not add errors if username is nil' do
account = double(username: nil, persisted?: false, errors: double(add: nil)) account = double(username: nil, domain: 'example.com', persisted?: false, errors: double(add: nil))
subject.validate(account) subject.validate(account)
expect(account.errors).to_not have_received(:add) expect(account.errors).to_not have_received(:add)
end end
it 'does not add errors when existing one is subject itself' do it 'does not add errors when existing one is subject itself' do
account = Fabricate(:account, username: 'abcdef') account = Fabricate(:account, username: 'abcdef', domain: 'example.com')
expect(account).to be_valid expect(account).to be_valid
end end
it 'adds an error when the username is already used with ignoring cases' do it 'adds an error when the username is already used with ignoring cases' do
Fabricate(:account, username: 'ABCdef') Fabricate(:account, username: 'ABCdef', domain: 'example.com')
account = double(username: 'abcDEF', persisted?: false, errors: double(add: nil)) account = double(username: 'abcDEF', domain: 'example.com', persisted?: false, errors: double(add: nil))
subject.validate(account) subject.validate(account)
expect(account.errors).to have_received(:add) expect(account.errors).to have_received(:add)
end end
it 'adds an error when the domain is already used with ignoring cases' do
Fabricate(:account, username: 'ABCdef', domain: 'example.com')
account = double(username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: double(add: nil))
subject.validate(account)
expect(account.errors).to have_received(:add)
end
it 'does not add errors when account with the same username and another domain exists' do
Fabricate(:account, username: 'abcdef', domain: 'example.com')
account = double(username: 'abcdef', domain: 'example2.com', persisted?: false, errors: double(add: nil))
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
end end
end end