From d820c0883d5557f011b4de8fafa1ff9f68b0d5de Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Mon, 26 Aug 2024 18:42:46 +0200 Subject: [PATCH 01/18] Add quick links to Administration and Moderation Reports from Web UI (#24838) --- .../features/getting_started/index.jsx | 14 +++++++++++++- .../ui/components/navigation_panel.jsx | 11 +++++++++-- app/javascript/mastodon/locales/en.json | 2 ++ app/javascript/mastodon/permissions.ts | 19 +++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index 628bbe62bb..8d26115dfa 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -12,9 +12,11 @@ import { connect } from 'react-redux'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react'; import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; import PersonAddIcon from '@/material-icons/400-24px/person_add.svg?react'; import PublicIcon from '@/material-icons/400-24px/public.svg?react'; @@ -25,6 +27,7 @@ import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; +import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; import { me, showTrends } from '../../initial_state'; import { NavigationBar } from '../compose/components/navigation_bar'; @@ -43,6 +46,8 @@ const messages = defineMessages({ direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' }, + moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, @@ -99,7 +104,7 @@ class GettingStarted extends ImmutablePureComponent { render () { const { intl, myAccount, multiColumn, unreadFollowRequests } = this.props; - const { signedIn } = this.props.identity; + const { signedIn, permissions } = this.props.identity; const navItems = []; @@ -136,6 +141,13 @@ class GettingStarted extends ImmutablePureComponent { , , ); + + if (canManageReports(permissions)) { + navItems.push(); + } + if (canViewAdminDashboard(permissions)) { + navItems.push(); + } } return ( diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index 2648923bfc..d30ea0ac5d 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -7,16 +7,17 @@ import { Link } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; - import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react'; import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeIcon from '@/material-icons/400-24px/home.svg?react'; import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; @@ -34,6 +35,7 @@ import { NavigationPortal } from 'mastodon/components/navigation_portal'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { timelinePreview, trendsEnabled } from 'mastodon/initial_state'; import { transientSingleColumn } from 'mastodon/is_mobile'; +import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; import ColumnLink from './column_link'; @@ -51,6 +53,8 @@ const messages = defineMessages({ bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' }, + moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' }, followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, @@ -114,7 +118,7 @@ class NavigationPanel extends Component { render () { const { intl } = this.props; - const { signedIn, disabledAccountId } = this.props.identity; + const { signedIn, disabledAccountId, permissions } = this.props.identity; let banner = undefined; @@ -176,6 +180,9 @@ class NavigationPanel extends Component {
+ + {canManageReports(permissions) && } + {canViewAdminDashboard(permissions) && } )} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 88920431fb..343af40424 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -467,6 +467,7 @@ "mute_modal.you_wont_see_mentions": "You won't see posts that mention them.", "mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.", "navigation_bar.about": "About", + "navigation_bar.administration": "Administration", "navigation_bar.advanced_interface": "Open in advanced web interface", "navigation_bar.blocks": "Blocked users", "navigation_bar.bookmarks": "Bookmarks", @@ -483,6 +484,7 @@ "navigation_bar.follows_and_followers": "Follows and followers", "navigation_bar.lists": "Lists", "navigation_bar.logout": "Logout", + "navigation_bar.moderation": "Moderation", "navigation_bar.mutes": "Muted users", "navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.", "navigation_bar.personal": "Personal", diff --git a/app/javascript/mastodon/permissions.ts b/app/javascript/mastodon/permissions.ts index b583535c00..8f015610ea 100644 --- a/app/javascript/mastodon/permissions.ts +++ b/app/javascript/mastodon/permissions.ts @@ -1,4 +1,23 @@ export const PERMISSION_INVITE_USERS = 0x0000000000010000; export const PERMISSION_MANAGE_USERS = 0x0000000000000400; export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020; + export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010; +export const PERMISSION_VIEW_DASHBOARD = 0x0000000000000008; + +// These helpers don't quite align with the names/categories in UserRole, +// but are likely "good enough" for the use cases at present. +// +// See: https://docs.joinmastodon.org/entities/Role/#permission-flags + +export function canViewAdminDashboard(permissions: number) { + return ( + (permissions & PERMISSION_VIEW_DASHBOARD) === PERMISSION_VIEW_DASHBOARD + ); +} + +export function canManageReports(permissions: number) { + return ( + (permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS + ); +} From 29b9642b315a30ca5d3dd9375fa85ab8fe74ad52 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 26 Aug 2024 19:12:17 +0200 Subject: [PATCH 02/18] Change design of boost modal in web UI (#31555) --- .../features/ui/components/boost_modal.tsx | 170 ++++++++---------- app/javascript/mastodon/locales/en.json | 2 + .../styles/mastodon/components.scss | 61 +++++++ 3 files changed, 135 insertions(+), 98 deletions(-) diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.tsx b/app/javascript/mastodon/features/ui/components/boost_modal.tsx index 40b0c81833..cdf7138d49 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/boost_modal.tsx @@ -1,28 +1,17 @@ -import type { MouseEventHandler } from 'react'; import { useCallback, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { useHistory } from 'react-router'; - -import type Immutable from 'immutable'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import AttachmentList from 'mastodon/components/attachment_list'; +import { Button } from 'mastodon/components/button'; import { Icon } from 'mastodon/components/icon'; -import { VisibilityIcon } from 'mastodon/components/visibility_icon'; import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown'; -import type { Account } from 'mastodon/models/account'; +import { EmbeddedStatus } from 'mastodon/features/notifications_v2/components/embedded_status'; import type { Status, StatusVisibility } from 'mastodon/models/status'; import { useAppSelector } from 'mastodon/store'; -import { Avatar } from '../../../components/avatar'; -import { Button } from '../../../components/button'; -import { DisplayName } from '../../../components/display_name'; -import { RelativeTimestamp } from '../../../components/relative_timestamp'; -import StatusContent from '../../../components/status_content'; - const messages = defineMessages({ cancel_reblog: { id: 'status.cancel_reblog_private', @@ -37,18 +26,17 @@ export const BoostModal: React.FC<{ onReblog: (status: Status, privacy: StatusVisibility) => void; }> = ({ status, onReblog, onClose }) => { const intl = useIntl(); - const history = useHistory(); - const default_privacy = useAppSelector( + const defaultPrivacy = useAppSelector( // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access (state) => state.compose.get('default_privacy') as StatusVisibility, ); - const account = status.get('account') as Account; + const statusId = status.get('id') as string; const statusVisibility = status.get('visibility') as StatusVisibility; const [privacy, setPrivacy] = useState( - statusVisibility === 'private' ? 'private' : default_privacy, + statusVisibility === 'private' ? 'private' : defaultPrivacy, ); const onPrivacyChange = useCallback((value: StatusVisibility) => { @@ -60,20 +48,9 @@ export const BoostModal: React.FC<{ onClose(); }, [onClose, onReblog, status, privacy]); - const handleAccountClick = useCallback( - (e) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - onClose(); - history.push(`/@${account.acct}`); - } - }, - [history, onClose, account], - ); - - const buttonText = status.get('reblogged') - ? messages.cancel_reblog - : messages.reblog; + const handleCancel = useCallback(() => { + onClose(); + }, [onClose]); const findContainer = useCallback( () => document.getElementsByClassName('modal-root__container')[0], @@ -81,81 +58,78 @@ export const BoostModal: React.FC<{ ); return ( -
-
-
-
- - - - - - - - -
- -
- - -
+
+
+
+
+
- {/* @ts-expect-error Expected until StatusContent is typed */} - +
+

+ {status.get('reblogged') ? ( + + ) : ( + + )} +

