mirror of
https://github.com/mastodon/mastodon.git
synced 2024-11-20 03:25:17 +01:00
Merge branch 'main' into feature/db-advisory-locks
This commit is contained in:
commit
b7fb2faeeb
18
Dockerfile
18
Dockerfile
@ -196,11 +196,14 @@ ARG VIPS_VERSION=8.15.3
|
||||
ARG VIPS_URL=https://github.com/libvips/libvips/releases/download
|
||||
|
||||
WORKDIR /usr/local/libvips/src
|
||||
# Download and extract libvips source code
|
||||
ADD ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz /usr/local/libvips/src/
|
||||
RUN tar xf vips-${VIPS_VERSION}.tar.xz;
|
||||
|
||||
WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION}
|
||||
|
||||
# Configure and compile libvips
|
||||
RUN \
|
||||
curl -sSL -o vips-${VIPS_VERSION}.tar.xz ${VIPS_URL}/v${VIPS_VERSION}/vips-${VIPS_VERSION}.tar.xz; \
|
||||
tar xf vips-${VIPS_VERSION}.tar.xz; \
|
||||
cd vips-${VIPS_VERSION}; \
|
||||
meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false; \
|
||||
cd build; \
|
||||
ninja; \
|
||||
@ -216,11 +219,14 @@ ARG FFMPEG_VERSION=7.0.2
|
||||
ARG FFMPEG_URL=https://ffmpeg.org/releases
|
||||
|
||||
WORKDIR /usr/local/ffmpeg/src
|
||||
# Download and extract ffmpeg source code
|
||||
ADD ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz /usr/local/ffmpeg/src/
|
||||
RUN tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz;
|
||||
|
||||
WORKDIR /usr/local/ffmpeg/src/ffmpeg-${FFMPEG_VERSION}
|
||||
|
||||
# Configure and compile ffmpeg
|
||||
RUN \
|
||||
curl -sSL -o ffmpeg-${FFMPEG_VERSION}.tar.xz ${FFMPEG_URL}/ffmpeg-${FFMPEG_VERSION}.tar.xz; \
|
||||
tar xf ffmpeg-${FFMPEG_VERSION}.tar.xz; \
|
||||
cd ffmpeg-${FFMPEG_VERSION}; \
|
||||
./configure \
|
||||
--prefix=/usr/local/ffmpeg \
|
||||
--toolchain=hardened \
|
||||
|
@ -514,17 +514,17 @@ GEM
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rack (~> 0.21)
|
||||
opentelemetry-instrumentation-action_view (0.7.1)
|
||||
opentelemetry-instrumentation-action_view (0.7.2)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-active_support (~> 0.1)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_job (0.7.4)
|
||||
opentelemetry-instrumentation-active_job (0.7.6)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_model_serializers (0.20.2)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_record (0.7.2)
|
||||
opentelemetry-instrumentation-active_record (0.7.3)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-active_support (0.6.0)
|
||||
@ -558,7 +558,7 @@ GEM
|
||||
opentelemetry-instrumentation-rack (0.24.6)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-base (~> 0.22.1)
|
||||
opentelemetry-instrumentation-rails (0.31.1)
|
||||
opentelemetry-instrumentation-rails (0.31.2)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-instrumentation-action_mailer (~> 0.1.0)
|
||||
opentelemetry-instrumentation-action_pack (~> 0.9.0)
|
||||
|
@ -1,8 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Notifications::RequestsController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :index
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: :index
|
||||
include Redisable
|
||||
|
||||
before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: [:index, :show, :merged?]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, except: [:index, :show, :merged?]
|
||||
|
||||
before_action :require_user!
|
||||
before_action :set_request, only: [:show, :accept, :dismiss]
|
||||
@ -19,6 +21,10 @@ class Api::V1::Notifications::RequestsController < Api::BaseController
|
||||
render json: @requests, each_serializer: REST::NotificationRequestSerializer, relationships: @relationships
|
||||
end
|
||||
|
||||
def merged?
|
||||
render json: { merged: redis.get("notification_unfilter_jobs:#{current_account.id}").to_i <= 0 }
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @request, serializer: REST::NotificationRequestSerializer
|
||||
end
|
||||
|
@ -138,8 +138,18 @@ export const processNewNotificationForGroups = createAppAsyncThunk(
|
||||
|
||||
export const loadPending = createAction('notificationGroups/loadPending');
|
||||
|
||||
export const updateScrollPosition = createAction<{ top: boolean }>(
|
||||
export const updateScrollPosition = createAppAsyncThunk(
|
||||
'notificationGroups/updateScrollPosition',
|
||||
({ top }: { top: boolean }, { dispatch, getState }) => {
|
||||
if (
|
||||
top &&
|
||||
getState().notificationGroups.mergedNotifications === 'needs-reload'
|
||||
) {
|
||||
void dispatch(fetchNotifications());
|
||||
}
|
||||
|
||||
return { top };
|
||||
},
|
||||
);
|
||||
|
||||
export const setNotificationsFilter = createAppAsyncThunk(
|
||||
@ -165,5 +175,34 @@ export const markNotificationsAsRead = createAction(
|
||||
'notificationGroups/markAsRead',
|
||||
);
|
||||
|
||||
export const mountNotifications = createAction('notificationGroups/mount');
|
||||
export const mountNotifications = createAppAsyncThunk(
|
||||
'notificationGroups/mount',
|
||||
(_, { dispatch, getState }) => {
|
||||
const state = getState();
|
||||
|
||||
if (
|
||||
state.notificationGroups.mounted === 0 &&
|
||||
state.notificationGroups.mergedNotifications === 'needs-reload'
|
||||
) {
|
||||
void dispatch(fetchNotifications());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const unmountNotifications = createAction('notificationGroups/unmount');
|
||||
|
||||
export const refreshStaleNotificationGroups = createAppAsyncThunk<{
|
||||
deferredRefresh: boolean;
|
||||
}>('notificationGroups/refreshStale', (_, { dispatch, getState }) => {
|
||||
const state = getState();
|
||||
|
||||
if (
|
||||
state.notificationGroups.scrolledToTop ||
|
||||
!state.notificationGroups.mounted
|
||||
) {
|
||||
void dispatch(fetchNotifications());
|
||||
return { deferredRefresh: false };
|
||||
}
|
||||
|
||||
return { deferredRefresh: true };
|
||||
});
|
||||
|
@ -188,8 +188,8 @@ const noOp = () => {};
|
||||
|
||||
let expandNotificationsController = new AbortController();
|
||||
|
||||
export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
|
||||
return (dispatch, getState) => {
|
||||
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;
|
||||
@ -199,7 +199,6 @@ export function expandNotifications({ maxId, forceLoad = false } = {}, done = no
|
||||
expandNotificationsController.abort();
|
||||
expandNotificationsController = new AbortController();
|
||||
} else {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -226,7 +225,8 @@ export function expandNotifications({ maxId, forceLoad = false } = {}, done = no
|
||||
|
||||
dispatch(expandNotificationsRequest(isLoadingMore));
|
||||
|
||||
api().get('/api/v1/notifications', { params, signal: expandNotificationsController.signal }).then(response => {
|
||||
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)));
|
||||
@ -236,11 +236,9 @@ export function expandNotifications({ maxId, forceLoad = false } = {}, done = no
|
||||
dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
|
||||
fetchRelatedRelationships(dispatch, response.data);
|
||||
dispatch(submitMarkers());
|
||||
}).catch(error => {
|
||||
} catch(error) {
|
||||
dispatch(expandNotificationsFail(error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,6 @@ export const initializeNotifications = createAppAsyncThunk(
|
||||
) as boolean;
|
||||
|
||||
if (enableBeta) void dispatch(fetchNotifications());
|
||||
else dispatch(expandNotifications());
|
||||
else void dispatch(expandNotifications());
|
||||
},
|
||||
);
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
deleteAnnouncement,
|
||||
} from './announcements';
|
||||
import { updateConversations } from './conversations';
|
||||
import { processNewNotificationForGroups } from './notification_groups';
|
||||
import { processNewNotificationForGroups, refreshStaleNotificationGroups } from './notification_groups';
|
||||
import { updateNotifications, expandNotifications } from './notifications';
|
||||
import { updateStatus } from './statuses';
|
||||
import {
|
||||
@ -37,7 +37,7 @@ const randomUpTo = max =>
|
||||
* @param {string} channelName
|
||||
* @param {Object.<string, string>} params
|
||||
* @param {Object} options
|
||||
* @param {function(Function, Function): void} [options.fallback]
|
||||
* @param {function(Function): Promise<void>} [options.fallback]
|
||||
* @param {function(): void} [options.fillGaps]
|
||||
* @param {function(object): boolean} [options.accept]
|
||||
* @returns {function(): void}
|
||||
@ -52,14 +52,13 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
let pollingId;
|
||||
|
||||
/**
|
||||
* @param {function(Function, Function): void} fallback
|
||||
* @param {function(Function): Promise<void>} fallback
|
||||
*/
|
||||
|
||||
const useFallback = fallback => {
|
||||
fallback(dispatch, () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
|
||||
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
|
||||
});
|
||||
const useFallback = async fallback => {
|
||||
await fallback(dispatch);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook
|
||||
pollingId = setTimeout(() => useFallback(fallback), 20000 + randomUpTo(20000));
|
||||
};
|
||||
|
||||
return {
|
||||
@ -109,6 +108,14 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'notifications_merged':
|
||||
const state = getState();
|
||||
if (state.notifications.top || !state.notifications.mounted)
|
||||
dispatch(expandNotifications({ forceLoad: true, maxId: undefined }));
|
||||
if(state.settings.getIn(['notifications', 'groupingBeta'], false)) {
|
||||
dispatch(refreshStaleNotificationGroups());
|
||||
}
|
||||
break;
|
||||
case 'conversation':
|
||||
// @ts-expect-error
|
||||
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||
@ -132,21 +139,17 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
|
||||
|
||||
/**
|
||||
* @param {Function} dispatch
|
||||
* @param {function(): void} done
|
||||
*/
|
||||
const refreshHomeTimelineAndNotification = (dispatch, done) => {
|
||||
// @ts-expect-error
|
||||
dispatch(expandHomeTimeline({}, () =>
|
||||
// @ts-expect-error
|
||||
dispatch(expandNotifications({}, () =>
|
||||
dispatch(fetchAnnouncements(done))))));
|
||||
};
|
||||
async function refreshHomeTimelineAndNotification(dispatch) {
|
||||
await dispatch(expandHomeTimeline({ maxId: undefined }));
|
||||
await dispatch(expandNotifications({}));
|
||||
await dispatch(fetchAnnouncements());
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
export const connectUserStream = () =>
|
||||
// @ts-expect-error
|
||||
connectTimelineStream('home', 'user', {}, { fallback: refreshHomeTimelineAndNotification, fillGaps: fillHomeTimelineGaps });
|
||||
|
||||
/**
|
||||
|
@ -76,21 +76,18 @@ export function clearTimeline(timeline) {
|
||||
};
|
||||
}
|
||||
|
||||
const noOp = () => {};
|
||||
|
||||
const parseTags = (tags = {}, mode) => {
|
||||
return (tags[mode] || []).map((tag) => {
|
||||
return tag.value;
|
||||
});
|
||||
};
|
||||
|
||||
export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
return (dispatch, getState) => {
|
||||
export function expandTimeline(timelineId, path, params = {}) {
|
||||
return async (dispatch, getState) => {
|
||||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
|
||||
const isLoadingMore = !!params.max_id;
|
||||
|
||||
if (timeline.get('isLoading')) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -109,7 +106,8 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
|
||||
dispatch(expandTimelineRequest(timelineId, isLoadingMore));
|
||||
|
||||
api().get(path, { params }).then(response => {
|
||||
try {
|
||||
const response = await api().get(path, { params });
|
||||
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||
|
||||
dispatch(importFetchedStatuses(response.data));
|
||||
@ -127,52 +125,48 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||
if (timelineId === 'home') {
|
||||
dispatch(submitMarkers());
|
||||
}
|
||||
}).catch(error => {
|
||||
} catch(error) {
|
||||
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
|
||||
}).finally(() => {
|
||||
done();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
|
||||
return (dispatch, getState) => {
|
||||
export function fillTimelineGaps(timelineId, path, params = {}) {
|
||||
return async (dispatch, getState) => {
|
||||
const timeline = getState().getIn(['timelines', timelineId], ImmutableMap());
|
||||
const items = timeline.get('items');
|
||||
const nullIndexes = items.map((statusId, index) => statusId === null ? index : null);
|
||||
const gaps = nullIndexes.map(index => index > 0 ? items.get(index - 1) : null);
|
||||
|
||||
// Only expand at most two gaps to avoid doing too many requests
|
||||
done = gaps.take(2).reduce((done, maxId) => {
|
||||
return (() => dispatch(expandTimeline(timelineId, path, { ...params, maxId }, done)));
|
||||
}, done);
|
||||
|
||||
done();
|
||||
for (const maxId of gaps.take(2)) {
|
||||
await dispatch(expandTimeline(timelineId, path, { ...params, maxId }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
|
||||
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||
export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId });
|
||||
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia });
|
||||
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia });
|
||||
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId });
|
||||
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
|
||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||
export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done);
|
||||
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
||||
export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId });
|
||||
export const expandLinkTimeline = (url, { maxId } = {}) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId });
|
||||
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}) => {
|
||||
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||
max_id: maxId,
|
||||
any: parseTags(tags, 'any'),
|
||||
all: parseTags(tags, 'all'),
|
||||
none: parseTags(tags, 'none'),
|
||||
local: local,
|
||||
}, done);
|
||||
});
|
||||
};
|
||||
|
||||
export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done);
|
||||
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia }, done);
|
||||
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
|
||||
export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
|
||||
export const fillHomeTimelineGaps = () => fillTimelineGaps('home', '/api/v1/timelines/home', {});
|
||||
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote } = {}) => fillTimelineGaps(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia });
|
||||
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia });
|
||||
export const fillListTimelineGaps = (id) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {});
|
||||
|
||||
export function expandTimelineRequest(timeline, isLoadingMore) {
|
||||
return {
|
||||
|
@ -153,7 +153,7 @@ class ReportReasonSelector extends PureComponent {
|
||||
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='legal' text={intl.formatMessage(messages.legal)} selected={category === 'legal'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
|
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled || rules.length === 0}>
|
||||
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
|
||||
</Category>
|
||||
</div>
|
||||
|
@ -36,13 +36,13 @@ export const LinkTimeline: React.FC<{
|
||||
|
||||
const handleLoadMore = useCallback(
|
||||
(maxId: string) => {
|
||||
dispatch(expandLinkTimeline(decodedUrl, { maxId }));
|
||||
void dispatch(expandLinkTimeline(decodedUrl, { maxId }));
|
||||
},
|
||||
[dispatch, decodedUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(expandLinkTimeline(decodedUrl));
|
||||
void dispatch(expandLinkTimeline(decodedUrl));
|
||||
}, [dispatch, decodedUrl]);
|
||||
|
||||
return (
|
||||
|
@ -7,6 +7,7 @@ import { Helmet } from 'react-helmet';
|
||||
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react';
|
||||
import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react';
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
@ -15,6 +16,7 @@ import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { CheckBox } from 'mastodon/components/check_box';
|
||||
import Column from 'mastodon/components/column';
|
||||
import ColumnHeader from 'mastodon/components/column_header';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
|
||||
@ -26,16 +28,14 @@ const messages = defineMessages({
|
||||
title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' },
|
||||
maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
acceptAll: { id: 'notification_requests.accept_all', defaultMessage: 'Accept all' },
|
||||
dismissAll: { id: 'notification_requests.dismiss_all', defaultMessage: 'Dismiss all' },
|
||||
acceptMultiple: { id: 'notification_requests.accept_multiple', defaultMessage: '{count, plural, one {Accept # request} other {Accept # requests}}' },
|
||||
dismissMultiple: { id: 'notification_requests.dismiss_multiple', defaultMessage: '{count, plural, one {Dismiss # request} other {Dismiss # requests}}' },
|
||||
confirmAcceptAllTitle: { id: 'notification_requests.confirm_accept_all.title', defaultMessage: 'Accept notification requests?' },
|
||||
confirmAcceptAllMessage: { id: 'notification_requests.confirm_accept_all.message', defaultMessage: 'You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?' },
|
||||
confirmAcceptAllButton: { id: 'notification_requests.confirm_accept_all.button', defaultMessage: 'Accept all' },
|
||||
confirmDismissAllTitle: { id: 'notification_requests.confirm_dismiss_all.title', defaultMessage: 'Dismiss notification requests?' },
|
||||
confirmDismissAllMessage: { id: 'notification_requests.confirm_dismiss_all.message', defaultMessage: "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?" },
|
||||
confirmDismissAllButton: { id: 'notification_requests.confirm_dismiss_all.button', defaultMessage: 'Dismiss all' },
|
||||
acceptMultiple: { id: 'notification_requests.accept_multiple', defaultMessage: '{count, plural, one {Accept # request…} other {Accept # requests…}}' },
|
||||
dismissMultiple: { id: 'notification_requests.dismiss_multiple', defaultMessage: '{count, plural, one {Dismiss # request…} other {Dismiss # requests…}}' },
|
||||
confirmAcceptMultipleTitle: { id: 'notification_requests.confirm_accept_multiple.title', defaultMessage: 'Accept notification requests?' },
|
||||
confirmAcceptMultipleMessage: { id: 'notification_requests.confirm_accept_multiple.message', defaultMessage: 'You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?' },
|
||||
confirmAcceptMultipleButton: { id: 'notification_requests.confirm_accept_multiple.button', defaultMessage: '{count, plural, one {Accept request} other {Accept requests}}' },
|
||||
confirmDismissMultipleTitle: { id: 'notification_requests.confirm_dismiss_multiple.title', defaultMessage: 'Dismiss notification requests?' },
|
||||
confirmDismissMultipleMessage: { id: 'notification_requests.confirm_dismiss_multiple.message', defaultMessage: "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?" },
|
||||
confirmDismissMultipleButton: { id: 'notification_requests.confirm_dismiss_multiple.button', defaultMessage: '{count, plural, one {Dismiss request} other {Dismiss requests}}' },
|
||||
});
|
||||
|
||||
const ColumnSettings = () => {
|
||||
@ -74,45 +74,15 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items']));
|
||||
|
||||
const selectedCount = selectedItems.length;
|
||||
|
||||
const handleAcceptAll = useCallback(() => {
|
||||
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: items.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(acceptNotificationRequests(items)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, notificationRequests]);
|
||||
|
||||
const handleDismissAll = useCallback(() => {
|
||||
const items = notificationRequests.map(request => request.get('id')).toArray();
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: items.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||
onConfirm: () =>
|
||||
dispatch(dismissNotificationRequests(items)),
|
||||
},
|
||||
}));
|
||||
}, [dispatch, intl, notificationRequests]);
|
||||
|
||||
const handleAcceptMultiple = useCallback(() => {
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmAcceptAllTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptAllButton),
|
||||
title: intl.formatMessage(messages.confirmAcceptMultipleTitle),
|
||||
message: intl.formatMessage(messages.confirmAcceptMultipleMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmAcceptMultipleButton, { count: selectedItems.length}),
|
||||
onConfirm: () =>
|
||||
dispatch(acceptNotificationRequests(selectedItems)),
|
||||
},
|
||||
@ -123,9 +93,9 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||
dispatch(openModal({
|
||||
modalType: 'CONFIRM',
|
||||
modalProps: {
|
||||
title: intl.formatMessage(messages.confirmDismissAllTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissAllButton),
|
||||
title: intl.formatMessage(messages.confirmDismissMultipleTitle),
|
||||
message: intl.formatMessage(messages.confirmDismissMultipleMessage, { count: selectedItems.length }),
|
||||
confirm: intl.formatMessage(messages.confirmDismissMultipleButton, { count: selectedItems.length}),
|
||||
onConfirm: () =>
|
||||
dispatch(dismissNotificationRequests(selectedItems)),
|
||||
},
|
||||
@ -136,46 +106,45 @@ const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionM
|
||||
setSelectionMode((mode) => !mode);
|
||||
}, [setSelectionMode]);
|
||||
|
||||
const menu = selectedCount === 0 ?
|
||||
[
|
||||
{ text: intl.formatMessage(messages.acceptAll), action: handleAcceptAll },
|
||||
{ text: intl.formatMessage(messages.dismissAll), action: handleDismissAll },
|
||||
] : [
|
||||
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptMultiple },
|
||||
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
|
||||
];
|
||||
const menu = [
|
||||
{ text: intl.formatMessage(messages.acceptMultiple, { count: selectedCount }), action: handleAcceptMultiple },
|
||||
{ text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissMultiple },
|
||||
];
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setSelectionMode(true);
|
||||
toggleSelectAll();
|
||||
}, [setSelectionMode, toggleSelectAll]);
|
||||
|
||||
return (
|
||||
<div className='column-header__select-row'>
|
||||
{selectionMode && (
|
||||
<div className='column-header__select-row__checkbox'>
|
||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={toggleSelectAll} />
|
||||
</div>
|
||||
)}
|
||||
<div className='column-header__select-row__selection-mode'>
|
||||
<div className='column-header__select-row__checkbox'>
|
||||
<CheckBox checked={selectAllChecked} indeterminate={selectedCount > 0 && !selectAllChecked} onChange={handleSelectAll} />
|
||||
</div>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
>
|
||||
<button className='dropdown-button column-header__select-row__select-menu' disabled={selectedItems.length === 0}>
|
||||
<span className='dropdown-button__label'>
|
||||
{selectedCount} selected
|
||||
</span>
|
||||
<Icon id='down' icon={ArrowDropDownIcon} />
|
||||
</button>
|
||||
</DropdownMenuContainer>
|
||||
<div className='column-header__select-row__mode-button'>
|
||||
<button className='text-btn' tabIndex={0} onClick={handleToggleSelectionMode}>
|
||||
{selectionMode ? (
|
||||
<FormattedMessage id='notification_requests.exit_selection_mode' defaultMessage='Cancel' />
|
||||
<FormattedMessage id='notification_requests.exit_selection' defaultMessage='Done' />
|
||||
) :
|
||||
(
|
||||
<FormattedMessage id='notification_requests.enter_selection_mode' defaultMessage='Select' />
|
||||
<FormattedMessage id='notification_requests.edit_selection' defaultMessage='Edit' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{selectedCount > 0 &&
|
||||
<div className='column-header__select-row__selected-count'>
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
}
|
||||
<div className='column-header__select-row__actions'>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icons='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -81,7 +81,11 @@ export const Notifications: React.FC<{
|
||||
|
||||
const anyPendingNotification = useAppSelector(selectAnyPendingNotification);
|
||||
|
||||
const isUnread = unreadNotificationsCount > 0;
|
||||
const needsReload = useAppSelector(
|
||||
(state) => state.notificationGroups.mergedNotifications === 'needs-reload',
|
||||
);
|
||||
|
||||
const isUnread = unreadNotificationsCount > 0 || needsReload;
|
||||
|
||||
const canMarkAsRead =
|
||||
useAppSelector(selectSettingsNotificationsShowUnread) &&
|
||||
@ -118,11 +122,11 @@ export const Notifications: React.FC<{
|
||||
|
||||
// Keep track of mounted components for unread notification handling
|
||||
useEffect(() => {
|
||||
dispatch(mountNotifications());
|
||||
void dispatch(mountNotifications());
|
||||
|
||||
return () => {
|
||||
dispatch(unmountNotifications());
|
||||
dispatch(updateScrollPosition({ top: false }));
|
||||
void dispatch(updateScrollPosition({ top: false }));
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
@ -147,11 +151,11 @@ export const Notifications: React.FC<{
|
||||
}, [dispatch]);
|
||||
|
||||
const handleScrollToTop = useDebouncedCallback(() => {
|
||||
dispatch(updateScrollPosition({ top: true }));
|
||||
void dispatch(updateScrollPosition({ top: true }));
|
||||
}, 100);
|
||||
|
||||
const handleScroll = useDebouncedCallback(() => {
|
||||
dispatch(updateScrollPosition({ top: false }));
|
||||
void dispatch(updateScrollPosition({ top: false }));
|
||||
}, 100);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -518,19 +518,17 @@
|
||||
"notification.status": "{name} just posted",
|
||||
"notification.update": "{name} edited a post",
|
||||
"notification_requests.accept": "Accept",
|
||||
"notification_requests.accept_all": "Accept all",
|
||||
"notification_requests.accept_multiple": "{count, plural, one {Accept # request} other {Accept # requests}}",
|
||||
"notification_requests.confirm_accept_all.button": "Accept all",
|
||||
"notification_requests.confirm_accept_all.message": "You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?",
|
||||
"notification_requests.confirm_accept_all.title": "Accept notification requests?",
|
||||
"notification_requests.confirm_dismiss_all.button": "Dismiss all",
|
||||
"notification_requests.confirm_dismiss_all.message": "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?",
|
||||
"notification_requests.confirm_dismiss_all.title": "Dismiss notification requests?",
|
||||
"notification_requests.accept_multiple": "{count, plural, one {Accept # request…} other {Accept # requests…}}",
|
||||
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Accept request} other {Accept requests}}",
|
||||
"notification_requests.confirm_accept_multiple.message": "You are about to accept {count, plural, one {one notification request} other {# notification requests}}. Are you sure you want to proceed?",
|
||||
"notification_requests.confirm_accept_multiple.title": "Accept notification requests?",
|
||||
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Dismiss request} other {Dismiss requests}}",
|
||||
"notification_requests.confirm_dismiss_multiple.message": "You are about to dismiss {count, plural, one {one notification request} other {# notification requests}}. You won't be able to easily access {count, plural, one {it} other {them}} again. Are you sure you want to proceed?",
|
||||
"notification_requests.confirm_dismiss_multiple.title": "Dismiss notification requests?",
|
||||
"notification_requests.dismiss": "Dismiss",
|
||||
"notification_requests.dismiss_all": "Dismiss all",
|
||||
"notification_requests.dismiss_multiple": "{count, plural, one {Dismiss # request} other {Dismiss # requests}}",
|
||||
"notification_requests.enter_selection_mode": "Select",
|
||||
"notification_requests.exit_selection_mode": "Cancel",
|
||||
"notification_requests.dismiss_multiple": "{count, plural, one {Dismiss # request…} other {Dismiss # requests…}}",
|
||||
"notification_requests.edit_selection": "Edit",
|
||||
"notification_requests.exit_selection": "Done",
|
||||
"notification_requests.explainer_for_limited_account": "Notifications from this account have been filtered because the account has been limited by a moderator.",
|
||||
"notification_requests.explainer_for_limited_remote_account": "Notifications from this account have been filtered because the account or its server has been limited by a moderator.",
|
||||
"notification_requests.maximize": "Maximize",
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
markNotificationsAsRead,
|
||||
mountNotifications,
|
||||
unmountNotifications,
|
||||
refreshStaleNotificationGroups,
|
||||
} from 'mastodon/actions/notification_groups';
|
||||
import {
|
||||
disconnectTimeline,
|
||||
@ -51,6 +52,7 @@ interface NotificationGroupsState {
|
||||
readMarkerId: string;
|
||||
mounted: number;
|
||||
isTabVisible: boolean;
|
||||
mergedNotifications: 'ok' | 'pending' | 'needs-reload';
|
||||
}
|
||||
|
||||
const initialState: NotificationGroupsState = {
|
||||
@ -58,6 +60,8 @@ const initialState: NotificationGroupsState = {
|
||||
pendingGroups: [], // holds pending groups in slow mode
|
||||
scrolledToTop: false,
|
||||
isLoading: false,
|
||||
// this is used to track whether we need to refresh notifications after accepting requests
|
||||
mergedNotifications: 'ok',
|
||||
// The following properties are used to track unread notifications
|
||||
lastReadId: '0', // used internally for unread notifications
|
||||
readMarkerId: '0', // user-facing and updated when focus changes
|
||||
@ -301,6 +305,7 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||
json.type === 'gap' ? json : createNotificationGroupFromJSON(json),
|
||||
);
|
||||
state.isLoading = false;
|
||||
state.mergedNotifications = 'ok';
|
||||
updateLastReadId(state);
|
||||
})
|
||||
.addCase(fetchNotificationsGap.fulfilled, (state, action) => {
|
||||
@ -455,7 +460,7 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||
state.groups = state.pendingGroups.concat(state.groups);
|
||||
state.pendingGroups = [];
|
||||
})
|
||||
.addCase(updateScrollPosition, (state, action) => {
|
||||
.addCase(updateScrollPosition.fulfilled, (state, action) => {
|
||||
state.scrolledToTop = action.payload.top;
|
||||
updateLastReadId(state);
|
||||
trimNotifications(state);
|
||||
@ -482,7 +487,7 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||
action.payload.markers.notifications.last_read_id;
|
||||
}
|
||||
})
|
||||
.addCase(mountNotifications, (state) => {
|
||||
.addCase(mountNotifications.fulfilled, (state) => {
|
||||
state.mounted += 1;
|
||||
commitLastReadId(state);
|
||||
updateLastReadId(state);
|
||||
@ -498,6 +503,10 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
|
||||
.addCase(unfocusApp, (state) => {
|
||||
state.isTabVisible = false;
|
||||
})
|
||||
.addCase(refreshStaleNotificationGroups.fulfilled, (state, action) => {
|
||||
if (action.payload.deferredRefresh)
|
||||
state.mergedNotifications = 'needs-reload';
|
||||
})
|
||||
.addMatcher(
|
||||
isAnyOf(authorizeFollowRequestSuccess, rejectFollowRequestSuccess),
|
||||
(state, action) => {
|
||||
|
@ -65,4 +65,5 @@ body {
|
||||
--background-color: #fff;
|
||||
--background-color-tint: rgba(255, 255, 255, 80%);
|
||||
--background-filter: blur(10px);
|
||||
--on-surface-color: #{transparentize($ui-base-color, 0.65)};
|
||||
}
|
||||
|
@ -4217,7 +4217,7 @@ a.status-card {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: lighten($ui-base-color, 2%);
|
||||
background: var(--on-surface-color);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4346,19 +4346,18 @@ a.status-card {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__selection-mode {
|
||||
flex-grow: 1;
|
||||
|
||||
.text-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
&__select-menu:disabled {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
.icon-button {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--background-border-color);
|
||||
padding: 5px;
|
||||
&__mode-button {
|
||||
margin-left: auto;
|
||||
color: $highlight-text-color;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: lighten($highlight-text-color, 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4566,6 +4565,7 @@ a.status-card {
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@ -10366,7 +10366,7 @@ noscript {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: lighten($ui-base-color, 1%);
|
||||
background: var(--on-surface-color);
|
||||
}
|
||||
|
||||
.notification-request__checkbox {
|
||||
|
@ -109,5 +109,6 @@ $font-monospace: 'mastodon-font-monospace' !default;
|
||||
--surface-background-color: #{darken($ui-base-color, 4%)};
|
||||
--surface-variant-background-color: #{$ui-base-color};
|
||||
--surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
|
||||
--on-surface-color: #{transparentize($ui-base-color, 0.5)};
|
||||
--avatar-border-radius: 8px;
|
||||
}
|
||||
|
@ -144,6 +144,7 @@ class Account < ApplicationRecord
|
||||
scope :dormant, -> { joins(:account_stat).merge(AccountStat.without_recent_activity) }
|
||||
scope :with_username, ->(value) { where arel_table[:username].lower.eq(value.to_s.downcase) }
|
||||
scope :with_domain, ->(value) { where arel_table[:domain].lower.eq(value&.to_s&.downcase) }
|
||||
scope :without_memorial, -> { where(memorial: false) }
|
||||
|
||||
after_update_commit :trigger_update_webhooks
|
||||
|
||||
|
@ -31,6 +31,7 @@ class AccountSuggestions::FriendsOfFriendsSource < AccountSuggestions::Source
|
||||
AND accounts.suspended_at IS NULL
|
||||
AND accounts.silenced_at IS NULL
|
||||
AND accounts.moved_to_account_id IS NULL
|
||||
AND accounts.memorial = FALSE
|
||||
AND follow_recommendation_mutes.target_account_id IS NULL
|
||||
GROUP BY accounts.id, account_stats.id
|
||||
ORDER BY frequency DESC, account_stats.followers_count ASC
|
||||
|
@ -14,6 +14,7 @@ class AccountSuggestions::Source
|
||||
.searchable
|
||||
.where(discoverable: true)
|
||||
.without_silenced
|
||||
.without_memorial
|
||||
.where.not(follows_sql, id: account.id)
|
||||
.where.not(follow_requests_sql, id: account.id)
|
||||
.not_excluded_by_account(account)
|
||||
|
@ -14,7 +14,7 @@ class Trends::TagFilter
|
||||
|
||||
def results
|
||||
scope = if params[:status] == 'pending_review'
|
||||
Tag.unscoped
|
||||
Tag.unscoped.order(id: :desc)
|
||||
else
|
||||
trending_scope
|
||||
end
|
||||
|
@ -1,9 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AcceptNotificationRequestService < BaseService
|
||||
include Redisable
|
||||
|
||||
def call(request)
|
||||
NotificationPermission.create!(account: request.account, from_account: request.from_account)
|
||||
increment_worker_count!(request)
|
||||
UnfilterNotificationsWorker.perform_async(request.account_id, request.from_account_id)
|
||||
request.destroy!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def increment_worker_count!(request)
|
||||
with_redis do |redis|
|
||||
redis.incr("notification_unfilter_jobs:#{request.account_id}")
|
||||
redis.expire("notification_unfilter_jobs:#{request.account_id}", 30.minutes.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
class UnfilterNotificationsWorker
|
||||
include Sidekiq::Worker
|
||||
include Redisable
|
||||
|
||||
# Earlier versions of the feature passed a `notification_request` ID
|
||||
# If `to_account_id` is passed, the first argument is an account ID
|
||||
@ -9,19 +10,20 @@ class UnfilterNotificationsWorker
|
||||
def perform(notification_request_or_account_id, from_account_id = nil)
|
||||
if from_account_id.present?
|
||||
@notification_request = nil
|
||||
@from_account = Account.find(from_account_id)
|
||||
@recipient = Account.find(notification_request_or_account_id)
|
||||
@from_account = Account.find_by(id: from_account_id)
|
||||
@recipient = Account.find_by(id: notification_request_or_account_id)
|
||||
else
|
||||
@notification_request = NotificationRequest.find(notification_request_or_account_id)
|
||||
@from_account = @notification_request.from_account
|
||||
@recipient = @notification_request.account
|
||||
@notification_request = NotificationRequest.find_by(id: notification_request_or_account_id)
|
||||
@from_account = @notification_request&.from_account
|
||||
@recipient = @notification_request&.account
|
||||
end
|
||||
|
||||
return if @from_account.nil? || @recipient.nil?
|
||||
|
||||
push_to_conversations!
|
||||
unfilter_notifications!
|
||||
remove_request!
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
decrement_worker_count!
|
||||
end
|
||||
|
||||
private
|
||||
@ -45,4 +47,17 @@ class UnfilterNotificationsWorker
|
||||
def notifications_with_private_mentions
|
||||
filtered_notifications.where(type: :mention).joins(mention: :status).merge(Status.where(visibility: :direct)).includes(mention: :status)
|
||||
end
|
||||
|
||||
def decrement_worker_count!
|
||||
value = redis.decr("notification_unfilter_jobs:#{@recipient.id}")
|
||||
push_streaming_event! if value <= 0 && subscribed_to_streaming_api?
|
||||
end
|
||||
|
||||
def push_streaming_event!
|
||||
redis.publish("timeline:#{@recipient.id}:notifications", Oj.dump(event: :notifications_merged, payload: '1'))
|
||||
end
|
||||
|
||||
def subscribed_to_streaming_api?
|
||||
redis.exists?("subscribed:timeline:#{@recipient.id}") || redis.exists?("subscribed:timeline:#{@recipient.id}:notifications")
|
||||
end
|
||||
end
|
||||
|
@ -158,6 +158,7 @@ namespace :api, format: false do
|
||||
collection do
|
||||
post :accept, to: 'requests#accept_bulk'
|
||||
post :dismiss, to: 'requests#dismiss_bulk'
|
||||
get :merged, to: 'requests#merged?'
|
||||
end
|
||||
|
||||
member do
|
||||
|
@ -23,7 +23,7 @@
|
||||
"lint:css": "stylelint \"**/*.{css,scss}\"",
|
||||
"lint": "yarn lint:js && yarn lint:css",
|
||||
"postversion": "git push --tags",
|
||||
"prepare": "husky",
|
||||
"postinstall": "test -d node_modules/husky && husky || echo \"husky is not installed\"",
|
||||
"start": "node ./streaming/index.js",
|
||||
"test": "yarn lint && yarn run typecheck && yarn jest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
@ -15,6 +15,7 @@ RSpec.describe AccountSuggestions::FriendsOfFriendsSource do
|
||||
let!(:john) { Fabricate(:account, discoverable: true, hide_collections: false) }
|
||||
let!(:jerk) { Fabricate(:account, discoverable: true, hide_collections: false) }
|
||||
let!(:larry) { Fabricate(:account, discoverable: true, hide_collections: false) }
|
||||
let!(:morty) { Fabricate(:account, discoverable: true, hide_collections: false, memorial: true) }
|
||||
|
||||
context 'with follows and blocks' do
|
||||
before do
|
||||
@ -27,8 +28,8 @@ RSpec.describe AccountSuggestions::FriendsOfFriendsSource do
|
||||
# alice follows eve and mallory
|
||||
[john, mallory].each { |account| alice.follow!(account) }
|
||||
|
||||
# eugen follows eve, john, jerk, larry and neil
|
||||
[eve, mallory, jerk, larry, neil].each { |account| eugen.follow!(account) }
|
||||
# eugen follows eve, john, jerk, larry, neil and morty
|
||||
[eve, mallory, jerk, larry, neil, morty].each { |account| eugen.follow!(account) }
|
||||
end
|
||||
|
||||
it 'returns eligible accounts', :aggregate_failures do
|
||||
@ -51,6 +52,9 @@ RSpec.describe AccountSuggestions::FriendsOfFriendsSource do
|
||||
|
||||
# the suggestion for neil has already been rejected
|
||||
expect(results).to_not include([neil.id, :friends_of_friends])
|
||||
|
||||
# morty is not included because his account is in memoriam
|
||||
expect(results).to_not include([morty.id, :friends_of_friends])
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -21,6 +21,7 @@ RSpec.describe AccountSuggestions::Source do
|
||||
let!(:moved_account) { Fabricate(:account, moved_to_account: Fabricate(:account), discoverable: true) }
|
||||
let!(:silenced_account) { Fabricate(:account, silenced: true, discoverable: true) }
|
||||
let!(:undiscoverable_account) { Fabricate(:account, discoverable: false) }
|
||||
let!(:memorial_account) { Fabricate(:account, memorial: true, discoverable: true) }
|
||||
|
||||
before do
|
||||
Fabricate :account_domain_block, account: account, domain: account_domain_blocked_account.domain
|
||||
@ -44,6 +45,7 @@ RSpec.describe AccountSuggestions::Source do
|
||||
.and not_include(moved_account)
|
||||
.and not_include(silenced_account)
|
||||
.and not_include(undiscoverable_account)
|
||||
.and not_include(memorial_account)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -120,4 +120,34 @@ RSpec.describe 'Requests' do
|
||||
expect(response).to have_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/notifications/requests/merged' do
|
||||
subject do
|
||||
get '/api/v1/notifications/requests/merged', headers: headers
|
||||
end
|
||||
|
||||
it_behaves_like 'forbidden for wrong scope', 'write write:notifications'
|
||||
|
||||
context 'when the user has no accepted request pending merge' do
|
||||
it 'returns http success and returns merged: true' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to eq({ merged: true })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the user has an accepted request pending merge' do
|
||||
before do
|
||||
redis.set("notification_unfilter_jobs:#{user.account_id}", 1)
|
||||
end
|
||||
|
||||
it 'returns http success and returns merged: false' do
|
||||
subject
|
||||
|
||||
expect(response).to have_http_status(200)
|
||||
expect(body_as_json).to eq({ merged: false })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -185,7 +185,7 @@ RSpec.describe 'Notifications' do
|
||||
it 'returns the requested number of notifications paginated', :aggregate_failures do
|
||||
subject
|
||||
|
||||
notifications = user.account.notifications.browserable
|
||||
notifications = user.account.notifications.browserable.order(id: :asc)
|
||||
|
||||
expect(body_as_json.size)
|
||||
.to eq(params[:limit])
|
||||
|
@ -8,10 +8,11 @@ RSpec.describe AcceptNotificationRequestService do
|
||||
let(:notification_request) { Fabricate(:notification_request) }
|
||||
|
||||
describe '#call' do
|
||||
it 'destroys the notification request, creates a permission, and queues a worker' do
|
||||
it 'destroys the notification request, creates a permission, increases the jobs count and queues a worker' do
|
||||
expect { subject.call(notification_request) }
|
||||
.to change { NotificationRequest.exists?(notification_request.id) }.to(false)
|
||||
.and change { NotificationPermission.exists?(account_id: notification_request.account_id, from_account_id: notification_request.from_account_id) }.to(true)
|
||||
.and change { redis.get("notification_unfilter_jobs:#{notification_request.account_id}").to_i }.by(1)
|
||||
|
||||
expect(UnfilterNotificationsWorker).to have_enqueued_sidekiq_job(notification_request.account_id, notification_request.from_account_id)
|
||||
end
|
||||
|
@ -13,13 +13,56 @@ describe UnfilterNotificationsWorker do
|
||||
Fabricate(:notification, filtered: true, from_account: sender, account: recipient, type: :mention, activity: mention)
|
||||
follow_request = sender.request_follow!(recipient)
|
||||
Fabricate(:notification, filtered: true, from_account: sender, account: recipient, type: :follow_request, activity: follow_request)
|
||||
allow(redis).to receive(:publish)
|
||||
allow(redis).to receive(:exists?).and_return(false)
|
||||
end
|
||||
|
||||
shared_examples 'shared behavior' do
|
||||
it 'unfilters notifications and adds private messages to conversations' do
|
||||
expect { subject }
|
||||
.to change { recipient.notifications.where(from_account_id: sender.id).pluck(:filtered) }.from([true, true]).to([false, false])
|
||||
.and change { recipient.conversations.exists?(last_status_id: sender.statuses.first.id) }.to(true)
|
||||
context 'when this is the last pending merge job and the user is subscribed to streaming' do
|
||||
before do
|
||||
redis.set("notification_unfilter_jobs:#{recipient.id}", 1)
|
||||
allow(redis).to receive(:exists?).with("subscribed:timeline:#{recipient.id}").and_return(true)
|
||||
end
|
||||
|
||||
it 'unfilters notifications, adds private messages to conversations, and pushes to redis' do
|
||||
expect { subject }
|
||||
.to change { recipient.notifications.where(from_account_id: sender.id).pluck(:filtered) }.from([true, true]).to([false, false])
|
||||
.and change { recipient.conversations.exists?(last_status_id: sender.statuses.first.id) }.to(true)
|
||||
.and change { redis.get("notification_unfilter_jobs:#{recipient.id}").to_i }.by(-1)
|
||||
|
||||
expect(redis).to have_received(:publish).with("timeline:#{recipient.id}:notifications", '{"event":"notifications_merged","payload":"1"}')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when this is not last pending merge job and the user is subscribed to streaming' do
|
||||
before do
|
||||
redis.set("notification_unfilter_jobs:#{recipient.id}", 2)
|
||||
allow(redis).to receive(:exists?).with("subscribed:timeline:#{recipient.id}").and_return(true)
|
||||
end
|
||||
|
||||
it 'unfilters notifications, adds private messages to conversations, and does not push to redis' do
|
||||
expect { subject }
|
||||
.to change { recipient.notifications.where(from_account_id: sender.id).pluck(:filtered) }.from([true, true]).to([false, false])
|
||||
.and change { recipient.conversations.exists?(last_status_id: sender.statuses.first.id) }.to(true)
|
||||
.and change { redis.get("notification_unfilter_jobs:#{recipient.id}").to_i }.by(-1)
|
||||
|
||||
expect(redis).to_not have_received(:publish).with("timeline:#{recipient.id}:notifications", '{"event":"notifications_merged","payload":"1"}')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when this is the last pending merge job and the user is not subscribed to streaming' do
|
||||
before do
|
||||
redis.set("notification_unfilter_jobs:#{recipient.id}", 1)
|
||||
end
|
||||
|
||||
it 'unfilters notifications, adds private messages to conversations, and does not push to redis' do
|
||||
expect { subject }
|
||||
.to change { recipient.notifications.where(from_account_id: sender.id).pluck(:filtered) }.from([true, true]).to([false, false])
|
||||
.and change { recipient.conversations.exists?(last_status_id: sender.statuses.first.id) }.to(true)
|
||||
.and change { redis.get("notification_unfilter_jobs:#{recipient.id}").to_i }.by(-1)
|
||||
|
||||
expect(redis).to_not have_received(:publish).with("timeline:#{recipient.id}:notifications", '{"event":"notifications_merged","payload":"1"}')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user