Merge branch 'upstream-main' into develop
Some checks failed
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
Ruby Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Historical data migration test / test (14-alpine) (push) Waiting to run
Historical data migration test / test (15-alpine) (push) Waiting to run
Historical data migration test / test (16-alpine) (push) Waiting to run
Historical data migration test / test (17-alpine) (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / Libvips tests (push) Blocked by required conditions
Ruby Testing / End to End testing (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (push) Blocked by required conditions
Check formatting / lint (push) Waiting to run
Bundler Audit / security (push) Has been cancelled
Crowdin / Upload translations / upload-translations (push) Has been cancelled
Haml Linting / lint (push) Has been cancelled

This commit is contained in:
Jeremy Kescher 2025-01-04 19:35:00 +01:00
commit c34f481005
No known key found for this signature in database
GPG key ID: 80A419A7A613DFA4
33 changed files with 115 additions and 1352 deletions

View file

@ -135,7 +135,7 @@ The following changelog entries focus on changes visible to users, administrator
- **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\ - **Add notifications of severed relationships** (#27511, #29665, #29668, #29670, #29700, #29714, #29712, and #29731 by @ClearlyClaire and @Gargron)\
Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\ Notify local users when they lose relationships as a result of a local moderator blocking a remote account or server, allowing the affected user to retrieve the list of broken relationships.\
Note that this does not notify remote users.\ Note that this does not notify remote users.\
This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`relationship_severance_event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event). This adds the `severed_relationships` notification type to the REST API and streaming, with a new [`event` attribute](https://docs.joinmastodon.org/entities/Notification/#relationship_severance_event).
- **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\ - **Add hover cards in web UI** (#30754, #30864, #30850, #30879, #30928, #30949, #30948, #30931, and #31300 by @ClearlyClaire, @Gargron, and @renchap)\
Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\ Hovering over an avatar or username will now display a hover card with the first two lines of the user's description and their first two profile fields.\
This can be disabled in the “Animations and accessibility” section of the preferences. This can be disabled in the “Animations and accessibility” section of the preferences.

View file

@ -6,7 +6,7 @@ ruby '>= 3.2.0', '< 3.5'
gem 'propshaft' gem 'propshaft'
gem 'puma', '~> 6.3' gem 'puma', '~> 6.3'
gem 'rack', '~> 2.2.7' gem 'rack', '~> 2.2.7'
gem 'rails', '~> 7.2.0' gem 'rails', '~> 8.0'
gem 'thor', '~> 1.2' gem 'thor', '~> 1.2'
gem 'dotenv' gem 'dotenv'
@ -73,7 +73,7 @@ gem 'public_suffix', '~> 6.0'
gem 'pundit', '~> 2.3' gem 'pundit', '~> 2.3'
gem 'rack-attack', '~> 6.6' gem 'rack-attack', '~> 6.6'
gem 'rack-cors', '~> 2.0', require: 'rack/cors' gem 'rack-cors', '~> 2.0', require: 'rack/cors'
gem 'rails-i18n', '~> 7.0' gem 'rails-i18n', '~> 8.0'
gem 'redcarpet', '~> 3.6' gem 'redcarpet', '~> 3.6'
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
gem 'redis-namespace', '~> 1.10' gem 'redis-namespace', '~> 1.10'

View file

@ -10,46 +10,45 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.2.1) actioncable (8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.2.2.1) actionmailbox (8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
activejob (= 7.2.2.1) activejob (= 8.0.1)
activerecord (= 7.2.2.1) activerecord (= 8.0.1)
activestorage (= 7.2.2.1) activestorage (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (7.2.2.1) actionmailer (8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
actionview (= 7.2.2.1) actionview (= 8.0.1)
activejob (= 7.2.2.1) activejob (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.2.2.1) actionpack (8.0.1)
actionview (= 7.2.2.1) actionview (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (7.2.2.1) actiontext (8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
activerecord (= 7.2.2.1) activerecord (= 8.0.1)
activestorage (= 7.2.2.1) activestorage (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.2.1) actionview (8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
@ -59,22 +58,22 @@ GEM
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.2.2.1) activejob (8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.2.1) activemodel (8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
activerecord (7.2.2.1) activerecord (8.0.1)
activemodel (= 7.2.2.1) activemodel (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.2.2.1) activestorage (8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
activejob (= 7.2.2.1) activejob (= 8.0.1)
activerecord (= 7.2.2.1) activerecord (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.2.2.1) activesupport (8.0.1)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@ -86,6 +85,7 @@ GEM
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
@ -613,20 +613,20 @@ GEM
rackup (1.0.1) rackup (1.0.1)
rack (< 3) rack (< 3)
webrick webrick
rails (7.2.2.1) rails (8.0.1)
actioncable (= 7.2.2.1) actioncable (= 8.0.1)
actionmailbox (= 7.2.2.1) actionmailbox (= 8.0.1)
actionmailer (= 7.2.2.1) actionmailer (= 8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
actiontext (= 7.2.2.1) actiontext (= 8.0.1)
actionview (= 7.2.2.1) actionview (= 8.0.1)
activejob (= 7.2.2.1) activejob (= 8.0.1)
activemodel (= 7.2.2.1) activemodel (= 8.0.1)
activerecord (= 7.2.2.1) activerecord (= 8.0.1)
activestorage (= 7.2.2.1) activestorage (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.2.1) railties (= 8.0.1)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
@ -638,12 +638,12 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (7.0.10) rails-i18n (8.0.1)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 8.0.0, < 9)
railties (7.2.2.1) railties (8.0.1)
actionpack (= 7.2.2.1) actionpack (= 8.0.1)
activesupport (= 7.2.2.1) activesupport (= 8.0.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@ -986,9 +986,9 @@ DEPENDENCIES
rack-attack (~> 6.6) rack-attack (~> 6.6)
rack-cors (~> 2.0) rack-cors (~> 2.0)
rack-test (~> 2.1) rack-test (~> 2.1)
rails (~> 7.2.0) rails (~> 8.0)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 7.0) rails-i18n (~> 8.0)
rdf-normalize (~> 0.5) rdf-normalize (~> 0.5)
redcarpet (~> 3.6) redcarpet (~> 3.6)
redis (~> 4.5) redis (~> 4.5)

View file

@ -1,21 +1,13 @@
import { IntlMessageFormat } from 'intl-messageformat'; import { IntlMessageFormat } from 'intl-messageformat';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications'; import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests } from './accounts'; import { fetchFollowRequests } from './accounts';
import { import {
importFetchedAccount, importFetchedAccount,
importFetchedAccounts,
importFetchedStatus, importFetchedStatus,
importFetchedStatuses,
} from './importer'; } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import { notificationsUpdate } from "./notifications_typed"; import { notificationsUpdate } from "./notifications_typed";
@ -26,50 +18,18 @@ export * from "./notifications_typed";
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
// tracking the notif cleaning request
export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST';
export const NOTIFICATIONS_DELETE_MARKED_SUCCESS = 'NOTIFICATIONS_DELETE_MARKED_SUCCESS';
export const NOTIFICATIONS_DELETE_MARKED_FAIL = 'NOTIFICATIONS_DELETE_MARKED_FAIL';
export const NOTIFICATIONS_MARK_ALL_FOR_DELETE = 'NOTIFICATIONS_MARK_ALL_FOR_DELETE';
export const NOTIFICATIONS_ENTER_CLEARING_MODE = 'NOTIFICATIONS_ENTER_CLEARING_MODE'; // arg: yes
// Unmark notifications (when the cleaning mode is left)
export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR_DELETE';
// Mark one for delete
export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE';
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST'; export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
}); group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING,
}); });
export function updateNotifications(notification, intlMessages, intlLocale) { export function updateNotifications(notification, intlMessages, intlLocale) {
@ -108,8 +68,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(importFetchedAccount(notification.report.target_account)); dispatch(importFetchedAccount(notification.report.target_account));
} }
dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered}));
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
} else if (playSound && !filtered) { } else if (playSound && !filtered) {
dispatch({ dispatch({
type: NOTIFICATIONS_UPDATE_NOOP, type: NOTIFICATIONS_UPDATE_NOOP,
@ -132,200 +91,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
}; };
} }
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => {
const allTypes = ImmutableList([
'follow',
'follow_request',
'favourite',
'reaction',
'reblog',
'mention',
'poll',
'status',
'update',
'admin.sign_up',
'admin.report',
]);
return allTypes.filterNot(item => item === filter).toJS();
};
const noOp = () => {}; const noOp = () => {};
let expandNotificationsController = new AbortController();
export function expandNotifications({ maxId = undefined, forceLoad = false }) {
return async (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications');
const isLoadingMore = !!maxId;
if (notifications.get('isLoading')) {
if (forceLoad) {
expandNotificationsController.abort();
expandNotificationsController = new AbortController();
} else {
return;
}
}
const params = {
max_id: maxId,
exclude_types: activeFilter === 'all'
? excludeTypesFromSettings(getState())
: excludeTypesFromFilter(activeFilter),
};
if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
const a = notifications.getIn(['pendingItems', 0, 'id']);
const b = notifications.getIn(['items', 0, 'id']);
if (a && b && compareId(a, b) > 0) {
params.since_id = a;
} else {
params.since_id = b || a;
}
}
const isLoadingRecent = !!params.since_id;
dispatch(expandNotificationsRequest(isLoadingMore));
try {
const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal });
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
dispatch(submitMarkers());
} catch(error) {
dispatch(expandNotificationsFail(error, isLoadingMore));
}
};
}
export function expandNotificationsRequest(isLoadingMore) {
return {
type: NOTIFICATIONS_EXPAND_REQUEST,
skipLoading: !isLoadingMore,
};
}
export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
return {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
next,
isLoadingRecent: isLoadingRecent,
usePendingItems,
skipLoading: !isLoadingMore,
};
}
export function expandNotificationsFail(error, isLoadingMore) {
return {
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
skipAlert: !isLoadingMore || error.name === 'AbortError',
};
}
export function scrollTopNotifications(top) {
return {
type: NOTIFICATIONS_SCROLL_TOP,
top,
};
}
export function deleteMarkedNotifications() {
return (dispatch, getState) => {
dispatch(deleteMarkedNotificationsRequest());
let ids = [];
getState().getIn(['notifications', 'items']).forEach((n) => {
if (n.get('markedForDelete')) {
ids.push(n.get('id'));
}
});
if (ids.length === 0) {
return;
}
api().delete(`/api/v1/notifications/destroy_multiple?ids[]=${ids.join('&ids[]=')}`).then(() => {
dispatch(deleteMarkedNotificationsSuccess());
}).catch(error => {
console.error(error);
dispatch(deleteMarkedNotificationsFail(error));
});
};
}
export function enterNotificationClearingMode(yes) {
return {
type: NOTIFICATIONS_ENTER_CLEARING_MODE,
yes: yes,
};
}
export function markAllNotifications(yes) {
return {
type: NOTIFICATIONS_MARK_ALL_FOR_DELETE,
yes: yes, // true, false or null. null = invert
};
}
export function deleteMarkedNotificationsRequest() {
return {
type: NOTIFICATIONS_DELETE_MARKED_REQUEST,
};
}
export function deleteMarkedNotificationsFail() {
return {
type: NOTIFICATIONS_DELETE_MARKED_FAIL,
};
}
export function markNotificationForDelete(id, yes) {
return {
type: NOTIFICATION_MARK_FOR_DELETE,
id: id,
yes: yes,
};
}
export function deleteMarkedNotificationsSuccess() {
return {
type: NOTIFICATIONS_DELETE_MARKED_SUCCESS,
};
}
export function mountNotifications() {
return {
type: NOTIFICATIONS_MOUNT,
};
}
export function unmountNotifications() {
return {
type: NOTIFICATIONS_UNMOUNT,
};
}
export function notificationsSetVisibility(visibility) {
return {
type: NOTIFICATIONS_SET_VISIBILITY,
visibility: visibility,
};
}
export function setFilter (filterType) { export function setFilter (filterType) {
return dispatch => { return dispatch => {
dispatch({ dispatch({
@ -333,17 +100,10 @@ export function setFilter (filterType) {
path: ['notifications', 'quickFilter', 'active'], path: ['notifications', 'quickFilter', 'active'],
value: filterType, value: filterType,
}); });
dispatch(expandNotifications({ forceLoad: true }));
dispatch(saveSettings()); dispatch(saveSettings());
}; };
} }
export function markNotificationsAsRead() {
return {
type: NOTIFICATIONS_MARK_AS_READ,
};
}
// Browser support // Browser support
export function setupBrowserNotifications() { export function setupBrowserNotifications() {
return dispatch => { return dispatch => {

View file

@ -1,10 +0,0 @@
import { createAppAsyncThunk } from 'flavours/glitch/store';
import { fetchNotifications } from './notification_groups';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch }) => {
void dispatch(fetchNotifications());
},
);

View file

@ -9,7 +9,6 @@ export const notificationsUpdate = createAction(
...args ...args
}: { }: {
notification: ApiNotificationJSON; notification: ApiNotificationJSON;
usePendingItems: boolean;
playSound: boolean; playSound: boolean;
}) => ({ }) => ({
payload: args, payload: args,

View file

@ -11,7 +11,7 @@ import {
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
updateTimeline, updateTimeline,
@ -107,9 +107,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
break; break;
} }
case 'notifications_merged': { case 'notifications_merged': {
const state = getState();
if (state.notifications.top || !state.notifications.mounted)
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
dispatch(refreshStaleNotificationGroups()); dispatch(refreshStaleNotificationGroups());
break; break;
} }

View file

@ -1,64 +0,0 @@
/**
* Buttons widget for controlling the notification clearing mode.
* In idle state, the cleaning mode button is shown. When the mode is active,
* a Confirm and Abort buttons are shown in its place.
*/
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import ImmutablePureComponent from 'react-immutable-pure-component';
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
btnAll : { id: 'notification_purge.btn_all', defaultMessage: 'Select\nall' },
btnNone : { id: 'notification_purge.btn_none', defaultMessage: 'Select\nnone' },
btnInvert : { id: 'notification_purge.btn_invert', defaultMessage: 'Invert\nselection' },
btnApply : { id: 'notification_purge.btn_apply', defaultMessage: 'Clear\nselected' },
});
class NotificationPurgeButtons extends ImmutablePureComponent {
static propTypes = {
onDeleteMarked : PropTypes.func.isRequired,
onMarkAll : PropTypes.func.isRequired,
onMarkNone : PropTypes.func.isRequired,
onInvert : PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
markNewForDelete: PropTypes.bool,
};
render () {
const { intl, markNewForDelete } = this.props;
//className='active'
return (
<div className='column-header__notif-cleaning-buttons'>
<button onClick={this.props.onMarkAll} className={classNames('column-header__button', { active: markNewForDelete })}>
<b></b><br />{intl.formatMessage(messages.btnAll)}
</button>
<button onClick={this.props.onMarkNone} className={classNames('column-header__button', { active: !markNewForDelete })}>
<b></b><br />{intl.formatMessage(messages.btnNone)}
</button>
<button onClick={this.props.onInvert} className='column-header__button'>
<b>¬</b><br />{intl.formatMessage(messages.btnInvert)}
</button>
<button onClick={this.props.onDeleteMarked} className='column-header__button'>
<Icon id='trash' icon={DeleteIcon} /><br />{intl.formatMessage(messages.btnApply)}
</button>
</div>
);
}
}
export default injectIntl(NotificationPurgeButtons);

View file

@ -11,7 +11,6 @@ import { HotKeys } from 'react-hotkeys';
import { ContentWarning } from 'flavours/glitch/components/content_warning'; import { ContentWarning } from 'flavours/glitch/components/content_warning';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/glitch/utils/react_router';
@ -745,12 +744,6 @@ class Status extends ImmutablePureComponent {
onFilter={matchedFilters ? this.handleFilterClick : null} onFilter={matchedFilters ? this.handleFilterClick : null}
{...other} {...other}
/> />
{notification && (
<NotificationOverlayContainer
notification={notification}
/>
)}
</div> </div>
</div> </div>
</HotKeys> </HotKeys>

View file

@ -94,7 +94,7 @@ class TranslateButton extends PureComponent {
if (translation) { if (translation) {
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language')); const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
const languageName = language ? language[2] : translation.get('detected_source_language'); const languageName = language ? language[1] : translation.get('detected_source_language');
const provider = translation.get('provider'); const provider = translation.get('provider');
return ( return (

View file

@ -1,53 +0,0 @@
// Package imports.
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
// Our imports.
import { openModal } from 'flavours/glitch/actions/modal';
import {
deleteMarkedNotifications,
enterNotificationClearingMode,
markAllNotifications,
} from 'flavours/glitch/actions/notifications';
import NotificationPurgeButtons from 'flavours/glitch/components/notification_purge_buttons';
const messages = defineMessages({
clearMessage: { id: 'notifications.marked_clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all selected notifications?' },
clearConfirm: { id: 'notifications.marked_clear', defaultMessage: 'Clear selected notifications' },
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onEnterCleaningMode(yes) {
dispatch(enterNotificationClearingMode(yes));
},
onDeleteMarked() {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.clearMessage),
confirm: intl.formatMessage(messages.clearConfirm),
onConfirm: () => dispatch(deleteMarkedNotifications()),
},
}));
},
onMarkAll() {
dispatch(markAllNotifications(true));
},
onMarkNone() {
dispatch(markAllNotifications(false));
},
onInvert() {
dispatch(markAllNotifications(null));
},
});
const mapStateToProps = state => ({
markNewForDelete: state.getIn(['notifications', 'markNewForDelete']),
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationPurgeButtons));

View file

@ -20,7 +20,6 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import FollowRequestContainer from '../containers/follow_request_container'; import FollowRequestContainer from '../containers/follow_request_container';
import NotificationOverlayContainer from '../containers/overlay_container';
import { ModerationWarning } from './moderation_warning'; import { ModerationWarning } from './moderation_warning';
import { RelationshipsSeveranceEvent } from './relationships_severance_event'; import { RelationshipsSeveranceEvent } from './relationships_severance_event';
@ -133,7 +132,6 @@ class Notification extends ImmutablePureComponent {
</div> </div>
<Account id={account.get('id')} hidden={this.props.hidden} /> <Account id={account.get('id')} hidden={this.props.hidden} />
<NotificationOverlayContainer notification={notification} />
</div> </div>
</HotKeys> </HotKeys>
); );
@ -154,7 +152,6 @@ class Notification extends ImmutablePureComponent {
</div> </div>
<FollowRequestContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} /> <FollowRequestContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
<NotificationOverlayContainer notification={notification} />
</div> </div>
</HotKeys> </HotKeys>
); );
@ -391,7 +388,6 @@ class Notification extends ImmutablePureComponent {
</div> </div>
<Account id={account.get('id')} hidden={this.props.hidden} /> <Account id={account.get('id')} hidden={this.props.hidden} />
<NotificationOverlayContainer notification={notification} />
</div> </div>
</HotKeys> </HotKeys>
); );
@ -431,7 +427,6 @@ class Notification extends ImmutablePureComponent {
</div> </div>
<Report account={account} report={notification.get('report')} hidden={this.props.hidden} /> <Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
<NotificationOverlayContainer notification={notification} />
</div> </div>
</HotKeys> </HotKeys>
); );

View file

@ -1,61 +0,0 @@
/**
* Notification overlay
*/
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { Icon } from 'flavours/glitch/components/icon';
const messages = defineMessages({
markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
});
class NotificationOverlay extends ImmutablePureComponent {
static propTypes = {
notification : ImmutablePropTypes.map.isRequired,
onMarkForDelete : PropTypes.func.isRequired,
show : PropTypes.bool.isRequired,
intl : PropTypes.object.isRequired,
};
onToggleMark = () => {
const mark = !this.props.notification.get('markedForDelete');
const id = this.props.notification.get('id');
this.props.onMarkForDelete(id, mark);
};
render () {
const { notification, show, intl } = this.props;
const active = notification.get('markedForDelete');
const label = intl.formatMessage(messages.markForDeletion);
return show ? (
<div
aria-label={label}
role='checkbox'
aria-checked={active}
tabIndex={0}
className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
onClick={this.onToggleMark}
>
<div className='wrappy'>
<div className='ckbox' aria-hidden='true' title={label}>
{active ? (<Icon id='check' icon={CheckIcon} />) : ''}
</div>
</div>
</div>
) : null;
}
}
export default injectIntl(NotificationOverlay);

View file

@ -3,7 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { initializeNotifications } from 'flavours/glitch/actions/notifications_migration'; import { fetchNotifications } from 'flavours/glitch/actions/notification_groups';
import { showAlert } from '../../../actions/alerts'; import { showAlert } from '../../../actions/alerts';
import { setFilter, requestBrowserPermission } from '../../../actions/notifications'; import { setFilter, requestBrowserPermission } from '../../../actions/notifications';
@ -60,7 +60,7 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
if(path[0] === 'group' && path[1] === 'follow') { if(path[0] === 'group' && path[1] === 'follow') {
dispatch(initializeNotifications()); dispatch(fetchNotifications());
} }
} }
}, },

View file

@ -2,8 +2,8 @@ import { connect } from 'react-redux';
import { mentionCompose } from '../../../actions/compose'; import { mentionCompose } from '../../../actions/compose';
import { import {
toggleReblog,
toggleFavourite, toggleFavourite,
toggleReblog,
} from '../../../actions/interactions'; } from '../../../actions/interactions';
import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors'; import { makeGetNotification, makeGetStatus, makeGetReport } from '../../../selectors';
import Notification from '../components/notification'; import Notification from '../components/notification';
@ -19,7 +19,6 @@ const makeMapStateToProps = () => {
notification: notification, notification: notification,
status: notification.get('status') ? getStatus(state, { id: notification.get('status'), contextType: 'notifications' }) : null, status: notification.get('status') ? getStatus(state, { id: notification.get('status'), contextType: 'notifications' }) : null,
report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null, report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null,
notifCleaning: state.getIn(['notifications', 'cleaningMode']),
}; };
}; };

View file

@ -1,19 +0,0 @@
// Package imports.
import { connect } from 'react-redux';
// Our imports.
import { markNotificationForDelete } from 'flavours/glitch/actions/notifications';
import NotificationOverlay from '../components/overlay';
const mapDispatchToProps = dispatch => ({
onMarkForDelete(id, yes) {
dispatch(markNotificationForDelete(id, yes));
},
});
const mapStateToProps = state => ({
show: state.getIn(['notifications', 'cleaningMode']),
});
export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);

View file

@ -14,7 +14,7 @@ import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'flavours/glitch/actions/app'; import { focusApp, unfocusApp, changeLayout } from 'flavours/glitch/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
import { initializeNotifications } from 'flavours/glitch/actions/notifications_migration'; import { fetchNotifications } from 'flavours/glitch/actions/notification_groups';
import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding'; import { INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
import { HoverCardController } from 'flavours/glitch/components/hover_card_controller'; import { HoverCardController } from 'flavours/glitch/components/hover_card_controller';
import { Permalink } from 'flavours/glitch/components/permalink'; import { Permalink } from 'flavours/glitch/components/permalink';
@ -26,7 +26,6 @@ import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { notificationsSetVisibility } from '../../actions/notifications';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, timelinePreview, disableHoverCards } from '../../initial_state'; import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, timelinePreview, disableHoverCards } from '../../initial_state';
@ -316,7 +315,6 @@ class UI extends PureComponent {
handleVisibilityChange = () => { handleVisibilityChange = () => {
const visibility = !document[this.visibilityHiddenProp]; const visibility = !document[this.visibilityHiddenProp];
this.props.dispatch(notificationsSetVisibility(visibility));
if (visibility) { if (visibility) {
this.props.dispatch(focusApp()); this.props.dispatch(focusApp());
this.props.dispatch(submitMarkers({ immediate: true })); this.props.dispatch(submitMarkers({ immediate: true }));
@ -436,7 +434,7 @@ class UI extends PureComponent {
if (signedIn) { if (signedIn) {
this.props.dispatch(fetchMarkers()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(initializeNotifications()); this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages()); this.props.dispatch(fetchServerTranslationLanguages());
setTimeout(() => this.props.dispatch(fetchServer()), 3000); setTimeout(() => this.props.dispatch(fetchServer()), 3000);

View file

@ -148,13 +148,22 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version'); export const version = getMeta('version');
export const visibleReactions = getMeta('visible_reactions'); export const visibleReactions = getMeta('visible_reactions');
export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending; export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled'); export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
const displayNames = Intl.DisplayNames && new Intl.DisplayNames(getMeta('locale'), {
type: 'language',
fallback: 'none',
languageDisplay: 'standard',
});
export const languages = initialState?.languages?.map(lang => {
// zh-YUE is not a valid CLDR unicode_language_id
return [lang[0], displayNames?.of(lang[0].replace('zh-YUE', 'yue')) || lang[1], lang[2]];
});
// Glitch-soc-specific settings // Glitch-soc-specific settings
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4; export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;
export const favouriteModal = getMeta('favourite_modal'); export const favouriteModal = getMeta('favourite_modal');

View file

@ -51,18 +51,11 @@
"navigation_bar.app_settings": "App settings", "navigation_bar.app_settings": "App settings",
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts", "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
"navigation_bar.misc": "Misc", "navigation_bar.misc": "Misc",
"notification.markForDeletion": "Mark for deletion",
"notification.reaction": "{name} reacted to your post", "notification.reaction": "{name} reacted to your post",
"notification.reaction.name_and_others": "{name} and {count, plural, one {# other} other {# others}} reacted to your post", "notification.reaction.name_and_others": "{name} and {count, plural, one {# other} other {# others}} reacted to your post",
"notification_purge.btn_all": "Select\nall",
"notification_purge.btn_apply": "Clear\nselected",
"notification_purge.btn_invert": "Invert\nselection",
"notification_purge.btn_none": "Select\nnone",
"notifications.column_settings.filter_bar.show_bar": "Show filter bar", "notifications.column_settings.filter_bar.show_bar": "Show filter bar",
"notifications.column_settings.reaction": "Reactions:", "notifications.column_settings.reaction": "Reactions:",
"notifications.filter.reactions": "Reactions", "notifications.filter.reactions": "Reactions",
"notifications.marked_clear": "Clear selected notifications",
"notifications.marked_clear_confirmation": "Are you sure you want to permanently clear all selected notifications?",
"settings.always_show_spoilers_field": "Always enable the Content Warning field", "settings.always_show_spoilers_field": "Always enable the Content Warning field",
"settings.close": "Close", "settings.close": "Close",
"settings.compose_box_opts": "Compose box", "settings.compose_box_opts": "Compose box",

View file

@ -1,379 +1,32 @@
import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { fromJS, Map as ImmutableMap } from 'immutable';
import { blockDomainSuccess } from 'flavours/glitch/actions/domain_blocks';
import { timelineDelete } from 'flavours/glitch/actions/timelines_typed';
import { import {
authorizeFollowRequestSuccess,
blockAccountSuccess,
muteAccountSuccess,
rejectFollowRequestSuccess,
} from '../actions/accounts';
import {
fetchMarkers,
} from '../actions/markers';
import { clearNotifications } from '../actions/notification_groups';
import {
NOTIFICATIONS_MOUNT,
NOTIFICATIONS_UNMOUNT,
NOTIFICATIONS_SET_VISIBILITY,
notificationsUpdate,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_SCROLL_TOP,
NOTIFICATIONS_LOAD_PENDING,
NOTIFICATIONS_DELETE_MARKED_REQUEST,
NOTIFICATIONS_DELETE_MARKED_SUCCESS,
NOTIFICATION_MARK_FOR_DELETE,
NOTIFICATIONS_DELETE_MARKED_FAIL,
NOTIFICATIONS_ENTER_CLEARING_MODE,
NOTIFICATIONS_MARK_ALL_FOR_DELETE,
NOTIFICATIONS_MARK_AS_READ,
NOTIFICATIONS_SET_BROWSER_SUPPORT, NOTIFICATIONS_SET_BROWSER_SUPPORT,
NOTIFICATIONS_SET_BROWSER_PERMISSION, NOTIFICATIONS_SET_BROWSER_PERMISSION,
} from '../actions/notifications'; } from '../actions/notifications';
import { disconnectTimeline } from '../actions/timelines';
import { compareId } from '../compare_id';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
pendingItems: ImmutableList(),
items: ImmutableList(),
hasMore: true,
top: false,
mounted: 0,
unread: 0,
lastReadId: '0',
readMarkerId: '0',
isLoading: 0,
cleaningMode: false,
isTabVisible: true,
browserSupport: false, browserSupport: false,
browserPermission: 'default', browserPermission: 'default',
// notification removal mark of new notifs loaded whilst cleaningMode is true.
markNewForDelete: false,
}); });
export const notificationToMap = (notification) => ImmutableMap({ export const notificationToMap = notification => ImmutableMap({
id: notification.id, id: notification.id,
type: notification.type, type: notification.type,
account: notification.account.id, account: notification.account.id,
markedForDelete: false, created_at: notification.created_at,
status: notification.status ? notification.status.id : null, status: notification.status ? notification.status.id : null,
report: notification.report ? fromJS(notification.report) : null, report: notification.report ? fromJS(notification.report) : null,
event: notification.event ? fromJS(notification.event) : null, event: notification.event ? fromJS(notification.event) : null,
moderation_warning: notification.moderation_warning ? fromJS(notification.moderation_warning) : null, moderation_warning: notification.moderation_warning ? fromJS(notification.moderation_warning) : null,
}); });
const normalizeNotification = (state, notification, usePendingItems) => {
const markNewForDelete = state.get('markNewForDelete');
const top = state.get('top');
// Under currently unknown conditions, the client may receive duplicates from the server
if (state.get('pendingItems').some((item) => item?.get('id') === notification.id) || state.get('items').some((item) => item?.get('id') === notification.id)) {
return state;
}
if (usePendingItems || !state.get('pendingItems').isEmpty()) {
return state.update('pendingItems', list => list.unshift(notificationToMap(notification).set('markForDelete', markNewForDelete))).update('unread', unread => unread + 1);
}
if (shouldCountUnreadNotifications(state)) {
state = state.update('unread', unread => unread + 1);
} else {
state = state.set('lastReadId', notification.id);
}
return state.update('items', list => {
if (top && list.size > 40) {
list = list.take(20);
}
return list.unshift(notificationToMap(notification).set('markForDelete', markNewForDelete));
});
};
const expandNormalizedNotifications = (state, notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because:
// - existing notifications might be out of order
// - the existing notifications may have gaps, most often explicitly noted with a `null` item
// - ideally, we don't want it to reorder existing items
// - `notifications` may include items that are already included
// - this function can be called either to fill in a gap, or load newer items
const markNewForDelete = state.get('markNewForDelete');
const lastReadId = state.get('lastReadId');
const newItems = ImmutableList(notifications.map((notification) => notificationToMap(notification).set('markForDelete', markNewForDelete)));
return state.withMutations(mutable => {
if (!newItems.isEmpty()) {
usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('pendingItems').isEmpty());
mutable.update(usePendingItems ? 'pendingItems' : 'items', oldItems => {
// If called to poll *new* notifications, we just need to add them on top without duplicates
if (isLoadingRecent) {
const idsToCheck = oldItems.map(item => item?.get('id')).toSet();
const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
return insertedItems.concat(oldItems);
}
// If called to expand more (presumably older than any known to the WebUI), we just have to
// add them to the bottom without duplicates
if (isLoadingMore) {
const idsToCheck = oldItems.map(item => item?.get('id')).toSet();
const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
return oldItems.concat(insertedItems);
}
// Now this gets tricky, as we don't necessarily know for sure where the gap to fill is,
// and some items in the timeline may not be properly ordered.
// However, we know that `newItems.last()` is the oldest item that was requested and that
// there is no “hole” between `newItems.last()` and `newItems.first()`.
// First, find the furthest (if properly sorted, oldest) item in the notifications that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item.
const lastIndex = oldItems.findLastIndex(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the notifications that
// is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newItems` (or that were deleted from the server, so should be removed
// anyway).
// Stop the gap *after* that item.
const firstIndex = oldItems.take(lastIndex).findLastIndex(item => item !== null && compareId(item.get('id'), newItems.first().get('id')) > 0) + 1;
// At this point:
// - no `oldItems` after `firstIndex` is newer than any of the `newItems`
// - all `oldItems` after `lastIndex` are older than every of the `newItems`
// - it is possible for items in the replaced slice to be older than every `newItems`
// - it is possible for items before `firstIndex` to be in the `newItems` range
// Therefore:
// - to avoid losing items, items from the replaced slice that are older than `newItems`
// should be added in the back.
// - to avoid duplicates, `newItems` should be checked the first `firstIndex` items of
// `oldItems`
const idsToCheck = oldItems.take(firstIndex).map(item => item?.get('id')).toSet();
const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
const olderItems = oldItems.slice(firstIndex, lastIndex).filter(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) < 0);
return oldItems.take(firstIndex).concat(
insertedItems,
olderItems,
oldItems.skip(lastIndex),
);
});
}
if (!next) {
mutable.set('hasMore', false);
}
if (shouldCountUnreadNotifications(state)) {
mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), lastReadId) > 0));
} else {
const mostRecent = newItems.find(item => item !== null);
if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
mutable.set('lastReadId', mostRecent.get('id'));
}
}
mutable.update('isLoading', (nbLoading) => nbLoading - 1);
});
};
const filterNotifications = (state, accountIds, type) => {
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type')));
return state.update('items', helper).update('pendingItems', helper);
};
const clearUnread = (state) => {
state = state.set('unread', state.get('pendingItems').size);
const lastNotification = state.get('items').find(item => item !== null);
return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
};
const updateTop = (state, top) => {
state = state.set('top', top);
if (!shouldCountUnreadNotifications(state)) {
state = clearUnread(state);
}
return state;
};
const deleteByStatus = (state, statusId) => {
const lastReadId = state.get('lastReadId');
if (shouldCountUnreadNotifications(state)) {
const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
state = state.update('unread', unread => unread - deletedUnread.size);
}
const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
state = state.update('unread', unread => unread - deletedUnread.size);
return state.update('items', helper).update('pendingItems', helper);
};
const markForDelete = (state, notificationId, yes) => {
return state.update('items', list => list.map(item => {
if (item === null) {
return null;
} else if(item.get('id') === notificationId) {
return item.set('markedForDelete', yes);
} else {
return item;
}
}));
};
const markAllForDelete = (state, yes) => {
return state.update('items', list => list.map(item => {
if (item === null) {
return null;
} else if(yes !== null) {
return item.set('markedForDelete', yes);
} else {
return item.set('markedForDelete', !item.get('markedForDelete'));
}
}));
};
const unmarkAllForDelete = (state) => {
return state.update('items', list => list.map(item => item === null ? item : item.set('markedForDelete', false)));
};
const deleteMarkedNotifs = (state) => {
return state.update('items', list => list.filterNot(item => item === null ? item : item.get('markedForDelete')));
};
const updateMounted = (state) => {
state = state.update('mounted', count => count + 1);
if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
state = state.set('readMarkerId', state.get('lastReadId'));
state = clearUnread(state);
}
return state;
};
const updateVisibility = (state, visibility) => {
state = state.set('isTabVisible', visibility);
if (!shouldCountUnreadNotifications(state)) {
state = state.set('readMarkerId', state.get('lastReadId'));
state = clearUnread(state);
}
return state;
};
const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
const isTabVisible = state.get('isTabVisible');
const isOnTop = state.get('top');
const isMounted = state.get('mounted') > 0;
const lastReadId = state.get('lastReadId');
const lastItem = state.get('items').findLast(item => item !== null);
const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
};
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 (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
mutable.set('readMarkerId', 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) { export default function notifications(state = initialState, action) {
let st;
switch(action.type) { switch(action.type) {
case fetchMarkers.fulfilled.type:
return action.payload.markers.notifications ? recountUnread(state, action.payload.markers.notifications.last_read_id) : state;
case NOTIFICATIONS_MOUNT:
return updateMounted(state);
case NOTIFICATIONS_UNMOUNT:
return state.update('mounted', count => count - 1);
case NOTIFICATIONS_SET_VISIBILITY:
return updateVisibility(state, action.visibility);
case NOTIFICATIONS_LOAD_PENDING:
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
case NOTIFICATIONS_EXPAND_REQUEST:
case NOTIFICATIONS_DELETE_MARKED_REQUEST:
return state.update('isLoading', (nbLoading) => nbLoading + 1);
case NOTIFICATIONS_DELETE_MARKED_FAIL:
case NOTIFICATIONS_EXPAND_FAIL:
return state.update('isLoading', (nbLoading) => nbLoading - 1);
case NOTIFICATIONS_FILTER_SET:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case notificationsUpdate.type:
return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems);
case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems);
case blockAccountSuccess.type:
return filterNotifications(state, [action.payload.relationship.id]);
case muteAccountSuccess.type:
return action.payload.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state;
case blockDomainSuccess.type:
return filterNotifications(state, action.payload.accounts);
case authorizeFollowRequestSuccess.type:
case rejectFollowRequestSuccess.type:
return filterNotifications(state, [action.payload.id], 'follow_request');
case clearNotifications.pending.type:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case timelineDelete.type:
return deleteByStatus(state, action.payload.statusId);
case disconnectTimeline.type:
return action.payload.timeline === 'home' ?
state.update(action.payload.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
state;
case NOTIFICATIONS_SET_BROWSER_SUPPORT: case NOTIFICATIONS_SET_BROWSER_SUPPORT:
return state.set('browserSupport', action.value); return state.set('browserSupport', action.value);
case NOTIFICATIONS_SET_BROWSER_PERMISSION: case NOTIFICATIONS_SET_BROWSER_PERMISSION:
return state.set('browserPermission', action.value); return state.set('browserPermission', action.value);
case NOTIFICATION_MARK_FOR_DELETE:
return markForDelete(state, action.id, action.yes);
case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
return deleteMarkedNotifs(state).update('isLoading', (nbLoading) => nbLoading - 1);
case NOTIFICATIONS_ENTER_CLEARING_MODE:
st = state.set('cleaningMode', action.yes);
if (!action.yes) {
return unmarkAllForDelete(st).set('markNewForDelete', false);
} else {
return st;
}
case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
st = state;
if (action.yes === null) {
// Toggle - this is a bit confusing, as it toggles the all-none mode
//st = st.set('markNewForDelete', !st.get('markNewForDelete'));
} else {
st = st.set('markNewForDelete', action.yes);
}
return markAllForDelete(st, action.yes);
case NOTIFICATIONS_MARK_AS_READ: {
const lastNotification = state.get('items').find(item => item !== null);
return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
}
default: default:
return state; return state;
} }

View file

@ -1,21 +1,13 @@
import { IntlMessageFormat } from 'intl-messageformat'; import { IntlMessageFormat } from 'intl-messageformat';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { compareId } from 'mastodon/compare_id';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html'; import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications'; import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests } from './accounts'; import { fetchFollowRequests } from './accounts';
import { import {
importFetchedAccount, importFetchedAccount,
importFetchedAccounts,
importFetchedStatus, importFetchedStatus,
importFetchedStatuses,
} from './importer'; } from './importer';
import { submitMarkers } from './markers'; import { submitMarkers } from './markers';
import { notificationsUpdate } from "./notifications_typed"; import { notificationsUpdate } from "./notifications_typed";
@ -26,27 +18,11 @@ export * from "./notifications_typed";
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST';
export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS';
export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL';
export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST'; export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST';
export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS';
export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL';
@ -56,10 +32,6 @@ defineMessages({
group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
}); });
export const loadPending = () => ({
type: NOTIFICATIONS_LOAD_PENDING,
});
export function updateNotifications(notification, intlMessages, intlLocale) { export function updateNotifications(notification, intlMessages, intlLocale) {
return (dispatch, getState) => { return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']); const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
@ -96,8 +68,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
dispatch(importFetchedAccount(notification.report.target_account)); dispatch(importFetchedAccount(notification.report.target_account));
} }
dispatch(notificationsUpdate({ notification, playSound: playSound && !filtered}));
dispatch(notificationsUpdate({ notification, preferPendingItems, playSound: playSound && !filtered}));
} else if (playSound && !filtered) { } else if (playSound && !filtered) {
dispatch({ dispatch({
type: NOTIFICATIONS_UPDATE_NOOP, type: NOTIFICATIONS_UPDATE_NOOP,
@ -120,116 +91,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
}; };
} }
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => {
const allTypes = ImmutableList([
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'poll',
'status',
'update',
'admin.sign_up',
'admin.report',
]);
return allTypes.filterNot(item => item === filter).toJS();
};
const noOp = () => {}; const noOp = () => {};
let expandNotificationsController = new AbortController();
export function expandNotifications({ maxId = undefined, forceLoad = false }) {
return async (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications');
const isLoadingMore = !!maxId;
if (notifications.get('isLoading')) {
if (forceLoad) {
expandNotificationsController.abort();
expandNotificationsController = new AbortController();
} else {
return;
}
}
const params = {
max_id: maxId,
exclude_types: activeFilter === 'all'
? excludeTypesFromSettings(getState())
: excludeTypesFromFilter(activeFilter),
};
if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) {
const a = notifications.getIn(['pendingItems', 0, 'id']);
const b = notifications.getIn(['items', 0, 'id']);
if (a && b && compareId(a, b) > 0) {
params.since_id = a;
} else {
params.since_id = b || a;
}
}
const isLoadingRecent = !!params.since_id;
dispatch(expandNotificationsRequest(isLoadingMore));
try {
const response = await api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal });
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
dispatch(submitMarkers());
} catch(error) {
dispatch(expandNotificationsFail(error, isLoadingMore));
}
};
}
export function expandNotificationsRequest(isLoadingMore) {
return {
type: NOTIFICATIONS_EXPAND_REQUEST,
skipLoading: !isLoadingMore,
};
}
export function expandNotificationsSuccess(notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) {
return {
type: NOTIFICATIONS_EXPAND_SUCCESS,
notifications,
next,
isLoadingRecent: isLoadingRecent,
usePendingItems,
skipLoading: !isLoadingMore,
};
}
export function expandNotificationsFail(error, isLoadingMore) {
return {
type: NOTIFICATIONS_EXPAND_FAIL,
error,
skipLoading: !isLoadingMore,
skipAlert: !isLoadingMore || error.name === 'AbortError',
};
}
export function scrollTopNotifications(top) {
return {
type: NOTIFICATIONS_SCROLL_TOP,
top,
};
}
export function setFilter (filterType) { export function setFilter (filterType) {
return dispatch => { return dispatch => {
dispatch({ dispatch({
@ -237,24 +100,10 @@ export function setFilter (filterType) {
path: ['notifications', 'quickFilter', 'active'], path: ['notifications', 'quickFilter', 'active'],
value: filterType, value: filterType,
}); });
dispatch(expandNotifications({ forceLoad: true }));
dispatch(saveSettings()); dispatch(saveSettings());
}; };
} }
export const mountNotifications = () => ({
type: NOTIFICATIONS_MOUNT,
});
export const unmountNotifications = () => ({
type: NOTIFICATIONS_UNMOUNT,
});
export const markNotificationsAsRead = () => ({
type: NOTIFICATIONS_MARK_AS_READ,
});
// Browser support // Browser support
export function setupBrowserNotifications() { export function setupBrowserNotifications() {
return dispatch => { return dispatch => {

View file

@ -1,10 +0,0 @@
import { createAppAsyncThunk } from 'mastodon/store';
import { fetchNotifications } from './notification_groups';
export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch }) => {
void dispatch(fetchNotifications());
},
);

View file

@ -9,7 +9,6 @@ export const notificationsUpdate = createAction(
...args ...args
}: { }: {
notification: ApiNotificationJSON; notification: ApiNotificationJSON;
usePendingItems: boolean;
playSound: boolean; playSound: boolean;
}) => ({ }) => ({
payload: args, payload: args,

View file

@ -11,7 +11,7 @@ import {
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups'; import { processNewNotificationForGroups, refreshStaleNotificationGroups, pollRecentNotifications as pollRecentGroupNotifications } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
updateTimeline, updateTimeline,
@ -107,9 +107,6 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
break; break;
} }
case 'notifications_merged': { case 'notifications_merged': {
const state = getState();
if (state.notifications.top || !state.notifications.mounted)
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
dispatch(refreshStaleNotificationGroups()); dispatch(refreshStaleNotificationGroups());
break; break;
} }

View file

@ -38,7 +38,7 @@ class TranslateButton extends PureComponent {
if (translation) { if (translation) {
const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language')); const language = preloadedLanguages.find(lang => lang[0] === translation.get('detected_source_language'));
const languageName = language ? language[2] : translation.get('detected_source_language'); const languageName = language ? language[1] : translation.get('detected_source_language');
const provider = translation.get('provider'); const provider = translation.get('provider');
return ( return (

View file

@ -3,7 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { initializeNotifications } from 'mastodon/actions/notifications_migration'; import { fetchNotifications } from 'mastodon/actions/notification_groups';
import { showAlert } from '../../../actions/alerts'; import { showAlert } from '../../../actions/alerts';
import { setFilter, requestBrowserPermission } from '../../../actions/notifications'; import { setFilter, requestBrowserPermission } from '../../../actions/notifications';
@ -60,7 +60,7 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changeSetting(['notifications', ...path], checked)); dispatch(changeSetting(['notifications', ...path], checked));
if(path[0] === 'group' && path[1] === 'follow') { if(path[0] === 'group' && path[1] === 'follow') {
dispatch(initializeNotifications()); dispatch(fetchNotifications());
} }
} }
}, },

View file

@ -13,7 +13,7 @@ import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app'; import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { initializeNotifications } from 'mastodon/actions/notifications_migration'; import { fetchNotifications } from 'mastodon/actions/notification_groups';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding'; import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { HoverCardController } from 'mastodon/components/hover_card_controller'; import { HoverCardController } from 'mastodon/components/hover_card_controller';
import { PictureInPicture } from 'mastodon/features/picture_in_picture'; import { PictureInPicture } from 'mastodon/features/picture_in_picture';
@ -418,7 +418,7 @@ class UI extends PureComponent {
if (signedIn) { if (signedIn) {
this.props.dispatch(fetchMarkers()); this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(initializeNotifications()); this.props.dispatch(fetchNotifications());
this.props.dispatch(fetchServerTranslationLanguages()); this.props.dispatch(fetchServerTranslationLanguages());
setTimeout(() => this.props.dispatch(fetchServer()), 3000); setTimeout(() => this.props.dispatch(fetchServer()), 3000);

View file

@ -125,12 +125,23 @@ export const trendsAsLanding = getMeta('trends_as_landing_page');
export const useBlurhash = getMeta('use_blurhash'); export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items'); export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version'); export const version = getMeta('version');
export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending; export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url'); export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect'); export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled'); export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
const displayNames = Intl.DisplayNames && new Intl.DisplayNames(getMeta('locale'), {
type: 'language',
fallback: 'none',
languageDisplay: 'standard',
});
export const languages = initialState?.languages?.map(lang => {
// zh-YUE is not a valid CLDR unicode_language_id
return [lang[0], displayNames?.of(lang[0].replace('zh-YUE', 'yue')) || lang[1], lang[2]];
});
// Glitch-soc-specific settings // Glitch-soc-specific settings
export const pollLimits = (initialState && initialState.poll_limits); export const pollLimits = (initialState && initialState.poll_limits);

View file

@ -1,50 +1,11 @@
import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { fromJS, Map as ImmutableMap } from 'immutable';
import { blockDomainSuccess } from 'mastodon/actions/domain_blocks';
import { timelineDelete } from 'mastodon/actions/timelines_typed';
import { import {
authorizeFollowRequestSuccess,
blockAccountSuccess,
muteAccountSuccess,
rejectFollowRequestSuccess,
} from '../actions/accounts';
import {
focusApp,
unfocusApp,
} from '../actions/app';
import {
fetchMarkers,
} from '../actions/markers';
import { clearNotifications } from '../actions/notification_groups';
import {
notificationsUpdate,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_SCROLL_TOP,
NOTIFICATIONS_LOAD_PENDING,
NOTIFICATIONS_MOUNT,
NOTIFICATIONS_UNMOUNT,
NOTIFICATIONS_MARK_AS_READ,
NOTIFICATIONS_SET_BROWSER_SUPPORT, NOTIFICATIONS_SET_BROWSER_SUPPORT,
NOTIFICATIONS_SET_BROWSER_PERMISSION, NOTIFICATIONS_SET_BROWSER_PERMISSION,
} from '../actions/notifications'; } from '../actions/notifications';
import { disconnectTimeline } from '../actions/timelines';
import { compareId } from '../compare_id';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
pendingItems: ImmutableList(),
items: ImmutableList(),
hasMore: true,
top: false,
mounted: 0,
unread: 0,
lastReadId: '0',
readMarkerId: '0',
isTabVisible: true,
isLoading: 0,
browserSupport: false, browserSupport: false,
browserPermission: 'default', browserPermission: 'default',
}); });
@ -60,248 +21,8 @@ export const notificationToMap = notification => ImmutableMap({
moderation_warning: notification.moderation_warning ? fromJS(notification.moderation_warning) : null, moderation_warning: notification.moderation_warning ? fromJS(notification.moderation_warning) : null,
}); });
const normalizeNotification = (state, notification, usePendingItems) => {
const top = state.get('top');
// Under currently unknown conditions, the client may receive duplicates from the server
if (state.get('pendingItems').some((item) => item?.get('id') === notification.id) || state.get('items').some((item) => item?.get('id') === notification.id)) {
return state;
}
if (usePendingItems || !state.get('pendingItems').isEmpty()) {
return state.update('pendingItems', list => list.unshift(notificationToMap(notification))).update('unread', unread => unread + 1);
}
if (shouldCountUnreadNotifications(state)) {
state = state.update('unread', unread => unread + 1);
} else {
state = state.set('lastReadId', notification.id);
}
return state.update('items', list => {
if (top && list.size > 40) {
list = list.take(20);
}
return list.unshift(notificationToMap(notification));
});
};
const expandNormalizedNotifications = (state, notifications, next, isLoadingMore, isLoadingRecent, usePendingItems) => {
// This method is pretty tricky because:
// - existing notifications might be out of order
// - the existing notifications may have gaps, most often explicitly noted with a `null` item
// - ideally, we don't want it to reorder existing items
// - `notifications` may include items that are already included
// - this function can be called either to fill in a gap, or load newer items
const lastReadId = state.get('lastReadId');
const newItems = ImmutableList(notifications.map(notificationToMap));
return state.withMutations(mutable => {
if (!newItems.isEmpty()) {
usePendingItems = isLoadingRecent && (usePendingItems || !mutable.get('pendingItems').isEmpty());
mutable.update(usePendingItems ? 'pendingItems' : 'items', oldItems => {
// If called to poll *new* notifications, we just need to add them on top without duplicates
if (isLoadingRecent) {
const idsToCheck = oldItems.map(item => item?.get('id')).toSet();
const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
return insertedItems.concat(oldItems);
}
// If called to expand more (presumably older than any known to the WebUI), we just have to
// add them to the bottom without duplicates
if (isLoadingMore) {
const idsToCheck = oldItems.map(item => item?.get('id')).toSet();
const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
return oldItems.concat(insertedItems);
}
// Now this gets tricky, as we don't necessarily know for sure where the gap to fill is,
// and some items in the timeline may not be properly ordered.
// However, we know that `newItems.last()` is the oldest item that was requested and that
// there is no “hole” between `newItems.last()` and `newItems.first()`.
// First, find the furthest (if properly sorted, oldest) item in the notifications that is
// newer than the oldest fetched one, as it's most likely that it delimits the gap.
// Start the gap *after* that item.
const lastIndex = oldItems.findLastIndex(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) >= 0) + 1;
// Then, try to find the furthest (if properly sorted, oldest) item in the notifications that
// is newer than the most recent fetched one, as it delimits a section comprised of only
// items older or within `newItems` (or that were deleted from the server, so should be removed
// anyway).
// Stop the gap *after* that item.
const firstIndex = oldItems.take(lastIndex).findLastIndex(item => item !== null && compareId(item.get('id'), newItems.first().get('id')) > 0) + 1;
// At this point:
// - no `oldItems` after `firstIndex` is newer than any of the `newItems`
// - all `oldItems` after `lastIndex` are older than every of the `newItems`
// - it is possible for items in the replaced slice to be older than every `newItems`
// - it is possible for items before `firstIndex` to be in the `newItems` range
// Therefore:
// - to avoid losing items, items from the replaced slice that are older than `newItems`
// should be added in the back.
// - to avoid duplicates, `newItems` should be checked the first `firstIndex` items of
// `oldItems`
const idsToCheck = oldItems.take(firstIndex).map(item => item?.get('id')).toSet();
const insertedItems = newItems.filterNot(item => idsToCheck.includes(item.get('id')));
const olderItems = oldItems.slice(firstIndex, lastIndex).filter(item => item !== null && compareId(item.get('id'), newItems.last().get('id')) < 0);
return oldItems.take(firstIndex).concat(
insertedItems,
olderItems,
oldItems.skip(lastIndex),
);
});
}
if (!next) {
mutable.set('hasMore', false);
}
if (shouldCountUnreadNotifications(state)) {
mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), lastReadId) > 0));
} else {
const mostRecent = newItems.find(item => item !== null);
if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
mutable.set('lastReadId', mostRecent.get('id'));
}
}
mutable.update('isLoading', (nbLoading) => nbLoading - 1);
});
};
const filterNotifications = (state, accountIds, type) => {
const helper = list => list.filterNot(item => item !== null && accountIds.includes(item.get('account')) && (type === undefined || type === item.get('type')));
return state.update('items', helper).update('pendingItems', helper);
};
const clearUnread = (state) => {
state = state.set('unread', state.get('pendingItems').size);
const lastNotification = state.get('items').find(item => item !== null);
return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
};
const updateTop = (state, top) => {
state = state.set('top', top);
if (!shouldCountUnreadNotifications(state)) {
state = clearUnread(state);
}
return state;
};
const deleteByStatus = (state, statusId) => {
const lastReadId = state.get('lastReadId');
if (shouldCountUnreadNotifications(state)) {
const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
state = state.update('unread', unread => unread - deletedUnread.size);
}
const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
state = state.update('unread', unread => unread - deletedUnread.size);
return state.update('items', helper).update('pendingItems', helper);
};
const updateMounted = (state) => {
state = state.update('mounted', count => count + 1);
if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
state = state.set('readMarkerId', state.get('lastReadId'));
state = clearUnread(state);
}
return state;
};
const updateVisibility = (state, visibility) => {
state = state.set('isTabVisible', visibility);
if (!shouldCountUnreadNotifications(state)) {
state = state.set('readMarkerId', state.get('lastReadId'));
state = clearUnread(state);
}
return state;
};
const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
const isTabVisible = state.get('isTabVisible');
const isOnTop = state.get('top');
const isMounted = state.get('mounted') > 0;
const lastReadId = state.get('lastReadId');
const lastItem = state.get('items').findLast(item => item !== null);
const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
};
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 (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
mutable.set('readMarkerId', 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) { export default function notifications(state = initialState, action) {
switch(action.type) { switch(action.type) {
case fetchMarkers.fulfilled.type:
return action.payload.markers.notifications ? recountUnread(state, action.payload.markers.notifications.last_read_id) : state;
case NOTIFICATIONS_MOUNT:
return updateMounted(state);
case NOTIFICATIONS_UNMOUNT:
return state.update('mounted', count => count - 1);
case focusApp.type:
return updateVisibility(state, true);
case unfocusApp.type:
return updateVisibility(state, false);
case NOTIFICATIONS_LOAD_PENDING:
return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
case NOTIFICATIONS_EXPAND_REQUEST:
return state.update('isLoading', (nbLoading) => nbLoading + 1);
case NOTIFICATIONS_EXPAND_FAIL:
return state.update('isLoading', (nbLoading) => nbLoading - 1);
case NOTIFICATIONS_FILTER_SET:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case notificationsUpdate.type:
return normalizeNotification(state, action.payload.notification, action.payload.usePendingItems);
case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next, action.isLoadingMore, action.isLoadingRecent, action.usePendingItems);
case blockAccountSuccess.type:
return filterNotifications(state, [action.payload.relationship.id]);
case muteAccountSuccess.type:
return action.payload.relationship.muting_notifications ? filterNotifications(state, [action.payload.relationship.id]) : state;
case blockDomainSuccess.type:
return filterNotifications(state, action.payload.accounts);
case authorizeFollowRequestSuccess.type:
case rejectFollowRequestSuccess.type:
return filterNotifications(state, [action.payload.id], 'follow_request');
case clearNotifications.pending.type:
return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', false);
case timelineDelete.type:
return deleteByStatus(state, action.payload.statusId);
case disconnectTimeline.type:
return action.payload.timeline === 'home' ?
state.update(action.payload.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
state;
case NOTIFICATIONS_MARK_AS_READ: {
const lastNotification = state.get('items').find(item => item !== null);
return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
}
case NOTIFICATIONS_SET_BROWSER_SUPPORT: case NOTIFICATIONS_SET_BROWSER_SUPPORT:
return state.set('browserSupport', action.value); return state.set('browserSupport', action.value);
case NOTIFICATIONS_SET_BROWSER_PERMISSION: case NOTIFICATIONS_SET_BROWSER_PERMISSION:

View file

@ -59,7 +59,7 @@ Bundler.require(:pam_authentication) if ENV['PAM_ENABLED'] == 'true'
module Mastodon module Mastodon
class Application < Rails::Application class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.2 config.load_defaults 8.0
# Please, add to the `ignore` list any other `lib` subdirectories that do # Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded. # not contain `.rb` files, or that should not be reloaded or eager loaded.

View file

@ -6,5 +6,5 @@
# Use this to limit dissemination of sensitive information. # Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
] ]

View file

@ -13,7 +13,9 @@ module ActiveRecord
column_names.unshift(primary_key) column_names.unshift(primary_key)
relation = relation.reorder(build_batch_orders(order).to_h).limit(batch_limit) cursor = Array(primary_key)
relation = relation.reorder(build_batch_orders(cursor, order).to_h).limit(batch_limit)
relation.skip_query_cache! relation.skip_query_cache!
batch_relation = relation batch_relation = relation

View file

@ -119,6 +119,11 @@ RSpec.configure do |config|
config.include CommandLineHelpers, type: :cli config.include CommandLineHelpers, type: :cli
config.include SystemHelpers, type: :system config.include SystemHelpers, type: :system
# TODO: Remove when Devise fixes https://github.com/heartcombo/devise/issues/5705
config.before do
Rails.application.reload_routes_unless_loaded
end
config.around(:each, use_transactional_tests: false) do |example| config.around(:each, use_transactional_tests: false) do |example|
self.use_transactional_tests = false self.use_transactional_tests = false
example.run example.run