+
+ + Shift+ + + ), + }} + /> +
+
+
- {(status.get('media_attachments') as Immutable.List).size > - 0 && ( - - )} +
+
-
-
- - Shift + - - ), - }} +
+
+ {!status.get('reblogged') && ( + + )} + +
+ + + +
- {statusVisibility !== 'private' && !status.get('reblogged') && ( - - )} -
); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 343af40424..4321acef4b 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -97,6 +97,8 @@ "block_modal.title": "Block user?", "block_modal.you_wont_see_mentions": "You won't see posts that mention them.", "boost_modal.combo": "You can press {combo} to skip this next time", + "boost_modal.reblog": "Boost post?", + "boost_modal.undo_reblog": "Unboost post?", "bundle_column_error.copy_stacktrace": "Copy error report", "bundle_column_error.error.body": "The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.", "bundle_column_error.error.title": "Oh, no!", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 6157a83af4..0b0cbbfa84 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -6142,6 +6142,48 @@ a.status-card { } } + &__status { + border: 1px solid var(--modal-border-color); + border-radius: 8px; + padding: 8px; + cursor: pointer; + + &__account { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + color: $dark-text-color; + + bdi { + color: inherit; + } + } + + &__content { + display: -webkit-box; + font-size: 15px; + line-height: 22px; + color: $dark-text-color; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + max-height: 4 * 22px; + overflow: hidden; + + p, + a { + color: inherit; + } + } + + .reply-indicator__attachments { + margin-top: 0; + font-size: 15px; + line-height: 22px; + color: $dark-text-color; + } + } + &__bullet-points { display: flex; flex-direction: column; @@ -6219,6 +6261,12 @@ a.status-card { gap: 8px; justify-content: flex-end; + &__hint { + font-size: 14px; + line-height: 20px; + color: $dark-text-color; + } + .link-button { padding: 10px 12px; font-weight: 600; @@ -6226,6 +6274,18 @@ a.status-card { } } +.hotkey-combination { + display: inline-flex; + align-items: center; + gap: 4px; + + kbd { + padding: 3px 5px; + border: 1px solid var(--background-border-color); + border-radius: 4px; + } +} + .boost-modal, .report-modal, .actions-modal, @@ -10579,6 +10639,7 @@ noscript { } .reply-indicator__attachments { + margin-top: 0; font-size: 15px; line-height: 22px; color: $dark-text-color; From 14d7fe05d05473a5b078ad397cc078f1b033c5b5 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 03:40:18 -0400 Subject: [PATCH 03/18] Use `describe` instead of `context` in top-level spec declaration (#31607) --- spec/requests/anonymous_cookies_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/anonymous_cookies_spec.rb b/spec/requests/anonymous_cookies_spec.rb index 427f54e449..337ed4ec31 100644 --- a/spec/requests/anonymous_cookies_spec.rb +++ b/spec/requests/anonymous_cookies_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' -context 'when visited anonymously' do +describe 'Anonymous visits' do around do |example| old = ActionController::Base.allow_forgery_protection ActionController::Base.allow_forgery_protection = true From c09d232ee33f14a17b32ead7caa3aa2332d4b3d3 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 03:42:35 -0400 Subject: [PATCH 04/18] Convert `api/web/settings` controller spec to request spec (#31606) --- .../api/web/settings_controller_spec.rb | 24 ----------- spec/requests/api/web/settings_spec.rb | 41 +++++++++++++++++++ 2 files changed, 41 insertions(+), 24 deletions(-) delete mode 100644 spec/controllers/api/web/settings_controller_spec.rb create mode 100644 spec/requests/api/web/settings_spec.rb diff --git a/spec/controllers/api/web/settings_controller_spec.rb b/spec/controllers/api/web/settings_controller_spec.rb deleted file mode 100644 index 815da04c47..0000000000 --- a/spec/controllers/api/web/settings_controller_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Api::Web::SettingsController do - render_views - - let!(:user) { Fabricate(:user) } - - describe 'PATCH #update' do - it 'redirects to about page' do - sign_in(user) - patch :update, format: :json, params: { data: { 'onboarded' => true } } - - user.reload - expect(response).to have_http_status(200) - expect(user_web_setting.data['onboarded']).to eq('true') - end - - def user_web_setting - Web::Setting.where(user: user).first - end - end -end diff --git a/spec/requests/api/web/settings_spec.rb b/spec/requests/api/web/settings_spec.rb new file mode 100644 index 0000000000..81b8b44953 --- /dev/null +++ b/spec/requests/api/web/settings_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe '/api/web/settings' do + describe 'PATCH /api/web/settings' do + let(:user) { Fabricate :user } + + context 'when signed in' do + before { sign_in(user) } + + it 'updates setting and responds with success' do + patch '/api/web/settings', params: { data: { 'onboarded' => true } } + + expect(user_web_setting.data) + .to include('onboarded' => 'true') + + expect(response) + .to have_http_status(200) + end + end + + context 'when not signed in' do + it 'responds with unprocessable and does not modify setting' do + patch '/api/web/settings', params: { data: { 'onboarded' => true } } + + expect(user_web_setting) + .to be_nil + + expect(response) + .to have_http_status(422) + end + end + + def user_web_setting + Web::Setting + .where(user: user) + .first + end + end +end From 0e7c88aa6df12e2927396518f7663b07fb9660f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 09:54:58 +0200 Subject: [PATCH 05/18] New Crowdin Translations (automated) (#31609) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/ca.json | 4 ++++ app/javascript/mastodon/locales/de.json | 4 ++++ app/javascript/mastodon/locales/es-AR.json | 4 ++++ app/javascript/mastodon/locales/et.json | 3 +++ app/javascript/mastodon/locales/fi.json | 4 ++++ app/javascript/mastodon/locales/gl.json | 4 ++++ app/javascript/mastodon/locales/is.json | 4 ++++ app/javascript/mastodon/locales/ja.json | 10 ++++++++++ app/javascript/mastodon/locales/ko.json | 7 ++++++- app/javascript/mastodon/locales/lt.json | 4 ++++ app/javascript/mastodon/locales/nl.json | 3 +++ app/javascript/mastodon/locales/pl.json | 4 ++++ app/javascript/mastodon/locales/sq.json | 4 ++++ app/javascript/mastodon/locales/zh-TW.json | 4 ++++ config/locales/ko.yml | 1 + config/locales/simple_form.et.yml | 3 +++ 16 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 727c161873..d99b5c7375 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -97,6 +97,8 @@ "block_modal.title": "Bloquem l'usuari?", "block_modal.you_wont_see_mentions": "No veureu publicacions que l'esmentin.", "boost_modal.combo": "Pots prémer {combo} per a evitar-ho el pròxim cop", + "boost_modal.reblog": "Voleu impulsar la publicació?", + "boost_modal.undo_reblog": "Voleu retirar l'impuls a la publicació?", "bundle_column_error.copy_stacktrace": "Copia l'informe d'error", "bundle_column_error.error.body": "No s'ha pogut renderitzar la pàgina sol·licitada. Podria ser per un error en el nostre codi o per un problema de compatibilitat del navegador.", "bundle_column_error.error.title": "Oh, no!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "No veureu publicacions que els esmentin.", "mute_modal.you_wont_see_posts": "Encara poden veure les vostres publicacions, però no veureu les seves.", "navigation_bar.about": "Quant a", + "navigation_bar.administration": "Administració", "navigation_bar.advanced_interface": "Obre en la interfície web avançada", "navigation_bar.blocks": "Usuaris blocats", "navigation_bar.bookmarks": "Marcadors", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Seguint i seguidors", "navigation_bar.lists": "Llistes", "navigation_bar.logout": "Tanca la sessió", + "navigation_bar.moderation": "Moderació", "navigation_bar.mutes": "Usuaris silenciats", "navigation_bar.opened_in_classic_interface": "Els tuts, comptes i altres pàgines especifiques s'obren per defecte en la interfície web clàssica.", "navigation_bar.personal": "Personal", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index b960649eb2..8e52cf1086 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -97,6 +97,8 @@ "block_modal.title": "Profil blockieren?", "block_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.", "boost_modal.combo": "Mit {combo} erscheint dieses Fenster beim nächsten Mal nicht mehr", + "boost_modal.reblog": "Beitrag teilen?", + "boost_modal.undo_reblog": "Beitrag nicht mehr teilen?", "bundle_column_error.copy_stacktrace": "Fehlerbericht kopieren", "bundle_column_error.error.body": "Die angeforderte Seite konnte nicht dargestellt werden. Dies könnte auf einen Fehler in unserem Code oder auf ein Browser-Kompatibilitätsproblem zurückzuführen sein.", "bundle_column_error.error.title": "Oh nein!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.", "mute_modal.you_wont_see_posts": "Deine Beiträge können weiterhin angesehen werden, aber du wirst deren Beiträge nicht mehr sehen.", "navigation_bar.about": "Über", + "navigation_bar.administration": "Administration", "navigation_bar.advanced_interface": "Im erweiterten Webinterface öffnen", "navigation_bar.blocks": "Blockierte Profile", "navigation_bar.bookmarks": "Lesezeichen", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Follower und Folge ich", "navigation_bar.lists": "Listen", "navigation_bar.logout": "Abmelden", + "navigation_bar.moderation": "Moderation", "navigation_bar.mutes": "Stummgeschaltete Profile", "navigation_bar.opened_in_classic_interface": "Beiträge, Konten und andere bestimmte Seiten werden standardmäßig im klassischen Webinterface geöffnet.", "navigation_bar.personal": "Persönlich", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index 21e70dd98d..c3915c9b74 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -97,6 +97,8 @@ "block_modal.title": "¿Bloquear usuario?", "block_modal.you_wont_see_mentions": "No verás mensajes que los mencionen.", "boost_modal.combo": "Podés hacer clic en {combo} para saltar esto la próxima vez", + "boost_modal.reblog": "¿Adherir al mensaje?", + "boost_modal.undo_reblog": "¿Dejar de adherir al mensaje?", "bundle_column_error.copy_stacktrace": "Copiar informe de error", "bundle_column_error.error.body": "La página solicitada no pudo ser cargada. Podría deberse a un error de programación en nuestro código o a un problema de compatibilidad con el navegador web.", "bundle_column_error.error.title": "¡Epa!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "No verás mensajes que los mencionen.", "mute_modal.you_wont_see_posts": "Todavía pueden ver tus mensajes, pero vos no verás los suyos.", "navigation_bar.about": "Información", + "navigation_bar.administration": "Administración", "navigation_bar.advanced_interface": "Abrir en interface web avanzada", "navigation_bar.blocks": "Usuarios bloqueados", "navigation_bar.bookmarks": "Marcadores", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Cuentas seguidas y seguidores", "navigation_bar.lists": "Listas", "navigation_bar.logout": "Cerrar sesión", + "navigation_bar.moderation": "Moderación", "navigation_bar.mutes": "Usuarios silenciados", "navigation_bar.opened_in_classic_interface": "Los mensajes, las cuentas y otras páginas específicas se abren predeterminadamente en la interface web clásica.", "navigation_bar.personal": "Personal", diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index 80c65804fa..5caa258cd2 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -33,7 +33,9 @@ "account.follow_back": "Jälgi vastu", "account.followers": "Jälgijad", "account.followers.empty": "Keegi ei jälgi veel seda kasutajat.", + "account.followers_counter": "{count, plural, one {{counter} jälgija} other {{counter} jälgijat}}", "account.following": "Jälgib", + "account.following_counter": "{count, plural, one {{counter} jälgib} other {{counter} jälgib}}", "account.follows.empty": "See kasutaja ei jälgi veel kedagi.", "account.go_to_profile": "Mine profiilile", "account.hide_reblogs": "Peida @{name} jagamised", @@ -59,6 +61,7 @@ "account.requested_follow": "{name} on taodelnud sinu jälgimist", "account.share": "Jaga @{name} profiili", "account.show_reblogs": "Näita @{name} jagamisi", + "account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}", "account.unblock": "Eemalda blokeering @{name}", "account.unblock_domain": "Tee {domain} nähtavaks", "account.unblock_short": "Eemalda blokeering", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 10f9d79614..39df3e010f 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -97,6 +97,8 @@ "block_modal.title": "Estetäänkö käyttäjä?", "block_modal.you_wont_see_mentions": "Et näe enää julkaisuja, joissa hänet mainitaan.", "boost_modal.combo": "Ensi kerralla voit ohittaa tämän painamalla {combo}", + "boost_modal.reblog": "Tehostetaanko julkaisua?", + "boost_modal.undo_reblog": "Perutaanko julkaisun tehostus?", "bundle_column_error.copy_stacktrace": "Kopioi virheraportti", "bundle_column_error.error.body": "Pyydettyä sivua ei voitu hahmontaa. Se voi johtua virheestä koodissamme tai selaimen yhteensopivuudessa.", "bundle_column_error.error.title": "Voi ei!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "Et näe enää julkaisuja, joissa hänet mainitaan.", "mute_modal.you_wont_see_posts": "Hän voi yhä nähdä julkaisusi, mutta sinä et näe hänen.", "navigation_bar.about": "Tietoja", + "navigation_bar.administration": "Ylläpito", "navigation_bar.advanced_interface": "Avaa edistyneessä selainkäyttöliittymässä", "navigation_bar.blocks": "Estetyt käyttäjät", "navigation_bar.bookmarks": "Kirjanmerkit", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Seuratut ja seuraajat", "navigation_bar.lists": "Listat", "navigation_bar.logout": "Kirjaudu ulos", + "navigation_bar.moderation": "Moderointi", "navigation_bar.mutes": "Mykistetyt käyttäjät", "navigation_bar.opened_in_classic_interface": "Julkaisut, profiilit ja tietyt muut sivut avautuvat oletuksena perinteiseen selainkäyttöliittymään.", "navigation_bar.personal": "Henkilökohtaiset", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 9da7438da6..5a5ac1dd38 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -97,6 +97,8 @@ "block_modal.title": "Bloquear usuaria?", "block_modal.you_wont_see_mentions": "Non verás publicacións que a mencionen.", "boost_modal.combo": "Preme {combo} para ignorar isto na seguinte vez", + "boost_modal.reblog": "Promover publicación?", + "boost_modal.undo_reblog": "Retirar promoción?", "bundle_column_error.copy_stacktrace": "Copiar informe do erro", "bundle_column_error.error.body": "Non se puido mostrar a páxina solicitada. Podería deberse a un problema no código, ou incompatiblidade co navegador.", "bundle_column_error.error.title": "Vaites!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "Non verás as publicacións que a mencionen.", "mute_modal.you_wont_see_posts": "Seguirá podendo ler as túas publicacións, pero non verás as súas.", "navigation_bar.about": "Sobre", + "navigation_bar.administration": "Administración", "navigation_bar.advanced_interface": "Abrir coa interface web avanzada", "navigation_bar.blocks": "Usuarias bloqueadas", "navigation_bar.bookmarks": "Marcadores", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Seguindo e seguidoras", "navigation_bar.lists": "Listaxes", "navigation_bar.logout": "Pechar sesión", + "navigation_bar.moderation": "Moderación", "navigation_bar.mutes": "Usuarias silenciadas", "navigation_bar.opened_in_classic_interface": "Publicacións, contas e outras páxinas dedicadas ábrense por defecto na interface web clásica.", "navigation_bar.personal": "Persoal", diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json index 49b31755fa..54fbee48e6 100644 --- a/app/javascript/mastodon/locales/is.json +++ b/app/javascript/mastodon/locales/is.json @@ -97,6 +97,8 @@ "block_modal.title": "Útiloka notanda?", "block_modal.you_wont_see_mentions": "Þú munt ekki sjá færslur sem minnast á viðkomandi aðila.", "boost_modal.combo": "Þú getur ýtt á {combo} til að sleppa þessu næst", + "boost_modal.reblog": "Endurbirta færslu?", + "boost_modal.undo_reblog": "Taka færslu úr endurbirtingu?", "bundle_column_error.copy_stacktrace": "Afrita villuskýrslu", "bundle_column_error.error.body": "Umbeðna síðau var ekki hægt að myndgera. Það gæti verið vegna villu í kóðanum okkar eða vandamáls með samhæfni vafra.", "bundle_column_error.error.title": "Ó-nei!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "Þú munt ekki sjá færslur sem minnast á viðkomandi aðila.", "mute_modal.you_wont_see_posts": "Viðkomandi geta áfram séð færslurnar þínar en þú munt ekki sjá færslurnar þeirra.", "navigation_bar.about": "Um hugbúnaðinn", + "navigation_bar.administration": "Stjórnun", "navigation_bar.advanced_interface": "Opna í ítarlegu vefviðmóti", "navigation_bar.blocks": "Útilokaðir notendur", "navigation_bar.bookmarks": "Bókamerki", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Fylgist með og fylgjendur", "navigation_bar.lists": "Listar", "navigation_bar.logout": "Útskráning", + "navigation_bar.moderation": "Umsjón", "navigation_bar.mutes": "Þaggaðir notendur", "navigation_bar.opened_in_classic_interface": "Færslur, notendaaðgangar og aðrar sérhæfðar síður eru sjálfgefið opnaðar í klassíska vefviðmótinu.", "navigation_bar.personal": "Einka", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index ee4ba949b9..8784c6d1a8 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -502,7 +502,17 @@ "notification.status": "{name}さんが投稿しました", "notification.update": "{name}さんが投稿を編集しました", "notification_requests.accept": "受け入れる", + "notification_requests.accept_multiple": "{count, plural, other {選択中の#件を受け入れる}}", + "notification_requests.confirm_accept_multiple.button": "{count, plural, other {#件のアカウントを受け入れる}}", + "notification_requests.confirm_accept_multiple.message": "{count, plural, other {#件のアカウント}}に対して今後通知を受け入れるようにします。よろしいですか?", + "notification_requests.confirm_accept_multiple.title": "保留中のアカウントの受け入れ", + "notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {#件のアカウントを無視する}}", + "notification_requests.confirm_dismiss_multiple.message": "{count, plural, other {#件のアカウント}}からの通知を今後無視するようにします。一度この操作を行った{count, plural, other {アカウント}}とふたたび出会うことは容易ではありません。よろしいですか?", + "notification_requests.confirm_dismiss_multiple.title": "保留中のアカウントを無視しようとしています", "notification_requests.dismiss": "無視", + "notification_requests.dismiss_multiple": "{count, plural, other {選択中の#件を無視する}}", + "notification_requests.edit_selection": "選択", + "notification_requests.exit_selection": "選択の終了", "notification_requests.explainer_for_limited_account": "このアカウントはモデレーターにより制限が課されているため、このアカウントによる通知は保留されています", "notification_requests.explainer_for_limited_remote_account": "このアカウントが所属するサーバーはモデレーターにより制限が課されているため、このアカウントによる通知は保留されています", "notification_requests.minimize_banner": "「保留中の通知」のバナーを最小化する", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 32a846308d..3b4434fef7 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -498,9 +498,13 @@ "notification.admin.report_statuses": "{name} 님이 {target}을 {category}로 신고했습니다", "notification.admin.report_statuses_other": "{name} 님이 {target}을 신고했습니다", "notification.admin.sign_up": "{name} 님이 가입했습니다", + "notification.admin.sign_up.name_and_others": "{name} 외 {count, plural, other {# 명}}이 가입했습니다", "notification.favourite": "{name} 님이 내 게시물을 좋아합니다", + "notification.favourite.name_and_others_with_link": "{name} 외 {count, plural, other {# 명}}이 내 게시물을 좋아합니다", "notification.follow": "{name} 님이 나를 팔로우했습니다", + "notification.follow.name_and_others": "{name} 외 {count, plural, other {# 명}}이 날 팔로우 했습니다", "notification.follow_request": "{name} 님이 팔로우 요청을 보냈습니다", + "notification.follow_request.name_and_others": "{name} 외 {count, plural, other {# 명}}이 나에게 팔로우 요청을 보냈습니다", "notification.label.mention": "멘션", "notification.label.private_mention": "개인 멘션", "notification.label.private_reply": "개인 답글", @@ -518,6 +522,7 @@ "notification.own_poll": "설문을 마침", "notification.poll": "참여한 투표가 끝났습니다", "notification.reblog": "{name} 님이 부스트했습니다", + "notification.reblog.name_and_others_with_link": "{name} 외 {count, plural, other {# 명}}이 내 게시물을 부스트했습니다", "notification.relationships_severance_event": "{name} 님과의 연결이 끊어졌습니다", "notification.relationships_severance_event.account_suspension": "{from}의 관리자가 {target}를 정지시켰기 때문에 그들과 더이상 상호작용 할 수 없고 정보를 받아볼 수 없습니다.", "notification.relationships_severance_event.domain_block": "{from}의 관리자가 {target}를 차단하였고 여기에는 나의 {followersCount} 명의 팔로워와 {followingCount, plural, other {#}} 명의 팔로우가 포함되었습니다.", @@ -851,7 +856,7 @@ "upload_modal.description_placeholder": "다람쥐 헌 쳇바퀴 타고파", "upload_modal.detect_text": "사진에서 문자 탐색", "upload_modal.edit_media": "미디어 수정", - "upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 포컬 포인트를 맞추세요. 이 점은 썸네일에 항상 보여질 부분을 나타냅니다.", + "upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 초점을 맞추세요. 이 점은 썸네일에서 항상 보여질 부분을 나타냅니다.", "upload_modal.preparing_ocr": "OCR 준비 중…", "upload_modal.preview_label": "미리보기 ({ratio})", "upload_progress.label": "업로드 중...", diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json index 027326f9cb..051fd3717c 100644 --- a/app/javascript/mastodon/locales/lt.json +++ b/app/javascript/mastodon/locales/lt.json @@ -97,6 +97,8 @@ "block_modal.title": "Blokuoti naudotoją?", "block_modal.you_wont_see_mentions": "Nematysi įrašus, kuriuose jie paminimi.", "boost_modal.combo": "Galima paspausti {combo}, kad praleisti tai kitą kartą", + "boost_modal.reblog": "Pasidalinti įrašą?", + "boost_modal.undo_reblog": "Panaikinti pasidalintą įrašą?", "bundle_column_error.copy_stacktrace": "Kopijuoti klaidos ataskaitą", "bundle_column_error.error.body": "Paprašytos puslapio nepavyko atvaizduoti. Tai gali būti dėl mūsų kodo klaidos arba naršyklės suderinamumo problemos.", "bundle_column_error.error.title": "O, ne!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "Nematysi įrašus, kuriuose jie paminimi.", "mute_modal.you_wont_see_posts": "Jie vis tiek gali matyti tavo įrašus, bet tu nematysi jų.", "navigation_bar.about": "Apie", + "navigation_bar.administration": "Administravimas", "navigation_bar.advanced_interface": "Atidaryti išplėstinę žiniatinklio sąsają", "navigation_bar.blocks": "Užblokuoti naudotojai", "navigation_bar.bookmarks": "Žymės", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Sekimai ir sekėjai", "navigation_bar.lists": "Sąrašai", "navigation_bar.logout": "Atsijungti", + "navigation_bar.moderation": "Prižiūrėjimas", "navigation_bar.mutes": "Nutildyti naudotojai", "navigation_bar.opened_in_classic_interface": "Įrašai, paskyros ir kiti konkretūs puslapiai pagal numatytuosius nustatymus atidaromi klasikinėje žiniatinklio sąsajoje.", "navigation_bar.personal": "Asmeninis", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index b15c09fead..3362cf36ce 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -97,6 +97,7 @@ "block_modal.title": "Gebruiker blokkeren?", "block_modal.you_wont_see_mentions": "Je ziet geen berichten meer die dit account vermelden.", "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan", + "boost_modal.reblog": "Bericht boosten?", "bundle_column_error.copy_stacktrace": "Foutrapportage kopiëren", "bundle_column_error.error.body": "De opgevraagde pagina kon niet worden weergegeven. Dit kan het gevolg zijn van een fout in onze broncode, of van een compatibiliteitsprobleem met je webbrowser.", "bundle_column_error.error.title": "O nee!", @@ -467,6 +468,7 @@ "mute_modal.you_wont_see_mentions": "Je ziet geen berichten meer die dit account vermelden.", "mute_modal.you_wont_see_posts": "De persoon kan nog steeds jouw berichten zien, maar diens berichten zie je niet meer.", "navigation_bar.about": "Over", + "navigation_bar.administration": "Beheer", "navigation_bar.advanced_interface": "In geavanceerde webinterface openen", "navigation_bar.blocks": "Geblokkeerde gebruikers", "navigation_bar.bookmarks": "Bladwijzers", @@ -483,6 +485,7 @@ "navigation_bar.follows_and_followers": "Volgers en gevolgde accounts", "navigation_bar.lists": "Lijsten", "navigation_bar.logout": "Uitloggen", + "navigation_bar.moderation": "Moderatie", "navigation_bar.mutes": "Genegeerde gebruikers", "navigation_bar.opened_in_classic_interface": "Berichten, accounts en andere specifieke pagina’s, worden standaard geopend in de klassieke webinterface.", "navigation_bar.personal": "Persoonlijk", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 2a4c5d6e44..1af79127a5 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -97,6 +97,8 @@ "block_modal.title": "Zablokować użytkownika?", "block_modal.you_wont_see_mentions": "Nie zobaczysz wpisów, które wspominają tego użytkownika.", "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", + "boost_modal.reblog": "Podbić wpis?", + "boost_modal.undo_reblog": "Cofnąć podbicie?", "bundle_column_error.copy_stacktrace": "Skopiuj raport o błędzie", "bundle_column_error.error.body": "Nie można zrenderować żądanej strony. Może to być spowodowane błędem w naszym kodzie lub problemami z kompatybilnością przeglądarki.", "bundle_column_error.error.title": "O nie!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "Nie zobaczysz wpisów, które wspominają tego użytkownika.", "mute_modal.you_wont_see_posts": "Użytkownik dalej będzie widzieć Twoje posty, ale Ty nie będziesz widzieć jego.", "navigation_bar.about": "O serwerze", + "navigation_bar.administration": "Administracja", "navigation_bar.advanced_interface": "Otwórz w zaawansowanym interfejsie użytkownika", "navigation_bar.blocks": "Zablokowani użytkownicy", "navigation_bar.bookmarks": "Zakładki", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Obserwowani i obserwujący", "navigation_bar.lists": "Listy", "navigation_bar.logout": "Wyloguj", + "navigation_bar.moderation": "Moderacja", "navigation_bar.mutes": "Wyciszeni użytkownicy", "navigation_bar.opened_in_classic_interface": "Posty, konta i inne konkretne strony są otwierane domyślnie w klasycznym interfejsie sieciowym.", "navigation_bar.personal": "Osobiste", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 48f667086b..51b7a551c1 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -97,6 +97,8 @@ "block_modal.title": "Të bllokohet përdoruesi?", "block_modal.you_wont_see_mentions": "S’do të shihni postimet ku përmenden.", "boost_modal.combo": "Që kjo të anashkalohet herës tjetër, mund të shtypni {combo}", + "boost_modal.reblog": "Përforcim postimi?", + "boost_modal.undo_reblog": "Të hiqet përforcim për postimin?", "bundle_column_error.copy_stacktrace": "Kopjo raportim gabimi", "bundle_column_error.error.body": "Faqja e kërkuar s’u vizatua dot. Kjo mund të vijë nga një e metë në kodin tonë, ose nga një problem përputhshmërie i shfletuesit.", "bundle_column_error.error.title": "Oh, mos!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "S’do të shihni postime ku përmenden.", "mute_modal.you_wont_see_posts": "Ata munden ende të shohin postimet tuaja, por ju s’do të shihni të tyret.", "navigation_bar.about": "Mbi", + "navigation_bar.administration": "Administrim", "navigation_bar.advanced_interface": "Hape në ndërfaqe web të thelluar", "navigation_bar.blocks": "Përdorues të bllokuar", "navigation_bar.bookmarks": "Faqerojtës", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "Ndjekje dhe ndjekës", "navigation_bar.lists": "Lista", "navigation_bar.logout": "Dalje", + "navigation_bar.moderation": "Moderim", "navigation_bar.mutes": "Përdorues të heshtuar", "navigation_bar.opened_in_classic_interface": "Postime, llogari dhe të tjera faqe specifike, si parazgjedhje, hapen në ndërfaqe klasike web.", "navigation_bar.personal": "Personale", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 107267b5e5..df754f6d68 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -97,6 +97,8 @@ "block_modal.title": "是否封鎖該使用者?", "block_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。", "boost_modal.combo": "下次您可以按 {combo} 跳過", + "boost_modal.reblog": "是否要轉嘟?", + "boost_modal.undo_reblog": "是否要取消轉嘟?", "bundle_column_error.copy_stacktrace": "複製錯誤報告", "bundle_column_error.error.body": "無法繪製請求的頁面。這可能是因為我們程式碼中的臭蟲或是瀏覽器的相容問題。", "bundle_column_error.error.title": "糟糕!", @@ -467,6 +469,7 @@ "mute_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。", "mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的。", "navigation_bar.about": "關於", + "navigation_bar.administration": "管理介面", "navigation_bar.advanced_interface": "以進階網頁介面開啟", "navigation_bar.blocks": "已封鎖的使用者", "navigation_bar.bookmarks": "書籤", @@ -483,6 +486,7 @@ "navigation_bar.follows_and_followers": "跟隨中與跟隨者", "navigation_bar.lists": "列表", "navigation_bar.logout": "登出", + "navigation_bar.moderation": "站務", "navigation_bar.mutes": "已靜音的使用者", "navigation_bar.opened_in_classic_interface": "預設於經典網頁介面中開啟嘟文、帳號與其他特定頁面。", "navigation_bar.personal": "個人", diff --git a/config/locales/ko.yml b/config/locales/ko.yml index 7cbcb82019..e919e183d4 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -886,6 +886,7 @@ ko: name: 이름 newest: 최신 oldest: 오래된 순 + open: 공개시점으로 보기 reset: 초기화 review: 심사 상태 search: 검색 diff --git a/config/locales/simple_form.et.yml b/config/locales/simple_form.et.yml index 74660921d2..2109ceb496 100644 --- a/config/locales/simple_form.et.yml +++ b/config/locales/simple_form.et.yml @@ -211,6 +211,7 @@ et: setting_default_privacy: Postituse nähtavus setting_default_sensitive: Alati märgista meedia tundlikuks setting_delete_modal: Näita kinnitusdialoogi enne postituse kustutamist + setting_disable_hover_cards: Keela profiili eelvaade kui hõljutada setting_disable_swiping: Keela pühkimisliigutused setting_display_media: Meedia kuvarežiim setting_display_media_default: Vaikimisi @@ -242,11 +243,13 @@ et: warn: Peida hoiatusega form_admin_settings: activity_api_enabled: Avalda agregeeritud statistika kasutajaaktiivsuse kohta API-s + app_icon: Äpi ikoon backups_retention_period: Kasutajate arhiivi talletusperiood bootstrap_timeline_accounts: Alati soovita neid kontosid uutele kasutajatele closed_registrations_message: Kohandatud teade, kui liitumine pole võimalik content_cache_retention_period: Kaugsisu säilitamise aeg custom_css: Kohandatud CSS + favicon: Favicon mascot: Kohandatud maskott (kunagine) media_cache_retention_period: Meediapuhvri talletusperiood peers_api_enabled: Avalda avastatud serverite loetelu API kaudu From 38a3466741cfa148692c7aa58d8cf207793ac3c1 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 03:55:25 -0400 Subject: [PATCH 06/18] Convert `api/oembed` controller spec to request spec (#31605) --- .../controllers/api/oembed_controller_spec.rb | 22 ------------- spec/requests/api/oembed_spec.rb | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+), 22 deletions(-) delete mode 100644 spec/controllers/api/oembed_controller_spec.rb create mode 100644 spec/requests/api/oembed_spec.rb diff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb deleted file mode 100644 index 5f0ca560d2..0000000000 --- a/spec/controllers/api/oembed_controller_spec.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Api::OEmbedController do - render_views - - let(:alice) { Fabricate(:account, username: 'alice') } - let(:status) { Fabricate(:status, text: 'Hello world', account: alice) } - - describe 'GET #show' do - before do - request.host = Rails.configuration.x.local_domain - get :show, params: { url: short_account_status_url(alice, status) }, format: :json - end - - it 'returns private cache control headers', :aggregate_failures do - expect(response).to have_http_status(200) - expect(response.headers['Cache-Control']).to include('private, no-store') - end - end -end diff --git a/spec/requests/api/oembed_spec.rb b/spec/requests/api/oembed_spec.rb new file mode 100644 index 0000000000..b9578b37c8 --- /dev/null +++ b/spec/requests/api/oembed_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'API OEmbed' do + describe 'GET /api/oembed' do + before { host! Rails.configuration.x.local_domain } + + context 'when status is public' do + let(:status) { Fabricate(:status, visibility: :public) } + + it 'returns success with private cache control headers' do + get '/api/oembed', params: { url: short_account_status_url(status.account, status) } + + expect(response) + .to have_http_status(200) + expect(response.headers['Cache-Control']) + .to include('private, no-store') + end + end + + context 'when status is not public' do + let(:status) { Fabricate(:status, visibility: :direct) } + + it 'returns not found' do + get '/api/oembed', params: { url: short_account_status_url(status.account, status) } + + expect(response) + .to have_http_status(404) + end + end + end +end From a7f8417795a7306e10c9bec7202359a8e73f6e46 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 04:12:39 -0400 Subject: [PATCH 07/18] Convert "CSV export" settings controller specs to request specs (#31601) --- .../blocked_accounts_controller_spec.rb | 19 -------- .../blocked_domains_controller_spec.rb | 20 --------- .../exports/bookmarks_controller_spec.rb | 24 ---------- .../following_accounts_controller_spec.rb | 19 -------- .../settings/exports/lists_controller_spec.rb | 21 --------- .../exports/muted_accounts_controller_spec.rb | 19 -------- .../settings/exports/blocked_accounts_spec.rb | 42 ++++++++++++++++++ .../settings/exports/blocked_domains_spec.rb | 39 ++++++++++++++++ .../settings/exports/bookmarks_spec.rb | 44 +++++++++++++++++++ .../exports/following_accounts_spec.rb | 44 +++++++++++++++++++ spec/requests/settings/exports/lists_spec.rb | 40 +++++++++++++++++ .../settings/exports/muted_accounts_spec.rb | 43 ++++++++++++++++++ 12 files changed, 252 insertions(+), 122 deletions(-) delete mode 100644 spec/controllers/settings/exports/blocked_accounts_controller_spec.rb delete mode 100644 spec/controllers/settings/exports/blocked_domains_controller_spec.rb delete mode 100644 spec/controllers/settings/exports/bookmarks_controller_spec.rb delete mode 100644 spec/controllers/settings/exports/following_accounts_controller_spec.rb delete mode 100644 spec/controllers/settings/exports/lists_controller_spec.rb delete mode 100644 spec/controllers/settings/exports/muted_accounts_controller_spec.rb create mode 100644 spec/requests/settings/exports/blocked_accounts_spec.rb create mode 100644 spec/requests/settings/exports/blocked_domains_spec.rb create mode 100644 spec/requests/settings/exports/bookmarks_spec.rb create mode 100644 spec/requests/settings/exports/following_accounts_spec.rb create mode 100644 spec/requests/settings/exports/lists_spec.rb create mode 100644 spec/requests/settings/exports/muted_accounts_spec.rb diff --git a/spec/controllers/settings/exports/blocked_accounts_controller_spec.rb b/spec/controllers/settings/exports/blocked_accounts_controller_spec.rb deleted file mode 100644 index 459b278d64..0000000000 --- a/spec/controllers/settings/exports/blocked_accounts_controller_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Settings::Exports::BlockedAccountsController do - render_views - - describe 'GET #index' do - it 'returns a csv of the blocking accounts' do - user = Fabricate(:user) - user.account.block!(Fabricate(:account, username: 'username', domain: 'domain')) - - sign_in user, scope: :user - get :index, format: :csv - - expect(response.body).to eq "username@domain\n" - end - end -end diff --git a/spec/controllers/settings/exports/blocked_domains_controller_spec.rb b/spec/controllers/settings/exports/blocked_domains_controller_spec.rb deleted file mode 100644 index ac72fd9dd7..0000000000 --- a/spec/controllers/settings/exports/blocked_domains_controller_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Settings::Exports::BlockedDomainsController do - render_views - - describe 'GET #index' do - it 'returns a csv of the domains' do - account = Fabricate(:account, domain: 'example.com') - user = Fabricate(:user, account: account) - Fabricate(:account_domain_block, domain: 'example.com', account: account) - - sign_in user, scope: :user - get :index, format: :csv - - expect(response.body).to eq "example.com\n" - end - end -end diff --git a/spec/controllers/settings/exports/bookmarks_controller_spec.rb b/spec/controllers/settings/exports/bookmarks_controller_spec.rb deleted file mode 100644 index 9982eff165..0000000000 --- a/spec/controllers/settings/exports/bookmarks_controller_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Settings::Exports::BookmarksController do - render_views - - let(:user) { Fabricate(:user) } - let(:account) { Fabricate(:account, domain: 'foo.bar') } - let(:status) { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') } - - describe 'GET #index' do - before do - user.account.bookmarks.create!(status: status) - end - - it 'returns a csv of the bookmarked toots' do - sign_in user, scope: :user - get :index, format: :csv - - expect(response.body).to eq "https://foo.bar/statuses/1312\n" - end - end -end diff --git a/spec/controllers/settings/exports/following_accounts_controller_spec.rb b/spec/controllers/settings/exports/following_accounts_controller_spec.rb deleted file mode 100644 index 72b0b94e13..0000000000 --- a/spec/controllers/settings/exports/following_accounts_controller_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Settings::Exports::FollowingAccountsController do - render_views - - describe 'GET #index' do - it 'returns a csv of the following accounts' do - user = Fabricate(:user) - user.account.follow!(Fabricate(:account, username: 'username', domain: 'domain')) - - sign_in user, scope: :user - get :index, format: :csv - - expect(response.body).to eq "Account address,Show boosts,Notify on new posts,Languages\nusername@domain,true,false,\n" - end - end -end diff --git a/spec/controllers/settings/exports/lists_controller_spec.rb b/spec/controllers/settings/exports/lists_controller_spec.rb deleted file mode 100644 index 29623ba499..0000000000 --- a/spec/controllers/settings/exports/lists_controller_spec.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Settings::Exports::ListsController do - render_views - - describe 'GET #index' do - it 'returns a csv of the domains' do - account = Fabricate(:account) - user = Fabricate(:user, account: account) - list = Fabricate(:list, account: account, title: 'The List') - Fabricate(:list_account, list: list, account: account) - - sign_in user, scope: :user - get :index, format: :csv - - expect(response.body).to match 'The List' - end - end -end diff --git a/spec/controllers/settings/exports/muted_accounts_controller_spec.rb b/spec/controllers/settings/exports/muted_accounts_controller_spec.rb deleted file mode 100644 index b4170cb160..0000000000 --- a/spec/controllers/settings/exports/muted_accounts_controller_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Settings::Exports::MutedAccountsController do - render_views - - describe 'GET #index' do - it 'returns a csv of the muting accounts' do - user = Fabricate(:user) - user.account.mute!(Fabricate(:account, username: 'username', domain: 'domain')) - - sign_in user, scope: :user - get :index, format: :csv - - expect(response.body).to eq "Account address,Hide notifications\nusername@domain,true\n" - end - end -end diff --git a/spec/requests/settings/exports/blocked_accounts_spec.rb b/spec/requests/settings/exports/blocked_accounts_spec.rb new file mode 100644 index 0000000000..f335ba18c0 --- /dev/null +++ b/spec/requests/settings/exports/blocked_accounts_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Settings / Exports / Blocked Accounts' do + describe 'GET /settings/exports/blocks' do + context 'with a signed in user who has blocked accounts' do + let(:user) { Fabricate :user } + + before do + Fabricate( + :block, + account: user.account, + target_account: Fabricate(:account, username: 'username', domain: 'domain') + ) + sign_in user + end + + it 'returns a CSV with the blocking accounts' do + get '/settings/exports/blocks.csv' + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to eq('text/csv') + expect(response.body) + .to eq(<<~CSV) + username@domain + CSV + end + end + + describe 'when signed out' do + it 'returns unauthorized' do + get '/settings/exports/blocks.csv' + + expect(response) + .to have_http_status(401) + end + end + end +end diff --git a/spec/requests/settings/exports/blocked_domains_spec.rb b/spec/requests/settings/exports/blocked_domains_spec.rb new file mode 100644 index 0000000000..762907585f --- /dev/null +++ b/spec/requests/settings/exports/blocked_domains_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Settings / Exports / Blocked Domains' do + describe 'GET /settings/exports/domain_blocks' do + context 'with a signed in user who has blocked domains' do + let(:account) { Fabricate :account, domain: 'example.com' } + let(:user) { Fabricate :user, account: account } + + before do + Fabricate(:account_domain_block, domain: 'example.com', account: account) + sign_in user + end + + it 'returns a CSV with the domains' do + get '/settings/exports/domain_blocks.csv' + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to eq('text/csv') + expect(response.body) + .to eq(<<~CSV) + example.com + CSV + end + end + + describe 'when signed out' do + it 'returns unauthorized' do + get '/settings/exports/domain_blocks.csv' + + expect(response) + .to have_http_status(401) + end + end + end +end diff --git a/spec/requests/settings/exports/bookmarks_spec.rb b/spec/requests/settings/exports/bookmarks_spec.rb new file mode 100644 index 0000000000..f200e70383 --- /dev/null +++ b/spec/requests/settings/exports/bookmarks_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Settings / Exports / Bookmarks' do + describe 'GET /settings/exports/bookmarks' do + context 'with a signed in user who has bookmarks' do + let(:account) { Fabricate(:account, domain: 'foo.bar') } + let(:status) { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') } + let(:user) { Fabricate(:user) } + + before do + Fabricate( + :bookmark, + account: user.account, + status: status + ) + sign_in user + end + + it 'returns a CSV with the bookmarked statuses' do + get '/settings/exports/bookmarks.csv' + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to eq('text/csv') + expect(response.body) + .to eq(<<~CSV) + https://foo.bar/statuses/1312 + CSV + end + end + + describe 'when signed out' do + it 'returns unauthorized' do + get '/settings/exports/bookmarks.csv' + + expect(response) + .to have_http_status(401) + end + end + end +end diff --git a/spec/requests/settings/exports/following_accounts_spec.rb b/spec/requests/settings/exports/following_accounts_spec.rb new file mode 100644 index 0000000000..268b72c412 --- /dev/null +++ b/spec/requests/settings/exports/following_accounts_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Settings / Exports / Following Accounts' do + describe 'GET /settings/exports/follows' do + context 'with a signed in user who is following accounts' do + let(:user) { Fabricate :user } + + before do + Fabricate( + :follow, + account: user.account, + target_account: Fabricate(:account, username: 'username', domain: 'domain'), + languages: ['en'] + ) + sign_in user + end + + it 'returns a CSV with the accounts' do + get '/settings/exports/follows.csv' + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to eq('text/csv') + expect(response.body) + .to eq(<<~CSV) + Account address,Show boosts,Notify on new posts,Languages + username@domain,true,false,en + CSV + end + end + + describe 'when signed out' do + it 'returns unauthorized' do + get '/settings/exports/follows.csv' + + expect(response) + .to have_http_status(401) + end + end + end +end diff --git a/spec/requests/settings/exports/lists_spec.rb b/spec/requests/settings/exports/lists_spec.rb new file mode 100644 index 0000000000..b868f8dfda --- /dev/null +++ b/spec/requests/settings/exports/lists_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Settings / Exports / Lists' do + describe 'GET /settings/exports/lists' do + context 'with a signed in user who has lists' do + let(:account) { Fabricate(:account, username: 'test', domain: 'example.com') } + let(:list) { Fabricate :list, account: account, title: 'The List' } + let(:user) { Fabricate(:user, account: account) } + + before do + Fabricate(:list_account, list: list, account: account) + sign_in user + end + + it 'returns a CSV with the list' do + get '/settings/exports/lists.csv' + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to eq('text/csv') + expect(response.body) + .to eq(<<~CSV) + The List,test@example.com + CSV + end + end + + describe 'when signed out' do + it 'returns unauthorized' do + get '/settings/exports/lists.csv' + + expect(response) + .to have_http_status(401) + end + end + end +end diff --git a/spec/requests/settings/exports/muted_accounts_spec.rb b/spec/requests/settings/exports/muted_accounts_spec.rb new file mode 100644 index 0000000000..efdb0d8221 --- /dev/null +++ b/spec/requests/settings/exports/muted_accounts_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Settings / Exports / Muted Accounts' do + describe 'GET /settings/exports/mutes' do + context 'with a signed in user who has muted accounts' do + let(:user) { Fabricate :user } + + before do + Fabricate( + :mute, + account: user.account, + target_account: Fabricate(:account, username: 'username', domain: 'domain') + ) + sign_in user + end + + it 'returns a CSV with the muted accounts' do + get '/settings/exports/mutes.csv' + + expect(response) + .to have_http_status(200) + expect(response.content_type) + .to eq('text/csv') + expect(response.body) + .to eq(<<~CSV) + Account address,Hide notifications + username@domain,true + CSV + end + end + + describe 'when signed out' do + it 'returns unauthorized' do + get '/settings/exports/mutes.csv' + + expect(response) + .to have_http_status(401) + end + end + end +end From 4118688fba789a84956d77415c87d049d46a7bf4 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Tue, 27 Aug 2024 10:40:04 +0200 Subject: [PATCH 08/18] Streaming: Refactor move database and redis logic into separate files (#31567) --- streaming/database.js | 128 +++++++++++++++++++++++++++ streaming/index.js | 201 ++---------------------------------------- streaming/redis.js | 65 ++++++++++++++ streaming/utils.js | 20 +++++ 4 files changed, 219 insertions(+), 195 deletions(-) create mode 100644 streaming/database.js create mode 100644 streaming/redis.js diff --git a/streaming/database.js b/streaming/database.js new file mode 100644 index 0000000000..9f1d742143 --- /dev/null +++ b/streaming/database.js @@ -0,0 +1,128 @@ +import pg from 'pg'; +import pgConnectionString from 'pg-connection-string'; + +import { parseIntFromEnvValue } from './utils.js'; + +/** + * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from + * @param {string} environment + * @returns {pg.PoolConfig} the configuration for the PostgreSQL connection + */ +export function configFromEnv(env, environment) { + /** @type {Record} */ + const pgConfigs = { + development: { + user: env.DB_USER || pg.defaults.user, + password: env.DB_PASS || pg.defaults.password, + database: env.DB_NAME || 'mastodon_development', + host: env.DB_HOST || pg.defaults.host, + port: parseIntFromEnvValue(env.DB_PORT, pg.defaults.port ?? 5432, 'DB_PORT') + }, + + production: { + user: env.DB_USER || 'mastodon', + password: env.DB_PASS || '', + database: env.DB_NAME || 'mastodon_production', + host: env.DB_HOST || 'localhost', + port: parseIntFromEnvValue(env.DB_PORT, 5432, 'DB_PORT') + }, + }; + + /** + * @type {pg.PoolConfig} + */ + let baseConfig = {}; + + if (env.DATABASE_URL) { + const parsedUrl = pgConnectionString.parse(env.DATABASE_URL); + + // The result of dbUrlToConfig from pg-connection-string is not type + // compatible with pg.PoolConfig, since parts of the connection URL may be + // `null` when pg.PoolConfig expects `undefined`, as such we have to + // manually create the baseConfig object from the properties of the + // parsedUrl. + // + // For more information see: + // https://github.com/brianc/node-postgres/issues/2280 + // + // FIXME: clean up once brianc/node-postgres#3128 lands + if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password; + if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host; + if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user; + if (typeof parsedUrl.port === 'string') { + const parsedPort = parseInt(parsedUrl.port, 10); + if (isNaN(parsedPort)) { + throw new Error('Invalid port specified in DATABASE_URL environment variable'); + } + baseConfig.port = parsedPort; + } + if (typeof parsedUrl.database === 'string') baseConfig.database = parsedUrl.database; + if (typeof parsedUrl.options === 'string') baseConfig.options = parsedUrl.options; + + // The pg-connection-string type definition isn't correct, as parsedUrl.ssl + // can absolutely be an Object, this is to work around these incorrect + // types, including the casting of parsedUrl.ssl to Record + if (typeof parsedUrl.ssl === 'boolean') { + baseConfig.ssl = parsedUrl.ssl; + } else if (typeof parsedUrl.ssl === 'object' && !Array.isArray(parsedUrl.ssl) && parsedUrl.ssl !== null) { + /** @type {Record} */ + const sslOptions = parsedUrl.ssl; + baseConfig.ssl = {}; + + baseConfig.ssl.cert = sslOptions.cert; + baseConfig.ssl.key = sslOptions.key; + baseConfig.ssl.ca = sslOptions.ca; + baseConfig.ssl.rejectUnauthorized = sslOptions.rejectUnauthorized; + } + + // Support overriding the database password in the connection URL + if (!baseConfig.password && env.DB_PASS) { + baseConfig.password = env.DB_PASS; + } + } else if (Object.hasOwn(pgConfigs, environment)) { + baseConfig = pgConfigs[environment]; + + if (env.DB_SSLMODE) { + switch(env.DB_SSLMODE) { + case 'disable': + case '': + baseConfig.ssl = false; + break; + case 'no-verify': + baseConfig.ssl = { rejectUnauthorized: false }; + break; + default: + baseConfig.ssl = {}; + break; + } + } + } else { + throw new Error('Unable to resolve postgresql database configuration.'); + } + + return { + ...baseConfig, + max: parseIntFromEnvValue(env.DB_POOL, 10, 'DB_POOL'), + connectionTimeoutMillis: 15000, + // Deliberately set application_name to an empty string to prevent excessive + // CPU usage with PG Bouncer. See: + // - https://github.com/mastodon/mastodon/pull/23958 + // - https://github.com/pgbouncer/pgbouncer/issues/349 + application_name: '', + }; +} + +let pool; +/** + * + * @param {pg.PoolConfig} config + * @returns {pg.Pool} + */ +export function getPool(config) { + if (pool) { + return pool; + } + + pool = new pg.Pool(config); + return pool; +} diff --git a/streaming/index.js b/streaming/index.js index 2267c469c0..d94649d6e2 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -8,15 +8,14 @@ import url from 'node:url'; import cors from 'cors'; import dotenv from 'dotenv'; import express from 'express'; -import { Redis } from 'ioredis'; import { JSDOM } from 'jsdom'; -import pg from 'pg'; -import pgConnectionString from 'pg-connection-string'; import { WebSocketServer } from 'ws'; +import * as Database from './database.js'; import { AuthenticationError, RequestError, extractStatusAndMessage as extractErrorStatusAndMessage } from './errors.js'; import { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } from './logging.js'; import { setupMetrics } from './metrics.js'; +import * as Redis from './redis.js'; import { isTruthy, normalizeHashtag, firstParam } from './utils.js'; const environment = process.env.NODE_ENV || 'development'; @@ -48,23 +47,6 @@ initializeLogLevel(process.env, environment); * @property {string} deviceId */ -/** - * @param {RedisConfiguration} config - * @returns {Promise} - */ -const createRedisClient = async ({ redisParams, redisUrl }) => { - let client; - - if (typeof redisUrl === 'string') { - client = new Redis(redisUrl, redisParams); - } else { - client = new Redis(redisParams); - } - - client.on('error', (err) => logger.error({ err }, 'Redis Client Error!')); - - return client; -}; /** * Attempts to safely parse a string as JSON, used when both receiving a message @@ -97,177 +79,6 @@ const parseJSON = (json, req) => { } }; -/** - * Takes an environment variable that should be an integer, attempts to parse - * it falling back to a default if not set, and handles errors parsing. - * @param {string|undefined} value - * @param {number} defaultValue - * @param {string} variableName - * @returns {number} - */ -const parseIntFromEnv = (value, defaultValue, variableName) => { - if (typeof value === 'string' && value.length > 0) { - const parsedValue = parseInt(value, 10); - if (isNaN(parsedValue)) { - throw new Error(`Invalid ${variableName} environment variable: ${value}`); - } - return parsedValue; - } else { - return defaultValue; - } -}; - -/** - * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from - * @returns {pg.PoolConfig} the configuration for the PostgreSQL connection - */ -const pgConfigFromEnv = (env) => { - /** @type {Record} */ - const pgConfigs = { - development: { - user: env.DB_USER || pg.defaults.user, - password: env.DB_PASS || pg.defaults.password, - database: env.DB_NAME || 'mastodon_development', - host: env.DB_HOST || pg.defaults.host, - port: parseIntFromEnv(env.DB_PORT, pg.defaults.port ?? 5432, 'DB_PORT') - }, - - production: { - user: env.DB_USER || 'mastodon', - password: env.DB_PASS || '', - database: env.DB_NAME || 'mastodon_production', - host: env.DB_HOST || 'localhost', - port: parseIntFromEnv(env.DB_PORT, 5432, 'DB_PORT') - }, - }; - - /** - * @type {pg.PoolConfig} - */ - let baseConfig = {}; - - if (env.DATABASE_URL) { - const parsedUrl = pgConnectionString.parse(env.DATABASE_URL); - - // The result of dbUrlToConfig from pg-connection-string is not type - // compatible with pg.PoolConfig, since parts of the connection URL may be - // `null` when pg.PoolConfig expects `undefined`, as such we have to - // manually create the baseConfig object from the properties of the - // parsedUrl. - // - // For more information see: - // https://github.com/brianc/node-postgres/issues/2280 - // - // FIXME: clean up once brianc/node-postgres#3128 lands - if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password; - if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host; - if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user; - if (typeof parsedUrl.port === 'string') { - const parsedPort = parseInt(parsedUrl.port, 10); - if (isNaN(parsedPort)) { - throw new Error('Invalid port specified in DATABASE_URL environment variable'); - } - baseConfig.port = parsedPort; - } - if (typeof parsedUrl.database === 'string') baseConfig.database = parsedUrl.database; - if (typeof parsedUrl.options === 'string') baseConfig.options = parsedUrl.options; - - // The pg-connection-string type definition isn't correct, as parsedUrl.ssl - // can absolutely be an Object, this is to work around these incorrect - // types, including the casting of parsedUrl.ssl to Record - if (typeof parsedUrl.ssl === 'boolean') { - baseConfig.ssl = parsedUrl.ssl; - } else if (typeof parsedUrl.ssl === 'object' && !Array.isArray(parsedUrl.ssl) && parsedUrl.ssl !== null) { - /** @type {Record} */ - const sslOptions = parsedUrl.ssl; - baseConfig.ssl = {}; - - baseConfig.ssl.cert = sslOptions.cert; - baseConfig.ssl.key = sslOptions.key; - baseConfig.ssl.ca = sslOptions.ca; - baseConfig.ssl.rejectUnauthorized = sslOptions.rejectUnauthorized; - } - - // Support overriding the database password in the connection URL - if (!baseConfig.password && env.DB_PASS) { - baseConfig.password = env.DB_PASS; - } - } else if (Object.hasOwn(pgConfigs, environment)) { - baseConfig = pgConfigs[environment]; - - if (env.DB_SSLMODE) { - switch(env.DB_SSLMODE) { - case 'disable': - case '': - baseConfig.ssl = false; - break; - case 'no-verify': - baseConfig.ssl = { rejectUnauthorized: false }; - break; - default: - baseConfig.ssl = {}; - break; - } - } - } else { - throw new Error('Unable to resolve postgresql database configuration.'); - } - - return { - ...baseConfig, - max: parseIntFromEnv(env.DB_POOL, 10, 'DB_POOL'), - connectionTimeoutMillis: 15000, - // Deliberately set application_name to an empty string to prevent excessive - // CPU usage with PG Bouncer. See: - // - https://github.com/mastodon/mastodon/pull/23958 - // - https://github.com/pgbouncer/pgbouncer/issues/349 - application_name: '', - }; -}; - -/** - * @typedef RedisConfiguration - * @property {import('ioredis').RedisOptions} redisParams - * @property {string} redisPrefix - * @property {string|undefined} redisUrl - */ - -/** - * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from - * @returns {RedisConfiguration} configuration for the Redis connection - */ -const redisConfigFromEnv = (env) => { - // ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*, - // which means we can't use it. But this is something that should be looked into. - const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : ''; - - let redisPort = parseIntFromEnv(env.REDIS_PORT, 6379, 'REDIS_PORT'); - let redisDatabase = parseIntFromEnv(env.REDIS_DB, 0, 'REDIS_DB'); - - /** @type {import('ioredis').RedisOptions} */ - const redisParams = { - host: env.REDIS_HOST || '127.0.0.1', - port: redisPort, - // Force support for both IPv6 and IPv4, by default ioredis sets this to 4, - // only allowing IPv4 connections: - // https://github.com/redis/ioredis/issues/1576 - family: 0, - db: redisDatabase, - password: env.REDIS_PASSWORD || undefined, - }; - - // redisParams.path takes precedence over host and port. - if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) { - redisParams.path = env.REDIS_URL.slice(7); - } - - return { - redisParams, - redisPrefix, - redisUrl: typeof env.REDIS_URL === 'string' ? env.REDIS_URL : undefined, - }; -}; - const PUBLIC_CHANNELS = [ 'public', 'public:media', @@ -291,10 +102,12 @@ const CHANNEL_NAMES = [ ]; const startServer = async () => { - const pgPool = new pg.Pool(pgConfigFromEnv(process.env)); + const pgPool = Database.getPool(Database.configFromEnv(process.env, environment)); const metrics = setupMetrics(CHANNEL_NAMES, pgPool); + const redisConfig = Redis.configFromEnv(process.env); + const redisClient = Redis.createClient(redisConfig, logger); const server = http.createServer(); const wss = new WebSocketServer({ noServer: true }); @@ -386,9 +199,7 @@ const startServer = async () => { */ const subs = {}; - const redisConfig = redisConfigFromEnv(process.env); - const redisSubscribeClient = await createRedisClient(redisConfig); - const redisClient = await createRedisClient(redisConfig); + const redisSubscribeClient = Redis.createClient(redisConfig, logger); const { redisPrefix } = redisConfig; // When checking metrics in the browser, the favicon is requested this diff --git a/streaming/redis.js b/streaming/redis.js new file mode 100644 index 0000000000..208d6ae078 --- /dev/null +++ b/streaming/redis.js @@ -0,0 +1,65 @@ +import { Redis } from 'ioredis'; + +import { parseIntFromEnvValue } from './utils.js'; + +/** + * @typedef RedisConfiguration + * @property {import('ioredis').RedisOptions} redisParams + * @property {string} redisPrefix + * @property {string|undefined} redisUrl + */ + +/** + * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from + * @returns {RedisConfiguration} configuration for the Redis connection + */ +export function configFromEnv(env) { + // ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*, + // which means we can't use it. But this is something that should be looked into. + const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : ''; + + let redisPort = parseIntFromEnvValue(env.REDIS_PORT, 6379, 'REDIS_PORT'); + let redisDatabase = parseIntFromEnvValue(env.REDIS_DB, 0, 'REDIS_DB'); + + /** @type {import('ioredis').RedisOptions} */ + const redisParams = { + host: env.REDIS_HOST || '127.0.0.1', + port: redisPort, + // Force support for both IPv6 and IPv4, by default ioredis sets this to 4, + // only allowing IPv4 connections: + // https://github.com/redis/ioredis/issues/1576 + family: 0, + db: redisDatabase, + password: env.REDIS_PASSWORD || undefined, + }; + + // redisParams.path takes precedence over host and port. + if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) { + redisParams.path = env.REDIS_URL.slice(7); + } + + return { + redisParams, + redisPrefix, + redisUrl: typeof env.REDIS_URL === 'string' ? env.REDIS_URL : undefined, + }; +} + +/** + * @param {RedisConfiguration} config + * @param {import('pino').Logger} logger + * @returns {Redis} + */ +export function createClient({ redisParams, redisUrl }, logger) { + let client; + + if (typeof redisUrl === 'string') { + client = new Redis(redisUrl, redisParams); + } else { + client = new Redis(redisParams); + } + + client.on('error', (err) => logger.error({ err }, 'Redis Client Error!')); + + return client; +} diff --git a/streaming/utils.js b/streaming/utils.js index 4610bf660d..47c63dd4ca 100644 --- a/streaming/utils.js +++ b/streaming/utils.js @@ -59,3 +59,23 @@ export function firstParam(arrayOrString) { return arrayOrString; } } + +/** + * Takes an environment variable that should be an integer, attempts to parse + * it falling back to a default if not set, and handles errors parsing. + * @param {string|undefined} value + * @param {number} defaultValue + * @param {string} variableName + * @returns {number} + */ +export function parseIntFromEnvValue(value, defaultValue, variableName) { + if (typeof value === 'string' && value.length > 0) { + const parsedValue = parseInt(value, 10); + if (isNaN(parsedValue)) { + throw new Error(`Invalid ${variableName} environment variable: ${value}`); + } + return parsedValue; + } else { + return defaultValue; + } +} From 48f4e5444d1676414e7cf8e1344fab68fc61644b Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 05:44:16 -0400 Subject: [PATCH 09/18] Convert `media_proxy` controller spec to request spec (#31600) --- .../media_proxy_controller_spec.rb | 42 ------------ spec/requests/media_proxy_spec.rb | 67 +++++++++++++++++++ 2 files changed, 67 insertions(+), 42 deletions(-) delete mode 100644 spec/controllers/media_proxy_controller_spec.rb create mode 100644 spec/requests/media_proxy_spec.rb diff --git a/spec/controllers/media_proxy_controller_spec.rb b/spec/controllers/media_proxy_controller_spec.rb deleted file mode 100644 index 32510cf43d..0000000000 --- a/spec/controllers/media_proxy_controller_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe MediaProxyController do - render_views - - before do - stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) - end - - describe '#show' do - it 'redirects when attached to a status' do - status = Fabricate(:status) - media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') - get :show, params: { id: media_attachment.id } - - expect(response).to have_http_status(302) - end - - it 'responds with missing when there is not an attached status' do - media_attachment = Fabricate(:media_attachment, status: nil, remote_url: 'http://example.com/attachment.png') - get :show, params: { id: media_attachment.id } - - expect(response).to have_http_status(404) - end - - it 'raises when id cant be found' do - get :show, params: { id: 'missing' } - - expect(response).to have_http_status(404) - end - - it 'raises when not permitted to view' do - status = Fabricate(:status, visibility: :direct) - media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') - get :show, params: { id: media_attachment.id } - - expect(response).to have_http_status(404) - end - end -end diff --git a/spec/requests/media_proxy_spec.rb b/spec/requests/media_proxy_spec.rb new file mode 100644 index 0000000000..0524105d90 --- /dev/null +++ b/spec/requests/media_proxy_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'Media Proxy' do + describe 'GET /media_proxy/:id' do + before do + integration_session.https! # TODO: Move to global rails_helper for all request specs? + host! Rails.configuration.x.local_domain # TODO: Move to global rails_helper for all request specs? + + stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) + end + + context 'when attached to a status' do + let(:status) { Fabricate(:status) } + let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') } + + it 'redirects to correct original url' do + get "/media_proxy/#{media_attachment.id}" + + expect(response) + .to have_http_status(302) + .and redirect_to media_attachment.file.url(:original) + end + + it 'redirects to small style url' do + get "/media_proxy/#{media_attachment.id}/small" + + expect(response) + .to have_http_status(302) + .and redirect_to media_attachment.file.url(:small) + end + end + + context 'when there is not an attached status' do + let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') } + + it 'responds with missing' do + get "/media_proxy/#{media_attachment.id}" + + expect(response) + .to have_http_status(404) + end + end + + context 'when id cannot be found' do + it 'responds with missing' do + get '/media_proxy/missing' + + expect(response) + .to have_http_status(404) + end + end + + context 'when not permitted to view' do + let(:status) { Fabricate(:status, visibility: :direct) } + let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') } + + it 'responds with missing' do + get "/media_proxy/#{media_attachment.id}" + + expect(response) + .to have_http_status(404) + end + end + end +end From c513fdb9c5f69a7ae9d5efd89aa84778787096a7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 11:51:29 +0200 Subject: [PATCH 10/18] Update dependency pundit to v2.4.0 (#31598) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 40d7cea3d6..d14fc0168f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -608,7 +608,7 @@ GEM public_suffix (6.0.1) puma (6.4.2) nio4r (~> 2.0) - pundit (2.3.2) + pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) From da42e9d44618a09aca57b5c229e5f0ebc24e66fa Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Tue, 27 Aug 2024 14:51:34 +0200 Subject: [PATCH 11/18] Fix typo in Compose file (#31612) --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 05fd9e1887..0b3f24631c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -88,7 +88,7 @@ services: - internal_network healthcheck: # prettier-ignore - test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1'"] + test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1"] ports: - '127.0.0.1:4000:4000' depends_on: From c73868cd78592780cb9e0be6985fe2f34b7c91cd Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 27 Aug 2024 16:55:51 +0200 Subject: [PATCH 12/18] Add ability for admins to force grouped notifications in web UI (#31610) --- app/javascript/mastodon/actions/markers.ts | 8 ++------ .../actions/notifications_migration.tsx | 10 +++------- app/javascript/mastodon/actions/streaming.js | 8 +++++--- .../components/column_settings.jsx | 19 +++++++++++-------- .../features/notifications_wrapper.jsx | 3 ++- .../ui/components/navigation_panel.jsx | 3 ++- app/javascript/mastodon/initial_state.js | 2 ++ app/javascript/mastodon/selectors/settings.ts | 5 +++++ app/serializers/initial_state_serializer.rb | 1 + 9 files changed, 33 insertions(+), 26 deletions(-) diff --git a/app/javascript/mastodon/actions/markers.ts b/app/javascript/mastodon/actions/markers.ts index 77d91d9b9c..521859f6c2 100644 --- a/app/javascript/mastodon/actions/markers.ts +++ b/app/javascript/mastodon/actions/markers.ts @@ -2,6 +2,7 @@ import { debounce } from 'lodash'; import type { MarkerJSON } from 'mastodon/api_types/markers'; import { getAccessToken } from 'mastodon/initial_state'; +import { selectUseGroupedNotifications } from 'mastodon/selectors/settings'; import type { AppDispatch, RootState } from 'mastodon/store'; import { createAppAsyncThunk } from 'mastodon/store/typed_functions'; @@ -75,13 +76,8 @@ interface MarkerParam { } function getLastNotificationId(state: RootState): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const enableBeta = state.settings.getIn( - ['notifications', 'groupingBeta'], - false, - ) as boolean; // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return enableBeta + return selectUseGroupedNotifications(state) ? state.notificationGroups.lastReadId : // @ts-expect-error state.notifications is not yet typed // eslint-disable-next-line @typescript-eslint/no-unsafe-call diff --git a/app/javascript/mastodon/actions/notifications_migration.tsx b/app/javascript/mastodon/actions/notifications_migration.tsx index c245dc7565..0d4da765ec 100644 --- a/app/javascript/mastodon/actions/notifications_migration.tsx +++ b/app/javascript/mastodon/actions/notifications_migration.tsx @@ -1,3 +1,4 @@ +import { selectUseGroupedNotifications } from 'mastodon/selectors/settings'; import { createAppAsyncThunk } from 'mastodon/store'; import { fetchNotifications } from './notification_groups'; @@ -6,13 +7,8 @@ import { expandNotifications } from './notifications'; export const initializeNotifications = createAppAsyncThunk( 'notifications/initialize', (_, { dispatch, getState }) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const enableBeta = getState().settings.getIn( - ['notifications', 'groupingBeta'], - false, - ) as boolean; - - if (enableBeta) void dispatch(fetchNotifications()); + if (selectUseGroupedNotifications(getState())) + void dispatch(fetchNotifications()); else void dispatch(expandNotifications({})); }, ); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 04f5e6b88c..bfdd894b81 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -1,5 +1,7 @@ // @ts-check +import { selectUseGroupedNotifications } from 'mastodon/selectors/settings'; + import { getLocale } from '../locales'; import { connectStream } from '../stream'; @@ -103,7 +105,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti const notificationJSON = JSON.parse(data.payload); dispatch(updateNotifications(notificationJSON, messages, locale)); // TODO: remove this once the groups feature replaces the previous one - if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { + if(selectUseGroupedNotifications(getState())) { dispatch(processNewNotificationForGroups(notificationJSON)); } break; @@ -112,7 +114,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti const state = getState(); if (state.notifications.top || !state.notifications.mounted) dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); - if(state.settings.getIn(['notifications', 'groupingBeta'], false)) { + if (selectUseGroupedNotifications(state)) { dispatch(refreshStaleNotificationGroups()); } break; @@ -145,7 +147,7 @@ async function refreshHomeTimelineAndNotification(dispatch, getState) { await dispatch(expandHomeTimeline({ maxId: undefined })); // TODO: remove this once the groups feature replaces the previous one - if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { + if(selectUseGroupedNotifications(getState())) { // TODO: polling for merged notifications try { await dispatch(pollRecentGroupNotifications()); diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.jsx b/app/javascript/mastodon/features/notifications/components/column_settings.jsx index 359f0fb126..ed2f4ada3a 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.jsx +++ b/app/javascript/mastodon/features/notifications/components/column_settings.jsx @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; +import { forceGroupedNotifications } from 'mastodon/initial_state'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions'; import ClearColumnButton from './clear_column_button'; @@ -67,15 +68,17 @@ class ColumnSettings extends PureComponent { -
-

- -

+ {!forceGroupedNotifications && ( +
+

+ +

-
- -
-
+
+ +
+
+ )}

diff --git a/app/javascript/mastodon/features/notifications_wrapper.jsx b/app/javascript/mastodon/features/notifications_wrapper.jsx index 057ed1b395..4b3efeb54e 100644 --- a/app/javascript/mastodon/features/notifications_wrapper.jsx +++ b/app/javascript/mastodon/features/notifications_wrapper.jsx @@ -1,9 +1,10 @@ import Notifications from 'mastodon/features/notifications'; import Notifications_v2 from 'mastodon/features/notifications_v2'; +import { selectUseGroupedNotifications } from 'mastodon/selectors/settings'; import { useAppSelector } from 'mastodon/store'; export const NotificationsWrapper = (props) => { - const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); + const optedInGroupedNotifications = useAppSelector(selectUseGroupedNotifications); return ( optedInGroupedNotifications ? : diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx index d30ea0ac5d..407276d126 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx @@ -37,6 +37,7 @@ import { timelinePreview, trendsEnabled } from 'mastodon/initial_state'; import { transientSingleColumn } from 'mastodon/is_mobile'; import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications'; +import { selectUseGroupedNotifications } from 'mastodon/selectors/settings'; import ColumnLink from './column_link'; import DisabledAccountBanner from './disabled_account_banner'; @@ -64,7 +65,7 @@ const messages = defineMessages({ }); const NotificationsLink = () => { - const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); + const optedInGroupedNotifications = useSelector(selectUseGroupedNotifications); const count = useSelector(state => state.getIn(['notifications', 'unread'])); const intl = useIntl(); diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 60b35cb31a..cf33b12dd9 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -43,6 +43,7 @@ * @property {boolean=} use_pending_items * @property {string} version * @property {string} sso_redirect + * @property {boolean} force_grouped_notifications */ /** @@ -118,6 +119,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending; // @ts-expect-error export const statusPageUrl = getMeta('status_page_url'); export const sso_redirect = getMeta('sso_redirect'); +export const forceGroupedNotifications = getMeta('force_grouped_notifications'); /** * @returns {string | undefined} diff --git a/app/javascript/mastodon/selectors/settings.ts b/app/javascript/mastodon/selectors/settings.ts index 93276c6692..22e7c13b93 100644 --- a/app/javascript/mastodon/selectors/settings.ts +++ b/app/javascript/mastodon/selectors/settings.ts @@ -1,3 +1,4 @@ +import { forceGroupedNotifications } from 'mastodon/initial_state'; import type { RootState } from 'mastodon/store'; /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ @@ -25,6 +26,10 @@ export const selectSettingsNotificationsQuickFilterAdvanced = ( ) => state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean; +export const selectUseGroupedNotifications = (state: RootState) => + forceGroupedNotifications || + (state.settings.getIn(['notifications', 'groupingBeta']) as boolean); + export const selectSettingsNotificationsShowUnread = (state: RootState) => state.settings.getIn(['notifications', 'showUnread']) as boolean; diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 13f332c95c..25a352806f 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -109,6 +109,7 @@ class InitialStateSerializer < ActiveModel::Serializer trends_as_landing_page: Setting.trends_as_landing_page, trends_enabled: Setting.trends, version: instance_presenter.version, + force_grouped_notifications: ENV['FORCE_GROUPED_NOTIFICATIONS'] == 'true', } end From 3959f36d1913e1baa163dc86f6d0776d02b37ca2 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 10:59:56 -0400 Subject: [PATCH 13/18] Add checks about response body content to admin/dash spec (#30716) --- .../admin/dashboard_controller_spec.rb | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb index 25300fdd90..3e29ce1278 100644 --- a/spec/controllers/admin/dashboard_controller_spec.rb +++ b/spec/controllers/admin/dashboard_controller_spec.rb @@ -6,19 +6,30 @@ describe Admin::DashboardController do render_views describe 'GET #index' do + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) } + before do - allow(Admin::SystemCheck).to receive(:perform).and_return([ - Admin::SystemCheck::Message.new(:database_schema_check), - Admin::SystemCheck::Message.new(:rules_check, nil, admin_rules_path), - Admin::SystemCheck::Message.new(:sidekiq_process_check, 'foo, bar'), - ]) - sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')) + stub_system_checks + Fabricate :software_update + sign_in(user) end - it 'returns 200' do + it 'returns http success and body with system check messages' do get :index - expect(response).to have_http_status(200) + expect(response) + .to have_http_status(200) + .and have_attributes( + body: include(I18n.t('admin.system_checks.software_version_patch_check.message_html')) + ) + end + + private + + def stub_system_checks + stub_const 'Admin::SystemCheck::ACTIVE_CHECKS', [ + Admin::SystemCheck::SoftwareVersionCheck, + ] end end end From 6eba057e644d427428a9540c9a8d4906dde41f64 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Tue, 27 Aug 2024 11:23:08 -0400 Subject: [PATCH 14/18] Cache rspec persistence file between CI runs (#31065) --- .github/workflows/test-ruby.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 859b23ba66..3da53c1ae8 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -150,6 +150,19 @@ jobs: bin/rails db:setup bin/flatware fan bin/rails db:test:prepare + - name: Cache RSpec persistence file + uses: actions/cache@v4 + with: + path: | + tmp/rspec/examples.txt + key: rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + restore-keys: | + rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }}-${{ matrix.ruby-version }} + rspec-persistence-${{ github.head_ref || github.ref_name }}-${{ github.sha }} + rspec-persistence-${{ github.head_ref || github.ref_name }} + rspec-persistence-main + rspec-persistence + - run: bin/flatware rspec -r ./spec/flatware_helper.rb - name: Upload coverage reports to Codecov From 04f0468016b450ace8e0ce707b4c21aa18b51262 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Tue, 27 Aug 2024 18:05:19 +0200 Subject: [PATCH 15/18] Fix streaming image with Docker Compose (#31615) --- docker-compose.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0b3f24631c..8053b436ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,8 @@ services: # - '127.0.0.1:9200:9200' web: - build: . + # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes + # build: . image: ghcr.io/mastodon/mastodon:v4.3.0-beta.1 restart: always env_file: .env.production @@ -78,7 +79,10 @@ services: - ./public/system:/mastodon/public/system streaming: - build: . + # You can uncomment the following lines if you want to not use the prebuilt image, for example if you have local code changes + # build: + # dockerfile: ./streaming/Dockerfile + # context: . image: ghcr.io/mastodon/mastodon-streaming:v4.3.0-beta.1 restart: always env_file: .env.production From d3629d191f1cb1b47b872caed1ea303240ec26bd Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Mon, 26 Aug 2024 18:42:46 +0200 Subject: [PATCH 16/18] [Glitch] Add quick links to Administration and Moderation Reports from Web UI Port d820c0883d5557f011b4de8fafa1ff9f68b0d5de to glitch-soc Signed-off-by: Claire --- .../glitch/features/getting_started/index.jsx | 13 +++++++++---- .../ui/components/navigation_panel.jsx | 13 ++++++++++--- app/javascript/flavours/glitch/permissions.ts | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/javascript/flavours/glitch/features/getting_started/index.jsx b/app/javascript/flavours/glitch/features/getting_started/index.jsx index 2d13d3d584..af6dc313ad 100644 --- a/app/javascript/flavours/glitch/features/getting_started/index.jsx +++ b/app/javascript/flavours/glitch/features/getting_started/index.jsx @@ -12,11 +12,12 @@ import { connect } from 'react-redux'; import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react'; import PeopleIcon from '@/material-icons/400-24px/group.svg?react'; import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import MailIcon from '@/material-icons/400-24px/mail.svg?react'; -import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react'; +import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react'; import MenuIcon from '@/material-icons/400-24px/menu.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; @@ -29,9 +30,9 @@ import { openModal } from 'flavours/glitch/actions/modal'; import Column from 'flavours/glitch/features/ui/components/column'; import LinkFooter from 'flavours/glitch/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; +import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions'; import { preferencesLink } from 'flavours/glitch/utils/backend_links'; - import { me, showTrends } from '../../initial_state'; import { NavigationBar } from '../compose/components/navigation_bar'; import ColumnLink from '../ui/components/column_link'; @@ -51,6 +52,8 @@ const messages = defineMessages({ direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' }, + moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' }, settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, @@ -131,7 +134,7 @@ class GettingStarted extends ImmutablePureComponent { render () { const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications, lists, openSettings } = this.props; - const { signedIn } = this.props.identity; + const { signedIn, permissions } = this.props.identity; const navItems = []; let listItems = []; @@ -196,7 +199,9 @@ class GettingStarted extends ImmutablePureComponent { {listItems} { preferencesLink !== undefined && } - + + {canManageReports(permissions) && } + {canViewAdminDashboard(permissions) && } )}

diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx index 6153b3ceaa..33c2db8278 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx @@ -10,13 +10,14 @@ import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?re import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react'; import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react'; import ExploreIcon from '@/material-icons/400-24px/explore.svg?react'; +import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react'; import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react'; import HomeIcon from '@/material-icons/400-24px/home.svg?react'; import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import MailActiveIcon from '@/material-icons/400-24px/mail-fill.svg?react'; import MailIcon from '@/material-icons/400-24px/mail.svg?react'; -import ManufacturingIcon from '@/material-icons/400-24px/manufacturing.svg?react'; +import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; @@ -33,6 +34,7 @@ import { NavigationPortal } from 'flavours/glitch/components/navigation_portal'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state'; import { transientSingleColumn } from 'flavours/glitch/is_mobile'; +import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions'; import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; import { preferencesLink } from 'flavours/glitch/utils/backend_links'; @@ -51,6 +53,8 @@ const messages = defineMessages({ bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' }, + moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' }, followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' }, about: { id: 'navigation_bar.about', defaultMessage: 'About' }, search: { id: 'navigation_bar.search', defaultMessage: 'Search' }, @@ -116,7 +120,7 @@ class NavigationPanel extends Component { render () { const { intl, onOpenSettings } = this.props; - const { signedIn, disabledAccountId } = this.props.identity; + const { signedIn, disabledAccountId, permissions } = this.props.identity; let banner = undefined; @@ -174,7 +178,10 @@ class NavigationPanel extends Component {
{!!preferencesLink && } - + + + {canManageReports(permissions) && } + {canViewAdminDashboard(permissions) && } )} diff --git a/app/javascript/flavours/glitch/permissions.ts b/app/javascript/flavours/glitch/permissions.ts index b583535c00..8f015610ea 100644 --- a/app/javascript/flavours/glitch/permissions.ts +++ b/app/javascript/flavours/glitch/permissions.ts @@ -1,4 +1,23 @@ export const PERMISSION_INVITE_USERS = 0x0000000000010000; export const PERMISSION_MANAGE_USERS = 0x0000000000000400; export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020; + export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010; +export const PERMISSION_VIEW_DASHBOARD = 0x0000000000000008; + +// These helpers don't quite align with the names/categories in UserRole, +// but are likely "good enough" for the use cases at present. +// +// See: https://docs.joinmastodon.org/entities/Role/#permission-flags + +export function canViewAdminDashboard(permissions: number) { + return ( + (permissions & PERMISSION_VIEW_DASHBOARD) === PERMISSION_VIEW_DASHBOARD + ); +} + +export function canManageReports(permissions: number) { + return ( + (permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS + ); +} From e15fad27bceaa3252d8778c1695526b9b8e127d2 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 26 Aug 2024 19:12:17 +0200 Subject: [PATCH 17/18] [Glitch] Change design of boost modal in web UI Port 29b9642b315a30ca5d3dd9375fa85ab8fe74ad52 to glitch-soc Signed-off-by: Claire --- .../features/ui/components/boost_modal.tsx | 178 +++++++----------- .../flavours/glitch/styles/components.scss | 60 ++++++ 2 files changed, 132 insertions(+), 106 deletions(-) diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx index 452a022a7d..38c7894c24 100644 --- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx @@ -1,28 +1,17 @@ -import type { MouseEventHandler } from 'react'; import { useCallback, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; -import { useHistory } from 'react-router'; - -import type Immutable from 'immutable'; import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import AttachmentList from 'flavours/glitch/components/attachment_list'; +import { Button } from 'flavours/glitch/components/button'; import { Icon } from 'flavours/glitch/components/icon'; -import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon'; import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown'; -import type { Account } from 'flavours/glitch/models/account'; +import { EmbeddedStatus } from 'flavours/glitch/features/notifications_v2/components/embedded_status'; import type { Status, StatusVisibility } from 'flavours/glitch/models/status'; import { useAppSelector } from 'flavours/glitch/store'; -import { Avatar } from '../../../components/avatar'; -import { Button } from '../../../components/button'; -import { DisplayName } from '../../../components/display_name'; -import { RelativeTimestamp } from '../../../components/relative_timestamp'; -import StatusContent from '../../../components/status_content'; - const messages = defineMessages({ cancel_reblog: { id: 'status.cancel_reblog_private', @@ -35,21 +24,19 @@ export const BoostModal: React.FC<{ status: Status; onClose: () => void; onReblog: (status: Status, privacy: StatusVisibility) => void; - missingMediaDescription?: boolean; -}> = ({ status, onReblog, onClose, missingMediaDescription }) => { +}> = ({ status, onReblog, onClose }) => { const intl = useIntl(); - const history = useHistory(); - const default_privacy = useAppSelector( + const defaultPrivacy = useAppSelector( // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access (state) => state.compose.get('default_privacy') as StatusVisibility, ); - const account = status.get('account') as Account; + const statusId = status.get('id') as string; const statusVisibility = status.get('visibility') as StatusVisibility; const [privacy, setPrivacy] = useState( - statusVisibility === 'private' ? 'private' : default_privacy, + statusVisibility === 'private' ? 'private' : defaultPrivacy, ); const onPrivacyChange = useCallback((value: StatusVisibility) => { @@ -61,20 +48,9 @@ export const BoostModal: React.FC<{ onClose(); }, [onClose, onReblog, status, privacy]); - const handleAccountClick = useCallback( - (e) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - onClose(); - history.push(`/@${account.acct}`); - } - }, - [history, onClose, account], - ); - - const buttonText = status.get('reblogged') - ? messages.cancel_reblog - : messages.reblog; + const handleCancel = useCallback(() => { + onClose(); + }, [onClose]); const findContainer = useCallback( () => document.getElementsByClassName('modal-root__container')[0], @@ -82,88 +58,78 @@ export const BoostModal: React.FC<{ ); return ( -
-
-
-
- - - - - - - - -
- -
- - -
+
+
+
+
+
- {/* @ts-expect-error Expected until StatusContent is typed */} - +
+

+ {status.get('reblogged') ? ( + + ) : ( + + )} +

+
+ + Shift+ + + ), + }} + /> +
+
+
- {(status.get('media_attachments') as Immutable.List).size > - 0 && ( - - )} +
+
-
-
- {missingMediaDescription ? ( - - ) : ( - - Shift + - - ), - }} +
+
+ {!status.get('reblogged') && ( + )} -
- {statusVisibility !== 'private' && !status.get('reblogged') && ( - + + + +
); diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index a81a8bc22e..662607a7ca 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -6590,6 +6590,48 @@ a.status-card { } } + &__status { + border: 1px solid var(--modal-border-color); + border-radius: 8px; + padding: 8px; + cursor: pointer; + + &__account { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + color: $dark-text-color; + + bdi { + color: inherit; + } + } + + &__content { + display: -webkit-box; + font-size: 15px; + line-height: 22px; + color: $dark-text-color; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + max-height: 4 * 22px; + overflow: hidden; + + p, + a { + color: inherit; + } + } + + .reply-indicator__attachments { + margin-top: 0; + font-size: 15px; + line-height: 22px; + color: $dark-text-color; + } + } + &__bullet-points { display: flex; flex-direction: column; @@ -6667,6 +6709,12 @@ a.status-card { gap: 8px; justify-content: flex-end; + &__hint { + font-size: 14px; + line-height: 20px; + color: $dark-text-color; + } + .link-button { padding: 10px 12px; font-weight: 600; @@ -6674,6 +6722,18 @@ a.status-card { } } +.hotkey-combination { + display: inline-flex; + align-items: center; + gap: 4px; + + kbd { + padding: 3px 5px; + border: 1px solid var(--background-border-color); + border-radius: 4px; + } +} + .doodle-modal, .boost-modal, .report-modal, From 435ff8e550fbcbf83e7eca1bccafe2817252e666 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 27 Aug 2024 16:55:51 +0200 Subject: [PATCH 18/18] [Glitch] Add ability for admins to force grouped notifications in web UI Port c73868cd78592780cb9e0be6985fe2f34b7c91cd to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/markers.ts | 8 ++--- .../actions/notifications_migration.tsx | 10 ++---- .../flavours/glitch/actions/streaming.js | 8 +++-- .../components/column_settings.jsx | 19 ++++++----- .../glitch/features/notifications_wrapper.jsx | 3 +- .../features/ui/components/boost_modal.tsx | 32 ++++++++++++------- .../ui/components/navigation_panel.jsx | 3 +- .../flavours/glitch/initial_state.js | 2 ++ .../flavours/glitch/selectors/settings.ts | 5 +++ 9 files changed, 52 insertions(+), 38 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/markers.ts b/app/javascript/flavours/glitch/actions/markers.ts index 861eae41ec..1c1fd60dcf 100644 --- a/app/javascript/flavours/glitch/actions/markers.ts +++ b/app/javascript/flavours/glitch/actions/markers.ts @@ -2,6 +2,7 @@ import { debounce } from 'lodash'; import type { MarkerJSON } from 'flavours/glitch/api_types/markers'; import { getAccessToken } from 'flavours/glitch/initial_state'; +import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings'; import type { AppDispatch, RootState } from 'flavours/glitch/store'; import { createAppAsyncThunk } from 'flavours/glitch/store/typed_functions'; @@ -75,13 +76,8 @@ interface MarkerParam { } function getLastNotificationId(state: RootState): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const enableBeta = state.settings.getIn( - ['notifications', 'groupingBeta'], - false, - ) as boolean; // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return enableBeta + return selectUseGroupedNotifications(state) ? state.notificationGroups.lastReadId : // @ts-expect-error state.notifications is not yet typed // eslint-disable-next-line @typescript-eslint/no-unsafe-call diff --git a/app/javascript/flavours/glitch/actions/notifications_migration.tsx b/app/javascript/flavours/glitch/actions/notifications_migration.tsx index ac7727ecd1..6789dbf38c 100644 --- a/app/javascript/flavours/glitch/actions/notifications_migration.tsx +++ b/app/javascript/flavours/glitch/actions/notifications_migration.tsx @@ -1,3 +1,4 @@ +import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings'; import { createAppAsyncThunk } from 'flavours/glitch/store'; import { fetchNotifications } from './notification_groups'; @@ -6,13 +7,8 @@ import { expandNotifications } from './notifications'; export const initializeNotifications = createAppAsyncThunk( 'notifications/initialize', (_, { dispatch, getState }) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const enableBeta = getState().settings.getIn( - ['notifications', 'groupingBeta'], - false, - ) as boolean; - - if (enableBeta) void dispatch(fetchNotifications()); + if (selectUseGroupedNotifications(getState())) + void dispatch(fetchNotifications()); else void dispatch(expandNotifications({})); }, ); diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 49636d6212..3584a25625 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -1,5 +1,7 @@ // @ts-check +import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings'; + import { getLocale } from '../locales'; import { connectStream } from '../stream'; @@ -103,7 +105,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti const notificationJSON = JSON.parse(data.payload); dispatch(updateNotifications(notificationJSON, messages, locale)); // TODO: remove this once the groups feature replaces the previous one - if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { + if(selectUseGroupedNotifications(getState())) { dispatch(processNewNotificationForGroups(notificationJSON)); } break; @@ -112,7 +114,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti const state = getState(); if (state.notifications.top || !state.notifications.mounted) dispatch(expandNotifications({ forceLoad: true, maxId: undefined })); - if(state.settings.getIn(['notifications', 'groupingBeta'], false)) { + if (selectUseGroupedNotifications(state)) { dispatch(refreshStaleNotificationGroups()); } break; @@ -145,7 +147,7 @@ async function refreshHomeTimelineAndNotification(dispatch, getState) { await dispatch(expandHomeTimeline({ maxId: undefined })); // TODO: remove this once the groups feature replaces the previous one - if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { + if(selectUseGroupedNotifications(getState())) { // TODO: polling for merged notifications try { await dispatch(pollRecentGroupNotifications()); diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx b/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx index f98d09b500..3bd040eb51 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.jsx @@ -6,6 +6,7 @@ import { FormattedMessage } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; +import { forceGroupedNotifications } from 'flavours/glitch/initial_state'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions'; import ClearColumnButton from './clear_column_button'; @@ -78,15 +79,17 @@ class ColumnSettings extends PureComponent {
-
-

- -

+ {!forceGroupedNotifications && ( +
+

+ +

-
- -
-
+
+ +
+
+ )}

diff --git a/app/javascript/flavours/glitch/features/notifications_wrapper.jsx b/app/javascript/flavours/glitch/features/notifications_wrapper.jsx index 15ab3367cc..ab3eff889c 100644 --- a/app/javascript/flavours/glitch/features/notifications_wrapper.jsx +++ b/app/javascript/flavours/glitch/features/notifications_wrapper.jsx @@ -1,9 +1,10 @@ import Notifications from 'flavours/glitch/features/notifications'; import Notifications_v2 from 'flavours/glitch/features/notifications_v2'; +import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings'; import { useAppSelector } from 'flavours/glitch/store'; export const NotificationsWrapper = (props) => { - const optedInGroupedNotifications = useAppSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); + const optedInGroupedNotifications = useAppSelector(selectUseGroupedNotifications); return ( optedInGroupedNotifications ? : diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx index 38c7894c24..9d1cf8545f 100644 --- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.tsx @@ -24,7 +24,8 @@ export const BoostModal: React.FC<{ status: Status; onClose: () => void; onReblog: (status: Status, privacy: StatusVisibility) => void; -}> = ({ status, onReblog, onClose }) => { + missingMediaDescription?: boolean; +}> = ({ status, onReblog, onClose, missingMediaDescription }) => { const intl = useIntl(); const defaultPrivacy = useAppSelector( @@ -80,17 +81,24 @@ export const BoostModal: React.FC<{ )}

- - Shift+ - - ), - }} - /> + {missingMediaDescription ? ( + + ) : ( + + Shift+ + + ), + }} + /> + )}
diff --git a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx index 33c2db8278..905387caea 100644 --- a/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.jsx @@ -36,6 +36,7 @@ import { timelinePreview, trendsEnabled } from 'flavours/glitch/initial_state'; import { transientSingleColumn } from 'flavours/glitch/is_mobile'; import { canManageReports, canViewAdminDashboard } from 'flavours/glitch/permissions'; import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications'; +import { selectUseGroupedNotifications } from 'flavours/glitch/selectors/settings'; import { preferencesLink } from 'flavours/glitch/utils/backend_links'; import ColumnLink from './column_link'; @@ -65,7 +66,7 @@ const messages = defineMessages({ }); const NotificationsLink = () => { - const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false)); + const optedInGroupedNotifications = useSelector(selectUseGroupedNotifications); const count = useSelector(state => state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0); const intl = useIntl(); diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js index c5628f51ce..624f8bb101 100644 --- a/app/javascript/flavours/glitch/initial_state.js +++ b/app/javascript/flavours/glitch/initial_state.js @@ -46,6 +46,7 @@ * @property {boolean=} use_pending_items * @property {string} version * @property {string} sso_redirect + * @property {boolean} force_grouped_notifications * @property {string} status_page_url * @property {boolean} system_emoji_font * @property {string} default_content_type @@ -137,6 +138,7 @@ export const languages = initialState?.languages; export const criticalUpdatesPending = initialState?.critical_updates_pending; export const statusPageUrl = getMeta('status_page_url'); export const sso_redirect = getMeta('sso_redirect'); +export const forceGroupedNotifications = getMeta('force_grouped_notifications'); // Glitch-soc-specific settings export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4; diff --git a/app/javascript/flavours/glitch/selectors/settings.ts b/app/javascript/flavours/glitch/selectors/settings.ts index 9a1a2c990b..c9cfd6dde5 100644 --- a/app/javascript/flavours/glitch/selectors/settings.ts +++ b/app/javascript/flavours/glitch/selectors/settings.ts @@ -1,3 +1,4 @@ +import { forceGroupedNotifications } from 'flavours/glitch/initial_state'; import type { RootState } from 'flavours/glitch/store'; /* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ @@ -25,6 +26,10 @@ export const selectSettingsNotificationsQuickFilterAdvanced = ( ) => state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean; +export const selectUseGroupedNotifications = (state: RootState) => + forceGroupedNotifications || + (state.settings.getIn(['notifications', 'groupingBeta']) as boolean); + export const selectSettingsNotificationsShowUnread = (state: RootState) => state.settings.getIn(['notifications', 'showUnread']) as boolean;