From 664bef35730542791c44c739b8f39e37d7baddd3 Mon Sep 17 00:00:00 2001 From: Michael Stanclift Date: Thu, 8 Aug 2024 12:31:06 -0500 Subject: [PATCH 01/16] Fix styling issues with notification settings and mobile borders (#31346) --- .../styles/mastodon-light/diff.scss | 6 --- .../styles/mastodon-light/variables.scss | 2 +- .../styles/mastodon/components.scss | 15 ++++++++ .../styles/mastodon/emoji_picker.scss | 5 --- app/javascript/styles/mastodon/reset.scss | 37 ------------------- 5 files changed, 16 insertions(+), 49 deletions(-) diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 5684a99e51..b28ae8860c 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -214,12 +214,6 @@ html { border-top-color: lighten($ui-base-color, 8%); } -.column-header__collapsible-inner { - background: darken($ui-base-color, 4%); - border: 1px solid var(--background-border-color); - border-bottom: 0; -} - .column-settings__hashtags .column-select__option { color: $white; } diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss index 9f571b3f26..9d4fd60945 100644 --- a/app/javascript/styles/mastodon-light/variables.scss +++ b/app/javascript/styles/mastodon-light/variables.scss @@ -21,7 +21,7 @@ $valid-value-color: $success-green !default; $ui-base-color: $classic-secondary-color !default; $ui-base-lighter-color: #b0c0cf; -$ui-primary-color: #9bcbed; +$ui-primary-color: $classic-primary-color !default; $ui-secondary-color: $classic-base-color !default; $ui-highlight-color: $classic-highlight-color !default; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 2b1ae6e14f..67e47460b5 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2779,6 +2779,11 @@ $ui-header-logo-wordmark-width: 99px; &.privacy-policy { border-top: 1px solid var(--background-border-color); border-radius: 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 0; + border-bottom: 0; + } } } } @@ -4472,6 +4477,11 @@ a.status-card { .column-header__collapsible-inner { border: 1px solid var(--background-border-color); border-top: 0; + + @media screen and (max-width: $no-gap-breakpoint) { + border-left: 0; + border-right: 0; + } } .column-header__setting-btn { @@ -7657,6 +7667,11 @@ noscript { width: 100%; } } + + @media screen and (max-width: $no-gap-breakpoint) { + border-left: 0; + border-right: 0; + } } .drawer__backdrop { diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss index 3652ad4abb..68e016d44b 100644 --- a/app/javascript/styles/mastodon/emoji_picker.scss +++ b/app/javascript/styles/mastodon/emoji_picker.scss @@ -83,11 +83,6 @@ max-height: 35vh; padding: 0 6px 6px; will-change: transform; - - &::-webkit-scrollbar-track:hover, - &::-webkit-scrollbar-track:active { - background-color: rgba($base-overlay-background, 0.3); - } } .emoji-mart-search { diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss index f54ed5bc79..903b6c804f 100644 --- a/app/javascript/styles/mastodon/reset.scss +++ b/app/javascript/styles/mastodon/reset.scss @@ -56,40 +56,3 @@ table { html { scrollbar-color: lighten($ui-base-color, 4%) rgba($base-overlay-background, 0.1); } - -::-webkit-scrollbar { - width: 12px; - height: 12px; -} - -::-webkit-scrollbar-thumb { - background: lighten($ui-base-color, 4%); - border: 0px none $base-border-color; - border-radius: 50px; -} - -::-webkit-scrollbar-thumb:hover { - background: lighten($ui-base-color, 6%); -} - -::-webkit-scrollbar-thumb:active { - background: lighten($ui-base-color, 4%); -} - -::-webkit-scrollbar-track { - border: 0px none $base-border-color; - border-radius: 0; - background: rgba($base-overlay-background, 0.1); -} - -::-webkit-scrollbar-track:hover { - background: $ui-base-color; -} - -::-webkit-scrollbar-track:active { - background: $ui-base-color; -} - -::-webkit-scrollbar-corner { - background: transparent; -} From a207a1f7dcf85b3cea24c016e0d35c4a8e308265 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Thu, 8 Aug 2024 21:22:16 +0200 Subject: [PATCH 02/16] Disable stylelint rules that are conflicting with Prettier (#31339) --- package.json | 1 + stylelint.config.js | 2 +- yarn.lock | 13 +++++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 26ee3570a9..d183398f17 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,7 @@ "prettier": "^3.3.3", "react-test-renderer": "^18.2.0", "stylelint": "^16.0.2", + "stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-standard-scss": "^13.0.0", "typescript": "^5.0.4", "webpack-dev-server": "^3.11.3" diff --git a/stylelint.config.js b/stylelint.config.js index 9f95091630..632463c596 100644 --- a/stylelint.config.js +++ b/stylelint.config.js @@ -1,5 +1,5 @@ module.exports = { - extends: ['stylelint-config-standard-scss'], + extends: ['stylelint-config-standard-scss', 'stylelint-config-prettier-scss'], ignoreFiles: [ 'app/javascript/styles/mastodon/reset.scss', 'coverage/**/*', diff --git a/yarn.lock b/yarn.lock index ea934cac9d..77fda4e406 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2945,6 +2945,7 @@ __metadata: stacktrace-js: "npm:^2.0.2" stringz: "npm:^2.1.0" stylelint: "npm:^16.0.2" + stylelint-config-prettier-scss: "npm:^1.0.0" stylelint-config-standard-scss: "npm:^13.0.0" substring-trie: "npm:^1.0.2" terser-webpack-plugin: "npm:^4.2.3" @@ -16595,6 +16596,18 @@ __metadata: languageName: node linkType: hard +"stylelint-config-prettier-scss@npm:^1.0.0": + version: 1.0.0 + resolution: "stylelint-config-prettier-scss@npm:1.0.0" + peerDependencies: + stylelint: ">=15.0.0" + bin: + stylelint-config-prettier-scss: bin/check.js + stylelint-config-prettier-scss-check: bin/check.js + checksum: 10c0/4d5e1d1c200d4611b5b7bd2d2528cc9e301f26645802a2774aec192c4c2949cbf5a0147eba8b2e6e4ff14a071b03024f3034bb1b4fda37a8ed5a0081a9597d4d + languageName: node + linkType: hard + "stylelint-config-recommended-scss@npm:^14.0.0": version: 14.0.0 resolution: "stylelint-config-recommended-scss@npm:14.0.0" From 389549e7838291f09bc291c7559730fe3f2aad58 Mon Sep 17 00:00:00 2001 From: Michael Stanclift Date: Thu, 8 Aug 2024 14:23:15 -0500 Subject: [PATCH 03/16] Fix list creation textbox styling (#31348) --- app/javascript/styles/mastodon-light/diff.scss | 4 ++++ app/javascript/styles/mastodon/components.scss | 7 +++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index b28ae8860c..f056cb0668 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -551,3 +551,7 @@ a.sparkline { background: darken($ui-base-color, 10%); } } + +.setting-text { + background: darken($ui-base-color, 10%); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 67e47460b5..6c1b56337c 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3881,18 +3881,17 @@ $ui-header-logo-wordmark-width: 99px; display: block; box-sizing: border-box; margin: 0; - color: $inverted-text-color; - background: $white; + color: $primary-text-color; + background: $ui-base-color; padding: 7px 10px; font-family: inherit; font-size: 14px; line-height: 22px; border-radius: 4px; - border: 1px solid $white; + border: 1px solid var(--background-border-color); &:focus { outline: 0; - border-color: lighten($ui-highlight-color, 12%); } &__wrapper { From f045ef8e927b19a53fb4c399306a661c6e7eca63 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Aug 2024 21:30:42 +0200 Subject: [PATCH 04/16] Update dependency eslint-plugin-jsdoc to v50 (#31330) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 37 ++++++++++++++++++++++++++++--------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d183398f17..69fa90c610 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "eslint-import-resolver-typescript": "^3.5.5", "eslint-plugin-formatjs": "^4.10.1", "eslint-plugin-import": "~2.29.0", - "eslint-plugin-jsdoc": "^48.0.0", + "eslint-plugin-jsdoc": "^50.0.0", "eslint-plugin-jsx-a11y": "~6.9.0", "eslint-plugin-promise": "~6.6.0", "eslint-plugin-react": "^7.33.2", diff --git a/yarn.lock b/yarn.lock index 77fda4e406..44d59d6698 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2885,7 +2885,7 @@ __metadata: eslint-import-resolver-typescript: "npm:^3.5.5" eslint-plugin-formatjs: "npm:^4.10.1" eslint-plugin-import: "npm:~2.29.0" - eslint-plugin-jsdoc: "npm:^48.0.0" + eslint-plugin-jsdoc: "npm:^50.0.0" eslint-plugin-jsx-a11y: "npm:~6.9.0" eslint-plugin-promise: "npm:~6.6.0" eslint-plugin-react: "npm:^7.33.2" @@ -4618,12 +4618,12 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0": - version: 8.11.2 - resolution: "acorn@npm:8.11.2" +"acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.12.0, acorn@npm:^8.8.1, acorn@npm:^8.8.2, acorn@npm:^8.9.0": + version: 8.12.1 + resolution: "acorn@npm:8.12.1" bin: acorn: bin/acorn - checksum: 10c0/a3ed76c761b75ec54b1ec3068fb7f113a182e95aea7f322f65098c2958d232e3d211cb6dac35ff9c647024b63714bc528a26d54a925d1fef2c25585b4c8e4017 + checksum: 10c0/51fb26cd678f914e13287e886da2d7021f8c2bc0ccc95e03d3e0447ee278dd3b40b9c57dc222acd5881adcf26f3edc40901a4953403232129e3876793cd17386 languageName: node linkType: hard @@ -7968,15 +7968,16 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-jsdoc@npm:^48.0.0": - version: 48.8.3 - resolution: "eslint-plugin-jsdoc@npm:48.8.3" +"eslint-plugin-jsdoc@npm:^50.0.0": + version: 50.0.0 + resolution: "eslint-plugin-jsdoc@npm:50.0.0" dependencies: "@es-joy/jsdoccomment": "npm:~0.46.0" are-docs-informative: "npm:^0.0.2" comment-parser: "npm:1.4.1" debug: "npm:^4.3.5" escape-string-regexp: "npm:^4.0.0" + espree: "npm:^10.1.0" esquery: "npm:^1.6.0" parse-imports: "npm:^2.1.1" semver: "npm:^7.6.3" @@ -7984,7 +7985,7 @@ __metadata: synckit: "npm:^0.9.1" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/78d893614b188617de5a03d8163406455e3b739fd7b86192eb05a29cf8e7f06909a6f6a1b9dc2acd31e5ae2bccd94600eaea247d277f58c3c946c0fdb36a57f7 + checksum: 10c0/1d476eabdf604f4a07ef9a22fb7b13ba898d0aed81b2c428d4b6aea766b908ebdc7e6e82a16bac3f83e1013c6edba6d9a15a4015cab9a94c584ebccbd7255b70 languageName: node linkType: hard @@ -8087,6 +8088,13 @@ __metadata: languageName: node linkType: hard +"eslint-visitor-keys@npm:^4.0.0": + version: 4.0.0 + resolution: "eslint-visitor-keys@npm:4.0.0" + checksum: 10c0/76619f42cf162705a1515a6868e6fc7567e185c7063a05621a8ac4c3b850d022661262c21d9f1fc1d144ecf0d5d64d70a3f43c15c3fc969a61ace0fb25698cf5 + languageName: node + linkType: hard + "eslint@npm:^8.41.0": version: 8.57.0 resolution: "eslint@npm:8.57.0" @@ -8135,6 +8143,17 @@ __metadata: languageName: node linkType: hard +"espree@npm:^10.1.0": + version: 10.1.0 + resolution: "espree@npm:10.1.0" + dependencies: + acorn: "npm:^8.12.0" + acorn-jsx: "npm:^5.3.2" + eslint-visitor-keys: "npm:^4.0.0" + checksum: 10c0/52e6feaa77a31a6038f0c0e3fce93010a4625701925b0715cd54a2ae190b3275053a0717db698697b32653788ac04845e489d6773b508d6c2e8752f3c57470a0 + languageName: node + linkType: hard + "espree@npm:^9.6.0, espree@npm:^9.6.1": version: 9.6.1 resolution: "espree@npm:9.6.1" From 6e01a23e3bea35f7bf90bd420f9372a6a7421fae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 8 Aug 2024 21:31:00 +0200 Subject: [PATCH 05/16] Update dependency eslint-plugin-promise to v7 (#31120) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 69fa90c610..371b7cbf3d 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ "eslint-plugin-import": "~2.29.0", "eslint-plugin-jsdoc": "^50.0.0", "eslint-plugin-jsx-a11y": "~6.9.0", - "eslint-plugin-promise": "~6.6.0", + "eslint-plugin-promise": "~7.1.0", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "husky": "^9.0.11", diff --git a/yarn.lock b/yarn.lock index 44d59d6698..08379101ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2887,7 +2887,7 @@ __metadata: eslint-plugin-import: "npm:~2.29.0" eslint-plugin-jsdoc: "npm:^50.0.0" eslint-plugin-jsx-a11y: "npm:~6.9.0" - eslint-plugin-promise: "npm:~6.6.0" + eslint-plugin-promise: "npm:~7.1.0" eslint-plugin-react: "npm:^7.33.2" eslint-plugin-react-hooks: "npm:^4.6.0" file-loader: "npm:^6.2.0" @@ -8015,12 +8015,12 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-promise@npm:~6.6.0": - version: 6.6.0 - resolution: "eslint-plugin-promise@npm:6.6.0" +"eslint-plugin-promise@npm:~7.1.0": + version: 7.1.0 + resolution: "eslint-plugin-promise@npm:7.1.0" peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 - checksum: 10c0/93a667dbc9ff15c4d586b0d40a31c7828314cbbb31b2b9a75802aa4ef536e9457bb3e1a89b384b07aa336dd61b315ae8b0aadc0870210378023dd018819b59b3 + checksum: 10c0/bbc3406139715dfa5f48d04f6d5b5e82f68929d954b0fa3821eb8cd6dc381b210512cedd2d874e5de5381005d316566f4ae046a4750ce3f5f5cbf28a14cc0ab2 languageName: node linkType: hard From 2095d0f2b08c52a39380bb8b303851e475c6b3a7 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Thu, 8 Aug 2024 22:20:35 +0200 Subject: [PATCH 06/16] Update notification labels for mentions (#31304) --- .../components/notification_mention.tsx | 54 +++++++++++-------- app/javascript/mastodon/locales/en.json | 7 ++- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx index f8d646b07e..b7cd995118 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_mention.tsx @@ -2,26 +2,33 @@ import { FormattedMessage } from 'react-intl'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react'; -import type { StatusVisibility } from 'mastodon/api_types/statuses'; +import { me } from 'mastodon/initial_state'; import type { NotificationGroupMention } from 'mastodon/models/notification_group'; +import type { Status } from 'mastodon/models/status'; import { useAppSelector } from 'mastodon/store'; import type { LabelRenderer } from './notification_group_with_status'; import { NotificationWithStatus } from './notification_with_status'; -const labelRenderer: LabelRenderer = (values) => ( +const mentionLabelRenderer: LabelRenderer = () => ( + +); + +const privateMentionLabelRenderer: LabelRenderer = () => ( ); -const privateMentionLabelRenderer: LabelRenderer = (values) => ( +const replyLabelRenderer: LabelRenderer = () => ( + +); + +const privateReplyLabelRenderer: LabelRenderer = () => ( ); @@ -29,27 +36,30 @@ export const NotificationMention: React.FC<{ notification: NotificationGroupMention; unread: boolean; }> = ({ notification, unread }) => { - const statusVisibility = useAppSelector( - (state) => - state.statuses.getIn([ - notification.statusId, - 'visibility', - ]) as StatusVisibility, - ); + const [isDirect, isReply] = useAppSelector((state) => { + const status = state.statuses.get(notification.statusId) as Status; + + return [ + status.get('visibility') === 'direct', + status.get('in_reply_to_account_id') === me, + ] as const; + }); + + let labelRenderer = mentionLabelRenderer; + + if (isReply && isDirect) labelRenderer = privateReplyLabelRenderer; + else if (isReply) labelRenderer = replyLabelRenderer; + else if (isDirect) labelRenderer = privateMentionLabelRenderer; return ( ); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8db48c1a28..86afa7cd0d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -482,7 +482,11 @@ "notification.favourite": "{name} favorited your post", "notification.follow": "{name} followed you", "notification.follow_request": "{name} has requested to follow you", - "notification.mention": "{name} mentioned you", + "notification.label.mention": "Mention", + "notification.label.private_mention": "Private mention", + "notification.label.private_reply": "Private reply", + "notification.label.reply": "Reply", + "notification.mention": "Mention", "notification.moderation-warning.learn_more": "Learn more", "notification.moderation_warning": "You have received a moderation warning", "notification.moderation_warning.action_delete_statuses": "Some of your posts have been removed.", @@ -494,7 +498,6 @@ "notification.moderation_warning.action_suspend": "Your account has been suspended.", "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you voted in has ended", - "notification.private_mention": "{name} privately mentioned you", "notification.reblog": "{name} boosted your post", "notification.relationships_severance_event": "Lost connections with {name}", "notification.relationships_severance_event.account_suspension": "An admin from {from} has suspended {target}, which means you can no longer receive updates from them or interact with them.", From 6ca731e9b69962a0023df2c5314562293bb4b6b6 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 8 Aug 2024 22:20:52 +0200 Subject: [PATCH 07/16] Change unread notification count to only cover the selected notification type (#31326) --- .../features/notifications_v2/index.tsx | 31 +--------- .../mastodon/selectors/notifications.ts | 57 +++++++++++++++++-- 2 files changed, 54 insertions(+), 34 deletions(-) diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index 21afd9516e..63e602bdcc 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -4,8 +4,6 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { createSelector } from '@reduxjs/toolkit'; - import { useDebouncedCallback } from 'use-debounce'; import DoneAllIcon from '@/material-icons/400-24px/done_all.svg?react'; @@ -27,16 +25,13 @@ import { selectUnreadNotificationGroupsCount, selectPendingNotificationGroupsCount, selectAnyPendingNotification, + selectNotificationGroups, } from 'mastodon/selectors/notifications'; import { selectNeedsNotificationPermission, - selectSettingsNotificationsExcludedTypes, - selectSettingsNotificationsQuickFilterActive, - selectSettingsNotificationsQuickFilterShow, selectSettingsNotificationsShowUnread, } from 'mastodon/selectors/settings'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { RootState } from 'mastodon/store'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { submitMarkers } from '../../actions/markers'; @@ -62,34 +57,12 @@ const messages = defineMessages({ }, }); -const getNotifications = createSelector( - [ - selectSettingsNotificationsQuickFilterShow, - selectSettingsNotificationsQuickFilterActive, - selectSettingsNotificationsExcludedTypes, - (state: RootState) => state.notificationGroups.groups, - ], - (showFilterBar, allowedType, excludedTypes, notifications) => { - if (!showFilterBar || allowedType === 'all') { - // used if user changed the notification settings after loading the notifications from the server - // otherwise a list of notifications will come pre-filtered from the backend - // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category - return notifications.filter( - (item) => item.type === 'gap' || !excludedTypes.includes(item.type), - ); - } - return notifications.filter( - (item) => item.type === 'gap' || allowedType === item.type, - ); - }, -); - export const Notifications: React.FC<{ columnId?: string; multiColumn?: boolean; }> = ({ columnId, multiColumn }) => { const intl = useIntl(); - const notifications = useAppSelector(getNotifications); + const notifications = useAppSelector(selectNotificationGroups); const dispatch = useAppDispatch(); const isLoading = useAppSelector((s) => s.notificationGroups.isLoading); const hasMore = notifications.at(-1)?.type === 'gap'; diff --git a/app/javascript/mastodon/selectors/notifications.ts b/app/javascript/mastodon/selectors/notifications.ts index 962dedd650..ea640406ea 100644 --- a/app/javascript/mastodon/selectors/notifications.ts +++ b/app/javascript/mastodon/selectors/notifications.ts @@ -1,15 +1,62 @@ import { createSelector } from '@reduxjs/toolkit'; import { compareId } from 'mastodon/compare_id'; +import type { NotificationGroup } from 'mastodon/models/notification_group'; +import type { NotificationGap } from 'mastodon/reducers/notification_groups'; import type { RootState } from 'mastodon/store'; +import { + selectSettingsNotificationsExcludedTypes, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsQuickFilterShow, +} from './settings'; + +const filterNotificationsByAllowedTypes = ( + showFilterBar: boolean, + allowedType: string, + excludedTypes: string[], + notifications: (NotificationGroup | NotificationGap)[], +) => { + if (!showFilterBar || allowedType === 'all') { + // used if user changed the notification settings after loading the notifications from the server + // otherwise a list of notifications will come pre-filtered from the backend + // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category + return notifications.filter( + (item) => item.type === 'gap' || !excludedTypes.includes(item.type), + ); + } + return notifications.filter( + (item) => item.type === 'gap' || allowedType === item.type, + ); +}; + +export const selectNotificationGroups = createSelector( + [ + selectSettingsNotificationsQuickFilterShow, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsExcludedTypes, + (state: RootState) => state.notificationGroups.groups, + ], + filterNotificationsByAllowedTypes, +); + +const selectPendingNotificationGroups = createSelector( + [ + selectSettingsNotificationsQuickFilterShow, + selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsExcludedTypes, + (state: RootState) => state.notificationGroups.pendingGroups, + ], + filterNotificationsByAllowedTypes, +); + export const selectUnreadNotificationGroupsCount = createSelector( [ (s: RootState) => s.notificationGroups.lastReadId, - (s: RootState) => s.notificationGroups.pendingGroups, - (s: RootState) => s.notificationGroups.groups, + selectNotificationGroups, + selectPendingNotificationGroups, ], - (notificationMarker, pendingGroups, groups) => { + (notificationMarker, groups, pendingGroups) => { return ( groups.filter( (group) => @@ -31,7 +78,7 @@ export const selectUnreadNotificationGroupsCount = createSelector( export const selectAnyPendingNotification = createSelector( [ (s: RootState) => s.notificationGroups.readMarkerId, - (s: RootState) => s.notificationGroups.groups, + selectNotificationGroups, ], (notificationMarker, groups) => { return groups.some( @@ -44,7 +91,7 @@ export const selectAnyPendingNotification = createSelector( ); export const selectPendingNotificationGroupsCount = createSelector( - [(s: RootState) => s.notificationGroups.pendingGroups], + [selectPendingNotificationGroups], (pendingGroups) => pendingGroups.filter((group) => group.type !== 'gap').length, ); From 9538d9c298ed98127e3ba297a1933608976b1906 Mon Sep 17 00:00:00 2001 From: Michael Stanclift Date: Fri, 9 Aug 2024 07:45:39 -0500 Subject: [PATCH 08/16] Fix post filter & report styling (#31349) --- .../styles/mastodon-light/diff.scss | 4 ++ .../styles/mastodon/components.scss | 38 +++++++++---------- .../styles/mastodon/emoji_picker.scss | 1 - 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index f056cb0668..1f282605ed 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -555,3 +555,7 @@ a.sparkline { .setting-text { background: darken($ui-base-color, 10%); } + +.report-dialog-modal__textarea { + background: darken($ui-base-color, 10%); +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 6c1b56337c..3c938ac4c5 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -6244,9 +6244,10 @@ a.status-card { max-width: 90vw; width: 480px; height: 80vh; - background: lighten($ui-secondary-color, 8%); - color: $inverted-text-color; - border-radius: 8px; + background: var(--background-color); + color: $primary-text-color; + border-radius: 4px; + border: 1px solid var(--background-border-color); overflow: hidden; position: relative; flex-direction: column; @@ -6254,7 +6255,7 @@ a.status-card { &__container { box-sizing: border-box; - border-top: 1px solid $ui-secondary-color; + border-top: 1px solid var(--background-border-color); padding: 20px; flex-grow: 1; display: flex; @@ -6284,7 +6285,7 @@ a.status-card { &__lead { font-size: 17px; line-height: 22px; - color: lighten($inverted-text-color, 16%); + color: $secondary-text-color; margin-bottom: 30px; a { @@ -6319,7 +6320,7 @@ a.status-card { .status__content, .status__content p { - color: $inverted-text-color; + color: $primary-text-color; } .status__content__spoiler-link { @@ -6364,7 +6365,7 @@ a.status-card { .poll__option.dialog-option { padding: 15px 0; flex: 0 0 auto; - border-bottom: 1px solid $ui-secondary-color; + border-bottom: 1px solid var(--background-border-color); &:last-child { border-bottom: 0; @@ -6372,13 +6373,13 @@ a.status-card { & > .poll__option__text { font-size: 13px; - color: lighten($inverted-text-color, 16%); + color: $secondary-text-color; strong { font-size: 17px; font-weight: 500; line-height: 22px; - color: $inverted-text-color; + color: $primary-text-color; display: block; margin-bottom: 4px; @@ -6397,22 +6398,19 @@ a.status-card { display: block; box-sizing: border-box; width: 100%; - color: $inverted-text-color; - background: $simple-background-color; + color: $primary-text-color; + background: $ui-base-color; padding: 10px; font-family: inherit; font-size: 17px; line-height: 22px; resize: vertical; border: 0; + border: 1px solid var(--background-border-color); outline: 0; border-radius: 4px; margin: 20px 0; - &::placeholder { - color: $dark-text-color; - } - &:focus { outline: 0; } @@ -6433,16 +6431,16 @@ a.status-card { } .button.button-secondary { - border-color: $inverted-text-color; - color: $inverted-text-color; + border-color: $ui-button-destructive-background-color; + color: $ui-button-destructive-background-color; flex: 0 0 auto; &:hover, &:focus, &:active { - background: transparent; - border-color: $ui-button-background-color; - color: $ui-button-background-color; + background: $ui-button-destructive-background-color; + border-color: $ui-button-destructive-background-color; + color: $white; } } diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss index 68e016d44b..3189000588 100644 --- a/app/javascript/styles/mastodon/emoji_picker.scss +++ b/app/javascript/styles/mastodon/emoji_picker.scss @@ -111,7 +111,6 @@ &:focus { outline: none !important; border-width: 1px !important; - border-color: $ui-button-background-color; } &::-webkit-search-cancel-button { From 9bae2377924f4cda71bdff54fd270ef03ac9443c Mon Sep 17 00:00:00 2001 From: Jeong Arm Date: Fri, 9 Aug 2024 21:47:02 +0900 Subject: [PATCH 09/16] Change confirmation prompt on trending management (#19626) --- app/views/admin/trends/links/index.html.haml | 8 ++++---- app/views/admin/trends/statuses/index.html.haml | 8 ++++---- app/views/admin/trends/tags/index.html.haml | 4 ++-- config/locales/en.yml | 10 ++++++++++ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml index 647c24b1e9..e54acd656f 100644 --- a/app/views/admin/trends/links/index.html.haml +++ b/app/views/admin/trends/links/index.html.haml @@ -39,22 +39,22 @@ .batch-table__toolbar__actions = f.button safe_join([material_symbol('check'), t('admin.trends.links.allow')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.links.confirm_allow') }, name: :approve, type: :submit = f.button safe_join([material_symbol('check'), t('admin.trends.links.allow_provider')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.links.confirm_allow_provider') }, name: :approve_providers, type: :submit = f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.links.confirm_disallow') }, name: :reject, type: :submit = f.button safe_join([material_symbol('close'), t('admin.trends.links.disallow_provider')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.links.confirm_disallow_provider') }, name: :reject_providers, type: :submit .batch-table__body diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml index 4713f8c2ae..f9238dee46 100644 --- a/app/views/admin/trends/statuses/index.html.haml +++ b/app/views/admin/trends/statuses/index.html.haml @@ -35,22 +35,22 @@ .batch-table__toolbar__actions = f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.statuses.confirm_allow') }, name: :approve, type: :submit = f.button safe_join([material_symbol('check'), t('admin.trends.statuses.allow_account')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.statuses.confirm_allow_account') }, name: :approve_accounts, type: :submit = f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.statuses.confirm_disallow') }, name: :reject, type: :submit = f.button safe_join([material_symbol('close'), t('admin.trends.statuses.disallow_account')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.statuses.confirm_disallow_account') }, name: :reject_accounts, type: :submit .batch-table__body diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml index 3a44cf3a70..480877456f 100644 --- a/app/views/admin/trends/tags/index.html.haml +++ b/app/views/admin/trends/tags/index.html.haml @@ -27,12 +27,12 @@ .batch-table__toolbar__actions = f.button safe_join([material_symbol('check'), t('admin.trends.allow')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.confirm_allow') }, name: :approve, type: :submit = f.button safe_join([material_symbol('close'), t('admin.trends.disallow')]), class: 'table-action-link', - data: { confirm: t('admin.reports.are_you_sure') }, + data: { confirm: t('admin.trends.confirm_disallow') }, name: :reject, type: :submit diff --git a/config/locales/en.yml b/config/locales/en.yml index aab8db1815..771bada520 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -907,10 +907,16 @@ en: trends: allow: Allow approved: Approved + confirm_allow: Are you sure you want to allow selected tags? + confirm_disallow: Are you sure you want to disallow selected tags? disallow: Disallow links: allow: Allow link allow_provider: Allow publisher + confirm_allow: Are you sure you want to allow selected links? + confirm_allow_provider: Are you sure you want to allow selected providers? + confirm_disallow: Are you sure you want to disallow selected links? + confirm_disallow_provider: Are you sure you want to disallow selected providers? description_html: These are links that are currently being shared a lot by accounts that your server sees posts from. It can help your users find out what's going on in the world. No links are displayed publicly until you approve the publisher. You can also allow or reject individual links. disallow: Disallow link disallow_provider: Disallow publisher @@ -934,6 +940,10 @@ en: statuses: allow: Allow post allow_account: Allow author + confirm_allow: Are you sure you want to allow selected statuses? + confirm_allow_account: Are you sure you want to allow selected accounts? + confirm_disallow: Are you sure you want to disallow selected statuses? + confirm_disallow_account: Are you sure you want to disallow selected accounts? description_html: These are posts that your server knows about that are currently being shared and favorited a lot at the moment. It can help your new and returning users to find more people to follow. No posts are displayed publicly until you approve the author, and the author allows their account to be suggested to others. You can also allow or reject individual posts. disallow: Disallow post disallow_account: Disallow author From 994ef16b72478c3c33f81466a13b77ef4fee5ebb Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 14:48:34 +0200 Subject: [PATCH 10/16] Bust CDN cache on media deletion (#31353) --- app/models/media_attachment.rb | 28 ++++++++++++++++++++++++++++ spec/models/media_attachment_spec.rb | 19 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index f53da04a97..a9470e1ad2 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -276,6 +276,9 @@ class MediaAttachment < ApplicationRecord before_create :set_unknown_type before_create :set_processing + before_destroy :prepare_cache_bust!, prepend: true + after_destroy :bust_cache! + after_commit :enqueue_processing, on: :create after_commit :reset_parent_cache, on: :update @@ -410,4 +413,29 @@ class MediaAttachment < ApplicationRecord def reset_parent_cache Rails.cache.delete("v3:statuses/#{status_id}") if status_id.present? end + + # Record the cache keys to burst before the file get actually deleted + def prepare_cache_bust! + return unless Rails.configuration.x.cache_buster_enabled + + @paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name| + attachment = public_send(attachment_name) + styles = DEFAULT_STYLES | attachment.styles.keys + styles.map { |style| attachment.path(style) } + end + rescue => e + # We really don't want any error here preventing media deletion + Rails.logger.warn "Error #{e.class} busting cache: #{e.message}" + end + + # Once Paperclip has deleted the files, we can't recover the cache keys, + # so use the previously-saved ones + def bust_cache! + return unless Rails.configuration.x.cache_buster_enabled + + CacheBusterWorker.push_bulk(@paths_to_cache_bust) { |path| [path] } + rescue => e + # We really don't want any error here preventing media deletion + Rails.logger.warn "Error #{e.class} busting cache: #{e.message}" + end end diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb index 3142b291fb..3297387ff7 100644 --- a/spec/models/media_attachment_spec.rb +++ b/spec/models/media_attachment_spec.rb @@ -292,6 +292,25 @@ RSpec.describe MediaAttachment, :attachment_processing do end end + describe 'cache deletion hooks' do + let(:media) { Fabricate(:media_attachment) } + + before do + allow(Rails.configuration.x).to receive(:cache_buster_enabled).and_return(true) + end + + it 'queues CacheBusterWorker jobs' do + original_path = media.file.path(:original) + small_path = media.file.path(:small) + thumbnail_path = media.thumbnail.path(:original) + + expect { media.destroy } + .to enqueue_sidekiq_job(CacheBusterWorker).with(original_path) + .and enqueue_sidekiq_job(CacheBusterWorker).with(small_path) + .and enqueue_sidekiq_job(CacheBusterWorker).with(thumbnail_path) + end + end + private def media_metadata From e29c401f774613b5c47fb69faf6ec99abf7c61a6 Mon Sep 17 00:00:00 2001 From: Christian Schmidt Date: Fri, 9 Aug 2024 15:05:34 +0200 Subject: [PATCH 11/16] Add lang attribute on preview card title (#31303) --- app/views/admin/trends/links/_preview_card.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml index ee3774790c..49e0dd3fca 100644 --- a/app/views/admin/trends/links/_preview_card.html.haml +++ b/app/views/admin/trends/links/_preview_card.html.haml @@ -4,12 +4,12 @@ .batch-table__row__content.pending-account .pending-account__header - = link_to preview_card.title, url_for_preview_card(preview_card) + = link_to preview_card.title, url_for_preview_card(preview_card), lang: preview_card.language %br/ - if preview_card.provider_name.present? - = preview_card.provider_name + %span{ lang: preview_card.language }= preview_card.provider_name · - if preview_card.language.present? From cbdd8edf68321b384d871855cd73b717ca394de2 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 15:30:55 +0200 Subject: [PATCH 12/16] Revamp notification policy options (#31343) --- .../v1/notifications/policies_controller.rb | 4 +- .../v2/notifications/policies_controller.rb | 38 ++++ app/models/notification_policy.rb | 47 ++++- .../rest/notification_policy_serializer.rb | 9 +- .../rest/v1/notification_policy_serializer.rb | 32 +++ app/services/notify_service.rb | 195 ++++++++++-------- config/routes/api.rb | 4 + ...808114841_add_new_notification_policies.rb | 11 + ...8124338_migrate_notifications_policy_v2.rb | 26 +++ ...loyment_migrate_notifications_policy_v2.rb | 26 +++ ..._old_policies_from_notifications_policy.rb | 12 ++ db/schema.rb | 11 +- lib/tasks/tests.rake | 4 +- .../api/v1/notifications/policies_spec.rb | 2 +- .../api/v2/notifications/policies_spec.rb | 72 +++++++ spec/services/notify_service_spec.rb | 58 +++++- 16 files changed, 442 insertions(+), 109 deletions(-) create mode 100644 app/controllers/api/v2/notifications/policies_controller.rb create mode 100644 app/serializers/rest/v1/notification_policy_serializer.rb create mode 100644 db/migrate/20240808114841_add_new_notification_policies.rb create mode 100644 db/migrate/20240808124338_migrate_notifications_policy_v2.rb create mode 100644 db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb create mode 100644 db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb create mode 100644 spec/requests/api/v2/notifications/policies_spec.rb diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 1ec336f9a5..9d70c283be 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController before_action :set_policy def show - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end def update @policy.update!(resource_params) - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end private diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 0000000000..637587967f --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts + ) + end +end diff --git a/app/models/notification_policy.rb b/app/models/notification_policy.rb index 2bb58004e3..3b16f33d88 100644 --- a/app/models/notification_policy.rb +++ b/app/models/notification_policy.rb @@ -4,17 +4,25 @@ # # Table name: notification_policies # -# id :bigint(8) not null, primary key -# account_id :bigint(8) not null -# filter_not_following :boolean default(FALSE), not null -# filter_not_followers :boolean default(FALSE), not null -# filter_new_accounts :boolean default(FALSE), not null -# filter_private_mentions :boolean default(TRUE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# for_not_following :integer default("accept"), not null +# for_not_followers :integer default("accept"), not null +# for_new_accounts :integer default("accept"), not null +# for_private_mentions :integer default("filter"), not null +# for_limited_accounts :integer default("filter"), not null # class NotificationPolicy < ApplicationRecord + self.ignored_columns += %w( + filter_not_following + filter_not_followers + filter_new_accounts + filter_private_mentions + ) + belongs_to :account has_many :notification_requests, primary_key: :account_id, foreign_key: :account_id, dependent: nil, inverse_of: false @@ -23,11 +31,34 @@ class NotificationPolicy < ApplicationRecord MAX_MEANINGFUL_COUNT = 100 + enum :for_not_following, { accept: 0, filter: 1, drop: 2 }, suffix: :not_following + enum :for_not_followers, { accept: 0, filter: 1, drop: 2 }, suffix: :not_followers + enum :for_new_accounts, { accept: 0, filter: 1, drop: 2 }, suffix: :new_accounts + enum :for_private_mentions, { accept: 0, filter: 1, drop: 2 }, suffix: :private_mentions + enum :for_limited_accounts, { accept: 0, filter: 1, drop: 2 }, suffix: :limited_accounts + def summarize! @pending_requests_count = pending_notification_requests.first @pending_notifications_count = pending_notification_requests.last end + # Compat helpers with V1 + def filter_not_following=(value) + self.for_not_following = value ? :filter : :accept + end + + def filter_not_followers=(value) + self.for_not_followers = value ? :filter : :accept + end + + def filter_new_accounts=(value) + self.for_new_accounts = value ? :filter : :accept + end + + def filter_private_mentions=(value) + self.for_private_mentions = value ? :filter : :accept + end + private def pending_notification_requests diff --git a/app/serializers/rest/notification_policy_serializer.rb b/app/serializers/rest/notification_policy_serializer.rb index 8bf85250fa..3902c1a04a 100644 --- a/app/serializers/rest/notification_policy_serializer.rb +++ b/app/serializers/rest/notification_policy_serializer.rb @@ -3,10 +3,11 @@ class REST::NotificationPolicySerializer < ActiveModel::Serializer # Please update `app/javascript/mastodon/api_types/notification_policies.ts` when making changes to the attributes - attributes :filter_not_following, - :filter_not_followers, - :filter_new_accounts, - :filter_private_mentions, + attributes :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts, :summary def summary diff --git a/app/serializers/rest/v1/notification_policy_serializer.rb b/app/serializers/rest/v1/notification_policy_serializer.rb new file mode 100644 index 0000000000..e1bbdc44ff --- /dev/null +++ b/app/serializers/rest/v1/notification_policy_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class REST::V1::NotificationPolicySerializer < ActiveModel::Serializer + attributes :filter_not_following, + :filter_not_followers, + :filter_new_accounts, + :filter_private_mentions, + :summary + + def summary + { + pending_requests_count: object.pending_requests_count.to_i, + pending_notifications_count: object.pending_notifications_count.to_i, + } + end + + def filter_not_following + !object.accept_not_following? + end + + def filter_not_followers + !object.accept_not_followers? + end + + def filter_new_accounts + !object.accept_new_accounts? + end + + def filter_private_mentions + !object.accept_private_mentions? + end +end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index b0bef8cd65..788381fe6b 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -16,59 +16,7 @@ class NotifyService < BaseService severed_relationships ).freeze - class DismissCondition - def initialize(notification) - @recipient = notification.account - @sender = notification.from_account - @notification = notification - end - - def dismiss? - blocked = @recipient.unavailable? - blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type) - - return blocked if message? && from_staff? - - blocked ||= domain_blocking? - blocked ||= @recipient.blocking?(@sender) - blocked ||= @recipient.muting_notifications?(@sender) - blocked ||= conversation_muted? - blocked ||= blocked_mention? if message? - blocked - end - - private - - def blocked_mention? - FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient) - end - - def message? - @notification.type == :mention - end - - def from_staff? - @sender.local? && @sender.user.present? && @sender.user_role&.overrides?(@recipient.user_role) && @sender.user_role&.highlighted? && @sender.user_role&.can?(*UserRole::Flags::CATEGORIES[:moderation]) - end - - def from_self? - @recipient.id == @sender.id - end - - def domain_blocking? - @recipient.domain_blocking?(@sender.domain) && !following_sender? - end - - def conversation_muted? - @notification.target_status && @recipient.muting_conversation?(@notification.target_status.conversation) - end - - def following_sender? - @recipient.following?(@sender) - end - end - - class FilterCondition + class BaseCondition NEW_ACCOUNT_THRESHOLD = 30.days.freeze NEW_FOLLOWER_THRESHOLD = 3.days.freeze @@ -82,39 +30,16 @@ class NotifyService < BaseService ).freeze def initialize(notification) - @notification = notification @recipient = notification.account @sender = notification.from_account + @notification = notification @policy = NotificationPolicy.find_or_initialize_by(account: @recipient) end - def filter? - return false unless Notification::PROPERTIES[@notification.type][:filterable] - return false if override_for_sender? - - from_limited? || - filtered_by_not_following_policy? || - filtered_by_not_followers_policy? || - filtered_by_new_accounts_policy? || - filtered_by_private_mentions_policy? - end - private - def filtered_by_not_following_policy? - @policy.filter_not_following? && not_following? - end - - def filtered_by_not_followers_policy? - @policy.filter_not_followers? && not_follower? - end - - def filtered_by_new_accounts_policy? - @policy.filter_new_accounts? && new_account? - end - - def filtered_by_private_mentions_policy? - @policy.filter_private_mentions? && not_following? && private_mention_not_in_response? + def filterable_type? + Notification::PROPERTIES[@notification.type][:filterable] end def not_following? @@ -174,6 +99,112 @@ class NotifyService < BaseService end end + class DropCondition < BaseCondition + def drop? + blocked = @recipient.unavailable? + blocked ||= from_self? && %i(poll severed_relationships moderation_warning).exclude?(@notification.type) + + return blocked if message? && from_staff? + + blocked ||= domain_blocking? + blocked ||= @recipient.blocking?(@sender) + blocked ||= @recipient.muting_notifications?(@sender) + blocked ||= conversation_muted? + blocked ||= blocked_mention? if message? + + return true if blocked + return false unless filterable_type? + return false if override_for_sender? + + blocked_by_limited_accounts_policy? || + blocked_by_not_following_policy? || + blocked_by_not_followers_policy? || + blocked_by_new_accounts_policy? || + blocked_by_private_mentions_policy? + end + + private + + def blocked_mention? + FeedManager.instance.filter?(:mentions, @notification.target_status, @recipient) + end + + def message? + @notification.type == :mention + end + + def from_staff? + @sender.local? && @sender.user.present? && @sender.user_role&.overrides?(@recipient.user_role) && @sender.user_role&.highlighted? && @sender.user_role&.can?(*UserRole::Flags::CATEGORIES[:moderation]) + end + + def from_self? + @recipient.id == @sender.id + end + + def domain_blocking? + @recipient.domain_blocking?(@sender.domain) && not_following? + end + + def conversation_muted? + @notification.target_status && @recipient.muting_conversation?(@notification.target_status.conversation) + end + + def blocked_by_not_following_policy? + @policy.drop_not_following? && not_following? + end + + def blocked_by_not_followers_policy? + @policy.drop_not_followers? && not_follower? + end + + def blocked_by_new_accounts_policy? + @policy.drop_new_accounts? && new_account? && not_following? + end + + def blocked_by_private_mentions_policy? + @policy.drop_private_mentions? && not_following? && private_mention_not_in_response? + end + + def blocked_by_limited_accounts_policy? + @policy.drop_limited_accounts? && @sender.silenced? && not_following? + end + end + + class FilterCondition < BaseCondition + def filter? + return false unless filterable_type? + return false if override_for_sender? + + filtered_by_limited_accounts_policy? || + filtered_by_not_following_policy? || + filtered_by_not_followers_policy? || + filtered_by_new_accounts_policy? || + filtered_by_private_mentions_policy? + end + + private + + def filtered_by_not_following_policy? + @policy.filter_not_following? && not_following? + end + + def filtered_by_not_followers_policy? + @policy.filter_not_followers? && not_follower? + end + + def filtered_by_new_accounts_policy? + @policy.filter_new_accounts? && new_account? && not_following? + end + + def filtered_by_private_mentions_policy? + @policy.filter_private_mentions? && not_following? && private_mention_not_in_response? + end + + def filtered_by_limited_accounts_policy? + @policy.filter_limited_accounts? && @sender.silenced? && not_following? + end + end + def call(recipient, type, activity) return if recipient.user.nil? @@ -182,7 +213,7 @@ class NotifyService < BaseService @notification = Notification.new(account: @recipient, type: type, activity: @activity) # For certain conditions we don't need to create a notification at all - return if dismiss? + return if drop? @notification.filtered = filter? @notification.group_key = notification_group_key @@ -222,8 +253,8 @@ class NotifyService < BaseService "#{type_prefix}-#{hour_bucket}" end - def dismiss? - DismissCondition.new(@notification).dismiss? + def drop? + DropCondition.new(@notification).drop? end def filter? diff --git a/config/routes/api.rb b/config/routes/api.rb index fa74c025b4..488bdb7453 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -336,6 +336,10 @@ namespace :api, format: false do namespace :admin do resources :accounts, only: [:index] end + + namespace :notifications do + resource :policy, only: [:show, :update] + end end namespace :v2_alpha do diff --git a/db/migrate/20240808114841_add_new_notification_policies.rb b/db/migrate/20240808114841_add_new_notification_policies.rb new file mode 100644 index 0000000000..9087ee35dc --- /dev/null +++ b/db/migrate/20240808114841_add_new_notification_policies.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddNewNotificationPolicies < ActiveRecord::Migration[7.1] + def change + add_column :notification_policies, :for_not_following, :integer, default: 0, null: false + add_column :notification_policies, :for_not_followers, :integer, default: 0, null: false + add_column :notification_policies, :for_new_accounts, :integer, default: 0, null: false + add_column :notification_policies, :for_private_mentions, :integer, default: 1, null: false + add_column :notification_policies, :for_limited_accounts, :integer, default: 1, null: false + end +end diff --git a/db/migrate/20240808124338_migrate_notifications_policy_v2.rb b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb new file mode 100644 index 0000000000..2e0684826a --- /dev/null +++ b/db/migrate/20240808124338_migrate_notifications_policy_v2.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class MigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + # Dummy classes, to make migration possible across version changes + class NotificationPolicy < ApplicationRecord; end + + def up + NotificationPolicy.in_batches.update_all(<<~SQL.squish) + for_not_following = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, + for_not_followers = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, + for_new_accounts = CASE filter_new_accounts WHEN true THEN 1 ELSE 0 END, + for_private_mentions = CASE filter_private_mentions WHEN true THEN 1 ELSE 0 END + SQL + end + + def down + NotificationPolicy.in_batches.update_all(<<~SQL.squish) + filter_not_following = CASE for_not_following WHEN 0 THEN false ELSE true END, + filter_not_following = CASE for_not_followers WHEN 0 THEN false ELSE true END, + filter_new_accounts = CASE for_new_accounts WHEN 0 THEN false ELSE true END, + filter_private_mentions = CASE for_private_mentions WHEN 0 THEN false ELSE true END + SQL + end +end diff --git a/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb b/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb new file mode 100644 index 0000000000..eb0c909729 --- /dev/null +++ b/db/post_migrate/20240808124339_post_deployment_migrate_notifications_policy_v2.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class PostDeploymentMigrateNotificationsPolicyV2 < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + # Dummy classes, to make migration possible across version changes + class NotificationPolicy < ApplicationRecord; end + + def up + NotificationPolicy.in_batches.update_all(<<~SQL.squish) + for_not_following = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, + for_not_followers = CASE filter_not_following WHEN true THEN 1 ELSE 0 END, + for_new_accounts = CASE filter_new_accounts WHEN true THEN 1 ELSE 0 END, + for_private_mentions = CASE filter_private_mentions WHEN true THEN 1 ELSE 0 END + SQL + end + + def down + NotificationPolicy.in_batches.update_all(<<~SQL.squish) + filter_not_following = CASE for_not_following WHEN 0 THEN false ELSE true END, + filter_not_following = CASE for_not_followers WHEN 0 THEN false ELSE true END, + filter_new_accounts = CASE for_new_accounts WHEN 0 THEN false ELSE true END, + filter_private_mentions = CASE for_private_mentions WHEN 0 THEN false ELSE true END + SQL + end +end diff --git a/db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb b/db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb new file mode 100644 index 0000000000..99ab1e4344 --- /dev/null +++ b/db/post_migrate/20240808125420_drop_old_policies_from_notifications_policy.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class DropOldPoliciesFromNotificationsPolicy < ActiveRecord::Migration[7.1] + def change + safety_assured do + remove_column :notification_policies, :filter_not_following, :boolean, default: false, null: false + remove_column :notification_policies, :filter_not_followers, :boolean, default: false, null: false + remove_column :notification_policies, :filter_new_accounts, :boolean, default: false, null: false + remove_column :notification_policies, :filter_private_mentions, :boolean, default: true, null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index d4796079ca..f01e11792d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_07_24_181224) do +ActiveRecord::Schema[7.1].define(version: 2024_08_08_125420) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -692,12 +692,13 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_24_181224) do create_table "notification_policies", force: :cascade do |t| t.bigint "account_id", null: false - t.boolean "filter_not_following", default: false, null: false - t.boolean "filter_not_followers", default: false, null: false - t.boolean "filter_new_accounts", default: false, null: false - t.boolean "filter_private_mentions", default: true, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.integer "for_not_following", default: 0, null: false + t.integer "for_not_followers", default: 0, null: false + t.integer "for_new_accounts", default: 0, null: false + t.integer "for_private_mentions", default: 1, null: false + t.integer "for_limited_accounts", default: 1, null: false t.index ["account_id"], name: "index_notification_policies_on_account_id", unique: true end diff --git a/lib/tasks/tests.rake b/lib/tasks/tests.rake index c8e4dc31cd..cb7fce3139 100644 --- a/lib/tasks/tests.rake +++ b/lib/tasks/tests.rake @@ -107,8 +107,8 @@ namespace :tests do end policy = NotificationPolicy.find_by(account: User.find(1).account) - unless policy.filter_private_mentions == false && policy.filter_not_following == true - puts 'Notification policy not migrated as expected' + unless policy.for_private_mentions == 'accept' && policy.for_not_following == 'filter' + puts "Notification policy not migrated as expected: #{policy.for_private_mentions.inspect}, #{policy.for_not_following.inspect}" exit(1) end diff --git a/spec/requests/api/v1/notifications/policies_spec.rb b/spec/requests/api/v1/notifications/policies_spec.rb index cbd4499772..a73d4217be 100644 --- a/spec/requests/api/v1/notifications/policies_spec.rb +++ b/spec/requests/api/v1/notifications/policies_spec.rb @@ -51,7 +51,7 @@ RSpec.describe 'Policies' do it 'changes notification policy and returns an updated json object', :aggregate_failures do expect { subject } - .to change { NotificationPolicy.find_or_initialize_by(account: user.account).filter_not_following }.from(false).to(true) + .to change { NotificationPolicy.find_or_initialize_by(account: user.account).for_not_following.to_sym }.from(:accept).to(:filter) expect(response).to have_http_status(200) expect(body_as_json).to include( diff --git a/spec/requests/api/v2/notifications/policies_spec.rb b/spec/requests/api/v2/notifications/policies_spec.rb new file mode 100644 index 0000000000..f9860b5fb4 --- /dev/null +++ b/spec/requests/api/v2/notifications/policies_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Policies' do + let(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + let(:scopes) { 'read:notifications write:notifications' } + let(:headers) { { 'Authorization' => "Bearer #{token.token}" } } + + describe 'GET /api/v2/notifications/policy', :inline_jobs do + subject do + get '/api/v2/notifications/policy', headers: headers, params: params + end + + let(:params) { {} } + + before do + Fabricate(:notification_request, account: user.account) + end + + it_behaves_like 'forbidden for wrong scope', 'write write:notifications' + + context 'with no options' do + it 'returns json with expected attributes', :aggregate_failures do + subject + + expect(response).to have_http_status(200) + expect(body_as_json).to include( + for_not_following: 'accept', + for_not_followers: 'accept', + for_new_accounts: 'accept', + for_private_mentions: 'filter', + for_limited_accounts: 'filter', + summary: a_hash_including( + pending_requests_count: 1, + pending_notifications_count: 0 + ) + ) + end + end + end + + describe 'PUT /api/v2/notifications/policy' do + subject do + put '/api/v2/notifications/policy', headers: headers, params: params + end + + let(:params) { { for_not_following: 'filter', for_limited_accounts: 'drop' } } + + it_behaves_like 'forbidden for wrong scope', 'read read:notifications' + + it 'changes notification policy and returns an updated json object', :aggregate_failures do + expect { subject } + .to change { NotificationPolicy.find_or_initialize_by(account: user.account).for_not_following.to_sym }.from(:accept).to(:filter) + .and change { NotificationPolicy.find_or_initialize_by(account: user.account).for_limited_accounts.to_sym }.from(:filter).to(:drop) + + expect(response).to have_http_status(200) + expect(body_as_json).to include( + for_not_following: 'filter', + for_not_followers: 'accept', + for_new_accounts: 'accept', + for_private_mentions: 'filter', + for_limited_accounts: 'drop', + summary: a_hash_including( + pending_requests_count: 0, + pending_notifications_count: 0 + ) + ) + end + end +end diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index d64cfe5907..935b94c709 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -196,20 +196,58 @@ RSpec.describe NotifyService do end end - describe NotifyService::DismissCondition do + describe NotifyService::DropCondition do subject { described_class.new(notification) } let(:activity) { Fabricate(:mention, status: Fabricate(:status)) } let(:notification) { Fabricate(:notification, type: :mention, activity: activity, from_account: activity.status.account, account: activity.account) } - describe '#dismiss?' do - context 'when sender is silenced' do + describe '#drop' do + context 'when sender is silenced and recipient has a default policy' do before do notification.from_account.silence! end it 'returns false' do - expect(subject.dismiss?).to be false + expect(subject.drop?).to be false + end + end + + context 'when sender is silenced and recipient has a policy to ignore silenced accounts' do + before do + notification.from_account.silence! + notification.account.create_notification_policy!(for_limited_accounts: :drop) + end + + it 'returns true' do + expect(subject.drop?).to be true + end + end + + context 'when sender is new and recipient has a default policy' do + it 'returns false' do + expect(subject.drop?).to be false + end + end + + context 'when sender is new and recipient has a policy to ignore silenced accounts' do + before do + notification.account.create_notification_policy!(for_new_accounts: :drop) + end + + it 'returns true' do + expect(subject.drop?).to be true + end + end + + context 'when sender is new and followed and recipient has a policy to ignore silenced accounts' do + before do + notification.account.create_notification_policy!(for_new_accounts: :drop) + notification.account.follow!(notification.from_account) + end + + it 'returns false' do + expect(subject.drop?).to be false end end @@ -219,7 +257,7 @@ RSpec.describe NotifyService do end it 'returns true' do - expect(subject.dismiss?).to be true + expect(subject.drop?).to be true end end end @@ -250,6 +288,16 @@ RSpec.describe NotifyService do expect(subject.filter?).to be false end end + + context 'when recipient is allowing limited accounts' do + before do + notification.account.create_notification_policy!(for_limited_accounts: :accept) + end + + it 'returns false' do + expect(subject.filter?).to be false + end + end end context 'when recipient is filtering not-followed senders' do From 8a5b57f66874dd936bcaa2532f0fb9d3fabc6e5c Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 15:48:54 +0200 Subject: [PATCH 13/16] Revert "Support JSON-LD named graph (#31288)" (#31355) --- app/lib/link_details_extractor.rb | 4 +-- spec/lib/link_details_extractor_spec.rb | 36 +++++++------------------ 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb index 6929fc1b0f..bd78aef7a9 100644 --- a/app/lib/link_details_extractor.rb +++ b/app/lib/link_details_extractor.rb @@ -101,9 +101,7 @@ class LinkDetailsExtractor end def json - @json ||= root_array(Oj.load(@data)) - .map { |node| JSON::LD::API.compact(node, 'https://schema.org') } - .find { |node| SUPPORTED_TYPES.include?(node['type']) } || {} + @json ||= root_array(Oj.load(@data)).compact.find { |obj| SUPPORTED_TYPES.include?(obj['@type']) } || {} end end diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb index 7ceb6f511d..b1e5cedced 100644 --- a/spec/lib/link_details_extractor_spec.rb +++ b/spec/lib/link_details_extractor_spec.rb @@ -79,16 +79,6 @@ RSpec.describe LinkDetailsExtractor do }, }.to_json end - let(:html) { <<~HTML } - - - - - - - HTML shared_examples 'structured data' do it 'extracts the expected values from structured data' do @@ -234,27 +224,21 @@ RSpec.describe LinkDetailsExtractor do }, }.to_json end + let(:html) { <<~HTML } + + + + + + + HTML it 'joins author names' do expect(subject.author_name).to eq 'Author 1, Author 2' end end - - context 'with named graph' do - let(:ld_json) do - { - '@context' => 'https://schema.org', - '@graph' => [ - '@type' => 'NewsArticle', - 'headline' => "What's in a name", - ], - }.to_json - end - - it 'descends into @graph node' do - expect(subject.title).to eq "What's in a name" - end - end end context 'when Open Graph protocol data is present' do From 170157570447d30732445f6339b0c7b2fe7617d8 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 16:21:55 +0200 Subject: [PATCH 14/16] Add option to ignore filtered notifications to the web interface (#31342) --- .../mastodon/api/notification_policies.ts | 4 +- .../api_types/notification_policies.ts | 11 +- .../mastodon/components/dropdown_selector.tsx | 2 +- .../components/policy_controls.tsx | 131 +++++++++++---- .../components/select_with_label.tsx | 153 ++++++++++++++++++ .../components/ignore_notifications_modal.jsx | 108 +++++++++++++ .../features/ui/components/modal_root.jsx | 2 + .../features/ui/util/async-components.js | 4 + app/javascript/mastodon/locales/en.json | 19 ++- .../400-24px/person_alert-fill.svg | 1 + .../material-icons/400-24px/person_alert.svg | 1 + .../400-24px/shield_question-fill.svg | 1 + .../400-24px/shield_question.svg | 1 + .../styles/mastodon/components.scss | 7 + 14 files changed, 402 insertions(+), 43 deletions(-) create mode 100644 app/javascript/mastodon/features/notifications/components/select_with_label.tsx create mode 100644 app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx create mode 100644 app/javascript/material-icons/400-24px/person_alert-fill.svg create mode 100644 app/javascript/material-icons/400-24px/person_alert.svg create mode 100644 app/javascript/material-icons/400-24px/shield_question-fill.svg create mode 100644 app/javascript/material-icons/400-24px/shield_question.svg diff --git a/app/javascript/mastodon/api/notification_policies.ts b/app/javascript/mastodon/api/notification_policies.ts index 4032134fb5..7747397556 100644 --- a/app/javascript/mastodon/api/notification_policies.ts +++ b/app/javascript/mastodon/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api'; import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequestGet('/v1/notifications/policy'); + apiRequestGet('/v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('/v2/notifications/policy', policy); diff --git a/app/javascript/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/mastodon/api_types/notification_policies.ts +++ b/app/javascript/mastodon/api_types/notification_policies.ts @@ -1,10 +1,13 @@ // See app/serializers/rest/notification_policy_serializer.rb +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + export interface NotificationPolicyJSON { - filter_not_following: boolean; - filter_not_followers: boolean; - filter_new_accounts: boolean; - filter_private_mentions: boolean; + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/mastodon/components/dropdown_selector.tsx b/app/javascript/mastodon/components/dropdown_selector.tsx index f8bf96c634..b86d2d0f80 100644 --- a/app/javascript/mastodon/components/dropdown_selector.tsx +++ b/app/javascript/mastodon/components/dropdown_selector.tsx @@ -13,7 +13,7 @@ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -interface SelectItem { +export interface SelectItem { value: string; icon?: string; iconComponent?: IconProp; diff --git a/app/javascript/mastodon/features/notifications/components/policy_controls.tsx b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx index d6bc412994..032c0ea483 100644 --- a/app/javascript/mastodon/features/notifications/components/policy_controls.tsx +++ b/app/javascript/mastodon/features/notifications/components/policy_controls.tsx @@ -1,16 +1,52 @@ import { useCallback } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; +import { openModal } from 'mastodon/actions/modal'; import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies'; +import type { AppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; -import { CheckboxWithLabel } from './checkbox_with_label'; +import { SelectWithLabel } from './select_with_label'; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; +const messages = defineMessages({ + accept: { id: 'notifications.policy.accept', defaultMessage: 'Accept' }, + accept_hint: { + id: 'notifications.policy.accept_hint', + defaultMessage: 'Show in notifications', + }, + filter: { id: 'notifications.policy.filter', defaultMessage: 'Filter' }, + filter_hint: { + id: 'notifications.policy.filter_hint', + defaultMessage: 'Send to filtered notifications inbox', + }, + drop: { id: 'notifications.policy.drop', defaultMessage: 'Ignore' }, + drop_hint: { + id: 'notifications.policy.drop_hint', + defaultMessage: 'Send to the void, never to be seen again', + }, +}); + +// TODO: change the following when we change the API +const changeFilter = ( + dispatch: AppDispatch, + filterType: string, + value: string, +) => { + if (value === 'drop') { + dispatch( + openModal({ + modalType: 'IGNORE_NOTIFICATIONS', + modalProps: { filterType }, + }), + ); + } else { + void dispatch(updateNotificationsPolicy({ [filterType]: value })); + } +}; export const PolicyControls: React.FC = () => { + const intl = useIntl(); const dispatch = useAppDispatch(); const notificationPolicy = useAppSelector( @@ -18,56 +54,74 @@ export const PolicyControls: React.FC = () => { ); const handleFilterNotFollowing = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_not_following: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_not_following', value); }, [dispatch], ); const handleFilterNotFollowers = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_not_followers: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_not_followers', value); }, [dispatch], ); const handleFilterNewAccounts = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_new_accounts: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_new_accounts', value); }, [dispatch], ); const handleFilterPrivateMentions = useCallback( - (checked: boolean) => { - void dispatch( - updateNotificationsPolicy({ filter_private_mentions: checked }), - ); + (value: string) => { + changeFilter(dispatch, 'for_private_mentions', value); + }, + [dispatch], + ); + + const handleFilterLimitedAccounts = useCallback( + (value: string) => { + changeFilter(dispatch, 'for_limited_accounts', value); }, [dispatch], ); if (!notificationPolicy) return null; + const options = [ + { + value: 'accept', + text: intl.formatMessage(messages.accept), + meta: intl.formatMessage(messages.accept_hint), + }, + { + value: 'filter', + text: intl.formatMessage(messages.filter), + meta: intl.formatMessage(messages.filter_hint), + }, + { + value: 'drop', + text: intl.formatMessage(messages.drop), + meta: intl.formatMessage(messages.drop_hint), + }, + ]; + return (

- { defaultMessage='Until you manually approve them' /> - + - { values={{ days: 3 }} /> - + - { values={{ days: 30 }} /> - + - { defaultMessage="Filtered unless it's in reply to your own mention or if you follow the sender" /> - + - + { defaultMessage='Limited by server moderators' /> - +
); diff --git a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx new file mode 100644 index 0000000000..413267c0f8 --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx @@ -0,0 +1,153 @@ +import type { PropsWithChildren } from 'react'; +import { useCallback, useState, useRef } from 'react'; + +import classNames from 'classnames'; + +import type { Placement, State as PopperState } from '@popperjs/core'; +import Overlay from 'react-overlays/Overlay'; + +import ArrowDropDownIcon from '@/material-icons/400-24px/arrow_drop_down.svg?react'; +import type { SelectItem } from 'mastodon/components/dropdown_selector'; +import { DropdownSelector } from 'mastodon/components/dropdown_selector'; +import { Icon } from 'mastodon/components/icon'; + +interface DropdownProps { + value: string; + options: SelectItem[]; + disabled?: boolean; + onChange: (value: string) => void; + placement?: Placement; +} + +const Dropdown: React.FC = ({ + value, + options, + disabled, + onChange, + placement: initialPlacement = 'bottom-end', +}) => { + const activeElementRef = useRef(null); + const containerRef = useRef(null); + const [isOpen, setOpen] = useState(false); + const [placement, setPlacement] = useState(initialPlacement); + + const handleToggle = useCallback(() => { + if ( + isOpen && + activeElementRef.current && + activeElementRef.current instanceof HTMLElement + ) { + activeElementRef.current.focus({ preventScroll: true }); + } + + setOpen(!isOpen); + }, [isOpen, setOpen]); + + const handleMouseDown = useCallback(() => { + if (!isOpen) activeElementRef.current = document.activeElement; + }, [isOpen]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case ' ': + case 'Enter': + if (!isOpen) activeElementRef.current = document.activeElement; + break; + } + }, + [isOpen], + ); + + const handleClose = useCallback(() => { + if ( + isOpen && + activeElementRef.current && + activeElementRef.current instanceof HTMLElement + ) + activeElementRef.current.focus({ preventScroll: true }); + setOpen(false); + }, [isOpen]); + + const handleOverlayEnter = useCallback( + (state: Partial) => { + if (state.placement) setPlacement(state.placement); + }, + [setPlacement], + ); + + const valueOption = options.find((item) => item.value === value); + + return ( +
+ + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+
+ ); +}; + +interface Props { + value: string; + options: SelectItem[]; + disabled?: boolean; + onChange: (value: string) => void; +} + +export const SelectWithLabel: React.FC> = ({ + value, + options, + disabled, + children, + onChange, +}) => { + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx new file mode 100644 index 0000000000..b163b8ce47 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/ignore_notifications_modal.jsx @@ -0,0 +1,108 @@ +import PropTypes from 'prop-types'; +import { useCallback } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useDispatch } from 'react-redux'; + +import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; +import PersonAlertIcon from '@/material-icons/400-24px/person_alert.svg?react'; +import ShieldQuestionIcon from '@/material-icons/400-24px/shield_question.svg?react'; +import { closeModal } from 'mastodon/actions/modal'; +import { updateNotificationsPolicy } from 'mastodon/actions/notification_policies'; +import { Button } from 'mastodon/components/button'; +import { Icon } from 'mastodon/components/icon'; + +export const IgnoreNotificationsModal = ({ filterType }) => { + const dispatch = useDispatch(); + + const handleClick = useCallback(() => { + dispatch(closeModal({ modalType: undefined, ignoreFocus: false })); + void dispatch(updateNotificationsPolicy({ [filterType]: 'drop' })); + }, [dispatch, filterType]); + + const handleSecondaryClick = useCallback(() => { + dispatch(closeModal({ modalType: undefined, ignoreFocus: false })); + void dispatch(updateNotificationsPolicy({ [filterType]: 'filter' })); + }, [dispatch, filterType]); + + const handleCancel = useCallback(() => { + dispatch(closeModal({ modalType: undefined, ignoreFocus: false })); + }, [dispatch]); + + let title = null; + + switch(filterType) { + case 'for_not_following': + title = ; + break; + case 'for_not_followers': + title = ; + break; + case 'for_new_accounts': + title = ; + break; + case 'for_private_mentions': + title = ; + break; + case 'for_limited_accounts': + title = ; + break; + } + + return ( +
+
+
+

{title}

+
+ +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ + + + +
+
+
+ ); +}; + +IgnoreNotificationsModal.propTypes = { + filterType: PropTypes.string.isRequired, +}; + +export default IgnoreNotificationsModal; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 3e900a0667..64933fd1ae 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -17,6 +17,7 @@ import { InteractionModal, SubscribedLanguagesModal, ClosedRegistrationsModal, + IgnoreNotificationsModal, } from 'mastodon/features/ui/util/async-components'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; @@ -70,6 +71,7 @@ export const MODAL_COMPONENTS = { 'SUBSCRIBED_LANGUAGES': SubscribedLanguagesModal, 'INTERACTION': InteractionModal, 'CLOSED_REGISTRATIONS': ClosedRegistrationsModal, + 'IGNORE_NOTIFICATIONS': IgnoreNotificationsModal, }; export default class ModalRoot extends PureComponent { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 7c4372d5a6..7e9a7af00a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -134,6 +134,10 @@ export function ReportModal () { return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); } +export function IgnoreNotificationsModal () { + return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/ignore_notifications_modal'); +} + export function MediaGallery () { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 86afa7cd0d..8df1eef8ca 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -356,6 +356,17 @@ "home.pending_critical_update.link": "See updates", "home.pending_critical_update.title": "Critical security update available!", "home.show_announcements": "Show announcements", + "ignore_notifications_modal.disclaimer": "Mastodon cannot inform users that you've ignored their notifications. Ignoring notifications will not stop the messages themselves from being sent.", + "ignore_notifications_modal.filter_instead": "Filter instead", + "ignore_notifications_modal.filter_to_act_users": "Filtering helps avoid potential confusion", + "ignore_notifications_modal.filter_to_avoid_confusion": "Filtering helps avoid potential confusion", + "ignore_notifications_modal.filter_to_review_separately": "You can review filtered notifications speparately", + "ignore_notifications_modal.ignore": "Ignore notifications", + "ignore_notifications_modal.limited_accounts_title": "Ignore notifications from moderated accounts?", + "ignore_notifications_modal.new_accounts_title": "Ignore notifications from new accounts?", + "ignore_notifications_modal.not_followers_title": "Ignore notifications from people not following you?", + "ignore_notifications_modal.not_following_title": "Ignore notifications from people you don't follow?", + "ignore_notifications_modal.private_mentions_title": "Ignore notifications from unsolicited Private Mentions?", "interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.", "interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.", "interaction_modal.description.reblog": "With an account on Mastodon, you can boost this post to share it with your own followers.", @@ -550,6 +561,12 @@ "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request", "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before", "notifications.permission_required": "Desktop notifications are unavailable because the required permission has not been granted.", + "notifications.policy.accept": "Accept", + "notifications.policy.accept_hint": "Show in notifications", + "notifications.policy.drop": "Ignore", + "notifications.policy.drop_hint": "Send to the void, never to be seen again", + "notifications.policy.filter": "Filter", + "notifications.policy.filter_hint": "Send to filtered notifications inbox", "notifications.policy.filter_limited_accounts_hint": "Limited by server moderators", "notifications.policy.filter_limited_accounts_title": "Moderated accounts", "notifications.policy.filter_new_accounts.hint": "Created within the past {days, plural, one {one day} other {# days}}", @@ -560,7 +577,7 @@ "notifications.policy.filter_not_following_title": "People you don't follow", "notifications.policy.filter_private_mentions_hint": "Filtered unless it's in reply to your own mention or if you follow the sender", "notifications.policy.filter_private_mentions_title": "Unsolicited private mentions", - "notifications.policy.title": "Filter out notifications from…", + "notifications.policy.title": "Manage notifications from…", "notifications_permission_banner.enable": "Enable desktop notifications", "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", "notifications_permission_banner.title": "Never miss a thing", diff --git a/app/javascript/material-icons/400-24px/person_alert-fill.svg b/app/javascript/material-icons/400-24px/person_alert-fill.svg new file mode 100644 index 0000000000..ddbecc6053 --- /dev/null +++ b/app/javascript/material-icons/400-24px/person_alert-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/person_alert.svg b/app/javascript/material-icons/400-24px/person_alert.svg new file mode 100644 index 0000000000..292ea32154 --- /dev/null +++ b/app/javascript/material-icons/400-24px/person_alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/shield_question-fill.svg b/app/javascript/material-icons/400-24px/shield_question-fill.svg new file mode 100644 index 0000000000..c647567a00 --- /dev/null +++ b/app/javascript/material-icons/400-24px/shield_question-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/shield_question.svg b/app/javascript/material-icons/400-24px/shield_question.svg new file mode 100644 index 0000000000..342ac0800e --- /dev/null +++ b/app/javascript/material-icons/400-24px/shield_question.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 3c938ac4c5..0fec52f375 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -877,6 +877,13 @@ body > [data-popper-placement] { text-overflow: ellipsis; white-space: nowrap; + &[disabled] { + cursor: default; + color: $highlight-text-color; + border-color: $highlight-text-color; + opacity: 0.5; + } + .icon { width: 15px; height: 15px; From eaedd52def6bec787c48386f5e102f57de94403e Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 16:48:05 +0200 Subject: [PATCH 15/16] Fix incorrect rate limit on PUT requests (#31356) --- config/initializers/rack_attack.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 14fab7ecda..b4eaab1daa 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -142,7 +142,7 @@ class Rack::Attack end throttle('throttle_password_change/account', limit: 10, period: 10.minutes) do |req| - req.warden_user_id if req.put? || (req.patch? && req.path_matches?('/auth')) + req.warden_user_id if (req.put? || req.patch?) && (req.path_matches?('/auth') || req.path_matches?('/auth/password')) end self.throttled_responder = lambda do |request| From 658addcbf783f6baa922d11c9524ebb9ddbcbc59 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 9 Aug 2024 16:56:39 +0200 Subject: [PATCH 16/16] Add ability to report, block and mute from notification requests list (#31309) Co-authored-by: Renaud Chaput --- .../mastodon/actions/notifications.js | 64 ++++++++ .../mastodon/components/check_box.tsx | 13 +- .../components/notification_request.jsx | 75 ++++++++- .../features/notifications/requests.jsx | 144 +++++++++++++++++- app/javascript/mastodon/locales/en.json | 13 ++ .../reducers/notification_requests.js | 5 + .../check_indeterminate_small-fill.svg | 1 + .../400-24px/check_indeterminate_small.svg | 1 + .../styles/mastodon/components.scss | 111 +++++++++++--- 9 files changed, 395 insertions(+), 32 deletions(-) create mode 100644 app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg create mode 100644 app/javascript/material-icons/400-24px/check_indeterminate_small.svg diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 48afb003ad..7d6a9d5a32 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -64,6 +64,14 @@ export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMIS export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; +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_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; +export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; + export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; @@ -496,6 +504,62 @@ export const dismissNotificationRequestFail = (id, error) => ({ error, }); +export const acceptNotificationRequests = (ids) => (dispatch, getState) => { + const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0); + dispatch(acceptNotificationRequestsRequest(ids)); + + api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => { + dispatch(acceptNotificationRequestsSuccess(ids)); + dispatch(decreasePendingNotificationsCount(count)); + }).catch(err => { + dispatch(acceptNotificationRequestFail(ids, err)); + }); +}; + +export const acceptNotificationRequestsRequest = ids => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST, + ids, +}); + +export const acceptNotificationRequestsSuccess = ids => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS, + ids, +}); + +export const acceptNotificationRequestsFail = (ids, error) => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_FAIL, + ids, + error, +}); + +export const dismissNotificationRequests = (ids) => (dispatch, getState) => { + const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0); + dispatch(acceptNotificationRequestsRequest(ids)); + + api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => { + dispatch(dismissNotificationRequestsSuccess(ids)); + dispatch(decreasePendingNotificationsCount(count)); + }).catch(err => { + dispatch(dismissNotificationRequestFail(ids, err)); + }); +}; + +export const dismissNotificationRequestsRequest = ids => ({ + type: NOTIFICATION_REQUESTS_DISMISS_REQUEST, + ids, +}); + +export const dismissNotificationRequestsSuccess = ids => ({ + type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS, + ids, +}); + +export const dismissNotificationRequestsFail = (ids, error) => ({ + type: NOTIFICATION_REQUESTS_DISMISS_FAIL, + ids, + error, +}); + export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { const current = getState().getIn(['notificationRequests', 'current']); const params = { account_id: accountId }; diff --git a/app/javascript/mastodon/components/check_box.tsx b/app/javascript/mastodon/components/check_box.tsx index 7da8ef0ac5..9bd137abf5 100644 --- a/app/javascript/mastodon/components/check_box.tsx +++ b/app/javascript/mastodon/components/check_box.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; +import CheckIndeterminateSmallIcon from '@/material-icons/400-24px/check_indeterminate_small.svg?react'; import DoneIcon from '@/material-icons/400-24px/done.svg?react'; import { Icon } from './icon'; @@ -7,6 +8,7 @@ import { Icon } from './icon'; interface Props { value: string; checked: boolean; + indeterminate: boolean; name: string; onChange: (event: React.ChangeEvent) => void; label: React.ReactNode; @@ -16,6 +18,7 @@ export const CheckBox: React.FC = ({ name, value, checked, + indeterminate, onChange, label, }) => { @@ -29,8 +32,14 @@ export const CheckBox: React.FC = ({ onChange={onChange} /> - - {checked && } + + {indeterminate ? ( + + ) : ( + checked && + )} {label} diff --git a/app/javascript/mastodon/features/notifications/components/notification_request.jsx b/app/javascript/mastodon/features/notifications/components/notification_request.jsx index fc96bd2ee7..2f378942bc 100644 --- a/app/javascript/mastodon/features/notifications/components/notification_request.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification_request.jsx @@ -3,15 +3,21 @@ import { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { Link, useHistory } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; import DeleteIcon from '@/material-icons/400-24px/delete.svg?react'; -import DoneIcon from '@/material-icons/400-24px/done.svg?react'; +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import { initBlockModal } from 'mastodon/actions/blocks'; +import { initMuteModal } from 'mastodon/actions/mutes'; import { acceptNotificationRequest, dismissNotificationRequest } from 'mastodon/actions/notifications'; +import { initReport } from 'mastodon/actions/reports'; import { Avatar } from 'mastodon/components/avatar'; +import { CheckBox } from 'mastodon/components/check_box'; import { IconButton } from 'mastodon/components/icon_button'; +import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { makeGetAccount } from 'mastodon/selectors'; import { toCappedNumber } from 'mastodon/utils/numbers'; @@ -20,12 +26,18 @@ const getAccount = makeGetAccount(); const messages = defineMessages({ accept: { id: 'notification_requests.accept', defaultMessage: 'Accept' }, dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' }, + view: { id: 'notification_requests.view', defaultMessage: 'View notifications' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + more: { id: 'status.more', defaultMessage: 'More' }, }); -export const NotificationRequest = ({ id, accountId, notificationsCount }) => { +export const NotificationRequest = ({ id, accountId, notificationsCount, checked, showCheckbox, toggleCheck }) => { const dispatch = useDispatch(); const account = useSelector(state => getAccount(state, accountId)); const intl = useIntl(); + const { push: historyPush } = useHistory(); const handleDismiss = useCallback(() => { dispatch(dismissNotificationRequest(id)); @@ -35,9 +47,51 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => { dispatch(acceptNotificationRequest(id)); }, [dispatch, id]); + const handleMute = useCallback(() => { + dispatch(initMuteModal(account)); + }, [dispatch, account]); + + const handleBlock = useCallback(() => { + dispatch(initBlockModal(account)); + }, [dispatch, account]); + + const handleReport = useCallback(() => { + dispatch(initReport(account)); + }, [dispatch, account]); + + const handleView = useCallback(() => { + historyPush(`/notifications/requests/${id}`); + }, [historyPush, id]); + + const menu = [ + { text: intl.formatMessage(messages.view), action: handleView }, + null, + { text: intl.formatMessage(messages.accept), action: handleAccept }, + null, + { text: intl.formatMessage(messages.mute, { name: account.username }), action: handleMute, dangerous: true }, + { text: intl.formatMessage(messages.block, { name: account.username }), action: handleBlock, dangerous: true }, + { text: intl.formatMessage(messages.report, { name: account.username }), action: handleReport, dangerous: true }, + ]; + + const handleCheck = useCallback(() => { + toggleCheck(id); + }, [toggleCheck, id]); + + const handleClick = useCallback((e) => { + if (showCheckbox) { + toggleCheck(id); + e.preventDefault(); + e.stopPropagation(); + } + }, [toggleCheck, id, showCheckbox]); + return ( -
- + /* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- this is just a minor affordance, but we will need a comprehensive accessibility pass */ +
+
+ +
+
@@ -51,7 +105,13 @@ export const NotificationRequest = ({ id, accountId, notificationsCount }) => {
- +
); @@ -61,4 +121,7 @@ NotificationRequest.propTypes = { id: PropTypes.string.isRequired, accountId: PropTypes.string.isRequired, notificationsCount: PropTypes.string.isRequired, + checked: PropTypes.bool, + showCheckbox: PropTypes.bool, + toggleCheck: PropTypes.func, }; diff --git a/app/javascript/mastodon/features/notifications/requests.jsx b/app/javascript/mastodon/features/notifications/requests.jsx index 2fe8dc2b6c..d5f191b734 100644 --- a/app/javascript/mastodon/features/notifications/requests.jsx +++ b/app/javascript/mastodon/features/notifications/requests.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { useRef, useCallback, useEffect } from 'react'; +import { useRef, useCallback, useEffect, useState } from 'react'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; @@ -8,11 +8,15 @@ import { Helmet } from 'react-helmet'; import { useSelector, useDispatch } from 'react-redux'; import InventoryIcon from '@/material-icons/400-24px/inventory_2.svg?react'; -import { fetchNotificationRequests, expandNotificationRequests } from 'mastodon/actions/notifications'; +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { fetchNotificationRequests, expandNotificationRequests, acceptNotificationRequests, dismissNotificationRequests } from 'mastodon/actions/notifications'; 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 ScrollableList from 'mastodon/components/scrollable_list'; +import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import { NotificationRequest } from './components/notification_request'; import { PolicyControls } from './components/policy_controls'; @@ -20,7 +24,18 @@ import SettingToggle from './components/setting_toggle'; const messages = defineMessages({ title: { id: 'notification_requests.title', defaultMessage: 'Filtered notifications' }, - maximize: { id: 'notification_requests.maximize', defaultMessage: 'Maximize' } + 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' }, }); const ColumnSettings = () => { @@ -55,6 +70,94 @@ const ColumnSettings = () => { ); }; +const SelectRow = ({selectAllChecked, toggleSelectAll, selectedItems, selectionMode, setSelectionMode}) => { + const intl = useIntl(); + const dispatch = useDispatch(); + + const selectedCount = selectedItems.length; + + const handleAcceptAll = useCallback(() => { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + title: intl.formatMessage(messages.confirmAcceptAllTitle), + message: intl.formatMessage(messages.confirmAcceptAllMessage, { count: selectedItems.length }), + confirm: intl.formatMessage(messages.confirmAcceptAllButton), + onConfirm: () => + dispatch(acceptNotificationRequests(selectedItems)), + }, + })); + }, [dispatch, intl, selectedItems]); + + const handleDismissAll = useCallback(() => { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + title: intl.formatMessage(messages.confirmDismissAllTitle), + message: intl.formatMessage(messages.confirmDismissAllMessage, { count: selectedItems.length }), + confirm: intl.formatMessage(messages.confirmDismissAllButton), + onConfirm: () => + dispatch(dismissNotificationRequests(selectedItems)), + }, + })); + }, [dispatch, intl, selectedItems]); + + const handleToggleSelectionMode = useCallback(() => { + 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: handleAcceptAll }, + { text: intl.formatMessage(messages.dismissMultiple, { count: selectedCount }), action: handleDismissAll }, + ]; + + return ( +
+ {selectionMode && ( +
+ 0 && !selectAllChecked} onChange={toggleSelectAll} /> +
+ )} +
+ +
+ {selectedCount > 0 && +
+ {selectedCount} selected +
+ } +
+ +
+
+ ); +}; + +SelectRow.propTypes = { + selectAllChecked: PropTypes.func.isRequired, + toggleSelectAll: PropTypes.func.isRequired, + selectedItems: PropTypes.arrayOf(PropTypes.string).isRequired, + selectionMode: PropTypes.bool, + setSelectionMode: PropTypes.func.isRequired, +}; + export const NotificationRequests = ({ multiColumn }) => { const columnRef = useRef(); const intl = useIntl(); @@ -63,10 +166,40 @@ export const NotificationRequests = ({ multiColumn }) => { const notificationRequests = useSelector(state => state.getIn(['notificationRequests', 'items'])); const hasMore = useSelector(state => !!state.getIn(['notificationRequests', 'next'])); + const [selectionMode, setSelectionMode] = useState(false); + const [checkedRequestIds, setCheckedRequestIds] = useState([]); + const [selectAllChecked, setSelectAllChecked] = useState(false); + const handleHeaderClick = useCallback(() => { columnRef.current?.scrollTop(); }, [columnRef]); + const handleCheck = useCallback(id => { + setCheckedRequestIds(ids => { + const position = ids.indexOf(id); + + if(position > -1) + ids.splice(position, 1); + else + ids.push(id); + + setSelectAllChecked(ids.length === notificationRequests.size); + + return [...ids]; + }); + }, [setCheckedRequestIds, notificationRequests]); + + const toggleSelectAll = useCallback(() => { + setSelectAllChecked(checked => { + if(checked) + setCheckedRequestIds([]); + else + setCheckedRequestIds(notificationRequests.map(request => request.get('id')).toArray()); + + return !checked; + }); + }, [notificationRequests]); + const handleLoadMore = useCallback(() => { dispatch(expandNotificationRequests()); }, [dispatch]); @@ -84,6 +217,8 @@ export const NotificationRequests = ({ multiColumn }) => { onClick={handleHeaderClick} multiColumn={multiColumn} showBackButton + appendContent={ + } > @@ -104,6 +239,9 @@ export const NotificationRequests = ({ multiColumn }) => { id={request.get('id')} accountId={request.get('account')} notificationsCount={request.get('notifications_count')} + showCheckbox={selectionMode} + checked={checkedRequestIds.includes(request.get('id'))} + toggleCheck={handleCheck} /> ))} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 8df1eef8ca..388fb80de5 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -518,13 +518,26 @@ "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.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.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", "notification_requests.minimize_banner": "Minimize filtered notifications banner", "notification_requests.notifications_from": "Notifications from {name}", "notification_requests.title": "Filtered notifications", + "notification_requests.view": "View notifications", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.clear_title": "Clear notifications?", diff --git a/app/javascript/mastodon/reducers/notification_requests.js b/app/javascript/mastodon/reducers/notification_requests.js index 1aaf167fa6..f73c641965 100644 --- a/app/javascript/mastodon/reducers/notification_requests.js +++ b/app/javascript/mastodon/reducers/notification_requests.js @@ -13,6 +13,8 @@ import { NOTIFICATION_REQUEST_FETCH_FAIL, NOTIFICATION_REQUEST_ACCEPT_REQUEST, NOTIFICATION_REQUEST_DISMISS_REQUEST, + NOTIFICATION_REQUESTS_ACCEPT_REQUEST, + NOTIFICATION_REQUESTS_DISMISS_REQUEST, NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST, NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS, NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL, @@ -83,6 +85,9 @@ export const notificationRequestsReducer = (state = initialState, action) => { case NOTIFICATION_REQUEST_ACCEPT_REQUEST: case NOTIFICATION_REQUEST_DISMISS_REQUEST: return removeRequest(state, action.id); + case NOTIFICATION_REQUESTS_ACCEPT_REQUEST: + case NOTIFICATION_REQUESTS_DISMISS_REQUEST: + return action.ids.reduce((state, id) => removeRequest(state, id), state); case blockAccountSuccess.type: return removeRequestByAccount(state, action.payload.relationship.id); case muteAccountSuccess.type: diff --git a/app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg b/app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg new file mode 100644 index 0000000000..d78d33e656 --- /dev/null +++ b/app/javascript/material-icons/400-24px/check_indeterminate_small-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/material-icons/400-24px/check_indeterminate_small.svg b/app/javascript/material-icons/400-24px/check_indeterminate_small.svg new file mode 100644 index 0000000000..d78d33e656 --- /dev/null +++ b/app/javascript/material-icons/400-24px/check_indeterminate_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0fec52f375..fb615300b1 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -4320,6 +4320,36 @@ a.status-card { } } +.column-header__select-row { + border-width: 0 1px 1px; + border-style: solid; + border-color: var(--background-border-color); + padding: 15px; + display: flex; + align-items: center; + gap: 8px; + + &__checkbox .check-box { + display: flex; + } + + &__selection-mode { + flex-grow: 1; + + .text-btn:hover { + text-decoration: underline; + } + } + + &__actions { + .icon-button { + border-radius: 4px; + border: 1px solid var(--background-border-color); + padding: 5px; + } + } +} + .column-header { display: flex; font-size: 16px; @@ -7467,20 +7497,9 @@ a.status-card { flex: 0 0 auto; border-radius: 50%; - &.checked { + &.checked, + &.indeterminate { border-color: $ui-highlight-color; - - &::before { - position: absolute; - left: 2px; - top: 2px; - content: ''; - display: block; - border-radius: 50%; - width: 12px; - height: 12px; - background: $ui-highlight-color; - } } .icon { @@ -7490,19 +7509,28 @@ a.status-card { } } +.radio-button.checked::before { + position: absolute; + left: 2px; + top: 2px; + content: ''; + display: block; + border-radius: 50%; + width: 12px; + height: 12px; + background: $ui-highlight-color; +} + .check-box { &__input { width: 18px; height: 18px; border-radius: 2px; - &.checked { + &.checked, + &.indeterminate { background: $ui-highlight-color; color: $white; - - &::before { - display: none; - } } } } @@ -10223,12 +10251,28 @@ noscript { } .notification-request { + $padding: 15px; + display: flex; - align-items: center; - gap: 16px; - padding: 15px; + padding: $padding; + gap: 8px; + position: relative; border-bottom: 1px solid var(--background-border-color); + &__checkbox { + position: absolute; + inset-inline-start: $padding; + top: 50%; + transform: translateY(-50%); + width: 0; + overflow: hidden; + opacity: 0; + + .check-box { + display: flex; + } + } + &__link { display: flex; align-items: center; @@ -10286,6 +10330,31 @@ noscript { padding: 5px; } } + + .notification-request__link { + transition: padding-inline-start 0.1s ease-in-out; + } + + &--forced-checkbox { + cursor: pointer; + + &:hover { + background: lighten($ui-base-color, 1%); + } + + .notification-request__checkbox { + opacity: 1; + width: 30px; + } + + .notification-request__link { + padding-inline-start: 30px; + } + + .notification-request__actions { + display: none; + } + } } .more-from-author {